A tiny, multi-issuer mock OAuth 2.0 / OpenID Connect provider for local development and integration tests — written in Rust, shipping as a few-MB FROM scratch container.
Point your app's OIDC client at it instead of a real identity provider and run complete login flows offline: authorization code + PKCE, RS256-signed ID & access tokens, a real JWKS endpoint, /userinfo, and DPoP sender-constrained tokens — no accounts, no passwords, no setup.
Existing lightweight mocks are single-issuer; the multi-issuer ones are heavyweight runtimes (hundreds of MB). mox-oauth2 is the small and multi-issuer and Rust option:
- Multi-issuer by path —
/issuers/<name>/...; each issuer has its own discovery doc, keys, and clients. Test an app that talks to several IdPs at once, with isolation. - Tiny — a few-MB static binary and scratch image, starts instantly. Pure-Rust crypto (no OpenSSL/ring), so the container is just the static binary.
- Spec-correct enough for real client libraries — absolute discovery URLs, matching
iss, RFC-compliant error responses, published JWKS. Conformant libraries accept it.
This is a mock — deliberately not a real IdP:
- No credential verification: any email "logs in".
sub = FNV1a(lowercased email) mod 1e6. - State is in-memory (auth sessions, codes, DPoP
jtis) and not persisted or evicted — fine for tests, restart to reset. - One RSA signing key per instance, generated on first run and cached to
mox_oauth2.prv. Mount a volume to persist it across restarts. - Refresh tokens are not implemented yet.
docker build -t mox-oauth2 .
docker run --rm -p 8090:8090 mox-oauth2cargo run # listens on 0.0.0.0:8090Then fetch a discovery document:
curl http://localhost:8090/issuers/google/.well-known/openid-configuration| Env var | Default | Purpose |
|---|---|---|
BASE_URL |
http://localhost:8090 |
Public origin used to build absolute discovery URLs and iss. |
ISSUERS_CONFIG |
static/builtin_issuers.toml |
Path to the issuers definition (bind-mount to customize). |
HEADLESS |
unset | When truthy (1/true), /authorize skips the login page and auto-approves — for non-interactive e2e tests. |
The server listens on 0.0.0.0:8090.
With HEADLESS=1, GET /authorize skips the HTML login form and immediately
redirects back with a code — no page scraping needed. The user identity is taken
from the login_hint query parameter (default headless@mock.local):
GET /issuers/google/authorize?client_id=...&redirect_uri=...&response_type=code
&scope=openid%20email&state=xyz&login_hint=alice@example.com
=> 303 redirect to redirect_uri?code=...&state=xyz
Then exchange the code at /token as usual. Your test driver never has to render a page.
Issuers are declared in a TOML file — the table name becomes the issuer id in the URL path:
[google]
client_ids = ["my-web-app", "my-mobile-app"]
claims_supported = ["sub", "email", "email_verified", "name"]
[acme]
client_ids = ["internal-dashboard"]
claims_supported = ["sub", "email"]This exposes /issuers/google/... and /issuers/acme/..., each as an independent IdP.
All endpoints are scoped per issuer under /issuers/<issuer>:
| Path | Method | Description |
|---|---|---|
/.well-known/openid-configuration |
GET | OIDC discovery document |
/.well-known/jwks.json |
GET | Signing public key (JWK Set) |
/authorize |
GET/POST | Login page; issues the authorization code |
/token |
POST | Exchanges code → ID + access token |
/userinfo |
GET/POST | Claims for the access token |
BASE=http://localhost:8090
ISS=$BASE/issuers/google
# 1. Open /authorize in a browser (or script the form POST). Submit any email —
# no password is checked; the email deterministically maps to a stable `sub`.
# 2. After login you're redirected to redirect_uri?code=<CODE>&state=...
# 3. Exchange the code:
curl -s -X POST $ISS/token \
-d grant_type=authorization_code \
-d code=<CODE> \
-d redirect_uri=https://your-app/callback \
-d client_id=my-web-app
# => { "access_token": "...", "id_token": "...", "token_type": "Bearer", ... }
# 4. Call userinfo:
curl -s $ISS/userinfo -H "Authorization: Bearer <ACCESS_TOKEN>"
# => { "sub": "656198", "email": "you@example.com", "email_verified": true, ... }The same email always yields the same sub (case/whitespace-insensitive), so tests are reproducible across runs and restarts.
If the client sends code_challenge/code_challenge_method at /authorize, the matching code_verifier is required and verified at /token (S256 and plain, RFC 7636).
Send a DPoP proof header on the token request and the issued access token is sender-constrained via a cnf.jkt claim and returned as token_type: "DPoP". /userinfo then requires a valid DPoP proof whose key matches the binding and whose ath matches the token.
The server speaks plain HTTP and keeps no TLS stack (that's what keeps it tiny). Most OAuth2 libraries require an https issuer except for localhost, so:
- Local use: the default
http://localhost:8090is accepted as-is. - Anywhere else: run behind a TLS-terminating reverse proxy and set
BASE_URL=https://your-hostso the discovery URLs andissare HTTPS.