---
title: "oauthsonas"
description: "In-memory OpenID Connect provider for local development and integration tests: Authorization Code + S256 PKCE, RS256 JWTs, personas, and rotating refresh tokens."
canonical_url: https://optimi.com/en/docs/oauthsonas
md_url: https://optimi.com/en/docs/oauthsonas.md
last_updated: 2026-09-04
---

# 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`](https://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](https://github.com/optimiweb/oauthsonas/blob/main/SECURITY.md)
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:

   ```sh
   go run ./cmd/oauthsonas --config config.example.yaml
   ```

2. Point your dashboard (or other RP) at the [dashboard example](#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:

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

Print the build version:

```sh
oauthsonas --version
```

Discovery:

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

## Run in a container

```sh
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):

```sh
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:

```sh
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](/en/docs/oauthsonas/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

| 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:

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

`GET /readyz` returns 200 once the server accepts requests:

```json
{"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:

```text
--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.

| 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

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

```sh
go test -race ./...
go vet ./...
```

See [CONTRIBUTING.md](https://github.com/optimiweb/oauthsonas/blob/main/CONTRIBUTING.md)
for pull-request guidelines.

## Next

- **[Configuration](/en/docs/oauthsonas/configuration)** — YAML clients,
  personas, claims, and TTLs.
- **[Open source overview](/en/docs/open-source)** — other Optimi projects.
