From e53bc6d5def500f385ebb7ef0171c855333b488a Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Fri, 3 Jul 2026 10:27:06 -0700 Subject: [PATCH] =?UTF-8?q?docs:=20build=20a=20Di=C3=A1taxis=20documentati?= =?UTF-8?q?on=20site=20and=20slim=20the=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the documentation into a Diátaxis-organized MkDocs site: a hands-on tutorial, eleven task-focused how-to guides (including a migration guide from mock-oauth2-server), five reference pages, and four explanation pages, wired into the nav and building under mkdocs --strict. Shrink the README from 438 lines to a ~120-line entry point (description, features, quickstart, a documentation map, development), moving the flag reference, TLS/proxy/tracing how-tos, and release-chain detail into the site. Absorb contributor detail (project layout, tests, common tasks) into CONTRIBUTING.md and refresh its stale template framing. Co-Authored-By: Claude Fable 5 --- CONTRIBUTING.md | 52 ++- README.md | 440 +++--------------- .../architecture-and-distribution.md | 176 +++++++ docs/docs/explanation/issuers-and-identity.md | 164 +++++++ docs/docs/explanation/parity.md | 57 +++ docs/docs/explanation/security-model.md | 135 ++++++ .../how-to/capture-and-assert-requests.md | 150 ++++++ .../drive-the-authorization-code-flow.md | 190 ++++++++ .../docs/how-to/get-tokens-for-every-grant.md | 214 +++++++++ .../how-to/lock-down-the-control-plane.md | 112 +++++ .../how-to/migrate-from-mock-oauth2-server.md | 173 +++++++ .../how-to/run-behind-a-proxy-or-in-docker.md | 86 ++++ docs/docs/how-to/serve-over-tls.md | 94 ++++ docs/docs/how-to/shape-token-claims.md | 222 +++++++++ docs/docs/how-to/simulate-expiry-and-time.md | 175 +++++++ docs/docs/how-to/use-multiple-issuers.md | 155 ++++++ docs/docs/how-to/verify-released-artifacts.md | 99 ++++ docs/docs/index.md | 113 ++--- docs/docs/reference/cli.md | 54 +++ docs/docs/reference/configuration.md | 213 +++++++++ docs/docs/reference/control-plane.md | 300 ++++++++++++ docs/docs/reference/observability.md | 115 +++++ docs/docs/reference/tokens-and-claims.md | 168 +++++++ docs/docs/tutorials/first-mock-sign-in.md | 291 ++++++++++++ docs/mkdocs.yml | 27 +- 25 files changed, 3541 insertions(+), 434 deletions(-) create mode 100644 docs/docs/explanation/architecture-and-distribution.md create mode 100644 docs/docs/explanation/issuers-and-identity.md create mode 100644 docs/docs/explanation/parity.md create mode 100644 docs/docs/explanation/security-model.md create mode 100644 docs/docs/how-to/capture-and-assert-requests.md create mode 100644 docs/docs/how-to/drive-the-authorization-code-flow.md create mode 100644 docs/docs/how-to/get-tokens-for-every-grant.md create mode 100644 docs/docs/how-to/lock-down-the-control-plane.md create mode 100644 docs/docs/how-to/migrate-from-mock-oauth2-server.md create mode 100644 docs/docs/how-to/run-behind-a-proxy-or-in-docker.md create mode 100644 docs/docs/how-to/serve-over-tls.md create mode 100644 docs/docs/how-to/shape-token-claims.md create mode 100644 docs/docs/how-to/simulate-expiry-and-time.md create mode 100644 docs/docs/how-to/use-multiple-issuers.md create mode 100644 docs/docs/how-to/verify-released-artifacts.md create mode 100644 docs/docs/reference/cli.md create mode 100644 docs/docs/reference/configuration.md create mode 100644 docs/docs/reference/control-plane.md create mode 100644 docs/docs/reference/observability.md create mode 100644 docs/docs/reference/tokens-and-claims.md create mode 100644 docs/docs/tutorials/first-mock-sign-in.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9c71c6..e038626 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing Thank you for your interest in contributing. -This repository is a Go web API server template, so changes should keep the generated-project path simple and predictable. +This repository is `mock-oidc`, a standalone, container-first mock OIDC/OAuth2 authorization server for testing, so changes should keep it simple, predictable, and honest about its FOR TESTING ONLY stance. For private vulnerability reporting, use [SECURITY.md](SECURITY.md) instead of public channels. ## Reporting Bugs @@ -44,12 +44,60 @@ moon run root:test moon run docs:build # build the docs site (renders the OpenAPI spec) mise run stack-up # run the mock-oidc server via Docker Compose (Ctrl-C to stop) -curl -sS localhost:8080/isalive # smoke-test the running server (in another shell) +curl -sS http://localhost:8080/isalive # smoke-test the running server (in another shell) ``` The server is DB-less and needs no configuration: `./bin/mock-oidc serve` boots and serves the infrastructure routes immediately. +## Project Layout + +The codebase uses a pragmatic hexagonal (ports-and-adapters) layout. Dependencies +point inward, and the domain core depends on nothing in the adapters. + +- `cmd/mock-oidc` — thin `main` entrypoint. +- `internal/cli` — the `serve` / `version` / `openapi` subcommands. +- `internal/config` — configuration loading and precedence. +- `internal/oidc` — the pure domain core (layering-gated). Driven adapters live + under `signing/` (the real key-bearing signer) and `memory/` (in-memory + stores); driving adapters under `httpapi/` (the OAuth2/OIDC endpoints) and + `controlapi/` (the `/_mock` control plane). +- `internal/adapter/http` — generic chi transport: router and middleware, + RFC 9457 problem errors, infrastructure routes, and OpenAPI export. +- `internal/observability` — logging, metrics, and tracing wiring. +- `internal/app` — the composition root that wires everything together. +- `internal/integration` — container-backed tests behind the `integration` + build tag. + +The throwaway browser acceptance console under `webtest/` is a repo-internal +testing tool, not a shipped product. + +## Tests + +- Unit tests live beside the code they cover and use Testify. +- The OIDC core's outbound ports are doubled with mockery-generated mocks in + `internal/oidc/mocks`, drift-guarded by `moon run root:mockery-check`. +- The container-backed integration suite is behind the `integration` build tag, + so `go test ./...` and `moon run root:check` stay hermetic (no Docker). Run it + with `mise run image-local` then `moon run root:test-integration`. +- The core's layering is enforced two ways: the `oidc-core` depguard rule in + `.golangci.yml` and the `TestCoreImportsAreClean` architecture test. + +## Common Tasks + +```sh +moon run root:format # format +moon run root:lint # lint +moon run root:build # build the binary +moon run root:test # unit tests (hermetic) +moon run root:mockery # regenerate the OIDC core mocks +moon run root:test-integration # container-backed suite (run mise run image-local first) +moon run root:check # aggregate gate; CI runs it via `moon ci --summary minimal` + +moon run docs:build # build the docs site +moon run docs:serve # serve the docs site locally +``` + ## Release Changes Release Please reads Conventional Commit subjects to build changelogs and release PRs. diff --git a/README.md b/README.md index 6112101..1c10001 100644 --- a/README.md +++ b/README.md @@ -1,413 +1,95 @@ # mock-oidc `mock-oidc` is a standalone, container-first **mock OIDC/OAuth2 authorization -server for testing**. It issues real, cryptographically-verifiable tokens for -arbitrary identities so a test suite can exercise a full sign-in flow against an +server for testing**. It issues real, cryptographically-signed tokens for +arbitrary identities so a test suite can drive a full sign-in flow against an unmodified OAuth2/OIDC client — no real identity provider required. It is a Go -reimplementation of [navikt/mock-oauth2-server](https://github.com/navikt/mock-oauth2-server) -with a hexagonal architecture, first-class container delivery, and a strong -supply-chain/provenance baseline (pinned CI, signed multi-arch images, SBOMs). +reimplementation of [navikt/mock-oauth2-server](https://github.com/navikt/mock-oauth2-server). > **FOR TESTING ONLY.** mock-oidc mints signed tokens for any identity on > request. It must never front production traffic. The server logs this > positioning banner on every startup. -The server is built on [chi](https://github.com/go-chi/chi) and -[Huma](https://huma.rocks), is DB-less, and boots with zero configuration. - -## What it does - -Point an OAuth2/OIDC client at a running `mock-oidc` and it behaves like a real -authorization server: it publishes discovery and a JWKS, and it mints **real, -signed** ID tokens, access tokens, and refresh tokens for whatever identity the -test asks for. Because the tokens verify against the served JWKS, an unmodified -client library completes a full sign-in without knowing it is talking to a mock. - -Everything is namespaced under an **issuer**, so one server can impersonate many -identity providers. With zero configuration a single `default` issuer is served -at `http://localhost:8080/default`, exposing the standard OAuth2/OIDC surface: - -| Route (per issuer) | Purpose | -| --- | --- | -| `/{issuer}/.well-known/openid-configuration` | OIDC discovery document | -| `/{issuer}/.well-known/oauth-authorization-server` | RFC 8414 metadata (identical body) | -| `/{issuer}/authorize` | authorization endpoint (auth-code, PKCE) | -| `/{issuer}/token` | token endpoint (all grants) | -| `/{issuer}/jwks` | signing key set (`kid=`) | -| `/{issuer}/userinfo` | UserInfo endpoint | -| `/{issuer}/introspect` | RFC 7662 token introspection | -| `/{issuer}/revoke` | RFC 7009 token revocation | -| `/{issuer}/endsession` | RP-initiated logout | - -A test-time **control plane** is mounted at `/_mock` (direct token minting, -clock control, scenario enqueueing, request capture); it is on by default and -can be locked down with a token or disabled entirely — see -[Configuration](#configuration). - -Infrastructure routes (outside any issuer): - -```sh -curl -sS localhost:8080/isalive # liveness alias => 200 -curl -sS localhost:8080/healthz # liveness => {"status":"ok"} -curl -sS localhost:8080/readyz # readiness => {"status":"ready","checks":{}} -curl -sS localhost:9090/metrics # Prometheus exposition (dedicated listener) -``` - -## Prerequisites - -- [mise](https://mise.jdx.dev) — provisions every pinned tool from `mise.toml` + - `mise.lock`: Go, Moon, Python + uv (for the MkDocs docs project), the - `golangci-lint`/`mockery` CLIs, and `melange`/`apko`/`cosign` for releases. Run - `mise install` once; there is nothing else to install by hand. -- Docker (to build and run the container image, and for the container-backed - integration tests). - -Tool versions live in `mise.toml`; `mise.lock` records a per-platform download -URL and checksum for each (and, for the aqua-backed CLIs, cosign/SLSA/GitHub-attestation -verification). `mise install` runs with `locked = true`, so it **fails closed** -if a tool lacks a pre-resolved, checksummed entry for the current platform. Moon -runs every task against these tools as `system` binaries on PATH and manages no -toolchain itself. To bump a tool, edit its version in `mise.toml`, run -`mise lock --platform linux-x64,linux-arm64,macos-x64,macos-arm64`, and commit -`mise.toml` + `mise.lock`. +## Features + +- All six OAuth2 grants (`client_credentials`, `authorization_code`, `password`, + `refresh_token`, JWT-bearer, token-exchange) plus the auth-code flow with PKCE. +- Real, signed JWTs (ID, access, and refresh tokens) that verify against the + JWKS the server publishes for each issuer. +- Multi-issuer: one server impersonates many identity providers, and issuers + materialize on first touch — no registration step. +- A `/_mock` control plane to mint tokens directly, freeze or advance the clock, + enqueue one-shot scenarios, and capture inbound requests for assertions. +- Drop-in compatibility with `mock-oauth2-server`: the same unprefixed + environment variables and the same JSON configuration shape. +- Zero-config and DB-less — it boots instantly and serves a `default` issuer. +- Distributed as a signed, SBOM'd, multi-arch container image. ## Quickstart -The server is DB-less and needs no configuration. Run the published multi-arch -image (see [Container image](#container-image) for how it is built and signed): +The server needs no configuration. Run the published multi-arch image: ```sh docker run --rm -p 8080:8080 ghcr.io/meigma/mock-oidc - -# Discovery for the zero-config `default` issuer: -curl -sS localhost:8080/default/.well-known/openid-configuration -# => { "issuer": "http://localhost:8080/default", "token_endpoint": ..., "jwks_uri": ... } ``` -Or build and run from source: +Fetch discovery for the zero-config `default` issuer: ```sh -moon run root:build # or: go build -o bin/mock-oidc ./cmd/mock-oidc -./bin/mock-oidc serve # serve is the default subcommand; listens on :8080 -curl -sS localhost:8080/default/.well-known/openid-configuration -``` - -To build the container locally instead of pulling it: - -```sh -mise run image-local # build the host-arch image as mock-oidc:dev -docker run --rm -p 8080:8080 -p 9090:9090 mock-oidc:dev -``` - -`mise run stack-up` brings up the same image via Docker Compose. - -## Commands - -| Command | Description | -| --- | --- | -| `serve` (default) | Run the HTTP server. | -| `version` | Print version, commit, and build date. | -| `openapi` | Write the OpenAPI 3.0.3 spec to stdout or a file (`--output/-o`). | - -```sh -./bin/mock-oidc openapi -o docs/docs/openapi.yaml -./bin/mock-oidc version -``` - -## Configuration - -Flags bind to Viper, so every setting is also a `MOCK_OIDC_*` environment -variable (uppercase, dashes become underscores). Precedence is flag > env > -default. - -| Flag | Env var | Default | Description | -| --- | --- | --- | --- | -| `--addr` | `MOCK_OIDC_ADDR` | `:8080` | host:port the HTTP server listens on | -| `--metrics-addr` | `MOCK_OIDC_METRICS_ADDR` | `:9090` | dedicated `/metrics` listener; empty serves `/metrics` on `--addr` | -| `--log-level` | `MOCK_OIDC_LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` | -| `--log-format` | `MOCK_OIDC_LOG_FORMAT` | `json` | `json` or `text` | -| `--read-timeout` | `MOCK_OIDC_READ_TIMEOUT` | `5s` | reading an entire request | -| `--read-header-timeout` | `MOCK_OIDC_READ_HEADER_TIMEOUT` | `5s` | reading request headers | -| `--write-timeout` | `MOCK_OIDC_WRITE_TIMEOUT` | `10s` | writing the response | -| `--idle-timeout` | `MOCK_OIDC_IDLE_TIMEOUT` | `120s` | idle keep-alive connections | -| `--request-timeout` | `MOCK_OIDC_REQUEST_TIMEOUT` | `15s` | per-request processing | -| `--shutdown-grace` | `MOCK_OIDC_SHUTDOWN_GRACE` | `15s` | graceful shutdown window | -| `--cors-allowed-origins` | `MOCK_OIDC_CORS_ALLOWED_ORIGINS` | _(none)_ | tightens CORS to an allowlist (comma-separated); empty reflects any origin (default-on) | -| `--trusted-proxy-header` | `MOCK_OIDC_TRUSTED_PROXY_HEADER` | _(none)_ | proxy header to read the client IP from (e.g. `X-Real-IP`); empty trusts the TCP peer | -| `--tls-cert-file` | `MOCK_OIDC_TLS_CERT_FILE` | _(none)_ | PEM certificate for HTTPS; paired with `--tls-key-file` | -| `--tls-key-file` | `MOCK_OIDC_TLS_KEY_FILE` | _(none)_ | PEM private key for HTTPS; paired with `--tls-cert-file` | -| `--control-enabled` | `MOCK_OIDC_CONTROL_ENABLED` | `true` | serve the `/_mock` test-control plane | -| `--control-token` | `MOCK_OIDC_CONTROL_TOKEN` | _(none)_ | require this bearer token on `/_mock`; empty leaves it open | -| `--rate-limit-enabled` | `MOCK_OIDC_RATE_LIMIT_ENABLED` | `false` | enable per-client rate limiting; **off by default** so test traffic is never throttled | -| `--rate-limit-rps` | `MOCK_OIDC_RATE_LIMIT_RPS` | `10` | sustained per-client request rate (requests/second) | -| `--rate-limit-burst` | `MOCK_OIDC_RATE_LIMIT_BURST` | `20` | per-client burst size (token-bucket depth) | -| `--tracing-enabled` | `MOCK_OIDC_TRACING_ENABLED` | `false` | enable OpenTelemetry [tracing](#tracing); the OTLP exporter is configured via the standard `OTEL_*` env vars | - -For drop-in compatibility with the upstream `mock-oauth2-server`, the unprefixed -`SERVER_HOSTNAME`, `SERVER_PORT`, `PORT`, `JSON_CONFIG`, `JSON_CONFIG_PATH`, and -`LOG_LEVEL` environment variables are also honored (with `LOGBACK_CONFIG` -accepted as a no-op). The listen address is composed as `--addr` > explicit -`SERVER_HOSTNAME`/`SERVER_PORT` > `PORT` > `:8080`, and the JSON config is loaded -from `JSON_CONFIG` (inline JSON) > `JSON_CONFIG_PATH` > `./config.json`. - -**CORS is on by default.** With no allowlist the server reflects any request -`Origin` back with `Access-Control-Allow-Credentials: true` and answers -preflight `OPTIONS` with `204` — so a browser-based client works out of the box. -Setting `--cors-allowed-origins` tightens reflection to exactly those origins. -The `"*"` wildcard is never emitted; the origin is echoed verbatim. - -Client IP is read from the direct TCP peer unless you opt into a trusted proxy -header — never from `X-Forwarded-For` implicitly — so the default is not -spoofable. Rate limiting is **disabled by default** because a for-testing server -is hammered by container-backed suites. - -### JSON configuration - -Beyond flags and env vars, the server accepts the upstream `mock-oauth2-server` -JSON config shape (loaded from `JSON_CONFIG`/`JSON_CONFIG_PATH`/`./config.json`). -It declares issuers, per-request token callbacks, a `staticAssetsPath`, and the -`httpServer.ssl` TLS block — unknown keys are ignored for lenient parity. See -[TLS](#tls) for the `ssl` shape. - -### TLS - -The server terminates HTTPS on the API listener when TLS is enabled (the -`/metrics` and `/_mock` listeners stay plain HTTP). There are two ways to turn it -on: - -- Supply your own certificate with `--tls-cert-file` / `--tls-key-file` (both - required together). -- Ask for an in-process **self-signed `localhost`** certificate — matching - upstream's `ssl:{}` behavior — by adding an `ssl` block to the JSON config: - - ```json - { "httpServer": { "ssl": {} } } - ``` - - ```sh - JSON_CONFIG='{"httpServer":{"ssl":{}}}' ./bin/mock-oidc serve - curl -k https://localhost:8080/default/.well-known/openid-configuration - # => every advertised URL (issuer, *_endpoint, jwks_uri) is https - ``` - - The generated cert has SANs `localhost`, `127.0.0.1`, and `::1`. It is for - local testing only; pass `-k`/`--insecure` (or trust it) in clients. - -### Running behind a proxy or in Docker - -Every URL the server advertises — the discovery `issuer`, every `*_endpoint`, -and `jwks_uri` — is derived **per request** from `X-Forwarded-Proto`, -`X-Forwarded-Host`, and `X-Forwarded-Port` (falling back to the `Host` header), -resolved to the host root. Terminate TLS at a reverse proxy and the advertised -identity follows the external address automatically: - -```sh -curl -s -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: idp.example.com' \ - -H 'X-Forwarded-Port: 443' \ - localhost:8080/default/.well-known/openid-configuration -# => issuer and all endpoints are https://idp.example.com/default +curl -sS http://localhost:8080/default/.well-known/openid-configuration +# => { "issuer": "http://localhost:8080/default", "token_endpoint": ..., "jwks_uri": ... } ``` -This matters for containerized tests where the **browser** and the **application -under test** must reach the mock at the *same* issuer URL. On Docker Desktop, -run the container with `--add-host=host.docker.internal:host-gateway`, publish -`-p 8080:8080`, and have both the browser (on the host) and the app (in a -sibling container) use `http://host.docker.internal:8080/default` as the issuer, -so the advertised `iss` equals the reachable address for both. - -### Named parity gap: nested issuers - -Issuer IDs are **single-segment** only: `mock-oidc` routes `/{issuer}/…` and -rejects any issuer value containing a `/`. This is equivalent-in-intent to -upstream for the common single-segment case, but it **cannot** represent an -Azure-style deeply-nested issuer path (`tenant/v2.0/...`). This is a conscious, -documented divergence, not a silent one. - -## Tracing - -Distributed tracing is [OpenTelemetry](https://opentelemetry.io)-based and -**opt-in** (`--tracing-enabled`, default false) because it needs an external -collector. When enabled, the server exports spans over **OTLP/HTTP** and is -configured entirely through the standard `OTEL_*` environment variables: +Get a signed access token with the client-credentials grant: ```sh -MOCK_OIDC_TRACING_ENABLED=true \ -OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ -OTEL_SERVICE_NAME=mock-oidc \ -OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.1 \ - ./bin/mock-oidc serve +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=client_credentials -d client_id=my-service -d scope=api +# => {"token_type":"Bearer","access_token":"eyJ...","expires_in":3600,"scope":"api"} ``` -Inbound HTTP requests are server spans (`otelhttp`) that extract W3C trace -context; the infrastructure routes (`/isalive`, `/healthz`, `/readyz`, -`/metrics`) are excluded so health checks and scrapes do not flood the backend. -`service.name`/`service.version` default to `mock-oidc` and the build version and -are overridable via `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES`. The tracer -provider is flushed on graceful shutdown. - -## Testing - -Unit tests sit beside the code and use [Testify](https://github.com/stretchr/testify) -(`assert` / `require`). The outbound ports of the OIDC core (`internal/oidc`) -are doubled with **mockery-generated** testify mocks in `internal/oidc/mocks`, -drift-guarded by `moon run root:mockery-check`. - -The domain core's layering is enforced two ways: the `oidc-core` depguard rule in -`.golangci.yml` and the `TestCoreImportsAreClean` architecture test — both fail if -`internal/oidc` reaches transport, framework, or key-bearing signing packages. - -The container-backed [integration suite](internal/integration) is behind the -`integration` build tag, so the default `go test ./...` and `moon run root:check` -stay hermetic (no Docker). It boots the shipped `mock-oidc:dev` image with -testcontainers and asserts the infra routes and the boot banner; it skips loudly -if the image is not present: +Or build and run from source: ```sh -mise run image-local # build mock-oidc:dev first -moon run root:test-integration # or: go test -tags integration ./internal/integration/... -``` - -## Project layout - -The server follows pragmatic hexagonal (ports & adapters) layering: the domain -core depends on nothing in the adapters, and dependencies point inward. - -``` -cmd/mock-oidc/ thin main; builds the Cobra root and executes -internal/ - cli/ serve / version / openapi commands, Viper wiring - config/ server runtime config (flags + MOCK_OIDC_* env) - oidc/ domain core: OIDC/OAuth2 types, ports, services (layering-gated) - signing/ driven adapter: real key-bearing signing (Signer/KeyStore) - memory/ driven adapter: in-memory stores - httpapi/ driving adapter: the OAuth2/OIDC HTTP endpoints - controlapi/ driving adapter: the /_mock test-control plane - adapter/ - http/ generic transport: chi router, middleware, RFC 9457 errors, - /isalive /healthz /readyz /metrics, OpenAPI export, Registrar seam - observability/ slog logger, request logging, Prometheus metrics - logctx/ carries the request-scoped logger on the context - ratelimit/ in-process per-client rate limiter (disabled by default) - app/ composition root: wires everything and runs the server - integration/ container-backed integration tests (build tag: integration) -compose.yaml day-one local stack: the mock-oidc API service -.mockery.yaml mockery generation config (repo root) -docs/ MkDocs site; docs/docs/openapi.yaml is the exported spec +moon run root:build # or: go build -o bin/mock-oidc ./cmd/mock-oidc +./bin/mock-oidc serve # serve is the default subcommand; listens on :8080 ``` ## Documentation -The MkDocs site publishes to GitHub Pages at -, including a generated -[API Reference](https://meigma.github.io/mock-oidc/api/) rendered from the -OpenAPI spec. Build it locally with `moon run docs:build` or preview with -`moon run docs:serve`. - -## Common tasks - -Moon is the standard task front door: - -```sh -moon run root:format -moon run root:lint -moon run root:build -moon run root:test -moon run root:mockery # regenerate the committed testify mocks -moon run root:test-integration # container-backed tests (needs Docker + mock-oidc:dev) -moon run root:check # the aggregate gate CI runs via `moon ci --summary minimal` -``` - -## Container image - -The image is built **without a Dockerfile**: -[melange](https://github.com/chainguard-dev/melange) compiles the binary into a -signed [Wolfi](https://github.com/wolfi-dev) apk (`melange.yaml`), and -[apko](https://github.com/chainguard-dev/apko) assembles it into a minimal, -multi-arch, non-root runtime image (`apko.yaml`) — uid 65532, ca-certificates, -tzdata, no shell. Each architecture builds natively (no QEMU). Build and run it -locally with the bundled mise task (it uses melange's Docker runner, so Docker -must be running): - -```sh -mise run image-local # build the host-arch image, load as mock-oidc:dev -docker run --rm -p 8080:8080 -p 9090:9090 mock-oidc:dev -``` - -The server needs no configuration; it boots and serves the infra routes -immediately. The Wolfi base intentionally floats to the latest packages (fresh CA -bundle and timezones, low CVE surface); the exact resolved versions are recorded -in the per-build SBOM and provenance attestation rather than pinned. `version`, -`commit`, and `date` are stamped into the binary via melange `--vars-file` — the -release workflow supplies the real values, and `mise run image-local` uses `dev`. - -## CI and Security - -The default CI workflow keeps permissions minimal, pins external actions, disables -checkout credential persistence, and delegates checks to Moon. It uses -GitHub-hosted dependency caches for Go, golangci-lint, and uv download artifacts. -The docs workflow builds the MkDocs site on pull requests and deploys `docs/build` -to GitHub Pages from the default branch. The scheduled security scan workflow -builds the local container image weekly, scans it for high/critical fixed -vulnerabilities, and uploads SARIF results to GitHub code scanning. Dependabot -covers GitHub Actions, Docker base images, the root Go module, and the docs uv -project. - -The build CLIs are pinned in `mise.toml` and locked in `mise.lock`, which records -a per-platform download URL and checksum for every tool. `mise install` runs with -`locked = true`, so it fails closed if any tool lacks a pre-resolved, checksummed -entry for the current platform; the aqua-backed CLIs additionally verify cosign -signatures, SLSA provenance, and GitHub artifact attestations at install time. - -Repository settings live in `.github/repository-settings.toml`. They default to -immutable releases, private vulnerability reporting, signed commits, squash-only -merges, GitHub Pages workflow publishing, and protected tags. - -## Release Layer - -Release automation is enabled so this repository proves the full binary and -container release lifecycle. The release path is: - -- Release Please opens and maintains the release PR, then creates a draft GitHub - release and tag after merge. -- Release Dry Run rehearses the GoReleaser binary path and the native-runner - melange/apko container build path on pull requests. -- GoReleaser builds binaries, checksums, and SBOMs without publishing directly. -- The release workflow uploads assets to the draft release; a separate, isolated - reusable workflow (`attest.yml`) generates the GitHub-hosted provenance - attestation for the binary checksums. -- The release workflow builds amd64 and arm64 apks with melange on native - GitHub-hosted runners, assembles and publishes - `ghcr.io/meigma/mock-oidc:vX.Y.Z` as a multi-platform manifest with apko, signs - it with keyless cosign, and attaches a syft SBOM attestation; the isolated - `attest.yml` workflow then creates the GitHub-native provenance attestation for - the manifest digest. -- Generating both provenance attestations in the isolated `attest.yml` reusable - workflow (not in the build job) keeps the signing identity unreachable by build - steps — the SLSA Build L3 isolation requirement. -- A human inspects the draft release before publication. - -The root `ghd.toml` matches the default GoReleaser output so the binary can be -installed with `ghd` once the release workflow runs. - -### Verifying released artifacts - -Both the binaries and the container image carry SLSA provenance attestations -(generated in the isolated `attest.yml` workflow), and the image is additionally -signed with keyless cosign. Verify them before use: - -```sh -# Container image — provenance attestation (GitHub-native): -gh attestation verify oci://ghcr.io/meigma/mock-oidc:vX.Y.Z --repo meigma/mock-oidc - -# Container image — keyless cosign signature: -cosign verify ghcr.io/meigma/mock-oidc:vX.Y.Z \ - --certificate-identity-regexp '^https://github.com/meigma/mock-oidc/.github/workflows/release.yml@.*' \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com - -# Downloaded release binary — provenance attestation: -gh attestation verify ./mock-oidc_X.Y.Z__ --repo meigma/mock-oidc \ - --signer-workflow meigma/mock-oidc/.github/workflows/attest.yml -``` +The full documentation lives at , organized +by what you need: + +- **Learn** — start with the + [Your first mock sign-in](https://meigma.github.io/mock-oidc/tutorials/first-mock-sign-in/) + tutorial. +- **Do** — task-focused how-to guides: + [get tokens for every grant](https://meigma.github.io/mock-oidc/how-to/get-tokens-for-every-grant/), + [drive the authorization-code flow](https://meigma.github.io/mock-oidc/how-to/drive-the-authorization-code-flow/), + [shape token claims](https://meigma.github.io/mock-oidc/how-to/shape-token-claims/), + [simulate expiry and time](https://meigma.github.io/mock-oidc/how-to/simulate-expiry-and-time/), + [capture and assert requests](https://meigma.github.io/mock-oidc/how-to/capture-and-assert-requests/), + and [migrate from mock-oauth2-server](https://meigma.github.io/mock-oidc/how-to/migrate-from-mock-oauth2-server/). +- **Look up** — + [Configuration](https://meigma.github.io/mock-oidc/reference/configuration/), + [Tokens and claims](https://meigma.github.io/mock-oidc/reference/tokens-and-claims/), + [Control plane](https://meigma.github.io/mock-oidc/reference/control-plane/), + [CLI](https://meigma.github.io/mock-oidc/reference/cli/), + [Observability](https://meigma.github.io/mock-oidc/reference/observability/), + and the generated [API Reference](https://meigma.github.io/mock-oidc/api/). +- **Understand** — + [the security model](https://meigma.github.io/mock-oidc/explanation/security-model/), + [issuers and advertised identity](https://meigma.github.io/mock-oidc/explanation/issuers-and-identity/), + [parity with mock-oauth2-server](https://meigma.github.io/mock-oidc/explanation/parity/), + and [architecture and distribution](https://meigma.github.io/mock-oidc/explanation/architecture-and-distribution/). + +## Development + +Prerequisites are [mise](https://mise.jdx.dev) (provisions every pinned tool from +`mise.toml` + `mise.lock`) and Docker (for the container image and the +container-backed integration tests). Run `mise install` once, then +`moon run root:check` for the aggregate gate that CI runs. See +[CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide. ## Contributing diff --git a/docs/docs/explanation/architecture-and-distribution.md b/docs/docs/explanation/architecture-and-distribution.md new file mode 100644 index 0000000..9a8052c --- /dev/null +++ b/docs/docs/explanation/architecture-and-distribution.md @@ -0,0 +1,176 @@ +--- +title: Architecture and distribution +description: Why mock-oidc is built as a pure hexagonal core with enforced boundaries, and why it ships as a Dockerfile-free, signed, provenance-carrying image. +--- + +# Architecture and distribution + +This page is for evaluators sizing up whether mock-oidc is trustworthy enough to +sit in their test harness, and for contributors who want to understand the shape +of the code before changing it. It is not a runbook. It explains two decisions +that define the project: how the code is *organised*, and how the binary and +image are *built and shipped*. Both decisions are driven by the same underlying +goal — a tool that mints real cryptographic tokens should be legible, and its +key-holding and signing paths should be small enough to audit at a glance. + +## A pragmatic hexagonal core + +mock-oidc follows ports-and-adapters (hexagonal) architecture, but pragmatically +rather than dogmatically. The point of the pattern here is not layering for its +own sake; it is to keep the part of the system that understands OAuth2 and OIDC +— what a token means, how a grant resolves, what belongs in a discovery document +— completely free of any knowledge about HTTP, chi, Huma, JSON wire formats, or +where signing keys physically live. + +That core is `internal/oidc`. It is pure domain logic and depends on nothing in +the adapters. Dependencies point *inward*: the outer rings know about the core, +the core knows nothing about the outer rings. It talks to the outside world only +through interfaces (ports) that it defines and the adapters implement. + +Around the core sit two kinds of adapters: + +- **Driven adapters** — things the core *calls*. `internal/oidc/signing` is the + real, key-bearing implementation that produces and verifies signatures. + `internal/oidc/memory` provides the in-memory stores for issuers, codes, and + refresh tokens. Because these are DB-less and in-memory, a fresh process boots + with zero configuration and no external dependencies — which is exactly what a + test fixture wants. +- **Driving adapters** — things that *call* the core. `internal/oidc/httpapi` + exposes the OAuth2/OIDC HTTP surface (authorize, token, userinfo, introspect, + and the rest). `internal/oidc/controlapi` exposes the `/_mock` control plane. + Both translate an inbound request into a core operation and the core's answer + back into a response; neither leaks its transport concerns inward. + +Underneath them is a deliberately generic transport layer, `internal/adapter/http`: +the chi router, the middleware stack, the RFC 9457 problem+json error rendering, +the infrastructure routes, the OpenAPI export, and a `Registrar` seam that lets +each driving adapter mount its routes without the transport layer knowing what +those routes *are*. Finally `internal/app` is the composition root — the single +place where concrete adapters are constructed and wired into the core. Nothing +else does wiring, so there is exactly one file to read to understand how the +whole object graph is assembled. + +### Why the boundary is enforced, not just documented + +Architectural intentions rot. A boundary that exists only in a diagram gets +crossed the first time someone reaches for a convenient import. So the core's +isolation here is *enforced by the build*, in two independent ways: + +- An **`oidc-core` depguard rule** in `.golangci.yml` fails linting if anything + under the core imports transport, framework, or key-bearing packages. +- A **`TestCoreImportsAreClean` architecture test** asserts the same invariant + from inside the test suite, so it fails even a `go test` run that skips the + linter. + +Having both a linter rule and a test is intentional redundancy: they run in +different tools, at different times, and neither alone can be silently disabled +without someone noticing. The payoff is that a reviewer never has to manually +police "is the core still pure?" — a violating pull request goes red on its own. + +### JOSE is stdlib-only, on purpose + +The most consequential dependency decision is one the project *declined* to make: +signing and verification (the JOSE path) use only the Go standard library, with +no third-party JOSE package. This keeps the amount of external code in the +key-holding path at zero. + +There is a real trade-off here. A mature JOSE library is convenient and battle- +tested, and reimplementing signing means owning that code. The project accepts +that cost because the alternative is worse for its specific purpose: an +evaluator who wants to know "what code touches my signing keys?" gets a small, +self-contained answer instead of a transitive dependency tree. For a tool whose +whole job is to hold keys and mint tokens, the surface you can audit matters more +than the convenience you give up. The security reasoning behind the key-holding +path is developed further in the [security model](security-model.md). + +## Built without a Dockerfile + +The distribution story rhymes with the architecture story: prefer a small, +inspectable, provenance-carrying artifact over a convenient opaque one. + +The container image is built **without a Dockerfile**. Instead of `FROM` a base +image and running shell commands as root at build time, the pipeline uses two +declarative tools: + +- **melange** compiles the Go binary into a signed Wolfi **apk** package + (described by `melange.yaml`). Version, commit, and build date are stamped into + the binary here via a vars file. +- **apko** assembles that apk plus a minimal Wolfi base into an OCI image + (`apko.yaml`). The result is multi-arch, runs as a **non-root** user + (uid 65532), carries `ca-certificates` and `tzdata`, and has **no shell** at + all. + +Several properties fall out of this that a Dockerfile makes hard. There is no +build-time shell and no root layer, so there is no accreted state to reason +about — the image contents are exactly the declared package set and nothing +else. "No shell" is not an inconvenience to work around; it is a deliberate +reduction of what an attacker (or a confused test) could do inside a running +container. Each architecture is built **natively** rather than emulated under +QEMU, which keeps builds fast and avoids the subtle correctness questions that +cross-emulation can introduce. + +The Wolfi base intentionally **floats to latest** packages rather than pinning +every version by hand. Pinning is the usual instinct for reproducibility, but it +trades security for a false sense of it — pinned bases quietly rot and ship known +vulnerabilities. The project takes the opposite bet: track upstream, and make the +build *self-documenting* instead. Every build records the exact resolved versions +in a per-build **SBOM** and provenance attestation, so "what was actually in this +image?" is always answerable after the fact even though the input floated. + +## Provenance and the SLSA Build L3 isolation boundary + +A signed, minimal image is only half the trust story; the other half is being +able to prove *where it came from*. Releases carry SLSA provenance, and the +release machinery is arranged specifically so that provenance can reach **Build +Level 3**. + +The moving parts: + +- **Release Please** maintains the release pull request and, on merge, cuts a + **draft** GitHub release and tag. +- **GoReleaser** builds the binaries, checksums, and SBOMs. +- The release workflow publishes `ghcr.io/meigma/mock-oidc:vX.Y.Z` as a + multi-arch manifest via apko, **keyless-cosign-signs** the image, and attaches + a syft SBOM attestation. +- A **separate, isolated reusable workflow** (`attest.yml`) generates the + GitHub-native SLSA provenance attestations for *both* the binary checksums and + the image manifest digest. + +The reason attestation lives in its own workflow instead of alongside the build +is the crux of SLSA Build L3. L3 requires that the signing identity used to +produce provenance be **unreachable by the build steps** — otherwise a +compromised build could forge its own provenance and the attestation would prove +nothing. By keeping attestation in an isolated workflow with its own privileges, +the build job never holds the credential that signs the provenance, so it cannot +fabricate a claim about itself. This isolation is the whole point; it is why the +work is split across two workflows that could, superficially, have been one. + +Finally, the release is cut as a **draft** so a human inspects it before it goes +public. Automation gets the artifacts, the signatures, and the attestations +right; a person still makes the decision to publish. That human gate is a +deliberate seam, not a missing feature. + +!!! note "Verifying, not just trusting" + The value of signatures and attestations is that you don't have to take any + of this on faith. The exact `cosign` and `gh attestation verify` commands for + checking a released image and binary live in + [Verify released artifacts](../how-to/verify-released-artifacts.md). + +## What is deliberately not done + +A few omissions are choices worth naming, because their absence is sometimes +mistaken for an oversight: + +- **No embedded library API.** Upstream mock-oauth2-server can be driven + in-process as a JVM library. mock-oidc is container-first instead, and the + `/_mock` control plane is the equivalent driving surface. This is discussed as + a parity decision in [Parity with mock-oauth2-server](parity.md). +- **No hand-pinned base image.** As above, floating-plus-SBOM is preferred over + pinning-and-drifting. +- **No third-party JOSE dependency**, even though one would be less code to + maintain — the audit surface is worth more than the convenience. + +Taken together, the architecture and the distribution pipeline express one +consistent preference: keep the trusted parts small and their boundaries +machine-enforced, and make everything about a build answerable after the fact +rather than asking anyone to trust it blindly. diff --git a/docs/docs/explanation/issuers-and-identity.md b/docs/docs/explanation/issuers-and-identity.md new file mode 100644 index 0000000..4ccfdb9 --- /dev/null +++ b/docs/docs/explanation/issuers-and-identity.md @@ -0,0 +1,164 @@ +--- +title: Issuers and advertised identity +description: Why one server can impersonate many issuers on demand, and how it decides which identity to advertise on every request. +--- + +# Issuers and advertised identity + +An OIDC provider has an identity: a URL that names it. Clients pin that URL — +they fetch discovery and JWKS from it, and they reject any token whose `iss` +does not match it. A mock that stands in for real providers therefore has to +answer two questions convincingly. *Who* is minting this token — which issuer's +key signed it, and which trust domain does it belong to? And *where* does that +issuer live — what URL should the token claim, so the client under test +actually believes it? + +mock-oidc answers the first question with a namespace and the second by +deriving the URL fresh on every request. The two mechanisms are independent, +and understanding why they are separate is the key to understanding the whole +identity model. + +## One server, many issuers + +Everything the server does is namespaced under a single path segment: the +issuer. Discovery, JWKS, `authorize`, `token`, `userinfo` — all of it lives +under `/{issuer}/...`. With zero configuration the server answers as one issuer +named `default`, reachable at `http://localhost:8080/default`, but that name is +not special. It is simply the issuer you get when you do not ask for another. + +Issuers are not registered. They **materialize on first touch**: the first time +any request hits `/{some-id}/...`, that issuer springs into existence with a +lazily generated signing key, and every subsequent request under the same +segment shares it. There is no create step, no config entry, no restart. + +This is a deliberate inversion of how a real provider works, and it is chosen +for what a test needs. A test suite wants to drive a sign-in without first +provisioning a tenant; picking an issuer name and using it *is* the +provisioning. The same property lets a single running container impersonate an +arbitrary number of independent IdPs at once — one for each name a test cares +to invent — which is exactly what you want when exercising multi-tenant +isolation, federation across several providers, or an audience matrix. The +alternative, an explicit registration API or a config block listing every +issuer up front, would buy nothing here except setup: it trades away the +zero-friction property that makes the mock worth using. + +Each materialized issuer is its own trust domain. Its signing key carries +`kid` equal to the issuer id, so a verifier can route its trust off the issuer +name alone, and — more importantly — verification is isolated. A token minted +under one issuer is worthless to another: the second issuer's JWKS never +advertises the first one's key, so its `userinfo` rejects the foreign token and +its `introspect` reports it inactive. That isolation is not an add-on; it falls out of every +issuer having a distinct key and a distinct discovery document. (For the wider +consequences of "any string becomes a trusted, key-bearing issuer," see +[The security model](security-model.md).) + +The cost of materialize-on-touch is that there is no such thing as a typo. Ask +for `/defalt/token` and you have not hit an error — you have created and used a +brand-new issuer named `defalt`, with its own key and its own `iss`. That is a +reasonable price for a testing tool, but it is why the model belongs to a +process you run and throw away, never one that fronts real traffic. + +## The advertised-identity problem + +The harder question is *where* an issuer lives. A real provider is reached at +one canonical URL and advertises exactly that. A mock is not so lucky: the +same running server is reached under several different names *at the same +time*. A developer curls it at `http://localhost:8080`. An app inside a +container reaches it through a Docker network alias or the host gateway. A +reverse proxy answers for it at `https://idp.example.com` on port 443 and +forwards inward. Each caller sees a different address — and OIDC gives the +server no room to fudge, because the client compares the `issuer` in discovery +and the `iss` in the token against *the URL it actually fetched discovery from*. +If those disagree, verification fails. + +A fixed, configured base URL cannot solve this. Whatever single value you bake +in at startup is correct for exactly one of those callers and wrong for the +rest. The proxy topology wants `https://idp.example.com`; the localhost +developer wants `http://localhost:8080`; the container wants the host-gateway +name. No constant is right for all three simultaneously. + +So mock-oidc does not use a constant. It derives every advertised URL — the +discovery `issuer`, every `*_endpoint`, `jwks_uri`, and the `iss` stamped into +tokens — **per request**, from the address the request appears to have arrived +at. It reads `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-Port` +when a proxy set them, and falls back to the request's own `Host` header when it +did not. It keeps only the host root — scheme plus authority — discarding the +request path, and then appends the issuer segment. The advertised identity, in +other words, is a function of *how you reached the server*, computed anew every +time. + +That single decision is why the awkward topologies "just work" without any +matching configuration: + +- **Behind a proxy**, the proxy forwards `X-Forwarded-*` describing the external + address it answers on. A discovery request that entered as + `X-Forwarded-Proto: https`, `X-Forwarded-Host: idp.example.com` comes back + advertising `https://idp.example.com/default`, and tokens minted on that + request claim the same `iss` — even though the mock's own listener is plain + HTTP on `:8080`. +- **Across two containers**, the app under test and the browser must agree on + one issuer URL, because the app fetches discovery and JWKS from it while the + browser is redirected to `authorize` on it. Give both sides the same + reachable name — `host.docker.internal:8080` is the usual one on Docker + Desktop — and because the mock derives `iss` from the incoming `Host`, it + advertises exactly that name back to both. Remapping the published port needs + no configuration either: the mock sees the new port in `Host` and advertises + it. + +The mirror image of this is that the mock only advertises what it is told. If a +proxy strips the forwarded headers, the mock falls back to the `Host` it sees +and will advertise the internal address — correct behavior for a wrong input. +The identity is honest about the address the request carried, which is the most +a per-request derivation can promise. What exactly ends up in a token, `iss` +included, is catalogued in [Tokens and claims](../reference/tokens-and-claims.md); +the practical recipes live in +[Run behind a proxy or in Docker](../how-to/run-behind-a-proxy-or-in-docker.md). + +## The single-segment constraint + +An issuer id is one path segment. It cannot contain a `/`. This is a real +limitation with a real consequence, and it is documented here rather than +papered over, because papering over it would be worse. + +Some real providers publish *nested* issuer URLs. Azure AD is the canonical +example: its issuer looks like `https://login.microsoftonline.com/{tenant}/v2.0` +— several path segments deep. mock-oidc cannot represent that shape. A request +to `/tenant/v2.0/token` does not create a nested issuer called `tenant/v2.0`; +it is read as the issuer `tenant` with `v2.0/token` as a path beneath it. There +is no configuration that changes this. + +The reason is routing. Single-segment issuers map cleanly onto path-parameter +routing — `/{issuer}/...` — where the router extracts exactly one segment and +everything after it is a known, fixed endpoint. Allowing multi-segment issuers +would make the boundary between "issuer" and "endpoint" ambiguous: the router +could not tell where the issuer name ends and the OAuth path begins without +some escaping convention or greedy-matching rule, and that ambiguity would leak +into every route. The project chose the clean routing model and accepted that +Azure-style nested issuers fall outside it. + +What makes this a *named* gap rather than a silent bug is that it is a +deliberate, stated boundary of intent-parity with the upstream +`mock-oauth2-server`, not an accident of implementation. The mock aims to match +what a provider is *for*, and for the vast majority of clients the issuer is an +opaque URL to compare byte-for-byte — a single flat segment serves that +perfectly. Nested issuer paths are the corner it does not reproduce, and saying +so plainly is more useful than pretending a request under a nested path did +something sensible. The full inventory of what is and isn't reproduced, and the +reasoning behind each choice, lives in +[Parity with mock-oauth2-server](parity.md). + +One related reservation follows the same spirit: the `_mock` segment is taken by +the control plane, so it cannot be used as an issuer id. A protocol request +under that prefix is refused rather than quietly treated as an issuer — again, +an explicit boundary in place of a silent surprise. + +## Two questions, two mechanisms + +The namespace answers *who* — an isolated, key-bearing trust domain that exists +the moment you name it. The per-request derivation answers *where* — a URL that +follows the address each caller actually used. Keeping them separate is what +lets one process behave as many issuers and, for each of them, present the +right address to a proxy, a container network, and `localhost` all at once. When +you are ready to put either mechanism to work, see +[Use multiple issuers](../how-to/use-multiple-issuers.md) and +[Run behind a proxy or in Docker](../how-to/run-behind-a-proxy-or-in-docker.md). diff --git a/docs/docs/explanation/parity.md b/docs/docs/explanation/parity.md new file mode 100644 index 0000000..5a10544 --- /dev/null +++ b/docs/docs/explanation/parity.md @@ -0,0 +1,57 @@ +--- +title: Parity with mock-oauth2-server +description: Why mock-oidc matches what navikt/mock-oauth2-server is FOR while correcting its defects, and which upstream behaviours it deliberately does not reproduce. +--- + +# Parity with mock-oauth2-server + +mock-oidc began life as a reimplementation of [navikt/mock-oauth2-server](https://github.com/navikt/mock-oauth2-server), the JVM-based mock identity provider that many teams already reach for when they need a test suite to drive a real OAuth2/OIDC sign-in. Because so many projects already know that tool's shape, an obvious temptation is to reproduce it byte for byte — to treat every response it emits, correct or not, as a specification. mock-oidc deliberately does not do that. Its guiding principle is **parity in intent, not parity in quirks**. + +The distinction matters, and this page exists to explain it: what "intent" we chose to match, which upstream behaviours we treat as defects and correct, and — just as importantly — which capabilities we consciously chose not to carry over at all. If you are here to actually perform the switch, the mechanics live in [Migrate from mock-oauth2-server](../how-to/migrate-from-mock-oauth2-server.md); this page is about *why* the switch is safe and where it isn't. + +## What upstream is FOR + +Before deciding what to keep, it helps to name what the upstream tool is genuinely good at — its purpose, stripped of implementation accidents. mock-oauth2-server exists so that an automated test can perform a *complete, honest* sign-in against an **unmodified** OAuth2/OIDC client. It is not a stub that returns canned strings; it mints real, cryptographically signed tokens for arbitrary identities, publishes a JWKS, serves discovery, and honours the standard grant flows. The value is that your application code under test never has to know it is talking to a fake. You point it at a mock issuer instead of the real one, and everything downstream — signature verification, claim extraction, expiry checks — runs for real. + +That purpose is what mock-oidc sets out to preserve exactly. A token minted here verifies against the advertised JWKS; a full authorization-code round trip completes against a real browser client; the clock that stamps a token is the same clock that later validates it. Anywhere the two tools would produce a *materially different testing outcome* for a correct client, mock-oidc treats that as a bug to be reconciled — usually in upstream's favour, sometimes in ours where upstream is simply wrong. + +!!! warning "For testing only" + This inherited purpose carries an inherited constraint. A server whose entire job is to mint valid tokens for *any* identity with *no* secret validation is a catastrophic thing to expose to real traffic. mock-oidc, like the tool it replaces, must never front production. + +## Correcting defects rather than copying them + +The interesting cases are where upstream's *observable* behaviour diverges from what the specifications — or plain good sense — call for. Faithfully reproducing those would mean importing bugs into a fresh codebase and asking every future user to work around them forever. Instead, each was examined on its merits, and where upstream is demonstrably wrong, mock-oidc corrects it. The corrections are small in number and each has a concrete rationale. + +**OAuth2 error codes keep their correct case.** RFC 6749 defines error codes such as `invalid_request`, `invalid_grant`, and `invalid_client` as exact tokens. Upstream lowercases error bodies in a way that can mangle them; mock-oidc emits them verbatim. A client that switches on the error code — the entire reason the field exists — should not have to normalise case first, and a test asserting on the spec-defined value should pass. + +**A `form_post` response with no `state` is tolerated.** The `form_post` response mode is legitimately used without a `state` parameter; `state` is optional. Upstream could fault on that combination and return a 500. A missing optional parameter is not a server error, so mock-oidc renders the self-submitting form regardless. The absence of `state` simply means it is omitted from the posted body, which is exactly what the spec implies. + +**There is no 302-to-400 status coercion.** In some error situations upstream rewrites what should be a redirect-carried error into a flat `400`. That defeats the purpose of the OAuth2 redirect error channel, where the error is meant to travel back to the client's `redirect_uri` so the client's own handling runs. mock-oidc preserves the redirect semantics the flow prescribes rather than collapsing them to a bare status code. + +**`at+jwt` access tokens self-verify.** RFC 9068 blesses `at+jwt` as the media type for JWT access tokens, set in the JWS `typ` header. Upstream's verification path could reject a token whose `typ` was anything other than the default, so a token it had itself been asked to issue with a custom `typ` would fail its own `userinfo` and introspection checks. mock-oidc treats a token it minted as valid at its own endpoints — an `at+jwt` token introspects `active:true` and is accepted at `userinfo`. A genuinely foreign `typ` still fails, which is the behaviour you actually want: the server trusts the shapes it produces and rejects the ones it does not. + +**The login page has no network dependency.** Upstream's interactive login page pulls a web font (Raleway, via Google Fonts) at render time. In a test environment — frequently air-gapped, network-policied, or simply offline in CI — an external font request is at best latency and at worst a hang or a failed render. mock-oidc's login page inlines its CSS and depends on nothing beyond the response itself. A mock server used precisely because you want to avoid the real network should not reach out to a third party to draw a form. + +**Issuers are addressed by a path parameter, not a suffix.** Upstream distinguishes issuers by matching a suffix on the path. mock-oidc routes every issuer under a single leading path segment, `/{issuer}/`, so `default` lives at `/default` and its endpoints hang beneath it. Path-parameter routing composes cleanly with a standard router, makes the reserved `_mock` control namespace unambiguous, and gives every issuer a predictable, greppable prefix. It is the same conceptual model — many issuers behind one server — expressed in a form that is easier to reason about and to put behind a proxy. The consequences of this model for identity resolution are explored in [Issuers and identity](issuers-and-identity.md). + +None of these corrections require a well-behaved client to change anything. A client that already followed the specifications was, in effect, coding against the corrected behaviour all along; the divergences only ever bit code that had adapted to a bug. + +## Deliberate non-goals + +Parity in intent also means being honest about intent *not* shared. Several upstream capabilities were left out on purpose. These are design decisions, not unfinished work, and each reflects a judgement about what a container-first testing IdP should be. + +**No in-process embedded library.** Upstream ships as a JVM library you can start inside a test process and address through Kotlin/Java APIs. mock-oidc is container-first: you run it as a process — usually a container — and talk to it over HTTP. The trade-off is deliberate. An embedded library ties you to one language runtime; a server on a port serves a Go suite, a Node suite, a browser end-to-end run, and a shell script equally well. The dynamic control that the embedded API gave you in-process is provided instead by the `/_mock` control plane, which lets any client mint tokens, queue one-shot scenarios, capture requests, and drive the clock over plain JSON. That plane *is* the equivalent surface, reached over the network rather than through a class. See the [control-plane reference](../reference/control-plane.md) for its shape, and [Architecture and distribution](architecture-and-distribution.md) for why container-first was the organising choice. + +**No nested, multi-segment issuers.** An issuer id must be a single path segment. Azure-style issuers whose paths carry several segments (a tenant, then more) are unsupported. This is a *named, documented gap* rather than an oversight — single-segment routing is what keeps the model simple and the `_mock` namespace unambiguous, and multi-segment issuers would complicate both for a case most test suites do not need. Where it does matter, it is called out plainly so you are never surprised; the reasoning and its boundaries live in [Issuers and identity](issuers-and-identity.md). + +**Assertion signatures are parsed, not verified.** For the `jwt-bearer` and `token-exchange` grants, the incoming assertion or subject token is decoded for its claims but its signature is *not* checked — a dummy or empty signature works. This looks like a shortcut and is instead the point. A test needs to hand the server arbitrary assertions to exercise its own downstream handling: expired ones, ones with unusual claims, ones from an issuer that does not exist. Requiring a valid upstream signature would force every test to stand up a second signing authority just to feed the first, which defeats the purpose of a mock. Verifying here would buy security theatre in a component that already mints tokens for anyone. + +**No `actor_token` / `act` delegation chains.** Token exchange in mock-oidc handles the subject token and audience, but it does not model delegation via `actor_token` or stamp `act` claims to represent an acting party. Delegation chains are a rich corner of RFC 8693 that most test suites never touch; supporting them would add surface and claim-shaping complexity for a scenario better served, when genuinely needed, by minting a token with exactly the `act` claim you want through the control plane. + +**No arbitrary raw-response injection.** There is no facility to make the server return a hand-crafted, non-conforming raw HTTP response. mock-oidc's job is to behave like a *correct* identity provider, and every response it emits goes through the same token and error machinery so that what your client receives is internally consistent — a token that verifies, an error in the right envelope with the right status. An escape hatch for injecting arbitrary bytes would undermine that guarantee, and the legitimate need behind it — shaping what a specific token or callback contains — is already met by scenarios and minting. + +## The shape of the trade + +Taken together, these choices describe a tool that is faithful to upstream's *reason for existing* and unsentimental about its *implementation history*. You get the behaviour a correct OAuth2/OIDC client expects, the defects quietly fixed, and a smaller, more honest feature set with the sharp edges labelled rather than hidden. For most suites the practical result is that swapping the servers changes almost nothing in your application code; where it does, the difference is a bug you no longer have to work around, or a gap you were told about in advance. + +When you are ready to make the change concretely — the endpoint mapping, the configuration that carries over, and the handful of behaviours to re-check — follow [Migrate from mock-oauth2-server](../how-to/migrate-from-mock-oauth2-server.md). diff --git a/docs/docs/explanation/security-model.md b/docs/docs/explanation/security-model.md new file mode 100644 index 0000000..53ac45c --- /dev/null +++ b/docs/docs/explanation/security-model.md @@ -0,0 +1,135 @@ +--- +title: The security model +description: Why mock-oidc is safe as a test tool and dangerous anywhere else, and the design choices that keep the two apart. +--- + +# The security model + +mock-oidc has an unusual security posture: it is safe precisely because it is +insecure, and it is insecure on purpose. It will mint a valid, cryptographically +signed token for any identity you ask for, and it will never check a client +secret or a password before doing so. That behaviour is the whole product. It is +also exactly what would make it catastrophic anywhere near production. This page +explains why those two facts are the same fact, and what the server does to keep +the line between "test fixture" and "open oracle" from being crossed by accident. + +!!! warning "FOR TESTING ONLY" + mock-oidc is a test tool. It authenticates nobody and authorizes everything. + Never place it in front of production traffic, and never expose it on a + network where an untrusted party can reach it. + +## Credentials are never checked, on purpose + +A real identity provider exists to answer one question: *is this caller who they +claim to be?* mock-oidc exists to answer the opposite need: *let this test +pretend to be anyone, instantly, without standing up an identity provider.* Those +goals are irreconcilable. A test that had to present a valid client secret or a +correct password to obtain a token would need a real credential store, real user +provisioning, and real secret management — the very machinery the mock is meant +to let you skip. + +So the mock skips the check. Client authentication is accepted in every documented +form (`client_secret_basic`, `client_secret_post`, `private_key_jwt`) and then +never validated — the `client_secret_basic` and `client_secret_post` secrets are +discarded outright. The password grant accepts any password. The `private_key_jwt` +and `jwt-bearer` and `token-exchange` assertions are *parsed* for their structure +and claims but their signatures are never verified — a dummy signature works. This is not an oversight to be hardened later; verifying any of +it would defeat the purpose. The value of the tool is that a test can drive any +identity and any scenario by simply asking, and asking is the only credential. + +It helps to be precise about *which* checks are dropped. The server is blind to +the inputs that prove **authorization** — secrets, passwords, assertion +signatures — because gatekeeping is not its job. It remains strict about the one +thing that makes a token a token: **cryptographic integrity**. Tokens it issues +are properly signed, `userinfo` refuses a Bearer token whose signature it cannot +verify, introspection reports an unverifiable token as inactive, and `alg=none` +is rejected on the verifying side. The mock drops the checks that would get in a +test's way and keeps the checks that make its tokens real. + +## The tokens are real, and that is the danger + +The tokens are not stubs or fixtures with a recognizable "fake" shape. They are +ordinary JWTs signed with an ordinary key, and they verify against the server's +published JWKS using any standard OIDC library — the same code path your client +uses against your real provider. That realism is the entire reason an *unmodified* +client can complete a sign-in against the mock: nothing in the client has to be +told it is talking to a test double, because at the protocol level it is not +talking to anything unusual. It fetches discovery, fetches JWKS, validates the +signature and the `iss`, and is satisfied — exactly as it would be with a +production IdP. + +That same realism is why the server belongs only in a closed environment. A +mock-oidc instance is, functionally, a machine that hands out genuine, signed +bearer tokens for the identity "admin" (or any other) to whoever asks. Any +service configured to trust its JWKS will honour those tokens. There is no +weaker, sandboxed variant of a "real signed token" — a token strong enough to +satisfy an unmodified client is strong enough to be dangerous if the audience for +it is not confined to your test suite. The feature and the hazard are the same +property viewed from two sides. + +## Why it announces itself so loudly + +Because the danger is invisible at the protocol level — a mock token looks like a +real one — the server makes its nature visible out of band. On **every** startup +it logs a "FOR TESTING ONLY" banner, so a mock instance that has drifted into an +environment where someone forgot what it is announces itself in the logs rather +than blending in. Every response from the `/_mock` control plane carries the +header `X-Mock-Oidc: testing-only`, so any traffic capture or proxy that sees a +control-plane response can recognise what it is dealing with. These are not +security controls — they stop no attacker — but they are honesty about identity, +which is the most useful thing a component this dangerous can offer to the humans +operating it. + +## Safe by default, despite the "mock anything" stance + +The permissiveness is deliberately confined to the OAuth2 semantics — identities, +secrets, passwords. Around that core, the boundary behaviours lean *toward* safe +defaults rather than maximal openness, because there is no test-ergonomics reason +for them to be loose. + +- **CORS reflects, but never wildcards.** With no allowlist configured the server + reflects each request's `Origin` back verbatim (with credentials allowed), so a + browser-based suite from any origin just works. But it never emits + `Access-Control-Allow-Credentials: true` alongside a `*` wildcard — a + combination browsers reject and a habit worth not forming. Echoing the exact + origin keeps the response correct rather than blanket-open, and an allowlist + tightens it further when you want that. +- **Client IP comes from the TCP peer, not from a header.** The advertised issuer + URLs *do* follow `X-Forwarded-*`, because presenting the right identity behind a + proxy is core to how the mock is deployed. But the client IP used for logging is + read from the actual TCP peer and does **not** implicitly trust + `X-Forwarded-For`; honouring a forwarded client header is opt-in, via a named + trusted-proxy header. Identity-of-the-server is derived from forwarded headers; + trust-of-the-caller is not handed to them for free. +- **Rate limiting is off.** A test suite firing thousands of token requests should + never be throttled, so the limiter is disabled by default. This is a + test-first default, not a security stance — it exists precisely because the + server is not meant to face hostile load. It can be turned on, but the honest + mitigation for abuse is network isolation, not a rate limiter. +- **The control plane can be closed.** `/_mock` — which can mint arbitrary tokens + outright — is on by default for zero-config convenience, but it can be gated + behind a bearer token (compared in constant time) or disabled entirely so it + `404`s. In a shared or CI environment, closing or tokening it is the difference + between "a test helper" and "an open token-minting endpoint." + +None of these turn mock-oidc into something safe to expose. They reduce the +number of ways a *correctly isolated* deployment can still surprise you, and they +avoid teaching bad habits (wildcard CORS, blind header trust) that might migrate +into real code. + +## Where the line actually is + +The load-bearing control is not any single flag; it is the network boundary. The +right place to run mock-oidc is a closed test environment — a CI job, a developer +laptop, a container network your suite owns — where the only clients that can +reach it are the ones you are testing. Every choice above assumes that boundary +exists. The banner and the `testing-only` header help you notice when it does +not; the CORS, client-IP, and control-plane defaults reduce the blast radius if +something slips; but nothing substitutes for keeping the server unreachable by +anyone you would not hand an "admin" token. + +For the concrete steps to gate or disable the control plane, see +[Lock down the control plane](../how-to/lock-down-the-control-plane.md). For the +full set of flags and their defaults — CORS allowlists, the trusted-proxy header, +rate-limit and control-plane settings — see the +[Configuration reference](../reference/configuration.md). diff --git a/docs/docs/how-to/capture-and-assert-requests.md b/docs/docs/how-to/capture-and-assert-requests.md new file mode 100644 index 0000000..3af9ac2 --- /dev/null +++ b/docs/docs/how-to/capture-and-assert-requests.md @@ -0,0 +1,150 @@ +--- +title: Capture and assert requests +description: Assert on the exact requests a client sent, using the /_mock request-capture API. +--- + +# Capture and assert requests + +`mock-oidc` records every inbound protocol request. Use the `/_mock` +request-capture API to pull a request back out and assert that your client sent +exactly what you expected — down to the raw bytes. + +Two ways to read the log: + +- **Take** (`POST /_mock/requests/take`) — a destructive FIFO long-poll. Blocks + until a matching request arrives (or times out), then removes and returns it. + This is the one to use from a test. +- **List** (`GET /_mock/requests`) — a non-destructive snapshot of everything + recorded so far. + +All commands assume the default control plane, co-located on `:8080`. + +## Take the next matching request + +`POST /_mock/requests/take` with the issuer and endpoint you want. `endpoint` is +one of `authorize`, `token`, `userinfo`, `introspect`, `revoke`, `endsession`, +`jwks`. `timeoutMs` is how long to block waiting for a match: + +```sh +curl -sS -X POST http://localhost:8080/_mock/requests/take \ + -H 'Content-Type: application/json' \ + -d '{"timeoutMs": 2000, "issuer": "default", "endpoint": "token"}' +``` + +It returns a single `CapturedRequest` and removes it from the log (FIFO — you +get the oldest unmatched request first): + +```json +{ + "id": "…", + "receivedAt": "2026-07-03T12:00:00Z", + "issuer": "default", + "method": "POST", + "path": "/default/token", + "url": "http://localhost:8080/default/token", + "query": {}, + "headers": { "Content-Type": ["application/x-www-form-urlencoded"] }, + "bodyBase64": "Z3JhbnRfdHlwZT1jbGllbnRfY3JlZGVudGlhbHMm…", + "body": "grant_type=client_credentials&client_id=my-app&scope=read+write" +} +``` + +See the [control-plane reference](../reference/control-plane.md) for every +`CapturedRequest` field. + +!!! note "A timeout is a clean miss, not an error" + If no matching request arrives within `timeoutMs`, `take` returns **404** — + a clean empty result, not a failure. Treat 404 as "nothing captured yet", + not as an error to retry blindly. + +## Assert on the exact bytes + +`body` is a best-effort UTF-8 decode, convenient for eyeballing. For assertions, +prefer **`bodyBase64`**: it is the raw request body verbatim, so it preserves +parameter **order**, **duplicate keys**, and `+` / `%` encoding exactly as the +client sent them. Decode it and compare: + +```sh +curl -sS -X POST http://localhost:8080/_mock/requests/take \ + -H 'Content-Type: application/json' \ + -d '{"timeoutMs": 2000, "issuer": "default", "endpoint": "token"}' \ + | jq -r .bodyBase64 | base64 -d +# => grant_type=client_credentials&client_id=my-app&scope=read+write +``` + +Because these are raw bytes, `scope=read+write` stays literal — the `+` is not +folded into a space, and a form that sends `scope` twice keeps both copies in +order. That is the difference that lets you assert on wire format, not just on a +parsed map. + +## Worked example: assert a token request body + +Drive a request with a body you control, then take it and check it. + +1. Make the request (here, a `client_credentials` token grant): + + ```sh + curl -sS -X POST http://localhost:8080/default/token \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=client_credentials&client_id=my-app&scope=read+write' + ``` + +2. Take it back and decode the body: + + ```sh + BODY=$(curl -sS -X POST http://localhost:8080/_mock/requests/take \ + -H 'Content-Type: application/json' \ + -d '{"timeoutMs": 2000, "issuer": "default", "endpoint": "token"}' \ + | jq -r .bodyBase64 | base64 -d) + + [ "$BODY" = 'grant_type=client_credentials&client_id=my-app&scope=read+write' ] \ + && echo PASS || echo "FAIL: $BODY" + # => PASS + ``` + +The token endpoint also captures `query` and `headers`, so you can assert on the +client-auth header, `Content-Type`, or any query parameter the same way. + +## Peek without consuming (list, then clear) + +To inspect what has been recorded without removing anything, use the +non-destructive list. Both filters are optional; omit them to see everything: + +```sh +curl -sS 'http://localhost:8080/_mock/requests?issuer=default&endpoint=token' +# => { "count": 1, "requests": [ { … CapturedRequest … } ] } +``` + +Clear the whole log between test cases: + +```sh +curl -sS -X DELETE http://localhost:8080/_mock/requests +# => { "cleared": true } +``` + +!!! tip "Choose take vs. list per test shape" + Use `take` when a test expects exactly one request and should block for it. + Use `GET` + `DELETE` when you want to snapshot several requests at once and + reset the log around a test. + +## What is never captured + +The recorder logs inbound protocol traffic only. It never records the control +plane or the operational surface, so your assertions stay free of your own +test-harness noise. These are always excluded: + +- `/_mock/*` (the control plane itself) +- `/healthz`, `/readyz`, `/isalive` +- `/metrics` +- `/openapi*`, `/docs` +- `/favicon.ico` + +This self-isolation means a `take` or `GET /_mock/requests` will only ever +return requests your client made against an issuer's OIDC endpoints — polling +the control plane to read the log does not pollute it. + +!!! note "Dedicated control-listener mode" + If you run the control plane on its own address, the API listener carries no + request-recording middleware and this API records nothing. Request capture + is available in the default co-located mode. See + [Lock down the control plane](lock-down-the-control-plane.md). diff --git a/docs/docs/how-to/drive-the-authorization-code-flow.md b/docs/docs/how-to/drive-the-authorization-code-flow.md new file mode 100644 index 0000000..2a28f1e --- /dev/null +++ b/docs/docs/how-to/drive-the-authorization-code-flow.md @@ -0,0 +1,190 @@ +--- +title: Drive the authorization-code flow +description: Run the authorization-code flow and its variations — auto-issue, interactive login, PKCE, response modes, state, and nonce. +--- + +# Drive the authorization-code flow + +This guide runs the authorization-code flow against a running server and covers +the variations you actually hit: getting a code with or without a login page, +adding PKCE, choosing a response mode, and pinning `state` and `nonce`. Examples +use the zero-config `default` issuer at `http://localhost:8080`. + +For the shape of the tokens you get back, see +[Tokens and claims](../reference/tokens-and-claims.md). For how the advertised +issuer URL is derived, see +[Issuers and advertised identity](../explanation/issuers-and-identity.md). + +## Get a code without a login page (the default) + +`interactiveLogin` is off by default, so a bare `GET /authorize` **auto-issues** +a code and returns a `302` — there is no login page to click through. Read the +code straight off the `Location` header with `curl -i`: + +```sh +curl -i "http://localhost:8080/default/authorize?\ +response_type=code&\ +client_id=test-client&\ +redirect_uri=http://localhost:3000/callback&\ +scope=openid&\ +state=xyz" +# => HTTP/1.1 302 Found +# => Location: http://localhost:3000/callback?code=&state=xyz +``` + +To script it, capture the headers and pull the `code` out of `Location`: + +```sh +code=$(curl -s -o /dev/null -D - "http://localhost:8080/default/authorize?\ +response_type=code&client_id=test-client&\ +redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz" \ + | grep -i '^location:' | sed -E 's/.*[?&]code=([^&[:space:]]+).*/\1/') +``` + +## Exchange the code for tokens + +`POST` the code to the token endpoint with the `authorization_code` grant. The +code is **single-use**; redeeming it (or a failed PKCE attempt — see below) +burns it, so a retry needs a fresh `authorize` call. + +```sh +curl -s http://localhost:8080/default/token \ + -d grant_type=authorization_code \ + -d code="$code" \ + -d redirect_uri=http://localhost:3000/callback \ + -d client_id=test-client +# => {"token_type":"Bearer","access_token":"...","id_token":"...", +# => "refresh_token":"...","expires_in":3600} +``` + +This is the only grant that returns an `id_token`, an `access_token`, and a +`refresh_token` together. For the other grants, see +[Get tokens for every grant](get-tokens-for-every-grant.md). + +## Use the interactive login page + +Render a login form instead of auto-issuing when you want to choose the identity +per request. There are two ways to turn it on: + +- **Per request:** add `prompt=login` (also `consent` or `select_account`) to + the `authorize` query. No config change needed. +- **Always:** seed `interactiveLogin: true` so every `GET /authorize` shows the + form. + +```json +{ "interactiveLogin": true } +``` + +A `GET /authorize` that triggers the form returns `200` with an HTML page +instead of a `302`. Submit the identity by `POST`ing back to the **same** +`authorize` URL, query preserved. `username` is required and becomes the token +`sub`; `claims` is an optional JSON object merged into the token: + +```sh +curl -i "http://localhost:8080/default/authorize?\ +response_type=code&client_id=test-client&\ +redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz&prompt=login" \ + --data-urlencode 'username=alice' \ + --data-urlencode 'claims={"acr":"Level4","groups":["admin"]}' +# => HTTP/1.1 302 Found +# => Location: http://localhost:3000/callback?code=&state=xyz +``` + +A missing `username` returns `400` with `error: invalid_request`. + +## Add PKCE + +PKCE is optional and supports both `S256` and `plain`. Generate a verifier and +its `S256` challenge (`base64url(SHA-256(verifier))`) with one line: + +```sh +verifier=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') +challenge=$(printf '%s' "$verifier" | openssl dgst -binary -sha256 \ + | openssl base64 | tr '+/' '-_' | tr -d '=') +``` + +Send `code_challenge` and `code_challenge_method=S256` on `authorize`: + +```sh +curl -si "http://localhost:8080/default/authorize?\ +response_type=code&client_id=test-client&\ +redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz&\ +code_challenge=$challenge&code_challenge_method=S256" \ + | grep -i '^location:' +``` + +Then send the matching `code_verifier` on the token exchange: + +```sh +curl -s http://localhost:8080/default/token \ + -d grant_type=authorization_code \ + -d code="$code" \ + -d code_verifier="$verifier" \ + -d redirect_uri=http://localhost:3000/callback \ + -d client_id=test-client +``` + +Notes on the branches: + +- `plain` is also supported, and an omitted `code_challenge_method` **defaults + to `plain`**. +- A challenge without a verifier (or a verifier without a challenge) returns + `invalid_grant`. +- A mismatch returns `invalid_grant` with `invalid_pkce` in the description — + and the code is **burned even on that failed attempt**, so re-run `authorize` + to get a fresh code. + +## Choose a response mode + +Add `response_mode` to the `authorize` request to control how the code comes +back. All three are supported: + +- **`query`** (default): `302` to `redirect_uri?code=&state=`. +- **`fragment`**: `302` to `redirect_uri#code=&state=`. +- **`form_post`**: `200` with a **self-submitting HTML page** that `POST`s + `code` and `state` to `redirect_uri`. In a browser it auto-submits; with + `curl` you receive the HTML form body rather than a `Location` header. + +```sh +curl -si "http://localhost:8080/default/authorize?\ +response_type=code&client_id=test-client&\ +redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz&\ +response_mode=fragment" | grep -i '^location:' +# => location: http://localhost:3000/callback#code=&state=xyz +``` + +## Echo state and pin a nonce + +`state` is echoed back **verbatim** in the redirect (and omitted entirely when +empty) — use it for CSRF checks and to correlate the callback with the request. + +`nonce` is different: pass it on `authorize` and it is **cached server-side** +into the code record, then stamped into the `id_token` at exchange. A token +request cannot supply or forge a `nonce` — it only comes from the original +authorize call. + +```sh +curl -si "http://localhost:8080/default/authorize?\ +response_type=code&client_id=test-client&\ +redirect_uri=http://localhost:3000/callback&scope=openid&\ +state=xyz&nonce=n-0S6_WzA2Mj" | grep -i '^location:' +``` + +After exchanging the resulting code, the `id_token` carries `nonce: n-0S6_WzA2Mj`. + +## Explore it in a browser + +For a click-through round trip, point a browser at the built-in playground: + +```text +http://localhost:8080/default/debugger +``` + +It ships a prefilled form, runs a real authorization-code + PKCE exchange, and +renders the decoded tokens — handy for eyeballing claims without wiring up curl. + +!!! note "Two constraints to know" + - **`redirect_uri` is never validated.** Any value is accepted and captured + verbatim; there is no allowlist to register. + - **Only `response_type=code` dispatches.** `none`, `id_token`, and `token` + appear in discovery for compatibility but return an error if requested. diff --git a/docs/docs/how-to/get-tokens-for-every-grant.md b/docs/docs/how-to/get-tokens-for-every-grant.md new file mode 100644 index 0000000..00ff485 --- /dev/null +++ b/docs/docs/how-to/get-tokens-for-every-grant.md @@ -0,0 +1,214 @@ +--- +title: Get tokens for every grant +description: Copy-pasteable curl recipes to mint a token via each of mock-oidc's six OAuth2 grants. +--- + +# Get tokens for every grant + +Every grant is a form-encoded `POST` to `http://localhost:8080/{issuer}/token`. +These recipes use the zero-config `default` issuer; swap `default` for any id and +it materializes on first touch. Each curl uses `-d` (which sends +`application/x-www-form-urlencoded`). + +!!! warning "Testing only — secrets are never validated" + `mock-oidc` accepts any `client_secret` (or none) for every grant and never + checks it. Passwords, assertion signatures, and subject-token signatures are + not verified either. This is deliberate; the server must never front real + traffic. + +At a glance, this is what each grant hands back: + +| Grant | Tokens returned | Default `sub` | +|-------|-----------------|---------------| +| `client_credentials` | access | `client_id` | +| `password` | id + access | `username` | +| `authorization_code` | id + access + refresh | login user / configured / random UUID | +| `refresh_token` | access (+ id if a nonce was cached) | same as original | +| `jwt-bearer` | access | assertion's `sub` | +| `token-exchange` | access | subject token's `sub` | + +For the full claim rules (`aud` precedence, `azp`/`tid`, `typ`) see +[Tokens and claims](../reference/tokens-and-claims.md). + +## `client_credentials` + +```sh +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=client_credentials \ + -d client_id=orders-service \ + -d client_secret=unchecked \ + -d scope=api://orders +# => { "token_type":"Bearer", "access_token":"eyJ...", "expires_in":3600, +# => "scope":"api://orders" } +``` + +Returns an **access token only** — no `id_token`, no `refresh_token`. `sub` +defaults to `client_id`. The non-OIDC `scope` value becomes the access token's +`aud`. + +## `password` (ROPC) + +```sh +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=password \ + -d username=alice \ + -d password=anything \ + -d scope=openid +# => { "token_type":"Bearer", "access_token":"eyJ...", "id_token":"eyJ...", +# => "expires_in":3600, "scope":"openid" } +``` + +Returns an **id token + access token, but no refresh token**. `sub == username`, +and **any password is accepted**. + +## `refresh_token` + +There is no direct way to request a refresh token — only the +`authorization_code` grant issues one. Get a code, exchange it, then redeem the +refresh token it returns. + +First mint a code. With `interactiveLogin` off (the default) a bare +`GET /authorize` auto-issues one via redirect: + +```sh +curl -sS -i "http://localhost:8080/default/authorize?response_type=code&client_id=web-app&redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz" +# => HTTP/1.1 302 Found +# => Location: http://localhost:3000/callback?code=THE_CODE&state=xyz +``` + +Exchange the code for the token set (this is where the refresh token comes from): + +```sh +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=authorization_code \ + -d code=THE_CODE \ + -d client_id=web-app \ + -d redirect_uri=http://localhost:3000/callback +# => { "access_token":"eyJ...", "id_token":"eyJ...", "refresh_token":"THE_REFRESH", ... } +``` + +Now redeem the refresh token as often as you need: + +```sh +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=refresh_token \ + -d refresh_token=THE_REFRESH \ + -d client_id=web-app \ + -d scope=openid +# => { "token_type":"Bearer", "access_token":"eyJ...", "expires_in":3600, "scope":"openid" } +``` + +Each redemption re-mints a fresh access token (new `jti`/`iat`/`exp`, same +`sub`). Rotation is **off by default**, so the same `refresh_token` keeps +working and no new one is returned. An `id_token` comes back only if the original +`/authorize` request carried a `nonce` (add `&nonce=...` above to get one on +every refresh). + +An unknown token returns `invalid_grant`; redeeming a token minted by a +different issuer returns `invalid_grant` with `"different issuer"` in the +description. + +## `authorization_code` + +Shown as the first two steps of the refresh recipe above: obtain a code from +`/authorize`, then `POST` it to `/token` with `grant_type=authorization_code`. +It returns **id + access + refresh** tokens, and it is the only grant that adds +`azp == client_id`. The code is **single-use** and is burned even on a failed +PKCE check. + +For the full browser round trip — interactive login, PKCE (`plain`/`S256`), +`response_mode`, and `nonce` — see +[Drive the authorization-code flow](drive-the-authorization-code-flow.md). + +## `jwt-bearer` + +The assertion JWT is **parsed, not signature-verified**, so a literal dummy +signature works. Build `base64url(header).base64url(payload).dummy` yourself: + +```sh +# Base64url-encode stdin, no padding. Needs openssl (preinstalled on macOS and most Linux). +b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } + +header=$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | b64url) +payload=$(printf '%s' '{"sub":"svc-account","scope":"api://reports"}' | b64url) +assertion="$header.$payload.dummy" # third segment is a literal dummy signature + +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \ + --data-urlencode "assertion=$assertion" \ + -d scope=api://reports +# => { "token_type":"Bearer", "access_token":"eyJ...", "expires_in":3600, +# => "scope":"api://reports" } +``` + +Returns an **access token only**; `issued_token_type` is omitted. All assertion +claims are copied into the token, then `iss`/`exp`/`nbf`/`iat`/`jti`/`aud` are +re-stamped. + +Scope resolves as request `scope` → the assertion's `scope` claim → +`invalid_request`. Drop `-d scope=...` above and the token picks up +`"scope":"api://reports"` from the assertion payload instead. A blank +`assertion` returns `invalid_request`. + +## `token-exchange` + +The `subject_token` is also parsed, not verified — reuse the `b64url` helper and +header from the previous recipe. **Client authentication is required**; the +simplest form is `client_id` + `client_secret` fields. + +```sh +subject_payload=$(printf '%s' '{"sub":"alice","email":"alice@example.com"}' | b64url) +subject_token="$header.$subject_payload.dummy" # $header reused from the jwt-bearer recipe + +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \ + --data-urlencode "subject_token=$subject_token" \ + -d subject_token_type=urn:ietf:params:oauth:token-type:access_token \ + -d client_id=exchange-client \ + -d client_secret=unchecked \ + -d audience=https://api.internal.example +# => { "token_type":"Bearer", "access_token":"eyJ...", "expires_in":3600, +# => "issued_token_type":"urn:ietf:params:oauth:token-type:access_token" } +``` + +Returns an **access token only**, with +`issued_token_type=urn:ietf:params:oauth:token-type:access_token` and **no +`scope` field**. The `audience` param sets the token's `aud` — but only when the +matched issuer has no configured callback audience (a configured audience wins). + +!!! warning "Client auth is not optional here" + Omit `client_id`/`client_secret` and the request fails with + `invalid_request` mentioning `ClientAuthentication`. Only this grant enforces + that a client authenticates (the secret is still discarded). + +## grant_type errors + +- A **blank** `grant_type` returns `invalid_request`. +- An **unknown** `grant_type` returns `invalid_grant`. + +Both use the OAuth2 error envelope: `{"error":"...","error_description":"..."}`. + +## Skip the flow entirely with /_mock/mint + +When you just need a token and do not care which grant produced it, mint one +directly through the control plane. The result is byte-identical to a granted +token: it verifies against `/default/jwks` and is accepted at `/default/userinfo`. + +```sh +curl -sS -X POST http://localhost:8080/_mock/mint \ + -H 'Content-Type: application/json' \ + -d '{"issuer":"default","subject":"alice","audience":["api://orders"], + "scope":["openid"],"clientId":"web-app","kind":"access_token"}' +# => { "token":"eyJ...", "kid":"default", "algorithm":"RS256", "issuer":"...", ... } +``` + +See [Control plane](../reference/control-plane.md) for the full `/_mock/mint` +body, plus scenarios that shape claims on the next real grant. + +## Related + +- [Tokens and claims](../reference/tokens-and-claims.md) — claim rules, `aud` + precedence, and default token content +- [Drive the authorization-code flow](drive-the-authorization-code-flow.md) — the + full interactive flow with PKCE +- [Control plane](../reference/control-plane.md) — `/_mock/mint` and scenarios diff --git a/docs/docs/how-to/lock-down-the-control-plane.md b/docs/docs/how-to/lock-down-the-control-plane.md new file mode 100644 index 0000000..5979108 --- /dev/null +++ b/docs/docs/how-to/lock-down-the-control-plane.md @@ -0,0 +1,112 @@ +--- +title: Lock down the control plane +description: Restrict the /_mock control plane with a token, move it to a dedicated listener, or disable it entirely. +--- + +# Lock down the control plane + +The `/_mock` control plane is **on by default** and **co-located on the API +listener** (`:8080`). Anyone who can reach the server can mint tokens, freeze the +clock, and read captured requests through it. This guide shows the three ways to +restrict it: require a token, move it to its own listener, or turn it off. + +!!! warning "For testing only" + Even locked down, `mock-oidc` mints signed tokens for arbitrary identities. + None of these controls make it safe to expose; they exist so a shared test + environment does not hand its control plane to every client on the network. + +Every control response — including rejections — carries the header +`X-Mock-Oidc: testing-only`, so you can positively identify the control plane in +traffic captures regardless of which mode it runs in. + +## Require a control token + +Set a token at startup. `mock-oidc` then rejects any `/_mock` request that does +not present it in the `X-Mock-Control-Token` header (compared in constant time). + +```sh +./bin/mock-oidc serve --control-token s3cr3t +# equivalently: MOCK_OIDC_CONTROL_TOKEN=s3cr3t ./bin/mock-oidc serve +``` + +A call with no token — or the wrong one — gets `401` with an RFC 9457 body: + +```sh +curl -sS -i http://localhost:8080/_mock/clock +# => HTTP/1.1 401 Unauthorized +# => Content-Type: application/problem+json +# => X-Mock-Oidc: testing-only +# => {"title":"Unauthorized","status":401,"detail":"missing or invalid control token"} +``` + +Present the header and the request succeeds: + +```sh +curl -sS http://localhost:8080/_mock/clock \ + -H 'X-Mock-Control-Token: s3cr3t' +# => {"frozen":false,"now":"2026-07-03T12:00:00Z"} +``` + +The gate applies to the whole `/_mock` surface (mint, scenarios, requests, +clock, reset) and works the same whether the plane is co-located or on a +dedicated listener. Leaving `--control-token` empty (the default) disables the +gate entirely. + +## Move it to a dedicated listener + +To keep `/_mock` off the public API surface, bind it to its own address with +`--control-addr`. The plane then leaves the API listener completely. + +```sh +./bin/mock-oidc serve --control-addr :8090 +# equivalently: MOCK_OIDC_CONTROL_ADDR=:8090 ./bin/mock-oidc serve +``` + +`/_mock` now answers only on the control address; the API listener 404s it: + +```sh +curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8080/_mock/clock +# => 404 + +curl -sS http://localhost:8090/_mock/clock +# => {"frozen":false,"now":"2026-07-03T12:00:00Z"} +``` + +Combine this with `--control-token` to also require the header on the dedicated +listener. + +!!! note + The control address must differ from `--addr` and `--metrics-addr`; a + collision fails startup. The dedicated control listener carries **no + request-recording middleware** of its own. Protocol traffic is still + recorded on the API listener, so `/_mock/requests` and + `/_mock/requests/take` keep working — the control listener simply never + records its own `/_mock` calls. + +## Disable it entirely + +To serve only the public OIDC protocol and no control plane at all, turn it off: + +```sh +./bin/mock-oidc serve --control-enabled=false +# equivalently: MOCK_OIDC_CONTROL_ENABLED=false ./bin/mock-oidc serve +``` + +Every `/_mock` path now 404s, with or without a token: + +```sh +curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8080/_mock/clock +# => 404 +``` + +With the control plane off there is no direct token minting, no scenario queue, +no clock steering, and no request capture — the server behaves purely as an +authorization server driven through its normal `authorize`/`token` endpoints. + +## See also + +- [Configuration reference](../reference/configuration.md) — every flag and + `MOCK_OIDC_*` variable, including `control-enabled`, `control-token`, and + `control-addr`. +- [Control plane reference](../reference/control-plane.md) — the full `/_mock` + endpoint catalog these controls gate. diff --git a/docs/docs/how-to/migrate-from-mock-oauth2-server.md b/docs/docs/how-to/migrate-from-mock-oauth2-server.md new file mode 100644 index 0000000..d733f5a --- /dev/null +++ b/docs/docs/how-to/migrate-from-mock-oauth2-server.md @@ -0,0 +1,173 @@ +--- +title: Migrate from mock-oauth2-server +description: Switch a navikt/mock-oauth2-server test setup to mock-oidc with minimal changes. +--- + +# Migrate from mock-oauth2-server + +Move a test suite off `navikt/mock-oauth2-server` and onto mock-oidc while +keeping your existing environment variables, JSON config, and issuer URLs +working. mock-oidc targets intent-parity with upstream, so most setups migrate +by swapping how the server runs — not by rewriting your tests. + +## Run the container in place of the embedded server + +The core change is operational: mock-oauth2-server runs as an in-process JVM +test library (`MockOAuth2Server`), while mock-oidc runs as a standalone +container or binary. Start it and point your client at it exactly as before: + +```sh +docker run --rm -p 8080:8080 ghcr.io/meigma/mock-oidc +curl -sS http://localhost:8080/default/.well-known/openid-configuration +# => { "issuer": "http://localhost:8080/default", ... } +``` + +Issuers still materialize on first touch, so no registration step is needed — +hitting `/{issuer}/...` for any id creates it with a lazily generated signing +key. + +## Keep your environment variables + +mock-oidc honors the upstream environment variables **unprefixed**, in addition +to its own `MOCK_OIDC_*` variables. Leave your existing container/env wiring in +place: + +| Upstream variable | Effect in mock-oidc | +|---|---| +| `SERVER_HOSTNAME` | Listen host | +| `SERVER_PORT` | Listen port | +| `PORT` | Listen port (fallback) | +| `JSON_CONFIG` | Inline JSON config string | +| `JSON_CONFIG_PATH` | Path to a JSON config file | +| `LOG_LEVEL` | Log level (`debug`\|`info`\|`warn`\|`error`) | +| `LOGBACK_CONFIG` | Accepted and ignored (no-op) | + +The listen address resolves by precedence, highest first: + +```text +--addr (MOCK_OIDC_ADDR) > SERVER_HOSTNAME / SERVER_PORT > PORT > :8080 +``` + +## Keep your JSON config + +The JSON config shape is upstream-compatible and **unknown keys are silently +ignored**, so most `config.json` files load unchanged. These keys carry over: + +- `interactiveLogin` +- `tokenCallbacks[]`, including `requestMappings[]` +- `staticAssetsPath` +- `httpServer.ssl` (in-process self-signed localhost cert) +- `tokenProvider` (`systemTime`, `keyProvider`), `rotateRefreshToken` + +Load it the same way you already do — inline or by path: + +```sh +# By path (mounted into the container) +docker run --rm -p 8080:8080 \ + -e JSON_CONFIG_PATH=/config.json \ + -v "$(pwd)/config.json:/config.json:ro" \ + ghcr.io/meigma/mock-oidc + +# Or inline +docker run --rm -p 8080:8080 -e JSON_CONFIG="$(cat config.json)" ghcr.io/meigma/mock-oidc +``` + +A typical upstream callback config works as-is: + +```json +{ + "interactiveLogin": true, + "tokenCallbacks": [ + { + "issuer": "default", + "subject": "alice", + "audience": ["my-api"], + "claims": { "acr": "Level4" }, + "requestMappings": [ + { "param": "scope", "match": "admin", "claims": { "role": "admin" } } + ] + } + ] +} +``` + +See [Configuration](../reference/configuration.md) for the full key list and +precedence rules. + +## Replace embedded-API calls with the control plane + +The upstream embedded library API has no in-process equivalent — mock-oidc is +container-first. The `/_mock` control plane is its replacement: drive it over +HTTP from your test harness instead of calling JVM methods. + +| Upstream (embedded) | mock-oidc (`/_mock`) | +|---|---| +| `enqueueCallback(...)` | `POST /_mock/scenarios` | +| `takeRequest()` | `POST /_mock/requests/take` | +| direct token issue | `POST /_mock/mint` | + +Enqueue a one-shot, issuer-matched callback — the body is the same shape as a +`tokenCallbacks` entry, and it alters only the next matching token: + +```sh +curl -sS -X POST http://localhost:8080/_mock/scenarios \ + -H 'content-type: application/json' \ + -d '{"issuer":"default","subject":"alice","claims":{"role":"admin"}}' +# => {"scenarioId":"...","queueDepth":1} +``` + +Take the next recorded request for an endpoint (destructive FIFO long-poll): + +```sh +curl -sS -X POST http://localhost:8080/_mock/requests/take \ + -H 'content-type: application/json' \ + -d '{"issuer":"default","endpoint":"token","timeoutMs":2000}' +# => {"id":"...","method":"POST","path":"/default/token", ...} +# 404 on timeout is a clean miss, not an error. +``` + +Mint a token directly — byte-identical to a granted one, so it verifies against +`/{issuer}/jwks` and is accepted at `/userinfo`: + +```sh +curl -sS -X POST http://localhost:8080/_mock/mint \ + -H 'content-type: application/json' \ + -d '{"issuer":"default","subject":"alice","audience":["my-api"],"kind":"access_token"}' +# => {"token":"eyJ...","kid":"default","algorithm":"RS256", ...} +``` + +See [Control plane (`/_mock`)](../reference/control-plane.md) for every field. + +## What's different — check these + +Most tests migrate untouched, but review these before you run: + +- **Path-param routing.** Issuers are matched as a single route parameter at + `/{issuer}/...`, not by suffix. `http://localhost:8080/default/token` behaves + as before; assertions that depended on suffix-style URL construction should be + checked. +- **Single-segment issuers only.** An issuer id may not contain `/`. Nested, + Azure-style multi-segment issuers (e.g. `tenant/v2.0`) are a named, documented + parity gap and are unsupported by design. The `_mock` prefix is reserved. +- **Corrected upstream quirks may break brittle assertions.** mock-oidc fixes + defects rather than copying them: + - OAuth2 error codes keep correct case (e.g. `invalid_request`, not + lowercased variants). + - `form_post` without `state` is tolerated (no 500). + - No 302→400 status coercion on protocol errors. + - `at+jwt` access tokens self-verify (`userinfo` 200, `introspect` + `active:true`). + - The login page has no Google Fonts / Raleway network dependency (inline + CSS), so it renders offline. + +!!! warning "Intentionally not provided" + The in-process **embedded library API** (use the container + `/_mock` + instead) and **arbitrary raw-response injection** are deliberately absent. + Tests that reached into either will not port directly. + +## Related + +- [Parity with mock-oauth2-server](../explanation/parity.md) — the full + philosophy and the complete list of corrected and unreproduced behaviors. +- [Configuration](../reference/configuration.md) — every config key, env + alias, and precedence rule. diff --git a/docs/docs/how-to/run-behind-a-proxy-or-in-docker.md b/docs/docs/how-to/run-behind-a-proxy-or-in-docker.md new file mode 100644 index 0000000..b81ea82 --- /dev/null +++ b/docs/docs/how-to/run-behind-a-proxy-or-in-docker.md @@ -0,0 +1,86 @@ +--- +title: Run behind a proxy or in Docker +description: Make the advertised issuer identity match the address clients actually reach, behind a reverse proxy or in containers. +--- + +# Run behind a proxy or in Docker + +An OIDC client fetches discovery and JWKS from the advertised `issuer` URL and +rejects tokens whose `iss` does not match it, so the mock's advertised identity +must equal the address your clients actually reach. mock-oidc derives every +advertised URL — `issuer`, every `*_endpoint`, and `jwks_uri` — **per request** +from the `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-Port` +headers, falling back to the `Host` header. Point those at the external address +and the identity follows. For why identity is computed per request rather than +pinned at startup, see +[Issuers and advertised identity](../explanation/issuers-and-identity.md). + +## Behind a reverse proxy + +Terminate TLS (or route traffic) at your proxy and have it forward the standard +`X-Forwarded-*` headers to mock-oidc. The advertised URLs then reflect the +external address the proxy answers on, not the mock's internal listener. + +```sh +curl -sS http://localhost:8080/default/.well-known/openid-configuration \ + -H 'X-Forwarded-Proto: https' \ + -H 'X-Forwarded-Host: idp.example.com' +# => { +# => "issuer": "https://idp.example.com/default", +# => "authorization_endpoint": "https://idp.example.com/default/authorize", +# => "token_endpoint": "https://idp.example.com/default/token", +# => "jwks_uri": "https://idp.example.com/default/jwks", +# => ... +# => } +``` + +Every advertised URL — and the `iss` claim stamped into minted tokens — is now +`https://idp.example.com/default`. A real proxy (nginx, Traefik, Envoy) sets +these headers on the upstream request for you; the curl above just simulates one +hop so you can confirm the behavior. + +!!! tip "Non-standard external ports" + When your proxy answers on a port other than the scheme default, add + `X-Forwarded-Port` and that port appears in every advertised URL. For + example, `X-Forwarded-Port: 8443` yields + `https://idp.example.com:8443/default`. + +If mock-oidc itself terminates TLS instead of a proxy, see +[Serve over TLS](serve-over-tls.md) — over HTTPS every advertised URL is +`https` without any forwarded headers. + +## In Docker + +Container-backed tests have two callers that must agree on one issuer URL: the +**app under test** fetches discovery and JWKS from it, and the **browser** is +redirected to `authorize` on it. If they reach the mock by different names +(say, the app uses a Docker network alias the host browser cannot resolve), the +advertised `iss` will not match what one side used and verification fails. + +Give both sides the same reachable name. On Docker Desktop, expose the host +gateway and publish the port: + +```sh +docker run --rm \ + --add-host=host.docker.internal:host-gateway \ + -p 8080:8080 \ + ghcr.io/meigma/mock-oidc +``` + +Then configure the app under test **and** the browser to use the same issuer: + +``` +http://host.docker.internal:8080/default +``` + +Because the mock derives its identity from the incoming `Host`, a request to +`host.docker.internal:8080` advertises +`iss: http://host.docker.internal:8080/default` — exactly the address both the +in-container app (via the host gateway) and the host browser reach it on. + +!!! note "Port remapping just works" + Publishing to a different host port (for example `-p 9000:8080`) needs no + mock-oidc configuration. Have both sides use + `http://host.docker.internal:9000/default`; the mock sees `Host: + host.docker.internal:9000` and advertises that URL. The advertised `iss` + always follows the `Host` / `X-Forwarded-*` the mock is reached on. diff --git a/docs/docs/how-to/serve-over-tls.md b/docs/docs/how-to/serve-over-tls.md new file mode 100644 index 0000000..3755bdf --- /dev/null +++ b/docs/docs/how-to/serve-over-tls.md @@ -0,0 +1,94 @@ +--- +title: Serve over TLS +description: Run mock-oidc over HTTPS with a self-signed localhost certificate or your own cert/key pair. +--- + +# Serve over TLS + +Run mock-oidc over HTTPS so your client talks to `https://` endpoints. There are +two ways to terminate TLS in-process: a throwaway self-signed localhost +certificate, or your own certificate and key. Pick one below. + +Over HTTPS every advertised discovery URL — `issuer`, every `*_endpoint`, and +`jwks_uri` — is derived as `https://`, so a client that reads discovery follows +the correct scheme automatically. + +## Method 1: self-signed localhost certificate + +For local development, have mock-oidc generate a self-signed certificate in +process. Enable it through the JSON config `httpServer.ssl` object (an empty +object is enough; this matches upstream's `ssl: {}`): + +```sh +JSON_CONFIG='{"httpServer":{"ssl":{}}}' ./bin/mock-oidc serve +``` + +Then hit discovery over HTTPS. The certificate is untrusted, so pass `-k` +(`--insecure`) to skip verification: + +```sh +curl -k https://localhost:8080/default/.well-known/openid-configuration +# => { "issuer": "https://localhost:8080/default", +# "authorization_endpoint": "https://localhost:8080/default/authorize", +# ... +# "jwks_uri": "https://localhost:8080/default/jwks", ... } +``` + +Every URL in the body is `https://`. + +The generated certificate carries Subject Alternative Names `localhost`, +`127.0.0.1`, and `::1`, so it validates for those hosts once trusted. In your +client, either disable verification (the `curl -k` / `--insecure` equivalent) or +add the certificate to that client's trust store. + +!!! warning "Testing only" + The in-process self-signed certificate exists to unblock local HTTPS + testing. Do not add it to a shared or system trust store, and never rely on + it for anything beyond a test client. + +`JSON_CONFIG` is one of several config sources; you can equally point +`JSON_CONFIG_PATH` at a file or drop the `ssl` block into `./config.json`. See +[Configuration](../reference/configuration.md) for the full precedence and +schema. + +## Method 2: your own certificate and key + +To serve a certificate you already have, pass both `--tls-cert-file` and +`--tls-key-file`. They are required together — supplying one without the other +is an error. + +```sh +./bin/mock-oidc serve \ + --tls-cert-file ./tls/server.crt \ + --tls-key-file ./tls/server.key +``` + +```sh +curl https://localhost:8080/default/.well-known/openid-configuration +# => { "issuer": "https://localhost:8080/default", ... } +``` + +If your certificate is issued by a CA the client already trusts, no `-k` is +needed. Each flag also has a `MOCK_OIDC_*` environment variable +(`MOCK_OIDC_TLS_CERT_FILE`, `MOCK_OIDC_TLS_KEY_FILE`), which is convenient for +containers. + +## What TLS covers + +TLS terminates on the **API listener only** — the issuer endpoints under +`/{issuer}/` and the `/static/*` mounts. The following stay plain HTTP by +design and are unaffected by either method above: + +- `/metrics` on its dedicated listener (default `:9090`) +- the `/_mock` control plane + +Point your metrics scraper and control-plane tooling at `http://`, and your +OAuth2/OIDC client at `https://`. + +## Terminating TLS elsewhere + +If you would rather present HTTPS at a reverse proxy (or an ingress / sidecar) +and keep mock-oidc itself on plain HTTP, terminate at the proxy and forward +`X-Forwarded-Proto: https` so the advertised URLs still come out `https://`. +That setup is covered in +[Run behind a proxy or in Docker](run-behind-a-proxy-or-in-docker.md). diff --git a/docs/docs/how-to/shape-token-claims.md b/docs/docs/how-to/shape-token-claims.md new file mode 100644 index 0000000..11c0095 --- /dev/null +++ b/docs/docs/how-to/shape-token-claims.md @@ -0,0 +1,222 @@ +--- +title: Shape token claims +description: Control the subject, audience, claims, typ, and expiry that mock-oidc stamps into a minted token. +--- + +# Shape token claims + +mock-oidc gives you three ways to control what a token carries, from most ad-hoc +to most declarative: + +1. **Mint directly** — `POST /_mock/mint` builds one token from an explicit body. +2. **Override the next grant** — `POST /_mock/scenarios` rewrites the next token + an issuer mints, then reverts. +3. **Seed at boot** — `tokenCallbacks` in the JSON config customises an issuer + for the whole run. + +All three accept `subject`, `audience`, `claims`, `typ`, and `expirySeconds`. +Pick the one that matches how permanent the change should be. + +!!! warning "For testing only" + Every endpoint below mints signed tokens for arbitrary identities. The + `/_mock` control plane exists purely to script tests; never expose it to + real traffic. + +## Mint a one-off token directly + +The fastest path when you just need a token with exact claims and don't care +that it never went through a grant. `POST /_mock/mint`: + +```sh +curl -sS http://localhost:8080/_mock/mint \ + -H 'Content-Type: application/json' \ + -d '{ + "issuer": "default", + "subject": "alice@example.com", + "audience": ["my-api"], + "scope": ["openid", "profile"], + "clientId": "web-app", + "kind": "access_token", + "typ": "at+jwt", + "claims": {"role": "admin", "tenant": "acme"}, + "expirySeconds": 300 + }' +# => {"token":"eyJ...","kid":"default","algorithm":"RS256", +# "issuer":"http://localhost:8080/default","expiresAt":"...","claims":{...}} +``` + +`kind` selects `access_token` vs `id_token`. The returned token is +byte-identical to one from a real grant: it verifies against the issuer's JWKS +and is accepted at `/userinfo`. + +```sh +TOKEN=$(curl -sS http://localhost:8080/_mock/mint -H 'Content-Type: application/json' \ + -d '{"issuer":"default","subject":"alice","kind":"access_token","typ":"at+jwt"}' \ + | jq -r .token) +curl -sS http://localhost:8080/default/userinfo -H "Authorization: Bearer $TOKEN" +# => 200 {"sub":"alice",...} +``` + +Reserved-prefix issuers (for example `_mock`) are rejected. `mint` is a +self-contained path — you supply every field in the body; it does not run the +grant machinery. + +## Override the next token for an issuer + +When you want the token to come out of a real grant (so the client's +`authorize` / `token` round trip is exercised), enqueue a **one-shot scenario**. +It rewrites the next token that issuer mints, then reverts automatically. The +body is the same shape as a config `tokenCallbacks` entry. + +```sh +curl -sS http://localhost:8080/_mock/scenarios \ + -H 'Content-Type: application/json' \ + -d '{ + "issuer": "default", + "subject": "bob@example.com", + "audience": ["my-api"], + "claims": {"role": "editor"}, + "typ": "at+jwt", + "expirySeconds": 600 + }' +# => {"scenarioId":"...","queueDepth":1} +``` + +The next grant against `default` now carries those values: + +```sh +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=client_credentials -d client_id=web-app -d scope=openid +# => access_token with sub=bob@example.com, aud=[my-api], typ=at+jwt +``` + +The scenario is **single-use**: a second grant falls back to normal behaviour. +The `refresh_token` grant consults the same queue. Inspect or clear pending +scenarios: + +```sh +curl -sS http://localhost:8080/_mock/scenarios +# => {"queueDepth":1,"scenarios":[{"issuer":"default","kind":"default"}]} +curl -sS -X DELETE http://localhost:8080/_mock/scenarios +# => {"queueDepth":0} +``` + +### Template claims from request parameters + +To derive claims from the incoming request instead of hard-coding them, add +`requestMappings`. Each mapping tests one form `param`, and any `${key}` in a +string claim leaf is substituted with that request's form value. + +```sh +curl -sS http://localhost:8080/_mock/scenarios \ + -H 'Content-Type: application/json' \ + -d '{ + "issuer": "default", + "requestMappings": [ + { + "param": "scope", + "match": "*", + "typeHeader": "at+jwt", + "claims": {"sub": "${username}", "acr": "urn:level:${acr}"} + } + ] + }' +``` + +A grant that supplies those params gets the substituted claims: + +```sh +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=password -d username=carol -d password=x -d scope=openid -d acr=high +# => token with sub=carol, acr=urn:level:high, typ=at+jwt +``` + +Details that trip people up: + +- `match` decides when the mapping fires against the value of `param`: `"*"` + matches any value, an exact string matches equality, anything else is treated + as a regex. An invalid regex silently never matches (no panic). +- Only **string** claim leaves are templated. Unknown `${keys}` stay literal. +- `client_id` / `clientId` can never be shadowed by a same-named form param. +- `typeHeader` sets the JWS `typ` for the mapping. +- A `requestMapping` callback adds neither `tid` nor `azp` (unlike the default + callback). + +## Seed claims at boot + +For claims you always want on a given issuer, put a `tokenCallbacks` entry in the +JSON config rather than enqueuing at runtime. Each entry is the exact same shape +as a scenario body and applies for the whole run. + +```sh +JSON_CONFIG='{ + "tokenCallbacks": [ + { + "issuer": "default", + "subject": "service-account", + "audience": ["my-api"], + "claims": {"role": "admin"}, + "typ": "at+jwt" + } + ] +}' ./bin/mock-oidc serve +``` + +Every token that issuer mints now defaults to those values until a scenario +overrides them. See [Configuration](../reference/configuration.md) for config +precedence (`JSON_CONFIG` > `JSON_CONFIG_PATH` > `./config.json`). + +## Resolution priority + +When more than one mechanism could apply to a grant, the token content resolves +in this order: + +1. An enqueued one-shot **scenario** matching the issuer (head of the queue) — + single-use, highest priority. +2. A config **`tokenCallbacks`** entry — first match by issuer. +3. The built-in **default** callback — supplies `iss`/`iat`/`exp`/`jti`, a + random-UUID `sub` fallback, `tid`, and (on `authorization_code`) `azp`. + +`POST /_mock/mint` is outside this chain: it builds the token straight from its +own body. + +## Set the token type (`typ`) + +`typ` defaults to `JWT`. Set `at+jwt` for RFC 9068 access tokens — they still +self-verify against this server: + +```sh +TOKEN=$(curl -sS http://localhost:8080/_mock/mint -H 'Content-Type: application/json' \ + -d '{"issuer":"default","subject":"alice","kind":"access_token","typ":"at+jwt"}' \ + | jq -r .token) +curl -sS -o /dev/null -w '%{http_code}\n' \ + http://localhost:8080/default/userinfo -H "Authorization: Bearer $TOKEN" +# => 200 +``` + +!!! note + A genuinely foreign `typ` (for example `foo+jwt`) is minted fine but does + **not** self-verify here: `/userinfo` returns 401 and introspection reports + `active:false`. `JWT` and `at+jwt` do self-verify. + +## Control the audience (`aud`) + +`aud` does not follow the fields above uniformly: + +- An **`id_token`** `aud` is always `[client_id]` — `audience` in your body does + not change it. +- An **`access_token`** `aud` follows a four-step precedence: + 1. A configured / scenario `audience` (an explicit empty list `[]` counts and + wins). + 2. The token-exchange `audience` form param. + 3. Non-OIDC `scope` values (after stripping + `openid`/`profile`/`email`/`address`/`phone`/`offline_access`). + 4. `["default"]` as a last resort. + +So to pin an access token's `aud`, set `audience` in your mint / scenario / +callback body; to clear it, pass `"audience": []`. + +For every default claim and the full `aud` rules, see +[Tokens and claims](../reference/tokens-and-claims.md). For the exact +request/response DTO shapes of `/_mock/mint` and `/_mock/scenarios`, see the +[Control plane reference](../reference/control-plane.md). diff --git a/docs/docs/how-to/simulate-expiry-and-time.md b/docs/docs/how-to/simulate-expiry-and-time.md new file mode 100644 index 0000000..c12b658 --- /dev/null +++ b/docs/docs/how-to/simulate-expiry-and-time.md @@ -0,0 +1,175 @@ +--- +title: Simulate expiry and time +description: Use the /_mock clock to freeze, advance, and reset server time so time-dependent token behavior is deterministic. +--- + +# Simulate expiry and time + +The server runs on a single logical clock that drives **both** token issuance and +token verification. Freeze it and every new token's `iat`/`nbf`/`exp` is pinned to +that instant; advance it and a token minted earlier slides past its `exp` and starts +failing verification. That one clock is what makes expiry tests deterministic — no +`sleep` calls, no flaky wall-clock races. + +All clock operations live on the `/_mock` control plane, co-located on the `:8080` +API listener by default. If you have configured a control token, add +`-H "X-Mock-Control-Token: "` to every request below. + +!!! warning "FOR TESTING ONLY" + The `/_mock` clock rewrites the server's notion of time for _all_ issuers and + _all_ callers at once. Never expose it outside a test environment. + +## Read the current clock + +```bash +curl -s http://localhost:8080/_mock/clock +# => {"frozen":false,"now":"2026-07-03T14:20:05Z"} +``` + +`frozen:false` means the clock tracks real wall time; `now` is the instant the +server would stamp into a token issued right now. + +## Freeze the clock at an instant + +Send `frozen:true` with the `instant` you want (RFC 3339). `instant` is required +whenever `frozen` is true. + +```bash +curl -s -X PUT http://localhost:8080/_mock/clock \ + -H 'Content-Type: application/json' \ + -d '{"frozen":true,"instant":"2030-01-01T00:00:00Z"}' +# => {"frozen":true,"now":"2030-01-01T00:00:00Z"} +``` + +Now mint a token and observe that its timestamps are pinned to the frozen instant — +not to real time: + +```bash +curl -s -X POST http://localhost:8080/_mock/mint \ + -H 'Content-Type: application/json' \ + -d '{"issuer":"default","subject":"alice","kind":"access_token"}' +# => { +# "token": "eyJ...", +# "kid": "default", +# "algorithm": "RS256", +# "issuer": "http://localhost:8080/default", +# "expiresAt": "2030-01-01T01:00:00Z", +# "claims": { "sub":"alice", "iat":1893456000, "nbf":1893456000, "exp":1893459600, ... } +# } +``` + +`iat` and `nbf` are pinned to `2030-01-01T00:00:00Z` and, with the default 3600s +lifetime, `exp` lands exactly one hour later. Everything you issue while frozen +shares that instant, so timestamps across a whole test are reproducible. + +## Advance time to expire a live token + +`POST /_mock/clock/advance` freezes the clock (if it is not already) and moves it +forward by a Go duration string (`90s`, `5m`, `2h`, `1h1m`). Use it to push an +already-issued token past its `exp` without waiting. + +Mint (or grant) a token with the default one-hour lifetime, then jump two hours +ahead: + +```bash +# Capture a live token +TOKEN=$(curl -s -X POST http://localhost:8080/_mock/mint \ + -H 'Content-Type: application/json' \ + -d '{"issuer":"default","subject":"alice","kind":"access_token"}' | jq -r .token) + +# Move the clock past its exp +curl -s -X POST http://localhost:8080/_mock/clock/advance \ + -H 'Content-Type: application/json' \ + -d '{"duration":"2h"}' +# => {"frozen":true,"now":"...T16:20:05Z"} +``` + +Because the same clock now governs verification, that token reads as expired +everywhere: + +```bash +# Introspection flips to inactive (still HTTP 200, not an error) +curl -s -X POST http://localhost:8080/default/introspect \ + -u any:any \ + --data-urlencode "token=$TOKEN" +# => {"active":false} + +# userinfo rejects it +curl -s -o /dev/null -w '%{http_code}\n' \ + -H "Authorization: Bearer $TOKEN" \ + http://localhost:8080/default/userinfo +# => 401 +``` + +The 401 carries `WWW-Authenticate: Bearer error="invalid_token"` and a body of +`{"error":"invalid_token"}`. + +!!! note + `introspect` requires _any_ non-empty `Authorization` header — the `-u any:any` + above just satisfies that; the credentials themselves are never validated. See + the [control-plane reference](../reference/control-plane.md) for the full + request contracts. + +## Unfreeze the clock + +Return to real wall time by clearing `frozen`. No `instant` is needed. + +```bash +curl -s -X PUT http://localhost:8080/_mock/clock \ + -H 'Content-Type: application/json' \ + -d '{"frozen":false}' +# => {"frozen":false,"now":"2026-07-03T14:20:07Z"} +``` + +## Reset unfreezes as part of cleanup + +If a test may leave the clock frozen, you do not have to unfreeze it explicitly. +`POST /_mock/reset` clears the scenario queue and the request log **and** unfreezes +the clock in one call — a good teardown hook. Signing keys are preserved, so any +JWKS a client already fetched keeps verifying. + +```bash +curl -s -X POST http://localhost:8080/_mock/reset +# => clock unfrozen, scenario queue and request log cleared, signing keys kept +``` + +## Set a token's own lifetime with expirySeconds + +Advancing the clock moves _everyone_ forward. When you instead want a specific token +to be short-lived while global time keeps running, set its own lifetime with +`expirySeconds`. The field is accepted in the same shape everywhere a token is +produced: + +- `POST /_mock/mint` — `{"issuer":"default","subject":"alice","kind":"access_token","expirySeconds":60}` +- `POST /_mock/scenarios` — the one-shot callback body takes `expirySeconds` +- config `tokenCallbacks[]` — a pre-seeded callback entry takes `expirySeconds` + +```bash +curl -s -X POST http://localhost:8080/_mock/mint \ + -H 'Content-Type: application/json' \ + -d '{"issuer":"default","subject":"alice","kind":"access_token","expirySeconds":60}' +# => expiresAt is now + 60s instead of the default now + 3600s +``` + +Combine the two for fully deterministic expiry: freeze, mint with a short +`expirySeconds`, then `advance` just past it. See +[Tokens and claims](../reference/tokens-and-claims.md) for how `iat`/`nbf`/`exp` are +derived. + +## Freeze at boot with systemTime + +To start the server already frozen — for example, to pin a golden test fixture — set +`tokenProvider.systemTime` (RFC 3339) in the JSON config. The clock boots frozen at +that instant; you can still advance or unfreeze it later via `/_mock`. + +```json +{ + "tokenProvider": { + "systemTime": "2030-01-01T00:00:00Z" + } +} +``` + +Load it with any of the config sources (`JSON_CONFIG`, `JSON_CONFIG_PATH`, or +`./config.json`). Every token minted before you touch the clock will carry the +`2030-01-01T00:00:00Z` timestamps shown above. diff --git a/docs/docs/how-to/use-multiple-issuers.md b/docs/docs/how-to/use-multiple-issuers.md new file mode 100644 index 0000000..214a64c --- /dev/null +++ b/docs/docs/how-to/use-multiple-issuers.md @@ -0,0 +1,155 @@ +--- +title: Use multiple issuers +description: Make one running mock-oidc server behave as several independent identity providers. +--- + +# Use multiple issuers + +One server is many identity providers. Every namespace under a single path +segment — `http://localhost:8080/{issuer}/...` — is an independent issuer with +its own signing key and its own discovery document. There is no registration +step: an issuer exists the moment you touch it. Use this to test tenant +isolation, multi-IdP federation, or a token-audience matrix from a single +container. + +All examples below use base `http://localhost:8080` and two issuers, `acme` and +`beta`. + +## Materialize an issuer by touching it + +Hit any issuer-scoped endpoint with a name you choose. The issuer springs into +existence with a lazily generated signing key: + +```bash +curl -s http://localhost:8080/acme/.well-known/openid-configuration +# => "issuer":"http://localhost:8080/acme", +# "authorization_endpoint":"http://localhost:8080/acme/authorize", +# "token_endpoint":"http://localhost:8080/acme/token", ... (all under /acme/) +``` + +Do the same for `beta` — no config, no restart: + +```bash +curl -s http://localhost:8080/beta/.well-known/openid-configuration +# => "issuer":"http://localhost:8080/beta", ... (all under /beta/) +``` + +Each issuer's signing key uses `kid == `. Fetch the two JWKS and the +key sets are distinct: + +```bash +curl -s http://localhost:8080/acme/jwks +# => {"keys":[{"kty":"RSA","kid":"acme","use":"sig","alg":"RS256", ...}]} + +curl -s http://localhost:8080/beta/jwks +# => {"keys":[{"kty":"RSA","kid":"beta","use":"sig","alg":"RS256", ...}]} +``` + +!!! note + JWKS exposes public key members only (no `d`, `p`, `q`, ...). The `kid` + always equals the issuer id, so a verifier can key its trust off the issuer + name alone. + +## Prove keys are isolated + +Each issuer is a self-contained trust domain: a token signed by one is +worthless to another. Mint an access token under `acme` (see the +[control plane reference](../reference/control-plane.md) for the full +`/_mock/mint` body): + +```bash +ACME_TOKEN=$(curl -s http://localhost:8080/_mock/mint \ + -H 'content-type: application/json' \ + -d '{"issuer":"acme","subject":"alice","audience":["acme-api"],"clientId":"web","kind":"access_token"}' \ + | jq -r .token) +``` + +It verifies at `acme`'s userinfo: + +```bash +curl -s http://localhost:8080/acme/userinfo -H "Authorization: Bearer $ACME_TOKEN" +# => 200 {"sub":"alice","aud":["acme-api"], ...} +``` + +The **same** token is rejected by `beta`, whose JWKS advertises only `kid=beta`: + +```bash +curl -si http://localhost:8080/beta/userinfo -H "Authorization: Bearer $ACME_TOKEN" +# => HTTP/1.1 401 Unauthorized +# WWW-Authenticate: Bearer error="invalid_token" +# {"error":"invalid_token"} +``` + +A minted token is byte-identical to a granted one, so the same isolation holds +for tokens obtained through `/{issuer}/token` or the authorization-code flow: a +token minted or granted under one issuer will not pass `userinfo` or +`introspect` under another. + +## Pre-seed issuer-specific behavior + +To give each issuer its own default claims, audience, or `typ` before any +traffic arrives, use config `tokenCallbacks`. Each entry is keyed by `issuer` +and applies to grants for that issuer only; the first matching entry wins. + +```json +{ + "tokenCallbacks": [ + { + "issuer": "acme", + "audience": ["acme-api"], + "claims": { "tenant": "acme", "roles": ["admin"] } + }, + { + "issuer": "beta", + "audience": ["beta-api"], + "claims": { "tenant": "beta" } + } + ] +} +``` + +Start the server with that file (for example `JSON_CONFIG_PATH=./config.json`; +see the [configuration reference](../reference/configuration.md) for the full +shape and precedence), then a plain grant against each issuer carries its own +seeded claims: + +```bash +ACME_AT=$(curl -s http://localhost:8080/acme/token \ + -d grant_type=client_credentials -d client_id=web | jq -r .access_token) + +curl -s http://localhost:8080/acme/userinfo -H "Authorization: Bearer $ACME_AT" +# => {"sub":"web","aud":["acme-api"],"tenant":"acme","roles":["admin"], ...} +``` + +A `beta` grant instead reports `"tenant":"beta"` and `aud:["beta-api"]`. + +!!! tip + A `tokenCallbacks` entry is the same shape as a `/_mock/scenarios` body, so + you can seed a durable default in config and still enqueue a one-shot + override at runtime for the same issuer. Issuers that are **not** named in + the config still work — they materialize on first touch with the built-in + default callback. + +## Constraints you will hit + +**Issuer ids are a single path segment.** No `/` is allowed. A request to +`/tenants/acme/authorize` treats `tenants` as the issuer and `acme/authorize` +as a path beneath it — it does not create a nested `tenants/acme` issuer. +Nested, multi-segment (Azure-style) issuers are a deliberate, documented parity +gap; see [Issuers and advertised identity](../explanation/issuers-and-identity.md) +for why and what to do instead. + +**`_mock` is reserved.** It is the control plane, so you cannot use it as an +issuer id — issuer-scoped routes under that prefix return `404` with the OAuth2 +error `not_found`, and `/_mock/mint` rejects a reserved-prefix `issuer`: + +```bash +curl -si http://localhost:8080/_mock/.well-known/openid-configuration +# => HTTP/1.1 404 Not Found +# {"error":"not_found", ...} +``` + +!!! warning "FOR TESTING ONLY" + Materialize-on-touch means any string becomes a trusted issuer with a valid + signing key. That is exactly what makes this useful for tests and exactly + why it must never front production traffic. diff --git a/docs/docs/how-to/verify-released-artifacts.md b/docs/docs/how-to/verify-released-artifacts.md new file mode 100644 index 0000000..67c4581 --- /dev/null +++ b/docs/docs/how-to/verify-released-artifacts.md @@ -0,0 +1,99 @@ +--- +title: Verify released artifacts +description: Verify the SLSA provenance and cosign signature of released mock-oidc images and binaries before use. +--- + +# Verify released artifacts + +Every `mock-oidc` release publishes a signed multi-arch container image and +signed binaries, each carrying a GitHub-native SLSA provenance attestation. +Verify them before use to prove they were built and signed by this repository's +release pipeline — not tampered with or rebuilt by a third party. + +You need the [`gh`](https://cli.github.com/) CLI (authenticated: `gh auth login`) +and [`cosign`](https://github.com/sigstore/cosign). Throughout, replace the +version and platform placeholders with the real release values: + +```sh +VERSION=X.Y.Z # e.g. 1.4.0 — the released tag, without the leading "v" +OS= # linux | darwin +ARCH= # amd64 | arm64 +``` + +## Verify the container image's provenance + +Confirm the image manifest was built by this repository's release workflow: + +```sh +gh attestation verify "oci://ghcr.io/meigma/mock-oidc:v${VERSION}" \ + --repo meigma/mock-oidc +# => Loaded digest sha256:... for oci://ghcr.io/meigma/mock-oidc:vX.Y.Z +# => ✓ Verification succeeded! +``` + +A pass proves the image manifest carries a valid SLSA provenance attestation +issued by this repository's release pipeline. + +## Verify the image's cosign signature + +The image is also signed with **keyless cosign**. Verify that the signer is the +release workflow's ephemeral Sigstore identity: + +```sh +cosign verify "ghcr.io/meigma/mock-oidc:v${VERSION}" \ + --certificate-identity-regexp '^https://github.com/meigma/mock-oidc/.github/workflows/release.yml@.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com +# => Verification for ghcr.io/meigma/mock-oidc:vX.Y.Z -- +# => The following checks were performed on each of these signatures: +# => - The cosign claims were validated +# => - Existence of the claims in the transparency log was verified offline +# => - The code-signing certificate was verified using trusted certificate authority certificates +``` + +The `--certificate-identity-regexp` pins the Fulcio certificate to +`release.yml` in this repository, and `--certificate-oidc-issuer` pins it to the +GitHub Actions OIDC issuer. Both must match, so a signature minted by any other +workflow or repository is rejected. + +!!! tip "Pin, don't trust, the tag" + The image tag `vX.Y.Z` is convenient, but a tag can be re-pointed. To pin + the exact artifact you verified, resolve and use its digest + (`ghcr.io/meigma/mock-oidc@sha256:...`) — both commands above accept a + `name@sha256:...` reference in place of the tag. + +## Verify a downloaded binary + +For a binary downloaded from the GitHub release, verify its provenance against +the dedicated attestation workflow: + +```sh +gh attestation verify "./mock-oidc_${VERSION}_${OS}_${ARCH}" \ + --repo meigma/mock-oidc \ + --signer-workflow meigma/mock-oidc/.github/workflows/attest.yml +# => Loaded digest sha256:... for file://./mock-oidc_X.Y.Z__ +# => ✓ Verification succeeded! +``` + +`--signer-workflow` requires the provenance to have been issued by `attest.yml`, +the reusable workflow that attests the release's binary checksums. A pass proves +the file on disk is byte-for-byte the artifact that workflow attested. + +!!! note "Offline verification" + `gh attestation verify` can run against a bundle fetched earlier. Download + the attestation once with `gh attestation download`, then pass + `--bundle ` to verify without network access on subsequent runs. + +## What to do when verification fails + +A non-zero exit or a message other than `Verification succeeded!` means the +artifact does not match the expected identity. Do not use it. Re-check that: + +- the `VERSION`, `OS`, and `ARCH` values match a real published release; +- for the image, you are verifying the digest you actually pulled (a moved tag + can point at a different manifest); +- your `gh` and `cosign` versions are current, so they trust the right Sigstore + and GitHub attestation roots. + +To understand how the provenance chain is built — the melange/apko image build, +the GoReleaser binaries, keyless cosign signing, and the isolated `attest.yml` +workflow — see [Architecture and distribution](../explanation/architecture-and-distribution.md). diff --git a/docs/docs/index.md b/docs/docs/index.md index 5bd4ef6..810590b 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -1,72 +1,81 @@ --- -title: mock-oidc Docs +title: Home +description: Standalone, container-first mock OIDC/OAuth2 authorization server for testing. slug: / -description: Standalone mock OIDC/OAuth2 authorization server for testing. --- # mock-oidc -`mock-oidc` is a standalone, container-first **mock OIDC/OAuth2 authorization -server for testing**. It issues real, cryptographically-verifiable tokens for -arbitrary identities so a test suite can exercise a full sign-in flow against an -unmodified OAuth2/OIDC client — no real identity provider required. It is a Go -reimplementation of [navikt/mock-oauth2-server](https://github.com/navikt/mock-oauth2-server) -built on chi + Huma, with a hexagonal architecture and a strong supply-chain -baseline. +`mock-oidc` is a standalone, container-first mock OIDC/OAuth2 authorization +server **for testing only**. It mints real, cryptographically-signed tokens for +arbitrary identities, so a test suite can drive a full sign-in against an +unmodified OAuth2/OIDC client with no real identity provider. It is a Go +reimplementation of [navikt/mock-oauth2-server](https://github.com/navikt/mock-oauth2-server), +is DB-less, and boots with zero configuration. !!! warning "For testing only" - mock-oidc mints signed tokens for any identity on request. It must never - front production traffic; the server logs this positioning banner on every - startup. + mock-oidc signs a token for any identity on request and never validates + client secrets. It must never front production traffic; the server logs a + "FOR TESTING ONLY" banner on every startup. -## What it does +## 30 seconds to a token -Point an OAuth2/OIDC client at a running `mock-oidc` and it behaves like a real -authorization server: it publishes discovery and a JWKS and mints real, signed -tokens for any identity, all namespaced under an **issuer**. With zero -configuration a single `default` issuer is served at -`http://localhost:8080/default`, exposing discovery, `authorize`, `token`, -`jwks`, `userinfo`, `introspect`, `revoke`, and `endsession`. A test-time -control plane is mounted at `/_mock`. - -## Quick start - -The server is DB-less and needs no configuration. Run the published container: +The server needs no configuration. Run the published container, read discovery +for the zero-config `default` issuer, and mint an access token: ```sh docker run --rm -p 8080:8080 ghcr.io/meigma/mock-oidc -curl -sS localhost:8080/default/.well-known/openid-configuration -# => { "issuer": "http://localhost:8080/default", ... } -``` -Or build and run from source: +# Discovery for the "default" issuer (materializes on first touch) +curl -sS http://localhost:8080/default/.well-known/openid-configuration +# => {"issuer":"http://localhost:8080/default", ...} -```sh -moon run root:build # or: go build -o bin/mock-oidc ./cmd/mock-oidc -./bin/mock-oidc serve # serve is the default subcommand; listens on :8080 +# client_credentials grant — client secrets are never validated +curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=client_credentials \ + -d client_id=test-client \ + -d scope=api +# => {"token_type":"Bearer","access_token":"eyJ...","expires_in":3600, ...} ``` -See the [README](https://github.com/meigma/mock-oidc#readme) for the full -configuration reference, including TLS (`httpServer.ssl`), running behind a -proxy / `host.docker.internal`, the named nested-issuer parity gap, and -artifact verification. - -## API reference - -The [API Reference](api.md) is generated from the OpenAPI specification. A -running server also serves interactive docs at `/docs` and the live spec at -`/openapi.yaml`. - -## Operating notes - -- Liveness: `GET /isalive` (upstream-parity alias) and `GET /healthz` -- Readiness: `GET /readyz` (reports named per-check results; the server is - DB-less, so it is unconditionally ready) -- Metrics: `GET /metrics` on a dedicated listener (`--metrics-addr`, default `:9090`) -- Configuration is via flags or `MOCK_OIDC_*` environment variables; the - server boots with zero configuration. +## Find your way + +This site follows the [Diátaxis](https://diataxis.fr/) framework. Pick the +section that matches what you need right now. + +- **Learning the tool** — Start with the tutorial, + [Your first mock sign-in](tutorials/first-mock-sign-in.md), a guided + end-to-end run. + +- **Getting a specific task done** — The [how-to guides](how-to/get-tokens-for-every-grant.md) + are goal-oriented recipes: + [get tokens for every grant](how-to/get-tokens-for-every-grant.md), + [drive the authorization-code flow](how-to/drive-the-authorization-code-flow.md), + [shape token claims](how-to/shape-token-claims.md), + [simulate expiry and time](how-to/simulate-expiry-and-time.md), + [capture and assert requests](how-to/capture-and-assert-requests.md), + [use multiple issuers](how-to/use-multiple-issuers.md), + [serve over TLS](how-to/serve-over-tls.md), + [run behind a proxy or in Docker](how-to/run-behind-a-proxy-or-in-docker.md), + [lock down the control plane](how-to/lock-down-the-control-plane.md), + [migrate from mock-oauth2-server](how-to/migrate-from-mock-oauth2-server.md), + and [verify released artifacts](how-to/verify-released-artifacts.md). + +- **Looking something up** — The reference pages describe the software exactly: + [Configuration](reference/configuration.md), + [Tokens and claims](reference/tokens-and-claims.md), + [Control plane (`/_mock`)](reference/control-plane.md), + [CLI](reference/cli.md), + [Observability](reference/observability.md), and the + [API Reference](api.md). + +- **Understanding why** — The explanation pages cover design and rationale: + [the security model](explanation/security-model.md), + [issuers and advertised identity](explanation/issuers-and-identity.md), + [parity with mock-oauth2-server](explanation/parity.md), and + [architecture and distribution](explanation/architecture-and-distribution.md). ## Support and security -- Issues and contributions: see [CONTRIBUTING.md](https://github.com/meigma/mock-oidc/blob/master/CONTRIBUTING.md). -- Security reports: see [SECURITY.md](https://github.com/meigma/mock-oidc/blob/master/SECURITY.md). +- Contributions and issues: [CONTRIBUTING.md](https://github.com/meigma/mock-oidc/blob/master/CONTRIBUTING.md) +- Security reports: [SECURITY.md](https://github.com/meigma/mock-oidc/blob/master/SECURITY.md) diff --git a/docs/docs/reference/cli.md b/docs/docs/reference/cli.md new file mode 100644 index 0000000..3be2301 --- /dev/null +++ b/docs/docs/reference/cli.md @@ -0,0 +1,54 @@ +--- +title: CLI +description: Reference for the mock-oidc command-line interface and its subcommands. +--- + +# CLI + +The binary is `mock-oidc`. It exposes three subcommands. When invoked with no +subcommand, the binary runs `serve`. + +## Subcommands + +| Subcommand | Description | +| --- | --- | +| `serve` | Runs the HTTP server. This is the default subcommand; the bare binary runs it. Listens on `:8080` by default. | +| `version` | Prints the version, commit, and build date, then exits. | +| `openapi` | Writes the OpenAPI 3.0.3 specification to standard output, or to a file when `--output`/`-o` is given, then exits. | + +## `serve` + +Runs the OAuth2/OIDC server and the `/_mock` control plane. Equivalent to +running the binary with no subcommand. + +```bash +./bin/mock-oidc serve +``` + +The flags accepted by `serve` (and their `MOCK_OIDC_*` environment-variable +equivalents) are listed in [Configuration](configuration.md). + +## `version` + +Prints build metadata and exits. The output contains the version, the commit +the binary was built from, and the build date. + +```bash +./bin/mock-oidc version +``` + +## `openapi` + +Writes the OpenAPI 3.0.3 document describing the server's HTTP API. With no +flag the document is written to standard output. + +| Flag | Alias | Description | +| --- | --- | --- | +| `--output` | `-o` | Path to write the specification to instead of standard output. | + +```bash +./bin/mock-oidc openapi -o docs/docs/openapi.yaml +``` + +The document produced by this subcommand is the source for the +[API Reference](../api.md). diff --git a/docs/docs/reference/configuration.md b/docs/docs/reference/configuration.md new file mode 100644 index 0000000..9a62f13 --- /dev/null +++ b/docs/docs/reference/configuration.md @@ -0,0 +1,213 @@ +--- +title: Configuration +description: The complete configuration reference — flags, environment variables, upstream aliases, JSON config, and CORS defaults. +--- + +# Configuration + +This page enumerates every configuration input `mock-oidc` reads: command-line +flags, their `MOCK_OIDC_*` environment equivalents, the unprefixed upstream +environment aliases, the JSON configuration file, and the CORS defaults. It +describes what each input is and its default value. For the rationale behind the +defaults, see [The security model](../explanation/security-model.md); for the +`serve` invocation itself, see the [CLI reference](cli.md). + +## Precedence + +Each flag has three possible sources. The first that is set wins: + +1. **Flag** — the command-line flag (for example `--addr`). +2. **Environment variable** — the flag name uppercased, `MOCK_OIDC_`-prefixed, + with dashes converted to underscores (for example `MOCK_OIDC_ADDR`). +3. **Default** — the built-in default listed in the table below. + +Every flag has a corresponding `MOCK_OIDC_*` environment variable formed by this +rule. There are no exceptions. Boolean flags accept the standard string values +(`true`/`false`); duration flags accept Go duration strings (for example `5s`, +`120s`, `1m`). + +## Flags + +| Flag | Env var | Default | Description | +| --- | --- | --- | --- | +| `--addr` | `MOCK_OIDC_ADDR` | *(empty)* | API listener `host:port`, serving the OIDC/OAuth2 endpoints, `/_mock`, and static assets. When set it overrides `--server-hostname`/`--server-port`; when empty the listen address is composed from them (see [Listen address](#listen-address)). | +| `--server-hostname` | `MOCK_OIDC_SERVER_HOSTNAME` | *(empty)* | Host portion of the listen address, composed with `--server-port` when `--addr` is empty. | +| `--server-port` | `MOCK_OIDC_SERVER_PORT` | `8080` | Port portion of the listen address, composed with `--server-hostname` when `--addr` is empty. | +| `--metrics-addr` | `MOCK_OIDC_METRICS_ADDR` | `:9090` | Dedicated Prometheus `/metrics` listener. When empty, `/metrics` is served on the `--addr` listener instead. | +| `--log-level` | `MOCK_OIDC_LOG_LEVEL` | `info` | Log level: one of `debug`, `info`, `warn`, `error`. | +| `--log-format` | `MOCK_OIDC_LOG_FORMAT` | `json` | Log output format: `json` or `text`. | +| `--read-timeout` | `MOCK_OIDC_READ_TIMEOUT` | `5s` | Maximum duration for reading an entire request, including the body. | +| `--read-header-timeout` | `MOCK_OIDC_READ_HEADER_TIMEOUT` | `5s` | Maximum duration for reading request headers. | +| `--write-timeout` | `MOCK_OIDC_WRITE_TIMEOUT` | `10s` | Maximum duration before timing out writes of the response. | +| `--idle-timeout` | `MOCK_OIDC_IDLE_TIMEOUT` | `120s` | Maximum time to wait for the next request on a keep-alive connection. | +| `--request-timeout` | `MOCK_OIDC_REQUEST_TIMEOUT` | `15s` | Per-request handler timeout. | +| `--shutdown-grace` | `MOCK_OIDC_SHUTDOWN_GRACE` | `15s` | Grace period for in-flight requests to complete during graceful shutdown. | +| `--cors-allowed-origins` | `MOCK_OIDC_CORS_ALLOWED_ORIGINS` | *(empty)* | Comma-separated origin allowlist. When empty, any request `Origin` is reflected. See [CORS](#cors). | +| `--trusted-proxy-header` | `MOCK_OIDC_TRUSTED_PROXY_HEADER` | *(empty)* | Header to read the client IP from (for example `X-Real-IP`). When empty, the TCP peer address is trusted. | +| `--tls-cert-file` | `MOCK_OIDC_TLS_CERT_FILE` | *(empty)* | Path to a TLS certificate file. Must be paired with `--tls-key-file`. | +| `--tls-key-file` | `MOCK_OIDC_TLS_KEY_FILE` | *(empty)* | Path to a TLS private key file. Must be paired with `--tls-cert-file`. | +| `--control-enabled` | `MOCK_OIDC_CONTROL_ENABLED` | `true` | Whether the `/_mock` control plane is mounted. When `false`, `/_mock` returns 404. | +| `--control-addr` | `MOCK_OIDC_CONTROL_ADDR` | *(empty)* | Dedicated `host:port` listener for the `/_mock` control plane. When empty, `/_mock` is co-located on the `--addr` listener; a dedicated listener carries no request-recording middleware and must differ from `--addr` and `--metrics-addr`. See [Lock down the control plane](../how-to/lock-down-the-control-plane.md). | +| `--control-token` | `MOCK_OIDC_CONTROL_TOKEN` | *(empty)* | Bearer token required in the `X-Mock-Control-Token` header on `/_mock`. When empty, the control plane is unauthenticated. | +| `--rate-limit-enabled` | `MOCK_OIDC_RATE_LIMIT_ENABLED` | `false` | Whether request rate limiting is applied. Off by default so test traffic is never throttled. | +| `--rate-limit-rps` | `MOCK_OIDC_RATE_LIMIT_RPS` | `10` | Sustained requests per second when rate limiting is enabled. | +| `--rate-limit-burst` | `MOCK_OIDC_RATE_LIMIT_BURST` | `20` | Burst allowance when rate limiting is enabled. | +| `--tracing-enabled` | `MOCK_OIDC_TRACING_ENABLED` | `false` | Whether OTLP trace export is enabled. Exporter and sampler are configured via the standard `OTEL_*` variables. | + +!!! note + TLS applies to the `--addr` listener only. The `--tls-*` flags and the JSON + `httpServer.ssl` config are two independent ways to enable it; see + [Serve over TLS](../how-to/serve-over-tls.md). The `--metrics-addr` listener + and `/_mock` stay plain HTTP. + +Behavior driven by these flags is documented in the how-to guides: +[running behind a proxy or in Docker](../how-to/run-behind-a-proxy-or-in-docker.md) +covers `--trusted-proxy-header`; +[locking down the control plane](../how-to/lock-down-the-control-plane.md) covers +`--control-enabled` and `--control-token`; and the +[observability reference](observability.md) covers the `OTEL_*` variables read +when `--tracing-enabled` is set. + +## Upstream environment aliases + +For drop-in compatibility with `mock-oauth2-server`, the following unprefixed +environment variables are also read. They exist alongside the `MOCK_OIDC_*` +variables above. + +| Variable | Purpose | +| --- | --- | +| `SERVER_HOSTNAME` | Host portion of the listen address (composed with `SERVER_PORT`). | +| `SERVER_PORT` | Port portion of the listen address (composed with `SERVER_HOSTNAME`). | +| `PORT` | Listen port, used when `SERVER_HOSTNAME`/`SERVER_PORT` are not set. | +| `JSON_CONFIG` | Inline JSON configuration string. See [JSON configuration](#json-configuration). | +| `JSON_CONFIG_PATH` | Path to a JSON configuration file. | +| `LOG_LEVEL` | Log level, accepted as an alias for `--log-level`. | +| `LOGBACK_CONFIG` | Accepted for compatibility and ignored (no-op). | + +### Listen address + +The listen address is resolved from the first source that is set, in this order: + +1. `--addr` (or `MOCK_OIDC_ADDR`) +2. `--server-hostname` + `--server-port` (fed by `SERVER_HOSTNAME` and `SERVER_PORT` > `PORT`) +3. `:8080` + +## JSON configuration + +A JSON configuration document pre-seeds issuers, keys, the clock, and TLS. The +configuration source is resolved from the first that is present: + +1. `JSON_CONFIG` — an inline JSON string. +2. `JSON_CONFIG_PATH` — a path to a JSON file. +3. `./config.json` — a file in the working directory. +4. Built-in defaults — used when none of the above is present. + +The document is compatible with the upstream `mock-oauth2-server` config format. +**Unknown keys are ignored.** + +### Shape + +```json +{ + "interactiveLogin": false, + "rotateRefreshToken": false, + "staticAssetsPath": "/srv/static", + "tokenProvider": { + "systemTime": "2026-07-03T00:00:00Z", + "keyProvider": { + "algorithm": "RS256", + "initialKeys": [] + } + }, + "tokenCallbacks": [ + { + "issuer": "default", + "subject": "alice", + "audience": ["my-api"], + "claims": { "roles": ["admin"] }, + "typ": "JWT", + "expirySeconds": 3600, + "requestMappings": [ + { + "param": "client_id", + "match": "*", + "typeHeader": "JWT", + "claims": { "tenant": "acme" } + } + ] + } + ], + "httpServer": { "ssl": {} } +} +``` + +### Fields + +`interactiveLogin` (boolean, default `false`) +: When `true`, `GET /{issuer}/authorize` renders the login page instead of + auto-issuing a code. See + [Drive the authorization-code flow](../how-to/drive-the-authorization-code-flow.md). + +`rotateRefreshToken` (boolean, default `false`) +: When `true`, the `refresh_token` grant issues a new refresh token on each + redemption. When `false`, the same refresh token keeps redeeming. + +`staticAssetsPath` (string) +: Filesystem directory mounted at `/static/*` on the API listener. Path + traversal and symlink escapes are rejected with 404; there is no directory + index. + +`tokenProvider.systemTime` (string, RFC 3339) +: Freezes the server clock at this instant. The single clock drives both token + issuance and verification. See + [Simulate expiry and time](../how-to/simulate-expiry-and-time.md). + +`tokenProvider.keyProvider.algorithm` (string) +: Default signing algorithm for lazily generated issuer keys. One of `RS256`, + `RS384`, `RS512`, `PS256`, `PS384`, `PS512`, `ES256`, `ES384`. Defaults to + `RS256`. + +`tokenProvider.keyProvider.initialKeys` (array of JWK) +: Signing keys to preload rather than generating lazily. + +`tokenCallbacks` (array) +: Per-issuer callbacks that pre-seed those issuers and shape their minted + tokens. Each entry has the fields `issuer`, `subject`, `audience`, + `claims`, `typ`, `expirySeconds`, and `requestMappings`. Each `requestMappings` + entry has `param`, `match` (`"*"`, an exact string, or a regular + expression), `typeHeader`, and `claims`. A `tokenCallbacks` entry is the + **same shape as a `POST /_mock/scenarios` body**; see the + [control-plane reference](control-plane.md) and + [Shape token claims](../how-to/shape-token-claims.md). + +`httpServer` (string or object) +: A string form is accepted for upstream compatibility. The object form + `{ "ssl": {} }` enables an in-process self-signed `localhost` certificate on + the API listener. See [Serve over TLS](../how-to/serve-over-tls.md). + +### Callback resolution order + +When a token is minted, the callback applied is the first that matches, in this +order: + +1. An enqueued one-shot scenario from `/_mock/scenarios` (issuer-matched, single + use). +2. A `tokenCallbacks` entry (first match by issuer). +3. The built-in default callback. + +The full set of default and derived claims is described in the +[tokens and claims reference](tokens-and-claims.md). + +## CORS + +CORS is **on by default**. With no allowlist configured +(`--cors-allowed-origins` empty), the server: + +- Reflects any request `Origin` back in `Access-Control-Allow-Origin` verbatim. +- Sets `Access-Control-Allow-Credentials: true`. +- Answers preflight `OPTIONS` with 204, `Access-Control-Allow-Methods: POST, GET, OPTIONS`, and echoes the requested `Access-Control-Request-Headers`. + +The `*` wildcard is **never** emitted; the specific origin is echoed instead. +Setting `--cors-allowed-origins` to a comma-separated list tightens reflection to +exactly those origins. The rationale for these defaults is in +[The security model](../explanation/security-model.md). diff --git a/docs/docs/reference/control-plane.md b/docs/docs/reference/control-plane.md new file mode 100644 index 0000000..e728b2e --- /dev/null +++ b/docs/docs/reference/control-plane.md @@ -0,0 +1,300 @@ +--- +title: Control plane (/_mock) +description: Endpoint and DTO reference for the /_mock control plane — mint, scenarios, request capture, clock, and reset. +--- + +# Control plane (/_mock) + +The `/_mock` control plane is the out-of-band API for driving `mock-oidc` from a +test: minting tokens, enqueueing one-shot scenarios, reading captured requests, +and controlling the clock. All request and response bodies are JSON. + +- **Location** — co-located on the API listener (`:8080` by default). A + dedicated-listener mode moves it to a separate control address. +- **Enabled** — on by default. When disabled, every `/_mock/*` route returns + `404`. +- **Authentication** — an optional `X-Mock-Control-Token` header gate, active + only when a control token is configured. +- **Response header** — every control-plane response carries + `X-Mock-Oidc: testing-only`. + +!!! warning "For testing only" + The control plane mints and rewrites tokens for arbitrary identities with no + authentication beyond the optional token gate. It must never be exposed on a + production surface. See [The security model](../explanation/security-model.md). + +## Common behavior + +| Aspect | Value | +| --- | --- | +| Base path | `/_mock` | +| Body format | `application/json` | +| Listener | API listener (`:8080`) by default; a configured control address moves it to a dedicated listener | +| Enabled by default | Yes (`--control-enabled`, default `true`) | +| Disabled behavior | All `/_mock/*` routes return `404` | +| Auth gate | `X-Mock-Control-Token` header, required only when `--control-token` is set; compared in constant time | +| Response header | `X-Mock-Oidc: testing-only` on every response | +| Error format | RFC 9457 `application/problem+json` | + +!!! note "Dedicated-listener mode disables request capture" + When the control plane runs on its own address, the API listener carries no + request-recording middleware and the [request-capture](#request-capture) + endpoints record nothing. Request capture is available only in the default + co-located mode. See + [Lock down the control plane](../how-to/lock-down-the-control-plane.md). + +The `--control-enabled` and `--control-token` settings are documented in the +[configuration reference](configuration.md). + +## Mint + +Mints a signed token directly, bypassing any grant. The result is byte-identical +to a granted token: it verifies against `/{issuer}/jwks` and is accepted at +`/{issuer}/userinfo`. Issuers with the reserved `_mock` prefix are rejected. + +### `POST /_mock/mint` + +**Request** + +| Field | Type | Description | +| --- | --- | --- | +| `issuer` | string | Issuer id the token is minted under. Its signing key `kid` equals this id. Reserved-prefix issuers are rejected. | +| `issuerUrl` | string (optional) | Explicit issuer URL to stamp as the `iss` claim, overriding the per-request derived value. | +| `subject` | string | Value of the `sub` claim. | +| `audience` | string[] | Value of the `aud` claim. | +| `scope` | string[] | Scopes carried by the token. | +| `clientId` | string | Client id associated with the token. | +| `kind` | string | `access_token` or `id_token`. | +| `typ` | string | JWS `typ` header (for example `JWT` or `at+jwt`). | +| `claims` | object | Additional claims merged into the token. | +| `expirySeconds` | number | Token lifetime in seconds. | + +**Response** + +| Field | Type | Description | +| --- | --- | --- | +| `token` | string | The signed compact JWS. | +| `kid` | string | Signing key id (equals the issuer id). | +| `algorithm` | string | Signing algorithm (for example `RS256`). | +| `issuer` | string | Issuer the token was minted under. | +| `expiresAt` | string | Expiry timestamp. | +| `claims` | object | The full claim set of the minted token. | + +```json +{ + "issuer": "default", + "subject": "alice", + "audience": ["my-api"], + "scope": ["read"], + "clientId": "my-app", + "kind": "access_token", + "typ": "at+jwt", + "claims": { "role": "admin" }, + "expirySeconds": 3600 +} +``` + +Claim shaping is covered in [Shape token claims](../how-to/shape-token-claims.md); +claim semantics are in [Tokens and claims](tokens-and-claims.md). + +## Scenarios + +Scenarios enqueue one-shot, issuer-matched token callbacks. A scenario alters +the next matching token for its issuer only, then reverts (single-use). The +`refresh_token` grant consults the same queue. A scenario body has the same +shape as a config `tokenCallbacks` entry (see the +[configuration reference](configuration.md)). + +### `POST /_mock/scenarios` + +**Request** + +| Field | Type | Description | +| --- | --- | --- | +| `issuer` | string | Issuer the scenario matches. | +| `subject` | string | Overrides the `sub` claim. | +| `audience` | string[] | Overrides the `aud` claim (an explicit empty list is honored). | +| `claims` | object | Claims merged into the matched token. | +| `typ` | string | JWS `typ` header for the matched token. | +| `expirySeconds` | number | Token lifetime in seconds. | +| `requestMappings` | object[] | Per-request conditional overrides (see below). | + +Each `requestMappings` entry: + +| Field | Type | Description | +| --- | --- | --- | +| `param` | string | Request parameter to match on. | +| `match` | string | `*` (any), an exact value, or a regular expression. | +| `typeHeader` | string | JWS `typ` header applied when the mapping matches. | +| `claims` | object | Claims applied when the mapping matches. | + +**Response** + +| Field | Type | Description | +| --- | --- | --- | +| `scenarioId` | string | Identifier of the enqueued scenario. | +| `queueDepth` | number | Number of scenarios in the queue after enqueue. | + +### `GET /_mock/scenarios` + +Lists the queued scenarios non-destructively. + +| Field | Type | Description | +| --- | --- | --- | +| `queueDepth` | number | Number of scenarios currently queued. | +| `scenarios` | object[] | One entry per queued scenario: `{ issuer, kind }`. | + +Each `scenarios` entry: + +| Field | Type | Description | +| --- | --- | --- | +| `issuer` | string | Issuer the scenario matches. | +| `kind` | string | `default` or `requestMapping`. | + +### `DELETE /_mock/scenarios` + +Clears the scenario queue. + +| Field | Type | Description | +| --- | --- | --- | +| `queueDepth` | number | Always `0`. | + +## Request capture + +`mock-oidc` records every inbound protocol request except the routes on the +[capture blacklist](#capture-blacklist). These endpoints read the log. + +### `POST /_mock/requests/take` + +A destructive FIFO long-poll. Blocks until a request matches the filter (or the +timeout elapses), then removes and returns the oldest match. On timeout it +returns `404` — a clean miss, not an error. + +**Request** + +| Field | Type | Description | +| --- | --- | --- | +| `timeoutMs` | number | Maximum time to block waiting for a match, in milliseconds. | +| `issuer` | string | Issuer to match. | +| `endpoint` | string | Endpoint to match (see the [endpoint enum](#endpoint-enum)). | + +**Response** — a single [`CapturedRequest`](#capturedrequest), or `404` on timeout. + +### `GET /_mock/requests` + +A non-destructive snapshot. Both query parameters are optional; omitting them +returns every recorded request. + +| Query parameter | Description | +| --- | --- | +| `issuer` | Filter to a single issuer. | +| `endpoint` | Filter to a single [endpoint](#endpoint-enum). | + +**Response** + +| Field | Type | Description | +| --- | --- | --- | +| `count` | number | Number of requests returned. | +| `requests` | object[] | An array of [`CapturedRequest`](#capturedrequest). | + +### `DELETE /_mock/requests` + +Clears the request log. + +| Field | Type | Description | +| --- | --- | --- | +| `cleared` | boolean | Always `true`. | + +### CapturedRequest + +| Field | Type | Description | +| --- | --- | --- | +| `id` | string | Unique id of the captured request. | +| `receivedAt` | string | Timestamp the request was received. | +| `issuer` | string | Issuer the request targeted. | +| `method` | string | HTTP method. | +| `path` | string | Request path. | +| `url` | string | Full request URL. | +| `query` | object | Query parameters (name to values). | +| `headers` | object | Request headers (name to values). | +| `bodyBase64` | string | Exact raw request body bytes, base64-encoded — preserves order, duplicate keys, and `+` / `%` encoding. | +| `body` | string | Best-effort UTF-8 decode of the body, for convenience. | + +### Endpoint enum + +The `endpoint` field of `take` and the `endpoint` query parameter of `GET +/_mock/requests` accept exactly these values: + +| Value | Protocol endpoint | +| --- | --- | +| `authorize` | `GET`/`POST /{issuer}/authorize` | +| `token` | `POST /{issuer}/token` | +| `userinfo` | `GET /{issuer}/userinfo` | +| `introspect` | `POST /{issuer}/introspect` | +| `revoke` | `POST /{issuer}/revoke` | +| `endsession` | `GET`/`POST /{issuer}/endsession` | +| `jwks` | `GET /{issuer}/jwks` | + +### Capture blacklist + +The recorder never captures the control plane or the operational surface. These +routes are always excluded: + +- `/_mock/*` +- `/healthz`, `/readyz`, `/isalive` +- `/metrics` +- `/openapi*`, `/docs` +- `/favicon.ico` + +Request-capture usage is covered in +[Capture and assert requests](../how-to/capture-and-assert-requests.md). + +## Clock + +A single clock drives both token issuance and verification. Freezing or +advancing it can make a previously-valid token introspect `active:false` and +fail at `userinfo`. + +### `GET /_mock/clock` + +Reports the current clock state. + +| Field | Type | Description | +| --- | --- | --- | +| `frozen` | boolean | Whether the clock is frozen. | +| `now` | string | The clock's current instant. | + +### `PUT /_mock/clock` + +Sets the clock state. + +| Field | Type | Description | +| --- | --- | --- | +| `frozen` | boolean | Whether to freeze the clock. | +| `instant` | string (optional) | The instant to freeze at. Required when `frozen` is `true`. | + +### `POST /_mock/clock/advance` + +Freezes the clock, then advances it by a duration. + +| Field | Type | Description | +| --- | --- | --- | +| `duration` | string | A Go duration string, for example `90s`, `5m`, or `1h1m`. | + +Time-simulation usage is covered in +[Simulate expiry and time](../how-to/simulate-expiry-and-time.md). + +## Reset + +### `POST /_mock/reset` + +Clears the scenario queue and the request log and unfreezes the clock. Signing +keys are preserved, so already-fetched JWKS still verifies. The request body is +empty. + +| Field | Type | Description | +| --- | --- | --- | +| — | — | Returns an empty success response. | + +The full request and response schemas are also published in the +[API Reference](../api.md). diff --git a/docs/docs/reference/observability.md b/docs/docs/reference/observability.md new file mode 100644 index 0000000..fdd241e --- /dev/null +++ b/docs/docs/reference/observability.md @@ -0,0 +1,115 @@ +--- +title: Observability +description: Reference for the health, readiness, metrics, tracing, and API-documentation endpoints. +--- + +# Observability + +The health, metrics, tracing, and API-documentation surfaces sit outside every +issuer namespace. Health, readiness, and the API-documentation routes are served +on the API listener (`--addr`, default `:8080`); metrics are served on a +dedicated listener by default. The flags named on this page are documented in +full in [Configuration](configuration.md). + +## Health and status + +| Route | Response body | Kind | +| --- | --- | --- | +| `GET /isalive` | `{"status":"ok"}` | Liveness. Upstream-parity alias of `/healthz`. | +| `GET /healthz` | `{"status":"ok"}` | Liveness. | +| `GET /readyz` | `{"status":"ready","checks":{}}` | Readiness. | + +The server is DB-less and holds no external dependencies, so `/readyz` is always +ready and its `checks` object is always empty. + +```bash +curl -s http://localhost:8080/healthz +# => {"status":"ok"} + +curl -s http://localhost:8080/readyz +# => {"status":"ready","checks":{}} +``` + +## Metrics + +`GET /metrics` serves Prometheus exposition format. + +| Property | Value | +| --- | --- | +| Route | `GET /metrics` | +| Format | Prometheus text exposition | +| Listener | Dedicated listener, default `:9090` (`--metrics-addr`) | + +When `--metrics-addr` is empty, `/metrics` is served on the API listener +(`--addr`) instead of on a separate listener. + +```bash +curl -s http://localhost:9090/metrics +``` + +!!! note + The container publishes only `8080` by default. Add `-p 9090:9090` to reach + the metrics listener: + `docker run --rm -p 8080:8080 -p 9090:9090 ghcr.io/meigma/mock-oidc`. + +!!! note + The metrics listener always serves plain HTTP. TLS terminates on the API + listener only and does not apply to `/metrics`. See + [Serve over TLS](../how-to/serve-over-tls.md). + +## Tracing + +Tracing is opt-in and off by default. It requires an external OTLP collector. + +| Property | Value | +| --- | --- | +| Enable flag | `--tracing-enabled` (default `false`) | +| Export protocol | OTLP/HTTP | +| Configuration | Standard `OTEL_*` environment variables | +| `service.name` default | `mock-oidc` | +| `service.version` default | Build version | +| Shutdown | Pending spans are flushed on graceful shutdown | + +`--tracing-enabled` is the only mock-oidc-specific tracing setting; everything +else is configured through the standard OpenTelemetry environment variables. + +| Environment variable | Effect | +| --- | --- | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Base endpoint of the OTLP/HTTP collector spans are exported to. | +| `OTEL_SERVICE_NAME` | Overrides the `service.name` resource attribute (default `mock-oidc`). | +| `OTEL_TRACES_SAMPLER` | Selects the trace sampler. | +| `OTEL_TRACES_SAMPLER_ARG` | Argument passed to the selected sampler. | +| `OTEL_RESOURCE_ATTRIBUTES` | Additional resource attributes as comma-separated `key=value` pairs. | + +Inbound HTTP requests are recorded as `otelhttp` server spans. W3C trace context +is extracted from the incoming request headers, so spans join an upstream trace +when the caller propagates one. The infrastructure routes `/isalive`, +`/healthz`, `/readyz`, and `/metrics` are excluded from span creation. + +```bash +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \ +OTEL_SERVICE_NAME=mock-oidc \ +OTEL_TRACES_SAMPLER=parentbased_traceidratio \ +OTEL_TRACES_SAMPLER_ARG=1.0 \ +./bin/mock-oidc serve --tracing-enabled +``` + +## API documentation + +Huma serves the interactive and machine-readable API documentation on the API +listener. + +| Route | Content | +| --- | --- | +| `GET /docs` | Interactive API documentation (Stoplight UI). | +| `GET /openapi.json` | OpenAPI 3.0.3 document, JSON. | +| `GET /openapi.yaml` | OpenAPI 3.0.3 document, YAML. | + +The rendered [API Reference](../api.md) is generated from the same OpenAPI +document, which the [`openapi` subcommand](cli.md) also writes to a file or to +standard output. + +!!! note + The health, metrics, and API-documentation routes are never captured by the + request recorder. See [Control plane](control-plane.md) for the full list of + excluded paths. diff --git a/docs/docs/reference/tokens-and-claims.md b/docs/docs/reference/tokens-and-claims.md new file mode 100644 index 0000000..eeafb83 --- /dev/null +++ b/docs/docs/reference/tokens-and-claims.md @@ -0,0 +1,168 @@ +--- +title: Tokens and claims +description: Reference for the token response shape, default claims, audience resolution, and signing behavior. +--- + +# Tokens and claims + +This page describes the content of the tokens `mock-oidc` mints: the token +response JSON, which grants yield which tokens, the default registered claims, +how the subject and audiences are resolved, the JWS `typ` header, and the signing +algorithms and key identifier. It documents defaults exactly as the server +produces them. For the procedures to change any of this, see +[Shape token claims](../how-to/shape-token-claims.md). + +## Token response + +`POST /{issuer}/token` returns a JSON object on success. Every field except +`token_type`, `access_token`, and `expires_in` is emitted only when present +(`omitempty`). + +| Field | Type | Presence | Description | +| --- | --- | --- | --- | +| `token_type` | string | always | Always the literal `"Bearer"`. | +| `access_token` | string | always | The signed JWS access token. | +| `expires_in` | number | always | Access-token lifetime in seconds (default `3600`). | +| `id_token` | string | grant-dependent | The signed JWS ID token. | +| `refresh_token` | string | grant-dependent | Refresh token; issued only by the `authorization_code` grant. | +| `scope` | string | grant-dependent | Space-delimited granted scope. | +| `issued_token_type` | string | token-exchange only | `urn:ietf:params:oauth:token-type:access_token`. | + +```json +{ + "token_type": "Bearer", + "access_token": "eyJ…", + "id_token": "eyJ…", + "refresh_token": "…", + "expires_in": 3600, + "scope": "openid profile" +} +``` + +## Grants and tokens issued + +| `grant_type` | `access_token` | `id_token` | `refresh_token` | `issued_token_type` | +| --- | :---: | :---: | :---: | :---: | +| `client_credentials` | ✓ | — | — | — | +| `authorization_code` | ✓ | ✓ | ✓ | — | +| `password` | ✓ | ✓ | — | — | +| `refresh_token` | ✓ | conditional | — | — | +| `urn:ietf:params:oauth:grant-type:jwt-bearer` | ✓ | — | — | — | +| `urn:ietf:params:oauth:grant-type:token-exchange` | ✓ | — | — | ✓ | + +The `refresh_token` grant re-mints an `id_token` only when the underlying code +record carried a `nonce`; otherwise it returns an access token alone. The +`token-exchange` grant returns no `scope` field. + +## Registered claims + +Every minted token (access or ID) carries the following claims. + +| Claim | Value | +| --- | --- | +| `sub` | Subject. Resolved per the order below. | +| `aud` | Audience. Resolved per the rules below (differs between ID and access tokens). | +| `iss` | Issuer URL: the resolved host root, `+ "/" +` the issuer id (e.g. `http://localhost:8080/default`). | +| `iat` | Issued-at, set to the current clock time. | +| `nbf` | Not-before, set to the current clock time. | +| `exp` | Expiry: `iat` + the token lifetime (default `3600` seconds). | +| `jti` | A random UUID, unique per minted token. | +| `nonce` | Present only when a `nonce` was supplied to the authorize request. | + +The `iss` value is derived per request from the resolved external address (see +[Issuers and advertised identity](../explanation/issuers-and-identity.md)). The +same clock drives both issuance and verification; freezing or advancing it +affects `iat`/`nbf`/`exp` and whether an existing token still verifies (see +[Simulate expiry and time](../how-to/simulate-expiry-and-time.md)). + +## Subject resolution + +`sub` is resolved by the first rule that applies, in order: + +1. `client_credentials` grant → the `client_id`. +2. `password` grant → the `username`. +3. Interactive login → the submitted login username (or a `sub` supplied via a + request mapping). +4. A `subject` configured on the matching token callback. +5. A per-callback random UUID fallback. + +Rule 5 guarantees that zero-config authorization-code tokens always carry a +`sub`. + +## Default-callback claims + +The following claims are added by the built-in default callback only. A +`requestMapping` callback adds neither. + +| Claim | Value | Condition | +| --- | --- | --- | +| `tid` | The issuer id. | Default callback; user-overridable. | +| `azp` | The `client_id`. | `authorization_code` grant only; default callback. | + +## Audience + +### ID token + +The `id_token` `aud` is always `[client_id]`. + +### Access token + +The `access_token` `aud` is resolved by the first rule that applies, in order: + +1. A configured or scenario audience — the `audience` list on the matching + token callback, including an explicitly configured empty list (`[]`). +2. The token-exchange `audience` request parameter (the `token-exchange` grant + only, and only when no callback audience is configured). +3. The non-OIDC scopes remaining after the OIDC scopes `openid`, `profile`, + `email`, `address`, `phone`, and `offline_access` are stripped from the + requested `scope`. +4. `["default"]`. + +## Token type header (`typ`) + +The JWS `typ` header controls whether the server accepts its own token back at +`userinfo` and `introspect`. + +| `typ` | Self-verifies | `GET /userinfo` | `POST /introspect` | +| --- | :---: | --- | --- | +| `JWT` (default) | ✓ | `200` | `{"active": true}` | +| `at+jwt` (RFC 9068) | ✓ | `200` | `{"active": true}` | +| any other, e.g. `foo+jwt` | — | `401` | `{"active": false}` | + +A foreign `typ` produces a well-formed, signed token that the server treats as +unverifiable when presented back to it. + +## Signing + +### Algorithms + +Each issuer signs with one algorithm, selectable per issuer. + +| Algorithm | Default | +| --- | :---: | +| `RS256` | ✓ | +| `RS384` | | +| `RS512` | | +| `PS256` | | +| `PS384` | | +| `PS512` | | +| `ES256` | | +| `ES384` | | + +`alg=none` is rejected on verification. + +### Key identifier + +The signing key's `kid` equals the issuer id. It appears in the JWS header of +minted tokens and in the issuer's `GET /{issuer}/jwks` entry (with `use=sig` and +the `alg`, public members only). + +## Introspection audience serialization + +`POST /{issuer}/introspect` serializes a single-element `aud` as a bare string +rather than a one-element array. A multi-element `aud` is serialized as a JSON +array. See the [API Reference](../api.md) for the full introspection response +contract. + +To change any default on this page — claims, subject, audience, `typ`, or +algorithm — see [Shape token claims](../how-to/shape-token-claims.md). diff --git a/docs/docs/tutorials/first-mock-sign-in.md b/docs/docs/tutorials/first-mock-sign-in.md new file mode 100644 index 0000000..6157e07 --- /dev/null +++ b/docs/docs/tutorials/first-mock-sign-in.md @@ -0,0 +1,291 @@ +--- +title: Your first mock sign-in +description: Drive a complete authorization-code sign-in against a zero-config mock-oidc server using nothing but curl. +--- + +# Your first mock sign-in + +In this tutorial we'll take a freshly started `mock-oidc` server and drive a +complete OpenID Connect sign-in against it, start to finish, using nothing but +`curl`. We'll fetch the discovery document a real client would read, run the +authorization-code flow to get a code, exchange that code for tokens, decode the +ID token to see who we signed in as, and confirm the identity at the userinfo +endpoint. To finish, we'll mint a token in a single request using the built-in +control plane. + +By the end you'll have seen every moving part of an OIDC sign-in with your own +eyes, and you'll have the mental model to explore the rest of the docs. + +Everything here runs against a **zero-config** server: no configuration file, no +registration step, no real identity provider. + +## Prerequisites + +- Docker, to run the container. +- `curl` and `python3` (both are used only for making requests and decoding + JSON — no OAuth client libraries anywhere). + +## Step 1: Start the server + +Start the published container. It listens on port `8080`: + +```sh +docker run --rm -p 8080:8080 ghcr.io/meigma/mock-oidc +``` + +This stays in the foreground and streams its logs. On every startup it prints a +hard-to-miss **FOR TESTING ONLY** warning banner — something like: + +```text +mock-oidc — FOR TESTING ONLY +Never front production traffic with this server. +``` + +!!! warning "For testing only" + `mock-oidc` mints signed tokens for **any** identity on request. That is + exactly what makes it useful for tests — and exactly why it must never sit + in front of real users. + +Leave that terminal running and open a **second terminal** for the rest of the +tutorial. Confirm the server is alive: + +```sh +curl -sS http://localhost:8080/isalive +# => {"status":"ok"} +``` + +There's our server. + +## Step 2: See what a client sees — the discovery document + +An OIDC client's first move is to read the issuer's discovery document. Every +issuer lives under a single path segment; the zero-config default issuer is +named `default`. Ask for its discovery document: + +```sh +curl -sS http://localhost:8080/default/.well-known/openid-configuration | python3 -m json.tool +``` + +```json +{ + "issuer": "http://localhost:8080/default", + "authorization_endpoint": "http://localhost:8080/default/authorize", + "token_endpoint": "http://localhost:8080/default/token", + "userinfo_endpoint": "http://localhost:8080/default/userinfo", + "jwks_uri": "http://localhost:8080/default/jwks" +} +``` + +(Trimmed for brevity — the real document lists more fields.) Notice the +`issuer` and the four endpoints we'll use next: **authorize** to start the flow, +**token** to exchange the code, **userinfo** to read the identity, and +**jwks** to publish the public verification key. + +## Step 3: Fetch the JWKS + +The `jwks_uri` is where clients fetch the public key that verifies our tokens. +Fetch it: + +```sh +curl -sS http://localhost:8080/default/jwks | python3 -m json.tool +``` + +```json +{ + "keys": [ + { + "kty": "RSA", + "kid": "default", + "use": "sig", + "alg": "RS256", + "n": "...", + "e": "AQAB" + } + ] +} +``` + +Notice the `kid` is `default` — the same name as the issuer. The default issuer +was created, with its own signing key, the moment we first touched it. There was +no registration step. To learn how identity and keys fit together, see +[Issuers and identity](../explanation/issuers-and-identity.md). + +## Step 4: Run the authorization-code flow + +Now the sign-in itself. We send the client's browser to the `authorize` +endpoint. Because interactive login is off by default, the server skips the +login page and immediately hands back an authorization code as a `302` +redirect. Use `-i` so `curl` shows us the response headers: + +```sh +curl -sS -i "http://localhost:8080/default/authorize?response_type=code&client_id=demo&redirect_uri=http://localhost:8080/callback&scope=openid%20profile&state=xyz" +# => HTTP/1.1 302 Found +# => Location: http://localhost:8080/callback?code=&state=xyz +``` + +There's the code, tucked into the `Location` header the browser would follow. +Our `state=xyz` is echoed back untouched. Let's capture that code into a shell +variable so we can use it in the next step: + +```sh +CODE=$(curl -sS -D - -o /dev/null \ + "http://localhost:8080/default/authorize?response_type=code&client_id=demo&redirect_uri=http://localhost:8080/callback&scope=openid%20profile&state=xyz" \ + | grep -i '^location:' | sed -E 's/.*code=([^&]+).*/\1/' | tr -d '\r') +echo "$CODE" +# => 8f14e45f-ce9a-4c1b-9a0d-1f2b3c4d5e6f (a fresh code every run) +``` + +## Step 5: Exchange the code for tokens + +The client now takes that code straight to the `token` endpoint. This is a +form-encoded `POST`. Capture the whole JSON response so we can pick it apart: + +```sh +TOKENS=$(curl -sS -X POST http://localhost:8080/default/token \ + -d grant_type=authorization_code \ + -d code="$CODE" \ + -d client_id=demo \ + -d redirect_uri=http://localhost:8080/callback \ + -d "scope=openid profile") +echo "$TOKENS" | python3 -m json.tool +``` + +```json +{ + "token_type": "Bearer", + "access_token": "eyJhbGciOiJSUzI1NiIs...", + "id_token": "eyJhbGciOiJSUzI1NiIs...", + "refresh_token": "eyJhbGciOiJSUzI1NiIs...", + "expires_in": 3600, + "scope": "openid profile" +} +``` + +We signed in. The `authorization_code` grant hands back all three: an +**id_token** (who signed in), an **access_token** (to call APIs), and a +**refresh_token** (to get more). The code is single-use — try Step 5 again with +the same `$CODE` and you'll get an `invalid_grant` error, which is exactly how a +real server behaves. + +Pull the ID token and access token into variables: + +```sh +ID_TOKEN=$(printf '%s' "$TOKENS" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id_token"])') +ACCESS_TOKEN=$(printf '%s' "$TOKENS" | python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])') +``` + +## Step 6: Decode the ID token + +A JWT is three base64url segments separated by dots: header, payload, signature. +The identity lives in the middle segment. Let's cut it out and decode it: + +```sh +printf '%s' "$ID_TOKEN" | cut -d. -f2 | \ + python3 -c 'import sys,base64,json; s=sys.stdin.read().strip(); s+="="*(-len(s)%4); print(json.dumps(json.loads(base64.urlsafe_b64decode(s)), indent=2))' +``` + +```json +{ + "sub": "6b1e...-a random uuid", + "aud": ["demo"], + "iss": "http://localhost:8080/default", + "iat": 1751500000, + "nbf": 1751500000, + "exp": 1751503600, + "jti": "...", + "azp": "demo", + "tid": "default" +} +``` + +Read the payload like a client would: + +- `sub` — the subject, who signed in. We never named anyone, so the server gave + us a random UUID. +- `iss` — the issuer, matching the discovery document exactly. +- `aud` — the audience, `["demo"]`, the `client_id` we asked with. + +These are real, signed claims — the signature over the token verifies against +the JWKS we fetched in Step 3. + +## Step 7: Confirm the identity at userinfo + +A client can also ask the server directly who a token belongs to. The `userinfo` +endpoint takes the access token as a Bearer credential, verifies its signature, +and returns the claim set: + +```sh +curl -sS http://localhost:8080/default/userinfo -H "Authorization: Bearer $ACCESS_TOKEN" +``` + +```json +{"sub":"6b1e...-a random uuid","iss":"http://localhost:8080/default","aud":"default", "...": "..."} +``` + +The `sub` matches the one we decoded from the ID token — same identity, arriving +two different ways. Hand `userinfo` a garbled or expired token instead and it +answers `401` with `error="invalid_token"`. To understand how that verification +works, see [Issuers and identity](../explanation/issuers-and-identity.md). + +## Step 8: A first taste of the control plane + +The full flow is great for exercising a client end to end. But when a test just +needs *a valid token for a specific person*, there's a shortcut: the `/_mock` +control plane. Mint a token in one request: + +```sh +curl -sS -X POST http://localhost:8080/_mock/mint \ + -H 'Content-Type: application/json' \ + -d '{"issuer":"default","subject":"alice","audience":["demo"],"scope":["openid","profile"],"clientId":"demo","kind":"access_token"}' +``` + +```json +{ + "token": "eyJhbGciOiJSUzI1NiIs...", + "kid": "default", + "algorithm": "RS256", + "issuer": "http://localhost:8080/default", + "expiresAt": "2026-07-03T12:00:00Z", + "claims": {"sub": "alice", "aud": ["demo"], "iss": "http://localhost:8080/default"} +} +``` + +A minted token is byte-identical to one from the flow — same signing key, same +verification. Prove it by handing this one to `userinfo`: + +```sh +MINTED=$(curl -sS -X POST http://localhost:8080/_mock/mint \ + -H 'Content-Type: application/json' \ + -d '{"issuer":"default","subject":"alice","audience":["demo"],"scope":["openid","profile"],"clientId":"demo","kind":"access_token"}' \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])') +curl -sS http://localhost:8080/default/userinfo -H "Authorization: Bearer $MINTED" +# => {"sub":"alice", ...} +``` + +We asked for `alice`, and `alice` is who the server signs in. If minting any +identity on demand makes you wonder what stops this from being dangerous, that's +the right question to ask — read [The security model](../explanation/security-model.md). + +## What we did + +In one sitting, using only `curl`, we: + +- Started a zero-config server and confirmed it was alive. +- Read the issuer's discovery document and JWKS, the way a real client does. +- Ran the authorization-code flow to obtain a code, then exchanged it for an + ID token, access token, and refresh token. +- Decoded the ID token and confirmed the same identity at `userinfo`. +- Minted a token for a named subject in a single control-plane request. + +You now have the mental model: **issuers materialize on first touch, every token +is really signed, and `/_mock` is your shortcut when you don't need the full +flow.** + +## Where to next + +- [Get tokens for every grant](../how-to/get-tokens-for-every-grant.md) — the + other five grant types, each as a ready-to-run recipe. +- [Shape token claims](../how-to/shape-token-claims.md) — put whatever `sub`, + audience, and custom claims your test needs into the tokens. +- [The security model](../explanation/security-model.md) — why a server that + signs anything for anyone is safe for testing and only for testing. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 7be6a3f..ad14001 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -35,7 +35,32 @@ theme: nav: - Home: index.md - - API Reference: api.md + - Tutorial: + - Your first mock sign-in: tutorials/first-mock-sign-in.md + - How-to guides: + - Get tokens for every grant: how-to/get-tokens-for-every-grant.md + - Drive the authorization-code flow: how-to/drive-the-authorization-code-flow.md + - Shape token claims: how-to/shape-token-claims.md + - Simulate expiry and time: how-to/simulate-expiry-and-time.md + - Capture and assert requests: how-to/capture-and-assert-requests.md + - Use multiple issuers: how-to/use-multiple-issuers.md + - Serve over TLS: how-to/serve-over-tls.md + - Run behind a proxy or in Docker: how-to/run-behind-a-proxy-or-in-docker.md + - Lock down the control plane: how-to/lock-down-the-control-plane.md + - Migrate from mock-oauth2-server: how-to/migrate-from-mock-oauth2-server.md + - Verify released artifacts: how-to/verify-released-artifacts.md + - Reference: + - Configuration: reference/configuration.md + - CLI: reference/cli.md + - Tokens and claims: reference/tokens-and-claims.md + - Control plane (/_mock): reference/control-plane.md + - Observability: reference/observability.md + - API Reference: api.md + - Explanation: + - The security model: explanation/security-model.md + - Issuers and advertised identity: explanation/issuers-and-identity.md + - Parity with mock-oauth2-server: explanation/parity.md + - Architecture and distribution: explanation/architecture-and-distribution.md plugins: - search