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.
- Replace an external OIDC provider during local development and integration tests 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.
- Remain intentionally minimal: no database, password authentication, user provisioning, Auth0 Management API, or production deployment support.
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.
-
Start the provider with the example client and personas:
go run ./cmd/oauthsonas --config config.example.yaml
-
Configure the dashboard with the values in Dashboard example.
-
Start the dashboard login flow and choose a persona in the local authorization page, such as Acme Administrator.
-
The dashboard callback receives a normal authorization code; its OIDC library exchanges it and validates the RS256 tokens against the published JWKS.
Install the Go version declared in go.mod, then either run from a checkout or
install the command directly:
go install github.com/optimiweb/oauthsonas/cmd/oauthsonas@latestgo run ./cmd/oauthsonas --config config.example.yamlValidate a configuration without starting the server:
go run ./cmd/oauthsonas --config config.example.yaml --check-configPrint the build version:
oauthsonas --versionDiscovery is available at:
http://127.0.0.1:8181/.well-known/openid-configuration
The default issuer is exactly http://127.0.0.1:8181. Do not substitute localhost in client configuration: OIDC issuer matching is exact.
podman build -t oauthsonas -f Containerfile .
podman run --rm -p 127.0.0.1:8181:8181 -e OAUTHSONAS_ALLOW_NON_LOOPBACK=true oauthsonasDocker uses the same commands with docker in place of podman.
The container binds to 0.0.0.0 for port-forwarding to work. You must explicitly set OAUTHSONAS_ALLOW_NON_LOOPBACK=true to acknowledge the non-loopback exposure. Keep the published port loopback-bound (-p 127.0.0.1:8181:8181) as shown.
Published releases are available from GitHub Container Registry after a SemVer Git tag is pushed:
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.3Pushing 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.
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.testThe example client is public and requires S256 PKCE. The authorization page lets a developer select a configured persona; it then redirects to the dashboard callback with a normal authorization code and unchanged state.
config.example.yaml contains the default dashboard client and the requested platform, staff, and customer personas. YAML is decoded strictly: unknown fields fail startup rather than being ignored.
To add a persona, append a unique entry under personas:
- id: delta-viewer
subject: oauthsonas|delta-viewer
email: viewer@delta.dev.optimi.test
name: Delta Viewer
organization_id: org_delta
roles: [customer-viewer]Roles are carried as names only. This server never expands roles into application permissions. Add an optional top-level allowed_roles list if a project wants configuration-time role vocabulary validation; otherwise any non-empty role name is valid.
Client redirect_uris and post_logout_redirect_uris are exact-match registration values; they may include fixed query parameters but never fragments. allowed_origins permits browser CORS only for the listed origins. Token and userinfo responses apply client-specific CORS; discovery and JWKS apply CORS only to the union of registered origins, never *.
The server generates a 2048-bit RSA key when it starts and publishes its public component through /.well-known/jwks.json. Access and ID tokens are signed with RS256 and include a process-lifetime kid; neither unsigned nor symmetric JWTs are produced.
Persona attributes are emitted as JWT and userinfo claims. By default the claim names are generic:
| Persona field | Default claim name | When emitted |
|---|---|---|
roles |
roles |
Always (at least one role required) |
organization_id |
org_id |
Only when set on the persona |
memberships |
memberships |
Only when set on the persona |
Example access-token payload with defaults:
{
"roles": ["customer-admin"],
"org_id": "org_acme"
}org_id is omitted for staff personas that have no organization_id. memberships is omitted unless the persona lists them.
Override claim names with an optional top-level claims block in the YAML config. Every field is optional; omitted fields keep the defaults above.
claims:
roles: roles
memberships: memberships
org_id: org_idUse namespaced URLs when a consumer (for example Auth0-style custom claims) requires them:
claims:
roles: https://example.com/roles
memberships: https://example.com/memberships
org_id: org_idRules:
- Claim names must be non-empty and must not contain whitespace.
- The three configured names must be distinct from each other.
- The same names appear in access tokens, ID tokens, userinfo, and discovery
claims_supported. - Persona YAML fields (
roles,organization_id,memberships) stay the same; only the emitted JWT claim keys change.
Validate a config after changing claim names:
go run ./cmd/oauthsonas --config config.yaml --check-configemail and email_verified require the email scope; name requires profile. OAuth scopes remain protocol scopes and do not encode application permissions.
JWT aud values are serialized as JSON arrays by Fosite, which is valid JWT/OIDC representation: access tokens target the configured api_audience; ID tokens target the client ID.
- Supported flow: Authorization Code with required S256 PKCE,
response_type=code, andopenidscope. - Standard scopes:
openid,profile,email, and optionaloffline_access. audienceis optional, but if supplied it must exactly equalapi_audience; access tokens always use that API audience.offline_accessissues a refresh token. Fosite rotates refresh tokens and detects/rejects reuse.- Supplying
scopeto a refresh request can only downscope. The narrower scope set becomes the grant for the rotated refresh token. - Authorization codes are short-lived and one-time. State is returned unchanged. Nonces are copied to ID tokens.
GETorPOST /userinfovalidates the bearer access token and returns the granted profile/custom claim subset.GET /logoutredirects only to a registeredpost_logout_redirect_uriand returns an optionalstatevalue. When anid_token_hintis provided, the client is derived from the token'saudclaim, supporting RP-initiated logout without an explicitclient_id.
GET /healthz returns 200 with JSON payload:
{"status":"ok","version":"1.0.0","issuer":"http://127.0.0.1:8181","uptime":"5m23s"}GET /readyz returns 200 once the server is ready to accept requests:
{"status":"ready","issuer":"http://127.0.0.1:8181"}Use these for Kubernetes probes, health checks, or orchestration signaling. Both endpoints set Cache-Control: no-store.
The server uses log/slog with JSON (default) or text output. Control format and level with CLI flags:
--log-format json|text (default: json)
--log-level debug|info|warn|error (default: info)
Log attributes for each request: method, path, status, duration. Additional attributes on OAuth events:
| Event | Attributes logged |
|---|---|
authorize |
client_id, interaction_id (truncated), status, error_category |
select_persona |
client_id, persona_id, interaction_id (truncated), status |
token |
client_id, grant_type, status, error_category |
logout |
client_id, status |
Never logged: authorization codes, access tokens, refresh tokens, ID tokens, cookies, CSRF values, or PKCE verifiers.
OAuthSonas is designed for single-replica deployments only. Constraints:
- One replica max. All protocol state (tokens, authorization codes, interactions, signing keys) is process-local and in-memory. Running multiple replicas behind a load balancer will cause interaction cookie mismatches, invalid token signatures, and undefined behavior.
- No rolling surge. Deployments must use
strategy: Recreate. A second pod started alongside the old one would generate a different signing key and break JWKS validation. - No shared load balancing across instances.
- Restart invalidates everything. All issued tokens, authorization codes, and interactions are lost on restart.
- Use
/healthzfor liveness and/readyzfor readiness probes.
The server performs periodic garbage collection of expired in-memory state. For long-running instances, consider a process lifecycle manager that periodically restarts the pod.
The persona picker exposes stable selectors for test frameworks. The form contract for POST /oauth2/auth/select is versioned (see handler godoc for details).
| Element | Selector |
|---|---|
| Persona form container | form[data-testid="persona-form-<id>"] or .persona-form |
| CSRF hidden input | input[data-testid="csrf-input"] |
| Interaction ID hidden input | input[data-testid="interaction-id-input"] |
| Submit button | button[data-testid="persona-select-<id>"] |
- Navigate to
GET /oauth2/auth?...with standard OIDC params. - The response page contains one
<form>per persona with the selectors above. - Extract
csrfandinteraction_idfrom the hidden inputs. - Submit
POST /oauth2/auth/selectwith form fieldscsrf,interaction_id, andpersona. - The server responds with a 302 redirect to the registered
redirect_uricontainingcodeandstate. - Exchange the code at
POST /oauth2/tokenas usual.
- The request must include the
oauthsonas_interaction_<id>cookie set by step 1. - The CSRF token is one-shot. Replay returns HTTP 403.
- Duplicate form parameters are rejected with HTTP 400.
- Custom claim names must not collide with standard JWT/OIDC claim names (e.g.
sub,email,scope,client_id).
GET /healthzGET /readyzGET /.well-known/openid-configurationGET /.well-known/jwks.jsonGET /oauth2/authPOST /oauth2/auth/selectPOST /oauth2/tokenGET|POST /userinfoGET /logout
go test -race ./...There is intentionally no database, password flow, admin API, user-provisioning UI, Auth0 Organizations API, Management API, external identity provider, or production deployment manifest.
All protocol state is in memory. Restart the process to clear issued codes and tokens; do not use a long-running shared instance as an authentication service.
Read SECURITY.md before reporting vulnerabilities. The project is deliberately unsafe for production use, even when the process is bound to a loopback address.
See CONTRIBUTING.md for development and pull-request guidelines.