Testing an OIDC login flow usually forces one of two chores. Either you register an app with a real provider โ an Auth0/Google/Okta account, a callback-URL allow-list, a client secret smuggled into CI โ or you self-host a mock: navikt/mock-oauth2-server wants a JVM or a Docker container, oauth2-mock-server wants a Node process inside your test run, Keycloak in dev mode wants a coffee break. All fine tools โ but none of them gives you an issuer URL you can use right now, from CI, a deployed preview, a teammate's laptop, and a mobile build at the same time, with zero infrastructure.
Every Mockbird project is that issuer. It speaks enough of the OIDC spec that real client libraries run their full verification against it โ discovery, RS256 signature via a served JWKS, issuer, audience, nonce, expiry โ and it accepts any client_id, client_secret, and credentials, because it's a mock IdP for testing login plumbing, not an auth provider.
The shared demo project is a live issuer right now:
# a standard OIDC discovery document
curl https://mockbird.mockbird.workers.dev/m/demo/.well-known/openid-configuration
# a machine-to-machine access token โ any client_id/secret is accepted
curl -X POST https://mockbird.mockbird.workers.dev/m/demo/auth/token \
-d 'grant_type=client_credentials&client_id=my-service&client_secret=anything'
# โ {"access_token":"eyJhbGciOiJSUzI1NiIs...","token_type":"Bearer","expires_in":3600}
The token is a real RS256 JWT, signed with the project's own 2048-bit RSA key. Verify it yourself against the JWKS at /m/demo/auth/jwks.json โ that's exactly what your library will do.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' -d '{"preset":"saas"}'
# โ {"id":"x7k2m9qp4w","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/x7k2m9qp4w"}
Your issuer is <baseUrl>; your discovery URL is <baseUrl>/.well-known/openid-configuration. The saas preset seeds a users resource โ and that's the identity store: pass ?email= (or login_hint=) at the authorize endpoint, or username= in a password grant, and a matching record becomes the logged-in user โ sub is the record id, and /auth/userinfo serves the record's live fields (password-ish fields stripped). Edit a user via plain REST, and the claims change. Unknown email? A user is synthesized, the flow still works.
# 1. "browser" hits the authorize endpoint โ it auto-approves and 302s straight back
curl -si 'https://mockbird.mockbird.workers.dev/m/demo/auth/authorize?response_type=code&client_id=spa&redirect_uri=http://localhost:3000/cb&state=xyz&scope=openid%20profile%20email&email=ada@example.com' \
| grep -i '^location'
# โ location: http://localhost:3000/cb?code=SINGLE_USE_CODE&state=xyz
# 2. exchange the code (single-use, 10-minute TTL)
curl -X POST https://mockbird.mockbird.workers.dev/m/demo/auth/token \
-d 'grant_type=authorization_code&code=SINGLE_USE_CODE&client_id=spa&redirect_uri=http://localhost:3000/cb'
# โ {"access_token":"...","id_token":"...","refresh_token":"...","expires_in":3600}
# 3. standard userinfo
curl https://mockbird.mockbird.workers.dev/m/demo/auth/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"
PKCE works the way your SPA library expects: send code_challenge + code_challenge_method=S256 at the authorize endpoint and the token endpoint requires the matching code_verifier โ a wrong or missing verifier gets a proper invalid_grant. Wrong redirect_uri at exchange? invalid_grant. Reused code? invalid_grant. Your error handling gets real errors to handle.
This is the whole configuration for openid-client โ no app registration, values are arbitrary:
import * as client from 'openid-client';
const config = await client.discovery(
new URL('https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT'),
'any-client-id', 'any-secret'
);
const verifier = client.randomPKCECodeVerifier();
const challenge = await client.calculatePKCECodeChallenge(verifier);
const url = client.buildAuthorizationUrl(config, {
redirect_uri: 'http://localhost:3000/cb',
scope: 'openid profile email',
code_challenge: challenge, code_challenge_method: 'S256',
login_hint: 'ada@example.com', // pick which users record logs in
});
// ...browser hop โ callback with ?code=...
const tokens = await client.authorizationCodeGrant(config, callbackUrl,
{ pkceCodeVerifier: verifier });
tokens.claims().email; // validated id_token claims
NextAuth / Auth.js works the same way โ a custom provider with just an issuer:
// auth.config.ts
providers: [{
id: 'mockbird', name: 'Mock IdP', type: 'oidc',
issuer: 'https://mockbird.mockbird.workers.dev/m/YOUR_PROJECT',
clientId: 'nextauth-dev', clientSecret: 'anything',
}]
The library does discovery, redirects through /auth/authorize, exchanges the code, checks the RS256 signature against the JWKS, validates iss/aud/nonce/exp โ and everything passes, because everything is real except the security decisions.
curl -X POST https://mockbird.mockbird.workers.dev/m/demo/auth/token \
-d 'grant_type=client_credentials&client_id=c&expires_in=5'
# expires_in clamps 5sโ7d (default 3600)
Mint a token that dies in 5 seconds, watch your interceptor hit the 401, refresh with grant_type=refresh_token, assert the retry. The refresh grant returns a fresh access + id + refresh token set every time. (Trying to use a refresh token as a bearer token is correctly rejected, too.)
Flip the project to protected mode and every endpoint โ REST, GraphQL, SSE, WebSocket โ requires a Bearer token, returning proper 401s with WWW-Authenticate otherwise. OIDC access tokens work everywhere the classic /auth/login JWTs do. So your app can do the entire realistic dance against one fake backend: OIDC login โ store tokens โ authenticated API calls โ 401 on expiry โ refresh โ retry.
| Mockbird | navikt/mock-oauth2-server | oauth2-mock-server (npm) | Keycloak (dev mode) | Duende demo server | |
|---|---|---|---|---|---|
| Setup | one curl (or nothing โ use the demo) | JVM dependency or Docker container | Node process in your test run | container + realm config | none (public demo) |
| Hosted URL reachable from CI / previews / mobile | โ | only if you deploy it | โ (in-process) | only if you deploy it | โ |
| Your own users as identities | โ users resource records | โ configurable | partly (token callbacks) | โ full user admin | โ fixed demo users |
| Code flow + PKCE + refresh + userinfo | โ | โ | โ | โ | โ (fixed clients) |
| Arbitrary per-request token claims | โ (claims come from the user record) | โ its best feature | โ via hooks | โ mappers | โ |
| Consent screens / RBAC / realms | โ auto-approves | โ | โ | โ it's a real IdP | partial |
| Works offline | โ | โ | โ | โ | โ |
| Also mocks the API behind the login | โ same project: REST + GraphQL + webhooks | โ | โ | โ | โ |
| Price / account | free, no signup | free, self-host | free, self-run | free, self-host | free, shared |
Where the others win, plainly: navikt's killer feature is precise per-request control of token contents (arbitrary claims, multiple issuers) and it runs offline inside JVM tests โ if you're writing Kotlin/Java integration tests, use it. oauth2-mock-server is the right call for pure-Node offline unit tests. Keycloak is a real IdP โ if you need consent screens, roles, or realm import/export, mock nothing and run it. The Duende demo is great for a quick look at certified server behavior, but it's a shared instance with fixed clients and users. Mockbird's angle is different: a keyless hosted issuer per project, where the same base URL also serves the mock API your app calls after login.
| Endpoint | What it does |
|---|---|
GET /m/:project/.well-known/openid-configuration | standard discovery document (issuer = .../m/:project) |
GET /m/:project/auth/jwks.json | public RSA key (RS256), standard JWKS shape |
GET /m/:project/auth/authorize | auto-approving code flow; email/login_hint picks the user; PKCE S256/plain; errors redirect back per RFC |
POST /m/:project/auth/token | grants: authorization_code, client_credentials, password, refresh_token; form-encoded or JSON; Basic client auth accepted; expires_in 5sโ7d |
GET /m/:project/auth/userinfo | standard claims + live fields from the matching users record |
GET /m/:project/auth/logout | end_session_endpoint โ redirects to post_logout_redirect_uri |
The classic simpler flow โ POST /auth/login with any email/password for an HS256 JWT โ still exists and is documented in the mock JWT auth guide. Full docs: /docs#oidc.
/auth/login in the dashboard โ real URL, data browser already open, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.