An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

docs: add OAuth token endpoint design plan (MM-77)

Completed brainstorming session. Design includes:
- ES256 persistent signing key (oauth_signing_key table, AES-256-GCM encrypted)
- DPoP server nonces (in-memory store, 5-min TTL, required on all token requests)
- Refresh tokens only in DB (JWT-only access tokens, no revocation overhead)
- 6 implementation phases: schema → signing key → nonces → routing → auth_code grant → refresh grant

+250
+250
docs/design-plans/2026-03-22-MM-77.md
··· 1 + # OAuth Token Endpoint Design (MM-77) 2 + 3 + ## Summary 4 + 5 + `POST /oauth/token` is the grant-dispensing endpoint at the center of the relay's OAuth 2.0 implementation. It receives a request from a client that has already completed the authorization flow (MM-76) and exchanges the short-lived authorization code — or a previously issued refresh token — for a pair of tokens: a five-minute access token and a rotating refresh token. Because the relay targets the ATProto OAuth profile, every token request must also carry a DPoP proof: a small signed JWT that cryptographically binds the request to the client's keypair and to a server-issued nonce, preventing replay attacks and token theft. 6 + 7 + The implementation is organized as four infrastructure phases followed by two grant-type phases. The first three phases build the prerequisites that both grant types share: a V012 database migration (adding the `jkt` column and the `oauth_signing_key` table), startup logic that loads or generates a persistent ES256 server keypair and places it in `AppState`, and an in-memory nonce store that issues, validates, and prunes server-side DPoP nonces. Phase 4 registers the route and wires up request parsing. Phases 5 and 6 implement the `authorization_code` and `refresh_token` grant paths respectively, each running the same DPoP validation gate before their grant-specific logic (PKCE verification for code exchange; DPoP key-binding check for refresh rotation). Both grants call the same token-issuance utilities — an ES256 JWT for the access token and a hashed 32-byte random value for the refresh token — following patterns already established in the codebase for relay signing keys and session tokens. 8 + 9 + ## Definition of Done 10 + 11 + - `POST /oauth/token` accepts `application/x-www-form-urlencoded` and handles both `authorization_code` and `refresh_token` grant types. 12 + - PKCE (S256) is verified for authorization code exchange: `base64url(sha256(code_verifier))` must equal the stored `code_challenge`. 13 + - DPoP proofs are validated on every token request: correct `typ`, `alg`, `htm`/`htu`, fresh `iat`, and a valid server-issued nonce. 14 + - Server-issued DPoP nonces are required. Requests without a valid nonce receive `400 use_dpop_nonce` with a fresh `DPoP-Nonce:` response header. 15 + - Access tokens are ES256 JWTs (`typ: at+jwt`) signed with a persistent server keypair, binding the DPoP key via `cnf.jkt`. Lifetime: 5 minutes. 16 + - Refresh tokens are single-use, 24-hour-lived, stored as SHA-256 hashes in `oauth_tokens`. Rotation deletes the old row and issues a new token. 17 + - All error responses are JSON `{ "error": "...", "error_description": "..." }` per RFC 6749 §5.2. 18 + 19 + ## Acceptance Criteria 20 + 21 + ### MM-77.AC1: Authorization code exchange 22 + - **MM-77.AC1.1 Success:** Valid code + code_verifier + DPoP proof with nonce → 200 with `access_token`, `token_type="DPoP"`, `expires_in=300`, `refresh_token`, `scope` 23 + - **MM-77.AC1.2 Success:** Access token is ES256 JWT with `typ=at+jwt`, `cnf.jkt`, `exp=now+300s` 24 + - **MM-77.AC1.3 Success:** Refresh token plaintext is 43-char base64url; stored row has `scope='com.atproto.refresh'` 25 + - **MM-77.AC1.4 Failure:** Invalid `code_verifier` → 400 `invalid_grant` 26 + - **MM-77.AC1.5 Failure:** Expired auth code (>60s) → 400 `invalid_grant` 27 + - **MM-77.AC1.6 Failure:** Already-consumed code → 400 `invalid_grant` 28 + - **MM-77.AC1.7 Failure:** `client_id` mismatch → 400 `invalid_grant` 29 + - **MM-77.AC1.8 Failure:** `redirect_uri` mismatch → 400 `invalid_grant` 30 + 31 + ### MM-77.AC2: DPoP proof validation 32 + - **MM-77.AC2.1 Success:** Valid DPoP proof (ES256, correct `htm=POST`, `htu=<token endpoint>`, fresh `iat`, non-empty `jti`) accepted 33 + - **MM-77.AC2.2 Success:** Access token `cnf.jkt` matches the DPoP proof's JWK thumbprint 34 + - **MM-77.AC2.3 Failure:** Missing `DPoP:` header → 400 `invalid_dpop_proof` 35 + - **MM-77.AC2.4 Failure:** Wrong `htm` → 400 `invalid_dpop_proof` 36 + - **MM-77.AC2.5 Failure:** Wrong `htu` → 400 `invalid_dpop_proof` 37 + - **MM-77.AC2.6 Failure:** Stale `iat` (>60s) → 400 `invalid_dpop_proof` 38 + 39 + ### MM-77.AC3: DPoP server nonces 40 + - **MM-77.AC3.1 Success:** Request with valid unexpired nonce accepted 41 + - **MM-77.AC3.2 Failure:** No `nonce` claim in DPoP proof → 400 `use_dpop_nonce` + `DPoP-Nonce:` response header 42 + - **MM-77.AC3.3 Failure:** Expired nonce → 400 `use_dpop_nonce` + fresh `DPoP-Nonce:` header 43 + - **MM-77.AC3.4 Failure:** Unknown/fabricated nonce → 400 `use_dpop_nonce` 44 + - **MM-77.AC3.5 Success:** Successful token response includes `DPoP-Nonce:` header with a fresh nonce 45 + 46 + ### MM-77.AC4: Refresh token rotation 47 + - **MM-77.AC4.1 Success:** Valid refresh token + DPoP proof → 200 with new `access_token` and new `refresh_token` 48 + - **MM-77.AC4.2 Success:** Old refresh token row deleted after rotation; second use → 400 `invalid_grant` 49 + - **MM-77.AC4.3 Failure:** Expired refresh token (>24h) → 400 `invalid_grant` 50 + - **MM-77.AC4.4 Failure:** DPoP key thumbprint mismatch → 400 `invalid_grant` 51 + - **MM-77.AC4.5 Failure:** `client_id` mismatch on refresh → 400 `invalid_grant` 52 + 53 + ### MM-77.AC5: Error response format 54 + - **MM-77.AC5.1:** All errors return JSON with `error` and `error_description` string fields 55 + - **MM-77.AC5.2:** Unknown `grant_type` → 400 `unsupported_grant_type` 56 + - **MM-77.AC5.3:** Missing required params → 400 `invalid_request` 57 + - **MM-77.AC5.4:** No HTML in error responses 58 + 59 + ### MM-77.AC6: OAuth signing key persistence 60 + - **MM-77.AC6.1:** First startup generates P-256 keypair, stores encrypted in `oauth_signing_key` 61 + - **MM-77.AC6.2:** Subsequent restarts reload the same key (same `kid` in JWTs) 62 + - **MM-77.AC6.3:** Access tokens use ES256 signing, not HS256 63 + 64 + ## Glossary 65 + 66 + - **OAuth 2.0**: An authorization framework (RFC 6749) that lets a client obtain scoped access to a resource on behalf of a user. The token endpoint (`POST /oauth/token`) is where clients exchange credentials for access and refresh tokens. 67 + - **Grant type**: The OAuth mechanism a client uses to obtain tokens. This document implements two: `authorization_code` (exchange a one-time code for tokens) and `refresh_token` (exchange an expiring refresh token for a new token pair). 68 + - **Authorization code**: A short-lived, single-use token issued by the authorization endpoint (MM-76) after the user consents. The client presents it at the token endpoint within 60 seconds. 69 + - **Access token**: A short-lived credential (5 minutes, `typ: at+jwt`) that the client presents to protected resource endpoints. Signed with the server's ES256 keypair and DPoP-bound via `cnf.jkt`. 70 + - **Refresh token**: A longer-lived (24-hour) single-use credential that lets the client obtain a new access token without user interaction. Stored as a SHA-256 hash; the plaintext is returned to the client once and then discarded (single-use rotation). 71 + - **PKCE (Proof Key for Code Exchange)**: An OAuth extension (RFC 7636) that prevents authorization code interception. The client commits to a `code_challenge` (SHA-256 hash of a random `code_verifier`) at authorization time and proves possession of the verifier at token exchange. 72 + - **S256**: The PKCE challenge method used here. The challenge is `base64url(sha256(code_verifier))`. 73 + - **DPoP (Demonstrating Proof of Possession)**: An OAuth security mechanism (RFC 9449) in which the client signs each token request with an asymmetric keypair, binding the issued tokens to that keypair. Prevents a stolen token from being used by an attacker who does not hold the client's private key. 74 + - **DPoP proof**: A single-use signed JWT (header `typ: dpop+jwt`) sent in the `DPoP:` request header. Contains the HTTP method (`htm`), the request URL (`htu`), an issuance timestamp (`iat`), a unique identifier (`jti`), and (when required by the server) a `nonce` claim. 75 + - **DPoP server nonce**: A short-lived, server-issued value that clients must include in their DPoP proof. Prevents pre-computed proof reuse. The server returns a fresh nonce in the `DPoP-Nonce:` response header; the client must retry with that nonce if it receives `use_dpop_nonce`. 76 + - **`jkt` (JWK Thumbprint)**: A fingerprint of the client's DPoP public key (SHA-256 of the canonical JWK JSON, base64url-encoded). Stored in the `oauth_tokens` table and embedded in the access token's `cnf.jkt` claim to bind the token to the DPoP keypair. 77 + - **`cnf.jkt`**: A standard JWT claim (`confirmation.jkt`) that binds an access token to a specific public key by thumbprint. A resource server validating the token can reject requests where the DPoP proof's key does not match the token's `cnf.jkt`. 78 + - **ES256**: The JWT algorithm identifier for ECDSA signatures over the P-256 curve with SHA-256. Used here for access token signing (`typ: at+jwt`) by the server's persistent keypair, and also for DPoP proofs by the client. 79 + - **`at+jwt`**: The JWT `typ` header value defined by RFC 9068 for OAuth 2.0 access tokens. Distinguishes access tokens from other JWT types so that resource servers can reject misuse. 80 + - **JWK (JSON Web Key)**: A JSON representation of a cryptographic key, defined by RFC 7517. The server's P-256 public key is stored in the `oauth_signing_key` table as a JWK; the DPoP proof's header includes the client's public key as a JWK. 81 + - **AES-256-GCM**: A symmetric authenticated encryption algorithm. Used here (via the existing `crypto::encrypt_private_key()` helper) to encrypt the OAuth signing key's private bytes before storing them in the database. 82 + - **`oauth_signing_key` table**: A new single-row database table that persists the server's ES256 keypair across restarts. The public key is stored as a JWK; the private key is encrypted with `signing_key_master_key`. 83 + - **`oauth_tokens` table**: The existing database table for refresh tokens. Extended in this ticket with a `jkt` column to record the DPoP key thumbprint associated with each token. 84 + - **`oauth_authorization_codes` table**: The existing database table for authorization codes issued during the consent flow. The token endpoint reads and atomically deletes rows from this table during code exchange. 85 + - **Token rotation**: The practice of issuing a new refresh token (and deleting the old one) on every use. If the same refresh token is presented a second time, it has already been deleted and the request fails, signaling a possible token theft. 86 + - **Single-use (consume-on-read)**: The DB pattern used for both authorization codes and refresh tokens: SELECT and DELETE in a single atomic operation. If the row is not found (already consumed or never existed), the grant is rejected. 87 + - **RFC 6749 §5.2**: The section of the OAuth 2.0 specification that defines the error response format for the token endpoint: JSON body with `error` and `error_description` string fields. 88 + - **`use_dpop_nonce`**: The OAuth error code returned when a DPoP proof is missing or carries an invalid nonce. The response also includes a fresh `DPoP-Nonce:` header so the client can retry immediately. 89 + - **`invalid_grant`**: The OAuth error code for any credential that is invalid, expired, revoked, or mismatched (wrong `client_id`, `redirect_uri`, or PKCE verifier). 90 + - **`AppState`**: The Axum shared state struct passed to every request handler. Holds the DB pool, config, signing keypair, and (after this ticket) the DPoP nonce store. 91 + - **`p256` crate**: A Rust implementation of the P-256 elliptic curve (via the `RustCrypto` project). Provides `ecdsa::SigningKey` and `ecdsa::VerifyingKey`, used here for signing access token JWTs and verifying DPoP proofs. 92 + - **Bruno**: A desktop HTTP client. Each relay endpoint has a corresponding `.bru` file in the `bruno/` directory used for manual testing and as living API documentation. 93 + 94 + --- 95 + 96 + ## Architecture 97 + 98 + `POST /oauth/token` is a new route in `crates/relay/` that sits between the existing authorization endpoint (MM-76) and future resource-server validation. It integrates four subsystems: 99 + 100 + 1. **Auth code / refresh token DB** — reads `oauth_authorization_codes` and `oauth_tokens` via `db/oauth.rs`. Both code exchange and refresh use the same hash-then-lookup pattern already in the codebase. 101 + 2. **OAuth signing keypair** — a persistent P-256 keypair stored (encrypted) in a new `oauth_signing_key` table. Loaded at startup into `AppState`; used to sign ES256 access token JWTs. 102 + 3. **DPoP nonce store** — an in-memory `HashMap<String, Instant>` behind a `Mutex`, held in `AppState`. Issues short-lived nonces (5-minute TTL); rotated on every successful token response. 103 + 4. **DPoP proof validation** — extended from the existing implementation in `auth/mod.rs`. Adds a nonce claim check on top of the existing signature/freshness/`htm`/`htu` checks. 104 + 105 + Both grant types flow through the same DPoP validation gate before their grant-specific logic runs. On success both paths call the same token-issuance function (ES256 JWT + `generate_token()` for the refresh token). 106 + 107 + ## Existing Patterns 108 + 109 + **Token hashing** (`crates/relay/src/routes/token.rs`): all tokens are generated as 32 random bytes → base64url (43-char plaintext returned to client) and stored as SHA-256 hex (64-char hash). Auth codes and session tokens already use this pattern; OAuth refresh tokens follow it. 110 + 111 + **DB access functions** (`crates/relay/src/db/oauth.rs`): async functions receiving `&SqlitePool`, returning `Result<_, sqlx::Error>`. Callers convert DB errors to `ApiError` in the handler. No ORM — raw `sqlx::query()`. 112 + 113 + **Signing key lifecycle** (`crates/relay/src/routes/create_signing_key.rs` + `relay_signing_keys` table): generate P-256 keypair via `crypto::generate_p256_keypair()`, encrypt private key with AES-256-GCM via `crypto::encrypt_private_key()` using `config.signing_key_master_key`, insert into DB. The `oauth_signing_key` table mirrors this schema exactly. 114 + 115 + **AppState additions** (`crates/relay/src/app.rs`): new fields added to the `AppState` struct, initialized in `main.rs` after migrations run, and set to deterministic test values in `test_state()`. 116 + 117 + **Route registration** (`crates/relay/src/app.rs`): new routes added to the `app()` function's `Router::new()` chain. 118 + 119 + **DPoP proof validation** (`crates/relay/src/auth/mod.rs`): fully implemented for ES256 — parses DPoP JWT header and claims, verifies JWK signature, checks `typ`/`alg`/`htm`/`htu`/`iat`/`jti`. Phase 3 extends this to also validate the `nonce` claim against the nonce store. 120 + 121 + ## Implementation Phases 122 + 123 + <!-- START_PHASE_1 --> 124 + ### Phase 1: Schema — V012 migration 125 + 126 + **Goal:** Add the `jkt` column to `oauth_tokens` and create the `oauth_signing_key` table. 127 + 128 + **Components:** 129 + - `crates/relay/src/db/migrations/V012__oauth_token_endpoint.sql` 130 + - `ALTER TABLE oauth_tokens ADD COLUMN jkt TEXT` — nullable DPoP key thumbprint; NULL for non-DPoP-bound tokens 131 + - `CREATE TABLE oauth_signing_key (id TEXT NOT NULL, public_key_jwk TEXT NOT NULL, private_key_encrypted TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (id)) WITHOUT ROWID` — stores the single active ES256 server signing key 132 + 133 + **Dependencies:** None (first phase) 134 + 135 + **Done when:** `cargo test` passes — the migration runner applies V012 to the in-memory test DB in all test suites without errors. 136 + <!-- END_PHASE_1 --> 137 + 138 + <!-- START_PHASE_2 --> 139 + ### Phase 2: OAuth signing key infrastructure 140 + 141 + **Goal:** Load or generate the persistent ES256 signing keypair at startup and expose it via `AppState`. 142 + 143 + **Components:** 144 + - `crates/relay/src/db/oauth.rs` — new DB functions: 145 + - `get_oauth_signing_key(pool) -> Result<Option<OAuthSigningKeyRow>, sqlx::Error>` 146 + - `store_oauth_signing_key(pool, id, public_key_jwk, private_key_encrypted) -> Result<(), sqlx::Error>` 147 + - `crates/relay/src/auth/mod.rs` — new `OAuthSigningKey` type holding the decoded P-256 private key (using `p256::ecdsa::SigningKey`) and key ID string; derives `Clone` 148 + - `crates/relay/src/app.rs`: 149 + - `AppState` gains `pub oauth_signing_keypair: OAuthSigningKey` 150 + - `test_state()` generates a fresh keypair (no DB — ephemeral for tests) 151 + - `crates/relay/src/main.rs` — after `run_migrations`: load key from `oauth_signing_key` table; if absent, generate with `crypto::generate_p256_keypair()`, encrypt with `signing_key_master_key`, insert, then decode into `OAuthSigningKey`. Falls back to ephemeral with a warning when `signing_key_master_key` is not configured. 152 + 153 + **Dependencies:** Phase 1 (V012 creates `oauth_signing_key` table) 154 + 155 + **Done when:** Relay starts, key is generated and persisted on first boot, subsequent restarts reuse the same key ID, all existing tests pass with updated `AppState` constructor. 156 + <!-- END_PHASE_2 --> 157 + 158 + <!-- START_PHASE_3 --> 159 + ### Phase 3: DPoP nonce management 160 + 161 + **Goal:** Issue, validate, and rotate server-side DPoP nonces; add the nonce store to `AppState`. 162 + 163 + **Components:** 164 + - `crates/relay/src/auth/mod.rs` — new `DpopNonceStore` type (`Arc<Mutex<HashMap<String, Instant>>>`): 165 + - `issue_nonce() -> String` — generates a random base64url nonce (16 bytes → 22 chars), inserts with `Instant::now() + 5min TTL`, returns the nonce string 166 + - `validate_and_consume(nonce: &str) -> bool` — returns `true` if nonce is present and not expired; removes it from the map 167 + - `cleanup_expired()` — prunes stale entries (called on each token request to prevent unbounded growth) 168 + - `crates/relay/src/app.rs`: 169 + - `AppState` gains `pub dpop_nonces: DpopNonceStore` 170 + - `test_state()` creates a fresh empty store 171 + 172 + **Dependencies:** Phase 2 (AppState established) 173 + 174 + **Done when:** Unit tests in `auth/mod.rs` confirm: issued nonces validate once, expired nonces reject, unknown nonces reject, cleanup removes only expired entries. 175 + <!-- END_PHASE_3 --> 176 + 177 + <!-- START_PHASE_4 --> 178 + ### Phase 4: Token endpoint routing and request parsing 179 + 180 + **Goal:** Register `POST /oauth/token`, parse the form body into typed grant variants, and return correct errors for malformed requests. 181 + 182 + **Components:** 183 + - `crates/relay/src/routes/oauth_token.rs` — new handler file: 184 + - `TokenRequest` enum with `authorization_code` and `refresh_token` variants (deserialized from `application/x-www-form-urlencoded` via `axum::extract::Form`) 185 + - `TokenResponse` struct (`access_token`, `token_type: "DPoP"`, `expires_in: 300`, `refresh_token`, `scope`) 186 + - `OAuthTokenError` struct for RFC 6749 JSON error responses (`error`, `error_description`) 187 + - Handler stub `post_token(State, Form<TokenRequest>, headers) -> Result<Json<TokenResponse>, ...>` — returns `unsupported_grant_type` for unknown grants; full logic added in Phase 5 and 6 188 + - `crates/relay/src/routes/mod.rs` — add `pub mod oauth_token` 189 + - `crates/relay/src/app.rs` — register `.route("/oauth/token", post(oauth_token::post_token))` 190 + - `bruno/oauth_token_authorization_code.bru` — Bruno collection entry for authorization code exchange 191 + - `bruno/oauth_token_refresh.bru` — Bruno collection entry for refresh token rotation 192 + 193 + **Dependencies:** Phase 3 (AppState complete) 194 + 195 + **Done when:** `POST /oauth/token` with unknown `grant_type` returns `400 unsupported_grant_type` JSON; missing `grant_type` returns `400 invalid_request`; all existing tests continue to pass. 196 + <!-- END_PHASE_4 --> 197 + 198 + <!-- START_PHASE_5 --> 199 + ### Phase 5: Authorization code exchange grant 200 + 201 + **Goal:** Implement the full `authorization_code` grant: DPoP validation with nonce, PKCE check, code consumption, and token issuance. 202 + 203 + **Components:** 204 + - `crates/relay/src/db/oauth.rs` — new DB functions: 205 + - `consume_authorization_code(pool, code_hash) -> Result<Option<AuthCodeRow>, sqlx::Error>` — atomic SELECT + DELETE; returns None if not found or expired 206 + - `store_oauth_refresh_token(pool, token_hash, client_id, did, jkt, expires_at) -> Result<(), sqlx::Error>` — inserts into `oauth_tokens` with `scope = 'com.atproto.refresh'` 207 + - `crates/relay/src/auth/mod.rs` — extend existing DPoP proof validation: 208 + - `validate_dpop_proof_with_nonce(proof_jwt, method, url, nonce_store) -> Result<DpopClaims, DpopError>` — wraps existing validation, adds `nonce` claim check; on missing/invalid nonce, returns an error variant that triggers the `use_dpop_nonce` response path 209 + - `crates/relay/src/routes/oauth_token.rs` — implement `authorization_code` grant path: 210 + - Hash presented code → `consume_authorization_code` (returns `invalid_grant` if None) 211 + - Verify `client_id` and `redirect_uri` match stored values 212 + - PKCE: `base64url_no_pad(sha256(code_verifier)) == stored_code_challenge` 213 + - Issue access token: ES256 JWT with `typ: at+jwt`, `cnf.jkt`, `exp: now+300` 214 + - Issue refresh token: `generate_token()` → `store_oauth_refresh_token()` 215 + - Return `TokenResponse` + `DPoP-Nonce:` response header (fresh nonce from store) 216 + 217 + **Dependencies:** Phase 4 (handler + routing), Phase 3 (nonce store) 218 + 219 + **Done when:** Tests cover MM-77.AC1 (code exchange success/failure cases) and MM-77.AC2–AC3 (DPoP proof and nonce validation). All tests pass. 220 + <!-- END_PHASE_5 --> 221 + 222 + <!-- START_PHASE_6 --> 223 + ### Phase 6: Refresh token grant and rotation 224 + 225 + **Goal:** Implement the `refresh_token` grant with DPoP binding verification and single-use rotation. 226 + 227 + **Components:** 228 + - `crates/relay/src/db/oauth.rs` — new DB function: 229 + - `consume_oauth_refresh_token(pool, token_hash) -> Result<Option<RefreshTokenRow>, sqlx::Error>` — SELECT + DELETE in one shot; returns `None` if not found or expired 230 + - `crates/relay/src/routes/oauth_token.rs` — implement `refresh_token` grant path: 231 + - Hash presented refresh token → `consume_oauth_refresh_token` (returns `invalid_grant` if None) 232 + - Verify `client_id` matches stored value 233 + - DPoP binding check: if stored `jkt` is non-null, DPoP proof thumbprint must match → `invalid_grant` on mismatch 234 + - Issue new access + refresh token pair (same as Phase 5 issuance) 235 + - Return `TokenResponse` + fresh `DPoP-Nonce:` header 236 + 237 + **Dependencies:** Phase 5 (token issuance utilities in place) 238 + 239 + **Done when:** Tests cover MM-77.AC4 (refresh rotation success/failure cases). Consuming the same refresh token twice returns `invalid_grant` on the second attempt. All tests pass. 240 + <!-- END_PHASE_6 --> 241 + 242 + ## Additional Considerations 243 + 244 + **Signing key master key:** The OAuth signing key is encrypted with `config.signing_key_master_key` (same key used for relay signing keys). If the master key is not configured, the relay logs a warning and uses an ephemeral OAuth signing key — access tokens issued with an ephemeral key become invalid on restart. Operators running production deployments must configure `signing_key_master_key`. 245 + 246 + **`jwt_secret` retention:** `AppState.jwt_secret` (HS256) is retained for the session-based auth flow (`require_session` / `AuthenticatedUser`). It is not used for OAuth token signing. The two signing paths remain independent. 247 + 248 + **DPoP nonce growth:** `cleanup_expired()` is called on every token request. Under normal load (low request volume) this is sufficient. If the relay ever sees high token request volume, a background sweeper would be more appropriate. 249 + 250 + **Bruno collection `seq` numbers:** The two new `.bru` files (Phase 4) must use the next available `seq` values after the existing OAuth entries.