oauth2-mock by Curro Ya

A real OpenID Connect provider, small enough to forget about.

Authorization code with PKCE, signed RS256 ID tokens and a one-click sign-in page. Built for local development and end-to-end tests, where Google has no sandbox and Dex wants a password.

docker run --rm -p 5556:5556 curroya/oauth2-mock

Then point your client at http://localhost:5556 with local-client / local-secret. More setups

localhost:5556/auth?response_type=code&code_challenge_method=S256&…
Pick a user or type any email to see the redirect.

Your client runs for real. Only the person signing in is fake.

Discovery, PKCE, code exchange and ID-token verification all happen against a working provider. What you skip is a Google account and a password field.

How it compares

Against Dex and mock-oauth2-server, measured idle with no requests in flight.

oauth2-mockDexmock-oauth2-server
Image~52 MB~100 MB~350 MB
Idle memory~3 MiB~25 MB~150–300 MB (JVM)
Sign inClick, type any email, or login_hintEmail and passwordForm or login_hint
UsersOptional; any email worksStatic list with bcrypt hashesAny username
Signing key across restartsStableRotatesRotates
IssuerConfiguredConfiguredFrom the request's host

Quick start

Images for amd64 and arm64 are on Docker Hub. latest follows main; a vX.Y.Z tag pins a release.

shell
docker run --rm -p 5556:5556 -e USERS=alice@example.test,bob@example.test curroya/oauth2-mock
docker-compose.yml
services:
  oidc:
    image: curroya/oauth2-mock
    ports: ["5556:5556"]
    environment:
      ISSUER: http://oidc:5556
      BROWSER_BASE_URL: http://localhost:5556
      CLIENT_ID: my-app
      CLIENT_SECRET: my-app-secret
      USERS_FILE: /users.json
    volumes:
      - ./users.json:/users.json:ro
users.json
[
  {
    "name": "Candidate",
    "email": "candidate@example.test",
    "id": "6f8d3c1e-0b2a-4f5c-9d7e-1a2b3c4d5e6a"
  },
  { "email": "empty@example.test" }
]

What happens on a sign-in

Standard authorization code with PKCE. Nothing your client does is skipped.

  1. Your backend

    Discovers the provider

    GET /.well-known/openid-configuration · GET /keys

    Endpoints and the public key, cached like any production provider.

  2. Browser

    Opens the authorization page

    GET /auth?code_challenge=…&nonce=…&state=…

    A request without an S256 challenge gets 400.

  3. Person

    Picks a user

    POST /auth login_hint=… → 303 /callback?code=…

    A click, a typed email, or a login_hint on the first request, which skips the page.

  4. Your backend

    Exchanges the code

    POST /token → id_token (RS256)

    Client, redirect and verifier are checked before a signed ID token comes back.

Configuration

Environment variables only. An invalid value stops the server at startup and names the variable.

VariableDefaultPurpose
ISSUERhttp://localhost:5556Exact iss and base of the server-to-server endpoints; its path is the mount point
BROWSER_BASE_URLISSUERBase of the authorization endpoint the browser opens
CLIENT_IDlocal-clientThe one accepted client
CLIENT_SECRETlocal-secretIts secret, basic or post
USERSnoneComma-separated emails shown as buttons
USERS_FILEnoneJSON list of { email, name?, id? }; wins over USERS
KEY_SEEDoauth2-mockSeed of the deterministic RSA key
TOKEN_LIFETIME_SECONDS3600ID and access token lifetime
PORT5556Listen port

In end-to-end tests

Every email is a stable identity, so give each parallel worker its own address. No fixture list required.

sign-in.spec.ts
// click a configured user
await page.getByRole('button', { name: /candidate@example\.test/ }).click()

// or sign in as anyone
await page.getByLabel('Email').fill(`worker-${workerIndex}@example.test`)
await page.getByRole('button', { name: 'Sign in' }).click()