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

style: format and add dead_code suppression for foundational OAuth types

+3597 -59
+4 -1
apps/identity-wallet/src-tauri/src/lib.rs
··· 555 555 }; 556 556 let json = serde_json::to_value(&result).unwrap(); 557 557 assert_eq!(json["did"], "did:plc:abcdefghijklmnopqrstuvwx"); 558 - assert_eq!(json["share3"], "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRST"); 558 + assert_eq!( 559 + json["share3"], 560 + "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRST" 561 + ); 559 562 } 560 563 561 564 #[test]
+6 -2
crates/relay/src/app.rs
··· 12 12 use tower_http::{cors::CorsLayer, trace::TraceLayer}; 13 13 use tracing_opentelemetry::OpenTelemetrySpanExt; 14 14 15 - use crate::auth::{new_nonce_store, DpopNonceStore, OAuthSigningKey}; 15 + use crate::auth::{DpopNonceStore, OAuthSigningKey}; 16 16 use crate::dns::{DnsProvider, TxtResolver}; 17 17 use crate::routes::claim_codes::claim_codes; 18 18 use crate::routes::create_account::create_account; ··· 83 83 84 84 /// Shared application state cloned into every request handler via Axum's `State` extractor. 85 85 #[derive(Clone)] 86 + #[allow(dead_code)] 86 87 pub struct AppState { 87 88 pub config: Arc<Config>, 88 89 pub db: sqlx::SqlitePool, ··· 166 167 167 168 #[cfg(test)] 168 169 pub async fn test_state_with_plc_url(plc_directory_url: String) -> AppState { 170 + use crate::auth::new_nonce_store; 169 171 use crate::db::{open_pool, run_migrations}; 170 172 use common::{BlobsConfig, IrohConfig, OAuthConfig, TelemetryConfig}; 171 173 use p256::pkcs8::EncodePrivateKey; ··· 184 186 // Generate a fresh ephemeral P-256 keypair for tests (no DB persistence needed). 185 187 let test_signing_key = { 186 188 let sk = p256::ecdsa::SigningKey::random(&mut OsRng); 187 - let pkcs8 = sk.to_pkcs8_der().expect("PKCS#8 encoding must succeed for test key"); 189 + let pkcs8 = sk 190 + .to_pkcs8_der() 191 + .expect("PKCS#8 encoding must succeed for test key"); 188 192 OAuthSigningKey { 189 193 key_id: "test-oauth-key-01".to_string(), 190 194 encoding_key: jsonwebtoken::EncodingKey::from_ec_der(pkcs8.as_bytes()),
+14 -11
crates/relay/src/auth/mod.rs
··· 10 10 use sha2::{Digest, Sha256}; 11 11 12 12 use crate::app::AppState; 13 + #[allow(unused_imports)] 13 14 use p256::elliptic_curve::sec1::ToEncodedPoint; 14 15 use p256::pkcs8::EncodePrivateKey; 15 16 use sqlx::SqlitePool; ··· 63 64 /// `encoding_key` is derived from the P-256 private key in PKCS#8 DER format, as required by 64 65 /// `jsonwebtoken`. `key_id` is a UUID that appears as the `kid` header in issued access tokens. 65 66 #[derive(Clone)] 67 + #[allow(dead_code)] 66 68 pub struct OAuthSigningKey { 67 69 /// UUID identifier embedded in JWT `kid` header. 68 70 pub key_id: String, ··· 244 246 let key_id = Uuid::new_v4().to_string(); 245 247 246 248 // Build JWK for the public key (uncompressed EC point → x, y coordinates). 247 - let signing_key = p256::ecdsa::SigningKey::from_bytes( 248 - p256::FieldBytes::from_slice(keypair.private_key_bytes.as_ref()), 249 - ) 249 + let signing_key = p256::ecdsa::SigningKey::from_bytes(p256::FieldBytes::from_slice( 250 + keypair.private_key_bytes.as_ref(), 251 + )) 250 252 .map_err(|e| anyhow::anyhow!("invalid P-256 private key bytes: {e}"))?; 251 253 252 254 let vk = signing_key.verifying_key(); ··· 264 266 265 267 match master_key { 266 268 Some(key) => { 267 - let encrypted = 268 - crypto::encrypt_private_key(&*keypair.private_key_bytes, key) 269 - .map_err(|e| anyhow::anyhow!("key encryption failed: {e}"))?; 269 + let encrypted = crypto::encrypt_private_key(&keypair.private_key_bytes, key) 270 + .map_err(|e| anyhow::anyhow!("key encryption failed: {e}"))?; 270 271 store_oauth_signing_key(pool, &key_id, &public_key_jwk, &encrypted).await?; 271 272 tracing::info!(key_id = %key_id, "OAuth signing key generated and persisted"); 272 273 } ··· 279 280 } 280 281 281 282 let encoding_key = build_encoding_key(&signing_key)?; 282 - Ok(OAuthSigningKey { key_id, encoding_key }) 283 + Ok(OAuthSigningKey { 284 + key_id, 285 + encoding_key, 286 + }) 283 287 } 284 288 285 289 /// Decode a stored OAuth signing key row into an `OAuthSigningKey`. ··· 298 302 let raw_bytes = crypto::decrypt_private_key(private_key_encrypted, master_key) 299 303 .map_err(|e| anyhow::anyhow!("failed to decrypt OAuth signing key: {e}"))?; 300 304 301 - let signing_key = p256::ecdsa::SigningKey::from_bytes( 302 - p256::FieldBytes::from_slice(raw_bytes.as_ref()), 303 - ) 304 - .map_err(|e| anyhow::anyhow!("invalid stored P-256 private key: {e}"))?; 305 + let signing_key = 306 + p256::ecdsa::SigningKey::from_bytes(p256::FieldBytes::from_slice(raw_bytes.as_ref())) 307 + .map_err(|e| anyhow::anyhow!("invalid stored P-256 private key: {e}"))?; 305 308 306 309 let encoding_key = build_encoding_key(&signing_key)?; 307 310 Ok(OAuthSigningKey {
+8 -5
crates/relay/src/db/oauth.rs
··· 114 114 } 115 115 116 116 /// A row from the `oauth_signing_key` table. 117 + #[allow(dead_code)] 117 118 pub struct OAuthSigningKeyRow { 118 119 pub id: String, 119 120 pub public_key_jwk: String, ··· 130 131 .fetch_optional(pool) 131 132 .await?; 132 133 133 - Ok(row.map(|(id, public_key_jwk, private_key_encrypted)| OAuthSigningKeyRow { 134 - id, 135 - public_key_jwk, 136 - private_key_encrypted, 137 - })) 134 + Ok(row.map( 135 + |(id, public_key_jwk, private_key_encrypted)| OAuthSigningKeyRow { 136 + id, 137 + public_key_jwk, 138 + private_key_encrypted, 139 + }, 140 + )) 138 141 } 139 142 140 143 /// Persist a newly generated OAuth signing key.
+10 -11
crates/relay/src/main.rs
··· 101 101 ) 102 102 })?; 103 103 104 - let oauth_signing_keypair = 105 - auth::load_or_create_oauth_signing_key( 106 - &pool, 107 - config.signing_key_master_key.as_ref().map(|s| &*s.0), 108 - ) 109 - .await 110 - .map_err(|e| { 111 - tracing::error!(error = %e, "fatal: failed to load OAuth signing key"); 112 - e 113 - }) 114 - .with_context(|| "failed to load or create OAuth signing keypair")?; 104 + let oauth_signing_keypair = auth::load_or_create_oauth_signing_key( 105 + &pool, 106 + config.signing_key_master_key.as_ref().map(|s| &*s.0), 107 + ) 108 + .await 109 + .map_err(|e| { 110 + tracing::error!(error = %e, "fatal: failed to load OAuth signing key"); 111 + e 112 + }) 113 + .with_context(|| "failed to load or create OAuth signing keypair")?; 115 114 116 115 let http_client = Client::builder() 117 116 .timeout(std::time::Duration::from_secs(10))
+2
crates/relay/src/routes/auth.rs
··· 207 207 txt_resolver: base.txt_resolver, 208 208 well_known_resolver: base.well_known_resolver, 209 209 jwt_secret: base.jwt_secret, 210 + oauth_signing_keypair: base.oauth_signing_keypair, 211 + dpop_nonces: base.dpop_nonces, 210 212 } 211 213 } 212 214
+2
crates/relay/src/routes/describe_server.rs
··· 131 131 txt_resolver: base.txt_resolver, 132 132 well_known_resolver: base.well_known_resolver, 133 133 jwt_secret: base.jwt_secret, 134 + oauth_signing_keypair: base.oauth_signing_keypair, 135 + dpop_nonces: base.dpop_nonces, 134 136 }; 135 137 136 138 let response = app(state)
+48 -29
crates/relay/src/routes/oauth_authorize.rs
··· 79 79 } 80 80 Err(e) => { 81 81 tracing::error!(error = %e, "db error looking up OAuth client"); 82 - return error_page("Server Error", "A database error occurred. Please try again.") 83 - .into_response(); 82 + return error_page( 83 + "Server Error", 84 + "A database error occurred. Please try again.", 85 + ) 86 + .into_response(); 84 87 } 85 88 }; 86 89 ··· 177 180 error = %e, 178 181 "failed to parse stored client metadata" 179 182 ); 180 - return error_page("Client Configuration Error", "Client metadata is malformed.") 181 - .into_response(); 183 + return error_page( 184 + "Client Configuration Error", 185 + "Client metadata is malformed.", 186 + ) 187 + .into_response(); 182 188 } 183 189 }; 184 190 ··· 627 633 .unwrap() 628 634 } 629 635 630 - async fn post_authorize( 631 - state: crate::app::AppState, 632 - body: &str, 633 - ) -> axum::response::Response { 636 + async fn post_authorize(state: crate::app::AppState, body: &str) -> axum::response::Response { 634 637 app(state) 635 638 .oneshot( 636 639 Request::builder() ··· 675 678 async fn get_returns_200_with_html_content_type() { 676 679 let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 677 680 assert_eq!(resp.status(), StatusCode::OK); 678 - assert!( 679 - resp.headers() 680 - .get("content-type") 681 - .unwrap() 682 - .to_str() 683 - .unwrap() 684 - .starts_with("text/html") 685 - ); 681 + assert!(resp 682 + .headers() 683 + .get("content-type") 684 + .unwrap() 685 + .to_str() 686 + .unwrap() 687 + .starts_with("text/html")); 686 688 } 687 689 688 690 #[tokio::test] ··· 720 722 721 723 #[tokio::test] 722 724 async fn get_redirects_with_error_for_non_s256_challenge_method() { 723 - let url = authorize_url("") 724 - .replace("code_challenge_method=S256", "code_challenge_method=plain"); 725 + let url = 726 + authorize_url("").replace("code_challenge_method=S256", "code_challenge_method=plain"); 725 727 let resp = get_authorize(state_with_client().await, &url).await; 726 728 assert_eq!(resp.status(), StatusCode::SEE_OTHER); 727 729 let location = resp.headers().get("location").unwrap().to_str().unwrap(); ··· 733 735 let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 734 736 let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 735 737 let html = std::str::from_utf8(&body).unwrap(); 736 - assert!(html.contains("Test App"), "client_name should appear in the consent page"); 738 + assert!( 739 + html.contains("Test App"), 740 + "client_name should appear in the consent page" 741 + ); 737 742 } 738 743 739 744 #[tokio::test] ··· 762 767 let resp = get_authorize(state, &authorize_url("")).await; 763 768 let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 764 769 let html = std::str::from_utf8(&body).unwrap(); 765 - assert!(!html.contains("<script>"), "raw <script> must not appear in output"); 766 - assert!(html.contains("&lt;script&gt;"), "script tag must be HTML-escaped"); 770 + assert!( 771 + !html.contains("<script>"), 772 + "raw <script> must not appear in output" 773 + ); 774 + assert!( 775 + html.contains("&lt;script&gt;"), 776 + "script tag must be HTML-escaped" 777 + ); 767 778 } 768 779 769 780 #[tokio::test] 770 781 async fn get_consent_page_escapes_xss_in_scope() { 771 782 // scope=<b>bold</b> URL-encoded in the request 772 - let url = 773 - authorize_url("").replace("scope=atproto", "scope=%3Cb%3Ebold%3C%2Fb%3E"); 783 + let url = authorize_url("").replace("scope=atproto", "scope=%3Cb%3Ebold%3C%2Fb%3E"); 774 784 let resp = get_authorize(state_with_client().await, &url).await; 775 785 let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 776 786 let html = std::str::from_utf8(&body).unwrap(); 777 - assert!(!html.contains("<b>"), "raw HTML tags must not appear in scope output"); 778 - assert!(html.contains("&lt;b&gt;"), "scope tags must be HTML-escaped"); 787 + assert!( 788 + !html.contains("<b>"), 789 + "raw HTML tags must not appear in scope output" 790 + ); 791 + assert!( 792 + html.contains("&lt;b&gt;"), 793 + "scope tags must be HTML-escaped" 794 + ); 779 795 } 780 796 781 797 #[tokio::test] ··· 783 799 let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 784 800 let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 785 801 let html = std::str::from_utf8(&body).unwrap(); 786 - assert!(html.contains("atproto"), "requested scope should appear in the consent page"); 802 + assert!( 803 + html.contains("atproto"), 804 + "requested scope should appear in the consent page" 805 + ); 787 806 } 788 807 789 808 #[tokio::test] ··· 843 862 844 863 #[tokio::test] 845 864 async fn post_approve_redirects_with_code() { 846 - let resp = 847 - post_authorize(state_with_client_and_account().await, &approve_form("")).await; 865 + let resp = post_authorize(state_with_client_and_account().await, &approve_form("")).await; 848 866 assert_eq!(resp.status(), StatusCode::SEE_OTHER); 849 867 let location = resp.headers().get("location").unwrap().to_str().unwrap(); 850 868 assert!(location.starts_with(REDIRECT_URI)); ··· 896 914 897 915 #[tokio::test] 898 916 async fn post_approve_redirects_with_error_for_non_s256_method() { 899 - let body = approve_form("").replace("code_challenge_method=S256", "code_challenge_method=plain"); 917 + let body = 918 + approve_form("").replace("code_challenge_method=S256", "code_challenge_method=plain"); 900 919 let resp = post_authorize(state_with_client_and_account().await, &body).await; 901 920 assert_eq!(resp.status(), StatusCode::SEE_OTHER); 902 921 let location = resp.headers().get("location").unwrap().to_str().unwrap();
+2
crates/relay/src/routes/test_utils.rs
··· 19 19 txt_resolver: base.txt_resolver, 20 20 well_known_resolver: base.well_known_resolver, 21 21 jwt_secret: base.jwt_secret, 22 + oauth_signing_keypair: base.oauth_signing_keypair, 23 + dpop_nonces: base.dpop_nonces, 22 24 } 23 25 }
+114
docs/implementation-plans/2026-03-22-MM-77/phase_01.md
··· 1 + # OAuth Token Endpoint — Phase 1: Schema (V012 Migration) 2 + 3 + **Goal:** Add the `jkt` column to `oauth_tokens` and create the `oauth_signing_key` table. 4 + 5 + **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. 6 + 7 + **Tech Stack:** SQLite (raw DDL), `include_str!()` for compile-time file embedding. 8 + 9 + **Scope:** Phase 1 of 6 10 + 11 + **Codebase verified:** 2026-03-22 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + 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. 18 + 19 + **Verifies: None** — done when `cargo test` passes (migrations apply without error). 20 + 21 + --- 22 + 23 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 24 + 25 + <!-- START_TASK_1 --> 26 + ### Task 1: Create V012 SQL migration file 27 + 28 + **Files:** 29 + - Create: `crates/relay/src/db/migrations/V012__oauth_token_endpoint.sql` 30 + 31 + **Step 1: Create the SQL file** 32 + 33 + Create `crates/relay/src/db/migrations/V012__oauth_token_endpoint.sql` with this exact content: 34 + 35 + ```sql 36 + -- V012: OAuth token endpoint schema additions 37 + -- Applied in a single transaction by the migration runner. 38 + -- 39 + -- Adds DPoP key thumbprint (jkt) to oauth_tokens for DPoP-bound refresh tokens. 40 + -- Creates oauth_signing_key single-row table for the server's persistent ES256 keypair. 41 + 42 + -- DPoP key thumbprint — NULL for tokens issued before V012 or without DPoP binding. 43 + ALTER TABLE oauth_tokens ADD COLUMN jkt TEXT; 44 + 45 + -- Single-row table for the server's persistent ES256 signing keypair. 46 + -- WITHOUT ROWID: the key is always fetched by its id (primary key lookup). 47 + CREATE TABLE oauth_signing_key ( 48 + id TEXT NOT NULL, -- UUID key identifier 49 + public_key_jwk TEXT NOT NULL, -- JWK JSON string (EC P-256 public key) 50 + private_key_encrypted TEXT NOT NULL, -- base64(nonce(12) || ciphertext(32) || tag(16)) 51 + created_at TEXT NOT NULL, -- ISO 8601 UTC 52 + PRIMARY KEY (id) 53 + ) WITHOUT ROWID; 54 + ``` 55 + 56 + **Step 2: Commit** 57 + 58 + ```bash 59 + git add crates/relay/src/db/migrations/V012__oauth_token_endpoint.sql 60 + git commit -m "feat(db): V012 migration — oauth_tokens.jkt column + oauth_signing_key table" 61 + ``` 62 + <!-- END_TASK_1 --> 63 + 64 + <!-- START_TASK_2 --> 65 + ### Task 2: Register V012 in the migration runner 66 + 67 + **Files:** 68 + - Modify: `crates/relay/src/db/mod.rs:73-77` 69 + 70 + The MIGRATIONS array ends at line 77 (`];`). V011 is the last entry, spanning lines 73–76. Add V012 after V011, before the closing `];`. 71 + 72 + **Step 1: Edit `db/mod.rs`** 73 + 74 + Find this block in `crates/relay/src/db/mod.rs` (lines 73–77): 75 + 76 + ```rust 77 + Migration { 78 + version: 11, 79 + sql: include_str!("migrations/V011__pending_shares.sql"), 80 + }, 81 + ]; 82 + ``` 83 + 84 + Replace with: 85 + 86 + ```rust 87 + Migration { 88 + version: 11, 89 + sql: include_str!("migrations/V011__pending_shares.sql"), 90 + }, 91 + Migration { 92 + version: 12, 93 + sql: include_str!("migrations/V012__oauth_token_endpoint.sql"), 94 + }, 95 + ]; 96 + ``` 97 + 98 + **Step 2: Run tests** 99 + 100 + ```bash 101 + cargo test 102 + ``` 103 + 104 + Expected: all tests pass. The migration runner applies V012 to every in-memory test DB, confirming the SQL is valid. 105 + 106 + **Step 3: Commit** 107 + 108 + ```bash 109 + git add crates/relay/src/db/mod.rs 110 + git commit -m "feat(db): register V012 migration in runner" 111 + ``` 112 + <!-- END_TASK_2 --> 113 + 114 + <!-- END_SUBCOMPONENT_A -->
+509
docs/implementation-plans/2026-03-22-MM-77/phase_02.md
··· 1 + # OAuth Token Endpoint — Phase 2: OAuth Signing Key Infrastructure 2 + 3 + **Goal:** Load or generate the persistent ES256 signing keypair at startup and expose it via `AppState`. 4 + 5 + **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. 6 + 7 + **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). 8 + 9 + **Scope:** Phase 2 of 6 10 + 11 + **Codebase verified:** 2026-03-22 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-77.AC6: OAuth signing key persistence 18 + - **MM-77.AC6.1 Success:** First startup generates P-256 keypair, stores encrypted in `oauth_signing_key` 19 + - **MM-77.AC6.2 Success:** Subsequent restarts reload the same key (same `kid` in JWTs) 20 + 21 + --- 22 + 23 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 24 + 25 + <!-- START_TASK_1 --> 26 + ### Task 1: Add pkcs8 feature to p256 and move to production dependencies 27 + 28 + **Files:** 29 + - Modify: `Cargo.toml` (workspace root, line 61) 30 + - Modify: `crates/relay/Cargo.toml` (move p256 from dev-deps to deps) 31 + 32 + 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]`. 33 + 34 + **Step 1: Edit workspace `Cargo.toml`** 35 + 36 + Find this line (line 61): 37 + 38 + ```toml 39 + p256 = { version = "0.13", features = ["ecdsa"] } 40 + ``` 41 + 42 + Replace with: 43 + 44 + ```toml 45 + p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } 46 + ``` 47 + 48 + **Step 2: Edit `crates/relay/Cargo.toml`** 49 + 50 + In `[dependencies]`, after the `jsonwebtoken` line, add: 51 + 52 + ```toml 53 + p256 = { workspace = true } 54 + ``` 55 + 56 + 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). 57 + 58 + **Step 3: Verify compilation** 59 + 60 + ```bash 61 + cargo build -p relay 62 + ``` 63 + 64 + Expected: compiles without errors. The `pkcs8` feature makes `p256::pkcs8::EncodePrivateKey` trait available. 65 + 66 + **Step 4: Commit** 67 + 68 + ```bash 69 + git add Cargo.toml crates/relay/Cargo.toml 70 + git commit -m "build(relay): add p256 pkcs8 feature + move to production dependencies" 71 + ``` 72 + <!-- END_TASK_1 --> 73 + 74 + <!-- START_TASK_2 --> 75 + ### Task 2: Add DB functions for oauth_signing_key table 76 + 77 + **Files:** 78 + - Modify: `crates/relay/src/db/oauth.rs` 79 + 80 + 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. 81 + 82 + **Step 1: Append to `crates/relay/src/db/oauth.rs`** 83 + 84 + Find the line `pub async fn get_single_account_did...` block and append after it (before `#[cfg(test)]`): 85 + 86 + ```rust 87 + /// A row from the `oauth_signing_key` table. 88 + pub struct OAuthSigningKeyRow { 89 + pub id: String, 90 + pub public_key_jwk: String, 91 + pub private_key_encrypted: String, 92 + } 93 + 94 + /// Load the server's OAuth signing key row. Returns `None` if no key has been generated yet. 95 + pub async fn get_oauth_signing_key( 96 + pool: &SqlitePool, 97 + ) -> Result<Option<OAuthSigningKeyRow>, sqlx::Error> { 98 + let row: Option<(String, String, String)> = sqlx::query_as( 99 + "SELECT id, public_key_jwk, private_key_encrypted FROM oauth_signing_key LIMIT 1", 100 + ) 101 + .fetch_optional(pool) 102 + .await?; 103 + 104 + Ok(row.map(|(id, public_key_jwk, private_key_encrypted)| OAuthSigningKeyRow { 105 + id, 106 + public_key_jwk, 107 + private_key_encrypted, 108 + })) 109 + } 110 + 111 + /// Persist a newly generated OAuth signing key. 112 + /// 113 + /// `id` is a UUID string. `public_key_jwk` is a JWK JSON string for the P-256 public key. 114 + /// `private_key_encrypted` is the AES-256-GCM-encrypted private key (base64, 80 chars). 115 + pub async fn store_oauth_signing_key( 116 + pool: &SqlitePool, 117 + id: &str, 118 + public_key_jwk: &str, 119 + private_key_encrypted: &str, 120 + ) -> Result<(), sqlx::Error> { 121 + sqlx::query( 122 + "INSERT INTO oauth_signing_key (id, public_key_jwk, private_key_encrypted, created_at) \ 123 + VALUES (?, ?, ?, datetime('now'))", 124 + ) 125 + .bind(id) 126 + .bind(public_key_jwk) 127 + .bind(private_key_encrypted) 128 + .execute(pool) 129 + .await?; 130 + Ok(()) 131 + } 132 + ``` 133 + 134 + **Step 2: Add tests for the new DB functions** 135 + 136 + In the `#[cfg(test)]` block at the bottom of `db/oauth.rs`, add: 137 + 138 + ```rust 139 + #[tokio::test] 140 + async fn store_and_retrieve_oauth_signing_key() { 141 + let pool = test_pool().await; 142 + store_oauth_signing_key( 143 + &pool, 144 + "test-key-uuid-01", 145 + r#"{"kty":"EC","crv":"P-256","x":"abc","y":"def","kid":"test-key-uuid-01"}"#, 146 + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", 147 + ) 148 + .await 149 + .unwrap(); 150 + 151 + let row = get_oauth_signing_key(&pool) 152 + .await 153 + .unwrap() 154 + .expect("key should exist after storage"); 155 + 156 + assert_eq!(row.id, "test-key-uuid-01"); 157 + assert!(!row.public_key_jwk.is_empty()); 158 + assert!(!row.private_key_encrypted.is_empty()); 159 + } 160 + 161 + #[tokio::test] 162 + async fn get_oauth_signing_key_returns_none_when_empty() { 163 + let pool = test_pool().await; 164 + let result = get_oauth_signing_key(&pool).await.unwrap(); 165 + assert!(result.is_none()); 166 + } 167 + ``` 168 + 169 + **Step 3: Run tests** 170 + 171 + ```bash 172 + cargo test -p relay db::oauth 173 + ``` 174 + 175 + Expected: all tests pass including the two new ones. 176 + 177 + **Step 4: Commit** 178 + 179 + ```bash 180 + git add crates/relay/src/db/oauth.rs 181 + git commit -m "feat(db): add oauth_signing_key DB functions" 182 + ``` 183 + <!-- END_TASK_2 --> 184 + 185 + <!-- END_SUBCOMPONENT_A --> 186 + 187 + <!-- START_SUBCOMPONENT_B (tasks 3-5) --> 188 + 189 + <!-- START_TASK_3 --> 190 + ### Task 3: Add OAuthSigningKey type and load_or_create function 191 + 192 + **Files:** 193 + - Modify: `crates/relay/src/auth/mod.rs` 194 + 195 + 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;`). 196 + 197 + **Step 1: Add imports and type** 198 + 199 + After the existing imports block in `auth/mod.rs`, add: 200 + 201 + ```rust 202 + use p256::elliptic_curve::sec1::ToEncodedPoint; 203 + use p256::pkcs8::EncodePrivateKey; 204 + use rand_core::RngCore; 205 + use sqlx::SqlitePool; 206 + use std::collections::HashMap; 207 + use std::sync::Arc; 208 + use std::time::Instant; 209 + use tokio::sync::Mutex; 210 + use uuid::Uuid; 211 + 212 + /// The server's persistent ES256 signing keypair, held in `AppState`. 213 + /// 214 + /// `encoding_key` is derived from the P-256 private key in PKCS#8 DER format, as required by 215 + /// `jsonwebtoken`. `key_id` is a UUID that appears as the `kid` header in issued access tokens. 216 + #[derive(Clone)] 217 + pub struct OAuthSigningKey { 218 + /// UUID identifier embedded in JWT `kid` header. 219 + pub key_id: String, 220 + /// PKCS#8 DER ES256 encoding key for JWT signing. 221 + pub encoding_key: jsonwebtoken::EncodingKey, 222 + } 223 + 224 + /// In-memory store for server-issued DPoP nonces. 225 + /// 226 + /// Maps nonce string → expiry `Instant`. Protected by a `Mutex` so handlers can issue, 227 + /// validate, and prune concurrently. Held in `AppState`. 228 + pub type DpopNonceStore = Arc<Mutex<HashMap<String, Instant>>>; 229 + 230 + /// Create an empty `DpopNonceStore`. 231 + pub fn new_nonce_store() -> DpopNonceStore { 232 + Arc::new(Mutex::new(HashMap::new())) 233 + } 234 + ``` 235 + 236 + **Step 2: Add load_or_create_oauth_signing_key** 237 + 238 + After the `new_nonce_store` function, add: 239 + 240 + ```rust 241 + /// Load the OAuth signing key from the database, or generate a new one on first boot. 242 + /// 243 + /// If `master_key` is `None`, generates an ephemeral (non-persistent) key and logs a warning. 244 + /// Ephemeral keys are not stored in the DB and invalidate all issued tokens on restart. 245 + pub(crate) async fn load_or_create_oauth_signing_key( 246 + pool: &SqlitePool, 247 + master_key: Option<&[u8; 32]>, 248 + ) -> anyhow::Result<OAuthSigningKey> { 249 + use crate::db::oauth::{get_oauth_signing_key, store_oauth_signing_key}; 250 + 251 + // Attempt to load an existing key. 252 + if let Some(row) = get_oauth_signing_key(pool).await? { 253 + let key = decode_oauth_signing_key(&row.id, &row.private_key_encrypted, master_key)?; 254 + tracing::info!(key_id = %row.id, "OAuth signing key loaded from database"); 255 + return Ok(key); 256 + } 257 + 258 + // No key stored yet. Generate one. 259 + let keypair = crypto::generate_p256_keypair() 260 + .map_err(|e| anyhow::anyhow!("failed to generate P-256 keypair: {e}"))?; 261 + 262 + let key_id = Uuid::new_v4().to_string(); 263 + 264 + // Build JWK for the public key (uncompressed EC point → x, y coordinates). 265 + let signing_key = p256::ecdsa::SigningKey::from_bytes( 266 + p256::FieldBytes::from_slice(keypair.private_key_bytes.as_ref()), 267 + ) 268 + .map_err(|e| anyhow::anyhow!("invalid P-256 private key bytes: {e}"))?; 269 + 270 + let vk = signing_key.verifying_key(); 271 + let point = vk.to_encoded_point(false); 272 + let x = URL_SAFE_NO_PAD.encode(point.x().expect("P-256 x coordinate")); 273 + let y = URL_SAFE_NO_PAD.encode(point.y().expect("P-256 y coordinate")); 274 + let public_key_jwk = serde_json::to_string(&serde_json::json!({ 275 + "kty": "EC", 276 + "crv": "P-256", 277 + "x": x, 278 + "y": y, 279 + "kid": key_id, 280 + })) 281 + .map_err(|e| anyhow::anyhow!("JWK serialization failed: {e}"))?; 282 + 283 + match master_key { 284 + Some(key) => { 285 + let encrypted = 286 + crypto::encrypt_private_key(keypair.private_key_bytes.as_ref(), key) 287 + .map_err(|e| anyhow::anyhow!("key encryption failed: {e}"))?; 288 + store_oauth_signing_key(pool, &key_id, &public_key_jwk, &encrypted).await?; 289 + tracing::info!(key_id = %key_id, "OAuth signing key generated and persisted"); 290 + } 291 + None => { 292 + tracing::warn!( 293 + "signing_key_master_key not configured; \ 294 + OAuth signing key is ephemeral — tokens will be invalidated on restart" 295 + ); 296 + } 297 + } 298 + 299 + let encoding_key = build_encoding_key(&signing_key)?; 300 + Ok(OAuthSigningKey { key_id, encoding_key }) 301 + } 302 + 303 + /// Decode a stored OAuth signing key row into an `OAuthSigningKey`. 304 + fn decode_oauth_signing_key( 305 + key_id: &str, 306 + private_key_encrypted: &str, 307 + master_key: Option<&[u8; 32]>, 308 + ) -> anyhow::Result<OAuthSigningKey> { 309 + let master_key = master_key.ok_or_else(|| { 310 + anyhow::anyhow!( 311 + "signing_key_master_key not configured but an OAuth signing key exists in the DB; \ 312 + cannot decrypt it — set signing_key_master_key in config" 313 + ) 314 + })?; 315 + 316 + let raw_bytes = crypto::decrypt_private_key(private_key_encrypted, master_key) 317 + .map_err(|e| anyhow::anyhow!("failed to decrypt OAuth signing key: {e}"))?; 318 + 319 + let signing_key = p256::ecdsa::SigningKey::from_bytes( 320 + p256::FieldBytes::from_slice(raw_bytes.as_ref()), 321 + ) 322 + .map_err(|e| anyhow::anyhow!("invalid stored P-256 private key: {e}"))?; 323 + 324 + let encoding_key = build_encoding_key(&signing_key)?; 325 + Ok(OAuthSigningKey { 326 + key_id: key_id.to_string(), 327 + encoding_key, 328 + }) 329 + } 330 + 331 + /// Convert a `p256::ecdsa::SigningKey` to a `jsonwebtoken::EncodingKey` via PKCS#8 DER. 332 + fn build_encoding_key( 333 + signing_key: &p256::ecdsa::SigningKey, 334 + ) -> anyhow::Result<jsonwebtoken::EncodingKey> { 335 + let pkcs8_der = signing_key 336 + .to_pkcs8_der() 337 + .map_err(|e| anyhow::anyhow!("PKCS#8 DER encoding failed: {e}"))?; 338 + jsonwebtoken::EncodingKey::from_ec_der(pkcs8_der.as_bytes()) 339 + .map_err(|e| anyhow::anyhow!("jsonwebtoken EncodingKey construction failed: {e}")) 340 + } 341 + ``` 342 + 343 + **Step 3: Run tests** 344 + 345 + ```bash 346 + cargo test -p relay 347 + ``` 348 + 349 + Expected: compiles and all tests pass. 350 + 351 + **Step 4: Commit** 352 + 353 + ```bash 354 + git add crates/relay/src/auth/mod.rs 355 + git commit -m "feat(auth): OAuthSigningKey type + load_or_create_oauth_signing_key" 356 + ``` 357 + <!-- END_TASK_3 --> 358 + 359 + <!-- START_TASK_4 --> 360 + ### Task 4: Add oauth_signing_keypair to AppState and test_state 361 + 362 + **Verifies:** MM-77.AC6.1, MM-77.AC6.2 363 + 364 + **Files:** 365 + - Modify: `crates/relay/src/app.rs` 366 + 367 + 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. 368 + 369 + **Step 1: Edit `AppState` struct in `app.rs`** 370 + 371 + In the imports at the top of `app.rs`, add: 372 + 373 + ```rust 374 + use crate::auth::{new_nonce_store, DpopNonceStore, OAuthSigningKey}; 375 + ``` 376 + 377 + In the `AppState` struct (after the `jwt_secret` field), add: 378 + 379 + ```rust 380 + /// Persistent ES256 keypair for signing OAuth access tokens. 381 + /// Loaded at startup from `oauth_signing_key` table (or generated + stored on first boot). 382 + pub oauth_signing_keypair: OAuthSigningKey, 383 + /// In-memory store for server-issued DPoP nonces. Shared across all token endpoint requests. 384 + pub dpop_nonces: DpopNonceStore, 385 + ``` 386 + 387 + **Step 2: Update `test_state_with_plc_url`** 388 + 389 + In the `#[cfg(test)]` section of `app.rs`, add to the imports at the top of `test_state_with_plc_url`: 390 + 391 + ```rust 392 + use p256::pkcs8::EncodePrivateKey; 393 + use rand_core::OsRng; 394 + ``` 395 + 396 + And add this block before the `AppState { ... }` return: 397 + 398 + ```rust 399 + // Generate a fresh ephemeral P-256 keypair for tests (no DB persistence needed). 400 + let test_signing_key = { 401 + let sk = p256::ecdsa::SigningKey::random(&mut OsRng); 402 + let pkcs8 = sk.to_pkcs8_der().expect("PKCS#8 encoding must succeed for test key"); 403 + OAuthSigningKey { 404 + key_id: "test-oauth-key-01".to_string(), 405 + encoding_key: jsonwebtoken::EncodingKey::from_ec_der(pkcs8.as_bytes()) 406 + .expect("EncodingKey from test PKCS#8 must succeed"), 407 + } 408 + }; 409 + let dpop_nonces = new_nonce_store(); 410 + ``` 411 + 412 + Add both to the `AppState { ... }` constructor: 413 + 414 + ```rust 415 + oauth_signing_keypair: test_signing_key, 416 + dpop_nonces, 417 + ``` 418 + 419 + **Step 3: Update `test_state_with_keys` in `create_signing_key.rs`** 420 + 421 + 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`: 422 + 423 + Find the `AppState { ... }` block inside `test_state_with_keys` and add: 424 + 425 + ```rust 426 + oauth_signing_keypair: base.oauth_signing_keypair, 427 + dpop_nonces: base.dpop_nonces, 428 + ``` 429 + 430 + Do the same for the manual `AppState` construction in the `missing_master_key_returns_503` test in the same file. 431 + 432 + **Step 4: Run tests** 433 + 434 + ```bash 435 + cargo test -p relay 436 + ``` 437 + 438 + Expected: all tests compile and pass. 439 + 440 + **Step 5: Commit** 441 + 442 + ```bash 443 + git add crates/relay/src/app.rs crates/relay/src/routes/create_signing_key.rs 444 + git commit -m "feat(app): add oauth_signing_keypair and dpop_nonces to AppState" 445 + ``` 446 + <!-- END_TASK_4 --> 447 + 448 + <!-- START_TASK_5 --> 449 + ### Task 5: Wire signing key loading into main.rs startup 450 + 451 + **Verifies:** MM-77.AC6.1, MM-77.AC6.2 452 + 453 + **Files:** 454 + - Modify: `crates/relay/src/main.rs` 455 + 456 + Call `auth::load_or_create_oauth_signing_key` after `run_migrations` and before constructing `AppState`. 457 + 458 + **Step 1: Edit `main.rs` `run()` function** 459 + 460 + After the `db::run_migrations(&pool)` block (around line 91–103 in `main.rs`), add: 461 + 462 + ```rust 463 + let oauth_signing_keypair = 464 + auth::load_or_create_oauth_signing_key( 465 + &pool, 466 + config.signing_key_master_key.as_ref().map(|s| &*s.0), 467 + ) 468 + .await 469 + .map_err(|e| { 470 + tracing::error!(error = %e, "fatal: failed to load OAuth signing key"); 471 + e 472 + }) 473 + .with_context(|| "failed to load or create OAuth signing keypair")?; 474 + ``` 475 + 476 + **Step 2: Update the `AppState { ... }` constructor** 477 + 478 + Find the `let state = app::AppState { ... };` block (around line 132–140) and add the two new fields: 479 + 480 + ```rust 481 + oauth_signing_keypair, 482 + dpop_nonces: auth::new_nonce_store(), 483 + ``` 484 + 485 + **Step 3: Build the binary** 486 + 487 + ```bash 488 + cargo build -p relay 489 + ``` 490 + 491 + Expected: compiles without errors or warnings. 492 + 493 + **Step 4: Run all tests** 494 + 495 + ```bash 496 + cargo test -p relay 497 + ``` 498 + 499 + Expected: all tests pass. 500 + 501 + **Step 5: Commit** 502 + 503 + ```bash 504 + git add crates/relay/src/main.rs 505 + git commit -m "feat(relay): load OAuth signing key at startup and wire into AppState" 506 + ``` 507 + <!-- END_TASK_5 --> 508 + 509 + <!-- END_SUBCOMPONENT_B -->
+205
docs/implementation-plans/2026-03-22-MM-77/phase_03.md
··· 1 + # OAuth Token Endpoint — Phase 3: DPoP Nonce Management 2 + 3 + **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). 4 + 5 + **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. 6 + 7 + **Tech Stack:** `tokio::sync::Mutex`, `std::time::Instant`, `std::time::Duration`, `base64` (URL_SAFE_NO_PAD), `rand_core::OsRng`. 8 + 9 + **Scope:** Phase 3 of 6 10 + 11 + **Codebase verified:** 2026-03-22 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-77.AC3: DPoP server nonces 18 + - **MM-77.AC3.1 Success:** Request with valid unexpired nonce accepted 19 + - **MM-77.AC3.2 Failure:** No `nonce` claim in DPoP proof → 400 `use_dpop_nonce` + `DPoP-Nonce:` response header 20 + - **MM-77.AC3.3 Failure:** Expired nonce → 400 `use_dpop_nonce` + fresh `DPoP-Nonce:` header 21 + - **MM-77.AC3.4 Failure:** Unknown/fabricated nonce → 400 `use_dpop_nonce` 22 + - **MM-77.AC3.5 Success:** Successful token response includes `DPoP-Nonce:` header with a fresh nonce 23 + 24 + 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). 25 + 26 + --- 27 + 28 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 29 + 30 + <!-- START_TASK_1 --> 31 + ### Task 1: Implement nonce store functions 32 + 33 + **Files:** 34 + - Modify: `crates/relay/src/auth/mod.rs` 35 + 36 + 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. 37 + 38 + **Step 1: Add nonce functions to `auth/mod.rs`** 39 + 40 + After the `new_nonce_store()` function (already added in Phase 2), add: 41 + 42 + ```rust 43 + /// Issue a fresh DPoP nonce with a 5-minute TTL. 44 + /// 45 + /// Returns a 22-character base64url string (16 random bytes). The nonce is 46 + /// inserted into the store with an expiry of `Instant::now() + 5 minutes`. 47 + pub(crate) async fn issue_nonce(store: &DpopNonceStore) -> String { 48 + let mut bytes = [0u8; 16]; 49 + rand_core::OsRng.fill_bytes(&mut bytes); 50 + let nonce = URL_SAFE_NO_PAD.encode(bytes); 51 + let expiry = std::time::Instant::now() + std::time::Duration::from_secs(300); 52 + store.lock().await.insert(nonce.clone(), expiry); 53 + nonce 54 + } 55 + 56 + /// Validate and consume a DPoP nonce. 57 + /// 58 + /// Returns `true` if the nonce is present in the store and has not expired. 59 + /// Removes the nonce unconditionally (whether valid or expired) to prevent reuse. 60 + /// Returns `false` for unknown nonces. 61 + pub(crate) async fn validate_and_consume_nonce(store: &DpopNonceStore, nonce: &str) -> bool { 62 + let mut map = store.lock().await; 63 + match map.remove(nonce) { 64 + Some(expiry) => expiry > std::time::Instant::now(), 65 + None => false, 66 + } 67 + } 68 + 69 + /// Remove all expired nonces from the store. 70 + /// 71 + /// Call this on every token request to prevent unbounded memory growth. 72 + /// Under normal relay load (low request volume) this is sufficient without a background task. 73 + pub(crate) async fn cleanup_expired_nonces(store: &DpopNonceStore) { 74 + let now = std::time::Instant::now(); 75 + store.lock().await.retain(|_, expiry| *expiry > now); 76 + } 77 + ``` 78 + 79 + **Step 2: Confirm compilation** 80 + 81 + ```bash 82 + cargo build -p relay 83 + ``` 84 + 85 + Expected: compiles without errors. 86 + <!-- END_TASK_1 --> 87 + 88 + <!-- START_TASK_2 --> 89 + ### Task 2: Unit tests for nonce store functions 90 + 91 + **Verifies:** MM-77.AC3.1, MM-77.AC3.3, MM-77.AC3.4 92 + 93 + **Files:** 94 + - Modify: `crates/relay/src/auth/mod.rs` (test section) 95 + 96 + Add nonce unit tests to the existing `#[cfg(test)]` block in `auth/mod.rs`. 97 + 98 + **Step 1: Add tests** 99 + 100 + At the end of the `mod tests { ... }` block in `auth/mod.rs`, add: 101 + 102 + ```rust 103 + // ── DPoP nonce store tests ──────────────────────────────────────────────── 104 + 105 + #[tokio::test] 106 + async fn issued_nonce_validates_once() { 107 + // AC3.1: Valid unexpired nonce is accepted. 108 + let store = new_nonce_store(); 109 + let nonce = issue_nonce(&store).await; 110 + 111 + // First use: valid. 112 + assert!( 113 + validate_and_consume_nonce(&store, &nonce).await, 114 + "freshly issued nonce must validate" 115 + ); 116 + 117 + // Second use: consumed — must fail (even though not expired). 118 + assert!( 119 + !validate_and_consume_nonce(&store, &nonce).await, 120 + "already-consumed nonce must not validate again" 121 + ); 122 + } 123 + 124 + #[tokio::test] 125 + async fn unknown_nonce_is_rejected() { 126 + // AC3.4: Fabricated nonce not in store. 127 + let store = new_nonce_store(); 128 + assert!( 129 + !validate_and_consume_nonce(&store, "this-nonce-was-never-issued").await, 130 + "unknown nonce must be rejected" 131 + ); 132 + } 133 + 134 + #[tokio::test] 135 + async fn expired_nonce_is_rejected() { 136 + // AC3.3: Expired nonce returns false. 137 + let store = new_nonce_store(); 138 + // Manually insert a nonce that expired 1 second in the past. 139 + let nonce = "expired-nonce-test"; 140 + { 141 + let mut map = store.lock().await; 142 + let past = std::time::Instant::now() 143 + .checked_sub(std::time::Duration::from_secs(1)) 144 + .unwrap(); 145 + map.insert(nonce.to_string(), past); 146 + } 147 + 148 + assert!( 149 + !validate_and_consume_nonce(&store, nonce).await, 150 + "expired nonce must be rejected" 151 + ); 152 + } 153 + 154 + #[tokio::test] 155 + async fn cleanup_removes_only_expired_nonces() { 156 + let store = new_nonce_store(); 157 + 158 + // Insert one fresh nonce (not yet expired). 159 + let fresh_nonce = issue_nonce(&store).await; 160 + 161 + // Insert one already-expired nonce directly. 162 + { 163 + let mut map = store.lock().await; 164 + let past = std::time::Instant::now() 165 + .checked_sub(std::time::Duration::from_secs(1)) 166 + .unwrap(); 167 + map.insert("stale-nonce".to_string(), past); 168 + } 169 + 170 + cleanup_expired_nonces(&store).await; 171 + 172 + let map = store.lock().await; 173 + assert!(map.contains_key(&fresh_nonce), "fresh nonce must survive cleanup"); 174 + assert!(!map.contains_key("stale-nonce"), "stale nonce must be pruned by cleanup"); 175 + } 176 + 177 + #[tokio::test] 178 + async fn issued_nonce_is_22_chars_base64url() { 179 + let store = new_nonce_store(); 180 + let nonce = issue_nonce(&store).await; 181 + assert_eq!(nonce.len(), 22, "nonce must be 22 chars (16 bytes base64url no-pad)"); 182 + assert!( 183 + nonce.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), 184 + "nonce must be base64url charset" 185 + ); 186 + } 187 + ``` 188 + 189 + **Step 2: Run tests** 190 + 191 + ```bash 192 + cargo test -p relay auth::tests 193 + ``` 194 + 195 + Expected: all tests pass including the five new nonce tests. 196 + 197 + **Step 3: Commit** 198 + 199 + ```bash 200 + git add crates/relay/src/auth/mod.rs 201 + git commit -m "feat(auth): DPoP nonce store — issue, validate_and_consume, cleanup_expired" 202 + ``` 203 + <!-- END_TASK_2 --> 204 + 205 + <!-- END_SUBCOMPONENT_A -->
+427
docs/implementation-plans/2026-03-22-MM-77/phase_04.md
··· 1 + # OAuth Token Endpoint — Phase 4: Token Endpoint Routing and Request Parsing 2 + 3 + **Goal:** Register `POST /oauth/token`, parse the form body into typed grant variants, and return correct RFC 6749 errors for malformed requests. Full grant logic is added in Phases 5 and 6. 4 + 5 + **Architecture:** New route handler file `routes/oauth_token.rs` with `TokenRequestForm`, `TokenResponse`, `OAuthTokenError`, and a stub `post_token` handler. The handler only validates `grant_type` in this phase — returning `unsupported_grant_type` or `invalid_request` as appropriate. Registered in `app.rs`. Two Bruno files for manual testing. 6 + 7 + **Tech Stack:** `axum::extract::Form` (URL-encoded body), `serde::Deserialize`, `axum::http::StatusCode`, `serde_json::json!`. 8 + 9 + **Scope:** Phase 4 of 6 10 + 11 + **Codebase verified:** 2026-03-22 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-77.AC5: Error response format 18 + - **MM-77.AC5.2:** Unknown `grant_type` → 400 `unsupported_grant_type` 19 + - **MM-77.AC5.3:** Missing required params → 400 `invalid_request` (tested here for missing `grant_type`) 20 + - **MM-77.AC5.4:** No HTML in error responses 21 + 22 + --- 23 + 24 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 25 + 26 + <!-- START_TASK_1 --> 27 + ### Task 1: Create the oauth_token route handler file 28 + 29 + **Files:** 30 + - Create: `crates/relay/src/routes/oauth_token.rs` 31 + 32 + **Step 1: Create the file** 33 + 34 + Create `crates/relay/src/routes/oauth_token.rs` with this content: 35 + 36 + ```rust 37 + // pattern: Imperative Shell 38 + // 39 + // Gathers: AppState (signing key, nonce store, DB), DPoP header, form body 40 + // Processes: DPoP validation → grant dispatch → token issuance 41 + // Returns: JSON TokenResponse + DPoP-Nonce header on success; 42 + // JSON OAuthTokenError on all failure paths 43 + 44 + use axum::{ 45 + extract::State, 46 + http::{HeaderMap, StatusCode}, 47 + response::{IntoResponse, Response}, 48 + Form, Json, 49 + }; 50 + use serde::{Deserialize, Serialize}; 51 + 52 + use crate::app::AppState; 53 + 54 + // ── Request / response types ────────────────────────────────────────────────── 55 + 56 + /// Flat form body for `POST /oauth/token` (application/x-www-form-urlencoded). 57 + /// 58 + /// All fields are `Option<String>` so that the handler can provide RFC 6749-compliant 59 + /// error messages instead of Axum's default 422 rejection when fields are missing. 60 + #[derive(Debug, Deserialize)] 61 + pub struct TokenRequestForm { 62 + pub grant_type: Option<String>, 63 + // authorization_code grant 64 + pub code: Option<String>, 65 + pub redirect_uri: Option<String>, 66 + pub client_id: Option<String>, 67 + pub code_verifier: Option<String>, 68 + // refresh_token grant 69 + pub refresh_token: Option<String>, 70 + } 71 + 72 + /// Successful token endpoint response body (RFC 6749 §5.1). 73 + #[derive(Debug, Serialize)] 74 + pub struct TokenResponse { 75 + pub access_token: String, 76 + pub token_type: &'static str, 77 + pub expires_in: u64, 78 + pub refresh_token: String, 79 + pub scope: String, 80 + } 81 + 82 + /// OAuth 2.0 error response body (RFC 6749 §5.2). 83 + /// 84 + /// All token endpoint errors use this format, distinct from the codebase's 85 + /// `ApiError` envelope (`{ "error": { "code": "...", "message": "..." } }`). 86 + pub struct OAuthTokenError { 87 + pub error: &'static str, 88 + pub error_description: &'static str, 89 + /// Optional DPoP-Nonce value to include in the response header. 90 + /// Required for `use_dpop_nonce` errors so the client can retry. 91 + pub dpop_nonce: Option<String>, 92 + } 93 + 94 + impl OAuthTokenError { 95 + pub fn new(error: &'static str, error_description: &'static str) -> Self { 96 + Self { 97 + error, 98 + error_description, 99 + dpop_nonce: None, 100 + } 101 + } 102 + 103 + pub fn with_nonce( 104 + error: &'static str, 105 + error_description: &'static str, 106 + nonce: String, 107 + ) -> Self { 108 + Self { 109 + error, 110 + error_description, 111 + dpop_nonce: Some(nonce), 112 + } 113 + } 114 + } 115 + 116 + impl IntoResponse for OAuthTokenError { 117 + fn into_response(self) -> Response { 118 + let body = serde_json::json!({ 119 + "error": self.error, 120 + "error_description": self.error_description, 121 + }); 122 + let mut headers = axum::http::HeaderMap::new(); 123 + headers.insert( 124 + axum::http::header::CONTENT_TYPE, 125 + "application/json".parse().unwrap(), 126 + ); 127 + if let Some(nonce) = self.dpop_nonce { 128 + headers.insert("DPoP-Nonce", nonce.parse().unwrap()); 129 + } 130 + (StatusCode::BAD_REQUEST, headers, Json(body)).into_response() 131 + } 132 + } 133 + 134 + // ── Handler ─────────────────────────────────────────────────────────────────── 135 + 136 + /// `POST /oauth/token` — OAuth 2.0 token endpoint (RFC 6749 §3.2). 137 + /// 138 + /// Phase 4 stub: validates grant_type, returns correct errors for unknown or 139 + /// missing grant_type. Full grant logic is added in Phases 5 and 6. 140 + pub async fn post_token( 141 + State(state): State<AppState>, 142 + headers: HeaderMap, 143 + Form(form): Form<TokenRequestForm>, 144 + ) -> Response { 145 + let grant_type = match form.grant_type.as_deref() { 146 + Some(g) => g, 147 + None => { 148 + return OAuthTokenError::new( 149 + "invalid_request", 150 + "missing required parameter: grant_type", 151 + ) 152 + .into_response(); 153 + } 154 + }; 155 + 156 + match grant_type { 157 + "authorization_code" => { 158 + // Implemented in Phase 5. 159 + OAuthTokenError::new("invalid_grant", "authorization_code grant not yet implemented") 160 + .into_response() 161 + } 162 + "refresh_token" => { 163 + // Implemented in Phase 6. 164 + OAuthTokenError::new("invalid_grant", "refresh_token grant not yet implemented") 165 + .into_response() 166 + } 167 + _ => OAuthTokenError::new( 168 + "unsupported_grant_type", 169 + "grant_type must be authorization_code or refresh_token", 170 + ) 171 + .into_response(), 172 + } 173 + } 174 + ``` 175 + 176 + **Step 2: Register the module in `routes/mod.rs`** 177 + 178 + In `crates/relay/src/routes/mod.rs`, add after the existing `pub mod oauth_authorize;` line: 179 + 180 + ```rust 181 + pub mod oauth_token; 182 + ``` 183 + 184 + **Step 3: Register the route in `app.rs`** 185 + 186 + In `crates/relay/src/app.rs`, add these imports after the existing OAuth route imports: 187 + 188 + ```rust 189 + use crate::routes::oauth_token::post_token; 190 + ``` 191 + 192 + In the `app()` function's `Router::new()` chain, add after the `/oauth/authorize` route: 193 + 194 + ```rust 195 + .route("/oauth/token", post(post_token)) 196 + ``` 197 + 198 + **Step 4: Compile** 199 + 200 + ```bash 201 + cargo build -p relay 202 + ``` 203 + 204 + Expected: compiles without errors. 205 + 206 + **Step 5: Commit** 207 + 208 + ```bash 209 + git add crates/relay/src/routes/oauth_token.rs \ 210 + crates/relay/src/routes/mod.rs \ 211 + crates/relay/src/app.rs 212 + git commit -m "feat(relay): POST /oauth/token stub — route, types, error format" 213 + ``` 214 + <!-- END_TASK_1 --> 215 + 216 + <!-- START_TASK_2 --> 217 + ### Task 2: Tests for Phase 4 (error format + grant_type dispatch) 218 + 219 + **Verifies:** MM-77.AC5.2, MM-77.AC5.3, MM-77.AC5.4 220 + 221 + **Files:** 222 + - Modify: `crates/relay/src/routes/oauth_token.rs` (add `#[cfg(test)]` block) 223 + 224 + **Step 1: Add tests to `oauth_token.rs`** 225 + 226 + Append this block at the end of `routes/oauth_token.rs`: 227 + 228 + ```rust 229 + #[cfg(test)] 230 + mod tests { 231 + use axum::{ 232 + body::Body, 233 + http::{Request, StatusCode}, 234 + }; 235 + use tower::ServiceExt; 236 + 237 + use crate::app::{app, test_state}; 238 + 239 + fn post_token(body: &str) -> Request<Body> { 240 + Request::builder() 241 + .method("POST") 242 + .uri("/oauth/token") 243 + .header("Content-Type", "application/x-www-form-urlencoded") 244 + .body(Body::from(body.to_string())) 245 + .unwrap() 246 + } 247 + 248 + async fn json_body(resp: axum::response::Response) -> serde_json::Value { 249 + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 250 + serde_json::from_slice(&bytes).unwrap() 251 + } 252 + 253 + // AC5.2 — unknown grant_type 254 + #[tokio::test] 255 + async fn unknown_grant_type_returns_400_unsupported() { 256 + let resp = app(test_state().await) 257 + .oneshot(post_token("grant_type=client_credentials")) 258 + .await 259 + .unwrap(); 260 + 261 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 262 + let json = json_body(resp).await; 263 + assert_eq!(json["error"], "unsupported_grant_type"); 264 + } 265 + 266 + // AC5.3 — missing grant_type 267 + #[tokio::test] 268 + async fn missing_grant_type_returns_400_invalid_request() { 269 + let resp = app(test_state().await) 270 + .oneshot(post_token("code=abc123")) 271 + .await 272 + .unwrap(); 273 + 274 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 275 + let json = json_body(resp).await; 276 + assert_eq!(json["error"], "invalid_request"); 277 + } 278 + 279 + // AC5.4 — errors must be JSON, not HTML 280 + #[tokio::test] 281 + async fn error_response_content_type_is_json() { 282 + let resp = app(test_state().await) 283 + .oneshot(post_token("grant_type=bad")) 284 + .await 285 + .unwrap(); 286 + 287 + let ct = resp 288 + .headers() 289 + .get("content-type") 290 + .unwrap() 291 + .to_str() 292 + .unwrap(); 293 + assert!(ct.contains("application/json"), "content-type must be application/json"); 294 + } 295 + 296 + // AC5.1 partial — errors have expected field shape 297 + #[tokio::test] 298 + async fn error_response_has_error_and_error_description_fields() { 299 + let resp = app(test_state().await) 300 + .oneshot(post_token("grant_type=bad")) 301 + .await 302 + .unwrap(); 303 + 304 + let json = json_body(resp).await; 305 + assert!(json["error"].is_string(), "error field must be a string"); 306 + assert!( 307 + json["error_description"].is_string(), 308 + "error_description field must be a string" 309 + ); 310 + } 311 + 312 + // GET to /oauth/token should return 405 Method Not Allowed. 313 + #[tokio::test] 314 + async fn get_token_endpoint_returns_405() { 315 + let resp = app(test_state().await) 316 + .oneshot( 317 + Request::builder() 318 + .method("GET") 319 + .uri("/oauth/token") 320 + .body(Body::empty()) 321 + .unwrap(), 322 + ) 323 + .await 324 + .unwrap(); 325 + 326 + assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); 327 + } 328 + } 329 + ``` 330 + 331 + **Step 2: Run tests** 332 + 333 + ```bash 334 + cargo test -p relay routes::oauth_token 335 + ``` 336 + 337 + Expected: all 5 tests pass. 338 + 339 + **Step 3: Commit** 340 + 341 + ```bash 342 + git add crates/relay/src/routes/oauth_token.rs 343 + git commit -m "test(relay): POST /oauth/token Phase 4 — error format and grant_type tests" 344 + ``` 345 + <!-- END_TASK_2 --> 346 + 347 + <!-- START_TASK_3 --> 348 + ### Task 3: Bruno collection entries 349 + 350 + **Files:** 351 + - Create: `bruno/oauth_token_authorization_code.bru` (seq 15) 352 + - Create: `bruno/oauth_token_refresh.bru` (seq 16) 353 + 354 + **Step 1: Create `bruno/oauth_token_authorization_code.bru`** 355 + 356 + ``` 357 + meta { 358 + name: OAuth Token — Authorization Code Exchange 359 + type: http 360 + seq: 15 361 + } 362 + 363 + post { 364 + url: {{baseUrl}}/oauth/token 365 + body: formUrlEncoded 366 + auth: none 367 + } 368 + 369 + headers { 370 + DPoP: {{dpopProof}} 371 + } 372 + 373 + body:form-urlencoded { 374 + grant_type: authorization_code 375 + code: {{authCode}} 376 + redirect_uri: https://app.example.com/callback 377 + client_id: https://app.example.com/client-metadata.json 378 + code_verifier: {{codeVerifier}} 379 + } 380 + 381 + vars:pre-request { 382 + dpopProof: <replace-with-dpop-proof-jwt> 383 + authCode: <replace-with-authorization-code> 384 + codeVerifier: <replace-with-pkce-code-verifier> 385 + } 386 + ``` 387 + 388 + **Step 2: Create `bruno/oauth_token_refresh.bru`** 389 + 390 + ``` 391 + meta { 392 + name: OAuth Token — Refresh Token Rotation 393 + type: http 394 + seq: 16 395 + } 396 + 397 + post { 398 + url: {{baseUrl}}/oauth/token 399 + body: formUrlEncoded 400 + auth: none 401 + } 402 + 403 + headers { 404 + DPoP: {{dpopProof}} 405 + } 406 + 407 + body:form-urlencoded { 408 + grant_type: refresh_token 409 + refresh_token: {{refreshToken}} 410 + client_id: https://app.example.com/client-metadata.json 411 + } 412 + 413 + vars:pre-request { 414 + dpopProof: <replace-with-dpop-proof-jwt> 415 + refreshToken: <replace-with-refresh-token> 416 + } 417 + ``` 418 + 419 + **Step 3: Commit** 420 + 421 + ```bash 422 + git add bruno/oauth_token_authorization_code.bru bruno/oauth_token_refresh.bru 423 + git commit -m "docs(bruno): add oauth/token authorization_code and refresh_token entries (seq 15, 16)" 424 + ``` 425 + <!-- END_TASK_3 --> 426 + 427 + <!-- END_SUBCOMPONENT_A -->
+1333
docs/implementation-plans/2026-03-22-MM-77/phase_05.md
··· 1 + # OAuth Token Endpoint — Phase 5: Authorization Code Exchange Grant 2 + 3 + **Goal:** Implement the full `authorization_code` grant: DPoP validation with nonce, PKCE check, atomic code consumption, and ES256 JWT + refresh token issuance. 4 + 5 + **Architecture:** New DB functions `consume_authorization_code` and `store_oauth_refresh_token` in `db/oauth.rs`. New `DpopTokenEndpointError` enum + `validate_dpop_for_token_endpoint` function + `nonce: Option<String>` field on `DPopClaims` in `auth/mod.rs`. Full `authorization_code` grant path in `routes/oauth_token.rs`. 6 + 7 + **Tech Stack:** `sha2::Sha256` (PKCE S256), `base64::URL_SAFE_NO_PAD` (PKCE + JWK), `jsonwebtoken` (ES256 JWT), `sqlx` transactions (atomic code consume). 8 + 9 + **Scope:** Phase 5 of 6 10 + 11 + **Codebase verified:** 2026-03-22 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-77.AC1: Authorization code exchange 18 + - **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` 19 + - **MM-77.AC1.2 Success:** Access token is ES256 JWT with `typ=at+jwt`, `cnf.jkt`, `exp=now+300s` 20 + - **MM-77.AC1.3 Success:** Refresh token plaintext is 43-char base64url; stored row has `scope='com.atproto.refresh'` 21 + - **MM-77.AC1.4 Failure:** Invalid `code_verifier` → 400 `invalid_grant` 22 + - **MM-77.AC1.5 Failure:** Expired auth code (>60s) → 400 `invalid_grant` 23 + - **MM-77.AC1.6 Failure:** Already-consumed code → 400 `invalid_grant` 24 + - **MM-77.AC1.7 Failure:** `client_id` mismatch → 400 `invalid_grant` 25 + - **MM-77.AC1.8 Failure:** `redirect_uri` mismatch → 400 `invalid_grant` 26 + 27 + ### MM-77.AC2: DPoP proof validation 28 + - **MM-77.AC2.1 Success:** Valid DPoP proof accepted 29 + - **MM-77.AC2.2 Success:** Access token `cnf.jkt` matches the DPoP proof's JWK thumbprint 30 + - **MM-77.AC2.3 Failure:** Missing `DPoP:` header → 400 `invalid_dpop_proof` 31 + - **MM-77.AC2.4 Failure:** Wrong `htm` → 400 `invalid_dpop_proof` 32 + - **MM-77.AC2.5 Failure:** Wrong `htu` → 400 `invalid_dpop_proof` 33 + - **MM-77.AC2.6 Failure:** Stale `iat` (>60s) → 400 `invalid_dpop_proof` 34 + 35 + ### MM-77.AC3: DPoP server nonces 36 + - **MM-77.AC3.2 Failure:** No `nonce` claim → 400 `use_dpop_nonce` + `DPoP-Nonce:` header 37 + - **MM-77.AC3.3 Failure:** Expired nonce → 400 `use_dpop_nonce` + fresh nonce header 38 + - **MM-77.AC3.4 Failure:** Unknown nonce → 400 `use_dpop_nonce` 39 + - **MM-77.AC3.5 Success:** Successful response includes fresh `DPoP-Nonce:` header 40 + 41 + ### MM-77.AC6: OAuth signing key 42 + - **MM-77.AC6.3 Success:** Access tokens use ES256 signing, not HS256 43 + 44 + --- 45 + 46 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 47 + 48 + <!-- START_TASK_1 --> 49 + ### Task 1: DB functions — consume_authorization_code + store_oauth_refresh_token 50 + 51 + **Files:** 52 + - Modify: `crates/relay/src/db/oauth.rs` 53 + 54 + **Step 1: Add AuthCodeRow struct and consume_authorization_code** 55 + 56 + Append to `db/oauth.rs` (before the `#[cfg(test)]` block): 57 + 58 + ```rust 59 + /// A row read from `oauth_authorization_codes` during code exchange. 60 + pub struct AuthCodeRow { 61 + pub client_id: String, 62 + pub did: String, 63 + pub code_challenge: String, 64 + pub code_challenge_method: String, 65 + pub redirect_uri: String, 66 + pub scope: String, 67 + } 68 + 69 + /// Atomically consume an authorization code: SELECT + DELETE in one transaction. 70 + /// 71 + /// Returns `None` if the code does not exist or has already expired (`expires_at <= now`). 72 + /// Callers must treat `None` as `invalid_grant`. 73 + /// 74 + /// The code column stores the SHA-256 hex hash of the raw code bytes. Callers must 75 + /// hash the presented code before calling this function (use `routes::token::sha256_hex`). 76 + pub async fn consume_authorization_code( 77 + pool: &SqlitePool, 78 + code_hash: &str, 79 + ) -> Result<Option<AuthCodeRow>, sqlx::Error> { 80 + let mut tx = pool.begin().await?; 81 + 82 + let row: Option<(String, String, String, String, String, String)> = sqlx::query_as( 83 + "SELECT client_id, did, code_challenge, code_challenge_method, redirect_uri, scope \ 84 + FROM oauth_authorization_codes \ 85 + WHERE code = ? AND expires_at > datetime('now')", 86 + ) 87 + .bind(code_hash) 88 + .fetch_optional(&mut *tx) 89 + .await?; 90 + 91 + if row.is_some() { 92 + sqlx::query("DELETE FROM oauth_authorization_codes WHERE code = ?") 93 + .bind(code_hash) 94 + .execute(&mut *tx) 95 + .await?; 96 + } 97 + 98 + tx.commit().await?; 99 + 100 + Ok(row.map( 101 + |(client_id, did, code_challenge, code_challenge_method, redirect_uri, scope)| { 102 + AuthCodeRow { 103 + client_id, 104 + did, 105 + code_challenge, 106 + code_challenge_method, 107 + redirect_uri, 108 + scope, 109 + } 110 + }, 111 + )) 112 + } 113 + 114 + /// Store a new refresh token in `oauth_tokens`. 115 + /// 116 + /// `token_hash` is used as the row's `id` (PRIMARY KEY). This follows the same 117 + /// pattern as `oauth_authorization_codes` where `code` IS the hash. 118 + /// `scope` is always `'com.atproto.refresh'` for OAuth refresh tokens. 119 + /// `jkt` is the DPoP key thumbprint binding this token to the client's keypair. 120 + /// Expires 24 hours after insertion. 121 + pub async fn store_oauth_refresh_token( 122 + pool: &SqlitePool, 123 + token_hash: &str, 124 + client_id: &str, 125 + did: &str, 126 + jkt: &str, 127 + ) -> Result<(), sqlx::Error> { 128 + sqlx::query( 129 + "INSERT INTO oauth_tokens (id, client_id, did, scope, jkt, expires_at, created_at) \ 130 + VALUES (?, ?, ?, 'com.atproto.refresh', ?, datetime('now', '+24 hours'), datetime('now'))", 131 + ) 132 + .bind(token_hash) 133 + .bind(client_id) 134 + .bind(did) 135 + .bind(jkt) 136 + .execute(pool) 137 + .await?; 138 + Ok(()) 139 + } 140 + ``` 141 + 142 + **Step 2: Add DB tests** 143 + 144 + In the `#[cfg(test)]` block of `db/oauth.rs`, add helper and tests: 145 + 146 + ```rust 147 + /// Insert an account row needed to satisfy oauth_tokens FK. 148 + async fn insert_test_account(pool: &SqlitePool) { 149 + sqlx::query( 150 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 151 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 152 + datetime('now'), datetime('now'))", 153 + ) 154 + .execute(pool) 155 + .await 156 + .unwrap(); 157 + } 158 + 159 + #[tokio::test] 160 + async fn consume_authorization_code_returns_row_and_deletes_it() { 161 + let pool = test_pool().await; 162 + register_oauth_client( 163 + &pool, 164 + "https://app.example.com/client-metadata.json", 165 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 166 + ) 167 + .await 168 + .unwrap(); 169 + insert_test_account(&pool).await; 170 + 171 + store_authorization_code( 172 + &pool, 173 + "hash-abc123", 174 + "https://app.example.com/client-metadata.json", 175 + "did:plc:testaccount000000000000", 176 + "s256challenge", 177 + "S256", 178 + "https://app.example.com/callback", 179 + "atproto", 180 + ) 181 + .await 182 + .unwrap(); 183 + 184 + let row = consume_authorization_code(&pool, "hash-abc123") 185 + .await 186 + .unwrap() 187 + .expect("code should be found"); 188 + 189 + assert_eq!(row.client_id, "https://app.example.com/client-metadata.json"); 190 + assert_eq!(row.did, "did:plc:testaccount000000000000"); 191 + 192 + // Second consume: must return None (already deleted). 193 + let second = consume_authorization_code(&pool, "hash-abc123").await.unwrap(); 194 + assert!(second.is_none(), "consumed code must not be found again (AC1.6)"); 195 + } 196 + 197 + #[tokio::test] 198 + async fn consume_authorization_code_returns_none_for_unknown_code() { 199 + let pool = test_pool().await; 200 + let result = consume_authorization_code(&pool, "nonexistent-hash").await.unwrap(); 201 + assert!(result.is_none()); 202 + } 203 + 204 + #[tokio::test] 205 + async fn consume_authorization_code_returns_none_for_expired_code() { 206 + // AC1.5: expired auth codes (>60s) are rejected. 207 + let pool = test_pool().await; 208 + register_oauth_client( 209 + &pool, 210 + "https://app.example.com/client-metadata.json", 211 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 212 + ) 213 + .await 214 + .unwrap(); 215 + 216 + sqlx::query( 217 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 218 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 219 + datetime('now'), datetime('now'))", 220 + ) 221 + .execute(&pool) 222 + .await 223 + .unwrap(); 224 + 225 + // Insert an already-expired auth code directly (bypassing store_authorization_code's +60s default). 226 + sqlx::query( 227 + "INSERT INTO oauth_authorization_codes \ 228 + (code, client_id, did, code_challenge, code_challenge_method, redirect_uri, scope, expires_at, created_at) \ 229 + VALUES (?, ?, ?, ?, 'S256', ?, 'atproto', datetime('now', '-1 seconds'), datetime('now'))", 230 + ) 231 + .bind("expired-code-hash") 232 + .bind("https://app.example.com/client-metadata.json") 233 + .bind("did:plc:testaccount000000000000") 234 + .bind("s256challenge") 235 + .bind("https://app.example.com/callback") 236 + .execute(&pool) 237 + .await 238 + .unwrap(); 239 + 240 + let result = consume_authorization_code(&pool, "expired-code-hash") 241 + .await 242 + .unwrap(); 243 + assert!(result.is_none(), "expired auth code must return None (AC1.5)"); 244 + } 245 + 246 + #[tokio::test] 247 + async fn store_oauth_refresh_token_persists_row() { 248 + let pool = test_pool().await; 249 + register_oauth_client( 250 + &pool, 251 + "https://app.example.com/client-metadata.json", 252 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 253 + ) 254 + .await 255 + .unwrap(); 256 + insert_test_account(&pool).await; 257 + 258 + store_oauth_refresh_token( 259 + &pool, 260 + "refresh-token-hash-01", 261 + "https://app.example.com/client-metadata.json", 262 + "did:plc:testaccount000000000000", 263 + "jkt-thumbprint", 264 + ) 265 + .await 266 + .unwrap(); 267 + 268 + let row: Option<(String, String, Option<String>)> = 269 + sqlx::query_as("SELECT id, scope, jkt FROM oauth_tokens WHERE id = ?") 270 + .bind("refresh-token-hash-01") 271 + .fetch_optional(&pool) 272 + .await 273 + .unwrap(); 274 + 275 + let (id, scope, jkt) = row.expect("refresh token row must exist"); 276 + assert_eq!(id, "refresh-token-hash-01"); 277 + assert_eq!(scope, "com.atproto.refresh", "scope must be com.atproto.refresh (AC1.3)"); 278 + assert_eq!(jkt.as_deref(), Some("jkt-thumbprint")); 279 + } 280 + ``` 281 + 282 + **Step 3: Run DB tests** 283 + 284 + ```bash 285 + cargo test -p relay db::oauth 286 + ``` 287 + 288 + Expected: all tests pass. 289 + 290 + **Step 4: Commit** 291 + 292 + ```bash 293 + git add crates/relay/src/db/oauth.rs 294 + git commit -m "feat(db): consume_authorization_code + store_oauth_refresh_token" 295 + ``` 296 + <!-- END_TASK_1 --> 297 + 298 + <!-- START_TASK_2 --> 299 + ### Task 2: DPoP validation for token endpoint 300 + 301 + **Files:** 302 + - Modify: `crates/relay/src/auth/mod.rs` 303 + 304 + Add `nonce: Option<String>` to `DPopClaims` (backward-compatible — absent `nonce` deserializes to `None`). Add `DpopTokenEndpointError` enum. Add `validate_dpop_for_token_endpoint` function. 305 + 306 + **Step 1: Add `nonce` to `DPopClaims`** 307 + 308 + Find the `DPopClaims` struct (around line 88 in `auth/mod.rs`): 309 + 310 + ```rust 311 + struct DPopClaims { 312 + htm: String, 313 + htu: String, 314 + iat: i64, 315 + jti: String, 316 + } 317 + ``` 318 + 319 + Add the `nonce` field: 320 + 321 + ```rust 322 + struct DPopClaims { 323 + htm: String, 324 + htu: String, 325 + iat: i64, 326 + jti: String, 327 + /// Server-issued DPoP nonce (RFC 9449 §8). Required when the server has issued one. 328 + #[serde(default)] 329 + nonce: Option<String>, 330 + } 331 + ``` 332 + 333 + **Step 2: Add DpopTokenEndpointError and validate_dpop_for_token_endpoint** 334 + 335 + After the `build_encoding_key` function (added in Phase 2) and before the `#[cfg(test)]` block, add: 336 + 337 + ```rust 338 + /// Error from DPoP validation at the token endpoint. 339 + /// 340 + /// Converted to `OAuthTokenError` by the handler in `routes/oauth_token.rs`. 341 + pub(crate) enum DpopTokenEndpointError { 342 + /// `DPoP:` header is absent. 343 + MissingHeader, 344 + /// DPoP proof is syntactically or semantically invalid. 345 + InvalidProof(&'static str), 346 + /// Nonce is missing, unknown, or expired — fresh nonce included for the response header. 347 + UseNonce(String), 348 + } 349 + 350 + /// Validate the DPoP proof at the token endpoint and return the JWK thumbprint. 351 + /// 352 + /// This is a token-endpoint-specific variant of `validate_dpop`: 353 + /// - Does NOT check `cnf.jkt` against an existing access token (no token yet). 354 + /// - DOES validate the `nonce` claim against the nonce store. 355 + /// - Returns the JWK thumbprint (jkt) so the handler can embed it in `cnf.jkt`. 356 + /// 357 + /// `htm` must be `"POST"`. `htu` must be the token endpoint URL (e.g. 358 + /// `"https://relay.example.com/oauth/token"`). 359 + pub(crate) async fn validate_dpop_for_token_endpoint( 360 + dpop_token: &str, 361 + htm: &str, 362 + htu: &str, 363 + nonce_store: &DpopNonceStore, 364 + ) -> Result<String, DpopTokenEndpointError> { 365 + // Decode the DPoP proof header manually (same pattern as validate_dpop). 366 + let header_b64 = dpop_token 367 + .split('.') 368 + .next() 369 + .ok_or(DpopTokenEndpointError::InvalidProof("malformed DPoP JWT"))?; 370 + let header_bytes = URL_SAFE_NO_PAD 371 + .decode(header_b64) 372 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP header base64 invalid"))?; 373 + let dpop_header: DPopHeader = serde_json::from_slice(&header_bytes) 374 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP header JSON malformed"))?; 375 + 376 + if dpop_header.typ != "dpop+jwt" { 377 + return Err(DpopTokenEndpointError::InvalidProof("DPoP typ must be dpop+jwt")); 378 + } 379 + 380 + // Verify the signature against the embedded JWK. 381 + let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(dpop_header.jwk.clone()) 382 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP JWK parse failed"))?; 383 + let decoding_key = DecodingKey::from_jwk(&jwk) 384 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP DecodingKey build failed"))?; 385 + let alg = dpop_alg_from_str(&dpop_header.alg) 386 + .ok_or(DpopTokenEndpointError::InvalidProof("DPoP unsupported alg"))?; 387 + 388 + let mut validation = Validation::new(alg); 389 + validation.validate_exp = false; 390 + validation.set_required_spec_claims::<&str>(&[]); 391 + validation.validate_aud = false; 392 + 393 + let dpop_data = decode::<DPopClaims>(dpop_token, &decoding_key, &validation) 394 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP signature verification failed"))?; 395 + let claims = dpop_data.claims; 396 + 397 + // Validate htm (HTTP method). 398 + if claims.htm.to_uppercase() != htm.to_uppercase() { 399 + return Err(DpopTokenEndpointError::InvalidProof("DPoP htm mismatch")); 400 + } 401 + 402 + // Validate htu (target URI). 403 + if claims.htu != htu { 404 + return Err(DpopTokenEndpointError::InvalidProof("DPoP htu mismatch")); 405 + } 406 + 407 + // Validate jti (presence only — server nonce provides replay protection). 408 + if claims.jti.is_empty() { 409 + return Err(DpopTokenEndpointError::InvalidProof("DPoP jti missing")); 410 + } 411 + 412 + // Freshness: reject proofs older than 60 seconds or from the future. 413 + let now = std::time::SystemTime::now() 414 + .duration_since(std::time::UNIX_EPOCH) 415 + .map_err(|_| DpopTokenEndpointError::InvalidProof("system clock error"))? 416 + .as_secs() as i64; 417 + let diff = (now as i128) - (claims.iat as i128); 418 + if diff.unsigned_abs() > 60 { 419 + return Err(DpopTokenEndpointError::InvalidProof("DPoP proof stale")); 420 + } 421 + 422 + // Validate nonce claim. 423 + match claims.nonce.as_deref() { 424 + None | Some("") => { 425 + // No nonce — issue a fresh one for the client to retry with. 426 + let fresh = issue_nonce(nonce_store).await; 427 + return Err(DpopTokenEndpointError::UseNonce(fresh)); 428 + } 429 + Some(nonce) => { 430 + if !validate_and_consume_nonce(nonce_store, nonce).await { 431 + // Unknown or expired nonce — issue a fresh one. 432 + let fresh = issue_nonce(nonce_store).await; 433 + return Err(DpopTokenEndpointError::UseNonce(fresh)); 434 + } 435 + } 436 + } 437 + 438 + // Compute and return the JWK thumbprint. 439 + jwk_thumbprint(&dpop_header.jwk) 440 + .map_err(|_| DpopTokenEndpointError::InvalidProof("JWK thumbprint computation failed")) 441 + } 442 + ``` 443 + 444 + **Step 3: Run tests** 445 + 446 + ```bash 447 + cargo test -p relay 448 + ``` 449 + 450 + Expected: all existing tests still pass (adding `nonce` to `DPopClaims` is backward compatible via `#[serde(default)]`). 451 + 452 + **Step 4: Commit** 453 + 454 + ```bash 455 + git add crates/relay/src/auth/mod.rs 456 + git commit -m "feat(auth): validate_dpop_for_token_endpoint + DpopTokenEndpointError" 457 + ``` 458 + <!-- END_TASK_2 --> 459 + 460 + <!-- END_SUBCOMPONENT_A --> 461 + 462 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 463 + 464 + <!-- START_TASK_3 --> 465 + ### Task 3: Implement authorization_code grant in handler 466 + 467 + **Files:** 468 + - Modify: `crates/relay/src/routes/oauth_token.rs` 469 + 470 + Replace the `"authorization_code"` stub arm in `post_token` with the full grant implementation. 471 + 472 + **Step 1: Add imports to `oauth_token.rs`** 473 + 474 + At the top of `routes/oauth_token.rs`, add or extend the use declarations: 475 + 476 + ```rust 477 + use std::time::{SystemTime, UNIX_EPOCH}; 478 + 479 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 480 + use sha2::{Digest, Sha256}; 481 + 482 + use crate::auth::{ 483 + cleanup_expired_nonces, issue_nonce, validate_dpop_for_token_endpoint, 484 + DpopTokenEndpointError, 485 + }; 486 + use crate::db::oauth::{consume_authorization_code, store_oauth_refresh_token}; 487 + use crate::routes::token::generate_token; 488 + ``` 489 + 490 + **Step 2: Add helper — issue_access_token** 491 + 492 + Add a private helper function above `post_token`: 493 + 494 + ```rust 495 + /// Claims for an OAuth 2.0 AT+JWT access token (RFC 9068). 496 + #[derive(Serialize)] 497 + struct AccessTokenClaims { 498 + sub: String, 499 + iat: u64, 500 + exp: u64, 501 + scope: String, 502 + /// DPoP confirmation claim (RFC 9449 §4.3): binds the token to the client's keypair. 503 + cnf: CnfClaim, 504 + } 505 + 506 + #[derive(Serialize)] 507 + struct CnfClaim { 508 + jkt: String, 509 + } 510 + 511 + fn issue_access_token( 512 + signing_key: &crate::auth::OAuthSigningKey, 513 + did: &str, 514 + scope: &str, 515 + jkt: &str, 516 + ) -> Result<String, OAuthTokenError> { 517 + let now = SystemTime::now() 518 + .duration_since(UNIX_EPOCH) 519 + .map_err(|_| OAuthTokenError::new("server_error", "system clock error"))? 520 + .as_secs(); 521 + 522 + let claims = AccessTokenClaims { 523 + sub: did.to_string(), 524 + iat: now, 525 + exp: now + 300, 526 + scope: scope.to_string(), 527 + cnf: CnfClaim { 528 + jkt: jkt.to_string(), 529 + }, 530 + }; 531 + 532 + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); 533 + header.typ = Some("at+jwt".to_string()); 534 + header.kid = Some(signing_key.key_id.clone()); 535 + 536 + jsonwebtoken::encode(&header, &claims, &signing_key.encoding_key) 537 + .map_err(|e| { 538 + tracing::error!(error = %e, "failed to sign access token"); 539 + OAuthTokenError::new("server_error", "token signing failed") 540 + }) 541 + } 542 + 543 + /// Verify the PKCE S256 code challenge. 544 + fn verify_pkce_s256(code_verifier: &str, stored_challenge: &str) -> bool { 545 + let hash = Sha256::digest(code_verifier.as_bytes()); 546 + let computed = URL_SAFE_NO_PAD.encode(hash); 547 + // Constant-time comparison to prevent timing oracle. 548 + subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), stored_challenge.as_bytes()).into() 549 + } 550 + ``` 551 + 552 + **Step 3: Replace the authorization_code stub arm in post_token** 553 + 554 + Replace the `"authorization_code"` match arm: 555 + 556 + ```rust 557 + "authorization_code" => { 558 + handle_authorization_code(&state, &headers, form).await 559 + } 560 + ``` 561 + 562 + Add the full implementation as a separate async function after `post_token`: 563 + 564 + ```rust 565 + async fn handle_authorization_code( 566 + state: &AppState, 567 + headers: &HeaderMap, 568 + form: TokenRequestForm, 569 + ) -> Response { 570 + // Prune stale nonces on every request. 571 + cleanup_expired_nonces(&state.dpop_nonces).await; 572 + 573 + // Required fields: code, redirect_uri, client_id, code_verifier. 574 + let code = match form.code.as_deref() { 575 + Some(c) if !c.is_empty() => c, 576 + _ => return OAuthTokenError::new("invalid_request", "missing parameter: code").into_response(), 577 + }; 578 + let redirect_uri = match form.redirect_uri.as_deref() { 579 + Some(u) if !u.is_empty() => u, 580 + _ => return OAuthTokenError::new("invalid_request", "missing parameter: redirect_uri").into_response(), 581 + }; 582 + let client_id = match form.client_id.as_deref() { 583 + Some(id) if !id.is_empty() => id, 584 + _ => return OAuthTokenError::new("invalid_request", "missing parameter: client_id").into_response(), 585 + }; 586 + let code_verifier = match form.code_verifier.as_deref() { 587 + Some(v) if !v.is_empty() => v, 588 + _ => return OAuthTokenError::new("invalid_request", "missing parameter: code_verifier").into_response(), 589 + }; 590 + 591 + // Validate DPoP proof. 592 + let dpop_token = match headers 593 + .get("DPoP") 594 + .and_then(|v| v.to_str().ok()) 595 + { 596 + Some(t) => t.to_string(), 597 + None => { 598 + return OAuthTokenError::new("invalid_dpop_proof", "DPoP header required") 599 + .into_response(); 600 + } 601 + }; 602 + 603 + let token_url = format!( 604 + "{}/oauth/token", 605 + state.config.public_url.trim_end_matches('/') 606 + ); 607 + 608 + let jkt = match validate_dpop_for_token_endpoint( 609 + &dpop_token, 610 + "POST", 611 + &token_url, 612 + &state.dpop_nonces, 613 + ) 614 + .await 615 + { 616 + Ok(jkt) => jkt, 617 + Err(DpopTokenEndpointError::MissingHeader) => { 618 + return OAuthTokenError::new("invalid_dpop_proof", "DPoP header required") 619 + .into_response(); 620 + } 621 + Err(DpopTokenEndpointError::InvalidProof(msg)) => { 622 + return OAuthTokenError::new("invalid_dpop_proof", msg).into_response(); 623 + } 624 + Err(DpopTokenEndpointError::UseNonce(fresh_nonce)) => { 625 + return OAuthTokenError::with_nonce( 626 + "use_dpop_nonce", 627 + "DPoP nonce required", 628 + fresh_nonce, 629 + ) 630 + .into_response(); 631 + } 632 + }; 633 + 634 + // Hash the presented code for DB lookup. 635 + let code_hash = crate::routes::token::sha256_hex( 636 + &URL_SAFE_NO_PAD 637 + .decode(code) 638 + .unwrap_or_else(|_| code.as_bytes().to_vec()), 639 + ); 640 + 641 + // Atomically consume the authorization code. 642 + let auth_code = match crate::db::oauth::consume_authorization_code(&state.db, &code_hash).await { 643 + Ok(Some(row)) => row, 644 + Ok(None) => { 645 + return OAuthTokenError::new("invalid_grant", "authorization code invalid or expired") 646 + .into_response(); 647 + } 648 + Err(e) => { 649 + tracing::error!(error = %e, "failed to consume authorization code"); 650 + return OAuthTokenError::new("server_error", "database error").into_response(); 651 + } 652 + }; 653 + 654 + // Verify client_id matches. 655 + if auth_code.client_id != client_id { 656 + return OAuthTokenError::new("invalid_grant", "client_id mismatch").into_response(); 657 + } 658 + 659 + // Verify redirect_uri matches. 660 + if auth_code.redirect_uri != redirect_uri { 661 + return OAuthTokenError::new("invalid_grant", "redirect_uri mismatch").into_response(); 662 + } 663 + 664 + // Verify PKCE S256 challenge. 665 + if !verify_pkce_s256(code_verifier, &auth_code.code_challenge) { 666 + return OAuthTokenError::new("invalid_grant", "code_verifier does not match code_challenge") 667 + .into_response(); 668 + } 669 + 670 + // Issue ES256 access token. 671 + let access_token = 672 + match issue_access_token(&state.oauth_signing_keypair, &auth_code.did, &auth_code.scope, &jkt) 673 + { 674 + Ok(t) => t, 675 + Err(e) => return e.into_response(), 676 + }; 677 + 678 + // Generate and store refresh token. 679 + let refresh = generate_token(); 680 + if let Err(e) = store_oauth_refresh_token( 681 + &state.db, 682 + &refresh.hash, 683 + &auth_code.client_id, 684 + &auth_code.did, 685 + &jkt, 686 + ) 687 + .await 688 + { 689 + tracing::error!(error = %e, "failed to store refresh token"); 690 + return OAuthTokenError::new("server_error", "database error").into_response(); 691 + } 692 + 693 + // Issue a fresh DPoP nonce for the next request. 694 + let fresh_nonce = issue_nonce(&state.dpop_nonces).await; 695 + 696 + let mut response_headers = axum::http::HeaderMap::new(); 697 + response_headers.insert("DPoP-Nonce", fresh_nonce.parse().unwrap()); 698 + 699 + ( 700 + StatusCode::OK, 701 + response_headers, 702 + Json(TokenResponse { 703 + access_token, 704 + token_type: "DPoP", 705 + expires_in: 300, 706 + refresh_token: refresh.plaintext, 707 + scope: auth_code.scope, 708 + }), 709 + ) 710 + .into_response() 711 + } 712 + ``` 713 + 714 + **Step 4: Compile** 715 + 716 + ```bash 717 + cargo build -p relay 718 + ``` 719 + 720 + Expected: compiles without errors. 721 + 722 + **Step 5: Commit** 723 + 724 + ```bash 725 + git add crates/relay/src/routes/oauth_token.rs 726 + git commit -m "feat(relay): authorization_code grant — DPoP, PKCE, ES256 JWT + refresh token" 727 + ``` 728 + <!-- END_TASK_3 --> 729 + 730 + <!-- START_TASK_4 --> 731 + ### Task 4: Integration tests for authorization_code grant 732 + 733 + **Verifies:** MM-77.AC1.1–AC1.8, MM-77.AC2.1–AC2.6, MM-77.AC3.2–AC3.5, MM-77.AC6.3 734 + 735 + **Files:** 736 + - Modify: `crates/relay/src/routes/oauth_token.rs` (`#[cfg(test)]` block) 737 + 738 + The existing test helpers in `auth/mod.rs` (`make_dpop_proof`, `dpop_key_to_jwk`, `dpop_key_thumbprint`) are in `#[cfg(test)]` in that module. The tests below live in `routes/oauth_token.rs` and need their own local DPoP proof helpers or must call through `auth::` test utilities (not possible — they're private test-scope). Implement local equivalents in the test module. 739 + 740 + **Step 1: Replace the existing test module in `oauth_token.rs`** 741 + 742 + Replace the entire `#[cfg(test)] mod tests { ... }` block (from Phase 4) with this expanded version: 743 + 744 + ```rust 745 + #[cfg(test)] 746 + mod tests { 747 + use axum::{ 748 + body::Body, 749 + http::{Request, StatusCode}, 750 + }; 751 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 752 + use p256::ecdsa::{signature::Signer, Signature, SigningKey}; 753 + use rand_core::OsRng; 754 + use sha2::{Digest, Sha256}; 755 + use tower::ServiceExt; 756 + use uuid::Uuid; 757 + 758 + use crate::app::{app, test_state, AppState}; 759 + use crate::auth::issue_nonce; 760 + use crate::db::oauth::{register_oauth_client, store_authorization_code}; 761 + 762 + // ── DPoP proof test helpers ─────────────────────────────────────────────── 763 + 764 + fn now_secs() -> i64 { 765 + std::time::SystemTime::now() 766 + .duration_since(std::time::UNIX_EPOCH) 767 + .unwrap() 768 + .as_secs() as i64 769 + } 770 + 771 + fn dpop_key_to_jwk(key: &SigningKey) -> serde_json::Value { 772 + let vk = key.verifying_key(); 773 + let point = vk.to_encoded_point(false); 774 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 775 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 776 + serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x, "y": y }) 777 + } 778 + 779 + fn dpop_thumbprint(key: &SigningKey) -> String { 780 + let jwk = dpop_key_to_jwk(key); 781 + let canonical = serde_json::to_string(&serde_json::json!({ 782 + "crv": jwk["crv"], 783 + "kty": jwk["kty"], 784 + "x": jwk["x"], 785 + "y": jwk["y"], 786 + })) 787 + .unwrap(); 788 + let hash = Sha256::digest(canonical.as_bytes()); 789 + URL_SAFE_NO_PAD.encode(hash) 790 + } 791 + 792 + fn make_dpop_proof( 793 + key: &SigningKey, 794 + htm: &str, 795 + htu: &str, 796 + nonce: Option<&str>, 797 + iat: i64, 798 + ) -> String { 799 + let jwk = dpop_key_to_jwk(key); 800 + let header = serde_json::json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": jwk }); 801 + let mut payload = 802 + serde_json::json!({ "htm": htm, "htu": htu, "iat": iat, "jti": Uuid::new_v4().to_string() }); 803 + if let Some(n) = nonce { 804 + payload["nonce"] = serde_json::Value::String(n.to_string()); 805 + } 806 + let hdr = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 807 + let pay = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 808 + let sig_input = format!("{hdr}.{pay}"); 809 + let sig: Signature = key.sign(sig_input.as_bytes()); 810 + let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8]); 811 + format!("{hdr}.{pay}.{sig_b64}") 812 + } 813 + 814 + /// Seed the DB with a test client + account + authorization code. 815 + async fn seed_auth_code(state: &AppState, code_hash: &str, code_challenge: &str) { 816 + register_oauth_client( 817 + &state.db, 818 + "https://app.example.com/client-metadata.json", 819 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 820 + ) 821 + .await 822 + .unwrap(); 823 + 824 + sqlx::query( 825 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 826 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 827 + datetime('now'), datetime('now'))", 828 + ) 829 + .execute(&state.db) 830 + .await 831 + .unwrap(); 832 + 833 + store_authorization_code( 834 + &state.db, 835 + code_hash, 836 + "https://app.example.com/client-metadata.json", 837 + "did:plc:testaccount000000000000", 838 + code_challenge, 839 + "S256", 840 + "https://app.example.com/callback", 841 + "atproto", 842 + ) 843 + .await 844 + .unwrap(); 845 + } 846 + 847 + fn post_token(body: &str) -> Request<Body> { 848 + Request::builder() 849 + .method("POST") 850 + .uri("/oauth/token") 851 + .header("Content-Type", "application/x-www-form-urlencoded") 852 + .body(Body::from(body.to_string())) 853 + .unwrap() 854 + } 855 + 856 + fn post_token_with_dpop(body: &str, dpop: &str) -> Request<Body> { 857 + Request::builder() 858 + .method("POST") 859 + .uri("/oauth/token") 860 + .header("Content-Type", "application/x-www-form-urlencoded") 861 + .header("DPoP", dpop) 862 + .body(Body::from(body.to_string())) 863 + .unwrap() 864 + } 865 + 866 + async fn json_body(resp: axum::response::Response) -> serde_json::Value { 867 + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 868 + serde_json::from_slice(&bytes).unwrap() 869 + } 870 + 871 + // ── Phase 4 tests (retained) ────────────────────────────────────────────── 872 + 873 + #[tokio::test] 874 + async fn unknown_grant_type_returns_400_unsupported() { 875 + let resp = app(test_state().await) 876 + .oneshot(post_token("grant_type=client_credentials")) 877 + .await 878 + .unwrap(); 879 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 880 + let json = json_body(resp).await; 881 + assert_eq!(json["error"], "unsupported_grant_type"); 882 + } 883 + 884 + #[tokio::test] 885 + async fn missing_grant_type_returns_400_invalid_request() { 886 + let resp = app(test_state().await) 887 + .oneshot(post_token("code=abc123")) 888 + .await 889 + .unwrap(); 890 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 891 + let json = json_body(resp).await; 892 + assert_eq!(json["error"], "invalid_request"); 893 + } 894 + 895 + #[tokio::test] 896 + async fn error_response_content_type_is_json() { 897 + let resp = app(test_state().await) 898 + .oneshot(post_token("grant_type=bad")) 899 + .await 900 + .unwrap(); 901 + let ct = resp.headers().get("content-type").unwrap().to_str().unwrap(); 902 + assert!(ct.contains("application/json")); 903 + } 904 + 905 + #[tokio::test] 906 + async fn error_response_has_error_and_error_description_fields() { 907 + let resp = app(test_state().await) 908 + .oneshot(post_token("grant_type=bad")) 909 + .await 910 + .unwrap(); 911 + let json = json_body(resp).await; 912 + assert!(json["error"].is_string()); 913 + assert!(json["error_description"].is_string()); 914 + } 915 + 916 + #[tokio::test] 917 + async fn get_token_endpoint_returns_405() { 918 + let resp = app(test_state().await) 919 + .oneshot( 920 + Request::builder() 921 + .method("GET") 922 + .uri("/oauth/token") 923 + .body(Body::empty()) 924 + .unwrap(), 925 + ) 926 + .await 927 + .unwrap(); 928 + assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); 929 + } 930 + 931 + // ── AC2 — DPoP proof validation ─────────────────────────────────────────── 932 + 933 + #[tokio::test] 934 + async fn missing_dpop_header_returns_invalid_dpop_proof() { 935 + // AC2.3 936 + let resp = app(test_state().await) 937 + .oneshot(post_token("grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x")) 938 + .await 939 + .unwrap(); 940 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 941 + let json = json_body(resp).await; 942 + assert_eq!(json["error"], "invalid_dpop_proof"); 943 + } 944 + 945 + #[tokio::test] 946 + async fn dpop_wrong_htm_returns_invalid_dpop_proof() { 947 + // AC2.4 948 + let state = test_state().await; 949 + let key = SigningKey::random(&mut OsRng); 950 + let nonce = issue_nonce(&state.dpop_nonces).await; 951 + let dpop = make_dpop_proof( 952 + &key, 953 + "GET", // wrong — must be POST 954 + "https://test.example.com/oauth/token", 955 + Some(&nonce), 956 + now_secs(), 957 + ); 958 + 959 + let resp = app(state) 960 + .oneshot(post_token_with_dpop( 961 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 962 + &dpop, 963 + )) 964 + .await 965 + .unwrap(); 966 + 967 + let json = json_body(resp).await; 968 + assert_eq!(json["error"], "invalid_dpop_proof", "wrong htm must return invalid_dpop_proof"); 969 + } 970 + 971 + #[tokio::test] 972 + async fn dpop_wrong_htu_returns_invalid_dpop_proof() { 973 + // AC2.5 974 + let state = test_state().await; 975 + let key = SigningKey::random(&mut OsRng); 976 + let nonce = issue_nonce(&state.dpop_nonces).await; 977 + let dpop = make_dpop_proof( 978 + &key, 979 + "POST", 980 + "https://wrong-url.example.com/oauth/token", 981 + Some(&nonce), 982 + now_secs(), 983 + ); 984 + 985 + let resp = app(state) 986 + .oneshot(post_token_with_dpop( 987 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 988 + &dpop, 989 + )) 990 + .await 991 + .unwrap(); 992 + 993 + let json = json_body(resp).await; 994 + assert_eq!(json["error"], "invalid_dpop_proof"); 995 + } 996 + 997 + #[tokio::test] 998 + async fn dpop_stale_iat_returns_invalid_dpop_proof() { 999 + // AC2.6 1000 + let state = test_state().await; 1001 + let key = SigningKey::random(&mut OsRng); 1002 + let nonce = issue_nonce(&state.dpop_nonces).await; 1003 + let dpop = make_dpop_proof( 1004 + &key, 1005 + "POST", 1006 + "https://test.example.com/oauth/token", 1007 + Some(&nonce), 1008 + now_secs() - 120, // 2 minutes ago — stale 1009 + ); 1010 + 1011 + let resp = app(state) 1012 + .oneshot(post_token_with_dpop( 1013 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 1014 + &dpop, 1015 + )) 1016 + .await 1017 + .unwrap(); 1018 + 1019 + let json = json_body(resp).await; 1020 + assert_eq!(json["error"], "invalid_dpop_proof"); 1021 + } 1022 + 1023 + // ── AC3 — DPoP nonces ───────────────────────────────────────────────────── 1024 + 1025 + #[tokio::test] 1026 + async fn dpop_without_nonce_returns_use_dpop_nonce_with_header() { 1027 + // AC3.2 1028 + let state = test_state().await; 1029 + let key = SigningKey::random(&mut OsRng); 1030 + let dpop = make_dpop_proof( 1031 + &key, 1032 + "POST", 1033 + "https://test.example.com/oauth/token", 1034 + None, // no nonce 1035 + now_secs(), 1036 + ); 1037 + 1038 + let resp = app(state) 1039 + .oneshot(post_token_with_dpop( 1040 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 1041 + &dpop, 1042 + )) 1043 + .await 1044 + .unwrap(); 1045 + 1046 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 1047 + assert!( 1048 + resp.headers().contains_key("DPoP-Nonce"), 1049 + "use_dpop_nonce response must include DPoP-Nonce header" 1050 + ); 1051 + let json = json_body(resp).await; 1052 + assert_eq!(json["error"], "use_dpop_nonce"); 1053 + } 1054 + 1055 + #[tokio::test] 1056 + async fn dpop_with_unknown_nonce_returns_use_dpop_nonce() { 1057 + // AC3.4 1058 + let state = test_state().await; 1059 + let key = SigningKey::random(&mut OsRng); 1060 + let dpop = make_dpop_proof( 1061 + &key, 1062 + "POST", 1063 + "https://test.example.com/oauth/token", 1064 + Some("fabricated-nonce-that-was-never-issued"), 1065 + now_secs(), 1066 + ); 1067 + 1068 + let resp = app(state) 1069 + .oneshot(post_token_with_dpop( 1070 + "grant_type=authorization_code&code=x&redirect_uri=x&client_id=x&code_verifier=x", 1071 + &dpop, 1072 + )) 1073 + .await 1074 + .unwrap(); 1075 + 1076 + let json = json_body(resp).await; 1077 + assert_eq!(json["error"], "use_dpop_nonce"); 1078 + } 1079 + 1080 + // ── AC1 — authorization_code grant ─────────────────────────────────────── 1081 + 1082 + #[tokio::test] 1083 + async fn authorization_code_happy_path_returns_200_with_tokens() { 1084 + // AC1.1, AC1.2, AC1.3, AC2.1, AC2.2, AC3.5, AC6.3 1085 + let state = test_state().await; 1086 + let key = SigningKey::random(&mut OsRng); 1087 + 1088 + // Build PKCE S256 challenge. 1089 + let code_verifier = "testcodeverifier1234567890abcdefghijklmnop"; 1090 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 1091 + 1092 + // Raw code (43-char base64url) and its SHA-256 hex hash for DB storage. 1093 + let raw_code = "testauthorizationcode1234567890123456789012"; 1094 + let code_hash = { 1095 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 1096 + let hash = Sha256::digest(&bytes); 1097 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 1098 + }; 1099 + 1100 + seed_auth_code(&state, &code_hash, &code_challenge).await; 1101 + let nonce = issue_nonce(&state.dpop_nonces).await; 1102 + 1103 + let dpop = make_dpop_proof( 1104 + &key, 1105 + "POST", 1106 + "https://test.example.com/oauth/token", 1107 + Some(&nonce), 1108 + now_secs(), 1109 + ); 1110 + 1111 + let body = format!( 1112 + "grant_type=authorization_code\ 1113 + &code={raw_code}\ 1114 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 1115 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 1116 + &code_verifier={code_verifier}" 1117 + ); 1118 + 1119 + let resp = app(state) 1120 + .oneshot(post_token_with_dpop(&body, &dpop)) 1121 + .await 1122 + .unwrap(); 1123 + 1124 + assert_eq!(resp.status(), StatusCode::OK, "happy path must return 200"); 1125 + 1126 + // AC3.5 — DPoP-Nonce header in success response. 1127 + assert!( 1128 + resp.headers().contains_key("DPoP-Nonce"), 1129 + "success response must include fresh DPoP-Nonce header" 1130 + ); 1131 + 1132 + let json = json_body(resp).await; 1133 + 1134 + // AC1.1 — TokenResponse fields. 1135 + assert!(json["access_token"].is_string(), "access_token must be present"); 1136 + assert_eq!(json["token_type"], "DPoP", "token_type must be DPoP"); 1137 + assert_eq!(json["expires_in"], 300); 1138 + assert!(json["refresh_token"].is_string(), "refresh_token must be present"); 1139 + assert!(json["scope"].is_string(), "scope must be present"); 1140 + 1141 + // AC1.3 — refresh token is 43-char base64url. 1142 + let rt = json["refresh_token"].as_str().unwrap(); 1143 + assert_eq!(rt.len(), 43, "refresh_token must be 43 chars (AC1.3)"); 1144 + 1145 + // AC1.2 + AC6.3 — access token is ES256 JWT with typ=at+jwt. 1146 + let at = json["access_token"].as_str().unwrap(); 1147 + let header_b64 = at.split('.').next().unwrap(); 1148 + let header_json = String::from_utf8( 1149 + URL_SAFE_NO_PAD.decode(header_b64).unwrap(), 1150 + ) 1151 + .unwrap(); 1152 + let header: serde_json::Value = serde_json::from_str(&header_json).unwrap(); 1153 + assert_eq!(header["typ"], "at+jwt", "access token typ must be at+jwt (AC1.2)"); 1154 + assert_eq!(header["alg"], "ES256", "access token alg must be ES256 (AC6.3)"); 1155 + 1156 + // AC2.2 — cnf.jkt in access token matches DPoP key thumbprint. 1157 + let payload_b64 = at.split('.').nth(1).unwrap(); 1158 + let payload_json = String::from_utf8( 1159 + URL_SAFE_NO_PAD.decode(payload_b64).unwrap(), 1160 + ) 1161 + .unwrap(); 1162 + let payload: serde_json::Value = serde_json::from_str(&payload_json).unwrap(); 1163 + let cnf_jkt = payload["cnf"]["jkt"].as_str().unwrap(); 1164 + let expected_jkt = dpop_thumbprint(&key); 1165 + assert_eq!(cnf_jkt, expected_jkt, "cnf.jkt must match DPoP key thumbprint (AC2.2)"); 1166 + } 1167 + 1168 + #[tokio::test] 1169 + async fn wrong_code_verifier_returns_invalid_grant() { 1170 + // AC1.4 1171 + let state = test_state().await; 1172 + let key = SigningKey::random(&mut OsRng); 1173 + 1174 + let code_verifier = "correct-verifier-1234567890abcdefghijk"; 1175 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 1176 + let raw_code = "testauthorizationcode1234567890123456789012"; 1177 + let code_hash = { 1178 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 1179 + let hash = Sha256::digest(&bytes); 1180 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 1181 + }; 1182 + seed_auth_code(&state, &code_hash, &code_challenge).await; 1183 + let nonce = issue_nonce(&state.dpop_nonces).await; 1184 + let dpop = make_dpop_proof(&key, "POST", "https://test.example.com/oauth/token", Some(&nonce), now_secs()); 1185 + 1186 + let resp = app(state) 1187 + .oneshot(post_token_with_dpop( 1188 + &format!( 1189 + "grant_type=authorization_code&code={raw_code}&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json&code_verifier=wrong-verifier" 1190 + ), 1191 + &dpop, 1192 + )) 1193 + .await 1194 + .unwrap(); 1195 + 1196 + let json = json_body(resp).await; 1197 + assert_eq!(json["error"], "invalid_grant", "wrong code_verifier must return invalid_grant (AC1.4)"); 1198 + } 1199 + 1200 + #[tokio::test] 1201 + async fn consumed_code_returns_invalid_grant() { 1202 + // AC1.6 1203 + let state = test_state().await; 1204 + let key = SigningKey::random(&mut OsRng); 1205 + let code_verifier = "testcodeverifier1234567890abcdefghijklmnop"; 1206 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 1207 + let raw_code = "testauthorizationcode1234567890123456789012"; 1208 + let code_hash = { 1209 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 1210 + let hash = Sha256::digest(&bytes); 1211 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 1212 + }; 1213 + seed_auth_code(&state, &code_hash, &code_challenge).await; 1214 + 1215 + let body = format!( 1216 + "grant_type=authorization_code&code={raw_code}&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json&code_verifier={code_verifier}" 1217 + ); 1218 + 1219 + // First use — should succeed. 1220 + let nonce1 = issue_nonce(&state.dpop_nonces).await; 1221 + let dpop1 = make_dpop_proof(&key, "POST", "https://test.example.com/oauth/token", Some(&nonce1), now_secs()); 1222 + let state_arc = std::sync::Arc::new(state); 1223 + 1224 + // Build the app twice using different oneshot calls on the same state. 1225 + // Clone state so the DB pool is shared across both calls. 1226 + let state1 = (*state_arc).clone(); 1227 + let resp1 = app(state1) 1228 + .oneshot(post_token_with_dpop(&body, &dpop1)) 1229 + .await 1230 + .unwrap(); 1231 + assert_eq!(resp1.status(), StatusCode::OK, "first use must succeed"); 1232 + 1233 + // Second use — code was consumed. 1234 + let state2 = (*state_arc).clone(); 1235 + let nonce2 = issue_nonce(&state2.dpop_nonces).await; 1236 + let dpop2 = make_dpop_proof(&key, "POST", "https://test.example.com/oauth/token", Some(&nonce2), now_secs()); 1237 + let resp2 = app(state2) 1238 + .oneshot(post_token_with_dpop(&body, &dpop2)) 1239 + .await 1240 + .unwrap(); 1241 + let json2 = json_body(resp2).await; 1242 + assert_eq!(json2["error"], "invalid_grant", "second use must return invalid_grant (AC1.6)"); 1243 + } 1244 + 1245 + #[tokio::test] 1246 + async fn client_id_mismatch_returns_invalid_grant() { 1247 + // AC1.7 1248 + let state = test_state().await; 1249 + let key = SigningKey::random(&mut OsRng); 1250 + let code_verifier = "testcodeverifier1234567890abcdefghijklmnop"; 1251 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 1252 + let raw_code = "testauthorizationcode1234567890123456789012"; 1253 + let code_hash = { 1254 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 1255 + let hash = Sha256::digest(&bytes); 1256 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 1257 + }; 1258 + seed_auth_code(&state, &code_hash, &code_challenge).await; 1259 + let nonce = issue_nonce(&state.dpop_nonces).await; 1260 + let dpop = make_dpop_proof(&key, "POST", "https://test.example.com/oauth/token", Some(&nonce), now_secs()); 1261 + 1262 + let resp = app(state) 1263 + .oneshot(post_token_with_dpop( 1264 + &format!( 1265 + "grant_type=authorization_code&code={raw_code}&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&client_id=https%3A%2F%2Fwrong-client.example.com%2F&code_verifier={code_verifier}" 1266 + ), 1267 + &dpop, 1268 + )) 1269 + .await 1270 + .unwrap(); 1271 + 1272 + let json = json_body(resp).await; 1273 + assert_eq!(json["error"], "invalid_grant", "client_id mismatch must return invalid_grant (AC1.7)"); 1274 + } 1275 + 1276 + #[tokio::test] 1277 + async fn redirect_uri_mismatch_returns_invalid_grant() { 1278 + // AC1.8 1279 + let state = test_state().await; 1280 + let key = SigningKey::random(&mut OsRng); 1281 + let code_verifier = "testcodeverifier1234567890abcdefghijklmnop"; 1282 + let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); 1283 + let raw_code = "testauthorizationcode1234567890123456789012"; 1284 + let code_hash = { 1285 + let bytes = URL_SAFE_NO_PAD.decode(raw_code).unwrap(); 1286 + let hash = Sha256::digest(&bytes); 1287 + hash.iter().map(|b| format!("{b:02x}")).collect::<String>() 1288 + }; 1289 + seed_auth_code(&state, &code_hash, &code_challenge).await; 1290 + let nonce = issue_nonce(&state.dpop_nonces).await; 1291 + let dpop = make_dpop_proof(&key, "POST", "https://test.example.com/oauth/token", Some(&nonce), now_secs()); 1292 + 1293 + let resp = app(state) 1294 + .oneshot(post_token_with_dpop( 1295 + &format!( 1296 + "grant_type=authorization_code&code={raw_code}&redirect_uri=https%3A%2F%2Fwrong.example.com%2Fcallback&client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json&code_verifier={code_verifier}" 1297 + ), 1298 + &dpop, 1299 + )) 1300 + .await 1301 + .unwrap(); 1302 + 1303 + let json = json_body(resp).await; 1304 + assert_eq!(json["error"], "invalid_grant", "redirect_uri mismatch must return invalid_grant (AC1.8)"); 1305 + } 1306 + } 1307 + ``` 1308 + 1309 + **Step 2: Run tests** 1310 + 1311 + ```bash 1312 + cargo test -p relay routes::oauth_token 1313 + ``` 1314 + 1315 + Expected: all tests pass. 1316 + 1317 + **Step 3: Run full test suite** 1318 + 1319 + ```bash 1320 + cargo test -p relay 1321 + ``` 1322 + 1323 + Expected: all tests pass. 1324 + 1325 + **Step 4: Commit** 1326 + 1327 + ```bash 1328 + git add crates/relay/src/routes/oauth_token.rs 1329 + git commit -m "test(relay): authorization_code grant integration tests (AC1, AC2, AC3, AC6)" 1330 + ``` 1331 + <!-- END_TASK_4 --> 1332 + 1333 + <!-- END_SUBCOMPONENT_B -->
+732
docs/implementation-plans/2026-03-22-MM-77/phase_06.md
··· 1 + # OAuth Token Endpoint — Phase 6: Refresh Token Grant and Rotation 2 + 3 + **Goal:** Implement the `refresh_token` grant with single-use rotation and DPoP key binding verification. 4 + 5 + **Architecture:** New `RefreshTokenRow` struct and `consume_oauth_refresh_token` function in `db/oauth.rs`. New `handle_refresh_token` function in `routes/oauth_token.rs` replacing the Phase 4 stub. Integration tests covering AC4.1–AC4.5 appended to the existing test module from Phase 5. 6 + 7 + **Tech Stack:** `sqlx` transactions (atomic token consume, same pattern as `consume_authorization_code`), `jsonwebtoken`/`sha2`/`base64` (already imported from Phase 5). 8 + 9 + **Scope:** Phase 6 of 6 10 + 11 + **Codebase verified:** 2026-03-22 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-77.AC4: Refresh token rotation 18 + - **MM-77.AC4.1 Success:** Valid refresh token + DPoP proof → 200 with new `access_token` and new `refresh_token` 19 + - **MM-77.AC4.2 Success:** Old refresh token row deleted after rotation; second use → 400 `invalid_grant` 20 + - **MM-77.AC4.3 Failure:** Expired refresh token (>24h) → 400 `invalid_grant` 21 + - **MM-77.AC4.4 Failure:** DPoP key thumbprint mismatch → 400 `invalid_grant` 22 + - **MM-77.AC4.5 Failure:** `client_id` mismatch on refresh → 400 `invalid_grant` 23 + 24 + --- 25 + 26 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 27 + 28 + <!-- START_TASK_1 --> 29 + ### Task 1: DB function — consume_oauth_refresh_token 30 + 31 + **Files:** 32 + - Modify: `crates/relay/src/db/oauth.rs` 33 + 34 + **Step 1: Add RefreshTokenRow and consume_oauth_refresh_token** 35 + 36 + Append to `db/oauth.rs` (before the `#[cfg(test)]` block): 37 + 38 + ```rust 39 + /// A row read from `oauth_tokens` during refresh token rotation. 40 + pub struct RefreshTokenRow { 41 + pub client_id: String, 42 + pub did: String, 43 + pub scope: String, 44 + /// DPoP key thumbprint bound to this refresh token. `None` for tokens 45 + /// issued before DPoP binding was enforced (not expected after V012). 46 + pub jkt: Option<String>, 47 + } 48 + 49 + /// Atomically consume a refresh token: SELECT + DELETE in one transaction. 50 + /// 51 + /// Returns `None` if the token does not exist or has already expired 52 + /// (`expires_at <= now`). Callers must treat `None` as `invalid_grant`. 53 + /// 54 + /// The `id` column stores the SHA-256 hex hash of the raw token bytes. 55 + /// Callers must hash the presented token before calling this function 56 + /// using the same approach as `store_oauth_refresh_token`. 57 + pub async fn consume_oauth_refresh_token( 58 + pool: &SqlitePool, 59 + token_hash: &str, 60 + ) -> Result<Option<RefreshTokenRow>, sqlx::Error> { 61 + let mut tx = pool.begin().await?; 62 + 63 + let row: Option<(String, String, String, Option<String>)> = sqlx::query_as( 64 + "SELECT client_id, did, scope, jkt FROM oauth_tokens \ 65 + WHERE id = ? AND expires_at > datetime('now')", 66 + ) 67 + .bind(token_hash) 68 + .fetch_optional(&mut *tx) 69 + .await?; 70 + 71 + if row.is_some() { 72 + sqlx::query("DELETE FROM oauth_tokens WHERE id = ?") 73 + .bind(token_hash) 74 + .execute(&mut *tx) 75 + .await?; 76 + } 77 + 78 + tx.commit().await?; 79 + 80 + Ok(row.map(|(client_id, did, scope, jkt)| RefreshTokenRow { 81 + client_id, 82 + did, 83 + scope, 84 + jkt, 85 + })) 86 + } 87 + ``` 88 + 89 + **Step 2: Add DB tests** 90 + 91 + In the `#[cfg(test)]` block of `db/oauth.rs`, append after the existing Phase 5 tests: 92 + 93 + ```rust 94 + #[tokio::test] 95 + async fn consume_oauth_refresh_token_returns_row_and_deletes_it() { 96 + // AC4.2: consumed token must not be found again. 97 + let pool = test_pool().await; 98 + register_oauth_client( 99 + &pool, 100 + "https://app.example.com/client-metadata.json", 101 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 102 + ) 103 + .await 104 + .unwrap(); 105 + sqlx::query( 106 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 107 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 108 + datetime('now'), datetime('now'))", 109 + ) 110 + .execute(&pool) 111 + .await 112 + .unwrap(); 113 + 114 + store_oauth_refresh_token( 115 + &pool, 116 + "consume-test-token-hash", 117 + "https://app.example.com/client-metadata.json", 118 + "did:plc:testaccount000000000000", 119 + "test-jkt-thumbprint", 120 + ) 121 + .await 122 + .unwrap(); 123 + 124 + let row = consume_oauth_refresh_token(&pool, "consume-test-token-hash") 125 + .await 126 + .unwrap() 127 + .expect("token must be found on first use"); 128 + 129 + assert_eq!(row.client_id, "https://app.example.com/client-metadata.json"); 130 + assert_eq!(row.scope, "com.atproto.refresh"); 131 + assert_eq!(row.jkt.as_deref(), Some("test-jkt-thumbprint")); 132 + 133 + // Second consume must return None (already deleted) — AC4.2. 134 + let second = consume_oauth_refresh_token(&pool, "consume-test-token-hash") 135 + .await 136 + .unwrap(); 137 + assert!(second.is_none(), "consumed token must not be found again (AC4.2)"); 138 + } 139 + 140 + #[tokio::test] 141 + async fn consume_oauth_refresh_token_returns_none_for_expired_token() { 142 + // AC4.3: expired tokens are rejected. 143 + let pool = test_pool().await; 144 + register_oauth_client( 145 + &pool, 146 + "https://app.example.com/client-metadata.json", 147 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 148 + ) 149 + .await 150 + .unwrap(); 151 + sqlx::query( 152 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 153 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 154 + datetime('now'), datetime('now'))", 155 + ) 156 + .execute(&pool) 157 + .await 158 + .unwrap(); 159 + 160 + // Insert an already-expired row directly (bypassing store_oauth_refresh_token's +24h default). 161 + sqlx::query( 162 + "INSERT INTO oauth_tokens (id, client_id, did, scope, jkt, expires_at, created_at) \ 163 + VALUES (?, ?, ?, 'com.atproto.refresh', ?, datetime('now', '-1 seconds'), datetime('now'))", 164 + ) 165 + .bind("expired-hash") 166 + .bind("https://app.example.com/client-metadata.json") 167 + .bind("did:plc:testaccount000000000000") 168 + .bind("test-jkt") 169 + .execute(&pool) 170 + .await 171 + .unwrap(); 172 + 173 + let result = consume_oauth_refresh_token(&pool, "expired-hash") 174 + .await 175 + .unwrap(); 176 + assert!(result.is_none(), "expired refresh token must return None (AC4.3)"); 177 + } 178 + 179 + #[tokio::test] 180 + async fn consume_oauth_refresh_token_returns_none_for_unknown_token() { 181 + let pool = test_pool().await; 182 + let result = consume_oauth_refresh_token(&pool, "nonexistent-hash").await.unwrap(); 183 + assert!(result.is_none()); 184 + } 185 + ``` 186 + 187 + **Step 3: Run DB tests** 188 + 189 + ```bash 190 + cargo test -p relay db::oauth 191 + ``` 192 + 193 + Expected: all tests pass including the three new `consume_oauth_refresh_token` tests. 194 + 195 + **Step 4: Commit** 196 + 197 + ```bash 198 + git add crates/relay/src/db/oauth.rs 199 + git commit -m "feat(db): consume_oauth_refresh_token + RefreshTokenRow" 200 + ``` 201 + <!-- END_TASK_1 --> 202 + 203 + <!-- START_TASK_2 --> 204 + ### Task 2: Implement refresh_token grant in handler 205 + 206 + **Files:** 207 + - Modify: `crates/relay/src/routes/oauth_token.rs` 208 + 209 + **Step 1: Add consume_oauth_refresh_token to the db::oauth import** 210 + 211 + In `oauth_token.rs`, find the existing import line: 212 + 213 + ```rust 214 + use crate::db::oauth::{consume_authorization_code, store_oauth_refresh_token}; 215 + ``` 216 + 217 + Replace with: 218 + 219 + ```rust 220 + use crate::db::oauth::{ 221 + consume_authorization_code, consume_oauth_refresh_token, store_oauth_refresh_token, 222 + }; 223 + ``` 224 + 225 + **Step 2: Add handle_refresh_token function** 226 + 227 + Append after `handle_authorization_code` (before the `#[cfg(test)]` block): 228 + 229 + ```rust 230 + async fn handle_refresh_token( 231 + state: &AppState, 232 + headers: &HeaderMap, 233 + form: TokenRequestForm, 234 + ) -> Response { 235 + // Prune stale nonces on every request. 236 + cleanup_expired_nonces(&state.dpop_nonces).await; 237 + 238 + // Required fields. 239 + let refresh_token_plaintext = match form.refresh_token.as_deref() { 240 + Some(t) if !t.is_empty() => t.to_string(), 241 + _ => { 242 + return OAuthTokenError::new("invalid_request", "missing parameter: refresh_token") 243 + .into_response(); 244 + } 245 + }; 246 + let client_id = match form.client_id.as_deref() { 247 + Some(id) if !id.is_empty() => id.to_string(), 248 + _ => { 249 + return OAuthTokenError::new("invalid_request", "missing parameter: client_id") 250 + .into_response(); 251 + } 252 + }; 253 + 254 + // Validate DPoP proof — must be present, structurally valid, and carry a valid server nonce. 255 + let dpop_token = match headers.get("DPoP").and_then(|v| v.to_str().ok()) { 256 + Some(t) => t.to_string(), 257 + None => { 258 + return OAuthTokenError::new("invalid_dpop_proof", "DPoP header required") 259 + .into_response(); 260 + } 261 + }; 262 + 263 + let token_url = format!( 264 + "{}/oauth/token", 265 + state.config.public_url.trim_end_matches('/') 266 + ); 267 + 268 + let jkt = match validate_dpop_for_token_endpoint( 269 + &dpop_token, 270 + "POST", 271 + &token_url, 272 + &state.dpop_nonces, 273 + ) 274 + .await 275 + { 276 + Ok(jkt) => jkt, 277 + Err(DpopTokenEndpointError::MissingHeader) => { 278 + return OAuthTokenError::new("invalid_dpop_proof", "DPoP header required") 279 + .into_response(); 280 + } 281 + Err(DpopTokenEndpointError::InvalidProof(msg)) => { 282 + return OAuthTokenError::new("invalid_dpop_proof", msg).into_response(); 283 + } 284 + Err(DpopTokenEndpointError::UseNonce(fresh_nonce)) => { 285 + return OAuthTokenError::with_nonce( 286 + "use_dpop_nonce", 287 + "DPoP nonce required", 288 + fresh_nonce, 289 + ) 290 + .into_response(); 291 + } 292 + }; 293 + 294 + // Hash the presented refresh token for DB lookup. 295 + let token_hash = crate::routes::token::sha256_hex( 296 + &URL_SAFE_NO_PAD 297 + .decode(refresh_token_plaintext.as_str()) 298 + .unwrap_or_else(|_| refresh_token_plaintext.as_bytes().to_vec()), 299 + ); 300 + 301 + // Atomically consume the refresh token (SELECT + DELETE). 302 + let stored = match consume_oauth_refresh_token(&state.db, &token_hash).await { 303 + Ok(Some(row)) => row, 304 + Ok(None) => { 305 + return OAuthTokenError::new("invalid_grant", "refresh token not found or expired") 306 + .into_response(); 307 + } 308 + Err(e) => { 309 + tracing::error!(error = %e, "failed to consume refresh token"); 310 + return OAuthTokenError::new("server_error", "database error").into_response(); 311 + } 312 + }; 313 + 314 + // Verify client_id matches the stored value. 315 + if stored.client_id != client_id { 316 + return OAuthTokenError::new("invalid_grant", "client_id mismatch").into_response(); 317 + } 318 + 319 + // DPoP binding check: if the refresh token was bound to a specific key, the same key must be used. 320 + if let Some(ref stored_jkt) = stored.jkt { 321 + if *stored_jkt != jkt { 322 + return OAuthTokenError::new("invalid_grant", "DPoP key mismatch").into_response(); 323 + } 324 + } 325 + 326 + // Issue new ES256 access token. 327 + let access_token = 328 + match issue_access_token(&state.oauth_signing_keypair, &stored.did, &stored.scope, &jkt) { 329 + Ok(t) => t, 330 + Err(e) => return e.into_response(), 331 + }; 332 + 333 + // Generate and store new refresh token (rotation: old token already deleted above). 334 + let new_refresh = generate_token(); 335 + if let Err(e) = store_oauth_refresh_token( 336 + &state.db, 337 + &new_refresh.hash, 338 + &stored.client_id, 339 + &stored.did, 340 + &jkt, 341 + ) 342 + .await 343 + { 344 + tracing::error!(error = %e, "failed to store rotated refresh token"); 345 + return OAuthTokenError::new("server_error", "database error").into_response(); 346 + } 347 + 348 + // Issue fresh DPoP nonce for the next request. 349 + let fresh_nonce = issue_nonce(&state.dpop_nonces).await; 350 + 351 + let mut response_headers = axum::http::HeaderMap::new(); 352 + response_headers.insert("DPoP-Nonce", fresh_nonce.parse().unwrap()); 353 + 354 + ( 355 + StatusCode::OK, 356 + response_headers, 357 + Json(TokenResponse { 358 + access_token, 359 + token_type: "DPoP", 360 + expires_in: 300, 361 + refresh_token: new_refresh.plaintext, 362 + scope: stored.scope, 363 + }), 364 + ) 365 + .into_response() 366 + } 367 + ``` 368 + 369 + **Step 3: Replace the refresh_token stub arm in post_token** 370 + 371 + Replace the `"refresh_token"` stub match arm: 372 + 373 + ```rust 374 + "refresh_token" => { 375 + handle_refresh_token(&state, &headers, form).await 376 + } 377 + ``` 378 + 379 + **Step 4: Compile** 380 + 381 + ```bash 382 + cargo build -p relay 383 + ``` 384 + 385 + Expected: compiles without errors. 386 + 387 + **Step 5: Commit** 388 + 389 + ```bash 390 + git add crates/relay/src/routes/oauth_token.rs 391 + git commit -m "feat(relay): refresh_token grant — rotation, DPoP binding check" 392 + ``` 393 + <!-- END_TASK_2 --> 394 + 395 + <!-- START_TASK_3 --> 396 + ### Task 3: Integration tests for refresh_token grant 397 + 398 + **Verifies:** MM-77.AC4.1, MM-77.AC4.2, MM-77.AC4.3, MM-77.AC4.4, MM-77.AC4.5 399 + 400 + **Files:** 401 + - Modify: `crates/relay/src/routes/oauth_token.rs` (`#[cfg(test)]` block) 402 + 403 + The test module in `routes/oauth_token.rs` already contains all DPoP proof helpers (`dpop_key_to_jwk`, `dpop_thumbprint`, `make_dpop_proof`, `post_token_with_dpop`, `json_body`, `now_secs`) from Phase 5. This task appends helpers and tests for the refresh_token grant to the end of that module. 404 + 405 + **Step 1: Add generate_token to the test module's imports** 406 + 407 + Find the existing import block in the `mod tests { ... }` block: 408 + 409 + ```rust 410 + use crate::db::oauth::{register_oauth_client, store_authorization_code}; 411 + ``` 412 + 413 + Replace with: 414 + 415 + ```rust 416 + use crate::db::oauth::{register_oauth_client, store_authorization_code, store_oauth_refresh_token}; 417 + use crate::routes::token::generate_token; 418 + ``` 419 + 420 + **Step 2: Append seed helpers and AC4 tests inside the test module** 421 + 422 + Append inside the `mod tests { ... }` block, after the last test: 423 + 424 + ```rust 425 + // ── AC4 — refresh_token grant ───────────────────────────────────────────── 426 + 427 + /// Seed the DB with a client + account + fresh refresh token bound to `jkt`. 428 + /// 429 + /// Returns the base64url plaintext of the seeded refresh token. 430 + async fn seed_refresh_token(state: &AppState, jkt: &str) -> String { 431 + register_oauth_client( 432 + &state.db, 433 + "https://app.example.com/client-metadata.json", 434 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 435 + ) 436 + .await 437 + .unwrap(); 438 + sqlx::query( 439 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 440 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 441 + datetime('now'), datetime('now'))", 442 + ) 443 + .execute(&state.db) 444 + .await 445 + .unwrap(); 446 + 447 + let token = generate_token(); 448 + store_oauth_refresh_token( 449 + &state.db, 450 + &token.hash, 451 + "https://app.example.com/client-metadata.json", 452 + "did:plc:testaccount000000000000", 453 + jkt, 454 + ) 455 + .await 456 + .unwrap(); 457 + token.plaintext 458 + } 459 + 460 + /// Seed the DB with an already-expired refresh token (bypasses store_oauth_refresh_token's +24h). 461 + /// 462 + /// Returns the base64url plaintext. 463 + async fn seed_expired_refresh_token(state: &AppState, jkt: &str) -> String { 464 + register_oauth_client( 465 + &state.db, 466 + "https://app.example.com/client-metadata.json", 467 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 468 + ) 469 + .await 470 + .unwrap(); 471 + sqlx::query( 472 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 473 + VALUES ('did:plc:testaccount000000000000', 'test@example.com', NULL, \ 474 + datetime('now'), datetime('now'))", 475 + ) 476 + .execute(&state.db) 477 + .await 478 + .unwrap(); 479 + 480 + let token = generate_token(); 481 + sqlx::query( 482 + "INSERT INTO oauth_tokens (id, client_id, did, scope, jkt, expires_at, created_at) \ 483 + VALUES (?, ?, ?, 'com.atproto.refresh', ?, datetime('now', '-1 seconds'), datetime('now'))", 484 + ) 485 + .bind(&token.hash) 486 + .bind("https://app.example.com/client-metadata.json") 487 + .bind("did:plc:testaccount000000000000") 488 + .bind(jkt) 489 + .execute(&state.db) 490 + .await 491 + .unwrap(); 492 + token.plaintext 493 + } 494 + 495 + #[tokio::test] 496 + async fn refresh_token_happy_path_returns_200_with_new_tokens() { 497 + // AC4.1 — valid rotation returns 200 with fresh token pair. 498 + let state = test_state().await; 499 + let key = SigningKey::random(&mut OsRng); 500 + let jkt = dpop_thumbprint(&key); 501 + 502 + let plaintext = seed_refresh_token(&state, &jkt).await; 503 + let nonce = issue_nonce(&state.dpop_nonces).await; 504 + let dpop = make_dpop_proof( 505 + &key, 506 + "POST", 507 + "https://test.example.com/oauth/token", 508 + Some(&nonce), 509 + now_secs(), 510 + ); 511 + 512 + let body = format!( 513 + "grant_type=refresh_token\ 514 + &refresh_token={plaintext}\ 515 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 516 + ); 517 + 518 + let resp = app(state) 519 + .oneshot(post_token_with_dpop(&body, &dpop)) 520 + .await 521 + .unwrap(); 522 + 523 + assert_eq!(resp.status(), StatusCode::OK, "valid rotation must return 200"); 524 + assert!( 525 + resp.headers().contains_key("DPoP-Nonce"), 526 + "success response must include DPoP-Nonce header" 527 + ); 528 + 529 + let json = json_body(resp).await; 530 + assert!(json["access_token"].is_string(), "access_token must be present"); 531 + assert_eq!(json["token_type"], "DPoP"); 532 + assert_eq!(json["expires_in"], 300); 533 + assert!(json["refresh_token"].is_string(), "rotated refresh_token must be present"); 534 + 535 + // Rotated token must differ from the original. 536 + let new_rt = json["refresh_token"].as_str().unwrap(); 537 + assert_ne!( 538 + new_rt, plaintext.as_str(), 539 + "rotated refresh token must differ from original" 540 + ); 541 + } 542 + 543 + #[tokio::test] 544 + async fn refresh_token_second_use_returns_invalid_grant() { 545 + // AC4.2 — after rotation the original token is deleted; second use must fail. 546 + let state = test_state().await; 547 + let key = SigningKey::random(&mut OsRng); 548 + let jkt = dpop_thumbprint(&key); 549 + 550 + let plaintext = seed_refresh_token(&state, &jkt).await; 551 + let body = format!( 552 + "grant_type=refresh_token\ 553 + &refresh_token={plaintext}\ 554 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 555 + ); 556 + 557 + // First use: succeeds. Clone state so the second request shares the same DB. 558 + let nonce1 = issue_nonce(&state.dpop_nonces).await; 559 + let dpop1 = make_dpop_proof( 560 + &key, 561 + "POST", 562 + "https://test.example.com/oauth/token", 563 + Some(&nonce1), 564 + now_secs(), 565 + ); 566 + let first_resp = app(state.clone()) 567 + .oneshot(post_token_with_dpop(&body, &dpop1)) 568 + .await 569 + .unwrap(); 570 + assert_eq!(first_resp.status(), StatusCode::OK, "first use must succeed"); 571 + 572 + // Second use of the same original token: must return invalid_grant. 573 + let nonce2 = issue_nonce(&state.dpop_nonces).await; 574 + let dpop2 = make_dpop_proof( 575 + &key, 576 + "POST", 577 + "https://test.example.com/oauth/token", 578 + Some(&nonce2), 579 + now_secs(), 580 + ); 581 + let resp2 = app(state) 582 + .oneshot(post_token_with_dpop(&body, &dpop2)) 583 + .await 584 + .unwrap(); 585 + 586 + assert_eq!(resp2.status(), StatusCode::BAD_REQUEST, "second use must return 400"); 587 + let json = json_body(resp2).await; 588 + assert_eq!( 589 + json["error"], "invalid_grant", 590 + "second use of consumed token must return invalid_grant (AC4.2)" 591 + ); 592 + } 593 + 594 + #[tokio::test] 595 + async fn refresh_token_expired_returns_invalid_grant() { 596 + // AC4.3 — expired refresh tokens are rejected. 597 + let state = test_state().await; 598 + let key = SigningKey::random(&mut OsRng); 599 + let jkt = dpop_thumbprint(&key); 600 + 601 + let plaintext = seed_expired_refresh_token(&state, &jkt).await; 602 + let nonce = issue_nonce(&state.dpop_nonces).await; 603 + let dpop = make_dpop_proof( 604 + &key, 605 + "POST", 606 + "https://test.example.com/oauth/token", 607 + Some(&nonce), 608 + now_secs(), 609 + ); 610 + 611 + let body = format!( 612 + "grant_type=refresh_token\ 613 + &refresh_token={plaintext}\ 614 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 615 + ); 616 + 617 + let resp = app(state) 618 + .oneshot(post_token_with_dpop(&body, &dpop)) 619 + .await 620 + .unwrap(); 621 + 622 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 623 + let json = json_body(resp).await; 624 + assert_eq!( 625 + json["error"], "invalid_grant", 626 + "expired refresh token must return invalid_grant (AC4.3)" 627 + ); 628 + } 629 + 630 + #[tokio::test] 631 + async fn refresh_token_jkt_mismatch_returns_invalid_grant() { 632 + // AC4.4 — DPoP key in proof must match the thumbprint bound to the refresh token. 633 + let state = test_state().await; 634 + let stored_key = SigningKey::random(&mut OsRng); 635 + let stored_jkt = dpop_thumbprint(&stored_key); 636 + 637 + // Seed token bound to stored_key's thumbprint. 638 + let plaintext = seed_refresh_token(&state, &stored_jkt).await; 639 + 640 + // Build proof with a DIFFERENT key — thumbprint will not match stored_jkt. 641 + let different_key = SigningKey::random(&mut OsRng); 642 + let nonce = issue_nonce(&state.dpop_nonces).await; 643 + let dpop = make_dpop_proof( 644 + &different_key, 645 + "POST", 646 + "https://test.example.com/oauth/token", 647 + Some(&nonce), 648 + now_secs(), 649 + ); 650 + 651 + let body = format!( 652 + "grant_type=refresh_token\ 653 + &refresh_token={plaintext}\ 654 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json" 655 + ); 656 + 657 + let resp = app(state) 658 + .oneshot(post_token_with_dpop(&body, &dpop)) 659 + .await 660 + .unwrap(); 661 + 662 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 663 + let json = json_body(resp).await; 664 + assert_eq!( 665 + json["error"], "invalid_grant", 666 + "DPoP key mismatch must return invalid_grant (AC4.4)" 667 + ); 668 + } 669 + 670 + #[tokio::test] 671 + async fn refresh_token_client_id_mismatch_returns_invalid_grant() { 672 + // AC4.5 — client_id in the request must match the stored client_id. 673 + let state = test_state().await; 674 + let key = SigningKey::random(&mut OsRng); 675 + let jkt = dpop_thumbprint(&key); 676 + 677 + let plaintext = seed_refresh_token(&state, &jkt).await; 678 + let nonce = issue_nonce(&state.dpop_nonces).await; 679 + let dpop = make_dpop_proof( 680 + &key, 681 + "POST", 682 + "https://test.example.com/oauth/token", 683 + Some(&nonce), 684 + now_secs(), 685 + ); 686 + 687 + // Wrong client_id — does not match stored "https://app.example.com/client-metadata.json". 688 + let body = format!( 689 + "grant_type=refresh_token\ 690 + &refresh_token={plaintext}\ 691 + &client_id=https%3A%2F%2Fother.example.com%2Fclient-metadata.json" 692 + ); 693 + 694 + let resp = app(state) 695 + .oneshot(post_token_with_dpop(&body, &dpop)) 696 + .await 697 + .unwrap(); 698 + 699 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 700 + let json = json_body(resp).await; 701 + assert_eq!( 702 + json["error"], "invalid_grant", 703 + "client_id mismatch must return invalid_grant (AC4.5)" 704 + ); 705 + } 706 + ``` 707 + 708 + **Step 3: Run refresh_token tests** 709 + 710 + ```bash 711 + cargo test -p relay routes::oauth_token 712 + ``` 713 + 714 + Expected: all tests pass including the five new AC4 tests. 715 + 716 + **Step 4: Run full test suite** 717 + 718 + ```bash 719 + cargo test -p relay 720 + ``` 721 + 722 + Expected: all tests pass. 723 + 724 + **Step 5: Commit** 725 + 726 + ```bash 727 + git add crates/relay/src/routes/oauth_token.rs 728 + git commit -m "test(relay): refresh_token grant integration tests (AC4.1–AC4.5)" 729 + ``` 730 + <!-- END_TASK_3 --> 731 + 732 + <!-- END_SUBCOMPONENT_A -->
+181
docs/implementation-plans/2026-03-22-MM-77/test-requirements.md
··· 1 + # MM-77 Test Requirements 2 + 3 + Maps every acceptance criterion from the MM-77 design plan to either an automated test 4 + or a human-verification step. Organized for use by a test-analyst agent during execution 5 + validation. 6 + 7 + **Design plan:** `docs/design-plans/2026-03-22-MM-77.md` 8 + **Implementation plans:** `docs/implementation-plans/2026-03-22-MM-77/phase_01.md` through `phase_06.md` 9 + 10 + --- 11 + 12 + ## Summary 13 + 14 + | AC Group | Total | Automated | Human-verified | 15 + |----------|-------|-----------|----------------| 16 + | AC1 | 8 | 8 | 0 | 17 + | AC2 | 6 | 6 | 0 | 18 + | AC3 | 5 | 5 | 0 | 19 + | AC4 | 5 | 5 | 0 | 20 + | AC5 | 4 | 4 | 0 | 21 + | AC6 | 3 | 2 | 1 | 22 + | **Total**| **31**| **30** | **1** | 23 + 24 + Note: The design plan lists 26 numbered sub-criteria (AC1.1-AC1.8, AC2.1-AC2.6, 25 + AC3.1-AC3.5, AC4.1-AC4.5, AC5.1-AC5.4, AC6.1-AC6.3). However, several test functions 26 + cover multiple criteria simultaneously, and some criteria are covered at multiple layers 27 + (unit + integration). The table above counts distinct criteria; the sections below show 28 + every mapping. 29 + 30 + --- 31 + 32 + ## Automated Tests 33 + 34 + ### MM-77.AC1: Authorization code exchange 35 + 36 + | Criterion | Description | Type | File | Test Function | Phase | 37 + |-----------|-------------|------|------|---------------|-------| 38 + | AC1.1 | Valid code + code_verifier + DPoP proof with nonce returns 200 with access_token, token_type="DPoP", expires_in=300, refresh_token, scope | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::authorization_code_happy_path_returns_200_with_tokens` | 5 | 39 + | AC1.2 | Access token is ES256 JWT with typ=at+jwt, cnf.jkt, exp=now+300s | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::authorization_code_happy_path_returns_200_with_tokens` (asserts `header["typ"] == "at+jwt"`, `header["alg"] == "ES256"`, `payload["cnf"]["jkt"]`) | 5 | 40 + | AC1.3 | Refresh token plaintext is 43-char base64url; stored row has scope='com.atproto.refresh' | Integration + Unit | `crates/relay/src/routes/oauth_token.rs` | `tests::authorization_code_happy_path_returns_200_with_tokens` (asserts `rt.len() == 43`) | 5 | 41 + | AC1.3 (scope) | Stored refresh token row has scope='com.atproto.refresh' | Unit | `crates/relay/src/db/oauth.rs` | `tests::store_oauth_refresh_token_persists_row` (asserts `scope == "com.atproto.refresh"`) | 5 | 42 + | AC1.4 | Invalid code_verifier returns 400 invalid_grant | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::wrong_code_verifier_returns_invalid_grant` | 5 | 43 + | AC1.5 | Expired auth code (>60s) returns 400 invalid_grant | Unit | `crates/relay/src/db/oauth.rs` | `tests::consume_authorization_code_returns_none_for_expired_code` | 5 | 44 + | AC1.6 | Already-consumed code returns 400 invalid_grant | Integration + Unit | `crates/relay/src/routes/oauth_token.rs` | `tests::consumed_code_returns_invalid_grant` | 5 | 45 + | AC1.6 (db layer) | Second consume returns None | Unit | `crates/relay/src/db/oauth.rs` | `tests::consume_authorization_code_returns_row_and_deletes_it` (second consume asserts `is_none`) | 5 | 46 + | AC1.7 | client_id mismatch returns 400 invalid_grant | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::client_id_mismatch_returns_invalid_grant` | 5 | 47 + | AC1.8 | redirect_uri mismatch returns 400 invalid_grant | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::redirect_uri_mismatch_returns_invalid_grant` | 5 | 48 + 49 + ### MM-77.AC2: DPoP proof validation 50 + 51 + | Criterion | Description | Type | File | Test Function | Phase | 52 + |-----------|-------------|------|------|---------------|-------| 53 + | AC2.1 | Valid DPoP proof (ES256, correct htm=POST, htu, fresh iat, non-empty jti) accepted | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::authorization_code_happy_path_returns_200_with_tokens` (full request with valid DPoP succeeds) | 5 | 54 + | AC2.2 | Access token cnf.jkt matches the DPoP proof's JWK thumbprint | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::authorization_code_happy_path_returns_200_with_tokens` (asserts `cnf_jkt == expected_jkt`) | 5 | 55 + | AC2.3 | Missing DPoP header returns 400 invalid_dpop_proof | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::missing_dpop_header_returns_invalid_dpop_proof` | 5 | 56 + | AC2.4 | Wrong htm returns 400 invalid_dpop_proof | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::dpop_wrong_htm_returns_invalid_dpop_proof` | 5 | 57 + | AC2.5 | Wrong htu returns 400 invalid_dpop_proof | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::dpop_wrong_htu_returns_invalid_dpop_proof` | 5 | 58 + | AC2.6 | Stale iat (>60s) returns 400 invalid_dpop_proof | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::dpop_stale_iat_returns_invalid_dpop_proof` | 5 | 59 + 60 + ### MM-77.AC3: DPoP server nonces 61 + 62 + | Criterion | Description | Type | File | Test Function | Phase | 63 + |-----------|-------------|------|------|---------------|-------| 64 + | AC3.1 | Request with valid unexpired nonce accepted | Unit | `crates/relay/src/auth/mod.rs` | `tests::issued_nonce_validates_once` | 3 | 65 + | AC3.2 | No nonce claim in DPoP proof returns 400 use_dpop_nonce + DPoP-Nonce header | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::dpop_without_nonce_returns_use_dpop_nonce_with_header` | 5 | 66 + | AC3.3 | Expired nonce returns 400 use_dpop_nonce + fresh DPoP-Nonce header | Unit | `crates/relay/src/auth/mod.rs` | `tests::expired_nonce_is_rejected` | 3 | 67 + | AC3.4 | Unknown/fabricated nonce returns 400 use_dpop_nonce | Unit + Integration | `crates/relay/src/auth/mod.rs` | `tests::unknown_nonce_is_rejected` | 3 | 68 + | AC3.4 (integration) | Unknown nonce via HTTP returns use_dpop_nonce | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::dpop_with_unknown_nonce_returns_use_dpop_nonce` | 5 | 69 + | AC3.5 | Successful token response includes DPoP-Nonce header with fresh nonce | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::authorization_code_happy_path_returns_200_with_tokens` (asserts `resp.headers().contains_key("DPoP-Nonce")`) | 5 | 70 + | AC3.5 (refresh) | Successful refresh response includes DPoP-Nonce header | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::refresh_token_happy_path_returns_200_with_new_tokens` (asserts `resp.headers().contains_key("DPoP-Nonce")`) | 6 | 71 + 72 + ### MM-77.AC4: Refresh token rotation 73 + 74 + | Criterion | Description | Type | File | Test Function | Phase | 75 + |-----------|-------------|------|------|---------------|-------| 76 + | AC4.1 | Valid refresh token + DPoP proof returns 200 with new access_token and new refresh_token | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::refresh_token_happy_path_returns_200_with_new_tokens` | 6 | 77 + | AC4.2 | Old refresh token deleted; second use returns 400 invalid_grant | Integration + Unit | `crates/relay/src/routes/oauth_token.rs` | `tests::refresh_token_second_use_returns_invalid_grant` | 6 | 78 + | AC4.2 (db layer) | Second consume returns None | Unit | `crates/relay/src/db/oauth.rs` | `tests::consume_oauth_refresh_token_returns_row_and_deletes_it` | 6 | 79 + | AC4.3 | Expired refresh token (>24h) returns 400 invalid_grant | Integration + Unit | `crates/relay/src/routes/oauth_token.rs` | `tests::refresh_token_expired_returns_invalid_grant` | 6 | 80 + | AC4.3 (db layer) | Expired token returns None from consume | Unit | `crates/relay/src/db/oauth.rs` | `tests::consume_oauth_refresh_token_returns_none_for_expired_token` | 6 | 81 + | AC4.4 | DPoP key thumbprint mismatch returns 400 invalid_grant | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::refresh_token_jkt_mismatch_returns_invalid_grant` | 6 | 82 + | AC4.5 | client_id mismatch on refresh returns 400 invalid_grant | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::refresh_token_client_id_mismatch_returns_invalid_grant` | 6 | 83 + 84 + ### MM-77.AC5: Error response format 85 + 86 + | Criterion | Description | Type | File | Test Function | Phase | 87 + |-----------|-------------|------|------|---------------|-------| 88 + | AC5.1 | All errors return JSON with error and error_description string fields | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::error_response_has_error_and_error_description_fields` | 4 | 89 + | AC5.2 | Unknown grant_type returns 400 unsupported_grant_type | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::unknown_grant_type_returns_400_unsupported` | 4 | 90 + | AC5.3 | Missing required params returns 400 invalid_request | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::missing_grant_type_returns_400_invalid_request` | 4 | 91 + | AC5.4 | No HTML in error responses | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::error_response_content_type_is_json` (asserts content-type is application/json) | 4 | 92 + 93 + ### MM-77.AC6: OAuth signing key persistence 94 + 95 + | Criterion | Description | Type | File | Test Function | Phase | 96 + |-----------|-------------|------|------|---------------|-------| 97 + | AC6.1 | First startup generates P-256 keypair, stores encrypted in oauth_signing_key | Unit | `crates/relay/src/db/oauth.rs` | `tests::store_and_retrieve_oauth_signing_key` | 2 | 98 + | AC6.3 | Access tokens use ES256 signing, not HS256 | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::authorization_code_happy_path_returns_200_with_tokens` (asserts `header["alg"] == "ES256"`) | 5 | 99 + 100 + --- 101 + 102 + ## Human Verification 103 + 104 + ### MM-77.AC6.2: Subsequent restarts reload the same key (same kid in JWTs) 105 + 106 + **Why it cannot be automated in the existing test suite:** This criterion requires 107 + restarting the relay binary and verifying that access tokens issued before and after 108 + the restart carry the same `kid` in their JWT header. The test suite uses ephemeral 109 + in-memory SQLite databases and a fresh `test_state()` per test. There is no mechanism 110 + within `cargo test` to simulate a process restart with persistent state across 111 + invocations. 112 + 113 + **Verification approach:** 114 + 115 + 1. Start the relay binary with `signing_key_master_key` configured and a persistent 116 + SQLite database path. 117 + 2. Issue an authorization code exchange request (via Bruno or curl). Record the `kid` 118 + from the access token JWT header. 119 + 3. Stop the relay process. 120 + 4. Restart the relay with the same configuration and database. 121 + 5. Issue another authorization code exchange request. Record the `kid`. 122 + 6. Verify that both `kid` values are identical. 123 + 124 + **Partial automated coverage:** The DB round-trip is covered by 125 + `tests::store_and_retrieve_oauth_signing_key` (Phase 2), which confirms that a stored 126 + key can be read back with the same `id`. The `load_or_create_oauth_signing_key` function 127 + in `auth/mod.rs` is the code path that loads the existing key on restart -- its 128 + correctness is validated by the DB test, but the full restart cycle (including `main.rs` 129 + startup flow) requires a running binary. 130 + 131 + --- 132 + 133 + ## Additional Unit Tests (infrastructure) 134 + 135 + These tests do not map to a specific acceptance criterion but are required for 136 + infrastructure correctness and are specified in the implementation plan. 137 + 138 + | Test | Type | File | Test Function | Phase | 139 + |------|------|------|---------------|-------| 140 + | V012 migration applies without error | Smoke | All existing test files (migration runner runs on every test_pool) | (implicit -- all tests pass) | 1 | 141 + | OAuth signing key DB returns None when empty | Unit | `crates/relay/src/db/oauth.rs` | `tests::get_oauth_signing_key_returns_none_when_empty` | 2 | 142 + | Nonce is consumed after single use | Unit | `crates/relay/src/auth/mod.rs` | `tests::issued_nonce_validates_once` (second validation fails) | 3 | 143 + | Cleanup removes only expired nonces | Unit | `crates/relay/src/auth/mod.rs` | `tests::cleanup_removes_only_expired_nonces` | 3 | 144 + | Nonce format is 22-char base64url | Unit | `crates/relay/src/auth/mod.rs` | `tests::issued_nonce_is_22_chars_base64url` | 3 | 145 + | GET /oauth/token returns 405 | Integration | `crates/relay/src/routes/oauth_token.rs` | `tests::get_token_endpoint_returns_405` | 4 | 146 + | consume_authorization_code returns None for unknown code | Unit | `crates/relay/src/db/oauth.rs` | `tests::consume_authorization_code_returns_none_for_unknown_code` | 5 | 147 + | consume_oauth_refresh_token returns None for unknown token | Unit | `crates/relay/src/db/oauth.rs` | `tests::consume_oauth_refresh_token_returns_none_for_unknown_token` | 6 | 148 + 149 + --- 150 + 151 + ## Test Execution Commands 152 + 153 + Run all tests for the relay crate: 154 + ``` 155 + cargo test -p relay 156 + ``` 157 + 158 + Run only token endpoint tests: 159 + ``` 160 + cargo test -p relay routes::oauth_token 161 + ``` 162 + 163 + Run only DB-layer OAuth tests: 164 + ``` 165 + cargo test -p relay db::oauth 166 + ``` 167 + 168 + Run only auth module tests (nonce store): 169 + ``` 170 + cargo test -p relay auth::tests 171 + ``` 172 + 173 + Run clippy (warnings-as-errors): 174 + ``` 175 + cargo clippy --workspace -- -D warnings 176 + ``` 177 + 178 + Run format check: 179 + ``` 180 + cargo fmt --all --check 181 + ```