oauthsonas

oauthsonas is a small, in-memory OpenID Connect provider for local development and integration tests. It implements a real browser Authorization Code flow with S256 PKCE, rotating refresh tokens, RS256 JWT access and ID tokens, discovery, JWKS, userinfo, logout, and configurable personas.

  • Module: github.com/optimiweb/oauthsonas
  • Install: go install github.com/optimiweb/oauthsonas/cmd/oauthsonas@latest
  • License: MIT
  • Default issuer: http://127.0.0.1:8181 (exact match — do not substitute localhost)

Security warning

Development and test use only. Never expose this server to the internet or deploy it to production.

It has an in-memory persona picker instead of real authentication, generates a new signing key on every start, and has no persistent state. It binds to 127.0.0.1 by default and refuses non-loopback addresses unless OAUTHSONAS_ALLOW_NON_LOOPBACK=true is set deliberately.

Read the security policy before reporting vulnerabilities.

What it is for

  • Replace an external OIDC provider during local development and CI without bypassing the relying party's real browser flow.
  • Exercise discovery, Authorization Code + S256 PKCE, callback-state preservation, token exchange, JWKS validation, userinfo, logout, and refresh-token rotation.
  • Provide stable application claims (roles, optional org_id, optional memberships) with configurable claim names.
  • Keep identities reproducible in YAML while leaving application authorization and role-to-permission expansion to the relying application.

What it is not

There is intentionally no database, password flow, admin API, user provisioning UI, Auth0 Organizations / Management API, external identity provider, or production deployment manifest. All protocol state is in memory — restart the process to clear issued codes and tokens.

Quick start

  1. Start the provider with the example client and personas:

    go run ./cmd/oauthsonas --config config.example.yaml
    
  2. Point your dashboard (or other RP) at the dashboard example environment variables.

  3. Start the login flow and choose a persona on the local authorization page (for example Acme Administrator).

  4. The callback receives a normal authorization code; the OIDC library exchanges it and validates the RS256 tokens against the published JWKS.

Validate configuration without starting the server:

go run ./cmd/oauthsonas --config config.example.yaml --check-config

Print the build version:

oauthsonas --version

Discovery:

http://127.0.0.1:8181/.well-known/openid-configuration

Run in a container

podman build -t oauthsonas -f Containerfile .
podman run --rm -p 127.0.0.1:8181:8181 \
  -e OAUTHSONAS_ALLOW_NON_LOOPBACK=true oauthsonas

Docker uses the same commands with docker instead of podman.

The image binds to 0.0.0.0 so port-forwarding works. You must set OAUTHSONAS_ALLOW_NON_LOOPBACK=true to acknowledge the exposure. Keep the published port loopback-bound (-p 127.0.0.1:8181:8181).

Published images (after a SemVer Git tag):

podman pull ghcr.io/optimiweb/oauthsonas:1.2.3
podman run --rm -p 127.0.0.1:8181:8181 \
  -e OAUTHSONAS_ALLOW_NON_LOOPBACK=true \
  ghcr.io/optimiweb/oauthsonas:1.2.3

Pushing v1.2.3 publishes 1.2.3, 1.2, 1, and latest. Pre-release tags such as v1.2.3-rc.1 publish their exact version only and do not move latest.

Dashboard example

Configure a browser dashboard using its OIDC library's equivalent of:

OIDC_AUTHORITY=http://127.0.0.1:8181
OIDC_CLIENT_ID=dashboard
OIDC_REDIRECT_URI=http://127.0.0.1:5173/auth/callback
OIDC_POST_LOGOUT_REDIRECT_URI=http://127.0.0.1:5173/
OIDC_SCOPE="openid profile email"
OIDC_AUDIENCE=https://api.optimicdn.test

The example client is public and requires S256 PKCE. The authorization page lets a developer select a configured persona, then redirects to the dashboard callback with a normal authorization code and unchanged state.

For clients, personas, claim names, and TTLs, see Configuration.

Protocol behavior

  • Flow: Authorization Code with required S256 PKCE, response_type=code, and openid scope.
  • Scopes: openid, profile, email, and optional offline_access.
  • Audience: optional on authorize; if supplied it must exactly equal api_audience. Access tokens always use that API audience.
  • Refresh: offline_access issues a refresh token. Fosite rotates refresh tokens and detects/rejects reuse. Supplying scope on refresh can only downscope; the narrower set becomes the grant for the rotated token.
  • Codes: short-lived and one-time. state is returned unchanged. Nonces are copied to ID tokens.
  • Userinfo: GET or POST /userinfo validates the bearer access token and returns the granted profile/custom claim subset.
  • Logout: GET /logout redirects only to a registered post_logout_redirect_uri. With id_token_hint, the client is derived from the token's aud (RP-initiated logout without an explicit client_id).

Endpoints

MethodPathPurpose
GET/healthzLiveness
GET/readyzReadiness
GET/.well-known/openid-configurationDiscovery
GET/.well-known/jwks.jsonPublic signing keys
GET/oauth2/authAuthorization (persona picker)
POST/oauth2/auth/selectPersona selection
POST/oauth2/tokenToken endpoint
GET | POST/userinfoUserinfo
GET/logoutRP-initiated logout

Health and readiness

GET /healthz returns 200:

{"status":"ok","version":"1.0.0","issuer":"http://127.0.0.1:8181","uptime":"5m23s"}

GET /readyz returns 200 once the server accepts requests:

{"status":"ready","issuer":"http://127.0.0.1:8181"}

Both set Cache-Control: no-store. Use them for Kubernetes probes or orchestration signaling.

Structured logging

The server uses log/slog with JSON (default) or text output:

--log-format json|text   (default: json)
--log-level  debug|info|warn|error   (default: info)

Request attributes: method, path, status, duration. OAuth events add client_id, grant/persona fields, and truncated interaction_id where relevant. Never logged: authorization codes, access/refresh/ID tokens, cookies, CSRF values, or PKCE verifiers.

Kubernetes constraints

oauthsonas is designed for single-replica deployments only:

  • One replica max. Tokens, codes, interactions, and signing keys are process-local. Multiple replicas cause cookie mismatches and invalid JWKS.
  • No rolling surge. Use strategy: Recreate. A second pod would mint a different signing key and break validation.
  • No shared load balancing across instances.
  • Restart invalidates everything — all issued tokens and codes are lost.
  • Use /healthz for liveness and /readyz for readiness.

The server garbage-collects expired in-memory state. For long-running instances, consider a process lifecycle that periodically restarts the pod.

Browser automation

The persona picker exposes stable selectors for Playwright, Cypress, and similar tools.

ElementSelector
Persona formform[data-testid="persona-form-<id>"] or .persona-form
CSRF inputinput[data-testid="csrf-input"]
Interaction IDinput[data-testid="interaction-id-input"]
Submitbutton[data-testid="persona-select-<id>"]

Flow

  1. Navigate to GET /oauth2/auth?… with standard OIDC parameters.
  2. The page contains one <form> per persona.
  3. Extract csrf and interaction_id from the hidden inputs.
  4. POST /oauth2/auth/select with csrf, interaction_id, and persona.
  5. Expect a 302 to the registered redirect_uri with code and state.
  6. Exchange the code at POST /oauth2/token.

Requirements: include the oauthsonas_interaction_<id> cookie from step 1; CSRF is one-shot (replay → 403); duplicate form parameters → 400.

Test and contribute

go test -race ./...
go vet ./...

See CONTRIBUTING.md for pull-request guidelines.

Next