Skip to content

Repository files navigation

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.

Objectives

  • 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, 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.
  • Remain intentionally minimal: no database, password authentication, user provisioning, Auth0 Management API, or production deployment support.

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.

Quick usage

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

    go run ./cmd/oauthsonas --config config.example.yaml
  2. Configure the dashboard with the values in Dashboard example.

  3. Start the dashboard login flow and choose a persona in the local authorization page, such as Acme Administrator.

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

Run locally

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@latest
go run ./cmd/oauthsonas --config config.example.yaml

Validate a configuration without starting the server:

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

Print the build version:

oauthsonas --version

Discovery 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.

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 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.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; it then redirects to the dashboard callback with a normal authorization code and unchanged state.

Configuration

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 *.

Claims and keys

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.

Application claims

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.

Configuring claim names

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_id

Use 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_id

Rules:

  • 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-config

Scope-gated profile claims

email 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.

Protocol behavior

  • Supported flow: Authorization Code with required S256 PKCE, response_type=code, and openid scope.
  • Standard scopes: openid, profile, email, and optional offline_access.
  • audience is optional, but if supplied it must exactly equal api_audience; access tokens always use that API audience.
  • offline_access issues a refresh token. Fosite rotates refresh tokens and detects/rejects reuse.
  • Supplying scope to 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.
  • GET or POST /userinfo validates the bearer access token and returns the granted profile/custom claim subset.
  • GET /logout redirects only to a registered post_logout_redirect_uri and returns an optional state value. When an id_token_hint is provided, the client is derived from the token's aud claim, supporting RP-initiated logout without an explicit client_id.

Health and readiness

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.

Structured logging

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.

Kubernetes deployment

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 /healthz for liveness and /readyz for 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.

Browser automation

The persona picker exposes stable selectors for test frameworks. The form contract for POST /oauth2/auth/select is versioned (see handler godoc for details).

Selectors

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>"]

Flow

  1. Navigate to GET /oauth2/auth?... with standard OIDC params.
  2. The response page contains one <form> per persona with the selectors above.
  3. Extract csrf and interaction_id from the hidden inputs.
  4. Submit POST /oauth2/auth/select with form fields csrf, interaction_id, and persona.
  5. The server responds with a 302 redirect to the registered redirect_uri containing code and state.
  6. Exchange the code at POST /oauth2/token as usual.

Requirements

  • 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).

Endpoints

  • GET /healthz
  • GET /readyz
  • GET /.well-known/openid-configuration
  • GET /.well-known/jwks.json
  • GET /oauth2/auth
  • POST /oauth2/auth/select
  • POST /oauth2/token
  • GET|POST /userinfo
  • GET /logout

Test

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.

Security

Read SECURITY.md before reporting vulnerabilities. The project is deliberately unsafe for production use, even when the process is bound to a loopback address.

Contributing

See CONTRIBUTING.md for development and pull-request guidelines.

License

MIT

About

Development-only OpenID Connect provider for local integration testing

Topics

Resources

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages