diff --git a/docs/core/endpoints.md b/docs/core/endpoints.md
index 7cf925a..0f9de0e 100644
--- a/docs/core/endpoints.md
+++ b/docs/core/endpoints.md
@@ -37,7 +37,7 @@ Endpoints supported by Authorizer
## `/metrics`
-`GET /metrics` - Prometheus-compatible metrics endpoint. See [Metrics & Monitoring](../core/metrics-monitoring) for details.
+`GET /metrics` — Prometheus-compatible metrics. **Always** served on a **separate listener** at **`--metrics-host`:`--metrics-port`** (defaults **`127.0.0.1:8081`**); never on the main HTTP port (`--http-port` must differ). See [Metrics & Monitoring](../core/metrics-monitoring) for details.
## `/verify_email`
diff --git a/docs/core/metrics-monitoring.md b/docs/core/metrics-monitoring.md
index e10fb9c..4bdd0f6 100644
--- a/docs/core/metrics-monitoring.md
+++ b/docs/core/metrics-monitoring.md
@@ -18,7 +18,7 @@ curl http://localhost:8080/healthz
# {"status":"ok"}
```
-Returns `503` with `{"status":"unhealthy","error":"..."}` when the database is unreachable.
+Returns `503` with `{"status":"unhealthy","error":"storage unavailable"}` when the database is unreachable (details are logged server-side only).
### `/readyz` - Readiness Probe
@@ -29,16 +29,27 @@ curl http://localhost:8080/readyz
# {"status":"ready"}
```
-Returns `503` with `{"status":"not ready","error":"..."}` when the system is not ready to serve traffic.
+Returns `503` with `{"status":"not ready","error":"storage unavailable"}` when the system is not ready to serve traffic (details are logged server-side only).
### `/metrics` - Prometheus Metrics
Serves all metrics in Prometheus exposition format.
+**`/metrics` is never on the main HTTP server.** It is always served by a **separate minimal HTTP server** that runs in parallel with the main Gin app (same pattern as running distinct app and metrics listeners). By default `--http-port` is `8080` and `--metrics-port` is `8081`; **`--http-port` and `--metrics-port` must differ** or the process exits at startup.
+
+The metrics listener is **not** reachable from other machines by default: **`--metrics-host` defaults to `127.0.0.1`**, so only loopback can scrape unless you change it (see below).
+
```bash
-curl http://localhost:8080/metrics
+curl http://127.0.0.1:8081/metrics
```
+#### Bind address and security
+
+- **Single host / node-exporter style:** Keep defaults (`127.0.0.1` + `--metrics-port`). Run Prometheus (or an agent) **on the same host** and scrape `127.0.0.1:8081`, or use a reverse proxy that forwards from an internal network to that socket.
+- **Docker / Kubernetes / another machine scrapes the pod:** Set **`--metrics-host=0.0.0.0`** (or the pod IP interface you use) so the metrics port accepts connections on the container network. **Do not** put the metrics port on a public ingress or internet-facing load balancer; use a **ClusterIP** Service (or internal Docker network) and scrape from inside the cluster only.
+
+For **Docker `EXPOSE` vs `-p` / Compose `ports:`** and Kubernetes **pod vs Service** exposure, see [Docker deployment](../deployment/docker#docker-ports-exposure) and [Kubernetes deployment](../deployment/kubernetes#k8s-ports-services).
+
## Available Metrics
### HTTP Metrics
@@ -48,6 +59,8 @@ curl http://localhost:8080/metrics
| `authorizer_http_requests_total` | Counter | `method`, `path`, `status` | Total HTTP requests received |
| `authorizer_http_request_duration_seconds` | Histogram | `method`, `path` | HTTP request latency in seconds |
+For routes that do not match a registered Gin pattern, `path` is recorded as `unmatched` (not the raw URL), to keep Prometheus cardinality bounded.
+
### Authentication Metrics
| Metric | Type | Labels | Description |
@@ -81,6 +94,7 @@ curl http://localhost:8080/metrics
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `authorizer_security_events_total` | Counter | `event`, `reason` | Security-sensitive events for alerting |
+| `authorizer_client_id_header_missing_total` | Counter | — | Requests with no `X-Authorizer-Client-ID` header (allowed for some routes) |
**Security event examples:**
@@ -98,6 +112,8 @@ curl http://localhost:8080/metrics
| `authorizer_graphql_errors_total` | Counter | `operation` | GraphQL responses containing errors (HTTP 200 with errors) |
| `authorizer_graphql_request_duration_seconds` | Histogram | `operation` | GraphQL operation latency |
+The `operation` label is **`anonymous`** for unnamed operations, or **`op_` + a short SHA-256 prefix** of the operation name so client-controlled names cannot create unbounded time series.
+
GraphQL APIs return HTTP 200 even when the response contains errors. These metrics capture those application-level errors that would otherwise be invisible to HTTP-level monitoring.
### Infrastructure Metrics
@@ -115,7 +131,8 @@ scrape_configs:
- job_name: 'authorizer'
scrape_interval: 15s
static_configs:
- - targets: ['authorizer:8080']
+ # In Docker/K8s, use --metrics-host=0.0.0.0 so the scraper can reach the pod/container; scrape via internal DNS/service.
+ - targets: ['authorizer:8081'] # default --metrics-port; same host only: use 127.0.0.1:8081
```
For Kubernetes with service discovery:
@@ -131,7 +148,7 @@ scrape_configs:
action: keep
- source_labels: [__meta_kubernetes_pod_ip]
target_label: __address__
- replacement: '$1:8080'
+ replacement: '$1:8081' # metrics port; ensure deployment sets --metrics-host=0.0.0.0 for in-cluster scrape
```
## Grafana Dashboard
@@ -230,8 +247,8 @@ make dev
curl http://localhost:8080/healthz
curl http://localhost:8080/readyz
-# 3. View raw metrics
-curl http://localhost:8080/metrics
+# 3. View raw metrics (default: loopback + metrics port)
+curl http://127.0.0.1:8081/metrics
# 4. Generate some auth events via GraphQL
curl -X POST http://localhost:8080/graphql \
@@ -239,9 +256,9 @@ curl -X POST http://localhost:8080/graphql \
-d '{"query":"mutation { login(params: {email: \"test@example.com\", password: \"wrong\"}) { message } }"}'
# 5. Check metrics again — look for auth and security counters
-curl -s http://localhost:8080/metrics | grep authorizer_auth
-curl -s http://localhost:8080/metrics | grep authorizer_security
-curl -s http://localhost:8080/metrics | grep authorizer_graphql
+curl -s http://127.0.0.1:8081/metrics | grep authorizer_auth
+curl -s http://127.0.0.1:8081/metrics | grep authorizer_security
+curl -s http://127.0.0.1:8081/metrics | grep authorizer_graphql
# 6. Run integration tests
TEST_DBS="sqlite" go test -p 1 -v -run "TestMetrics|TestHealth|TestReady|TestAuthEvent|TestAdminLoginMetrics|TestGraphQLError" ./internal/integration_tests/
@@ -251,6 +268,7 @@ TEST_DBS="sqlite" go test -p 1 -v -run "TestMetrics|TestHealth|TestReady|TestAut
| Flag | Default | Description |
|------|---------|-------------|
-| `--metrics-port` | `8081` | Port for dedicated metrics server (reserved for future use) |
+| `--metrics-port` | `8081` | Port for the dedicated Prometheus `/metrics` listener (**must differ** from `--http-port`) |
+| `--metrics-host` | `127.0.0.1` | Bind address for that dedicated listener only (use `0.0.0.0` for in-cluster or cross-container scrape; never expose on the public internet without a proxy and auth) |
-Currently all endpoints (`/healthz`, `/readyz`, `/metrics`) are served on the main HTTP port alongside the application routes.
+`/healthz`, `/readyz`, and `/health` stay on the **main HTTP** port (`--host`:`--http-port`). `/metrics` is **only** on the dedicated listener (`--metrics-host`:`--metrics-port`).
diff --git a/docs/core/oauth2-oidc.md b/docs/core/oauth2-oidc.md
index b356085..69cbdcb 100644
--- a/docs/core/oauth2-oidc.md
+++ b/docs/core/oauth2-oidc.md
@@ -1,71 +1,455 @@
---
sidebar_position: 5
-title: OAuth 2.0 & OIDC Endpoints
+title: OAuth 2.0, OIDC & SSO
---
-# OAuth 2.0 & OpenID Connect Endpoints
+# OAuth 2.0, OpenID Connect & SSO
-Authorizer implements industry-standard OAuth 2.0 and OpenID Connect (OIDC) endpoints. This page describes each endpoint, its parameters, and the relevant specs it complies with.
+Authorizer is a fully conformant OAuth 2.0 and OpenID Connect (OIDC) provider. You can:
-## OpenID Connect Discovery
+- **Use it standalone** as an SSO identity provider for your own apps — any OIDC-compliant client library "just works" against its Discovery URL.
+- **Federate it into an existing SSO** (Auth0, Okta, Keycloak, etc.) as an upstream OIDC identity provider.
+- **Use it alongside social providers** (Google, GitHub, Facebook, LinkedIn, Apple, Discord, Twitter, Twitch, Roblox, Microsoft).
-**Endpoint:** `GET /.well-known/openid-configuration`
+This page is the one-stop reference for every endpoint, parameter, and integration pattern Authorizer supports, plus a practical testing guide at the end.
-**Spec:** [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
+## Standards Implemented
+
+| Standard | Status | Notes |
+| ------------------------------------- | ------------- | ------------------------------------------------------------------ |
+| OIDC Core 1.0 | Implemented | ID tokens, UserInfo, nonce, `auth_time`, `amr`, `acr`, `at_hash`, `c_hash` |
+| OIDC Discovery 1.0 | Implemented | All required + recommended fields |
+| OIDC Hybrid Flow (§3.3) | Implemented | `code id_token`, `code token`, `code id_token token`, `id_token token` |
+| OIDC RP-Initiated Logout 1.0 | Implemented | `post_logout_redirect_uri`, `state` echo, `id_token_hint` |
+| OIDC Back-Channel Logout 1.0 | Implemented | Opt-in via `--backchannel-logout-uri` |
+| RFC 6749 (OAuth 2.0) | Implemented | Authorization Code + Refresh Token + Implicit grants |
+| RFC 6750 (Bearer Token) | Implemented | `WWW-Authenticate` on 401 |
+| RFC 7009 (Token Revocation) | Implemented | Returns 200 for invalid tokens |
+| RFC 7517 (JWK) | Implemented | RSA, ECDSA, HMAC; manual multi-key rotation |
+| RFC 7636 (PKCE) | Implemented | S256 method; required for authorization code flow |
+| RFC 7662 (Token Introspection) | Implemented | Non-disclosure for inactive tokens |
+
+**Not yet implemented** (tracked for future releases): RFC 7591 dynamic client registration, RFC 9101 JAR / Request Object, OIDC Session Management iframe, front-channel logout, automated time-based key rotation.
-Returns metadata about the Authorizer instance so clients can auto-configure themselves.
+---
+
+## Quickstart: Authorizer as an SSO Provider
-### Response Fields
+This is the most common setup — an app delegates authentication to Authorizer via OIDC.
-| Field | Description |
-| -------------------------------------- | ------------------------------------------------ |
-| `issuer` | Base URL of the Authorizer instance |
-| `authorization_endpoint` | URL for `/authorize` |
-| `token_endpoint` | URL for `/oauth/token` |
-| `userinfo_endpoint` | URL for `/userinfo` |
-| `jwks_uri` | URL for `/.well-known/jwks.json` |
-| `revocation_endpoint` | URL for `/oauth/revoke` |
-| `end_session_endpoint` | URL for `/logout` |
-| `response_types_supported` | `["code", "token", "id_token"]` |
-| `grant_types_supported` | `["authorization_code", "refresh_token"]` |
-| `scopes_supported` | `["openid", "email", "profile", "offline_access"]` |
-| `code_challenge_methods_supported` | `["S256"]` |
-| `token_endpoint_auth_methods_supported`| `["client_secret_basic", "client_secret_post"]` |
+### Step 1 — Configure your Authorizer instance
-### Usage
+Before any client can use Authorizer for SSO, you need to tell the server which origins and redirect URIs are allowed.
```bash
-curl https://your-authorizer.example/.well-known/openid-configuration
+./build/server \
+ --client-id=my-app \
+ --client-secret="$(openssl rand -hex 32)" \
+ --allowed-origins=https://app.example.com,http://localhost:3000 \
+ --jwt-type=RS256 \
+ --jwt-private-key="$(cat /etc/authorizer/jwt-private.pem)" \
+ --jwt-public-key="$(cat /etc/authorizer/jwt-public.pem)"
+```
+
+For production, use RSA or ECDSA keys (not HMAC) so public clients can verify tokens via the JWKS endpoint without sharing the signing secret. See the [Server Configuration guide](./server-config) for all flags.
+
+### Step 2 — Give your app the Discovery URL
+
+One URL is all a spec-compliant OIDC client library needs:
+
+```
+https://your-authorizer.example/.well-known/openid-configuration
+```
+
+Every library (Auth0.js, openid-client, go-oidc, python-jose, Spring Security OAuth, etc.) can bootstrap from this single URL. No hand-wiring of endpoint URLs needed.
+
+### Step 3 — Wire your client
+
+The following examples all implement the same Authorization Code + PKCE flow. Pick the one matching your stack.
+
+#### React SPA (using `@authorizerdev/authorizer-react`)
+
+Authorizer ships an official React SDK that wraps OIDC for you:
+
+```bash
+npm install @authorizerdev/authorizer-react
+```
+
+```jsx
+import { AuthorizerProvider, useAuthorizer } from '@authorizerdev/authorizer-react';
+
+function App() {
+ return (
+
Loading…
; + if (!user) { + return ; + } + returnHello {user.email}
; +} +``` + +#### Generic SPA (using `oidc-client-ts`) + +For a framework-agnostic JavaScript app using a standards-compliant OIDC library: + +```bash +npm install oidc-client-ts ``` -Most OIDC client libraries will automatically fetch this to discover all other endpoints. +```javascript +import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; + +const mgr = new UserManager({ + authority: 'https://your-authorizer.example', + client_id: 'my-app', + redirect_uri: `${window.location.origin}/callback`, + post_logout_redirect_uri: window.location.origin, + response_type: 'code', + scope: 'openid profile email offline_access', + userStore: new WebStorageStateStore({ store: window.localStorage }), +}); + +// Trigger sign-in +document.querySelector('#login').addEventListener('click', () => mgr.signinRedirect()); + +// On the /callback page +const user = await mgr.signinRedirectCallback(); +console.log('signed in as', user.profile.email); +``` + +#### Node.js / Express backend (using `openid-client`) + +```bash +npm install openid-client express-session +``` + +```javascript +import express from 'express'; +import session from 'express-session'; +import { Issuer, generators } from 'openid-client'; + +const app = express(); +app.use(session({ secret: 'change-me', resave: false, saveUninitialized: true })); + +// Discover Authorizer and build a Client +const authorizer = await Issuer.discover('https://your-authorizer.example'); +const client = new authorizer.Client({ + client_id: 'my-app', + client_secret: process.env.CLIENT_SECRET, + redirect_uris: ['http://localhost:3000/callback'], + response_types: ['code'], +}); + +app.get('/login', (req, res) => { + const code_verifier = generators.codeVerifier(); + const code_challenge = generators.codeChallenge(code_verifier); + req.session.code_verifier = code_verifier; + res.redirect(client.authorizationUrl({ + scope: 'openid profile email offline_access', + code_challenge, + code_challenge_method: 'S256', + })); +}); + +app.get('/callback', async (req, res) => { + const params = client.callbackParams(req); + const tokenSet = await client.callback( + 'http://localhost:3000/callback', + params, + { code_verifier: req.session.code_verifier }, + ); + req.session.tokens = tokenSet; + const userinfo = await client.userinfo(tokenSet.access_token); + res.json({ claims: tokenSet.claims(), userinfo }); +}); + +app.listen(3000); +``` + +#### Go backend (using `coreos/go-oidc`) + +```bash +go get github.com/coreos/go-oidc/v3/oidc golang.org/x/oauth2 +``` + +```go +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +func main() { + ctx := context.Background() + provider, err := oidc.NewProvider(ctx, "https://your-authorizer.example") + if err != nil { + log.Fatal(err) + } + verifier := provider.Verifier(&oidc.Config{ClientID: "my-app"}) + + cfg := oauth2.Config{ + ClientID: "my-app", + ClientSecret: "your-client-secret", + RedirectURL: "http://localhost:8000/callback", + Endpoint: provider.Endpoint(), + Scopes: []string{oidc.ScopeOpenID, "profile", "email", "offline_access"}, + } + + http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, cfg.AuthCodeURL("state-token"), http.StatusFound) + }) + + http.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + token, err := cfg.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + rawIDToken := token.Extra("id_token").(string) + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + var claims struct { + Email string `json:"email"` + Sub string `json:"sub"` + } + idToken.Claims(&claims) + json.NewEncoder(w).Encode(claims) + }) + + log.Fatal(http.ListenAndServe(":8000", nil)) +} +``` + +#### Python / FastAPI backend (using `authlib`) + +```bash +pip install authlib fastapi uvicorn itsdangerous +``` + +```python +from fastapi import FastAPI, Request +from starlette.middleware.sessions import SessionMiddleware +from starlette.responses import RedirectResponse +from authlib.integrations.starlette_client import OAuth + +app = FastAPI() +app.add_middleware(SessionMiddleware, secret_key="change-me") + +oauth = OAuth() +oauth.register( + name="authorizer", + server_metadata_url="https://your-authorizer.example/.well-known/openid-configuration", + client_id="my-app", + client_secret="your-client-secret", + client_kwargs={"scope": "openid profile email offline_access"}, +) + +@app.get("/login") +async def login(request: Request): + redirect_uri = request.url_for("callback") + return await oauth.authorizer.authorize_redirect(request, redirect_uri) + +@app.get("/callback", name="callback") +async def callback(request: Request): + token = await oauth.authorizer.authorize_access_token(request) + return {"claims": token.get("userinfo")} +``` + +#### Mobile (Flutter) + +Use the official Flutter SDK: + +```dart +dependencies: + authorizer_flutter: ^latest +``` + +See the [Flutter SDK docs](../sdks/authorizer-flutter) for the full API. + +#### Other frameworks + +Any framework with an OIDC library will work — ASP.NET Core (`Microsoft.AspNetCore.Authentication.OpenIdConnect`), Spring Boot (`spring-security-oauth2-client`), Rails (`omniauth_openid_connect`), Laravel (`socialite-providers/openid`), and so on. Point them at the Discovery URL and set `client_id` / `client_secret`. --- -## Authorization Endpoint +## Integrating Authorizer with an Existing SSO -**Endpoint:** `GET /authorize` +If you already run a commercial or enterprise SSO (Auth0, Okta, Keycloak, Azure AD, Ping) and want to add Authorizer as an **upstream identity source**, every major platform supports this via OIDC federation. Authorizer behaves like any other third-party OIDC provider. + +Typical reasons to do this: + +- **Bring-your-own-identity for a customer segment** — let customers on a self-hosted plan authenticate via Authorizer while everyone else uses your primary SSO. +- **Cost offload** — route high-volume, low-margin users through Authorizer to avoid per-MAU billing on your main SSO. +- **Private-label multi-tenancy** — each tenant runs their own Authorizer instance, federated into a central Auth0. +- **On-premise / air-gapped** — keep sensitive identities inside a self-hosted Authorizer and federate only short-lived tokens out. + +### Auth0: Add Authorizer as an Enterprise OIDC Connection + +Auth0 calls third-party OIDC identity providers **Enterprise Connections**. + +1. **Register Auth0 as an Authorizer client.** In your Authorizer instance, set the `--client-id` and `--client-secret` that Auth0 will use, and include Auth0's callback URL in `--allowed-origins`: + + ```bash + ./build/server \ + --client-id=auth0-upstream \ + --client-secret="$(openssl rand -hex 32)" \ + --allowed-origins=https://YOUR_TENANT.auth0.com + ``` + + Auth0's OIDC callback URL will be `https://YOUR_TENANT.auth0.com/login/callback` — add that to `--allowed-origins`. + +2. **In Auth0 Dashboard → Authentication → Enterprise → OpenID Connect → Create Connection**, fill in: + + - **Connection Name**: e.g. `authorizer` + - **Issuer URL**: `https://your-authorizer.example` — Auth0 fetches `/.well-known/openid-configuration` automatically + - **Client ID**: `auth0-upstream` (the value you configured in step 1) + - **Client Secret**: the secret from step 1 + - **Type**: Back Channel (authorization code flow with client_secret) + - **Scopes**: `openid profile email` + - Enable **Sync user profile attributes at each login** + +3. **Enable the connection for your Auth0 applications.** Under the connection → Applications tab, toggle on each Auth0 app that should see the new IdP. + +4. **Test.** Open an Auth0 Universal Login page — you should see a new button labeled `authorizer` (or whatever you named the connection). Clicking it redirects through Authorizer and lands back on your Auth0 app as a normal Auth0 user. + +**What Auth0 does under the hood:** it calls Authorizer's `/authorize` endpoint with a code-flow request, exchanges the code at `/oauth/token`, verifies the ID token via `/.well-known/jwks.json`, and calls `/userinfo` to populate the Auth0 user profile. All of these endpoints are implemented by Authorizer. + +### Okta: Add Authorizer as an Identity Provider + +Okta's equivalent feature is called **Identity Providers**. + +1. Register Okta's callback URL in Authorizer's `--allowed-origins`: `https://YOUR_OKTA_DOMAIN/oauth2/v1/authorize/callback` +2. In Okta Admin Console → **Security → Identity Providers → Add Identity Provider → OpenID Connect IdP**: + - **IdP Type**: OIDC + - **Client ID** / **Client Secret**: the Authorizer client credentials + - **Scopes**: `openid profile email` + - **Issuer**: `https://your-authorizer.example` + - **Authorization endpoint**: copy from Authorizer's discovery document + - **Token endpoint**, **JWKS endpoint**, **UserInfo endpoint**: copy from discovery + - **Authentication type**: `Client Secret` +3. Add a **Routing Rule** so the IdP appears on the Okta sign-in page. +4. Test by visiting the Okta sign-in page — the new IdP button appears. + +### Keycloak: Add Authorizer as an Identity Provider + +Keycloak's equivalent is **Identity Providers** as well. + +1. Register Keycloak's callback URL: `https://YOUR_KEYCLOAK/realms/YOUR_REALM/broker/authorizer/endpoint` +2. In Keycloak Admin → **Identity Providers → Add provider → OpenID Connect v1.0**: + - **Alias**: `authorizer` + - **Discovery endpoint**: `https://your-authorizer.example/.well-known/openid-configuration` — click **Import** to autofill the rest + - **Client ID** / **Client Secret**: the Authorizer client credentials + - **Default Scopes**: `openid profile email` +3. Save. The new IdP will appear on the Keycloak login page. + +### Azure AD B2C / Microsoft Entra External ID + +Microsoft Entra External ID supports custom OIDC identity providers. In **Identity Providers → New OpenID Connect provider**, supply the Authorizer metadata URL, client ID, and client secret. Map the `sub` claim to `issuerUserId` and `email` to `email`. + +### Generic pattern + +Any SSO product that supports OIDC federation uses the same inputs: + +| Input | Value | +|--------------------------|---------------------------------------------------------------| +| Issuer / Discovery URL | `https://your-authorizer.example/.well-known/openid-configuration` | +| Client ID | Set via `--client-id` on Authorizer | +| Client Secret | Set via `--client-secret` on Authorizer | +| Scopes | `openid profile email` (+ `offline_access` if you need refresh) | +| Redirect / Callback URL | Provided by the downstream SSO — must be added to Authorizer's `--allowed-origins` | +| Signing algorithm | Whatever `--jwt-type` is set to (prefer RSA/ECDSA) | + +--- + +## Endpoint Reference + +### OpenID Connect Discovery -**Specs:** [RFC 6749 (OAuth 2.0)](https://www.rfc-editor.org/rfc/rfc6749) | [RFC 7636 (PKCE)](https://www.rfc-editor.org/rfc/rfc7636) | [OIDC Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +**Endpoint:** `GET /.well-known/openid-configuration` +**Spec:** [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) -Initiates the OAuth 2.0 authorization flow. Supports Authorization Code (with PKCE), Implicit Token, and Implicit ID Token flows. +Returns metadata so clients can auto-configure themselves. + +**Selected response fields:** + +| Field | Value / Notes | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `issuer` | Base URL of the Authorizer instance | +| `authorization_endpoint` | URL for `/authorize` | +| `token_endpoint` | URL for `/oauth/token` | +| `userinfo_endpoint` | URL for `/userinfo` | +| `jwks_uri` | URL for `/.well-known/jwks.json` | +| `introspection_endpoint` | URL for `/oauth/introspect` | +| `revocation_endpoint` | URL for `/oauth/revoke` | +| `end_session_endpoint` | URL for `/logout` | +| `response_types_supported` | `["code", "token", "id_token", "code id_token", "code token", "code id_token token", "id_token token"]` | +| `grant_types_supported` | `["authorization_code", "refresh_token", "implicit"]` | +| `scopes_supported` | `["openid", "email", "profile", "offline_access"]` | +| `response_modes_supported` | `["query", "fragment", "form_post", "web_message"]` | +| `code_challenge_methods_supported` | `["S256"]` | +| `id_token_signing_alg_values_supported` | Includes configured `--jwt-type` and always `RS256` | +| `token_endpoint_auth_methods_supported` | `["client_secret_basic", "client_secret_post"]` | +| `introspection_endpoint_auth_methods_supported` | `["client_secret_basic", "client_secret_post"]` | +| `revocation_endpoint_auth_methods_supported` | `["client_secret_basic", "client_secret_post"]` | +| `claims_supported` | Includes `sub`, `iss`, `aud`, `exp`, `iat`, `auth_time`, `amr`, `acr`, `at_hash`, `c_hash`, `nonce`, `email`, `email_verified`, `given_name`, `family_name`, profile claims | +| `backchannel_logout_supported` | `true` iff `--backchannel-logout-uri` is configured | +| `backchannel_logout_session_supported` | Same as above | -### Request Parameters +```bash +curl https://your-authorizer.example/.well-known/openid-configuration +``` -| Parameter | Required | Description | -| ----------------------- | ------------------ | ----------------------------------------------------------------- | -| `client_id` | Yes | Your application's client ID | -| `response_type` | Yes | `code`, `token`, or `id_token` | -| `state` | Yes | Anti-CSRF token (opaque string) | -| `redirect_uri` | No | Where to redirect after auth (defaults to `/app`) | -| `scope` | No | Space-separated scopes (default: `openid profile email`) | -| `response_mode` | No | `query`, `fragment`, `form_post`, or `web_message` | -| `code_challenge` | Required for `code`| PKCE S256 challenge: `BASE64URL(SHA256(code_verifier))` | -| `code_challenge_method` | No | Only `S256` is supported (defaults to `S256`) | -| `nonce` | Recommended | Binds ID token to session; required for implicit flows per OIDC | -| `screen_hint` | No | Set to `signup` to show the signup page | +### Authorization Endpoint -### Authorization Code Flow (Recommended) +**Endpoint:** `GET /authorize` +**Specs:** [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) | [RFC 7636 (PKCE)](https://www.rfc-editor.org/rfc/rfc7636) | [OIDC Core 1.0 §3](https://openid.net/specs/openid-connect-core-1_0.html#Authentication) + +Supported flows: Authorization Code (with PKCE), Implicit, Hybrid. + +**Request parameters:** + +| Parameter | Required | Notes | +| ------------------------ | --------------------------- | ------------------------------------------------------------------------------------- | +| `client_id` | Yes | Your application's client ID | +| `response_type` | Yes | Any supported single or hybrid combination (see discovery) | +| `state` | Yes | Anti-CSRF token (opaque string). Mandatory in Authorizer | +| `redirect_uri` | No | Must match an allowed origin; defaults to `/app` | +| `scope` | No | Space-separated. Default: `openid profile email` | +| `response_mode` | No | `query`, `fragment`, `form_post`, `web_message`. Hybrid flows forbid `query` | +| `code_challenge` | Yes, when `code` is in type | PKCE challenge: `BASE64URL(SHA256(code_verifier))` | +| `code_challenge_method` | No | Only `S256` is supported; defaults to `S256` | +| `nonce` | Recommended | Binds ID token to session; required per OIDC when `response_type` includes `id_token` | +| `prompt` | No | `none`, `login`, `consent`, `select_account` (last two are parsed but no-op) | +| `max_age` | No | Seconds; `0` forces re-auth; positive values force re-auth if session is older | +| `login_hint` | No | Pre-fills the email field on the login UI | +| `ui_locales` | No | Forwarded to the login UI as a query parameter | +| `id_token_hint` | No | Advisory ID token; invalid hints are ignored (never cause the request to fail) | +| `screen_hint` | No | Authorizer extension: `signup` redirects to the signup page | + +**Example authorization code request:** ``` GET /authorize? @@ -75,78 +459,59 @@ GET /authorize? &code_challenge=BASE64URL_SHA256_OF_VERIFIER &code_challenge_method=S256 &redirect_uri=https://yourapp.com/callback - &scope=openid profile email + &scope=openid%20profile%20email%20offline_access ``` -**Success response:** Redirects to `redirect_uri?code=AUTH_CODE&state=RANDOM_STATE` - -The `code` is single-use and short-lived per RFC 6749 Section 4.1.2. - -### Implicit Flow +**Example hybrid request (OIDC Core §3.3):** ``` GET /authorize? client_id=YOUR_CLIENT_ID - &response_type=token + &response_type=code%20id_token &state=RANDOM_STATE &nonce=RANDOM_NONCE + &code_challenge=BASE64URL_SHA256_OF_VERIFIER + &code_challenge_method=S256 &redirect_uri=https://yourapp.com/callback + &scope=openid%20profile%20email + &response_mode=fragment ``` -**Success response:** Redirects to `redirect_uri#access_token=...&id_token=...&token_type=Bearer&state=...` - ---- +The response fragment contains **both** `code=` and `id_token=` in a single round trip. -## Token Endpoint +### Token Endpoint **Endpoint:** `POST /oauth/token` +**Specs:** [RFC 6749 §3.2](https://www.rfc-editor.org/rfc/rfc6749#section-3.2) | [RFC 7636 §4.6](https://www.rfc-editor.org/rfc/rfc7636#section-4.6) -**Specs:** [RFC 6749 Section 3.2](https://www.rfc-editor.org/rfc/rfc6749#section-3.2) | [RFC 7636 Section 4.6](https://www.rfc-editor.org/rfc/rfc7636#section-4.6) - -Exchanges an authorization code or refresh token for access/ID tokens. +Exchanges an authorization code or refresh token for access / ID tokens. **Content-Type:** `application/x-www-form-urlencoded` or `application/json` +**Response headers:** `Cache-Control: no-store`, `Pragma: no-cache` (RFC 6749 §5.1) -### Authorization Code Grant +**Authorization Code grant:** -| Parameter | Required | Description | -| --------------- | -------- | ---------------------------------------------------- | -| `grant_type` | Yes | `authorization_code` | -| `code` | Yes | The authorization code from `/authorize` | -| `code_verifier` | Yes\* | The PKCE code verifier (43-128 chars) | -| `client_id` | Yes | Your application's client ID | -| `client_secret` | Yes\* | Required if `code_verifier` is not provided | +| Parameter | Required | Notes | +| --------------- | -------- | ------------------------------------------------------- | +| `grant_type` | Yes | `authorization_code` | +| `code` | Yes | The authorization code from `/authorize` | +| `code_verifier` | Yes\* | PKCE verifier (43–128 chars) | +| `client_id` | Yes | Your client ID | +| `client_secret` | Yes\* | Required if `code_verifier` is not provided | -\*Either `code_verifier` or `client_secret` is required. +\*Either `code_verifier` or `client_secret` is required. Client authentication may also be sent via HTTP Basic Auth. -Client authentication can also be sent via HTTP Basic Auth (`Authorization: Basic base64(client_id:client_secret)`). +**Refresh Token grant:** -```bash -curl -X POST https://your-authorizer.example/oauth/token \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=authorization_code" \ - -d "code=AUTH_CODE" \ - -d "code_verifier=YOUR_CODE_VERIFIER" \ - -d "client_id=YOUR_CLIENT_ID" -``` - -### Refresh Token Grant +| Parameter | Required | Notes | +| --------------- | -------- | ---------------------------------------------- | +| `grant_type` | Yes | `refresh_token` | +| `refresh_token` | Yes | A valid refresh token | +| `client_id` | Yes | Your client ID | -| Parameter | Required | Description | -| --------------- | -------- | -------------------------- | -| `grant_type` | Yes | `refresh_token` | -| `refresh_token` | Yes | A valid refresh token | -| `client_id` | Yes | Your application's client ID | - -```bash -curl -X POST https://your-authorizer.example/oauth/token \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "grant_type=refresh_token" \ - -d "refresh_token=YOUR_REFRESH_TOKEN" \ - -d "client_id=YOUR_CLIENT_ID" -``` +Refresh tokens are **rotated** on each use — the old one is invalidated and a new one returned. -### Success Response +**Success response:** ```json { @@ -159,104 +524,107 @@ curl -X POST https://your-authorizer.example/oauth/token \ } ``` -### Error Response +**Error response:** ```json -{ - "error": "invalid_grant", - "error_description": "The authorization code is invalid or has expired" -} +{ "error": "invalid_grant", "error_description": "..." } ``` -Standard error codes: `invalid_request`, `invalid_client`, `invalid_grant`, `unsupported_grant_type`, `invalid_scope`. - ---- +Standard codes: `invalid_request`, `invalid_client`, `invalid_grant`, `unsupported_grant_type`, `invalid_scope`. -## UserInfo Endpoint +### UserInfo Endpoint **Endpoint:** `GET /userinfo` +**Specs:** [OIDC Core §5.3](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) | [OIDC Core §5.4 (scope-based claim filtering)](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) | [RFC 6750 (Bearer Token)](https://www.rfc-editor.org/rfc/rfc6750) -**Specs:** [OIDC Core Section 5.3](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) | [RFC 6750 (Bearer Token)](https://www.rfc-editor.org/rfc/rfc6750) - -Returns claims about the authenticated end-user. +Returns claims about the authenticated end-user, **filtered by the scopes encoded in the access token**. ```bash -curl -H "Authorization: Bearer ACCESS_TOKEN" \ - https://your-authorizer.example/userinfo +curl -H "Authorization: Bearer ACCESS_TOKEN" https://your-authorizer.example/userinfo ``` -### Success Response - -```json -{ - "sub": "user-uuid", - "email": "user@example.com", - "email_verified": true, - "given_name": "Jane", - "family_name": "Doe", - "picture": "https://example.com/photo.jpg", - "roles": "user" -} -``` +**Scope → claim mapping** (OIDC Core §5.4): -The `sub` claim is always returned per OIDC Core Section 5.3.2. +| Scope | Claims returned in addition to `sub` | +|-----------|-------------------------------------------------------------------------------------------------------------------------------| +| `profile` | `name`, `family_name`, `given_name`, `middle_name`, `nickname`, `preferred_username`, `profile`, `picture`, `website`, `gender`, `birthdate`, `zoneinfo`, `locale`, `updated_at` | +| `email` | `email`, `email_verified` | +| `phone` | `phone_number`, `phone_number_verified` | +| `address` | `address` | -### Error Response +The `sub` claim is **always** returned per OIDC Core §5.3.2. Keys belonging to a granted scope group are always present in the response; if the user has no value for a specific claim, the key is emitted with JSON `null` (explicitly permitted by §5.3.2) so callers can rely on a stable schema. -When the token is missing or invalid, the response includes the `WWW-Authenticate` header: +**Error response (RFC 6750 §3):** ``` HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="authorizer", error="invalid_token", error_description="The access token is invalid or has expired" ``` ---- +### Token Introspection -## Token Revocation Endpoint +**Endpoint:** `POST /oauth/introspect` +**Spec:** [RFC 7662 (OAuth 2.0 Token Introspection)](https://www.rfc-editor.org/rfc/rfc7662) -**Endpoint:** `POST /oauth/revoke` +Used by resource servers and API gateways to validate tokens without re-implementing JWT verification. -**Spec:** [RFC 7009 (Token Revocation)](https://www.rfc-editor.org/rfc/rfc7009) +**Content-Type:** `application/x-www-form-urlencoded` +**Response headers:** `Cache-Control: no-store`, `Pragma: no-cache` -Revokes a refresh token. Per RFC 7009, this endpoint returns HTTP 200 even for invalid or already-revoked tokens (to prevent token scanning). +**Client authentication:** `client_secret_basic` (HTTP Basic) or `client_secret_post` (form body). -**Content-Type:** `application/x-www-form-urlencoded` or `application/json` +**Request parameters:** -| Parameter | Required | Description | -| ----------------- | -------- | ---------------------------------------- | -| `token` | Yes | The refresh token to revoke | -| `client_id` | Yes | Your application's client ID | -| `token_type_hint` | No | `refresh_token` or `access_token` | +| Parameter | Required | Notes | +| ----------------- | -------- | ---------------------------------------------- | +| `token` | Yes | The token to introspect | +| `token_type_hint` | No | `access_token`, `refresh_token`, or `id_token` (unknown hints are ignored, not rejected) | +| `client_id` | Yes | When not using HTTP Basic | +| `client_secret` | Yes | When not using HTTP Basic | -```bash -curl -X POST https://your-authorizer.example/oauth/revoke \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "token=YOUR_REFRESH_TOKEN" \ - -d "client_id=YOUR_CLIENT_ID" +**Active token response:** + +```json +{ + "active": true, + "scope": "openid profile email", + "client_id": "my-app", + "exp": 1712500000, + "iat": 1712496400, + "sub": "user-uuid", + "aud": "my-app", + "iss": "https://your-authorizer.example", + "token_type": "access_token" +} ``` -### Responses +**Inactive token response:** + +```json +{ "active": false } +``` -- **200 OK** — Token was revoked (or was already invalid) -- **400 Bad Request** — Missing `client_id` or unsupported `token_type_hint` -- **401 Unauthorized** — Invalid `client_id` -- **503 Service Unavailable** — Server temporarily unable to process +Per RFC 7662 §2.2, the inactive response **never** contains any other fields — no `error`, no `error_description`, no claim leakage. A missing/expired/revoked/wrong-audience token all look identical to the client. ---- +### Token Revocation -## JSON Web Key Set Endpoint +**Endpoint:** `POST /oauth/revoke` +**Spec:** [RFC 7009](https://www.rfc-editor.org/rfc/rfc7009) -**Endpoint:** `GET /.well-known/jwks.json` +Revokes a refresh token. Per RFC 7009 §2.2, returns HTTP 200 even for invalid or already-revoked tokens (prevents token scanning). -**Spec:** [RFC 7517 (JWK)](https://www.rfc-editor.org/rfc/rfc7517) +| Parameter | Required | Notes | +| ----------------- | -------- | --------------------------------------- | +| `token` | Yes | The refresh token to revoke | +| `token_type_hint` | No | `refresh_token` or `access_token` | +| `client_id` | Yes | Your client ID (or via HTTP Basic) | -Returns the public keys used to verify JWT signatures. Clients use this to validate access tokens and ID tokens. +### JWKS -```bash -curl https://your-authorizer.example/.well-known/jwks.json -``` +**Endpoint:** `GET /.well-known/jwks.json` +**Spec:** [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517) -### Response +Public signing keys for JWT verification. Supports RSA (`RS256/384/512`), ECDSA (`ES256/384/512`), and HMAC. **HMAC secrets are never exposed** — the array is empty in HMAC-only configurations. ```json { @@ -273,48 +641,81 @@ curl https://your-authorizer.example/.well-known/jwks.json } ``` -Supports RSA (`RS256`, `RS384`, `RS512`), ECDSA (`ES256`, `ES384`, `ES512`), and HMAC (`HS256`, `HS384`, `HS512`) algorithms depending on configuration. +#### Manual key rotation ---- +Authorizer supports a zero-downtime manual key-rotation workflow via four optional secondary-key flags: -## Logout Endpoint +- `--jwt-secondary-type` +- `--jwt-secondary-secret` +- `--jwt-secondary-private-key` +- `--jwt-secondary-public-key` -**Endpoint:** `GET /logout` +When a secondary key is configured, JWKS publishes **both** public keys with distinct `kid`s (the secondary gets a `-secondary` suffix). The signing path always uses the primary key; verification tries the primary first and falls back to the secondary. -**Spec:** [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) +**Rotation workflow:** -Ends the user's session and optionally redirects. +1. Operator adds a new key as `--jwt-secondary-*` and restarts +2. JWKS now publishes both keys; both can verify existing tokens +3. Operator swaps: new key becomes primary (`--jwt-*`), old key becomes secondary (`--jwt-secondary-*`), restart +4. Outstanding tokens signed by the now-secondary key keep working +5. After all outstanding tokens expire, operator removes the `--jwt-secondary-*` flags and restarts -| Parameter | Required | Description | -| -------------- | -------- | ------------------------------------ | -| `redirect_uri` | No | URL to redirect to after logout | +Automated time-based rotation is a future roadmap item. -```bash -GET /logout?redirect_uri=https://yourapp.com -``` +### Logout (RP-Initiated) + +**Endpoint:** `GET /logout` or `POST /logout` +**Spec:** [OIDC RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) + +| Parameter | Notes | +| --------------------------- | ------------------------------------------------------------------------------------------- | +| `post_logout_redirect_uri` | Preferred (OIDC spec name). Must be in `--allowed-origins` | +| `redirect_uri` | Legacy alias — accepted as fallback | +| `state` | Echoed on the final redirect per §3 | +| `id_token_hint` | Proves the request comes from a real authenticated session (CSRF defense for GET) | + +**GET without `id_token_hint`** renders an HTML confirmation page — the actual session deletion only happens via the subsequent POST. This prevents `