Implementing a client
The printing company is rebuilding its photo connection. The first version grew one function at a time: a handler for the Connect button, another for the callback, a December calendar job with its own copy of the refresh logic, and a helper for calling the photo API. Each piece worked when it was written. Together they kept tokens in three places, refreshed them in two different ways, and read the photo service's token endpoint from a configuration file that nobody updated when the address changed.
Every client-side check in the earlier lessons has to live somewhere in real code. Where it lives decides whether it runs every time, including in the code someone adds next year.
Starting from a library
Much of a client's protocol work is precisely specified and easy to get slightly wrong. Generating a PKCE verifier and challenge, encoding an authorization URL, comparing the iss response parameter, form-encoding a client secret before HTTP Basic, signing a client assertion, and parsing an error response each have a correct answer, and each has caught out hand-written code. A well-maintained OAuth library has already met these problems, and its maintainers change it when security guidance changes.
Choosing one deserves some care. Look for active maintenance and a visible history of security fixes. Check that it supports what this integration needs: PKCE with S256, the iss response parameter, authorization server metadata, and the client authentication method in the printer's registration. Prefer a library whose defaults are already safe, so that PKCE is on unless someone deliberately turns it off and the implicit grant is not offered at all. For OpenID Connect and FAPI profiles, the OpenID Foundation certifies implementations that pass its conformance tests, and Interoperability and conformance testing described what such a result does and does not prove.
A library implements the protocol. It does not know what a printer account is, which session started an attempt, or what "connected" means to the printer's customers. The printing company therefore wraps the library in a small module of its own, and the rest of the application talks only to that module. Updating the library then means changing one module, and the team treats those updates like any other security fix.
Configuration from metadata
All domains, identifiers, and credentials in these examples are fictional. The printer's module starts from a handful of values:
issuer: https://auth.photos.example
client_id: photo-printer
redirect_uri: https://printer.example/oauth/callback
scope: photos.read
api_base: https://api.photos.example
Endpoint addresses are missing on purpose. The module reads authorization_endpoint, token_endpoint, and revocation_endpoint from the photo service's metadata document at https://auth.photos.example/.well-known/oauth-authorization-server, after checking its issuer against the configured value. It handles that document the way Validating metadata and issuer identity described: it refreshes it on a schedule, keeps the last validated copy when a fetch fails, and raises an alert, rather than quietly changing its behavior, if a capability it relies on such as S256 disappears. When the photo service moves its token endpoint, nobody at the printing company has to notice.
What stays in the module's own configuration is everything the printer decides for itself: which issuer it trusts, which client it is, where its callback lives, what access it asks for, and which API may receive its tokens. That division is worth keeping strict. A value the client should decide, such as its scope, never comes from a document the server publishes, and a value the server publishes, such as its token endpoint, is never copied into a file where it can go stale.
The parts of a client
Inside the module, the printer separates five jobs:
| Part | Its job |
|---|---|
| Transaction store | Creates the pending transaction when you select Connect, and lets the callback claim it once, from the same session. |
| Callback handler | Checks the response against its transaction and expected issuer, exchanges the code, and saves the result. |
| Token store | Keeps each connection's tokens on the backend, encrypted, keyed by printer account and issuer. |
| Refresh coordinator | The only code that refreshes. It allows one refresh per connection at a time and saves a replacement refresh token before anything uses the new access token. |
| API caller | The only code that attaches an access token to a request, and only to the configured photo API. |
A shortened sketch shows how the last two fit together. The oauth object stands for the wrapped library, and the lock can be a database row lock or a distributed lock, depending on how many servers run the code:
async function currentAccessToken(connectionId: string): Promise<string> {
return withConnectionLock(connectionId, async () => {
const stored = await tokenStore.load(connectionId);
if (!stored) throw new NeedsReconnection(connectionId);
if (stored.accessTokenExpiresAt > Date.now() + 60_000) {
return stored.accessToken; // good for at least a minute
}
const result = await oauth.refresh(stored.refreshToken);
await tokenStore.replace(connectionId, result); // save first, then use
return result.accessToken;
});
}
async function callPhotoApi(connectionId: string, path: string): Promise<Response> {
const url = new URL(path, config.apiBase);
if (url.origin !== new URL(config.apiBase).origin) {
throw new Error('Refusing to send a photo API token to another host');
}
const token = await currentAccessToken(connectionId);
return fetch(url, { headers: { Authorization: `Bearer ${token}` } });
}
The origin check writes a rule from Using access tokens into the one function that attaches tokens: a token goes only to the API it was issued for. It is needed because new URL treats a full address as replacing the base, so a path such as https://elsewhere.example/ would otherwise carry the token to another host. The photo picker and the December calendar job both call callPhotoApi, so both get the same refresh behavior and the same rule about where tokens may go. If two workers ask for the same connection at once, the second waits for the lock and then finds the fresh token the first one saved. When a refresh fails with invalid_grant, the coordinator marks the connection as needing reconnection, instead of letting each caller decide what that error means. A 401 from the API leads to one refresh and one retry, as Using access tokens described, and that logic belongs in the API caller for the same reason.
The callback handler follows the order the authorization code lessons taught in Correlating requests and responses and Errors and denied access: claim the transaction, compare the issuer, handle an error response, and only then exchange the code, sending it to the token endpoint taken from validated metadata. Keeping those steps in one handler makes their order visible and easy to test.
Environments and secrets
The printer's test website at https://test.printer.example runs the same code with a different configuration and its own registration at the photo service:
| Setting | Production | Test |
|---|---|---|
| Client ID | photo-printer | photo-printer-test |
| Redirect URI | https://printer.example/oauth/callback | https://test.printer.example/oauth/callback |
| Client credential | From the production secrets store | From the test secrets store |
| Photo accounts | Customers' accounts | Accounts created for testing |
The issuer is the same in both, because the test site connects to the photo service's real authorization server with test accounts. Everything that identifies the printer differs. If the test site accidentally loaded the production client ID, its first authorization request would fail at the photo service, because the test site's redirect URI is not registered for photo-printer. Separate registrations turn that configuration mistake into an error instead of a leak.
The client credential is the one value here that must stay private. The module reads it at run time from a secrets store, not from the repository or a file built into a container image. It never appears in logs, in error messages, or in process arguments, which other users of a machine can often read. Each environment has its own credential, so a developer with access to test credentials holds nothing that works in production. When the credential needs replacing, the overlap from Rotating client credentials applies, and a registration that uses private_key_jwt removes the shared secret altogether.
None of this is visible to the person connecting a photo account. They see a Connect button, a familiar approval screen, and their photos. The structure behind that experience is what keeps it working after the photo service moves an endpoint, the printer adds a second server, or an access token expires halfway through a December night.