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 substitutelocalhost)
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, optionalorg_id, optionalmemberships) 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
-
Start the provider with the example client and personas:
go run ./cmd/oauthsonas --config config.example.yaml -
Point your dashboard (or other RP) at the dashboard example environment variables.
-
Start the login flow and choose a persona on the local authorization page (for example Acme Administrator).
-
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, andopenidscope. - Scopes:
openid,profile,email, and optionaloffline_access. - Audience: optional on authorize; if supplied it must exactly equal
api_audience. Access tokens always use that API audience. - Refresh:
offline_accessissues a refresh token. Fosite rotates refresh tokens and detects/rejects reuse. Supplyingscopeon refresh can only downscope; the narrower set becomes the grant for the rotated token. - Codes: short-lived and one-time.
stateis returned unchanged. Nonces are copied to ID tokens. - Userinfo:
GETorPOST /userinfovalidates the bearer access token and returns the granted profile/custom claim subset. - Logout:
GET /logoutredirects only to a registeredpost_logout_redirect_uri. Withid_token_hint, the client is derived from the token'saud(RP-initiated logout without an explicitclient_id).
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /healthz | Liveness |
GET | /readyz | Readiness |
GET | /.well-known/openid-configuration | Discovery |
GET | /.well-known/jwks.json | Public signing keys |
GET | /oauth2/auth | Authorization (persona picker) |
POST | /oauth2/auth/select | Persona selection |
POST | /oauth2/token | Token endpoint |
GET | POST | /userinfo | Userinfo |
GET | /logout | RP-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
/healthzfor liveness and/readyzfor 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.
| Element | Selector |
|---|---|
| Persona form | form[data-testid="persona-form-<id>"] or .persona-form |
| CSRF input | input[data-testid="csrf-input"] |
| Interaction ID | input[data-testid="interaction-id-input"] |
| Submit | button[data-testid="persona-select-<id>"] |
Flow
- Navigate to
GET /oauth2/auth?…with standard OIDC parameters. - The page contains one
<form>per persona. - Extract
csrfandinteraction_idfrom the hidden inputs. POST /oauth2/auth/selectwithcsrf,interaction_id, andpersona.- Expect a 302 to the registered
redirect_uriwithcodeandstate. - 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
- Configuration — YAML clients, personas, claims, and TTLs.
- Open source overview — other Optimi projects.
