Implementing a relying party
All domains, identifiers, credentials, and tokens in these examples are fictional.
Over this series, the printer's Continue with your photo account button has collected a long list of duties. It creates a pending sign-in, checks the response, exchanges a code, validates an ID token claim by claim, asks the UserInfo endpoint about you, finds your account, and starts a session of its own. Later it refreshes access without mistaking that for your presence, asks for a recent sign-in before you change your delivery address, and ends sessions when you or the provider sign out.
Now the printer's developers have to build it. Most of that work is deciding what to write themselves and what to rely on, then connecting the pieces so that no check can be quietly skipped.
Choosing a library
Very little of the protocol should be the printer's own code. Parsing a JWT, choosing a key by kid, verifying an RS256 signature, caching a key set and refetching it when a new key appears: each is easy to get almost right, and an almost-right signature check is worse than none, because it looks finished. A maintained OpenID Connect library has already met the edge cases, and its maintainers fix the ones found later.
That library guards sign-in, so it deserves more scrutiny than most dependencies. Implementing a client, in the OAuth lessons on implementation and operations, covers what to look for in an OAuth library. A relying party adds its own list:
- It is certified for the relying party profiles the printer uses, such as the code flow with configuration from discovery. Conformance testing explains what that certification does and does not tell you.
- It validates ID tokens itself, with every check from Validation and trust on by default, and reports which check failed rather than a bare "invalid token".
- It reads provider metadata through discovery, fetches keys from the provider's
jwks_uri, and refetches, within a limit, when a token names a key it has not seen. - It accepts only the signing algorithms the printer configures, never
none, and never a key supplied inside the token. - It supports PKCE with
S256, theissauthorization response parameter, and several providers side by side. - It is actively maintained, publishes security fixes, and keeps tokens out of its own logs.
Some decisions no library can make for the printer. Which printer account a subject belongs to, whether a new customer needs to finish registering, what a printer session records and how long it lasts, and what you may do once signed in are all the printer's own. A library that offers to create or join accounts by matching email addresses is offering to make one of those decisions badly.
What the configuration holds
The library needs to be told what to trust. The printer keeps one entry per provider, and every value in it is a decision earlier lessons explained:
providers:
photos:
issuer: https://auth.photos.example # compared exactly; discovery is read from here
client_id: photo-printer
client_auth: client_secret_basic
client_secret: loaded from the secret store at startup, never written here
redirect_uri: https://printer.example/signin/callback
scope: openid profile email
id_token_signing_algorithms: [RS256]
mail:
issuer: https://accounts.mail.example
client_id: c2d7a915-4e8b-4f03-a6d2-7b19e5f80c4d
redirect_uri: https://printer.example/signin/mail/callback
# its own credential, algorithms, and scopes, agreed with the mail service
Endpoints and keys are missing on purpose. The library takes them from each provider's discovery document, after checking that the document's issuer equals the configured one, as Provider discovery described. A copied token endpoint or public key is a copy of something the provider may change, and the copy fails quietly the next time it does.
The client secret lives in a secret store, not in configuration files or source code. Each deployment also has its own registration, as Clients and registration recommended, so the printer's test site signs in with a test client and a leaked test credential is useless against real customers. Rarer choices belong in configuration too, such as the max_age the printer applies before an address change, where they can be reviewed in one place instead of rebuilt in each request.
The work in order
With a library and its configuration in place, the printer's own code mostly connects them, and that is where checks go missing: a handler that catches a validation error and carries on, or a session started before the UserInfo answer is checked. Writing the callback in the order the work happens makes those gaps easier to see. Each call in this sketch either returns a checked result or stops the attempt with a failure naming its stage and check:
attempt = session.pending_signins.claim(params.state) # this browser's attempts only, once
if attempt is None:
fail("callback", "no_pending_attempt")
provider = providers.get(attempt.issuer) # the provider this attempt went to
if params.iss != provider.issuer: # the photo service sends iss in every response
fail("callback", "issuer_mismatch")
if params.error:
return show_outcome(params.error) # a denial is an answer, not a crash
tokens = provider.exchange(params.code, attempt.verifier, attempt.redirect_uri)
claims = provider.validate_id_token(tokens.id_token, nonce=attempt.nonce,
max_age=attempt.max_age)
profile = provider.userinfo(tokens.access_token)
if profile.sub != claims.sub:
fail("userinfo", "subject_mismatch")
account = accounts.find(claims.iss, claims.sub) # never by email
if account is None:
return start_registration(claims, profile)
session.replace_with_new(account, issuer=claims.iss, subject=claims.sub,
auth_time=claims.auth_time, acr=claims.acr, sid=claims.sid,
id_token=tokens.id_token) # kept for a later logout hint
return redirect(attempt.return_page) # a local page checked when stored
The handler is one part of a longer list. Here is the order in which the printer's code meets each responsibility:
| Moment | What the printer does | Where the series covers it |
|---|---|---|
| You select Continue | Creates a pending attempt with state, nonce, PKCE verifier, expected issuer, and return page, tied to this browser, and sends the request. | Following a complete sign-in; State and nonce |
| The callback arrives | Claims the attempt once, compares iss, and treats errors as outcomes. | Authentication errors; Supporting several providers |
| The token exchange | Authenticates, sends the code and verifier, and treats a lost response as unknown, not failed. | Receiving the ID token; Errors and denied access |
| The ID token | Checks the signature with an allowed algorithm and a key from jwks_uri, then iss, aud and azp, exp and iat, nonce, and auth_time and acr when requested. | Validation and trust |
| UserInfo | Requires the same sub as the ID token. | The UserInfo endpoint |
| The account | Looks up the issuer and subject pair, and links accounts only after both sides authenticate. | Connecting a sign-in to an account; Linking accounts |
| The session | Issues a new session identifier and records the issuer and subject, auth_time, acr, sid, and the ID token for a later logout hint. | Establishing an application session |
| Afterward | Refreshes without extending the session, asks for a recent sign-in before sensitive changes, and ends its own session first at logout. | Offline access and refresh behavior; Session age and reauthentication; RP-initiated logout |
Two habits hold the handler together. The attempt is claimed before anything else happens, so a callback replayed from the browser's history finds nothing to finish. And every failure ends the attempt: no branch continues with claims that failed validation, and none falls back to UserInfo when the ID token is missing.
Leaving room to test
The printer's tests cannot ask the photo service for a token signed with the wrong key or carrying yesterday's nonce. They need seams: places where a test can replace something the code normally takes from the outside world.
- The clock. If validation reads the time from a clock the test can set, a test can move it to 09:10 and confirm that the token that expired at 09:05 is refused, then move it back and confirm that the same token is accepted.
- The key source. A test key set lets tests sign tokens with a key the printer trusts, with one it does not, and with a new
kidthat appears only after a refetch, asphotos-rs-2027-01will. - Random values. When state, nonce, and verifier come from one generator, a test can fix them and replay a recorded response against a known attempt.
- The provider. Because endpoints come from configuration and discovery, a test can point the printer at a small stand-in provider that times out, answers
invalid_grant, or returns UserInfo for a differentsub.
Failures should come back as values a test can inspect: the stage, such as the callback or ID token validation, and the check, such as aud or nonce. A test can then assert that a token issued to another client failed on its audience, not merely that something failed somewhere. The same values go into the printer's logs, where they make a failed sign-in explainable later.
These tests prove the printer's wiring: that the library's checks are switched on, that their results are acted on, and that the printer's own decisions about accounts and sessions are right. They share the blind spots of the people who wrote them, and Testing an OAuth integration covers the OAuth half of the same work. Everything they check, though, depends on the photo service having done its part.