···11+# OAuth Token Endpoint — Phase 1: Schema (V012 Migration)
22+33+**Goal:** Add the `jkt` column to `oauth_tokens` and create the `oauth_signing_key` table.
44+55+**Architecture:** Single SQL migration file + entry in the static MIGRATIONS array. No application-level code changes. The migration runner applies V012 to every in-memory test DB at test startup, so all existing tests act as a smoke test.
66+77+**Tech Stack:** SQLite (raw DDL), `include_str!()` for compile-time file embedding.
88+99+**Scope:** Phase 1 of 6
1010+1111+**Codebase verified:** 2026-03-22
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase is infrastructure-only. It enables the `oauth_signing_key` table used by AC6 and the `jkt` column used by AC4.4, but does not implement or directly test those criteria.
1818+1919+**Verifies: None** — done when `cargo test` passes (migrations apply without error).
2020+2121+---
2222+2323+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2424+2525+<!-- START_TASK_1 -->
2626+### Task 1: Create V012 SQL migration file
2727+2828+**Files:**
2929+- Create: `crates/relay/src/db/migrations/V012__oauth_token_endpoint.sql`
3030+3131+**Step 1: Create the SQL file**
3232+3333+Create `crates/relay/src/db/migrations/V012__oauth_token_endpoint.sql` with this exact content:
3434+3535+```sql
3636+-- V012: OAuth token endpoint schema additions
3737+-- Applied in a single transaction by the migration runner.
3838+--
3939+-- Adds DPoP key thumbprint (jkt) to oauth_tokens for DPoP-bound refresh tokens.
4040+-- Creates oauth_signing_key single-row table for the server's persistent ES256 keypair.
4141+4242+-- DPoP key thumbprint — NULL for tokens issued before V012 or without DPoP binding.
4343+ALTER TABLE oauth_tokens ADD COLUMN jkt TEXT;
4444+4545+-- Single-row table for the server's persistent ES256 signing keypair.
4646+-- WITHOUT ROWID: the key is always fetched by its id (primary key lookup).
4747+CREATE TABLE oauth_signing_key (
4848+ id TEXT NOT NULL, -- UUID key identifier
4949+ public_key_jwk TEXT NOT NULL, -- JWK JSON string (EC P-256 public key)
5050+ private_key_encrypted TEXT NOT NULL, -- base64(nonce(12) || ciphertext(32) || tag(16))
5151+ created_at TEXT NOT NULL, -- ISO 8601 UTC
5252+ PRIMARY KEY (id)
5353+) WITHOUT ROWID;
5454+```
5555+5656+**Step 2: Commit**
5757+5858+```bash
5959+git add crates/relay/src/db/migrations/V012__oauth_token_endpoint.sql
6060+git commit -m "feat(db): V012 migration — oauth_tokens.jkt column + oauth_signing_key table"
6161+```
6262+<!-- END_TASK_1 -->
6363+6464+<!-- START_TASK_2 -->
6565+### Task 2: Register V012 in the migration runner
6666+6767+**Files:**
6868+- Modify: `crates/relay/src/db/mod.rs:73-77`
6969+7070+The MIGRATIONS array ends at line 77 (`];`). V011 is the last entry, spanning lines 73–76. Add V012 after V011, before the closing `];`.
7171+7272+**Step 1: Edit `db/mod.rs`**
7373+7474+Find this block in `crates/relay/src/db/mod.rs` (lines 73–77):
7575+7676+```rust
7777+ Migration {
7878+ version: 11,
7979+ sql: include_str!("migrations/V011__pending_shares.sql"),
8080+ },
8181+];
8282+```
8383+8484+Replace with:
8585+8686+```rust
8787+ Migration {
8888+ version: 11,
8989+ sql: include_str!("migrations/V011__pending_shares.sql"),
9090+ },
9191+ Migration {
9292+ version: 12,
9393+ sql: include_str!("migrations/V012__oauth_token_endpoint.sql"),
9494+ },
9595+];
9696+```
9797+9898+**Step 2: Run tests**
9999+100100+```bash
101101+cargo test
102102+```
103103+104104+Expected: all tests pass. The migration runner applies V012 to every in-memory test DB, confirming the SQL is valid.
105105+106106+**Step 3: Commit**
107107+108108+```bash
109109+git add crates/relay/src/db/mod.rs
110110+git commit -m "feat(db): register V012 migration in runner"
111111+```
112112+<!-- END_TASK_2 -->
113113+114114+<!-- END_SUBCOMPONENT_A -->
···11+# OAuth Token Endpoint — Phase 2: OAuth Signing Key Infrastructure
22+33+**Goal:** Load or generate the persistent ES256 signing keypair at startup and expose it via `AppState`.
44+55+**Architecture:** New `OAuthSigningKey` type in `auth/mod.rs`. DB functions for the `oauth_signing_key` table in `db/oauth.rs`. Startup function `load_or_create_oauth_signing_key` in `auth/mod.rs`. `AppState` gains `oauth_signing_keypair: OAuthSigningKey`. `main.rs` calls the startup function after migrations.
66+77+**Tech Stack:** `p256 0.13` (ecdsa + pkcs8 features), `jsonwebtoken 9` (ES256 EncodingKey), `crypto` crate (generate_p256_keypair, encrypt_private_key, decrypt_private_key), `sqlx` (oauth_signing_key table).
88+99+**Scope:** Phase 2 of 6
1010+1111+**Codebase verified:** 2026-03-22
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+### MM-77.AC6: OAuth signing key persistence
1818+- **MM-77.AC6.1 Success:** First startup generates P-256 keypair, stores encrypted in `oauth_signing_key`
1919+- **MM-77.AC6.2 Success:** Subsequent restarts reload the same key (same `kid` in JWTs)
2020+2121+---
2222+2323+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2424+2525+<!-- START_TASK_1 -->
2626+### Task 1: Add pkcs8 feature to p256 and move to production dependencies
2727+2828+**Files:**
2929+- Modify: `Cargo.toml` (workspace root, line 61)
3030+- Modify: `crates/relay/Cargo.toml` (move p256 from dev-deps to deps)
3131+3232+The workspace p256 dependency currently only has `features = ["ecdsa"]`. The `pkcs8` feature is required for `SigningKey::to_pkcs8_der()`, which is needed to construct a `jsonwebtoken::EncodingKey`. Additionally, `p256` is currently only in relay's `[dev-dependencies]`; production code in `auth/mod.rs` will use it, so it must move to `[dependencies]`.
3333+3434+**Step 1: Edit workspace `Cargo.toml`**
3535+3636+Find this line (line 61):
3737+3838+```toml
3939+p256 = { version = "0.13", features = ["ecdsa"] }
4040+```
4141+4242+Replace with:
4343+4444+```toml
4545+p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }
4646+```
4747+4848+**Step 2: Edit `crates/relay/Cargo.toml`**
4949+5050+In `[dependencies]`, after the `jsonwebtoken` line, add:
5151+5252+```toml
5353+p256 = { workspace = true }
5454+```
5555+5656+The `p256` entry in `[dev-dependencies]` at the bottom of the file remains as-is (it's harmless to have it in both; dev-deps inherit from deps).
5757+5858+**Step 3: Verify compilation**
5959+6060+```bash
6161+cargo build -p relay
6262+```
6363+6464+Expected: compiles without errors. The `pkcs8` feature makes `p256::pkcs8::EncodePrivateKey` trait available.
6565+6666+**Step 4: Commit**
6767+6868+```bash
6969+git add Cargo.toml crates/relay/Cargo.toml
7070+git commit -m "build(relay): add p256 pkcs8 feature + move to production dependencies"
7171+```
7272+<!-- END_TASK_1 -->
7373+7474+<!-- START_TASK_2 -->
7575+### Task 2: Add DB functions for oauth_signing_key table
7676+7777+**Files:**
7878+- Modify: `crates/relay/src/db/oauth.rs`
7979+8080+Add `OAuthSigningKeyRow`, `get_oauth_signing_key`, and `store_oauth_signing_key` to `db/oauth.rs`. Append them after the existing `get_single_account_did` function, before the `#[cfg(test)]` block.
8181+8282+**Step 1: Append to `crates/relay/src/db/oauth.rs`**
8383+8484+Find the line `pub async fn get_single_account_did...` block and append after it (before `#[cfg(test)]`):
8585+8686+```rust
8787+/// A row from the `oauth_signing_key` table.
8888+pub struct OAuthSigningKeyRow {
8989+ pub id: String,
9090+ pub public_key_jwk: String,
9191+ pub private_key_encrypted: String,
9292+}
9393+9494+/// Load the server's OAuth signing key row. Returns `None` if no key has been generated yet.
9595+pub async fn get_oauth_signing_key(
9696+ pool: &SqlitePool,
9797+) -> Result<Option<OAuthSigningKeyRow>, sqlx::Error> {
9898+ let row: Option<(String, String, String)> = sqlx::query_as(
9999+ "SELECT id, public_key_jwk, private_key_encrypted FROM oauth_signing_key LIMIT 1",
100100+ )
101101+ .fetch_optional(pool)
102102+ .await?;
103103+104104+ Ok(row.map(|(id, public_key_jwk, private_key_encrypted)| OAuthSigningKeyRow {
105105+ id,
106106+ public_key_jwk,
107107+ private_key_encrypted,
108108+ }))
109109+}
110110+111111+/// Persist a newly generated OAuth signing key.
112112+///
113113+/// `id` is a UUID string. `public_key_jwk` is a JWK JSON string for the P-256 public key.
114114+/// `private_key_encrypted` is the AES-256-GCM-encrypted private key (base64, 80 chars).
115115+pub async fn store_oauth_signing_key(
116116+ pool: &SqlitePool,
117117+ id: &str,
118118+ public_key_jwk: &str,
119119+ private_key_encrypted: &str,
120120+) -> Result<(), sqlx::Error> {
121121+ sqlx::query(
122122+ "INSERT INTO oauth_signing_key (id, public_key_jwk, private_key_encrypted, created_at) \
123123+ VALUES (?, ?, ?, datetime('now'))",
124124+ )
125125+ .bind(id)
126126+ .bind(public_key_jwk)
127127+ .bind(private_key_encrypted)
128128+ .execute(pool)
129129+ .await?;
130130+ Ok(())
131131+}
132132+```
133133+134134+**Step 2: Add tests for the new DB functions**
135135+136136+In the `#[cfg(test)]` block at the bottom of `db/oauth.rs`, add:
137137+138138+```rust
139139+ #[tokio::test]
140140+ async fn store_and_retrieve_oauth_signing_key() {
141141+ let pool = test_pool().await;
142142+ store_oauth_signing_key(
143143+ &pool,
144144+ "test-key-uuid-01",
145145+ r#"{"kty":"EC","crv":"P-256","x":"abc","y":"def","kid":"test-key-uuid-01"}"#,
146146+ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
147147+ )
148148+ .await
149149+ .unwrap();
150150+151151+ let row = get_oauth_signing_key(&pool)
152152+ .await
153153+ .unwrap()
154154+ .expect("key should exist after storage");
155155+156156+ assert_eq!(row.id, "test-key-uuid-01");
157157+ assert!(!row.public_key_jwk.is_empty());
158158+ assert!(!row.private_key_encrypted.is_empty());
159159+ }
160160+161161+ #[tokio::test]
162162+ async fn get_oauth_signing_key_returns_none_when_empty() {
163163+ let pool = test_pool().await;
164164+ let result = get_oauth_signing_key(&pool).await.unwrap();
165165+ assert!(result.is_none());
166166+ }
167167+```
168168+169169+**Step 3: Run tests**
170170+171171+```bash
172172+cargo test -p relay db::oauth
173173+```
174174+175175+Expected: all tests pass including the two new ones.
176176+177177+**Step 4: Commit**
178178+179179+```bash
180180+git add crates/relay/src/db/oauth.rs
181181+git commit -m "feat(db): add oauth_signing_key DB functions"
182182+```
183183+<!-- END_TASK_2 -->
184184+185185+<!-- END_SUBCOMPONENT_A -->
186186+187187+<!-- START_SUBCOMPONENT_B (tasks 3-5) -->
188188+189189+<!-- START_TASK_3 -->
190190+### Task 3: Add OAuthSigningKey type and load_or_create function
191191+192192+**Files:**
193193+- Modify: `crates/relay/src/auth/mod.rs`
194194+195195+Add the `OAuthSigningKey` struct and `load_or_create_oauth_signing_key` function. Insert after the existing imports at the top of `auth/mod.rs` (after line 12: `use crate::app::AppState;`).
196196+197197+**Step 1: Add imports and type**
198198+199199+After the existing imports block in `auth/mod.rs`, add:
200200+201201+```rust
202202+use p256::elliptic_curve::sec1::ToEncodedPoint;
203203+use p256::pkcs8::EncodePrivateKey;
204204+use rand_core::RngCore;
205205+use sqlx::SqlitePool;
206206+use std::collections::HashMap;
207207+use std::sync::Arc;
208208+use std::time::Instant;
209209+use tokio::sync::Mutex;
210210+use uuid::Uuid;
211211+212212+/// The server's persistent ES256 signing keypair, held in `AppState`.
213213+///
214214+/// `encoding_key` is derived from the P-256 private key in PKCS#8 DER format, as required by
215215+/// `jsonwebtoken`. `key_id` is a UUID that appears as the `kid` header in issued access tokens.
216216+#[derive(Clone)]
217217+pub struct OAuthSigningKey {
218218+ /// UUID identifier embedded in JWT `kid` header.
219219+ pub key_id: String,
220220+ /// PKCS#8 DER ES256 encoding key for JWT signing.
221221+ pub encoding_key: jsonwebtoken::EncodingKey,
222222+}
223223+224224+/// In-memory store for server-issued DPoP nonces.
225225+///
226226+/// Maps nonce string → expiry `Instant`. Protected by a `Mutex` so handlers can issue,
227227+/// validate, and prune concurrently. Held in `AppState`.
228228+pub type DpopNonceStore = Arc<Mutex<HashMap<String, Instant>>>;
229229+230230+/// Create an empty `DpopNonceStore`.
231231+pub fn new_nonce_store() -> DpopNonceStore {
232232+ Arc::new(Mutex::new(HashMap::new()))
233233+}
234234+```
235235+236236+**Step 2: Add load_or_create_oauth_signing_key**
237237+238238+After the `new_nonce_store` function, add:
239239+240240+```rust
241241+/// Load the OAuth signing key from the database, or generate a new one on first boot.
242242+///
243243+/// If `master_key` is `None`, generates an ephemeral (non-persistent) key and logs a warning.
244244+/// Ephemeral keys are not stored in the DB and invalidate all issued tokens on restart.
245245+pub(crate) async fn load_or_create_oauth_signing_key(
246246+ pool: &SqlitePool,
247247+ master_key: Option<&[u8; 32]>,
248248+) -> anyhow::Result<OAuthSigningKey> {
249249+ use crate::db::oauth::{get_oauth_signing_key, store_oauth_signing_key};
250250+251251+ // Attempt to load an existing key.
252252+ if let Some(row) = get_oauth_signing_key(pool).await? {
253253+ let key = decode_oauth_signing_key(&row.id, &row.private_key_encrypted, master_key)?;
254254+ tracing::info!(key_id = %row.id, "OAuth signing key loaded from database");
255255+ return Ok(key);
256256+ }
257257+258258+ // No key stored yet. Generate one.
259259+ let keypair = crypto::generate_p256_keypair()
260260+ .map_err(|e| anyhow::anyhow!("failed to generate P-256 keypair: {e}"))?;
261261+262262+ let key_id = Uuid::new_v4().to_string();
263263+264264+ // Build JWK for the public key (uncompressed EC point → x, y coordinates).
265265+ let signing_key = p256::ecdsa::SigningKey::from_bytes(
266266+ p256::FieldBytes::from_slice(keypair.private_key_bytes.as_ref()),
267267+ )
268268+ .map_err(|e| anyhow::anyhow!("invalid P-256 private key bytes: {e}"))?;
269269+270270+ let vk = signing_key.verifying_key();
271271+ let point = vk.to_encoded_point(false);
272272+ let x = URL_SAFE_NO_PAD.encode(point.x().expect("P-256 x coordinate"));
273273+ let y = URL_SAFE_NO_PAD.encode(point.y().expect("P-256 y coordinate"));
274274+ let public_key_jwk = serde_json::to_string(&serde_json::json!({
275275+ "kty": "EC",
276276+ "crv": "P-256",
277277+ "x": x,
278278+ "y": y,
279279+ "kid": key_id,
280280+ }))
281281+ .map_err(|e| anyhow::anyhow!("JWK serialization failed: {e}"))?;
282282+283283+ match master_key {
284284+ Some(key) => {
285285+ let encrypted =
286286+ crypto::encrypt_private_key(keypair.private_key_bytes.as_ref(), key)
287287+ .map_err(|e| anyhow::anyhow!("key encryption failed: {e}"))?;
288288+ store_oauth_signing_key(pool, &key_id, &public_key_jwk, &encrypted).await?;
289289+ tracing::info!(key_id = %key_id, "OAuth signing key generated and persisted");
290290+ }
291291+ None => {
292292+ tracing::warn!(
293293+ "signing_key_master_key not configured; \
294294+ OAuth signing key is ephemeral — tokens will be invalidated on restart"
295295+ );
296296+ }
297297+ }
298298+299299+ let encoding_key = build_encoding_key(&signing_key)?;
300300+ Ok(OAuthSigningKey { key_id, encoding_key })
301301+}
302302+303303+/// Decode a stored OAuth signing key row into an `OAuthSigningKey`.
304304+fn decode_oauth_signing_key(
305305+ key_id: &str,
306306+ private_key_encrypted: &str,
307307+ master_key: Option<&[u8; 32]>,
308308+) -> anyhow::Result<OAuthSigningKey> {
309309+ let master_key = master_key.ok_or_else(|| {
310310+ anyhow::anyhow!(
311311+ "signing_key_master_key not configured but an OAuth signing key exists in the DB; \
312312+ cannot decrypt it — set signing_key_master_key in config"
313313+ )
314314+ })?;
315315+316316+ let raw_bytes = crypto::decrypt_private_key(private_key_encrypted, master_key)
317317+ .map_err(|e| anyhow::anyhow!("failed to decrypt OAuth signing key: {e}"))?;
318318+319319+ let signing_key = p256::ecdsa::SigningKey::from_bytes(
320320+ p256::FieldBytes::from_slice(raw_bytes.as_ref()),
321321+ )
322322+ .map_err(|e| anyhow::anyhow!("invalid stored P-256 private key: {e}"))?;
323323+324324+ let encoding_key = build_encoding_key(&signing_key)?;
325325+ Ok(OAuthSigningKey {
326326+ key_id: key_id.to_string(),
327327+ encoding_key,
328328+ })
329329+}
330330+331331+/// Convert a `p256::ecdsa::SigningKey` to a `jsonwebtoken::EncodingKey` via PKCS#8 DER.
332332+fn build_encoding_key(
333333+ signing_key: &p256::ecdsa::SigningKey,
334334+) -> anyhow::Result<jsonwebtoken::EncodingKey> {
335335+ let pkcs8_der = signing_key
336336+ .to_pkcs8_der()
337337+ .map_err(|e| anyhow::anyhow!("PKCS#8 DER encoding failed: {e}"))?;
338338+ jsonwebtoken::EncodingKey::from_ec_der(pkcs8_der.as_bytes())
339339+ .map_err(|e| anyhow::anyhow!("jsonwebtoken EncodingKey construction failed: {e}"))
340340+}
341341+```
342342+343343+**Step 3: Run tests**
344344+345345+```bash
346346+cargo test -p relay
347347+```
348348+349349+Expected: compiles and all tests pass.
350350+351351+**Step 4: Commit**
352352+353353+```bash
354354+git add crates/relay/src/auth/mod.rs
355355+git commit -m "feat(auth): OAuthSigningKey type + load_or_create_oauth_signing_key"
356356+```
357357+<!-- END_TASK_3 -->
358358+359359+<!-- START_TASK_4 -->
360360+### Task 4: Add oauth_signing_keypair to AppState and test_state
361361+362362+**Verifies:** MM-77.AC6.1, MM-77.AC6.2
363363+364364+**Files:**
365365+- Modify: `crates/relay/src/app.rs`
366366+367367+Add `oauth_signing_keypair: OAuthSigningKey` and `dpop_nonces: DpopNonceStore` to `AppState`. Update `test_state_with_plc_url` to initialize both fields. Also import the new types.
368368+369369+**Step 1: Edit `AppState` struct in `app.rs`**
370370+371371+In the imports at the top of `app.rs`, add:
372372+373373+```rust
374374+use crate::auth::{new_nonce_store, DpopNonceStore, OAuthSigningKey};
375375+```
376376+377377+In the `AppState` struct (after the `jwt_secret` field), add:
378378+379379+```rust
380380+ /// Persistent ES256 keypair for signing OAuth access tokens.
381381+ /// Loaded at startup from `oauth_signing_key` table (or generated + stored on first boot).
382382+ pub oauth_signing_keypair: OAuthSigningKey,
383383+ /// In-memory store for server-issued DPoP nonces. Shared across all token endpoint requests.
384384+ pub dpop_nonces: DpopNonceStore,
385385+```
386386+387387+**Step 2: Update `test_state_with_plc_url`**
388388+389389+In the `#[cfg(test)]` section of `app.rs`, add to the imports at the top of `test_state_with_plc_url`:
390390+391391+```rust
392392+ use p256::pkcs8::EncodePrivateKey;
393393+ use rand_core::OsRng;
394394+```
395395+396396+And add this block before the `AppState { ... }` return:
397397+398398+```rust
399399+ // Generate a fresh ephemeral P-256 keypair for tests (no DB persistence needed).
400400+ let test_signing_key = {
401401+ let sk = p256::ecdsa::SigningKey::random(&mut OsRng);
402402+ let pkcs8 = sk.to_pkcs8_der().expect("PKCS#8 encoding must succeed for test key");
403403+ OAuthSigningKey {
404404+ key_id: "test-oauth-key-01".to_string(),
405405+ encoding_key: jsonwebtoken::EncodingKey::from_ec_der(pkcs8.as_bytes())
406406+ .expect("EncodingKey from test PKCS#8 must succeed"),
407407+ }
408408+ };
409409+ let dpop_nonces = new_nonce_store();
410410+```
411411+412412+Add both to the `AppState { ... }` constructor:
413413+414414+```rust
415415+ oauth_signing_keypair: test_signing_key,
416416+ dpop_nonces,
417417+```
418418+419419+**Step 3: Update `test_state_with_keys` in `create_signing_key.rs`**
420420+421421+The `test_state_with_keys` helper in `crates/relay/src/routes/create_signing_key.rs` constructs `AppState` directly. It will fail to compile until it includes the two new fields. Update it to pass the fields through from `base`:
422422+423423+Find the `AppState { ... }` block inside `test_state_with_keys` and add:
424424+425425+```rust
426426+ oauth_signing_keypair: base.oauth_signing_keypair,
427427+ dpop_nonces: base.dpop_nonces,
428428+```
429429+430430+Do the same for the manual `AppState` construction in the `missing_master_key_returns_503` test in the same file.
431431+432432+**Step 4: Run tests**
433433+434434+```bash
435435+cargo test -p relay
436436+```
437437+438438+Expected: all tests compile and pass.
439439+440440+**Step 5: Commit**
441441+442442+```bash
443443+git add crates/relay/src/app.rs crates/relay/src/routes/create_signing_key.rs
444444+git commit -m "feat(app): add oauth_signing_keypair and dpop_nonces to AppState"
445445+```
446446+<!-- END_TASK_4 -->
447447+448448+<!-- START_TASK_5 -->
449449+### Task 5: Wire signing key loading into main.rs startup
450450+451451+**Verifies:** MM-77.AC6.1, MM-77.AC6.2
452452+453453+**Files:**
454454+- Modify: `crates/relay/src/main.rs`
455455+456456+Call `auth::load_or_create_oauth_signing_key` after `run_migrations` and before constructing `AppState`.
457457+458458+**Step 1: Edit `main.rs` `run()` function**
459459+460460+After the `db::run_migrations(&pool)` block (around line 91–103 in `main.rs`), add:
461461+462462+```rust
463463+ let oauth_signing_keypair =
464464+ auth::load_or_create_oauth_signing_key(
465465+ &pool,
466466+ config.signing_key_master_key.as_ref().map(|s| &*s.0),
467467+ )
468468+ .await
469469+ .map_err(|e| {
470470+ tracing::error!(error = %e, "fatal: failed to load OAuth signing key");
471471+ e
472472+ })
473473+ .with_context(|| "failed to load or create OAuth signing keypair")?;
474474+```
475475+476476+**Step 2: Update the `AppState { ... }` constructor**
477477+478478+Find the `let state = app::AppState { ... };` block (around line 132–140) and add the two new fields:
479479+480480+```rust
481481+ oauth_signing_keypair,
482482+ dpop_nonces: auth::new_nonce_store(),
483483+```
484484+485485+**Step 3: Build the binary**
486486+487487+```bash
488488+cargo build -p relay
489489+```
490490+491491+Expected: compiles without errors or warnings.
492492+493493+**Step 4: Run all tests**
494494+495495+```bash
496496+cargo test -p relay
497497+```
498498+499499+Expected: all tests pass.
500500+501501+**Step 5: Commit**
502502+503503+```bash
504504+git add crates/relay/src/main.rs
505505+git commit -m "feat(relay): load OAuth signing key at startup and wire into AppState"
506506+```
507507+<!-- END_TASK_5 -->
508508+509509+<!-- END_SUBCOMPONENT_B -->
···11+# OAuth Token Endpoint — Phase 3: DPoP Nonce Management
22+33+**Goal:** Issue, validate, and prune server-side DPoP nonces. Add nonce functions to `auth/mod.rs` (the type alias and `new_nonce_store()` constructor were already added in Phase 2).
44+55+**Architecture:** Three free functions operating on `&DpopNonceStore` — `issue_nonce`, `validate_and_consume_nonce`, `cleanup_expired_nonces`. All are `pub(crate) async`. The nonce is a 22-char base64url string (16 random bytes). TTL is 5 minutes using monotonic `Instant`. Cleanup is called on every token request to prevent unbounded growth.
66+77+**Tech Stack:** `tokio::sync::Mutex`, `std::time::Instant`, `std::time::Duration`, `base64` (URL_SAFE_NO_PAD), `rand_core::OsRng`.
88+99+**Scope:** Phase 3 of 6
1010+1111+**Codebase verified:** 2026-03-22
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+### MM-77.AC3: DPoP server nonces
1818+- **MM-77.AC3.1 Success:** Request with valid unexpired nonce accepted
1919+- **MM-77.AC3.2 Failure:** No `nonce` claim in DPoP proof → 400 `use_dpop_nonce` + `DPoP-Nonce:` response header
2020+- **MM-77.AC3.3 Failure:** Expired nonce → 400 `use_dpop_nonce` + fresh `DPoP-Nonce:` header
2121+- **MM-77.AC3.4 Failure:** Unknown/fabricated nonce → 400 `use_dpop_nonce`
2222+- **MM-77.AC3.5 Success:** Successful token response includes `DPoP-Nonce:` header with a fresh nonce
2323+2424+AC3.2–AC3.5 are fully tested in Phase 5 (where the token endpoint calls these functions). This phase verifies the nonce store itself in unit tests (AC3.1, AC3.3, AC3.4).
2525+2626+---
2727+2828+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2929+3030+<!-- START_TASK_1 -->
3131+### Task 1: Implement nonce store functions
3232+3333+**Files:**
3434+- Modify: `crates/relay/src/auth/mod.rs`
3535+3636+The `DpopNonceStore` type alias and `new_nonce_store()` function were added to `auth/mod.rs` in Phase 2. Add three new functions directly below them.
3737+3838+**Step 1: Add nonce functions to `auth/mod.rs`**
3939+4040+After the `new_nonce_store()` function (already added in Phase 2), add:
4141+4242+```rust
4343+/// Issue a fresh DPoP nonce with a 5-minute TTL.
4444+///
4545+/// Returns a 22-character base64url string (16 random bytes). The nonce is
4646+/// inserted into the store with an expiry of `Instant::now() + 5 minutes`.
4747+pub(crate) async fn issue_nonce(store: &DpopNonceStore) -> String {
4848+ let mut bytes = [0u8; 16];
4949+ rand_core::OsRng.fill_bytes(&mut bytes);
5050+ let nonce = URL_SAFE_NO_PAD.encode(bytes);
5151+ let expiry = std::time::Instant::now() + std::time::Duration::from_secs(300);
5252+ store.lock().await.insert(nonce.clone(), expiry);
5353+ nonce
5454+}
5555+5656+/// Validate and consume a DPoP nonce.
5757+///
5858+/// Returns `true` if the nonce is present in the store and has not expired.
5959+/// Removes the nonce unconditionally (whether valid or expired) to prevent reuse.
6060+/// Returns `false` for unknown nonces.
6161+pub(crate) async fn validate_and_consume_nonce(store: &DpopNonceStore, nonce: &str) -> bool {
6262+ let mut map = store.lock().await;
6363+ match map.remove(nonce) {
6464+ Some(expiry) => expiry > std::time::Instant::now(),
6565+ None => false,
6666+ }
6767+}
6868+6969+/// Remove all expired nonces from the store.
7070+///
7171+/// Call this on every token request to prevent unbounded memory growth.
7272+/// Under normal relay load (low request volume) this is sufficient without a background task.
7373+pub(crate) async fn cleanup_expired_nonces(store: &DpopNonceStore) {
7474+ let now = std::time::Instant::now();
7575+ store.lock().await.retain(|_, expiry| *expiry > now);
7676+}
7777+```
7878+7979+**Step 2: Confirm compilation**
8080+8181+```bash
8282+cargo build -p relay
8383+```
8484+8585+Expected: compiles without errors.
8686+<!-- END_TASK_1 -->
8787+8888+<!-- START_TASK_2 -->
8989+### Task 2: Unit tests for nonce store functions
9090+9191+**Verifies:** MM-77.AC3.1, MM-77.AC3.3, MM-77.AC3.4
9292+9393+**Files:**
9494+- Modify: `crates/relay/src/auth/mod.rs` (test section)
9595+9696+Add nonce unit tests to the existing `#[cfg(test)]` block in `auth/mod.rs`.
9797+9898+**Step 1: Add tests**
9999+100100+At the end of the `mod tests { ... }` block in `auth/mod.rs`, add:
101101+102102+```rust
103103+ // ── DPoP nonce store tests ────────────────────────────────────────────────
104104+105105+ #[tokio::test]
106106+ async fn issued_nonce_validates_once() {
107107+ // AC3.1: Valid unexpired nonce is accepted.
108108+ let store = new_nonce_store();
109109+ let nonce = issue_nonce(&store).await;
110110+111111+ // First use: valid.
112112+ assert!(
113113+ validate_and_consume_nonce(&store, &nonce).await,
114114+ "freshly issued nonce must validate"
115115+ );
116116+117117+ // Second use: consumed — must fail (even though not expired).
118118+ assert!(
119119+ !validate_and_consume_nonce(&store, &nonce).await,
120120+ "already-consumed nonce must not validate again"
121121+ );
122122+ }
123123+124124+ #[tokio::test]
125125+ async fn unknown_nonce_is_rejected() {
126126+ // AC3.4: Fabricated nonce not in store.
127127+ let store = new_nonce_store();
128128+ assert!(
129129+ !validate_and_consume_nonce(&store, "this-nonce-was-never-issued").await,
130130+ "unknown nonce must be rejected"
131131+ );
132132+ }
133133+134134+ #[tokio::test]
135135+ async fn expired_nonce_is_rejected() {
136136+ // AC3.3: Expired nonce returns false.
137137+ let store = new_nonce_store();
138138+ // Manually insert a nonce that expired 1 second in the past.
139139+ let nonce = "expired-nonce-test";
140140+ {
141141+ let mut map = store.lock().await;
142142+ let past = std::time::Instant::now()
143143+ .checked_sub(std::time::Duration::from_secs(1))
144144+ .unwrap();
145145+ map.insert(nonce.to_string(), past);
146146+ }
147147+148148+ assert!(
149149+ !validate_and_consume_nonce(&store, nonce).await,
150150+ "expired nonce must be rejected"
151151+ );
152152+ }
153153+154154+ #[tokio::test]
155155+ async fn cleanup_removes_only_expired_nonces() {
156156+ let store = new_nonce_store();
157157+158158+ // Insert one fresh nonce (not yet expired).
159159+ let fresh_nonce = issue_nonce(&store).await;
160160+161161+ // Insert one already-expired nonce directly.
162162+ {
163163+ let mut map = store.lock().await;
164164+ let past = std::time::Instant::now()
165165+ .checked_sub(std::time::Duration::from_secs(1))
166166+ .unwrap();
167167+ map.insert("stale-nonce".to_string(), past);
168168+ }
169169+170170+ cleanup_expired_nonces(&store).await;
171171+172172+ let map = store.lock().await;
173173+ assert!(map.contains_key(&fresh_nonce), "fresh nonce must survive cleanup");
174174+ assert!(!map.contains_key("stale-nonce"), "stale nonce must be pruned by cleanup");
175175+ }
176176+177177+ #[tokio::test]
178178+ async fn issued_nonce_is_22_chars_base64url() {
179179+ let store = new_nonce_store();
180180+ let nonce = issue_nonce(&store).await;
181181+ assert_eq!(nonce.len(), 22, "nonce must be 22 chars (16 bytes base64url no-pad)");
182182+ assert!(
183183+ nonce.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
184184+ "nonce must be base64url charset"
185185+ );
186186+ }
187187+```
188188+189189+**Step 2: Run tests**
190190+191191+```bash
192192+cargo test -p relay auth::tests
193193+```
194194+195195+Expected: all tests pass including the five new nonce tests.
196196+197197+**Step 3: Commit**
198198+199199+```bash
200200+git add crates/relay/src/auth/mod.rs
201201+git commit -m "feat(auth): DPoP nonce store — issue, validate_and_consume, cleanup_expired"
202202+```
203203+<!-- END_TASK_2 -->
204204+205205+<!-- END_SUBCOMPONENT_A -->