Testing an OAuth integration
OAuth code has an awkward property. The paths that matter most are the ones a demonstration never takes. A token with the wrong audience, a callback carrying someone else's state, two workers refreshing the same connection in the same second: none of these happens when a developer connects a test account and sees photos appear.
Tests are how those paths get exercised before an attacker, or a busy December night, exercises them instead.
Tokens built to fail
The photo API's validator is the easiest place to start, because it behaves like a function: a token goes in and a decision comes out. The tests generate their own signing key, configure the validator to trust it as the issuer's key for the length of the test run, and sign tokens that differ from a valid one in exactly one way:
| Test token | What it proves |
|---|---|
| A valid token | The validator accepts what it should. Without this test, a validator that rejects everything passes all the others. |
exp well in the past | Expired tokens are refused, beyond the allowed leeway. |
aud naming another API | Tokens meant for somewhere else are refused. |
iss of the staging authorization server | Only the configured issuer is trusted, even with a valid signature. |
typ of JWT instead of at+jwt | Another kind of JWT is not accepted as an access token. |
alg of none and no signature | Unsigned tokens are refused. |
alg of HS256, using the public key as an HMAC secret | The algorithm comes from configuration, not from the token. |
Signed by a different key under the same kid | The signature is really checked. |
A kid the key set does not contain | An unknown key leads to one fetch of the key set and then a rejection. |
All values in these examples are fictional, and the test keys exist only inside the test run. A shortened sketch shows the shape. The validator receives a fixed clock, so the results do not depend on when the tests run:
const now = 1790845300; // 2026-10-01 09:01:40 UTC
const valid = {
iss: 'https://auth.photos.example', sub: 'user-2048',
aud: 'https://api.photos.example', client_id: 'photo-printer',
scope: 'photos.read', iat: 1790845200, exp: 1790845800, jti: 'demo-token-id-7',
};
test('accepts a valid token', async () => {
const token = await sign(valid, testKey);
expect((await validate(token, { now })).ok).toBe(true);
});
test('rejects an expired token', async () => {
const token = await sign({ ...valid, iat: 1790844000, exp: 1790844600 }, testKey); // 08:40 to 08:50
expect((await validate(token, { now })).ok).toBe(false);
});
test('rejects a token signed by another key with the same kid', async () => {
const token = await sign(valid, otherKey);
expect((await validate(token, { now })).ok).toBe(false);
});
The last case avoids a common trap. Changing one character of a valid token's signature looks like a simpler way to test a bad signature, but the final character of a Base64url string can include unused bits, so some edits decode to exactly the same signature bytes and the "tampered" token is still valid. Signing with a different key produces a signature that is certainly wrong.
Testing the client's checks
The printer's callback handler has its own list. Each test starts a real attempt through the transaction store, then delivers a callback that is wrong in one way:
- A
statethat matches no pending attempt is refused. - A
statefrom an attempt started in a different session is refused. - The same callback delivered twice is processed once.
- An unexpected
iss, or none when the metadata says the server sends it, is refused before the code is used. - An
access_deniedresponse goes through the same checks and then shows the "not connected" message. - Every authorization request carries a fresh
S256challenge, and two attempts never share a verifier.
The tests run against a fake token endpoint that records every request it receives, so a refused callback can be shown to have sent nothing. That matters as much as the message on screen: a callback handler that displays an error after exchanging the code has already done the damage.
Above these sit integration tests against a real authorization server. The printer cannot run a copy of the photo service, so it uses two kinds. In automated builds it starts a local authorization server, an open-source implementation in a container configured with test clients, and drives the full flow with a scripted browser. Before each release it runs the same flow against the photo service itself, using the photo-printer-test client and dedicated test accounts. The local server catches the printer's own mistakes quickly. The real one catches the differences between how the printer believes the photo service behaves and how it actually does.
A team that runs an authorization server tests the other side: a redirect URI that differs by a trailing slash is refused without a redirect, a code is refused the second time, and a wrong verifier produces invalid_grant.
Testing under concurrency
The refresh coordinator from Implementing a client exists to prevent a race, and races do not appear in tests that do one thing at a time. A concurrency test starts two calls for the same connection at the same moment, against a fake authorization server that rotates refresh tokens:
test('two callers share one refresh', async () => {
await tokenStore.replace('conn-1', tokensAboutToExpire);
const [first, second] = await Promise.all([
currentAccessToken('conn-1'),
currentAccessToken('conn-1'),
]);
expect(fakeAuthServer.refreshRequests).toBe(1);
expect(first).toBe(second);
});
Run against a coordinator without its lock, this test fails. The fake server sees two refreshes, the second presents a refresh token the first has already spent, and a server with reuse detection would revoke the whole family. That is precisely the failure the test exists to catch. When the coordinator runs on several servers, the test needs to exercise the shared lock, because a lock held in one process's memory protects only that process.
A second test makes the fake server accept a refresh and then drop the response. The printer should end up either with a working replacement or with the connection marked as needing attention, and never retrying in a loop. Operating and monitoring an integration looks at that retry decision.
Test clients, accounts, and suites
Tests need identities, and real ones are the wrong choice. The printer's tests use the photo-printer-test client, whose registration allows only the test site's redirect URIs, and photo accounts created for testing that contain nothing private. Their credentials live in the test secrets store. A test that reached for production by mistake would fail, because nothing in the test configuration works there.
For widely used profiles, published test suites exist. The OpenID Foundation's conformance suite tests OpenID Connect and FAPI implementations, as Interoperability and conformance testing described, and some authorization server products publish their results. A passing suite shows that an implementation handles the cases the suite contains. It says nothing about the printer's sessions, its token store, or the lock on its refresh coordinator, which is why the printer's own tests stay in place.