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 ( + + + + ); +} + +function MyApp() { + const { user, loading, authorizerRef } = useAuthorizer(); + if (loading) return

Loading…

; + if (!user) { + return ; + } + return

Hello {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 `` attacks. + +### Back-Channel Logout (opt-in) + +**Spec:** [OIDC Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html) + +When the server is started with `--backchannel-logout-uri=https://your-rp.example/bcl`, every successful `/logout` fires a signed `logout_token` JWT via HTTP POST to that URL (fire-and-forget, 5-second timeout). -If no `redirect_uri` is provided, returns JSON: `{"message": "Logged out successfully"}`. +**`logout_token` claims:** + +- `iss`, `aud`, `iat`, `exp` (+5 minutes), `jti` (UUID) +- `sub` (user ID), `sid` (session identifier) +- `events`: `{"http://schemas.openid.net/event/backchannel-logout": {}}` +- **`nonce` is deliberately absent** (explicitly prohibited by §2.4) + +The `logout_token` is signed with the same key as ID tokens, so the receiver verifies it via the same JWKS endpoint. Discovery advertises `backchannel_logout_supported: true` when the URI is configured. --- -## PKCE (Proof Key for Code Exchange) Guide +## PKCE Guide -PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)) is required for the authorization code flow. It prevents authorization code interception attacks. +PKCE ([RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)) is required for the authorization code flow and prevents authorization code interception attacks. -### Step 1: Generate Code Verifier +### 1. Generate a code verifier -A random string of 43-128 characters from `[A-Za-z0-9-._~]`: +A random string of 43–128 characters from `[A-Za-z0-9-._~]`: ```javascript const codeVerifier = generateRandomString(43); ``` -### Step 2: Create Code Challenge +```bash +# Bash equivalent +openssl rand -base64 48 | tr -d '=+/' | cut -c1-64 +``` + +### 2. Create a code challenge ```javascript const hash = await crypto.subtle.digest( "SHA-256", - new TextEncoder().encode(codeVerifier) + new TextEncoder().encode(codeVerifier), ); const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash))) .replace(/\+/g, "-") @@ -322,13 +723,18 @@ const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash))) .replace(/=+$/, ""); ``` -### Step 3: Start Authorization +```bash +# Bash equivalent +printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr -d '=' | tr '/+' '_-' +``` + +### 3. Send it on `/authorize` ``` -GET /authorize?response_type=code&code_challenge=CODE_CHALLENGE&code_challenge_method=S256&... +GET /authorize?response_type=code&code_challenge=CHALLENGE&code_challenge_method=S256&... ``` -### Step 4: Exchange Code +### 4. Exchange at `/oauth/token` ``` POST /oauth/token @@ -337,14 +743,217 @@ grant_type=authorization_code&code=AUTH_CODE&code_verifier=CODE_VERIFIER&client_ --- -## Standards Compliance Summary - -| Standard | Status | Notes | -| ------------------------- | ----------- | --------------------------------------------- | -| RFC 6749 (OAuth 2.0) | Implemented | Authorization Code + Refresh Token grants | -| RFC 7636 (PKCE) | Implemented | S256 method required | -| RFC 7009 (Token Revocation) | Implemented | Returns 200 for invalid tokens | -| RFC 6750 (Bearer Token) | Implemented | WWW-Authenticate on 401 | -| OIDC Core 1.0 | Implemented | ID tokens, UserInfo, nonce | -| OIDC Discovery 1.0 | Implemented | All required + recommended fields | -| RFC 7517 (JWK) | Implemented | RSA, ECDSA, HMAC support | +## Testing Guide + +A practical, copy-paste-able checklist for verifying your Authorizer instance works against every OIDC spec it implements. + +### Prerequisites + +```bash +export AUTHORIZER_URL="http://localhost:8080" +export CLIENT_ID="your-client-id" +export CLIENT_SECRET="your-client-secret" +``` + +You will need `curl`, `jq`, `openssl`, and a web browser. + +### 1. Discovery + +```bash +curl -s $AUTHORIZER_URL/.well-known/openid-configuration | jq +``` + +**Check:** `issuer` matches `$AUTHORIZER_URL`; `response_types_supported` contains the hybrid combinations; `introspection_endpoint` is present; `registration_endpoint` is absent; `backchannel_logout_supported` is `true` iff the flag is set. + +### 2. JWKS + +```bash +curl -s $AUTHORIZER_URL/.well-known/jwks.json | jq +``` + +**Check:** HTTP 200; RSA/ECDSA keys include `kty`, `alg`, `kid`, `use: "sig"`; HMAC-only configs return `keys: []`; multi-key configs return two keys with distinct `kid`s. + +### 3. Authorization Code + PKCE (end-to-end) + +```bash +# 1. Generate verifier + challenge +CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '=+/' | cut -c1-64) +CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -binary -sha256 | openssl base64 | tr -d '=' | tr '/+' '_-') +STATE=$(openssl rand -hex 16) + +# 2. Print the URL to open in a browser +echo "$AUTHORIZER_URL/authorize?client_id=$CLIENT_ID&response_type=code&redirect_uri=http://localhost:3000/callback&scope=openid%20profile%20email%20offline_access&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&response_mode=query" +``` + +After logging in and copying the `code` from the redirect: + +```bash +CODE="paste-code-here" + +curl -s -X POST $AUTHORIZER_URL/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=$CODE" \ + -d "code_verifier=$CODE_VERIFIER" \ + -d "client_id=$CLIENT_ID" \ + -d "redirect_uri=http://localhost:3000/callback" | jq +``` + +**Check:** + +- HTTP 200 with `Cache-Control: no-store` header +- Response contains `access_token`, `id_token`, `refresh_token`, `token_type: "Bearer"`, `expires_in`, `scope` +- ID token payload (decode via [jwt.io](https://jwt.io)) contains: `iss`, `aud`, `sub`, `exp`, `iat`, `auth_time`, `amr`, `acr="0"`, `at_hash`, and `nonce` if supplied + +**Verify `at_hash` manually:** + +```bash +ACCESS_TOKEN="paste-access-token-here" +printf '%s' "$ACCESS_TOKEN" | openssl dgst -binary -sha256 | head -c 16 | openssl base64 | tr -d '=' | tr '/+' '_-' +``` + +Must equal the `at_hash` claim in the ID token. + +**Refresh the token:** + +```bash +REFRESH_TOKEN="paste-refresh-token-here" + +curl -s -X POST $AUTHORIZER_URL/oauth/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token" \ + -d "refresh_token=$REFRESH_TOKEN" \ + -d "client_id=$CLIENT_ID" | jq +``` + +**Check:** new tokens returned; the new refresh token differs from the old; the old refresh token no longer works (rotation). + +**Revoke the refresh token:** + +```bash +curl -s -o /dev/null -w "%{http_code}\n" -X POST $AUTHORIZER_URL/oauth/revoke \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=$REFRESH_TOKEN" \ + -d "client_id=$CLIENT_ID" \ + -d "token_type_hint=refresh_token" +# Must print 200 +``` + +### 4. UserInfo scope filtering + +```bash +curl -s -H "Authorization: Bearer $ACCESS_TOKEN" $AUTHORIZER_URL/userinfo | jq +``` + +Run the authorization code flow three times with different scope sets and observe: + +- `scope=openid` → response is `{"sub": "..."}` +- `scope=openid email` → adds `email`, `email_verified` +- `scope=openid profile email` → adds the full profile claim group + +### 5. Token Introspection + +```bash +curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -X POST $AUTHORIZER_URL/oauth/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=$ACCESS_TOKEN" | jq +``` + +**Check:** active token returns `active: true` + full claim set. Invalid token returns **exactly** `{"active": false}` with no leakage. Wrong client secret via HTTP Basic returns 401 with `WWW-Authenticate: Basic`. + +### 6. Hybrid flow + +```bash +# This MUST be rejected (query mode forbidden for hybrid per OIDC Core §3.3.2.5) +curl -sG $AUTHORIZER_URL/authorize \ + --data-urlencode "client_id=$CLIENT_ID" \ + --data-urlencode "response_type=code id_token" \ + --data-urlencode "response_mode=query" \ + --data-urlencode "state=$STATE" \ + --data-urlencode "code_challenge=$CODE_CHALLENGE" | jq +# {"error": "invalid_request", ...} +``` + +Full hybrid flow via browser: + +``` +$AUTHORIZER_URL/authorize?client_id=$CLIENT_ID&response_type=code%20id_token&redirect_uri=http://localhost:3000/callback&scope=openid%20profile%20email&state=$STATE&nonce=N&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256 +``` + +**Check:** redirect fragment contains **both** `code=` and `id_token=`; the ID token payload includes a `c_hash` claim (OIDC Core §3.3.2.11). + +### 7. Authorization request parameters + +- **`prompt=none` with no session** must redirect with `error=login_required` (not render the login UI) +- **`prompt=login` with a session** bypasses the session cookie and shows the login UI +- **`max_age=0`** is equivalent to `prompt=login` +- **`login_hint=alice@example.com`** pre-fills the email field + +### 8. Back-channel logout + +Start a local receiver and set `--backchannel-logout-uri` pointed at it: + +```bash +# Terminal 1: receiver +python3 -c " +from http.server import BaseHTTPRequestHandler, HTTPServer +import urllib.parse, base64, json + +class H(BaseHTTPRequestHandler): + def do_POST(self): + body = self.rfile.read(int(self.headers['Content-Length'])).decode() + form = urllib.parse.parse_qs(body) + payload = form['logout_token'][0].split('.')[1] + payload += '=' * (-len(payload) % 4) + print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2)) + self.send_response(200); self.end_headers() + +HTTPServer(('127.0.0.1', 9999), H).serve_forever() +" + +# Terminal 2: start Authorizer with --backchannel-logout-uri=http://127.0.0.1:9999/bcl + +# Terminal 3: sign in through the dashboard, then log out +``` + +**Check in receiver output:** `iss`, `aud`, `sub`, `sid`, `jti`, `iat`, `exp`, `events` containing the BCL event key, and — critically — `nonce` is **absent**. + +### 9. Social SSO providers + +1. Register the provider on its console (Google, GitHub, etc.), using `$AUTHORIZER_URL/oauth_callback/` as the callback +2. Configure the provider in Authorizer: `--google-client-id`, `--google-client-secret`, etc. +3. Restart Authorizer — the login page auto-shows the new button +4. Test: visit `$AUTHORIZER_URL/oauth_login/google?redirectURL=http://localhost:3000/callback&state=$STATE` +5. Verify the resulting ID token has `amr: ["fed"]` (federated authentication) + +### 10. Automated conformance testing + +For pre-production validation, run a full OIDC conformance suite: + +- **[OpenID Foundation Conformance Suite](https://openid.net/certification/instructions/)** — gold-standard; run the **Basic OP**, **Hybrid OP**, and **Introspection** test profiles. +- **[oidcdebugger.com](https://oidcdebugger.com/)** — lightweight in-browser authorization endpoint test harness. +- **[jwt.io](https://jwt.io)** — decode and verify ID tokens against your JWKS. + +--- + +## Common Issues + +| Symptom | Likely cause | +|---------------------------------------------------|----------------------------------------------------------------------------------------------------| +| `invalid_grant` on `/oauth/token` | Code already used, expired, or `code_verifier` doesn't match the original `code_challenge` | +| `invalid_request` on `/authorize` | Missing `state`; or `response_mode=query` with a hybrid `response_type` (forbidden by OIDC Core §3.3.2.5) | +| `/userinfo` returns only `{"sub":"..."}` | Working as designed — request `scope=openid profile email` to receive profile and email claims | +| `unsupported_response_type` | `response_type` value not in the discovery document's `response_types_supported` | +| ID token signature verification fails | JWKS returns a different key than the one used to sign. Check `--jwt-type` and key configuration | +| Social login callback shows `state mismatch` | Cookies blocked, third-party-cookie restrictions, or session cookie expired between redirects | +| Back-channel logout never fires | `--backchannel-logout-uri` not set, or receiver unreachable within 5 seconds | +| Auth0 Enterprise OIDC connection can't discover | Wrong Issuer URL — must be exactly `https://your-authorizer.example` (no trailing slash, no path) | +| `redirect_uri` rejected | Not in `--allowed-origins`. The debug-level log message names the exact URI that was rejected | + +## Debugging Tips + +- **Always check the discovery endpoint first.** Almost every OIDC problem is a configuration mismatch and discovery is the cheapest place to spot it. +- **Decode your tokens** at [jwt.io](https://jwt.io) before debugging further — the claims tell you a lot. +- **Enable debug logging** with `--log-level=debug` to see every OIDC decision, including which scope groups were filtered out of `/userinfo` and why a `prompt=none` request returned `login_required`. +- **Verify clock skew.** If `exp` or `iat` validation is failing, ensure your server and client clocks are within 60 seconds. +- **Audit `--allowed-origins`.** `/authorize` rejects unknown `redirect_uri` values with `invalid_request`. diff --git a/docs/core/rate-limiting.md b/docs/core/rate-limiting.md new file mode 100644 index 0000000..e773aa5 --- /dev/null +++ b/docs/core/rate-limiting.md @@ -0,0 +1,201 @@ +--- +sidebar_position: 8 +title: Rate Limiting +--- + +# Rate Limiting + +Authorizer includes built-in per-IP rate limiting to protect authentication endpoints from brute-force attacks and abuse. Rate limiting is always enabled by default with sensible defaults, and supports multi-replica deployments via Redis. + +## How it works + +Every incoming request is tracked by client IP address. Each IP is allowed a sustained request rate (`--rate-limit-rps`) with a burst allowance (`--rate-limit-burst`). When an IP exceeds the limit, Authorizer responds with `429 Too Many Requests` and a `Retry-After` header. + +**Single instance:** Rate limits are tracked in memory using a token bucket algorithm. No external dependencies required. + +**Multi-replica:** When Redis is configured (`--redis-url`), rate limits are shared across all replicas using an atomic Redis sliding-window counter (Lua script). This ensures consistent enforcement regardless of which replica handles the request. + +> **Fail-open behavior (default):** If Redis becomes temporarily unavailable, the rate limiter allows requests through rather than blocking legitimate users. Auth availability takes priority over rate limiting. Set **`--rate-limit-fail-closed=true`** if you prefer **`503`** responses when the rate-limit backend errors (stricter, can block traffic during Redis outages). + +--- + +## CLI Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--rate-limit-rps` | `30` | Maximum sustained requests per second per IP | +| `--rate-limit-burst` | `20` | Maximum burst size per IP (allows short spikes above the sustained rate) | +| `--rate-limit-fail-closed` | `false` | If `true`, rate-limit backend errors return **503** instead of allowing the request | + +```bash +./build/server \ + --rate-limit-rps=30 \ + --rate-limit-burst=20 +``` + +### Customizing limits + +For high-traffic deployments, increase the limits: + +```bash +./build/server \ + --rate-limit-rps=50 \ + --rate-limit-burst=100 +``` + +For stricter protection (e.g., a small internal deployment): + +```bash +./build/server \ + --rate-limit-rps=5 \ + --rate-limit-burst=10 +``` + +### Disabling rate limiting + +If your infrastructure already provides rate limiting (e.g., API gateway, CDN, or load balancer), you can disable it: + +```bash +./build/server \ + --rate-limit-rps=0 +``` + +--- + +## Exempt endpoints + +The following endpoints are **not** rate limited because they are infrastructure, static assets, or standards-required discovery endpoints: + +| Path | Reason | +|------|--------| +| `/` | Root/info endpoint | +| `/health` | Kubernetes liveness probe | +| `/healthz` | Kubernetes liveness probe | +| `/readyz` | Kubernetes readiness probe | +| `/.well-known/openid-configuration` | OIDC discovery (cacheable, spec-required) | +| `/.well-known/jwks.json` | JWKS endpoint (cacheable, spec-required) | +| `/app/*` | Static frontend assets (login UI) | +| `/dashboard/*` | Static frontend assets (admin UI) | + +All other endpoints are rate limited, including: + +- **`/metrics`** is not on the main HTTP router; it is on a **dedicated** listener with **no** Gin middleware (use a reasonable `scrape_interval` anyway). +- `/playground` (GraphQL playground) +- `/graphql` (all auth mutations: signup, login, reset password, etc.) +- `/oauth/token` (token exchange) +- `/oauth/revoke` (token revocation) +- `/oauth_login/:provider` and `/oauth_callback/:provider` (OAuth flows) +- `/authorize` (OAuth2 authorize) +- `/userinfo` (token-based user info) +- `/verify_email` (email verification) +- `/logout` (session termination) + +--- + +## Rate limit response + +When a client exceeds the rate limit, Authorizer returns: + +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 1 +Content-Type: application/json + +{ + "error": "rate_limit_exceeded", + "error_description": "Too many requests. Please try again later." +} +``` + +The `Retry-After: 1` header tells clients to wait at least 1 second before retrying (per [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585#section-4)). + +--- + +## Multi-replica setup with Redis + +For deployments with multiple Authorizer replicas, configure Redis to ensure rate limits are shared: + +```bash +./build/server \ + --redis-url=redis://user:pass@redis-host:6379/0 \ + --rate-limit-rps=30 \ + --rate-limit-burst=20 +``` + +When `--redis-url` is set, Authorizer automatically uses Redis for both session storage **and** rate limiting. No additional configuration is needed. + +### Redis Cluster + +Redis Cluster is also supported. Provide multiple URLs: + +```bash +./build/server \ + --redis-url="redis://node1:6379,node2:6380,node3:6381" +``` + +### Docker Compose example + +```yaml +services: + authorizer: + image: lakhansamani/authorizer:latest + command: + - --database-type=postgres + - --database-url=postgres://user:pass@db:5432/authorizer + - --redis-url=redis://redis:6379 + - --rate-limit-rps=30 + - --rate-limit-burst=20 + - --client-id=YOUR_CLIENT_ID + - --client-secret=YOUR_CLIENT_SECRET + ports: + - "8080:8080" + depends_on: + - db + - redis + deploy: + replicas: 3 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + db: + image: postgres:15 + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: pass + POSTGRES_DB: authorizer +``` + +--- + +## Monitoring rate limits + +Rate limit rejections appear in Authorizer's HTTP metrics: + +```promql +# Rate of 429 responses (rate-limited requests) +rate(authorizer_http_requests_total{status="429"}[5m]) + +# Rate-limited requests by path +sum(rate(authorizer_http_requests_total{status="429"}[5m])) by (path) +``` + +### Alerting example + +```yaml +groups: + - name: authorizer-rate-limit + rules: + - alert: HighRateLimitRate + expr: rate(authorizer_http_requests_total{status="429"}[5m]) > 1 + for: 5m + labels: + severity: warning + annotations: + summary: "High rate of rate-limited requests" + description: "More than 1 req/sec being rate-limited for 5 minutes. Possible attack or misconfigured client." +``` + +See [Metrics & Monitoring](./metrics-monitoring) for the full metrics reference. diff --git a/docs/core/server-config.md b/docs/core/server-config.md index 1424dc6..bc9de9b 100644 --- a/docs/core/server-config.md +++ b/docs/core/server-config.md @@ -20,13 +20,15 @@ If you are migrating from v1, first skim the high-level [Migration v1 to v2](../ --http-port=8080 \ --host=0.0.0.0 \ --metrics-port=8081 \ + --metrics-host=127.0.0.1 \ --log-level=info ``` - **`--env`**: environment name (for example `production`, `development`). - **`--http-port`**: HTTP listen port (default `8080`). -- **`--host`**: bind address (default `0.0.0.0`). -- **`--metrics-port`**: metrics/health port (default `8081`). +- **`--host`**: bind address for the **main** HTTP server (default `0.0.0.0`). +- **`--metrics-port`**: port for the dedicated **`/metrics`** listener (default `8081`; **must differ** from `--http-port`). Health probes stay on the HTTP port. +- **`--metrics-host`**: bind address for that **dedicated** metrics listener only (default `127.0.0.1`). The main app can listen on all interfaces while metrics stay on loopback. For Docker/Kubernetes scraping from another container/pod, set **`--metrics-host=0.0.0.0`** and keep the metrics port on an internal network only (never on a public load balancer). - **`--log-level`**: one of `debug`, `info`, `warn`, `error`, `fatal`, `panic`. --- @@ -171,7 +173,7 @@ In v2, the `_generate_jwt_keys` mutation is deprecated and returns an error; con --smtp-sender-email=auth@example.com \ --smtp-sender-name="Auth Team" \ --smtp-local-name=authorizer \ - --skip-tls-verification=false + --smtp-skip-tls-verification=false ``` ### Twilio (SMS OTP) @@ -213,10 +215,29 @@ Other supported providers follow the same pattern: --- -## 8. Admin and GraphQL security flags +## 8. Rate limiting + +```bash +./build/server \ + --rate-limit-rps=30 \ + --rate-limit-burst=20 \ + --rate-limit-fail-closed=false +``` + +- **`--rate-limit-rps`**: maximum sustained requests per second per IP (default `30`). Set to `0` to disable. +- **`--rate-limit-burst`**: maximum burst size per IP (default `20`). +- **`--rate-limit-fail-closed`**: when `true`, a failing rate-limit backend returns `503` instead of allowing the request (default `false`, fail-open). + +Rate limiting is always enabled by default. When `--redis-url` is set, limits are shared across replicas via Redis. See [Rate Limiting](./rate-limiting) for full details. + +--- + +## 9. Admin and GraphQL security flags New in v2: + + ```bash ./build/server \ --disable-admin-header-auth=true \ @@ -229,7 +250,7 @@ New in v2: --- -## 9. Discovering all flags +## 10. Discovering all flags To list all available flags and their defaults, run: diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index e4a6878..223cba1 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -26,6 +26,25 @@ Then open `http://localhost:8080/app` for the built-in login UI. --- +## Ports: `EXPOSE`, publishing, and metrics {#docker-ports-exposure} + +The image **`EXPOSE`s `8080` and `8081`**. That only **documents** which ports the application may listen on; it does **not** open them on the Docker host. You choose what to publish with `-p` or Compose `ports:`. + +| Port | Role | Typical use | +|------|------|-------------| +| **8080** | Main HTTP (API, UI, `/healthz`, `/readyz`) | **Yes** — map to the host or front with a reverse proxy / load balancer. | +| **8081** | Prometheus **`/metrics`** (separate listener) | **Depends** — see below. | + +**Recommended defaults** + +- **`docker run` (single container, no in-Docker Prometheus):** publish **only `8080`** (e.g. `-p 8080:8080`). Metrics stay on **`127.0.0.1:8081`** inside the container; that is enough if you scrape from an agent on the **same host** using the container’s network namespace, or you do not need metrics yet. +- **Docker Compose / Swarm with Prometheus as another service:** add **`--metrics-host=0.0.0.0`** so `8081` accepts connections on the **internal** compose network. Prefer **not** adding `"8081:8081"` under `ports:` (avoids exposing metrics on the host). Prometheus should use a service DNS name like `http://authorizer:8081/metrics` on the internal network only. +- **Public internet:** never publish **8081** to a public address. Keep metrics on loopback or an internal network; use auth/network policy at the edge if you must expose a scrape path. + +**Health checks:** the image `HEALTHCHECK` calls **`http://127.0.0.1:8080/healthz`** on the main server only, so liveness works even when metrics are loopback-only. + +--- + ## Using with PostgreSQL ```bash diff --git a/docs/deployment/helm-chart.md b/docs/deployment/helm-chart.md index bfe8215..97eb89a 100644 --- a/docs/deployment/helm-chart.md +++ b/docs/deployment/helm-chart.md @@ -104,6 +104,17 @@ helm install \ | `redis.storageClassName` | Storage class name for Redis PVC | - | | `redis.storage` | Size of Redis PVC | `5Gi` | +### HTTP, metrics, and rate limiting + +| Name | Description | Default | +| ---- | ----------- | ------- | +| `authorizer.http_port` | Main HTTP listen port (`--http-port`); must differ from `metrics_port` | `8080` | +| `authorizer.metrics_port` | Dedicated `/metrics` listener port (`--metrics-port`) | `8081` | +| `authorizer.metrics_host` | Bind address for `/metrics` (`--metrics-host`); `0.0.0.0` for in-cluster Prometheus | `0.0.0.0` | +| `authorizer.rate_limit_rps` | Per-IP sustained RPS (`--rate-limit-rps`); `0` disables | `30` | +| `authorizer.rate_limit_burst` | Per-IP burst size (`--rate-limit-burst`) | `20` | +| `authorizer.rate_limit_fail_closed` | On Redis/rate-limit errors, return 503 (`--rate-limit-fail-closed`) | `false` | + ### Couchbase | Name | Description | Default | diff --git a/docs/deployment/heroku.md b/docs/deployment/heroku.md index f900ee9..5879294 100644 --- a/docs/deployment/heroku.md +++ b/docs/deployment/heroku.md @@ -35,6 +35,20 @@ For Authorizer v2, configure the following required variables in your Heroku app | `CLIENT_ID` | `123456` | | `CLIENT_SECRET` | `secret` | +### Optional: metrics bind address and rate limits + +The [authorizer-heroku](https://github.com/authorizerdev/authorizer-heroku) Dockerfile passes these **Config Vars** through to the binary (shell defaults match Authorizer): + +| Variable | Maps to | Default | Notes | +| -------- | ------- | ------- | ----- | +| `METRICS_HOST` | `--metrics-host` | `127.0.0.1` | `0.0.0.0` only if an internal scraper must reach `METRICS_PORT`; do not publish metrics publicly. | +| `METRICS_PORT` | `--metrics-port` | `8081` | | +| `RATE_LIMIT_RPS` | `--rate-limit-rps` | `30` | `0` disables per-IP limiting. | +| `RATE_LIMIT_BURST` | `--rate-limit-burst` | `20` | | +| `RATE_LIMIT_FAIL_CLOSED` | `--rate-limit-fail-closed` | `false` | `true` → **503** on rate-limit backend errors. | + +Use `REDIS_URL` for shared sessions and rate limits across dynos ([rate limiting](../core/rate-limiting)). + Update the Procfile or startup command to pass CLI flags: ``` diff --git a/docs/deployment/kubernetes.md b/docs/deployment/kubernetes.md index 1e77d93..41f7979 100644 --- a/docs/deployment/kubernetes.md +++ b/docs/deployment/kubernetes.md @@ -18,7 +18,8 @@ If you are migrating from v1, compare with [Kubernetes](../deployment/kubernetes - Container image for **Authorizer v2** built with: ```dockerfile -ENTRYPOINT ["./build/server"] +# Official image listens on 8080 (HTTP) and 8081 (metrics); see Dockerfile EXPOSE comments. +ENTRYPOINT ["./authorizer"] CMD [] ``` @@ -54,6 +55,9 @@ spec: imagePullPolicy: Always ports: - containerPort: 8080 + name: http + - containerPort: 8081 + name: metrics env: - name: DATABASE_URL valueFrom: @@ -78,6 +82,11 @@ spec: args: - "--env=production" - "--http-port=8080" + - "--metrics-port=8081" + - "--metrics-host=0.0.0.0" + - "--rate-limit-rps=30" + - "--rate-limit-burst=20" + - "--rate-limit-fail-closed=false" - "--database-type=postgres" - "--database-url=$(DATABASE_URL)" - "--client-id=$(CLIENT_ID)" @@ -129,6 +138,35 @@ spec: > **Note:** Use Kubernetes `Secret` resources for sensitive values and reference them via `env` + `args` as shown. +### Ports: `containerPort`, Services, and Ingress {#k8s-ports-services} + +- Declare **both** `containerPort: 8080` and **`8081`** on the pod so the API contract matches the image (`EXPOSE 8080 8081` in the Dockerfile). This is documentation for humans and tooling; it does not by itself expose traffic to the internet. +- The **`Service`** that backs your **Ingress** (or cloud load balancer) should forward **only** the app port (**80 → 8080** in the example above). **Do not** add `8081` to that same public-facing `Service` or `Ingress`. +- For Prometheus, scrape **`8081`** via the **pod network** or a **separate ClusterIP `Service`** (below). Set **`--metrics-host=0.0.0.0`** in `args` so the metrics listener accepts connections from other pods; without it, metrics stay on container loopback and in-cluster scrapes will fail. + +**Summary:** expose **both** ports on the **Pod**; expose **only HTTP (8080)** to clients via Ingress/LB; keep **metrics (8081)** internal to the cluster. + +### Metrics (Prometheus) + +The app serves **`/metrics` on a second HTTP listener** on port **`8081`**. In Kubernetes, **`--metrics-host=0.0.0.0`** is usually required so Prometheus can scrape the pod IP. **Do not** put port `8081` on an internet-facing `Ingress`. Use a `ServiceMonitor`, PodMonitor, or a dedicated **ClusterIP** `Service` and scrape config only. If Prometheus runs on the **same host** as a bare binary (not K8s), you can keep the default **`127.0.0.1`** for metrics instead. + +Optional internal `Service` for metrics (ClusterIP only): + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: authorizer-v2-metrics +spec: + selector: + app: authorizer-v2 + ports: + - port: 8081 + targetPort: metrics + name: metrics + type: ClusterIP +``` + Apply the manifest: ```bash diff --git a/docs/deployment/railway.md b/docs/deployment/railway.md index 7b610b1..01c1c6e 100644 --- a/docs/deployment/railway.md +++ b/docs/deployment/railway.md @@ -37,6 +37,20 @@ After deployment, configure the following required variables in Railway's enviro | `CLIENT_ID` | `123456` | | `CLIENT_SECRET` | `secret` | +### Optional: metrics bind address and rate limits + +The [authorizer-railway](https://github.com/authorizerdev/authorizer-railway) Dockerfile maps these environment variables to CLI flags (defaults match the Authorizer binary): + +| Variable | Maps to | Default | When to set | +| -------- | ------- | ------- | ----------- | +| `METRICS_HOST` | `--metrics-host` | `127.0.0.1` | Use `0.0.0.0` only if something on the same private network must scrape `METRICS_PORT` (never expose metrics on the public internet). | +| `METRICS_PORT` | `--metrics-port` | `8081` | Change if the platform collides with this port. | +| `RATE_LIMIT_RPS` | `--rate-limit-rps` | `30` | Lower for stricter protection or raise for busy UIs; `0` disables. | +| `RATE_LIMIT_BURST` | `--rate-limit-burst` | `20` | Short spike allowance per IP. | +| `RATE_LIMIT_FAIL_CLOSED` | `--rate-limit-fail-closed` | `false` | Set `true` to return **503** when the rate-limit backend (e.g. Redis) errors instead of fail-open. | + +Set `REDIS_URL` when you use multiple instances so sessions and rate limits stay consistent ([rate limiting](../core/rate-limiting)). + Update the start command to pass CLI flags: ```bash diff --git a/docs/deployment/render.md b/docs/deployment/render.md index 3eb3520..135f200 100644 --- a/docs/deployment/render.md +++ b/docs/deployment/render.md @@ -41,6 +41,20 @@ Set the following required environment variables: | `CLIENT_ID` | `123456` | | `CLIENT_SECRET` | `secret` | +### Optional: metrics bind address and rate limits + +The [authorizer-render](https://github.com/authorizerdev/authorizer-render) image expands these variables to CLI flags: + +| Variable | Maps to | Default | +| -------- | ------- | ------- | +| `METRICS_HOST` | `--metrics-host` | `127.0.0.1` | +| `METRICS_PORT` | `--metrics-port` | `8081` | +| `RATE_LIMIT_RPS` | `--rate-limit-rps` | `30` | +| `RATE_LIMIT_BURST` | `--rate-limit-burst` | `20` | +| `RATE_LIMIT_FAIL_CLOSED` | `--rate-limit-fail-closed` | `false` | + +See [Metrics & monitoring](../core/metrics-monitoring) and [Rate limiting](../core/rate-limiting). Add `REDIS_URL` if you run more than one instance. + Update the start command to pass CLI flags: ```bash diff --git a/docs/migration/v1-to-v2.md b/docs/migration/v1-to-v2.md index c1b0388..d3c48d2 100644 --- a/docs/migration/v1-to-v2.md +++ b/docs/migration/v1-to-v2.md @@ -217,8 +217,11 @@ Use these v2 **CLI flags** instead of v1 env or dashboard config. Flag names use | `PORT` | `--http-port` (default: 8080) | | Host | `--host` (default: 0.0.0.0) | | Metrics port | `--metrics-port` (default: 8081) | +| Metrics bind | `--metrics-host` (default: `127.0.0.1`) for the dedicated metrics listener | | `LOG_LEVEL` | `--log-level` | +**`GET /metrics` is always** on the dedicated metrics listener at **`--metrics-host`:`--metrics-port`** (default **loopback**); **`--http-port` and `--metrics-port` must differ**. Health probes remain on the HTTP port. For in-cluster Prometheus, set **`--metrics-host=0.0.0.0`** and scrape over the private network. + ### Database | v1 | v2 CLI flag | diff --git a/docusaurus.config.ts b/docusaurus.config.ts index c00358c..a9f1270 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -188,10 +188,18 @@ const config: Config = { }, }, blog: false, - gtag: { - trackingID: process.env.GOOGLE_ANALYTICS_ID || 'G-XXXXXXXXXX', - anonymizeIP: true, - }, + // Only register gtag when a real tracking ID is supplied. + // The plugin loads the gtag script with whatever trackingID + // it receives — passing a placeholder like 'G-XXXXXXXXXX' + // causes the script load to fail (or be blocked locally by + // ad-blockers) and then every page-view call throws + // "window.gtag is not a function" at runtime. + gtag: process.env.GOOGLE_ANALYTICS_ID + ? { + trackingID: process.env.GOOGLE_ANALYTICS_ID, + anonymizeIP: true, + } + : undefined, theme: { customCss: './src/css/custom.css', }, diff --git a/sidebars.ts b/sidebars.ts index 19154d8..4ff4988 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -22,6 +22,7 @@ const sidebars: SidebarsConfig = { 'core/graphql-api', 'core/oauth2-oidc', 'core/email', + 'core/rate-limiting', 'core/metrics-monitoring', ], },