Beta

Create a tenant

A new tenant starts with its own users, OAuth settings, audit history and logs. You are its first Tenant Admin.

BTL Admin

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 tokenWhat it proves
A valid tokenThe validator accepts what it should. Without this test, a validator that rejects everything passes all the others.
exp well in the pastExpired tokens are refused, beyond the allowed leeway.
aud naming another APITokens meant for somewhere else are refused.
iss of the staging authorization serverOnly the configured issuer is trusted, even with a valid signature.
typ of JWT instead of at+jwtAnother kind of JWT is not accepted as an access token.
alg of none and no signatureUnsigned tokens are refused.
alg of HS256, using the public key as an HMAC secretThe algorithm comes from configuration, not from the token.
Signed by a different key under the same kidThe signature is really checked.
A kid the key set does not containAn 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 state that matches no pending attempt is refused.
  • A state from 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_denied response goes through the same checks and then shows the "not connected" message.
  • Every authorization request carries a fresh S256 challenge, 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.

Try it in the Lab

PUT IT INTO PRACTICE

Check your understanding

Try these questions before moving on. If an answer isn't right, use the feedback and try again.

0 of 2 answered correctly

Enable JavaScript to answer these questions and save progress in this browser.

QUESTION 1 OF 2A validator's test suite contains only tokens that should be rejected, and every test passes. What is it missing?

QUESTION 2 OF 2How should a test check that the printer never refreshes one connection twice at the same time?

We value your privacy

We use cookies and similar technologies to enhance your browsing experience, and analytics to understand our traffic. By clicking "Allow All", you consent to optional analytics. Cookie Policy

Learn identity