An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): PKCE generation and PAR HTTP call (MM-149 phase 4)

authored by

Malpercio and committed by
Tangled
d7c5bd08 6a62b15f

+3911
+2
Cargo.lock
··· 2592 2592 "crypto", 2593 2593 "multibase", 2594 2594 "p256", 2595 + "rand_core 0.6.4", 2595 2596 "reqwest 0.12.28", 2596 2597 "security-framework", 2597 2598 "serde", ··· 2602 2603 "tauri-plugin-deep-link", 2603 2604 "tauri-plugin-opener", 2604 2605 "thiserror 2.0.18", 2606 + "tokio", 2605 2607 "tracing", 2606 2608 "url", 2607 2609 "uuid",
+4
apps/identity-wallet/src-tauri/Cargo.toml
··· 32 32 tracing = { workspace = true } 33 33 sha2 = { workspace = true } 34 34 base64 = { workspace = true } 35 + rand_core = { workspace = true } 35 36 uuid = { workspace = true } 37 + 38 + [dev-dependencies] 39 + tokio = { version = "1", features = ["macros", "rt"] } 36 40 37 41 [build-dependencies] 38 42 # Tauri-specific — declared locally
+69
apps/identity-wallet/src-tauri/src/http.rs
··· 7 7 use reqwest::{Client, Response}; 8 8 use serde::Serialize; 9 9 10 + use crate::oauth::OAuthError; 11 + 10 12 #[cfg(debug_assertions)] 11 13 const RELAY_BASE_URL: &str = "http://localhost:8080"; 12 14 #[cfg(not(debug_assertions))] 13 15 const RELAY_BASE_URL: &str = "https://relay.ezpds.com"; 16 + 17 + /// Successful response from `POST /oauth/par` (RFC 9126 §2.2). 18 + #[derive(Debug, serde::Deserialize)] 19 + pub struct ParResponse { 20 + pub request_uri: String, 21 + pub expires_in: u32, 22 + } 14 23 15 24 /// HTTP client for relay API requests. 16 25 pub struct RelayClient { ··· 62 71 .json(body) 63 72 .send() 64 73 .await 74 + } 75 + 76 + /// POST `/oauth/par` — push the authorization request parameters to the relay. 77 + /// 78 + /// Sends the required PKCE and OAuth parameters as `application/x-www-form-urlencoded`. 79 + /// Includes a `DPoP` proof header per RFC 9449 §6. 80 + /// 81 + /// `dpop_jkt` is the JWK thumbprint of the DPoP key; included as a form field for 82 + /// servers that support PAR-level DPoP key binding (the relay currently ignores it, 83 + /// but it is spec-correct to send it). 84 + pub async fn par( 85 + &self, 86 + code_challenge: &str, 87 + state_param: &str, 88 + dpop_proof: &str, 89 + dpop_jkt: &str, 90 + login_hint: Option<&str>, 91 + ) -> Result<ParResponse, OAuthError> { 92 + let url = format!("{}/oauth/par", self.base_url); 93 + 94 + let hint_owned; 95 + let mut fields = vec![ 96 + ("client_id", "dev.malpercio.identitywallet"), 97 + ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"), 98 + ("code_challenge", code_challenge), 99 + ("code_challenge_method", "S256"), 100 + ("state", state_param), 101 + ("response_type", "code"), 102 + ("scope", "atproto"), 103 + ("dpop_jkt", dpop_jkt), 104 + ]; 105 + 106 + if let Some(hint) = login_hint { 107 + hint_owned = hint.to_string(); 108 + fields.push(("login_hint", &hint_owned)); 109 + } 110 + 111 + let resp = self 112 + .client 113 + .post(&url) 114 + .header("DPoP", dpop_proof) 115 + .form(&fields) 116 + .send() 117 + .await 118 + .map_err(|e| { 119 + tracing::error!(error = %e, "PAR request network error"); 120 + OAuthError::ParFailed 121 + })?; 122 + 123 + let status = resp.status(); 124 + if status.as_u16() != 201 { 125 + let body = resp.text().await.unwrap_or_default(); 126 + tracing::error!(status = %status, body = %body, "PAR request failed"); 127 + return Err(OAuthError::ParFailed); 128 + } 129 + 130 + resp.json::<ParResponse>().await.map_err(|e| { 131 + tracing::error!(error = %e, "PAR response deserialization failed"); 132 + OAuthError::ParFailed 133 + }) 65 134 } 66 135 67 136 /// Returns the compile-time base URL for this relay client instance.
+139
apps/identity-wallet/src-tauri/src/oauth.rs
··· 7 7 use p256::ecdsa::{signature::Signer, Signature, SigningKey}; 8 8 #[allow(unused_imports)] 9 9 use p256::elliptic_curve::sec1::ToEncodedPoint; 10 + use rand_core::{OsRng, RngCore}; 10 11 use sha2::{Digest, Sha256}; 11 12 use std::sync::Mutex; 12 13 use std::time::{SystemTime, UNIX_EPOCH}; ··· 213 214 } 214 215 } 215 216 217 + // ── PKCE utilities ──────────────────────────────────────────────────────────── 218 + 219 + pub mod pkce { 220 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 221 + use rand_core::{OsRng, RngCore}; 222 + use sha2::{Digest, Sha256}; 223 + 224 + /// Generate a PKCE code_verifier and code_challenge pair. 225 + /// 226 + /// - `verifier`: 32 OS-random bytes base64url-encoded (43 chars, all unreserved per RFC 7636 §4.1) 227 + /// - `challenge`: `base64url(SHA-256(verifier))` (S256 method per RFC 7636 §4.2) 228 + /// 229 + /// Returns `(verifier, challenge)`. 230 + pub fn generate() -> (String, String) { 231 + let mut bytes = [0u8; 32]; 232 + OsRng.fill_bytes(&mut bytes); 233 + let verifier = URL_SAFE_NO_PAD.encode(bytes); 234 + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); 235 + (verifier, challenge) 236 + } 237 + } 238 + 239 + /// Generate a CSRF state parameter: 16 OS-random bytes base64url-encoded (22 chars). 240 + pub fn generate_state_param() -> String { 241 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 242 + let mut bytes = [0u8; 16]; 243 + OsRng.fill_bytes(&mut bytes); 244 + URL_SAFE_NO_PAD.encode(bytes) 245 + } 246 + 216 247 // ── Pending flow (stub — filled out in Phase 5) ─────────────────────────────── 217 248 218 249 /// State parked inside `AppState.pending_auth` while `start_oauth_flow` waits ··· 440 471 URL_SAFE_NO_PAD.encode(hash) 441 472 }; 442 473 assert_eq!(ath, expected); 474 + } 475 + 476 + // PKCE tests 477 + #[test] 478 + fn pkce_verifier_is_43_unreserved_chars() { 479 + let (verifier, _) = pkce::generate(); 480 + assert_eq!(verifier.len(), 43, "base64url of 32 bytes must be 43 chars"); 481 + // RFC 7636 §4.1: ALPHA / DIGIT / "-" / "." / "_" / "~" 482 + assert!( 483 + verifier.chars().all(|c| c.is_alphanumeric() || "-._~".contains(c)), 484 + "verifier must consist only of unreserved chars: got {verifier}" 485 + ); 486 + } 487 + 488 + #[test] 489 + fn pkce_challenge_equals_sha256_base64url_of_verifier() { 490 + use sha2::{Digest, Sha256}; 491 + let (verifier, challenge) = pkce::generate(); 492 + let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); 493 + assert_eq!(challenge, expected, "challenge must be base64url(sha256(verifier))"); 494 + } 495 + 496 + #[test] 497 + fn state_param_is_22_chars() { 498 + let state = generate_state_param(); 499 + assert_eq!(state.len(), 22, "base64url of 16 bytes must be 22 chars"); 500 + } 501 + 502 + #[test] 503 + fn pkce_verifiers_are_unique() { 504 + let (v1, _) = pkce::generate(); 505 + let (v2, _) = pkce::generate(); 506 + assert_ne!(v1, v2, "each generate() call must produce a different verifier"); 507 + } 508 + 509 + /// Integration test: PAR call against a running relay. 510 + /// 511 + /// Requires the relay to be running at http://localhost:8080 with the V013 512 + /// migration applied (identity-wallet client registered). 513 + /// 514 + /// Run with: cargo test -p identity-wallet par_integration -- --include-ignored --nocapture 515 + #[tokio::test] 516 + #[ignore = "requires running relay at localhost:8080"] 517 + async fn par_integration_returns_201_with_request_uri() { 518 + let relay = crate::http::RelayClient::new(); 519 + let keypair = DPoPKeypair::get_or_create().expect("keypair must generate"); 520 + // `htu` is embedded in the DPoP proof JWT claims (the `htu` claim per RFC 9449 §4.2), 521 + // not used for the HTTP request itself — `relay.par()` constructs the URL internally. 522 + let htu = format!("{}/oauth/par", crate::http::RelayClient::base_url()); 523 + let dpop_proof = keypair 524 + .make_proof("POST", &htu, None, None) 525 + .expect("DPoP proof must build"); 526 + let dpop_jkt = keypair.public_jwk_thumbprint(); 527 + let (_, challenge) = pkce::generate(); 528 + let state = generate_state_param(); 529 + 530 + let resp = relay 531 + .par(&challenge, &state, &dpop_proof, &dpop_jkt, None) 532 + .await 533 + .expect("PAR must succeed"); 534 + 535 + assert!( 536 + resp.request_uri.starts_with("urn:ietf:params:oauth:request_uri:"), 537 + "request_uri must use OAuth PAR URN scheme, got: {}", 538 + resp.request_uri 539 + ); 540 + assert_eq!(resp.expires_in, 60); 541 + } 542 + 543 + /// Integration test: PAR call missing code_challenge is rejected by relay. 544 + /// 545 + /// Verifies MM-149.AC1.4: the relay returns a client error (400) when 546 + /// code_challenge is absent from the PAR request. 547 + /// 548 + /// Run with: cargo test -p identity-wallet par_missing_challenge -- --include-ignored --nocapture 549 + #[tokio::test] 550 + #[ignore = "requires running relay at localhost:8080"] 551 + async fn par_missing_code_challenge_returns_client_error() { 552 + // Build a minimal PAR form body with no code_challenge field. 553 + let base_url = crate::http::RelayClient::base_url(); 554 + let url = format!("{base_url}/oauth/par"); 555 + let keypair = DPoPKeypair::get_or_create().expect("keypair must generate"); 556 + let dpop_proof = keypair 557 + .make_proof("POST", &url, None, None) 558 + .expect("DPoP proof must build"); 559 + 560 + let client = reqwest::Client::new(); 561 + let resp = client 562 + .post(&url) 563 + .header("DPoP", dpop_proof) 564 + .form(&[ 565 + ("client_id", "dev.malpercio.identitywallet"), 566 + ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"), 567 + ("code_challenge_method", "S256"), 568 + ("state", "somestate"), 569 + ("response_type", "code"), 570 + ("scope", "atproto"), 571 + // code_challenge intentionally omitted 572 + ]) 573 + .send() 574 + .await 575 + .expect("request must reach relay"); 576 + 577 + assert!( 578 + resp.status().is_client_error(), 579 + "relay must reject PAR without code_challenge with 4xx, got: {}", 580 + resp.status() 581 + ); 443 582 } 444 583 }
+215
docs/implementation-plans/2026-03-23-MM-149/phase_01.md
··· 1 + # MM-149 OAuth PKCE Client Implementation Plan 2 + 3 + **Goal:** Pre-register the identity-wallet app as a known OAuth client in the relay database. 4 + 5 + **Architecture:** A single forward-only SQL migration adds one row to the existing `oauth_clients` table. The PAR handler already performs client lookup by `client_id`; registering the row is the only change needed for the relay to accept identity-wallet PAR requests. 6 + 7 + **Tech Stack:** SQLite (migration SQL), Rust/sqlx (migration runner in `crates/relay/src/db/mod.rs`) 8 + 9 + **Scope:** 7 phases from original design (phase 1 of 7) 10 + 11 + **Codebase verified:** 2026-03-23 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-149.AC1: PAR flow completes successfully 20 + - **MM-149.AC1.3 Failure:** PAR request with unknown `client_id` returns a client error (relay rejects it) 21 + 22 + > Note: MM-149.AC1.3 is already tested by the existing test suite in `oauth_par.rs`. This phase's "Done when" verifies that the seed row exists and that a PAR request with this client_id is accepted — the inverse of the existing failure test. Full AC1.1 and AC1.2 success criteria are verified in Phase 4 (PAR call from the mobile client). 23 + 24 + --- 25 + 26 + <!-- START_TASK_1 --> 27 + ### Task 1: Write the V013 migration SQL 28 + 29 + **Verifies:** None (infrastructure — verified operationally) 30 + 31 + **Files:** 32 + - Create: `crates/relay/src/db/migrations/V013__identity_wallet_oauth_client.sql` 33 + 34 + **Step 1: Create the migration file** 35 + 36 + ```sql 37 + -- Seed the identity-wallet as a registered OAuth client. 38 + -- 39 + -- client_metadata is a RFC 7591 JSON object. The PAR handler parses 40 + -- metadata["redirect_uris"] to validate the redirect_uri parameter. 41 + -- INSERT OR IGNORE makes this migration idempotent on re-run. 42 + INSERT OR IGNORE INTO oauth_clients (client_id, client_metadata, created_at) 43 + VALUES ( 44 + 'dev.malpercio.identitywallet', 45 + json('{ 46 + "client_id": "dev.malpercio.identitywallet", 47 + "application_type": "native", 48 + "token_endpoint_auth_method": "none", 49 + "dpop_bound_access_tokens": true, 50 + "redirect_uris": ["dev.malpercio.identitywallet:/oauth/callback"], 51 + "grant_types": ["authorization_code", "refresh_token"], 52 + "scope": "atproto", 53 + "client_name": "Malpercio Identity Wallet" 54 + }'), 55 + datetime('now') 56 + ); 57 + ``` 58 + 59 + **Step 2: Verify the migration file** 60 + 61 + Confirm the file exists at the correct path: 62 + ```bash 63 + ls crates/relay/src/db/migrations/V013__identity_wallet_oauth_client.sql 64 + ``` 65 + 66 + Expected: file is listed. 67 + 68 + <!-- END_TASK_1 --> 69 + 70 + <!-- START_TASK_2 --> 71 + ### Task 2: Register V013 in the migration runner 72 + 73 + **Verifies:** None (infrastructure) 74 + 75 + **Files:** 76 + - Modify: `crates/relay/src/db/mod.rs` 77 + 78 + The migration runner maintains a static `MIGRATIONS` array. Each entry is `(version: i64, sql: &str)`. V012 is the current last entry. 79 + 80 + **Step 1: Read the current MIGRATIONS array** 81 + 82 + Open `crates/relay/src/db/mod.rs` and find the `MIGRATIONS` constant (around line 33). The codebase uses a private `Migration` struct with `version: u32` and `sql: &'static str` fields, so each entry is a struct literal: 83 + 84 + ```rust 85 + static MIGRATIONS: &[Migration] = &[ 86 + Migration { 87 + version: 1, 88 + sql: include_str!("migrations/V001__init.sql"), 89 + }, 90 + // ... 91 + Migration { 92 + version: 12, 93 + sql: include_str!("migrations/V012__oauth_token_endpoint.sql"), 94 + }, 95 + ]; 96 + ``` 97 + 98 + **Step 2: Append V013** 99 + 100 + Add a new `Migration` entry after the V012 entry: 101 + 102 + ```rust 103 + Migration { 104 + version: 13, 105 + sql: include_str!("migrations/V013__identity_wallet_oauth_client.sql"), 106 + }, 107 + ``` 108 + 109 + The full array tail should read: 110 + ```rust 111 + Migration { 112 + version: 12, 113 + sql: include_str!("migrations/V012__oauth_token_endpoint.sql"), 114 + }, 115 + Migration { 116 + version: 13, 117 + sql: include_str!("migrations/V013__identity_wallet_oauth_client.sql"), 118 + }, 119 + ]; 120 + ``` 121 + 122 + **Step 3: Build to verify the migration compiles** 123 + 124 + ```bash 125 + cargo build -p relay 126 + ``` 127 + 128 + Expected: builds without errors. The `include_str!` macro fails at compile time if the file path is wrong — a successful build proves the path is correct. 129 + 130 + <!-- END_TASK_2 --> 131 + 132 + <!-- START_TASK_3 --> 133 + ### Task 3: Add a migration test to verify the seed row 134 + 135 + **Verifies:** MM-149.AC1.3 (PAR with this client_id is now accepted, not rejected as unknown) 136 + 137 + **Files:** 138 + - Modify: `crates/relay/src/db/mod.rs` (add one test at the bottom of the file) 139 + 140 + The relay's existing test infrastructure in `crates/relay/src/db/mod.rs` provides an `in_memory_pool()` helper that opens a fresh in-memory SQLite pool without running migrations. The test must call `run_migrations(&pool)` explicitly to apply all migrations, including V013. 141 + 142 + **Step 1: Read the existing tests at the bottom of `crates/relay/src/db/mod.rs`** 143 + 144 + Find the `#[cfg(test)]` module to understand the test pattern. Note the `in_memory_pool()` helper that creates a fresh in-memory SQLite pool (does NOT run migrations). 145 + 146 + **Step 2: Add a test that asserts the seed row exists** 147 + 148 + Add inside the existing `#[cfg(test)]` mod (or create one if absent): 149 + 150 + ```rust 151 + #[cfg(test)] 152 + mod tests { 153 + use super::*; 154 + use crate::db::oauth::get_oauth_client; 155 + 156 + #[tokio::test] 157 + async fn v013_seeds_identity_wallet_oauth_client() { 158 + let pool = in_memory_pool().await; 159 + run_migrations(&pool).await.expect("migrations must apply cleanly"); 160 + 161 + let row = get_oauth_client(&pool, "dev.malpercio.identitywallet") 162 + .await 163 + .expect("db query must not fail"); 164 + 165 + assert!( 166 + row.is_some(), 167 + "V013 migration must insert the identity-wallet client row" 168 + ); 169 + 170 + let row = row.unwrap(); 171 + let metadata: serde_json::Value = 172 + serde_json::from_str(&row.client_metadata).expect("client_metadata must be valid JSON"); 173 + 174 + assert_eq!( 175 + metadata["redirect_uris"][0].as_str(), 176 + Some("dev.malpercio.identitywallet:/oauth/callback"), 177 + "redirect_uri must match the custom URL scheme" 178 + ); 179 + assert_eq!( 180 + metadata["dpop_bound_access_tokens"].as_bool(), 181 + Some(true), 182 + "DPoP must be required for this client" 183 + ); 184 + } 185 + } 186 + ``` 187 + 188 + **Step 3: Run the test** 189 + 190 + ```bash 191 + cargo test -p relay v013_seeds_identity_wallet_oauth_client 192 + ``` 193 + 194 + Expected output: 195 + ``` 196 + test db::tests::v013_seeds_identity_wallet_oauth_client ... ok 197 + ``` 198 + 199 + **Step 4: Run all relay tests to confirm no regressions** 200 + 201 + ```bash 202 + cargo test -p relay 203 + ``` 204 + 205 + Expected: all tests pass. 206 + 207 + **Step 5: Commit** 208 + 209 + ```bash 210 + git add crates/relay/src/db/migrations/V013__identity_wallet_oauth_client.sql 211 + git add crates/relay/src/db/mod.rs 212 + git commit -m "feat(relay): register identity-wallet as OAuth client (MM-149 phase 1)" 213 + ``` 214 + 215 + <!-- END_TASK_3 -->
+408
docs/implementation-plans/2026-03-23-MM-149/phase_02.md
··· 1 + # MM-149 OAuth PKCE Client Implementation Plan 2 + 3 + **Goal:** Wire up the deep-link plugin, register AppState, and establish the OAuth callback routing path so the deep-link callback can be verified end-to-end before adding cryptographic logic. 4 + 5 + **Architecture:** Tauri's `.manage()` puts `AppState` into the app's DI container. The `tauri-plugin-deep-link` plugin injects a `CFBundleURLSchemes` entry into Info.plist at build time, routes incoming `dev.malpercio.identitywallet://` URLs to `on_open_url` at runtime, and that callback routes to `handle_deep_link` in `oauth.rs`. The opener plugin lets Rust open Safari. State is accessed from the callback via a cloned `AppHandle`. 6 + 7 + **Tech Stack:** Rust/Tauri v2, `tauri-plugin-deep-link = "2"`, `tauri-plugin-opener = "2"`, `std::sync::Mutex` 8 + 9 + **Scope:** 7 phases from original design (phase 2 of 7) 10 + 11 + **Codebase verified:** 2026-03-23 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + > This is an infrastructure phase. Done-when: the app builds for iOS and `xcrun simctl openurl` triggers the `on_open_url` handler (verified via tracing log). No AC cases are formally tested in this phase. 20 + 21 + **Verifies:** None (infrastructure — verified operationally) 22 + 23 + --- 24 + 25 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 26 + 27 + <!-- START_TASK_1 --> 28 + ### Task 1: Add plugin dependencies to Cargo.toml 29 + 30 + **Verifies:** None (infrastructure) 31 + 32 + **Files:** 33 + - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` 34 + 35 + These plugins are declared locally in the app's Cargo.toml (same pattern as `tauri` and `tauri-build`), not in workspace dependencies, because no other workspace crate uses them. 36 + 37 + **Step 1: Add the two plugin crates to `[dependencies]`** 38 + 39 + In `apps/identity-wallet/src-tauri/Cargo.toml`, after the `tauri = { version = "2", features = [] }` line, add: 40 + 41 + ```toml 42 + tauri-plugin-deep-link = "2" 43 + tauri-plugin-opener = "2" 44 + ``` 45 + 46 + The `[dependencies]` section should now include: 47 + 48 + ```toml 49 + tauri = { version = "2", features = [] } 50 + tauri-plugin-deep-link = "2" 51 + tauri-plugin-opener = "2" 52 + ``` 53 + 54 + **Step 2: Verify the crates download** 55 + 56 + ```bash 57 + cd apps/identity-wallet && cargo fetch 58 + ``` 59 + 60 + Expected: exits without error. This confirms the crate versions resolve. 61 + 62 + **Step 3: Build to confirm no compile errors** 63 + 64 + ```bash 65 + cargo build -p identity-wallet 66 + ``` 67 + 68 + Expected: builds without errors (no new code yet, just new deps). 69 + 70 + <!-- END_TASK_1 --> 71 + 72 + <!-- START_TASK_2 --> 73 + ### Task 2: Add deep-link plugin config to tauri.conf.json 74 + 75 + **Verifies:** None (infrastructure) 76 + 77 + **Files:** 78 + - Modify: `apps/identity-wallet/src-tauri/tauri.conf.json` 79 + 80 + The current file has no `plugins` section. Adding `plugins.deep-link.mobile` causes the build tool to inject a `CFBundleURLTypes` entry into the generated iOS Info.plist, registering `dev.malpercio.identitywallet` as a custom URL scheme. Only non-HTTPS schemes trigger this Info.plist path. 81 + 82 + **Step 1: Add the `plugins` section** 83 + 84 + The full updated `tauri.conf.json`: 85 + 86 + ```json 87 + { 88 + "$schema": "https://schema.tauri.app/config/2", 89 + "productName": "Identity Wallet", 90 + "version": "0.1.0", 91 + "identifier": "dev.malpercio.identitywallet", 92 + "build": { 93 + "devUrl": "http://localhost:5173", 94 + "frontendDist": "../dist", 95 + "beforeDevCommand": "pnpm dev", 96 + "beforeBuildCommand": "pnpm build" 97 + }, 98 + "app": { 99 + "windows": [ 100 + { 101 + "title": "Identity Wallet", 102 + "width": 400, 103 + "height": 600, 104 + "resizable": true 105 + } 106 + ] 107 + }, 108 + "bundle": { 109 + "active": true 110 + }, 111 + "plugins": { 112 + "deep-link": { 113 + "mobile": [ 114 + { 115 + "scheme": ["dev.malpercio.identitywallet"] 116 + } 117 + ] 118 + } 119 + } 120 + } 121 + ``` 122 + 123 + **Step 2: Regenerate the Xcode project** 124 + 125 + The Xcode project (`src-tauri/gen/apple/`) is machine-specific and gitignored. After any config change that affects the iOS build, regenerate it: 126 + 127 + ```bash 128 + cd apps/identity-wallet && cargo tauri ios init 129 + ``` 130 + 131 + Then re-apply the two one-time Xcode patches from the CLAUDE.md: 132 + 133 + ```bash 134 + # Patch 1: Add Nix devenv PATH to the build phase 135 + # Replace <project-root> with the absolute path to the workspace root (e.g. /Users/you/workspace/malpercio-dev/ezpds) 136 + # Find the shellScript line in project.pbxproj and prepend the PATH export 137 + 138 + # Patch 2: Disable user script sandboxing 139 + sed -i '' 's/ENABLE_USER_SCRIPT_SANDBOXING = YES/ENABLE_USER_SCRIPT_SANDBOXING = NO/g' \ 140 + src-tauri/gen/apple/identity-wallet.xcodeproj/project.pbxproj 141 + ``` 142 + 143 + <!-- END_TASK_2 --> 144 + 145 + <!-- END_SUBCOMPONENT_A --> 146 + 147 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 148 + 149 + <!-- START_TASK_3 --> 150 + ### Task 3: Create oauth.rs with AppState, PendingOAuthFlow, CallbackParams, and handle_deep_link stub 151 + 152 + **Verifies:** None (infrastructure stub — verified by tracing log in Task 5) 153 + 154 + **Files:** 155 + - Create: `apps/identity-wallet/src-tauri/src/oauth.rs` 156 + 157 + This file is the Functional Core for OAuth state types and the stub callback. `PendingOAuthFlow` is a placeholder for now; Phase 5 will add the `oneshot::Sender` and cryptographic state fields. The `Mutex` fields in `AppState` use `std::sync::Mutex` — it is never held across an `.await` point (it's always lock-set-drop or lock-take-drop), so it is safe in both the sync callback and the async command. 158 + 159 + **Step 1: Create `apps/identity-wallet/src-tauri/src/oauth.rs`** 160 + 161 + ```rust 162 + // pattern: Mixed (unavoidable) 163 + // 164 + // Types: AppState, PendingOAuthFlow, OAuthSession, CallbackParams (Functional Core) 165 + // handle_deep_link: Imperative Shell (reads OS callback, routes to pending channel) 166 + 167 + use std::sync::Mutex; 168 + use tracing; 169 + 170 + // ── Shared state ────────────────────────────────────────────────────────────── 171 + 172 + /// App-wide OAuth state registered via `.manage()` in lib.rs. 173 + /// 174 + /// Both fields are Option-wrapped so the state is cleanly empty before any 175 + /// OAuth flow starts and after a flow completes. 176 + pub struct AppState { 177 + /// The pending OAuth flow waiting for the deep-link callback. 178 + /// Set by `start_oauth_flow` before opening Safari; cleared by `handle_deep_link`. 179 + pub pending_auth: Mutex<Option<PendingOAuthFlow>>, 180 + /// The active authenticated session after a successful token exchange. 181 + /// Set by `start_oauth_flow` on success; read by `OAuthClient` for every request. 182 + pub oauth_session: Mutex<Option<OAuthSession>>, 183 + } 184 + 185 + impl AppState { 186 + pub fn new() -> Self { 187 + Self { 188 + pending_auth: Mutex::new(None), 189 + oauth_session: Mutex::new(None), 190 + } 191 + } 192 + } 193 + 194 + // ── Pending flow (stub — filled out in Phase 5) ─────────────────────────────── 195 + 196 + /// State parked inside `AppState.pending_auth` while `start_oauth_flow` waits 197 + /// for the deep-link callback. 198 + /// 199 + /// Phase 5 adds: oneshot::Sender<CallbackParams>, pkce_verifier, csrf_state. 200 + pub struct PendingOAuthFlow { 201 + /// The CSRF state parameter generated at the start of the flow. 202 + /// Used by `handle_deep_link` to validate the callback state. 203 + pub csrf_state: String, 204 + } 205 + 206 + // ── OAuth session (stub — filled out in Phase 5) ────────────────────────────── 207 + 208 + /// Active OAuth session stored after a successful token exchange. 209 + /// 210 + /// Phase 5 adds: access_token, refresh_token, expires_at, dpop_nonce. 211 + pub struct OAuthSession { 212 + pub access_token: String, 213 + pub refresh_token: String, 214 + } 215 + 216 + // ── Callback params ─────────────────────────────────────────────────────────── 217 + 218 + /// Parameters extracted from the OAuth deep-link callback URL. 219 + pub struct CallbackParams { 220 + pub code: String, 221 + pub state: String, 222 + } 223 + 224 + // ── Deep-link handler ───────────────────────────────────────────────────────── 225 + 226 + /// Process URLs received from the deep-link plugin's `on_open_url` event. 227 + /// 228 + /// Filters for the OAuth callback path and logs receipt. Phase 5 completes this 229 + /// by extracting `code`+`state` and sending them on the pending `oneshot` channel. 230 + pub fn handle_deep_link(urls: Vec<url::Url>, app_state: &AppState) { 231 + for url in &urls { 232 + let scheme = url.scheme(); 233 + let path = url.path(); 234 + 235 + if scheme == "dev.malpercio.identitywallet" && path == "/oauth/callback" { 236 + tracing::info!(url = %url, "OAuth deep-link callback received"); 237 + 238 + // Phase 5: extract code+state, validate CSRF, send on oneshot channel. 239 + // For now, just log that the callback arrived. 240 + let _pending = app_state.pending_auth.lock().unwrap(); 241 + tracing::info!("pending_auth slot present: {}", _pending.is_some()); 242 + 243 + return; 244 + } 245 + 246 + tracing::debug!(url = %url, "ignoring non-OAuth deep-link"); 247 + } 248 + } 249 + ``` 250 + 251 + **Step 2: Add `url = "2"` to Cargo.toml** 252 + 253 + Add to `[dependencies]` in `apps/identity-wallet/src-tauri/Cargo.toml`: 254 + 255 + ```toml 256 + url = "2" 257 + ``` 258 + 259 + The `url` crate is a transitive dependency of `tauri-plugin-deep-link`, but declaring it explicitly makes the version requirement clear and ensures `url::Url` resolves unambiguously in all contexts. 260 + 261 + **Step 3: Verify the file compiles** 262 + 263 + ```bash 264 + cargo build -p identity-wallet 265 + ``` 266 + 267 + Expected: builds without errors. The `url::Url` type resolves from the explicit `url = "2"` dependency. 268 + 269 + <!-- END_TASK_3 --> 270 + 271 + <!-- START_TASK_4 --> 272 + ### Task 4: Register plugins, AppState, and on_open_url in lib.rs 273 + 274 + **Verifies:** None (infrastructure — verified operationally in Task 5) 275 + 276 + **Files:** 277 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 278 + 279 + **Step 1: Add the `oauth` module declaration at the top of lib.rs** 280 + 281 + After the existing module declarations (lines 1-3), add: 282 + 283 + ```rust 284 + pub mod oauth; 285 + ``` 286 + 287 + The top of lib.rs should now read: 288 + 289 + ```rust 290 + pub mod device_key; 291 + pub mod http; 292 + pub mod keychain; 293 + pub mod oauth; 294 + ``` 295 + 296 + **Step 2: Update the `run()` function** 297 + 298 + The current `run()` function (lines 398-409) is: 299 + 300 + ```rust 301 + #[cfg_attr(mobile, tauri::mobile_entry_point)] 302 + pub fn run() { 303 + tauri::Builder::default() 304 + .invoke_handler(tauri::generate_handler![ 305 + create_account, 306 + get_or_create_device_key, 307 + sign_with_device_key, 308 + perform_did_ceremony, 309 + ]) 310 + .run(tauri::generate_context!()) 311 + .expect("error while running tauri application"); 312 + } 313 + ``` 314 + 315 + Replace it with: 316 + 317 + ```rust 318 + #[cfg_attr(mobile, tauri::mobile_entry_point)] 319 + pub fn run() { 320 + tauri::Builder::default() 321 + .manage(oauth::AppState::new()) 322 + .plugin(tauri_plugin_deep_link::init()) 323 + .plugin(tauri_plugin_opener::init()) 324 + .setup(|app| { 325 + let app_handle = app.app_handle().clone(); 326 + app.deep_link().on_open_url(move |event| { 327 + let state = app_handle.state::<oauth::AppState>(); 328 + oauth::handle_deep_link(event.urls(), &state); 329 + }); 330 + Ok(()) 331 + }) 332 + .invoke_handler(tauri::generate_handler![ 333 + create_account, 334 + get_or_create_device_key, 335 + sign_with_device_key, 336 + perform_did_ceremony, 337 + ]) 338 + .run(tauri::generate_context!()) 339 + .expect("error while running tauri application"); 340 + } 341 + ``` 342 + 343 + **Step 3: Add the missing use import for Tauri Manager** 344 + 345 + `app_handle.state::<T>()` requires the `tauri::Manager` trait in scope. Add it to the existing use imports at the top of lib.rs: 346 + 347 + ```rust 348 + use tauri::Manager; 349 + ``` 350 + 351 + **Step 4: Build to verify no compile errors** 352 + 353 + ```bash 354 + cargo build -p identity-wallet 355 + ``` 356 + 357 + Expected: builds without errors. 358 + 359 + <!-- END_TASK_4 --> 360 + 361 + <!-- END_SUBCOMPONENT_B --> 362 + 363 + <!-- START_TASK_5 --> 364 + ### Task 5: Verify deep-link callback fires end-to-end 365 + 366 + **Verifies:** None (operational — confirms the plumbing works before Phase 5 adds logic) 367 + 368 + This task verifies the Phase 2 Done-when criterion: the `on_open_url` handler fires when the iOS Simulator receives a custom URL scheme URL. 369 + 370 + **Step 1: Launch the app in the iOS Simulator** 371 + 372 + ```bash 373 + cd apps/identity-wallet 374 + cargo tauri ios dev 375 + ``` 376 + 377 + Wait for the Simulator to open and the app to launch. 378 + 379 + **Step 2: Trigger the deep-link callback** 380 + 381 + In a separate terminal (while `cargo tauri ios dev` is running), run: 382 + 383 + ```bash 384 + xcrun simctl openurl booted "dev.malpercio.identitywallet:/oauth/callback?code=test&state=abc" 385 + ``` 386 + 387 + **Step 3: Verify the handler fired** 388 + 389 + In the `cargo tauri ios dev` terminal output, confirm you see: 390 + 391 + ``` 392 + INFO identity_wallet::oauth: OAuth deep-link callback received url=dev.malpercio.identitywallet:/oauth/callback?code=test&state=abc 393 + INFO identity_wallet::oauth: pending_auth slot present: false 394 + ``` 395 + 396 + The `pending_auth slot present: false` is expected — no flow is in progress. The important thing is that the log appeared, proving the route landed. 397 + 398 + **Step 4: Commit** 399 + 400 + ```bash 401 + git add apps/identity-wallet/src-tauri/Cargo.toml 402 + git add apps/identity-wallet/src-tauri/tauri.conf.json 403 + git add apps/identity-wallet/src-tauri/src/lib.rs 404 + git add apps/identity-wallet/src-tauri/src/oauth.rs 405 + git commit -m "feat(identity-wallet): wire deep-link plugin and AppState for OAuth callback (MM-149 phase 2)" 406 + ``` 407 + 408 + <!-- END_TASK_5 -->
+556
docs/implementation-plans/2026-03-23-MM-149/phase_03.md
··· 1 + # MM-149 OAuth PKCE Client Implementation Plan 2 + 3 + **Goal:** Implement the DPoP keypair type (Keychain-persisted P-256 key) and the manual JOSE proof builder, plus Keychain helpers for OAuth tokens. 4 + 5 + **Architecture:** `DPoPKeypair` wraps a P-256 `SigningKey`. Proofs are constructed manually — base64url-encode header JSON + claims JSON, sign the `header.payload` signing input with P-256/SHA-256, base64url-encode the raw R||S signature. No JWT library needed. The relay's validator in `crates/relay/src/auth/dpop.rs` defines exactly what the proof must contain. The Keychain helpers in `keychain.rs` follow the existing `store_item`/`get_item` pattern. 6 + 7 + **Tech Stack:** `p256 = "0.13"` (ecdsa + pkcs8 features), `sha2 = "0.10"`, `base64 = "0.21"` (URL_SAFE_NO_PAD), `uuid = "1"` (v4) 8 + 9 + **Scope:** 7 phases from original design (phase 3 of 7) 10 + 11 + **Codebase verified:** 2026-03-23 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-149.AC3: DPoP proofs are correctly formed 20 + - **MM-149.AC3.1 Success:** DPoP proof header contains `typ: "dpop+jwt"`, `alg: "ES256"`, and a valid P-256 `jwk` 21 + - **MM-149.AC3.2 Success:** DPoP proof payload contains `jti`, `htm`, `htu`, `iat` 22 + - **MM-149.AC3.3 Success:** `ath` claim present and equals `base64url(sha256(access_token))` on resource requests 23 + - **MM-149.AC3.4 Success:** `nonce` claim present when a server nonce has been provided 24 + - **MM-149.AC3.5 Success:** Proof signature verifies against the `jwk` embedded in the header 25 + 26 + --- 27 + 28 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 29 + 30 + <!-- START_TASK_1 --> 31 + ### Task 1: Add sha2, base64, uuid dependencies to identity-wallet Cargo.toml 32 + 33 + **Verifies:** None (infrastructure) 34 + 35 + **Files:** 36 + - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` 37 + 38 + These crates are already in workspace dependencies (root `Cargo.toml` lines 65, 68, 71) but are not yet declared in identity-wallet. 39 + 40 + **Step 1: Add to `[dependencies]`** 41 + 42 + Add after the existing `serde_json = { workspace = true }` line: 43 + 44 + ```toml 45 + sha2 = { workspace = true } 46 + base64 = { workspace = true } 47 + uuid = { workspace = true } 48 + ``` 49 + 50 + The `uuid` workspace dep already has `features = ["v4"]` so no extra features spec needed. 51 + 52 + **Step 2: Build to verify** 53 + 54 + ```bash 55 + cargo build -p identity-wallet 56 + ``` 57 + 58 + Expected: builds without errors. 59 + 60 + <!-- END_TASK_1 --> 61 + 62 + <!-- START_TASK_2 --> 63 + ### Task 2: Add OAuth Keychain helpers to keychain.rs 64 + 65 + **Verifies:** None (helpers used by Phase 5; no AC directly) 66 + 67 + **Files:** 68 + - Modify: `apps/identity-wallet/src-tauri/src/keychain.rs` 69 + 70 + Add four helpers at the end of the file, following the same pattern as any existing helpers. The account keys match the design plan constants and follow the same `"ezpds-identity-wallet"` service (already enforced by the `SERVICE` constant in the file). 71 + 72 + **Step 1: Read the bottom of `apps/identity-wallet/src-tauri/src/keychain.rs`** 73 + 74 + Find where the existing helpers and constants are defined (the public `store_item`/`get_item`/`delete_item` API plus the `SERVICE` constant). 75 + 76 + **Step 2: Add the four OAuth Keychain helpers** 77 + 78 + Add at the end of the file (before any `#[cfg(test)]` block if one exists): 79 + 80 + ```rust 81 + // ── OAuth Keychain helpers ───────────────────────────────────────────────────── 82 + 83 + const DPOP_KEY_PRIV_ACCOUNT: &str = "oauth-dpop-key-priv"; 84 + const OAUTH_ACCESS_TOKEN_ACCOUNT: &str = "oauth-access-token"; 85 + const OAUTH_REFRESH_TOKEN_ACCOUNT: &str = "oauth-refresh-token"; 86 + 87 + /// Store the DPoP private key scalar (32 bytes) in the Keychain. 88 + pub fn store_dpop_key(private_bytes: &[u8]) -> Result<(), KeychainError> { 89 + store_item(DPOP_KEY_PRIV_ACCOUNT, private_bytes) 90 + } 91 + 92 + /// Load the DPoP private key scalar from the Keychain. 93 + /// 94 + /// Returns `None` if no key has been stored yet (first run). 95 + pub fn load_dpop_key() -> Option<[u8; 32]> { 96 + match get_item(DPOP_KEY_PRIV_ACCOUNT) { 97 + Ok(bytes) if bytes.len() == 32 => { 98 + let mut arr = [0u8; 32]; 99 + arr.copy_from_slice(&bytes); 100 + Some(arr) 101 + } 102 + Ok(_) => { 103 + tracing::warn!("DPoP key in Keychain has unexpected length; treating as absent"); 104 + None 105 + } 106 + Err(e) if is_not_found(&e) => None, 107 + Err(e) => { 108 + tracing::error!(error = ?e, "Keychain error loading DPoP key"); 109 + None 110 + } 111 + } 112 + } 113 + 114 + /// Store the OAuth access token and refresh token in the Keychain. 115 + pub fn store_oauth_tokens(access_token: &str, refresh_token: &str) -> Result<(), KeychainError> { 116 + store_item(OAUTH_ACCESS_TOKEN_ACCOUNT, access_token.as_bytes())?; 117 + store_item(OAUTH_REFRESH_TOKEN_ACCOUNT, refresh_token.as_bytes())?; 118 + Ok(()) 119 + } 120 + 121 + /// Load the OAuth access token and refresh token from the Keychain. 122 + /// 123 + /// Returns `None` if either token is missing (not yet authenticated). 124 + pub fn load_oauth_tokens() -> Option<(String, String)> { 125 + let access = match get_item(OAUTH_ACCESS_TOKEN_ACCOUNT) { 126 + Ok(b) => String::from_utf8(b).ok()?, 127 + Err(e) if is_not_found(&e) => return None, 128 + Err(e) => { 129 + tracing::error!(error = ?e, "Keychain error loading access token"); 130 + return None; 131 + } 132 + }; 133 + let refresh = match get_item(OAUTH_REFRESH_TOKEN_ACCOUNT) { 134 + Ok(b) => String::from_utf8(b).ok()?, 135 + Err(e) if is_not_found(&e) => return None, 136 + Err(e) => { 137 + tracing::error!(error = ?e, "Keychain error loading refresh token"); 138 + return None; 139 + } 140 + }; 141 + Some((access, refresh)) 142 + } 143 + ``` 144 + 145 + **Step 3: Build to verify** 146 + 147 + ```bash 148 + cargo build -p identity-wallet 149 + ``` 150 + 151 + Expected: builds without errors. The `tracing` crate is already a dependency. 152 + 153 + <!-- END_TASK_2 --> 154 + 155 + <!-- END_SUBCOMPONENT_A --> 156 + 157 + <!-- START_SUBCOMPONENT_B (tasks 3-5) --> 158 + 159 + <!-- START_TASK_3 --> 160 + ### Task 3: Implement DPoPKeypair in oauth.rs 161 + 162 + **Verifies:** MM-149.AC3.1 (header fields), MM-149.AC3.2 (claims fields), MM-149.AC3.3 (ath), MM-149.AC3.4 (nonce), MM-149.AC3.5 (signature verifies) 163 + 164 + **Files:** 165 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` 166 + 167 + The DPoP proof format is validated by the relay's `crates/relay/src/auth/dpop.rs` — study that file's expectations when reading the code below. Manual JWT construction: `base64url(header_json)` + `.` + `base64url(claims_json)`, then sign those bytes with P-256/SHA-256, then append `.` + `base64url(raw_RS_signature)`. 168 + 169 + **Step 1: Add imports at the top of oauth.rs** 170 + 171 + Add these imports to the top of the file, after the existing `use` statements: 172 + 173 + ```rust 174 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 175 + use p256::ecdsa::{SigningKey, Signature, signature::Signer}; 176 + use p256::elliptic_curve::sec1::ToEncodedPoint; 177 + use sha2::{Digest, Sha256}; 178 + use std::time::{SystemTime, UNIX_EPOCH}; 179 + use uuid::Uuid; 180 + ``` 181 + 182 + **Step 2: Define DPoPKeypair and OAuthError** 183 + 184 + Add after the existing `AppState` definition: 185 + 186 + ```rust 187 + // ── OAuth error ─────────────────────────────────────────────────────────────── 188 + 189 + /// Error type for all OAuth-related operations. 190 + /// 191 + /// Variants serialize as `{ "code": "SCREAMING_SNAKE_CASE" }` to match the 192 + /// existing error pattern (`CreateAccountError`, `DeviceKeyError`, etc.). 193 + #[derive(Debug, thiserror::Error, serde::Serialize)] 194 + #[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "code")] 195 + pub enum OAuthError { 196 + #[error("DPoP keypair generation failed")] 197 + DpopKeyGenFailed, 198 + #[error("DPoP keypair is invalid")] 199 + DpopKeyInvalid, 200 + #[error("DPoP proof construction failed")] 201 + DpopProofFailed, 202 + #[error("Keychain error")] 203 + KeychainError, 204 + #[error("State mismatch in OAuth callback")] 205 + StateMismatch, 206 + #[error("OAuth callback abandoned")] 207 + CallbackAbandoned, 208 + #[error("PAR request failed")] 209 + ParFailed, 210 + #[error("Token exchange failed")] 211 + TokenExchangeFailed, 212 + #[error("Token refresh failed")] 213 + TokenRefreshFailed, 214 + #[error("Not authenticated")] 215 + NotAuthenticated, 216 + } 217 + 218 + // ── DPoP keypair ───────────────────────────────────────────────────────────── 219 + 220 + /// A P-256 keypair used to produce DPoP proofs. 221 + /// 222 + /// The private key scalar (32 bytes) is persisted in the iOS Keychain under 223 + /// `"oauth-dpop-key-priv"`. The same key is used for all DPoP proofs across 224 + /// app sessions — it is never rotated by this implementation. 225 + pub struct DPoPKeypair { 226 + signing_key: SigningKey, 227 + } 228 + 229 + impl DPoPKeypair { 230 + /// Load the DPoP keypair from Keychain, or generate and persist a new one. 231 + pub fn get_or_create() -> Result<Self, OAuthError> { 232 + if let Some(private_bytes) = crate::keychain::load_dpop_key() { 233 + let signing_key = SigningKey::from_slice(&private_bytes) 234 + .map_err(|_| OAuthError::DpopKeyInvalid)?; 235 + return Ok(Self { signing_key }); 236 + } 237 + 238 + // Generate a new P-256 keypair via the shared crypto crate. 239 + let keypair = crypto::generate_p256_keypair().map_err(|_| OAuthError::DpopKeyGenFailed)?; 240 + // `private_key_bytes` is `Zeroizing<[u8; 32]>`, which derefs directly to `[u8; 32]`. 241 + let private_bytes: [u8; 32] = *keypair.private_key_bytes; 242 + 243 + crate::keychain::store_dpop_key(&private_bytes) 244 + .map_err(|_| OAuthError::KeychainError)?; 245 + 246 + let signing_key = SigningKey::from_slice(&private_bytes) 247 + .map_err(|_| OAuthError::DpopKeyInvalid)?; 248 + Ok(Self { signing_key }) 249 + } 250 + 251 + /// Build the public JWK for this keypair (EC, P-256, kty/crv/x/y only — no private fields). 252 + /// 253 + /// The relay's validator expects exactly: `{"kty":"EC","crv":"P-256","x":"<b64url>","y":"<b64url>"}`. 254 + pub fn public_jwk(&self) -> serde_json::Value { 255 + let verifying_key = self.signing_key.verifying_key(); 256 + let point = verifying_key.to_encoded_point(false); // false = uncompressed: 04 || x || y 257 + let x = URL_SAFE_NO_PAD.encode(point.x().expect("P-256 uncompressed point has x")); 258 + let y = URL_SAFE_NO_PAD.encode(point.y().expect("P-256 uncompressed point has y")); 259 + serde_json::json!({ 260 + "kty": "EC", 261 + "crv": "P-256", 262 + "x": x, 263 + "y": y, 264 + }) 265 + } 266 + 267 + /// Compute the RFC 7638 JWK thumbprint: `base64url(SHA-256(canonical_jwk_json))`. 268 + /// 269 + /// The canonical JSON uses lexicographically-sorted keys (crv, kty, x, y) per RFC 7638 §3.2. 270 + /// This matches the relay's `jwk_thumbprint()` function in `crates/relay/src/auth/dpop.rs`. 271 + pub fn public_jwk_thumbprint(&self) -> String { 272 + let jwk = self.public_jwk(); 273 + // Canonical member set per RFC 7638 §3.2 — lexicographic order for EC keys. 274 + // serde_json internally represents JSON objects as BTreeMap, which serializes 275 + // keys in lexicographic order. This is what RFC 7638 §3.2 requires for the 276 + // canonical JSON. The key ordering here (crv < kty < x < y) is lexicographic. 277 + let canonical = serde_json::json!({ 278 + "crv": jwk["crv"], 279 + "kty": jwk["kty"], 280 + "x": jwk["x"], 281 + "y": jwk["y"], 282 + }); 283 + let canonical_json = serde_json::to_string(&canonical) 284 + .expect("canonical JWK serialization is infallible for known types"); 285 + let hash = Sha256::digest(canonical_json.as_bytes()); 286 + URL_SAFE_NO_PAD.encode(hash) 287 + } 288 + 289 + /// Build a DPoP proof JWT for the given HTTP method, URL, and optional claims. 290 + /// 291 + /// - `htm`: HTTP method in uppercase, e.g. `"POST"` or `"GET"` 292 + /// - `htu`: Full target URL without query string, e.g. `"https://relay.ezpds.com/oauth/token"` 293 + /// - `nonce`: Server-issued nonce from a prior `use_dpop_nonce` 400 response (if any) 294 + /// - `ath`: `base64url(SHA-256(access_token_ascii))` — required for resource requests; None for token requests 295 + /// 296 + /// Proof format: `base64url(header_json)`.`base64url(claims_json)`.`base64url(sig)` 297 + /// where sig is the raw 64-byte R||S P-256 ECDSA signature of the signing input. 298 + pub fn make_proof( 299 + &self, 300 + htm: &str, 301 + htu: &str, 302 + nonce: Option<&str>, 303 + ath: Option<&str>, 304 + ) -> Result<String, OAuthError> { 305 + let jwk = self.public_jwk(); 306 + 307 + // Header JSON. 308 + let header = serde_json::json!({ 309 + "typ": "dpop+jwt", 310 + "alg": "ES256", 311 + "jwk": jwk, 312 + }); 313 + let header_b64 = URL_SAFE_NO_PAD.encode( 314 + serde_json::to_vec(&header).map_err(|_| OAuthError::DpopProofFailed)?, 315 + ); 316 + 317 + // Claims JSON. 318 + let iat = SystemTime::now() 319 + .duration_since(UNIX_EPOCH) 320 + .map_err(|_| OAuthError::DpopProofFailed)? 321 + .as_secs() as i64; 322 + 323 + let mut claims = serde_json::json!({ 324 + "jti": Uuid::new_v4().to_string(), 325 + "htm": htm, 326 + "htu": htu, 327 + "iat": iat, 328 + }); 329 + 330 + if let Some(n) = nonce { 331 + claims["nonce"] = serde_json::Value::String(n.to_string()); 332 + } 333 + if let Some(a) = ath { 334 + claims["ath"] = serde_json::Value::String(a.to_string()); 335 + } 336 + 337 + let claims_b64 = URL_SAFE_NO_PAD.encode( 338 + serde_json::to_vec(&claims).map_err(|_| OAuthError::DpopProofFailed)?, 339 + ); 340 + 341 + // Sign `header_b64.claims_b64` bytes with P-256/SHA-256. 342 + let signing_input = format!("{header_b64}.{claims_b64}"); 343 + let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); 344 + // Normalize to low-S (consistent with the rest of the codebase, even though 345 + // the relay's DPoP validator does not require it — low-S is harmless and keeps 346 + // key usage consistent with ATProto expectations). 347 + let signature = signature.normalize_s().unwrap_or(signature); 348 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes().as_slice()); 349 + 350 + Ok(format!("{signing_input}.{sig_b64}")) 351 + } 352 + 353 + /// Compute `base64url(SHA-256(access_token))` — the `ath` claim for resource requests. 354 + pub fn compute_ath(access_token: &str) -> String { 355 + let hash = Sha256::digest(access_token.as_bytes()); 356 + URL_SAFE_NO_PAD.encode(hash) 357 + } 358 + } 359 + ``` 360 + 361 + **Step 3: Build to verify** 362 + 363 + ```bash 364 + cargo build -p identity-wallet 365 + ``` 366 + 367 + Expected: builds without errors. Fix any import issues (e.g., if `normalize_s()` is on a different type in this p256 version, try `signature.normalize_s()` returning `Option<Signature>` — call `.unwrap_or(signature)` as shown above). 368 + 369 + <!-- END_TASK_3 --> 370 + 371 + <!-- START_TASK_4 --> 372 + ### Task 4: Write tests for DPoPKeypair 373 + 374 + **Verifies:** MM-149.AC3.1, MM-149.AC3.2, MM-149.AC3.3, MM-149.AC3.4, MM-149.AC3.5 375 + 376 + **Files:** 377 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` (add `#[cfg(test)]` module) 378 + 379 + The relay's DPoP validator is the authoritative spec. Tests should verify our proof passes the same checks the relay performs. We can import and call `relay::auth::dpop::validate_dpop_for_token_endpoint` for AC3.5 verification if the relay is accessible as a workspace crate dependency, but since identity-wallet does not depend on relay, we verify the JWT structure manually by decoding and re-checking the same properties the relay checks. 380 + 381 + **Tests must verify:** 382 + 383 + - **MM-149.AC3.1:** Decode the base64url header of a generated proof; check `typ = "dpop+jwt"`, `alg = "ES256"`, `jwk.kty = "EC"`, `jwk.crv = "P-256"`, `jwk.x` is non-empty, `jwk.y` is non-empty 384 + - **MM-149.AC3.2:** Decode the base64url claims of a generated proof; check `jti` is non-empty, `htm = "POST"`, `htu = "https://example.com/oauth/token"`, `iat` is within 5 seconds of now 385 + - **MM-149.AC3.3:** Generate a proof with `ath = Some("abc123")`; check `claims.ath = "abc123"`; generate without ath; check `claims.ath` is absent 386 + - **MM-149.AC3.4:** Generate with `nonce = Some("testnonce")`; check `claims.nonce = "testnonce"`; generate without nonce; check `claims.nonce` is absent 387 + - **MM-149.AC3.5:** Verify the proof signature using `p256::ecdsa::VerifyingKey`. Extract the public key from the proof's embedded JWK `x` and `y` coordinates, reconstruct the verifying key, then verify the signature over `header_b64.claims_b64` 388 + 389 + **Step 1: Add the test module at the bottom of oauth.rs** 390 + 391 + Add: 392 + 393 + ```rust 394 + #[cfg(test)] 395 + mod tests { 396 + use super::*; 397 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 398 + use p256::ecdsa::signature::Verifier; 399 + 400 + fn decode_jwt_part(b64: &str) -> serde_json::Value { 401 + let bytes = URL_SAFE_NO_PAD.decode(b64).expect("valid base64url"); 402 + serde_json::from_slice(&bytes).expect("valid JSON") 403 + } 404 + 405 + fn split_proof(proof: &str) -> (&str, &str, &str) { 406 + let parts: Vec<&str> = proof.splitn(3, '.').collect(); 407 + assert_eq!(parts.len(), 3, "JWT must have 3 parts"); 408 + (parts[0], parts[1], parts[2]) 409 + } 410 + 411 + #[test] 412 + fn dpop_proof_header_has_required_fields() { 413 + // MM-149.AC3.1 414 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 415 + let proof = kp.make_proof("POST", "https://example.com/oauth/token", None, None) 416 + .expect("proof must build"); 417 + let (header_b64, _, _) = split_proof(&proof); 418 + let header = decode_jwt_part(header_b64); 419 + 420 + assert_eq!(header["typ"].as_str(), Some("dpop+jwt")); 421 + assert_eq!(header["alg"].as_str(), Some("ES256")); 422 + assert_eq!(header["jwk"]["kty"].as_str(), Some("EC")); 423 + assert_eq!(header["jwk"]["crv"].as_str(), Some("P-256")); 424 + assert!(header["jwk"]["x"].as_str().map(|s| !s.is_empty()).unwrap_or(false)); 425 + assert!(header["jwk"]["y"].as_str().map(|s| !s.is_empty()).unwrap_or(false)); 426 + } 427 + 428 + #[test] 429 + fn dpop_proof_claims_has_required_fields() { 430 + // MM-149.AC3.2 431 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 432 + let proof = kp.make_proof("GET", "https://example.com/xrpc/foo", None, None) 433 + .expect("proof must build"); 434 + let (_, claims_b64, _) = split_proof(&proof); 435 + let claims = decode_jwt_part(claims_b64); 436 + 437 + assert!(claims["jti"].as_str().map(|s| !s.is_empty()).unwrap_or(false)); 438 + assert_eq!(claims["htm"].as_str(), Some("GET")); 439 + assert_eq!(claims["htu"].as_str(), Some("https://example.com/xrpc/foo")); 440 + let now = std::time::SystemTime::now() 441 + .duration_since(std::time::UNIX_EPOCH) 442 + .unwrap() 443 + .as_secs() as i64; 444 + let iat = claims["iat"].as_i64().expect("iat must be integer"); 445 + assert!((now - iat).abs() < 5, "iat must be within 5 seconds of now"); 446 + } 447 + 448 + #[test] 449 + fn dpop_proof_includes_ath_when_supplied() { 450 + // MM-149.AC3.3 451 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 452 + let proof_with = kp.make_proof("GET", "https://example.com/resource", None, Some("abc123")) 453 + .expect("proof with ath must build"); 454 + let (_, claims_b64, _) = split_proof(&proof_with); 455 + let claims = decode_jwt_part(claims_b64); 456 + assert_eq!(claims["ath"].as_str(), Some("abc123"), "ath must be present"); 457 + 458 + let proof_without = kp.make_proof("GET", "https://example.com/resource", None, None) 459 + .expect("proof without ath must build"); 460 + let (_, claims_b64, _) = split_proof(&proof_without); 461 + let claims = decode_jwt_part(claims_b64); 462 + assert!(claims["ath"].is_null(), "ath must be absent when not supplied"); 463 + } 464 + 465 + #[test] 466 + fn dpop_proof_includes_nonce_when_supplied() { 467 + // MM-149.AC3.4 468 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 469 + let proof = kp.make_proof("POST", "https://example.com/oauth/token", Some("nonce123"), None) 470 + .expect("proof with nonce must build"); 471 + let (_, claims_b64, _) = split_proof(&proof); 472 + let claims = decode_jwt_part(claims_b64); 473 + assert_eq!(claims["nonce"].as_str(), Some("nonce123"), "nonce must be present"); 474 + 475 + let proof_no = kp.make_proof("POST", "https://example.com/oauth/token", None, None) 476 + .expect("proof without nonce must build"); 477 + let (_, claims_b64, _) = split_proof(&proof_no); 478 + let claims = decode_jwt_part(claims_b64); 479 + assert!(claims["nonce"].is_null(), "nonce must be absent when not supplied"); 480 + } 481 + 482 + #[test] 483 + fn dpop_proof_signature_verifies_against_embedded_jwk() { 484 + // MM-149.AC3.5 485 + use p256::elliptic_curve::sec1::EncodedPoint; 486 + 487 + let kp = DPoPKeypair::get_or_create().expect("keypair must generate"); 488 + let proof = kp.make_proof("POST", "https://example.com/oauth/token", None, None) 489 + .expect("proof must build"); 490 + let (header_b64, claims_b64, sig_b64) = split_proof(&proof); 491 + 492 + // Reconstruct verifying key from the embedded JWK. 493 + let header = decode_jwt_part(header_b64); 494 + let x_bytes = URL_SAFE_NO_PAD.decode(header["jwk"]["x"].as_str().unwrap()).unwrap(); 495 + let y_bytes = URL_SAFE_NO_PAD.decode(header["jwk"]["y"].as_str().unwrap()).unwrap(); 496 + // Build uncompressed point: 0x04 || x || y 497 + let mut point_bytes = vec![0x04u8]; 498 + point_bytes.extend_from_slice(&x_bytes); 499 + point_bytes.extend_from_slice(&y_bytes); 500 + let point = EncodedPoint::from_bytes(&point_bytes).expect("valid uncompressed point"); 501 + let verifying_key = p256::ecdsa::VerifyingKey::from_encoded_point(&point) 502 + .expect("valid verifying key from JWK"); 503 + 504 + // Decode the signature. 505 + let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).expect("valid base64url sig"); 506 + let signature = p256::ecdsa::Signature::from_bytes(sig_bytes.as_slice().into()) 507 + .expect("valid R||S signature bytes"); 508 + 509 + // Verify the signature over the signing input. 510 + let signing_input = format!("{header_b64}.{claims_b64}"); 511 + verifying_key.verify(signing_input.as_bytes(), &signature) 512 + .expect("signature must verify against embedded JWK"); 513 + } 514 + 515 + #[test] 516 + fn compute_ath_matches_sha256_base64url() { 517 + let ath = DPoPKeypair::compute_ath("test_access_token"); 518 + // SHA-256("test_access_token") = known value 519 + let expected = { 520 + use sha2::{Digest, Sha256}; 521 + let hash = Sha256::digest(b"test_access_token"); 522 + URL_SAFE_NO_PAD.encode(hash) 523 + }; 524 + assert_eq!(ath, expected); 525 + } 526 + } 527 + ``` 528 + 529 + **Step 2: Run the tests** 530 + 531 + ```bash 532 + cargo test -p identity-wallet dpop 533 + ``` 534 + 535 + Expected output: all 5 tests pass. 536 + 537 + **Step 3: Run all identity-wallet tests to confirm no regressions** 538 + 539 + ```bash 540 + cargo test -p identity-wallet 541 + ``` 542 + 543 + Expected: all tests pass. 544 + 545 + **Step 4: Commit** 546 + 547 + ```bash 548 + git add apps/identity-wallet/src-tauri/Cargo.toml 549 + git add apps/identity-wallet/src-tauri/src/keychain.rs 550 + git add apps/identity-wallet/src-tauri/src/oauth.rs 551 + git commit -m "feat(identity-wallet): DPoP keypair, proof builder, and OAuth Keychain helpers (MM-149 phase 3)" 552 + ``` 553 + 554 + <!-- END_TASK_4 --> 555 + 556 + <!-- END_SUBCOMPONENT_B -->
+419
docs/implementation-plans/2026-03-23-MM-149/phase_04.md
··· 1 + # MM-149 OAuth PKCE Client Implementation Plan 2 + 3 + **Goal:** Implement PKCE generation utilities and the PAR HTTP call that kicks off the authorization flow. 4 + 5 + **Architecture:** PKCE is pure crypto: 32 OS-random bytes → base64url (verifier), then SHA-256 → base64url (challenge). The state parameter is 16 OS-random bytes → base64url. The PAR call is a new `par()` method on `RelayClient` that POSTs form-urlencoded data and returns a `request_uri`. This is the first outbound call in the OAuth round-trip. 6 + 7 + **Tech Stack:** `rand_core = "0.6"` (OsRng), `sha2 = "0.10"`, `base64 = "0.21"`, `reqwest 0.12` (+ `form` feature) 8 + 9 + **Scope:** 7 phases from original design (phase 4 of 7) 10 + 11 + **Codebase verified:** 2026-03-23 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-149.AC1: PAR flow completes successfully 20 + - **MM-149.AC1.1 Success:** `start_oauth_flow` posts to `/oauth/par` with a valid DPoP proof and receives a `request_uri` (201 response) 21 + - **MM-149.AC1.3 Failure:** PAR request with unknown `client_id` returns a client error (relay rejects it) 22 + - **MM-149.AC1.4 Failure:** PAR request missing `code_challenge` returns a client error 23 + 24 + > AC1.2 (Authorization URL opened in Safari) is verified in Phase 5 with the full `start_oauth_flow` command. AC1.1 is tested here as an integration test against a running relay. 25 + 26 + --- 27 + 28 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 29 + 30 + <!-- START_TASK_1 --> 31 + ### Task 1: Add rand_core and reqwest form feature 32 + 33 + **Verifies:** None (infrastructure) 34 + 35 + **Files:** 36 + - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` 37 + 38 + Two changes: 39 + 1. `rand_core` workspace dep is needed for PKCE random bytes (`OsRng.fill_bytes()`). The workspace dep already has `features = ["getrandom"]`. 40 + 2. `reqwest`'s `.form()` method requires the `form` cargo feature — without it, `.form()` does not exist on the request builder. 41 + 42 + **Step 1: Add rand_core and update reqwest features** 43 + 44 + In `apps/identity-wallet/src-tauri/Cargo.toml`, add `rand_core = { workspace = true }` and update reqwest's features: 45 + 46 + Before: 47 + ```toml 48 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 49 + ``` 50 + 51 + After: 52 + ```toml 53 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "form"] } 54 + rand_core = { workspace = true } 55 + ``` 56 + 57 + **Step 2: Build to verify** 58 + 59 + ```bash 60 + cargo build -p identity-wallet 61 + ``` 62 + 63 + Expected: builds without errors. 64 + 65 + <!-- END_TASK_1 --> 66 + 67 + <!-- START_TASK_2 --> 68 + ### Task 2: Implement PKCE utilities in oauth.rs 69 + 70 + **Verifies:** Part of MM-149.AC1.1 (pkce verifier/challenge used in PAR call); tested directly in Task 4 71 + 72 + **Files:** 73 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` 74 + 75 + PKCE is defined by RFC 7636. The code_verifier is 43-128 URL-safe characters (32 OS-random bytes → base64url gives exactly 43 unreserved chars). The code_challenge is the S256 transform: `base64url(sha256(ascii(verifier)))`. 76 + 77 + **Step 1: Add imports** 78 + 79 + Add at the top of oauth.rs, after the existing `use` statements: 80 + 81 + ```rust 82 + use rand_core::{OsRng, RngCore}; 83 + ``` 84 + 85 + **Step 2: Add the pkce module** 86 + 87 + Add inside `oauth.rs` (after the `DPoPKeypair` impl, before `#[cfg(test)]`): 88 + 89 + ```rust 90 + // ── PKCE utilities ──────────────────────────────────────────────────────────── 91 + 92 + pub mod pkce { 93 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 94 + use rand_core::{OsRng, RngCore}; 95 + use sha2::{Digest, Sha256}; 96 + 97 + /// Generate a PKCE code_verifier and code_challenge pair. 98 + /// 99 + /// - `verifier`: 32 OS-random bytes base64url-encoded (43 chars, all unreserved per RFC 7636 §4.1) 100 + /// - `challenge`: `base64url(SHA-256(verifier))` (S256 method per RFC 7636 §4.2) 101 + /// 102 + /// Returns `(verifier, challenge)`. 103 + pub fn generate() -> (String, String) { 104 + let mut bytes = [0u8; 32]; 105 + OsRng.fill_bytes(&mut bytes); 106 + let verifier = URL_SAFE_NO_PAD.encode(bytes); 107 + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); 108 + (verifier, challenge) 109 + } 110 + } 111 + 112 + /// Generate a CSRF state parameter: 16 OS-random bytes base64url-encoded (22 chars). 113 + pub fn generate_state_param() -> String { 114 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 115 + let mut bytes = [0u8; 16]; 116 + OsRng.fill_bytes(&mut bytes); 117 + URL_SAFE_NO_PAD.encode(bytes) 118 + } 119 + ``` 120 + 121 + **Step 3: Build to verify** 122 + 123 + ```bash 124 + cargo build -p identity-wallet 125 + ``` 126 + 127 + Expected: builds without errors. 128 + 129 + <!-- END_TASK_2 --> 130 + 131 + <!-- END_SUBCOMPONENT_A --> 132 + 133 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 134 + 135 + <!-- START_TASK_3 --> 136 + ### Task 3: Add PAR call to RelayClient in http.rs 137 + 138 + **Verifies:** MM-149.AC1.1 (PAR returns 201 with request_uri) 139 + 140 + **Files:** 141 + - Modify: `apps/identity-wallet/src-tauri/src/http.rs` 142 + 143 + The relay's PAR endpoint (`POST /oauth/par`) accepts `application/x-www-form-urlencoded`. The `DPoP` header is sent per RFC 9449 §6 (the relay currently ignores it at PAR, but it's spec-correct to include it). The function returns a typed `ParResponse` on 201, and `OAuthError::ParFailed` on any other status. 144 + 145 + **Step 1: Read the full current `http.rs`** 146 + 147 + Open `apps/identity-wallet/src-tauri/src/http.rs` to see the full current content (approximately 80 lines). Note the imports and the `RelayClient` struct definition. 148 + 149 + **Step 2: Add imports and ParResponse at the top of http.rs** 150 + 151 + Add at the top (after the existing `use` statements): 152 + 153 + ```rust 154 + use crate::oauth::OAuthError; 155 + ``` 156 + 157 + Add the `ParResponse` type after the existing struct definitions but before the `impl RelayClient` block: 158 + 159 + ```rust 160 + /// Successful response from `POST /oauth/par` (RFC 9126 §2.2). 161 + #[derive(Debug, serde::Deserialize)] 162 + pub struct ParResponse { 163 + pub request_uri: String, 164 + pub expires_in: u32, 165 + } 166 + ``` 167 + 168 + **Step 3: Add the `par()` method to `impl RelayClient`** 169 + 170 + Add after the existing `post_with_bearer()` method: 171 + 172 + ```rust 173 + /// POST `/oauth/par` — push the authorization request parameters to the relay. 174 + /// 175 + /// Sends the required PKCE and OAuth parameters as `application/x-www-form-urlencoded`. 176 + /// Includes a `DPoP` proof header per RFC 9449 §6. 177 + /// 178 + /// `dpop_jkt` is the JWK thumbprint of the DPoP key; included as a form field for 179 + /// servers that support PAR-level DPoP key binding (the relay currently ignores it, 180 + /// but it is spec-correct to send it). 181 + pub async fn par( 182 + &self, 183 + code_challenge: &str, 184 + state_param: &str, 185 + dpop_proof: &str, 186 + dpop_jkt: &str, 187 + login_hint: Option<&str>, 188 + ) -> Result<ParResponse, OAuthError> { 189 + let url = format!("{}/oauth/par", self.base_url); 190 + 191 + let mut fields = vec![ 192 + ("client_id", "dev.malpercio.identitywallet"), 193 + ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"), 194 + ("code_challenge", code_challenge), 195 + ("code_challenge_method", "S256"), 196 + ("state", state_param), 197 + ("response_type", "code"), 198 + ("scope", "atproto"), 199 + ("dpop_jkt", dpop_jkt), 200 + ]; 201 + 202 + let hint_owned; 203 + if let Some(hint) = login_hint { 204 + hint_owned = hint.to_string(); 205 + fields.push(("login_hint", &hint_owned)); 206 + } 207 + 208 + let resp = self 209 + .client 210 + .post(&url) 211 + .header("DPoP", dpop_proof) 212 + .form(&fields) 213 + .send() 214 + .await 215 + .map_err(|e| { 216 + tracing::error!(error = %e, "PAR request network error"); 217 + OAuthError::ParFailed 218 + })?; 219 + 220 + let status = resp.status(); 221 + if status.as_u16() != 201 { 222 + let body = resp.text().await.unwrap_or_default(); 223 + tracing::error!(status = %status, body = %body, "PAR request failed"); 224 + return Err(OAuthError::ParFailed); 225 + } 226 + 227 + resp.json::<ParResponse>().await.map_err(|e| { 228 + tracing::error!(error = %e, "PAR response deserialization failed"); 229 + OAuthError::ParFailed 230 + }) 231 + } 232 + ``` 233 + 234 + **Step 4: Build to verify** 235 + 236 + ```bash 237 + cargo build -p identity-wallet 238 + ``` 239 + 240 + Expected: builds without errors. If you get a lifetime error on `hint_owned`, move the `hint_owned` variable declaration before `fields` is defined (before the `let mut fields = ...` line). 241 + 242 + <!-- END_TASK_3 --> 243 + 244 + <!-- START_TASK_4 --> 245 + ### Task 4: Write PKCE unit tests and PAR integration test 246 + 247 + **Verifies:** MM-149.AC1.1, MM-149.AC1.3 (relay rejects unknown client), MM-149.AC1.4 (PAR without code_challenge returns 4xx) 248 + 249 + **Files:** 250 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` (add tests to existing `#[cfg(test)]` module) 251 + 252 + **Step 1: Add PKCE unit tests to the existing `#[cfg(test)]` mod in oauth.rs** 253 + 254 + Inside the existing `#[cfg(test)]` module (from Phase 3), add: 255 + 256 + ```rust 257 + // PKCE tests 258 + #[test] 259 + fn pkce_verifier_is_43_unreserved_chars() { 260 + let (verifier, _) = pkce::generate(); 261 + assert_eq!(verifier.len(), 43, "base64url of 32 bytes must be 43 chars"); 262 + // RFC 7636 §4.1: ALPHA / DIGIT / "-" / "." / "_" / "~" 263 + assert!( 264 + verifier.chars().all(|c| c.is_alphanumeric() || "-._~".contains(c)), 265 + "verifier must consist only of unreserved chars: got {verifier}" 266 + ); 267 + } 268 + 269 + #[test] 270 + fn pkce_challenge_equals_sha256_base64url_of_verifier() { 271 + use sha2::{Digest, Sha256}; 272 + let (verifier, challenge) = pkce::generate(); 273 + let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); 274 + assert_eq!(challenge, expected, "challenge must be base64url(sha256(verifier))"); 275 + } 276 + 277 + #[test] 278 + fn state_param_is_22_chars() { 279 + let state = generate_state_param(); 280 + assert_eq!(state.len(), 22, "base64url of 16 bytes must be 22 chars"); 281 + } 282 + 283 + #[test] 284 + fn pkce_verifiers_are_unique() { 285 + let (v1, _) = pkce::generate(); 286 + let (v2, _) = pkce::generate(); 287 + assert_ne!(v1, v2, "each generate() call must produce a different verifier"); 288 + } 289 + ``` 290 + 291 + **Step 2: Add a PAR integration test (requires running relay)** 292 + 293 + Integration tests that need external services should be marked `#[ignore]` so they don't run in CI. They can be run explicitly with `cargo test -- --include-ignored` when the relay is available. 294 + 295 + Add to the same test module: 296 + 297 + ```rust 298 + /// Integration test: PAR call against a running relay. 299 + /// 300 + /// Requires the relay to be running at http://localhost:8080 with the V013 301 + /// migration applied (identity-wallet client registered). 302 + /// 303 + /// Run with: cargo test -p identity-wallet par_integration -- --include-ignored --nocapture 304 + #[tokio::test] 305 + #[ignore = "requires running relay at localhost:8080"] 306 + async fn par_integration_returns_201_with_request_uri() { 307 + let relay = crate::http::RelayClient::new(); 308 + let keypair = DPoPKeypair::get_or_create().expect("keypair must generate"); 309 + // `htu` is embedded in the DPoP proof JWT claims (the `htu` claim per RFC 9449 §4.2), 310 + // not used for the HTTP request itself — `relay.par()` constructs the URL internally. 311 + let htu = format!("{}/oauth/par", crate::http::RelayClient::base_url()); 312 + let dpop_proof = keypair.make_proof("POST", &htu, None, None) 313 + .expect("DPoP proof must build"); 314 + let dpop_jkt = keypair.public_jwk_thumbprint(); 315 + let (_, challenge) = pkce::generate(); 316 + let state = generate_state_param(); 317 + 318 + let resp = relay.par(&challenge, &state, &dpop_proof, &dpop_jkt, None) 319 + .await 320 + .expect("PAR must succeed"); 321 + 322 + assert!( 323 + resp.request_uri.starts_with("urn:ietf:params:oauth:request_uri:"), 324 + "request_uri must use OAuth PAR URN scheme, got: {}", 325 + resp.request_uri 326 + ); 327 + assert_eq!(resp.expires_in, 60); 328 + } 329 + ``` 330 + 331 + **Step 2b: Add a negative integration test for AC1.4 (PAR without code_challenge)** 332 + 333 + Add to the same test module, immediately after `par_integration_returns_201_with_request_uri`: 334 + 335 + ```rust 336 + /// Integration test: PAR call missing code_challenge is rejected by relay. 337 + /// 338 + /// Verifies MM-149.AC1.4: the relay returns a client error (400) when 339 + /// code_challenge is absent from the PAR request. 340 + /// 341 + /// Run with: cargo test -p identity-wallet par_missing_challenge -- --include-ignored --nocapture 342 + #[tokio::test] 343 + #[ignore = "requires running relay at localhost:8080"] 344 + async fn par_missing_code_challenge_returns_client_error() { 345 + // Build a minimal PAR form body with no code_challenge field. 346 + let base_url = crate::http::RelayClient::base_url(); 347 + let url = format!("{base_url}/oauth/par"); 348 + let keypair = DPoPKeypair::get_or_create().expect("keypair must generate"); 349 + let dpop_proof = keypair 350 + .make_proof("POST", &url, None, None) 351 + .expect("DPoP proof must build"); 352 + 353 + let client = reqwest::Client::new(); 354 + let resp = client 355 + .post(&url) 356 + .header("DPoP", dpop_proof) 357 + .form(&[ 358 + ("client_id", "dev.malpercio.identitywallet"), 359 + ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"), 360 + ("code_challenge_method", "S256"), 361 + ("state", "somestate"), 362 + ("response_type", "code"), 363 + ("scope", "atproto"), 364 + // code_challenge intentionally omitted 365 + ]) 366 + .send() 367 + .await 368 + .expect("request must reach relay"); 369 + 370 + assert!( 371 + resp.status().is_client_error(), 372 + "relay must reject PAR without code_challenge with 4xx, got: {}", 373 + resp.status() 374 + ); 375 + } 376 + ``` 377 + 378 + Note: this test requires `reqwest` in `[dev-dependencies]`. The build-dependency already exists in `[dependencies]` (from Task 1), so the test can use it directly with `reqwest::Client::new()` — no additional Cargo.toml change needed. 379 + 380 + **Step 3: Run PKCE unit tests** 381 + 382 + ```bash 383 + cargo test -p identity-wallet pkce 384 + ``` 385 + 386 + Expected: 4 tests pass (pkce_verifier, pkce_challenge, state_param, pkce_verifiers_unique). 387 + 388 + **Step 4: Run all identity-wallet tests** 389 + 390 + ```bash 391 + cargo test -p identity-wallet 392 + ``` 393 + 394 + Expected: all tests pass (the PAR integration tests are skipped due to `#[ignore]`). 395 + 396 + **Step 5: (Optional, requires running relay) Run the PAR integration tests** 397 + 398 + Start the relay in another terminal, then: 399 + 400 + ```bash 401 + cargo test -p identity-wallet par_ -- --include-ignored --nocapture 402 + ``` 403 + 404 + Expected: 405 + - `par_integration_returns_201_with_request_uri` passes (AC1.1) 406 + - `par_missing_code_challenge_returns_client_error` passes with 400 (AC1.4) 407 + 408 + **Step 6: Commit** 409 + 410 + ```bash 411 + git add apps/identity-wallet/src-tauri/Cargo.toml 412 + git add apps/identity-wallet/src-tauri/src/http.rs 413 + git add apps/identity-wallet/src-tauri/src/oauth.rs 414 + git commit -m "feat(identity-wallet): PKCE generation and PAR HTTP call (MM-149 phase 4)" 415 + ``` 416 + 417 + <!-- END_TASK_4 --> 418 + 419 + <!-- END_SUBCOMPONENT_B -->
+650
docs/implementation-plans/2026-03-23-MM-149/phase_05.md
··· 1 + # MM-149 OAuth PKCE Client Implementation Plan 2 + 3 + **Goal:** Implement the full single-command OAuth round-trip: PKCE + DPoP + PAR + Safari + oneshot channel + CSRF validation + token exchange + Keychain storage. 4 + 5 + **Architecture:** `start_oauth_flow` is a `#[tauri::command]` that drives the entire round-trip. It generates all cryptographic material, calls PAR, opens Safari, then parks on a `tokio::sync::oneshot::Receiver`. When Safari completes authorization, `handle_deep_link` fires on a separate OS thread, takes `PendingOAuthFlow` from `AppState`, validates CSRF, and sends `CallbackParams` on the `Sender`. `start_oauth_flow` wakes, exchanges the code for tokens (with one retry on `use_dpop_nonce`), stores tokens in Keychain, and updates `AppState.oauth_session`. 6 + 7 + **Tech Stack:** `tokio = "1"` (oneshot channel, async command), `tauri-plugin-opener` (open Safari), `reqwest 0.12` (form POST for token exchange) 8 + 9 + **Scope:** 7 phases from original design (phase 5 of 7) 10 + 11 + **Codebase verified:** 2026-03-23 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-149.AC1: PAR flow completes successfully 20 + - **MM-149.AC1.1 Success:** `start_oauth_flow` posts to `/oauth/par` with a valid DPoP proof and receives a `request_uri` (201 response) 21 + - **MM-149.AC1.2 Success:** Authorization URL opened in system browser includes `client_id` and `request_uri` parameters 22 + 23 + ### MM-149.AC2: OAuth callback received and code exchanged 24 + - **MM-149.AC2.1 Success:** Deep-link handler receives `dev.malpercio.identitywallet:/oauth/callback?code=...&state=...` and wakes the parked `start_oauth_flow` command 25 + - **MM-149.AC2.2 Success:** Token exchange succeeds — relay returns `access_token`, `refresh_token`, and `token_type: "DPoP"` 26 + - **MM-149.AC2.3 Failure:** `state` mismatch between generated param and callback param aborts with `StateMismatch` error 27 + - **MM-149.AC2.4 Failure:** A second (replayed) deep-link callback with the same scheme is silently ignored 28 + - **MM-149.AC2.5 Edge:** `use_dpop_nonce` error on token exchange triggers one retry with server-provided nonce; retry succeeds 29 + 30 + ### MM-149.AC4: Tokens stored securely and loaded on restart 31 + - **MM-149.AC4.1 Success:** After successful exchange, `access_token`, `refresh_token`, and DPoP private key bytes are present in iOS Keychain under the expected account keys 32 + 33 + ### MM-149.AC5: Authenticated requests carry DPoP proofs 34 + - **MM-149.AC5.3 Failure:** Request after token is deliberately cleared returns an auth error, not a panic 35 + 36 + --- 37 + 38 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 39 + 40 + <!-- START_TASK_1 --> 41 + ### Task 1: Add tokio dependency to identity-wallet Cargo.toml 42 + 43 + **Verifies:** None (infrastructure) 44 + 45 + **Files:** 46 + - Modify: `apps/identity-wallet/src-tauri/Cargo.toml` 47 + 48 + Tokio is in the workspace with `features = ["full"]`. identity-wallet needs it for `tokio::sync::oneshot` and `#[tokio::main]` tests. 49 + 50 + **Step 1: Add tokio** 51 + 52 + Add to `[dependencies]` in `apps/identity-wallet/src-tauri/Cargo.toml`: 53 + 54 + ```toml 55 + tokio = { workspace = true } 56 + ``` 57 + 58 + **Step 2: Build to verify** 59 + 60 + ```bash 61 + cargo build -p identity-wallet 62 + ``` 63 + 64 + Expected: builds without errors. 65 + 66 + <!-- END_TASK_1 --> 67 + 68 + <!-- START_TASK_2 --> 69 + ### Task 2: Update PendingOAuthFlow and OAuthSession types in oauth.rs, and add token exchange to http.rs 70 + 71 + **Verifies:** None (types needed by Task 3) 72 + 73 + **Files:** 74 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` 75 + - Modify: `apps/identity-wallet/src-tauri/src/http.rs` 76 + 77 + **Step 1: Replace the stub PendingOAuthFlow in oauth.rs** 78 + 79 + Find the stub `PendingOAuthFlow` struct (from Phase 2) and replace it with the real version: 80 + 81 + Old (stub from Phase 2): 82 + ```rust 83 + pub struct PendingOAuthFlow { 84 + /// The CSRF state parameter generated at the start of the flow. 85 + pub csrf_state: String, 86 + } 87 + ``` 88 + 89 + Replace with: 90 + ```rust 91 + pub struct PendingOAuthFlow { 92 + /// Channel to deliver the callback result back to `start_oauth_flow`. 93 + /// 94 + /// Sends `Ok(CallbackParams)` on success or `Err(OAuthError::StateMismatch)` on 95 + /// CSRF mismatch, so the command can distinguish a mismatch from a dropped channel. 96 + pub tx: tokio::sync::oneshot::Sender<Result<CallbackParams, OAuthError>>, 97 + /// PKCE code_verifier to include in the token exchange. 98 + pub pkce_verifier: String, 99 + /// CSRF state parameter — validated against the callback's state param. 100 + pub csrf_state: String, 101 + } 102 + ``` 103 + 104 + **Step 2: Replace the stub OAuthSession in oauth.rs** 105 + 106 + Find the stub `OAuthSession` struct (from Phase 2) and replace it: 107 + 108 + Old (stub from Phase 2): 109 + ```rust 110 + pub struct OAuthSession { 111 + pub access_token: String, 112 + pub refresh_token: String, 113 + } 114 + ``` 115 + 116 + Replace with: 117 + ```rust 118 + /// Active OAuth session stored in AppState after successful token exchange. 119 + pub struct OAuthSession { 120 + pub access_token: String, 121 + pub refresh_token: String, 122 + /// Unix timestamp (seconds) when the access token expires. 123 + pub expires_at: u64, 124 + /// The most recent DPoP nonce issued by the server. 125 + /// Starts as None; updated whenever the server sends a DPoP-Nonce header. 126 + pub dpop_nonce: Option<String>, 127 + } 128 + ``` 129 + 130 + **Step 3: Add the token exchange method to RelayClient in http.rs** 131 + 132 + The relay's token endpoint (`POST /oauth/token`) uses the same form-urlencoded format as PAR. Add after the existing `par()` method: 133 + 134 + First, add the response type after `ParResponse`: 135 + ```rust 136 + /// Successful response from `POST /oauth/token` (RFC 6749 §5.1). 137 + #[derive(Debug, serde::Deserialize)] 138 + pub struct TokenResponse { 139 + pub access_token: String, 140 + pub token_type: String, 141 + pub expires_in: u64, 142 + pub refresh_token: String, 143 + pub scope: String, 144 + } 145 + 146 + /// Error response from `POST /oauth/token` (RFC 6749 §5.2). 147 + #[derive(Debug, serde::Deserialize)] 148 + pub struct TokenErrorResponse { 149 + pub error: String, 150 + pub error_description: Option<String>, 151 + } 152 + ``` 153 + 154 + Then add to `impl RelayClient`: 155 + ```rust 156 + /// POST `/oauth/token` — exchange an authorization code for tokens. 157 + /// 158 + /// Sends the authorization code, PKCE verifier, and DPoP proof. 159 + /// Returns the token response body on 200, or an error. 160 + /// The caller is responsible for reading the `DPoP-Nonce` response header 161 + /// if the server returns one (the full `reqwest::Response` is returned for this). 162 + pub async fn token_exchange( 163 + &self, 164 + code: &str, 165 + pkce_verifier: &str, 166 + dpop_proof: &str, 167 + ) -> Result<reqwest::Response, OAuthError> { 168 + let url = format!("{}/oauth/token", self.base_url); 169 + let resp = self 170 + .client 171 + .post(&url) 172 + .header("DPoP", dpop_proof) 173 + .form(&[ 174 + ("grant_type", "authorization_code"), 175 + ("code", code), 176 + ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"), 177 + ("client_id", "dev.malpercio.identitywallet"), 178 + ("code_verifier", pkce_verifier), 179 + ]) 180 + .send() 181 + .await 182 + .map_err(|e| { 183 + tracing::error!(error = %e, "token exchange network error"); 184 + OAuthError::TokenExchangeFailed 185 + })?; 186 + Ok(resp) 187 + } 188 + ``` 189 + 190 + **Step 4: Build to verify** 191 + 192 + ```bash 193 + cargo build -p identity-wallet 194 + ``` 195 + 196 + Expected: builds without errors. 197 + 198 + <!-- END_TASK_2 --> 199 + 200 + <!-- START_TASK_3 --> 201 + ### Task 3: Implement start_oauth_flow and complete handle_deep_link in oauth.rs 202 + 203 + **Verifies:** MM-149.AC1.1, MM-149.AC1.2, MM-149.AC2.1, MM-149.AC2.2, MM-149.AC2.3, MM-149.AC2.4, MM-149.AC2.5, MM-149.AC4.1 204 + 205 + **Files:** 206 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` 207 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 208 + 209 + **Step 1: Add the start_oauth_flow command to oauth.rs** 210 + 211 + Add after the `handle_deep_link` function. This is the most complex function in the file — read it carefully before implementing. 212 + 213 + ```rust 214 + // ── Tauri command ───────────────────────────────────────────────────────────── 215 + 216 + /// Drive the full OAuth 2.0 PKCE + DPoP authorization round-trip. 217 + /// 218 + /// Called from the SvelteKit frontend via `invoke('start_oauth_flow')`. 219 + /// Parks on a Tokio oneshot channel until `handle_deep_link` delivers 220 + /// the authorization code from the system browser redirect. 221 + /// 222 + /// # Flow 223 + /// 1. Generate PKCE verifier/challenge and CSRF state parameter 224 + /// 2. Get-or-create DPoP keypair; build PAR DPoP proof 225 + /// 3. POST /oauth/par → receive request_uri 226 + /// 4. Open system browser to /oauth/authorize?client_id=...&request_uri=... 227 + /// 5. Park on oneshot receiver; handle_deep_link will send the code+state 228 + /// 6. Validate CSRF state matches 229 + /// 7. POST /oauth/token (authorization_code grant + PKCE verifier + DPoP proof) 230 + /// → on use_dpop_nonce 400: retry with server-issued nonce 231 + /// 8. Store access_token + refresh_token in Keychain 232 + /// 9. Populate AppState.oauth_session 233 + #[tauri::command] 234 + pub async fn start_oauth_flow( 235 + app: tauri::AppHandle, 236 + state: tauri::State<'_, AppState>, 237 + login_hint: Option<String>, 238 + ) -> Result<(), OAuthError> { 239 + use tauri::Manager; 240 + // OpenerExt adds the `.opener()` method to AppHandle. 241 + use tauri_plugin_opener::OpenerExt; 242 + 243 + let relay = crate::http::RelayClient::new(); 244 + 245 + // 1. Generate PKCE and CSRF state. 246 + let (pkce_verifier, pkce_challenge) = pkce::generate(); 247 + let csrf_state = generate_state_param(); 248 + 249 + // 2. Get-or-create DPoP keypair. 250 + let dpop = DPoPKeypair::get_or_create()?; 251 + let dpop_jkt = dpop.public_jwk_thumbprint(); 252 + 253 + let par_htu = format!("{}/oauth/par", crate::http::RelayClient::base_url()); 254 + let par_proof = dpop.make_proof("POST", &par_htu, None, None)?; 255 + 256 + // 3. PAR call. 257 + let par_resp = relay 258 + .par(&pkce_challenge, &csrf_state, &par_proof, &dpop_jkt, login_hint.as_deref()) 259 + .await?; 260 + 261 + // 4. Set up the oneshot channel and park pending_auth. 262 + let (tx, rx) = tokio::sync::oneshot::channel::<Result<CallbackParams, OAuthError>>(); 263 + { 264 + let mut pending = state.pending_auth.lock().unwrap(); 265 + *pending = Some(PendingOAuthFlow { 266 + tx, 267 + pkce_verifier: pkce_verifier.clone(), 268 + csrf_state: csrf_state.clone(), 269 + }); 270 + } // Mutex guard dropped here — not held across .await. 271 + 272 + // 5. Open Safari to the authorization endpoint. 273 + let auth_url = { 274 + let base = crate::http::RelayClient::base_url(); 275 + let request_uri_encoded = url::form_urlencoded::byte_serialize( 276 + par_resp.request_uri.as_bytes(), 277 + ) 278 + .collect::<String>(); 279 + let mut u = format!( 280 + "{base}/oauth/authorize?client_id=dev.malpercio.identitywallet&request_uri={request_uri_encoded}" 281 + ); 282 + if let Some(hint) = &login_hint { 283 + let hint_encoded = url::form_urlencoded::byte_serialize(hint.as_bytes()) 284 + .collect::<String>(); 285 + u.push_str(&format!("&login_hint={hint_encoded}")); 286 + } 287 + u 288 + }; 289 + 290 + app.opener() 291 + .open_url(&auth_url, None::<&str>) 292 + .map_err(|e| { 293 + tracing::error!(error = %e, "failed to open system browser for OAuth"); 294 + OAuthError::ParFailed 295 + })?; 296 + 297 + // 6. Wait for the deep-link callback to deliver the authorization code. 298 + // The outer ? handles RecvError (channel dropped) → CallbackAbandoned. 299 + // The inner ? propagates OAuthError::StateMismatch if handle_deep_link detected a CSRF mismatch. 300 + let callback = rx.await.map_err(|_| OAuthError::CallbackAbandoned)??; 301 + 302 + // 7. Token exchange. 303 + let token_htu = format!("{}/oauth/token", crate::http::RelayClient::base_url()); 304 + let (token_resp, initial_nonce) = exchange_code_with_retry( 305 + &relay, 306 + &dpop, 307 + &callback.code, 308 + &pkce_verifier, 309 + &token_htu, 310 + ) 311 + .await?; 312 + 313 + // 8. Store tokens in Keychain. 314 + crate::keychain::store_oauth_tokens(&token_resp.access_token, &token_resp.refresh_token) 315 + .map_err(|_| OAuthError::KeychainError)?; 316 + 317 + // 9. Update AppState. 318 + // Seed dpop_nonce from the token response to avoid a guaranteed use_dpop_nonce retry 319 + // on the first OAuthClient request immediately after login. 320 + let expires_at = std::time::SystemTime::now() 321 + .duration_since(std::time::UNIX_EPOCH) 322 + .map_err(|_| OAuthError::TokenExchangeFailed)? 323 + .as_secs() 324 + + token_resp.expires_in; 325 + 326 + let mut session = state.oauth_session.lock().unwrap(); 327 + *session = Some(OAuthSession { 328 + access_token: token_resp.access_token, 329 + refresh_token: token_resp.refresh_token, 330 + expires_at, 331 + dpop_nonce: initial_nonce, 332 + }); 333 + 334 + tracing::info!("OAuth flow complete; session stored"); 335 + Ok(()) 336 + } 337 + 338 + /// Perform the authorization code token exchange with one retry on `use_dpop_nonce`. 339 + /// 340 + /// Returns the token response and the `DPoP-Nonce` header value from the successful 341 + /// response (if present). Storing this nonce in the session avoids a guaranteed 342 + /// `use_dpop_nonce` retry on the very first `OAuthClient` request after login. 343 + /// 344 + /// The relay always requires a DPoP nonce at the token endpoint (RFC 9449 §8). 345 + /// On the first attempt, the nonce is absent; the relay returns 400 with `use_dpop_nonce` 346 + /// and a `DPoP-Nonce` response header. We retry exactly once with that nonce. 347 + async fn exchange_code_with_retry( 348 + relay: &crate::http::RelayClient, 349 + dpop: &DPoPKeypair, 350 + code: &str, 351 + pkce_verifier: &str, 352 + token_htu: &str, 353 + ) -> Result<(crate::http::TokenResponse, Option<String>), OAuthError> { 354 + let proof = dpop.make_proof("POST", token_htu, None, None)?; 355 + let resp = relay.token_exchange(code, pkce_verifier, &proof).await?; 356 + 357 + if resp.status().as_u16() == 200 { 358 + // Capture DPoP-Nonce before consuming the body. 359 + let nonce = resp 360 + .headers() 361 + .get("DPoP-Nonce") 362 + .and_then(|v| v.to_str().ok()) 363 + .map(str::to_string); 364 + let token = resp.json::<crate::http::TokenResponse>().await.map_err(|e| { 365 + tracing::error!(error = %e, "token response deserialization failed"); 366 + OAuthError::TokenExchangeFailed 367 + })?; 368 + return Ok((token, nonce)); 369 + } 370 + 371 + // Check for use_dpop_nonce — extract the nonce from the DPoP-Nonce header. 372 + let nonce = resp 373 + .headers() 374 + .get("DPoP-Nonce") 375 + .and_then(|v| v.to_str().ok()) 376 + .map(str::to_string); 377 + 378 + let error_body = resp 379 + .json::<crate::http::TokenErrorResponse>() 380 + .await 381 + .unwrap_or_else(|_| crate::http::TokenErrorResponse { 382 + error: "unknown".into(), 383 + error_description: None, 384 + }); 385 + 386 + if error_body.error == "use_dpop_nonce" { 387 + if let Some(nonce_val) = nonce { 388 + tracing::debug!(nonce = %nonce_val, "retrying token exchange with server nonce"); 389 + let proof_with_nonce = dpop.make_proof("POST", token_htu, Some(&nonce_val), None)?; 390 + let retry_resp = relay 391 + .token_exchange(code, pkce_verifier, &proof_with_nonce) 392 + .await?; 393 + if retry_resp.status().as_u16() == 200 { 394 + // Capture DPoP-Nonce from the retry response too. 395 + let retry_nonce = retry_resp 396 + .headers() 397 + .get("DPoP-Nonce") 398 + .and_then(|v| v.to_str().ok()) 399 + .map(str::to_string); 400 + let token = retry_resp 401 + .json::<crate::http::TokenResponse>() 402 + .await 403 + .map_err(|e| { 404 + tracing::error!(error = %e, "retry token response deserialization failed"); 405 + OAuthError::TokenExchangeFailed 406 + })?; 407 + return Ok((token, retry_nonce)); 408 + } 409 + tracing::error!("token exchange failed after nonce retry"); 410 + return Err(OAuthError::TokenExchangeFailed); 411 + } 412 + } 413 + 414 + tracing::error!(error = %error_body.error, "token exchange failed"); 415 + Err(OAuthError::TokenExchangeFailed) 416 + } 417 + ``` 418 + 419 + > **url crate note:** The code above uses `url::form_urlencoded::byte_serialize()` to percent-encode the request_uri. The `url` crate is a transitive dependency via `tauri-plugin-deep-link`. If the compiler cannot resolve `url::form_urlencoded`, add `url = "2"` explicitly to `apps/identity-wallet/src-tauri/Cargo.toml`. 420 + 421 + **Step 2: Complete the handle_deep_link function** 422 + 423 + Find the existing `handle_deep_link` stub in oauth.rs and replace it entirely: 424 + 425 + ```rust 426 + /// Process URLs received from the deep-link plugin's `on_open_url` event. 427 + /// 428 + /// Filters for the OAuth callback path, extracts `code` and `state`, validates the 429 + /// CSRF state against the pending flow, and sends `CallbackParams` on the oneshot channel. 430 + /// 431 + /// Called from the `on_open_url` closure in lib.rs (sync context — no async). 432 + /// A second callback (replay) is silently ignored because `pending_auth.take()` clears 433 + /// the slot on first receipt (MM-149.AC2.4). 434 + pub fn handle_deep_link(urls: Vec<url::Url>, app_state: &AppState) { 435 + for url in &urls { 436 + let scheme = url.scheme(); 437 + let path = url.path(); 438 + 439 + if scheme == "dev.malpercio.identitywallet" && path == "/oauth/callback" { 440 + tracing::info!(url = %url, "OAuth deep-link callback received"); 441 + 442 + // Take the pending flow — clears the slot so replays are silently ignored. 443 + let pending = app_state.pending_auth.lock().unwrap().take(); 444 + let Some(flow) = pending else { 445 + tracing::warn!("OAuth callback received but no flow is pending; ignoring (replay?)"); 446 + return; 447 + }; 448 + 449 + // Extract code and state from query parameters. 450 + let mut code_opt: Option<String> = None; 451 + let mut state_opt: Option<String> = None; 452 + for (key, value) in url.query_pairs() { 453 + match key.as_ref() { 454 + "code" => code_opt = Some(value.into_owned()), 455 + "state" => state_opt = Some(value.into_owned()), 456 + _ => {} 457 + } 458 + } 459 + 460 + let (Some(code), Some(callback_state)) = (code_opt, state_opt) else { 461 + tracing::error!("OAuth callback URL missing code or state parameters"); 462 + return; 463 + }; 464 + 465 + // Validate CSRF state — must match before sending on the channel. 466 + if callback_state != flow.csrf_state { 467 + tracing::error!( 468 + expected = %flow.csrf_state, 469 + received = %callback_state, 470 + "CSRF state mismatch in OAuth callback; aborting flow" 471 + ); 472 + // Send the error explicitly so start_oauth_flow returns StateMismatch, 473 + // not CallbackAbandoned (which would occur if we just dropped tx). 474 + let _ = flow.tx.send(Err(OAuthError::StateMismatch)); 475 + return; 476 + } 477 + 478 + let _ = flow.tx.send(Ok(CallbackParams { 479 + code, 480 + state: callback_state, 481 + })); 482 + return; 483 + } 484 + 485 + tracing::debug!(url = %url, "ignoring non-OAuth deep-link"); 486 + } 487 + } 488 + ``` 489 + 490 + **Step 3: Register start_oauth_flow in lib.rs** 491 + 492 + Find the `invoke_handler` in `run()` (lib.rs:401-406) and add the new command: 493 + 494 + ```rust 495 + .invoke_handler(tauri::generate_handler![ 496 + create_account, 497 + get_or_create_device_key, 498 + sign_with_device_key, 499 + perform_did_ceremony, 500 + oauth::start_oauth_flow, 501 + ]) 502 + ``` 503 + 504 + **Step 4: Build to verify** 505 + 506 + ```bash 507 + cargo build -p identity-wallet 508 + ``` 509 + 510 + Expected: builds without errors. 511 + 512 + <!-- END_TASK_3 --> 513 + 514 + <!-- END_SUBCOMPONENT_A --> 515 + 516 + <!-- START_SUBCOMPONENT_B (tasks 4-5) --> 517 + 518 + <!-- START_TASK_4 --> 519 + ### Task 4: Write unit tests for handle_deep_link and the CSRF/replay logic 520 + 521 + **Verifies:** MM-149.AC2.3 (state mismatch), MM-149.AC2.4 (replay silently ignored) 522 + 523 + **Files:** 524 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` (add tests to existing `#[cfg(test)]` module) 525 + 526 + The `handle_deep_link` function can be tested synchronously without a running relay. We construct a fake `AppState` with a pending flow and drive it directly. 527 + 528 + **Tests must verify:** 529 + 530 + - **MM-149.AC2.3:** When `handle_deep_link` receives a callback URL whose `state` param does not match `flow.csrf_state`, the oneshot receiver is dropped (receives `Err(RecvError)`), proving the flow was aborted without sending. 531 + - **MM-149.AC2.4:** When `handle_deep_link` is called twice with matching state but the first call already cleared `pending_auth`, the second call sees `None` pending and returns silently (no panic, no send). 532 + 533 + **Step 1: Add tests to the existing `#[cfg(test)]` mod in oauth.rs** 534 + 535 + ```rust 536 + // handle_deep_link tests 537 + fn make_test_url(code: &str, state: &str) -> url::Url { 538 + url::Url::parse(&format!( 539 + "dev.malpercio.identitywallet:/oauth/callback?code={code}&state={state}" 540 + )) 541 + .unwrap() 542 + } 543 + 544 + #[test] 545 + fn handle_deep_link_csrf_mismatch_returns_state_mismatch_error() { 546 + // MM-149.AC2.3: CSRF mismatch sends Err(StateMismatch), not drops the sender. 547 + let (tx, rx) = tokio::sync::oneshot::channel::<Result<CallbackParams, OAuthError>>(); 548 + let state = AppState { 549 + pending_auth: std::sync::Mutex::new(Some(PendingOAuthFlow { 550 + tx, 551 + pkce_verifier: "v".to_string(), 552 + csrf_state: "correct-state".to_string(), 553 + })), 554 + oauth_session: std::sync::Mutex::new(None), 555 + }; 556 + 557 + let url = make_test_url("code123", "WRONG-STATE"); 558 + handle_deep_link(vec![url], &state); 559 + 560 + // Receiver must get Err(StateMismatch), not a channel-level error. 561 + assert!( 562 + matches!(rx.try_recv(), Ok(Err(OAuthError::StateMismatch))), 563 + "CSRF mismatch must deliver StateMismatch to the command" 564 + ); 565 + // The pending_auth slot was cleared. 566 + assert!(state.pending_auth.lock().unwrap().is_none(), "pending_auth must be cleared"); 567 + } 568 + 569 + #[test] 570 + fn handle_deep_link_replay_is_silently_ignored() { 571 + // MM-149.AC2.4 572 + let (tx, rx) = tokio::sync::oneshot::channel::<Result<CallbackParams, OAuthError>>(); 573 + let state = AppState { 574 + pending_auth: std::sync::Mutex::new(Some(PendingOAuthFlow { 575 + tx, 576 + pkce_verifier: "v".to_string(), 577 + csrf_state: "good-state".to_string(), 578 + })), 579 + oauth_session: std::sync::Mutex::new(None), 580 + }; 581 + 582 + // First callback succeeds. 583 + let url = make_test_url("code123", "good-state"); 584 + handle_deep_link(vec![url.clone()], &state); 585 + assert!(matches!(rx.try_recv(), Ok(Ok(_))), "first callback must deliver the code"); 586 + 587 + // Second callback (replay) — pending_auth is now None. 588 + handle_deep_link(vec![url], &state); // must not panic 589 + // pending_auth is still None. 590 + assert!(state.pending_auth.lock().unwrap().is_none(), "replay must not re-populate pending_auth"); 591 + } 592 + 593 + #[test] 594 + fn handle_deep_link_delivers_code_and_state() { 595 + // MM-149.AC2.1 596 + let (tx, rx) = tokio::sync::oneshot::channel::<Result<CallbackParams, OAuthError>>(); 597 + let state = AppState { 598 + pending_auth: std::sync::Mutex::new(Some(PendingOAuthFlow { 599 + tx, 600 + pkce_verifier: "v".to_string(), 601 + csrf_state: "expected-state".to_string(), 602 + })), 603 + oauth_session: std::sync::Mutex::new(None), 604 + }; 605 + 606 + let url = make_test_url("mycode", "expected-state"); 607 + handle_deep_link(vec![url], &state); 608 + 609 + let params = rx.try_recv() 610 + .expect("channel must not be empty") 611 + .expect("callback must succeed"); 612 + assert_eq!(params.code, "mycode"); 613 + assert_eq!(params.state, "expected-state"); 614 + } 615 + ``` 616 + 617 + **Step 2: Run the tests** 618 + 619 + ```bash 620 + cargo test -p identity-wallet handle_deep_link 621 + ``` 622 + 623 + Expected: 3 tests pass. 624 + 625 + **Step 3: Run all tests** 626 + 627 + ```bash 628 + cargo test -p identity-wallet 629 + ``` 630 + 631 + Expected: all tests pass. 632 + 633 + <!-- END_TASK_4 --> 634 + 635 + <!-- START_TASK_5 --> 636 + ### Task 5: Commit 637 + 638 + **Step 1: Commit all Phase 5 changes** 639 + 640 + ```bash 641 + git add apps/identity-wallet/src-tauri/Cargo.toml 642 + git add apps/identity-wallet/src-tauri/src/lib.rs 643 + git add apps/identity-wallet/src-tauri/src/http.rs 644 + git add apps/identity-wallet/src-tauri/src/oauth.rs 645 + git commit -m "feat(identity-wallet): start_oauth_flow command, deep-link handler, token exchange (MM-149 phase 5)" 646 + ``` 647 + 648 + <!-- END_TASK_5 --> 649 + 650 + <!-- END_SUBCOMPONENT_B -->
+649
docs/implementation-plans/2026-03-23-MM-149/phase_06.md
··· 1 + # MM-149 OAuth PKCE Client Implementation Plan 2 + 3 + **Goal:** Implement the authenticated HTTP client (`OAuthClient`) that wraps all requests with DPoP proofs, handles lazy token refresh, and retries once on `use_dpop_nonce`. 4 + 5 + **Architecture:** `OAuthClient` owns a `reqwest::Client`, a `DPoPKeypair`, a `base_url: String`, and an `Arc<Mutex<OAuthSession>>`. Before every request it calls `maybe_refresh_token()` — checks if the access token expires within 60 seconds (lazy, on-demand refresh), computes `ath = base64url(sha256(access_token))`, builds a fresh DPoP proof with nonce and ath, and attaches the `Authorization` + `DPoP` headers. On `use_dpop_nonce` 400, it updates `session.dpop_nonce`, rebuilds the proof, and retries once. Created as a separate file `oauth_client.rs`. 6 + 7 + > **Design clarification (lazy vs. background refresh):** The design DoD says "background token refresh before the 5-min TTL expires." AC6.1 is more precise: "When `expires_at < now + 60s`, a new token is fetched via refresh grant *before the next request proceeds*." The implementation satisfies AC6.1 with on-demand/lazy refresh (checked per-request in `maybe_refresh_token()`), which makes the refresh transparent to the caller — the spirit of "background" in the DoD. A proactive timer task is not implemented; the 60-second window ensures the relay's 5-minute token is always refreshed before it expires as long as the app is actively making requests. 8 + 9 + **Tech Stack:** `reqwest 0.12`, `p256 = "0.13"`, `sha2 = "0.10"`, `base64 = "0.21"`, `tokio = "1"` 10 + 11 + **Scope:** 7 phases from original design (phase 6 of 7) 12 + 13 + **Codebase verified:** 2026-03-23 14 + 15 + --- 16 + 17 + ## Acceptance Criteria Coverage 18 + 19 + This phase implements and tests: 20 + 21 + ### MM-149.AC4: Tokens stored securely and loaded on restart 22 + - **MM-149.AC4.2 Success:** On app restart with valid Keychain tokens, `AppState.oauth_session` is populated without re-running the OAuth flow (setup() logic — implemented here, exercised in Phase 7) 23 + 24 + ### MM-149.AC5: Authenticated requests carry DPoP proofs 25 + - **MM-149.AC5.1 Success:** Every `OAuthClient` request includes `Authorization: DPoP {token}` and a `DPoP` header with a fresh proof 26 + - **MM-149.AC5.2 Success:** `use_dpop_nonce` 400 from server triggers exactly one retry with the provided nonce; second consecutive failure returns an error 27 + - **MM-149.AC5.3 Failure:** Request after token is deliberately cleared returns an auth error, not a panic 28 + 29 + ### MM-149.AC6: Token refresh works transparently 30 + - **MM-149.AC6.1 Success:** When `expires_at < now + 60s`, a new token is fetched via refresh grant before the next request proceeds 31 + - **MM-149.AC6.2 Success:** Refresh grant POST includes a fresh DPoP proof without `ath` 32 + - **MM-149.AC6.3 Failure:** If refresh fails (e.g. relay returns `invalid_grant`), the error surfaces to the caller — no silent swallow 33 + 34 + --- 35 + 36 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 37 + 38 + <!-- START_TASK_1 --> 39 + ### Task 1: Create oauth_client.rs 40 + 41 + **Verifies:** MM-149.AC5.1, MM-149.AC5.2, MM-149.AC5.3, MM-149.AC6.1, MM-149.AC6.2, MM-149.AC6.3 42 + 43 + **Files:** 44 + - Create: `apps/identity-wallet/src-tauri/src/oauth_client.rs` 45 + 46 + Key design decisions: 47 + - `OAuthClient` stores a `DPoPKeypair` to avoid repeated Keychain lookups per request 48 + - `session: Arc<Mutex<OAuthSession>>` is mutable — refresh updates the session in place and persists to Keychain 49 + - `prepare_request()` is the central method: lazy refresh + ath + proof + headers 50 + - `execute_with_retry()` handles the `use_dpop_nonce` loop — retries exactly once 51 + - Token refresh requires `DPoP` proof WITHOUT `ath` (no access token in hand at that point) 52 + 53 + **Step 1: Create the file** 54 + 55 + ```rust 56 + // pattern: Imperative Shell 57 + // 58 + // Gathers: session state (access_token, refresh_token, expiry, nonce), request params 59 + // Processes: lazy refresh → DPoP proof → header attachment → nonce retry 60 + // Returns: reqwest::Response or OAuthError 61 + 62 + use std::sync::{Arc, Mutex}; 63 + 64 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 65 + use reqwest::{Client, Response}; 66 + use serde::Serialize; 67 + use sha2::{Digest, Sha256}; 68 + 69 + use crate::oauth::{DPoPKeypair, OAuthError, OAuthSession}; 70 + 71 + /// Authenticated HTTP client. 72 + /// 73 + /// Wraps every request with: 74 + /// - `Authorization: DPoP {access_token}` header 75 + /// - `DPoP: {proof}` header containing a fresh ES256 JWT with `ath` claim 76 + /// 77 + /// Transparently refreshes the access token when it has less than 60 seconds remaining. 78 + /// Retries once on `use_dpop_nonce` 400 responses. 79 + pub struct OAuthClient { 80 + inner: Client, 81 + dpop: DPoPKeypair, 82 + session: Arc<Mutex<OAuthSession>>, 83 + base_url: String, 84 + } 85 + 86 + impl OAuthClient { 87 + /// Construct from an existing session. 88 + /// 89 + /// Loads the DPoP keypair from Keychain (same key used in the original flow). 90 + /// 91 + /// `Client::new()` inherits the TLS backend configured at the crate level via Cargo features 92 + /// (`default-features = false, features = ["rustls-tls"]` in Cargo.toml). No builder 93 + /// configuration is needed — the feature flags apply crate-wide, not per-client-instance. 94 + pub fn new(session: Arc<Mutex<OAuthSession>>) -> Result<Self, OAuthError> { 95 + let dpop = DPoPKeypair::get_or_create()?; 96 + Ok(Self { 97 + inner: Client::new(), 98 + dpop, 99 + session, 100 + base_url: crate::http::RelayClient::base_url(), 101 + }) 102 + } 103 + 104 + /// GET `{base_url}/{path}` with DPoP authentication. 105 + pub async fn get(&self, path: &str) -> Result<Response, OAuthError> { 106 + let url = format!("{}/{}", self.base_url, path.trim_start_matches('/')); 107 + self.execute_with_retry(reqwest::Method::GET, &url, None::<&()>).await 108 + } 109 + 110 + /// POST `{base_url}/{path}` with JSON body and DPoP authentication. 111 + pub async fn post<B: Serialize + Sync>(&self, path: &str, body: &B) -> Result<Response, OAuthError> { 112 + let url = format!("{}/{}", self.base_url, path.trim_start_matches('/')); 113 + self.execute_with_retry(reqwest::Method::POST, &url, Some(body)).await 114 + } 115 + 116 + // ── Internal ────────────────────────────────────────────────────────────── 117 + 118 + /// Build and send a request with DPoP headers, retrying once on `use_dpop_nonce`. 119 + async fn execute_with_retry<B: Serialize + Sync>( 120 + &self, 121 + method: reqwest::Method, 122 + url: &str, 123 + body: Option<&B>, 124 + ) -> Result<Response, OAuthError> { 125 + // Lazy refresh before reading the access token. 126 + self.maybe_refresh_token().await?; 127 + 128 + let nonce_opt = { 129 + let s = self.session.lock().unwrap(); 130 + s.dpop_nonce.clone() 131 + }; 132 + 133 + let resp = self.send_with_dpop(&method, url, body, nonce_opt.as_deref()).await?; 134 + 135 + // On use_dpop_nonce, extract the server nonce, update session, retry once. 136 + if resp.status().as_u16() == 400 { 137 + // Peek at the error body to check for use_dpop_nonce. 138 + let maybe_nonce = resp.headers() 139 + .get("DPoP-Nonce") 140 + .and_then(|v| v.to_str().ok()) 141 + .map(str::to_string); 142 + 143 + if let Some(fresh_nonce) = maybe_nonce { 144 + { 145 + let mut s = self.session.lock().unwrap(); 146 + s.dpop_nonce = Some(fresh_nonce.clone()); 147 + } 148 + tracing::debug!(nonce = %fresh_nonce, "retrying request with server DPoP nonce"); 149 + // Do NOT re-check expiry on the retry — avoid double-refresh. 150 + return self.send_with_dpop(&method, url, body, Some(&fresh_nonce)).await; 151 + } 152 + } 153 + 154 + Ok(resp) 155 + } 156 + 157 + /// Send a single request with `Authorization: DPoP` and `DPoP: {proof}` headers. 158 + async fn send_with_dpop<B: Serialize + Sync>( 159 + &self, 160 + method: &reqwest::Method, 161 + url: &str, 162 + body: Option<&B>, 163 + nonce: Option<&str>, 164 + ) -> Result<Response, OAuthError> { 165 + let (access_token, ath) = { 166 + let s = self.session.lock().unwrap(); 167 + let ath = DPoPKeypair::compute_ath(&s.access_token); 168 + (s.access_token.clone(), ath) 169 + }; 170 + 171 + let proof = self.dpop.make_proof( 172 + method.as_str(), 173 + url, 174 + nonce, 175 + Some(&ath), 176 + )?; 177 + 178 + let mut builder = match method { 179 + m if *m == reqwest::Method::GET => self.inner.get(url), 180 + m if *m == reqwest::Method::POST => self.inner.post(url), 181 + _ => return Err(OAuthError::NotAuthenticated), 182 + }; 183 + 184 + builder = builder 185 + .header("Authorization", format!("DPoP {access_token}")) 186 + .header("DPoP", &proof); 187 + 188 + if let (Some(b), m) = (body, method) { 189 + if *m == reqwest::Method::POST { 190 + builder = builder.json(b); 191 + } 192 + } 193 + 194 + builder.send().await.map_err(|e| { 195 + tracing::error!(error = %e, "OAuthClient request network error"); 196 + OAuthError::NotAuthenticated 197 + }) 198 + } 199 + 200 + /// Refresh the access token if it expires within the next 60 seconds. 201 + async fn maybe_refresh_token(&self) -> Result<(), OAuthError> { 202 + let should_refresh = { 203 + let s = self.session.lock().unwrap(); 204 + let now = std::time::SystemTime::now() 205 + .duration_since(std::time::UNIX_EPOCH) 206 + .unwrap_or_default() 207 + .as_secs(); 208 + s.expires_at < now + 60 209 + }; 210 + 211 + if should_refresh { 212 + self.refresh_token().await?; 213 + } 214 + Ok(()) 215 + } 216 + 217 + /// POST `/oauth/token` with `grant_type=refresh_token` — no `ath` claim in DPoP proof. 218 + /// 219 + /// Updates `self.session` with the new tokens and persists to Keychain. 220 + /// Surfaces all errors to the caller — no silent swallowing (MM-149.AC6.3). 221 + pub async fn refresh_token(&self) -> Result<(), OAuthError> { 222 + let (refresh_token, nonce_opt) = { 223 + let s = self.session.lock().unwrap(); 224 + (s.refresh_token.clone(), s.dpop_nonce.clone()) 225 + }; 226 + 227 + let token_htu = format!("{}/oauth/token", self.base_url); 228 + let proof = self.dpop.make_proof("POST", &token_htu, nonce_opt.as_deref(), None)?; 229 + 230 + let resp = self.inner 231 + .post(&token_htu) 232 + .header("DPoP", &proof) 233 + .form(&[ 234 + ("grant_type", "refresh_token"), 235 + ("refresh_token", refresh_token.as_str()), 236 + ("client_id", "dev.malpercio.identitywallet"), 237 + ]) 238 + .send() 239 + .await 240 + .map_err(|e| { 241 + tracing::error!(error = %e, "token refresh network error"); 242 + OAuthError::TokenRefreshFailed 243 + })?; 244 + 245 + // On use_dpop_nonce from the refresh endpoint, retry once with the nonce. 246 + if resp.status().as_u16() == 400 { 247 + let retry_nonce = resp.headers() 248 + .get("DPoP-Nonce") 249 + .and_then(|v| v.to_str().ok()) 250 + .map(str::to_string); 251 + 252 + if let Some(nonce_val) = retry_nonce { 253 + let proof2 = self.dpop.make_proof("POST", &token_htu, Some(&nonce_val), None)?; 254 + let resp2 = self.inner 255 + .post(&token_htu) 256 + .header("DPoP", &proof2) 257 + .form(&[ 258 + ("grant_type", "refresh_token"), 259 + ("refresh_token", refresh_token.as_str()), 260 + ("client_id", "dev.malpercio.identitywallet"), 261 + ]) 262 + .send() 263 + .await 264 + .map_err(|_| OAuthError::TokenRefreshFailed)?; 265 + 266 + if resp2.status().as_u16() == 200 { 267 + return self.apply_token_response(resp2).await; 268 + } 269 + let body = resp2.text().await.unwrap_or_default(); 270 + tracing::error!(body = %body, "token refresh failed after nonce retry"); 271 + return Err(OAuthError::TokenRefreshFailed); 272 + } 273 + let body = resp.text().await.unwrap_or_default(); 274 + tracing::error!(body = %body, "token refresh 400 without nonce header"); 275 + return Err(OAuthError::TokenRefreshFailed); 276 + } 277 + 278 + if resp.status().as_u16() != 200 { 279 + let body = resp.text().await.unwrap_or_default(); 280 + tracing::error!(body = %body, "token refresh failed"); 281 + return Err(OAuthError::TokenRefreshFailed); 282 + } 283 + 284 + self.apply_token_response(resp).await 285 + } 286 + 287 + /// Construct with a custom base URL and pre-built keypair (test use only). 288 + #[cfg(test)] 289 + pub fn new_for_test( 290 + keypair: DPoPKeypair, 291 + session: Arc<Mutex<OAuthSession>>, 292 + base_url: String, 293 + ) -> Self { 294 + Self { 295 + inner: Client::new(), 296 + dpop: keypair, 297 + session, 298 + base_url, 299 + } 300 + } 301 + 302 + /// Deserialize a 200 token response and update session + Keychain. 303 + async fn apply_token_response(&self, resp: Response) -> Result<(), OAuthError> { 304 + // Capture the DPoP-Nonce header before consuming the response body. 305 + let new_nonce = resp.headers() 306 + .get("DPoP-Nonce") 307 + .and_then(|v| v.to_str().ok()) 308 + .map(str::to_string); 309 + 310 + let token_resp: crate::http::TokenResponse = resp.json().await.map_err(|e| { 311 + tracing::error!(error = %e, "token refresh response deserialization failed"); 312 + OAuthError::TokenRefreshFailed 313 + })?; 314 + 315 + let expires_at = std::time::SystemTime::now() 316 + .duration_since(std::time::UNIX_EPOCH) 317 + .unwrap_or_default() 318 + .as_secs() 319 + + token_resp.expires_in; 320 + 321 + crate::keychain::store_oauth_tokens(&token_resp.access_token, &token_resp.refresh_token) 322 + .map_err(|_| OAuthError::KeychainError)?; 323 + 324 + let mut s = self.session.lock().unwrap(); 325 + s.access_token = token_resp.access_token; 326 + s.refresh_token = token_resp.refresh_token; 327 + s.expires_at = expires_at; 328 + s.dpop_nonce = new_nonce; 329 + 330 + tracing::info!("access token refreshed"); 331 + Ok(()) 332 + } 333 + } 334 + ``` 335 + 336 + **Step 2: Add `pub mod oauth_client;` to lib.rs** 337 + 338 + Find the module declarations at the top of `apps/identity-wallet/src-tauri/src/lib.rs` (lines 1-4) and add: 339 + 340 + ```rust 341 + pub mod oauth_client; 342 + ``` 343 + 344 + So the module list reads: 345 + ```rust 346 + pub mod device_key; 347 + pub mod http; 348 + pub mod keychain; 349 + pub mod oauth; 350 + pub mod oauth_client; 351 + ``` 352 + 353 + **Step 3: Build to verify** 354 + 355 + ```bash 356 + cargo build -p identity-wallet 357 + ``` 358 + 359 + Expected: builds without errors. If there are Serialize/Sync bound issues with the `body: Option<&B>` generic, simplify by removing the generic and accepting `Option<&serde_json::Value>` instead, or split `get()` and `post()` implementations without sharing `execute_with_retry()`. 360 + 361 + <!-- END_TASK_1 --> 362 + 363 + <!-- START_TASK_2 --> 364 + ### Task 2: Write tests for OAuthClient 365 + 366 + **Verifies:** MM-149.AC5.1 (DPoP headers on requests), MM-149.AC5.2 (nonce retry, exactly 2 requests), MM-149.AC5.3 (cleared session, no panic), MM-149.AC6.1 (lazy refresh fires when near expiry), MM-149.AC6.2 (refresh DPoP proof has no ath), MM-149.AC6.3 (invalid_grant returns error) 367 + 368 + **Files:** 369 + - Modify: `apps/identity-wallet/src-tauri/src/oauth_client.rs` (add `#[cfg(test)]` module) 370 + 371 + These tests use `httpmock` to capture outgoing headers and verify behavior without a live relay. They call `OAuthClient::new_for_test(keypair, session, server.base_url())` which was added in Task 1. The DPoP keypair is created via `DPoPKeypair::get_or_create()` — in `#[cfg(test)]` builds, `keychain.rs` redirects all Keychain operations to an in-memory test store, so no real macOS Keychain access occurs during tests. 372 + 373 + **Step 1: Add httpmock dev-dependency** 374 + 375 + In `apps/identity-wallet/src-tauri/Cargo.toml`, add: 376 + 377 + ```toml 378 + [dev-dependencies] 379 + httpmock = "0.7" 380 + ``` 381 + 382 + **Step 2: Add test helper function (token response body)** 383 + 384 + The mock token endpoint needs to return a valid token response JSON. Define this once in the test module for reuse: 385 + 386 + ```rust 387 + fn token_response_body() -> serde_json::Value { 388 + let expires_at = std::time::SystemTime::now() 389 + .duration_since(std::time::UNIX_EPOCH) 390 + .unwrap() 391 + .as_secs() + 300; 392 + serde_json::json!({ 393 + "access_token": "new_access_token", 394 + "token_type": "DPoP", 395 + "expires_in": 300, 396 + "refresh_token": "new_refresh_token", 397 + "scope": "atproto", 398 + "expires_at": expires_at 399 + }) 400 + } 401 + ``` 402 + 403 + **Step 3: Add test module to oauth_client.rs** 404 + 405 + ```rust 406 + #[cfg(test)] 407 + mod tests { 408 + use super::*; 409 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 410 + use httpmock::prelude::*; 411 + 412 + fn make_session(access: &str, refresh: &str, expires_in_secs: u64) -> Arc<Mutex<OAuthSession>> { 413 + let now = std::time::SystemTime::now() 414 + .duration_since(std::time::UNIX_EPOCH) 415 + .unwrap() 416 + .as_secs(); 417 + Arc::new(Mutex::new(OAuthSession { 418 + access_token: access.to_string(), 419 + refresh_token: refresh.to_string(), 420 + expires_at: now + expires_in_secs, 421 + dpop_nonce: None, 422 + })) 423 + } 424 + 425 + fn token_response_body() -> serde_json::Value { 426 + serde_json::json!({ 427 + "access_token": "new_access_token", 428 + "token_type": "DPoP", 429 + "expires_in": 300, 430 + "refresh_token": "new_refresh_token", 431 + "scope": "atproto" 432 + }) 433 + } 434 + 435 + #[tokio::test] 436 + async fn dpop_and_authorization_headers_present_on_get() { 437 + // MM-149.AC5.1: Every request carries Authorization: DPoP {token} and DPoP: {proof} 438 + let server = MockServer::start(); 439 + let mock = server.mock(|when, then| { 440 + when.method(GET).path("/resource"); 441 + then.status(200).body("ok"); 442 + }); 443 + 444 + let keypair = DPoPKeypair::get_or_create().expect("keypair must exist"); 445 + let session = make_session("my_access_token", "my_refresh_token", 300); 446 + let client = OAuthClient::new_for_test(keypair, session, server.base_url()); 447 + 448 + let resp = client.get("/resource").await.expect("GET must succeed"); 449 + assert_eq!(resp.status().as_u16(), 200); 450 + 451 + // Verify the mock server received the expected headers. 452 + let request = mock.calls()[0].request.clone(); 453 + let auth = request.headers.get("authorization").expect("Authorization header must be present"); 454 + assert!(auth.starts_with("DPoP "), "Authorization must use DPoP scheme, got: {auth}"); 455 + assert_eq!(&auth[5..], "my_access_token", "Authorization must include the access token"); 456 + 457 + let dpop = request.headers.get("dpop").expect("DPoP header must be present"); 458 + let parts: Vec<&str> = dpop.splitn(3, '.').collect(); 459 + assert_eq!(parts.len(), 3, "DPoP proof must be a three-part JWT, got: {dpop}"); 460 + } 461 + 462 + #[tokio::test] 463 + async fn nonce_retry_sends_exactly_two_requests() { 464 + // MM-149.AC5.2: use_dpop_nonce 400 triggers one retry; second success returns response 465 + let server = MockServer::start(); 466 + 467 + // First request: 400 with DPoP-Nonce header 468 + let mock1 = server.mock(|when, then| { 469 + when.method(GET).path("/resource"); 470 + then.status(400) 471 + .header("DPoP-Nonce", "test-server-nonce"); 472 + }); 473 + 474 + // Second request (retry with nonce): 200 success 475 + let mock2 = server.mock(|when, then| { 476 + when.method(GET).path("/resource"); 477 + then.status(200).body("ok"); 478 + }); 479 + 480 + let keypair = DPoPKeypair::get_or_create().expect("keypair must exist"); 481 + let session = make_session("my_access_token", "my_refresh_token", 300); 482 + let client = OAuthClient::new_for_test(keypair, session, server.base_url()); 483 + 484 + let resp = client.get("/resource").await.expect("GET must succeed after retry"); 485 + assert_eq!(resp.status().as_u16(), 200); 486 + 487 + // Verify exactly 2 requests hit the server. 488 + assert_eq!(mock1.calls().len(), 1, "first request must hit once"); 489 + assert_eq!(mock2.calls().len(), 1, "retry request must hit once"); 490 + 491 + // Verify the retry carried the nonce in the DPoP proof. 492 + let retry_dpop = mock2.calls()[0].request.headers.get("dpop") 493 + .expect("retry must have DPoP header"); 494 + let (_, claims_b64, _) = { 495 + let parts: Vec<&str> = retry_dpop.splitn(3, '.').collect(); 496 + (parts[0], parts[1], parts[2]) 497 + }; 498 + let claims_bytes = URL_SAFE_NO_PAD.decode(claims_b64).expect("valid base64url"); 499 + let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).expect("valid JSON"); 500 + assert_eq!( 501 + claims["nonce"].as_str(), 502 + Some("test-server-nonce"), 503 + "retry DPoP proof must carry the server nonce" 504 + ); 505 + } 506 + 507 + #[tokio::test] 508 + async fn empty_access_token_does_not_panic() { 509 + // MM-149.AC5.3: Cleared session (empty access_token) must not panic 510 + let server = MockServer::start(); 511 + server.mock(|when, then| { 512 + when.method(GET).path("/resource"); 513 + then.status(401); 514 + }); 515 + 516 + let keypair = DPoPKeypair::get_or_create().expect("keypair must exist"); 517 + let session = make_session("", "my_refresh_token", 300); 518 + let client = OAuthClient::new_for_test(keypair, session, server.base_url()); 519 + 520 + // Should return a response (401) without panicking — the auth error comes from the server. 521 + let resp = client.get("/resource").await.expect("must not panic"); 522 + assert_eq!(resp.status().as_u16(), 401, "empty token produces a server-side auth error"); 523 + } 524 + 525 + #[tokio::test] 526 + async fn lazy_refresh_fires_when_expiry_near() { 527 + // MM-149.AC6.1: expires_at < now + 60 triggers refresh before the request 528 + let server = MockServer::start(); 529 + 530 + // Refresh endpoint returns new tokens. 531 + let refresh_mock = server.mock(|when, then| { 532 + when.method(POST).path("/oauth/token"); 533 + then.status(200).json_body(token_response_body()); 534 + }); 535 + 536 + // Resource endpoint (called after refresh). 537 + let resource_mock = server.mock(|when, then| { 538 + when.method(GET).path("/resource"); 539 + then.status(200).body("ok"); 540 + }); 541 + 542 + let keypair = DPoPKeypair::get_or_create().expect("keypair must exist"); 543 + // Token expires in 30 seconds — below the 60-second refresh threshold. 544 + let session = make_session("old_access_token", "my_refresh_token", 30); 545 + let client = OAuthClient::new_for_test(keypair, session.clone(), server.base_url()); 546 + 547 + client.get("/resource").await.expect("request must succeed"); 548 + 549 + // Verify refresh was called before the resource request. 550 + assert_eq!(refresh_mock.calls().len(), 1, "refresh must be called once"); 551 + assert_eq!(resource_mock.calls().len(), 1, "resource must be called once"); 552 + 553 + // Verify session was updated with the new token. 554 + let updated = session.lock().unwrap(); 555 + assert_eq!(updated.access_token, "new_access_token", "session must have new token"); 556 + } 557 + 558 + #[tokio::test] 559 + async fn refresh_dpop_proof_has_no_ath_claim() { 560 + // MM-149.AC6.2: Refresh grant DPoP proof must not include ath (no access token in hand) 561 + let server = MockServer::start(); 562 + 563 + let refresh_mock = server.mock(|when, then| { 564 + when.method(POST).path("/oauth/token"); 565 + then.status(200).json_body(token_response_body()); 566 + }); 567 + 568 + let keypair = DPoPKeypair::get_or_create().expect("keypair must exist"); 569 + // Session near expiry to trigger refresh. 570 + let session = make_session("old_token", "my_refresh_token", 30); 571 + let client = OAuthClient::new_for_test(keypair, session, server.base_url()); 572 + 573 + client.refresh_token().await.expect("refresh must succeed"); 574 + 575 + let request = refresh_mock.calls()[0].request.clone(); 576 + let dpop_header = request.headers.get("dpop").expect("DPoP header must be present"); 577 + 578 + // Decode the DPoP claims and verify no ath field. 579 + let parts: Vec<&str> = dpop_header.splitn(3, '.').collect(); 580 + let claims_bytes = URL_SAFE_NO_PAD.decode(parts[1]).expect("valid base64url claims"); 581 + let claims: serde_json::Value = serde_json::from_slice(&claims_bytes).expect("valid JSON"); 582 + assert!( 583 + claims["ath"].is_null(), 584 + "refresh DPoP proof must not include ath, got: {:?}", 585 + claims["ath"] 586 + ); 587 + } 588 + 589 + #[tokio::test] 590 + async fn refresh_invalid_grant_returns_token_refresh_failed() { 591 + // MM-149.AC6.3: Relay returns invalid_grant → Err(TokenRefreshFailed), not silent swallow 592 + let server = MockServer::start(); 593 + 594 + server.mock(|when, then| { 595 + when.method(POST).path("/oauth/token"); 596 + then.status(400) 597 + .json_body(serde_json::json!({ 598 + "error": "invalid_grant", 599 + "error_description": "refresh token expired" 600 + })); 601 + }); 602 + 603 + let keypair = DPoPKeypair::get_or_create().expect("keypair must exist"); 604 + let session = make_session("my_token", "my_refresh_token", 30); 605 + let client = OAuthClient::new_for_test(keypair, session, server.base_url()); 606 + 607 + let result = client.refresh_token().await; 608 + assert!( 609 + matches!(result, Err(OAuthError::TokenRefreshFailed)), 610 + "invalid_grant must surface as TokenRefreshFailed, got: {:?}", 611 + result 612 + ); 613 + } 614 + } 615 + ``` 616 + 617 + **Step 4: Run the tests** 618 + 619 + ```bash 620 + cargo test -p identity-wallet oauth_client 621 + ``` 622 + 623 + Expected: all 6 tests pass. 624 + 625 + **Step 5: Run all tests** 626 + 627 + ```bash 628 + cargo test -p identity-wallet 629 + ``` 630 + 631 + Expected: all tests pass. 632 + 633 + <!-- END_TASK_2 --> 634 + 635 + <!-- START_TASK_3 --> 636 + ### Task 3: Commit 637 + 638 + **Step 1: Commit Phase 6 changes** 639 + 640 + ```bash 641 + git add apps/identity-wallet/src-tauri/Cargo.toml 642 + git add apps/identity-wallet/src-tauri/src/lib.rs 643 + git add apps/identity-wallet/src-tauri/src/oauth_client.rs 644 + git commit -m "feat(identity-wallet): OAuthClient with DPoP proofs, lazy refresh, nonce retry (MM-149 phase 6)" 645 + ``` 646 + 647 + <!-- END_TASK_3 --> 648 + 649 + <!-- END_SUBCOMPONENT_A -->
+509
docs/implementation-plans/2026-03-23-MM-149/phase_07.md
··· 1 + # MM-149 OAuth PKCE Client Implementation Plan 2 + 3 + **Goal:** Add post-onboarding authentication screens and startup token restoration so the app auto-advances to OAuth after DID ceremony and skips onboarding on relaunch. 4 + 5 + **Architecture:** SvelteKit extends the 10-step onboarding state machine (Svelte 5 runes) with three new steps: `authenticating` (auto-invokes `start_oauth_flow` via `onMount`), `authenticated` (success), and `auth_failed` (retry/reset). The Rust `setup()` closure loads tokens from Keychain on startup and, if found, emits an `"auth_ready"` event to skip onboarding. A short async delay (300 ms) before emitting lets the webview JS listener register first. 6 + 7 + **Tech Stack:** Svelte 5 (runes), SvelteKit 2, Tauri v2 (`tauri::Emitter` trait, `tauri::async_runtime::spawn`), `@tauri-apps/api/event` `listen()` 8 + 9 + **Scope:** 7 phases from original design (phase 7 of 7) 10 + 11 + **Codebase verified:** 2026-03-24 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-149.AC7: Frontend authentication screens 20 + - **MM-149.AC7.1 Success:** After onboarding step 10 completes, app auto-advances to `authenticating` step and calls `start_oauth_flow` 21 + - **MM-149.AC7.2 Success:** On `start_oauth_flow` resolution, app transitions to `authenticated` state 22 + - **MM-149.AC7.3 Success:** On app relaunch with stored tokens, app skips onboarding and shows `authenticated` state directly 23 + - **MM-149.AC7.4 Failure:** `start_oauth_flow` error transitions app to `auth_failed` step 24 + 25 + ### MM-149.AC8: Failed auth recovery 26 + - **MM-149.AC8.1 Success:** "Try again" button on `auth_failed` re-invokes `start_oauth_flow` cleanly (no stale state) 27 + - **MM-149.AC8.2 Success:** "Start over" button resets to step 1 of onboarding 28 + 29 + --- 30 + 31 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 32 + 33 + <!-- START_TASK_1 --> 34 + ### Task 1: Add OAuthError type and startOAuthFlow to ipc.ts 35 + 36 + **Verifies:** None (IPC infrastructure for AC7 and AC8) 37 + 38 + **Files:** 39 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` 40 + 41 + **Step 1: Read the current ipc.ts** 42 + 43 + Open `apps/identity-wallet/src/lib/ipc.ts`. Note: 44 + - Line 1: `import { invoke } from '@tauri-apps/api/core';` 45 + - The existing export pattern: `export const createAccount = (params): Promise<Result> => invoke('command_name', params);` 46 + - Existing error type union pattern: each variant is `{ code: 'SCREAMING_SNAKE_CASE' }` 47 + 48 + **Step 2: Add OAuthError type and startOAuthFlow** 49 + 50 + Append to the bottom of `apps/identity-wallet/src/lib/ipc.ts`: 51 + 52 + ```typescript 53 + // ── OAuth ───────────────────────────────────────────────────────────────────── 54 + // 55 + // These variants must exactly match the Rust `OAuthError` enum in oauth.rs. 56 + // Rust serializes them as `{ "code": "SCREAMING_SNAKE_CASE" }` via: 57 + // #[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "code")] 58 + 59 + export type OAuthError = 60 + | { code: 'DPOP_KEY_GEN_FAILED' } 61 + | { code: 'DPOP_KEY_INVALID' } 62 + | { code: 'DPOP_PROOF_FAILED' } 63 + | { code: 'KEYCHAIN_ERROR' } 64 + | { code: 'STATE_MISMATCH' } 65 + | { code: 'CALLBACK_ABANDONED' } 66 + | { code: 'PAR_FAILED' } 67 + | { code: 'TOKEN_EXCHANGE_FAILED' } 68 + | { code: 'TOKEN_REFRESH_FAILED' } 69 + | { code: 'NOT_AUTHENTICATED' }; 70 + 71 + export const startOAuthFlow = (): Promise<void> => invoke('start_oauth_flow'); 72 + ``` 73 + 74 + **Step 3: TypeScript check** 75 + 76 + ```bash 77 + cd apps/identity-wallet && pnpm check 78 + ``` 79 + 80 + Expected: passes without errors. 81 + 82 + <!-- END_TASK_1 --> 83 + 84 + <!-- START_TASK_2 --> 85 + ### Task 2: Update lib.rs setup() to restore tokens and emit auth_ready 86 + 87 + **Verifies:** MM-149.AC7.3 (app relaunch with stored tokens → authenticated state directly) 88 + 89 + **Files:** 90 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` 91 + 92 + **Step 1: Add Emitter to the existing use import** 93 + 94 + Find the existing `use tauri::Manager;` import and add `Emitter`: 95 + 96 + Before: 97 + ```rust 98 + use tauri::Manager; 99 + ``` 100 + 101 + After: 102 + ```rust 103 + use tauri::{Emitter, Manager}; 104 + ``` 105 + 106 + **Step 2: Read the existing setup() closure** 107 + 108 + The existing `setup(|app| { ... })` from Phase 2 registers the deep-link `on_open_url` handler. 109 + It ends with `Ok(())`. The new code goes between the `on_open_url` block and `Ok(())`. 110 + 111 + **Step 3: Add Keychain restore + deferred auth_ready emission** 112 + 113 + After the `app.deep_link().on_open_url(...)` block and before `Ok(())`, add: 114 + 115 + ```rust 116 + // On relaunch: restore persisted session from Keychain and notify frontend. 117 + // The 300 ms delay lets the SvelteKit app boot and register its event listener 118 + // before the event fires — emitting synchronously here would be dropped. 119 + if let Some((access, refresh)) = keychain::load_oauth_tokens() { 120 + { 121 + let state = app.state::<oauth::AppState>(); 122 + *state.oauth_session.lock().unwrap() = Some(oauth::OAuthSession { 123 + access_token: access, 124 + refresh_token: refresh, 125 + // expires_at = 0 ensures OAuthClient refreshes immediately on first use. 126 + expires_at: 0, 127 + dpop_nonce: None, 128 + }); 129 + } 130 + let handle = app.handle().clone(); 131 + tauri::async_runtime::spawn(async move { 132 + tokio::time::sleep(std::time::Duration::from_millis(300)).await; 133 + handle.emit("auth_ready", ()).ok(); 134 + }); 135 + } 136 + ``` 137 + 138 + Note: `keychain::load_oauth_tokens()` was added in Phase 3. `oauth::OAuthSession` with `expires_at: u64` and `dpop_nonce: Option<String>` fields was updated in Phase 5. 139 + 140 + **Step 4: Build to verify** 141 + 142 + ```bash 143 + cargo build -p identity-wallet 144 + ``` 145 + 146 + Expected: builds without errors. 147 + 148 + <!-- END_TASK_2 --> 149 + 150 + <!-- END_SUBCOMPONENT_A --> 151 + 152 + <!-- START_SUBCOMPONENT_B (tasks 3-4) --> 153 + 154 + <!-- START_TASK_3 --> 155 + ### Task 3: Create AuthenticatingScreen.svelte 156 + 157 + **Verifies:** MM-149.AC7.1 (authenticating step auto-invokes start_oauth_flow on mount) 158 + 159 + **Files:** 160 + - Create: `apps/identity-wallet/src/lib/components/onboarding/AuthenticatingScreen.svelte` 161 + 162 + **Step 1: Review the existing DIDCeremonyScreen.svelte pattern** 163 + 164 + Open `apps/identity-wallet/src/lib/components/onboarding/DIDCeremonyScreen.svelte` and note: 165 + - `$props()` for typed prop declarations (Svelte 5 runes) 166 + - `onMount(() => runCeremony())` for auto-triggering the async operation on mount 167 + - Callback props `onsuccess` / `onfailure` called from inside the async function 168 + - `try/catch` with the error cast to the expected error type 169 + 170 + `AuthenticatingScreen` follows this same pattern. 171 + 172 + **Step 2: Create AuthenticatingScreen.svelte** 173 + 174 + ```svelte 175 + <script lang="ts"> 176 + import { onMount } from 'svelte'; 177 + import { startOAuthFlow, type OAuthError } from '$lib/ipc'; 178 + 179 + let { 180 + onresolved, 181 + onfailed, 182 + }: { 183 + onresolved: () => void; 184 + onfailed: (err: OAuthError) => void; 185 + } = $props(); 186 + 187 + async function authenticate() { 188 + try { 189 + await startOAuthFlow(); 190 + onresolved(); 191 + } catch (raw) { 192 + onfailed(raw as OAuthError); 193 + } 194 + } 195 + 196 + onMount(() => { 197 + authenticate(); 198 + }); 199 + </script> 200 + 201 + <div class="screen"> 202 + <div class="spinner" aria-label="Loading"></div> 203 + <p class="status">Opening browser for authentication…</p> 204 + </div> 205 + 206 + <style> 207 + .screen { 208 + display: flex; 209 + flex-direction: column; 210 + align-items: center; 211 + justify-content: center; 212 + height: 100%; 213 + gap: 24px; 214 + padding: 32px; 215 + } 216 + 217 + .spinner { 218 + width: 40px; 219 + height: 40px; 220 + border: 4px solid #e5e7eb; 221 + border-top-color: #007aff; 222 + border-radius: 50%; 223 + animation: spin 0.8s linear infinite; 224 + } 225 + 226 + @keyframes spin { 227 + to { transform: rotate(360deg); } 228 + } 229 + 230 + .status { 231 + text-align: center; 232 + color: #6b7280; 233 + font-size: 1rem; 234 + } 235 + </style> 236 + ``` 237 + 238 + **Step 3: TypeScript check** 239 + 240 + ```bash 241 + cd apps/identity-wallet && pnpm check 242 + ``` 243 + 244 + Expected: passes without errors. 245 + 246 + <!-- END_TASK_3 --> 247 + 248 + <!-- START_TASK_4 --> 249 + ### Task 4: Update +page.svelte with OAuth steps 250 + 251 + **Verifies:** MM-149.AC7.1, MM-149.AC7.2, MM-149.AC7.3, MM-149.AC7.4, MM-149.AC8.1, MM-149.AC8.2 252 + 253 + **Files:** 254 + - Modify: `apps/identity-wallet/src/routes/+page.svelte` 255 + 256 + **Step 1: Read the full +page.svelte** 257 + 258 + Open `apps/identity-wallet/src/routes/+page.svelte`. Locate: 259 + - Lines 23-33: `OnboardingStep` union type (10 string literals, last is `'complete'`) 260 + - Lines 37-38: `let step = $state<OnboardingStep>('welcome')` and `let form = $state(...)` 261 + - Lines 44+: `let errors = $state(...)` 262 + - Lines 50+: `goTo(next: OnboardingStep)` function 263 + - Lines 123-173 (approx): The `{#if step === 'welcome'}...{:else if step === 'complete'}...{/if}` conditional chain 264 + - The `{:else if step === 'complete'}` block — note whether it has a button with an onclick or ends automatically 265 + 266 + **Step 2: Add imports** 267 + 268 + At the top of the `<script>` block, add these imports (after the existing ones — do not duplicate `onMount` if it already exists): 269 + 270 + ```typescript 271 + import { listen } from '@tauri-apps/api/event'; 272 + import { onMount } from 'svelte'; 273 + import AuthenticatingScreen from '$lib/components/onboarding/AuthenticatingScreen.svelte'; 274 + import type { OAuthError } from '$lib/ipc'; 275 + ``` 276 + 277 + **Step 3: Extend the OnboardingStep type** 278 + 279 + Find the `type OnboardingStep = ...` declaration. Add three new variants at the end: 280 + 281 + ```typescript 282 + type OnboardingStep = 283 + | 'welcome' 284 + | 'claim_code' 285 + | 'email' 286 + | 'handle' 287 + | 'password' 288 + | 'loading' 289 + | 'did_ceremony' 290 + | 'did_success' 291 + | 'shamir_backup' 292 + | 'complete' 293 + | 'authenticating' 294 + | 'authenticated' 295 + | 'auth_failed'; 296 + ``` 297 + 298 + **Step 4: Add authError state variable** 299 + 300 + After the `let errors = $state(...)` declaration, add: 301 + 302 + ```typescript 303 + let authError = $state<OAuthError | null>(null); 304 + ``` 305 + 306 + **Step 5: Add auth_ready event listener in onMount** 307 + 308 + If `onMount` is not yet present in the file, add it after the state declarations. If it already exists (e.g., from Phase 2 deep-link testing), add the listener call inside the existing `onMount` block: 309 + 310 + ```typescript 311 + onMount(() => { 312 + listen('auth_ready', () => { 313 + goTo('authenticated'); 314 + }); 315 + // Note: We intentionally don't await listen() or return a cleanup function here. 316 + // Svelte 5's onMount does not await async cleanup return values (it would receive a 317 + // Promise, not the unlisten function). Since +page.svelte is the root page and never 318 + // unmounts during the app lifecycle, the listener persists for the app's lifetime, 319 + // which is the correct behavior. 320 + }); 321 + ``` 322 + 323 + **Step 6: Update the complete step to transition to authenticating** 324 + 325 + Find the `{:else if step === 'complete'}` rendering block. Inside it, locate the button or element that signals the user is done (e.g., a "Finish" or "Done" button). Change its onclick handler to call `goTo('authenticating')`. 326 + 327 + If no such button exists and the `complete` step is a static terminal screen, add a "Continue" button using the same CSS classes as other action buttons in the file (look at the `welcome` or `password` step buttons for the class pattern): 328 + 329 + ```svelte 330 + <button onclick={() => goTo('authenticating')}> 331 + Continue 332 + </button> 333 + ``` 334 + 335 + **Step 7: Add rendering blocks for the three OAuth steps** 336 + 337 + After the `{:else if step === 'complete'}` block, immediately before the closing `{/if}`, add: 338 + 339 + ```svelte 340 + {:else if step === 'authenticating'} 341 + <AuthenticatingScreen 342 + onresolved={() => goTo('authenticated')} 343 + onfailed={(err) => { 344 + authError = err; 345 + goTo('auth_failed'); 346 + }} 347 + /> 348 + 349 + {:else if step === 'authenticated'} 350 + <div class="oauth-screen"> 351 + <div class="oauth-icon" aria-hidden="true">✓</div> 352 + <h2 class="oauth-title">Authenticated</h2> 353 + <p class="oauth-body">Your identity wallet is ready.</p> 354 + </div> 355 + 356 + {:else if step === 'auth_failed'} 357 + <div class="oauth-screen"> 358 + <div class="oauth-icon" aria-hidden="true">✗</div> 359 + <h2 class="oauth-title">Authentication Failed</h2> 360 + {#if authError} 361 + <p class="oauth-error-code">{authError.code}</p> 362 + {/if} 363 + <div class="oauth-actions"> 364 + <button 365 + class="cta" 366 + onclick={() => { 367 + authError = null; 368 + goTo('authenticating'); 369 + }} 370 + > 371 + Try again 372 + </button> 373 + <button 374 + class="cta cta--secondary" 375 + onclick={() => { 376 + authError = null; 377 + goTo('welcome'); 378 + }} 379 + > 380 + Start over 381 + </button> 382 + </div> 383 + </div> 384 + ``` 385 + 386 + Add these CSS rules to the existing `<style>` block at the bottom of `+page.svelte` (alongside the existing `.screen`, `.cta`, etc. rules already present): 387 + 388 + ```css 389 + .oauth-screen { 390 + display: flex; 391 + flex-direction: column; 392 + align-items: center; 393 + justify-content: center; 394 + height: 100%; 395 + gap: 24px; 396 + padding: 32px; 397 + text-align: center; 398 + } 399 + 400 + .oauth-icon { 401 + font-size: 3rem; 402 + } 403 + 404 + .oauth-title { 405 + font-size: 1.5rem; 406 + font-weight: 700; 407 + color: #111827; 408 + margin: 0; 409 + } 410 + 411 + .oauth-body { 412 + color: #6b7280; 413 + font-size: 1rem; 414 + margin: 0; 415 + } 416 + 417 + .oauth-error-code { 418 + font-family: monospace; 419 + font-size: 0.875rem; 420 + color: #6b7280; 421 + margin: 0; 422 + } 423 + 424 + .oauth-actions { 425 + display: flex; 426 + flex-direction: column; 427 + gap: 12px; 428 + width: 100%; 429 + } 430 + 431 + .cta--secondary { 432 + background: #f3f4f6; 433 + color: #374151; 434 + } 435 + ``` 436 + 437 + **Step 8: TypeScript check** 438 + 439 + ```bash 440 + cd apps/identity-wallet && pnpm check 441 + ``` 442 + 443 + Expected: passes without errors. 444 + 445 + **Step 9: Build** 446 + 447 + ```bash 448 + cargo build -p identity-wallet 449 + ``` 450 + 451 + Expected: builds without errors. 452 + 453 + <!-- END_TASK_4 --> 454 + 455 + <!-- END_SUBCOMPONENT_B --> 456 + 457 + <!-- START_TASK_5 --> 458 + ### Task 5: Operational verification and commit 459 + 460 + **Verifies:** MM-149.AC7.1–4, MM-149.AC8.1–2 (manual test against running relay) 461 + 462 + **Step 1: Fresh install test (AC7.1, AC7.2)** 463 + 464 + Start the relay, then launch the app in the iOS Simulator: 465 + 466 + ```bash 467 + cd apps/identity-wallet && cargo tauri ios dev 468 + ``` 469 + 470 + Complete the 10-step onboarding flow through DID ceremony and Shamir backup. On the `complete` step, tap "Continue". Verify: 471 + - App transitions to `authenticating` (spinner visible, browser opens) 472 + - After authenticating in Safari, app returns to `authenticated` state 473 + 474 + **Step 1b: Keychain storage verification (AC7.3 prerequisite)** 475 + 476 + After Step 1 completes successfully (app shows `authenticated`), verify that tokens were persisted to Keychain before testing relaunch. In Xcode, open the debug console and confirm no Keychain errors appear (the `store_oauth_tokens` call logs at `tracing::error` level on failure — no error lines means storage succeeded). 477 + 478 + Alternatively, run the Keychain unit tests to confirm the helpers work correctly: 479 + 480 + ```bash 481 + cargo test -p identity-wallet keychain 482 + ``` 483 + 484 + Expected: all keychain-related tests pass. This verifies the storage path used by `start_oauth_flow` after token exchange. 485 + 486 + **Step 2: Relaunch test (AC7.3)** 487 + 488 + Stop and relaunch the app (press Home then tap the icon). Verify: 489 + - Onboarding is skipped entirely 490 + - App shows `authenticated` state directly 491 + 492 + **Step 3: Auth failure test (AC7.4, AC8.1, AC8.2)** 493 + 494 + Kill the relay so the PAR call will fail. Launch the app fresh (first clear Keychain tokens by uninstalling the app). Complete onboarding and tap "Continue" on the `complete` step. Verify: 495 + - App shows `auth_failed` step with error code 496 + - "Try again" button returns to `authenticating` (spinner + opens browser again) 497 + - "Start over" button resets to the `welcome` step 498 + 499 + **Step 4: Commit** 500 + 501 + ```bash 502 + git add apps/identity-wallet/src/lib/ipc.ts 503 + git add apps/identity-wallet/src/lib/components/onboarding/AuthenticatingScreen.svelte 504 + git add apps/identity-wallet/src/routes/+page.svelte 505 + git add apps/identity-wallet/src-tauri/src/lib.rs 506 + git commit -m "feat(identity-wallet): SvelteKit OAuth screens and startup token restore (MM-149 phase 7)" 507 + ``` 508 + 509 + <!-- END_TASK_5 -->
+291
docs/implementation-plans/2026-03-23-MM-149/test-requirements.md
··· 1 + # Test Requirements: MM-149 OAuth PKCE Client 2 + 3 + ## Summary 4 + 5 + MM-149 spans 8 acceptance criteria groups (AC1--AC8) with 25 individual criteria. Of these, 19 are covered by automated tests (unit or integration) across two crates (`relay` and `identity-wallet`), and 6 require human verification in the iOS Simulator because they depend on system browser interaction, iOS Keychain hardware, or WKWebView rendering that cannot be exercised in `cargo test`. 6 + 7 + **Test file inventory:** 8 + 9 + | File | Test Count | Type | 10 + |------|-----------|------| 11 + | `crates/relay/src/db/mod.rs` | 1 | Unit | 12 + | `apps/identity-wallet/src-tauri/src/oauth.rs` | 12 | Unit (+ 2 ignored integration) | 13 + | `apps/identity-wallet/src-tauri/src/oauth_client.rs` | 6 | Unit (httpmock) | 14 + | Manual (iOS Simulator) | 6 criteria | Human verification | 15 + 16 + --- 17 + 18 + ## Automated Tests 19 + 20 + ### AC1: PAR flow completes successfully 21 + 22 + | Criterion | Test Type | File | Test Name Pattern | Phase | Notes | 23 + |-----------|-----------|------|-------------------|-------|-------| 24 + | MM-149.AC1.1 | Integration (ignored) | `apps/identity-wallet/src-tauri/src/oauth.rs` | `par_integration_returns_201_with_request_uri` | 4 | Requires running relay at localhost:8080. Run with `cargo test -p identity-wallet par_integration -- --include-ignored`. Verifies PAR POST returns 201 with `request_uri` starting with `urn:ietf:params:oauth:request_uri:`. | 25 + | MM-149.AC1.2 | Human | -- | -- | 5/7 | See Human Verification section. Authorization URL opened in Safari cannot be asserted from `cargo test`. | 26 + | MM-149.AC1.3 | Unit (existing) | `crates/relay/src/routes/oauth_par.rs` | Existing relay test suite | -- | Already tested by the relay's PAR handler tests (unknown client_id returns 4xx). Phase 1 migration test indirectly verifies the inverse: the seeded client_id IS accepted. | 27 + | MM-149.AC1.3 (inverse) | Unit | `crates/relay/src/db/mod.rs` | `v013_seeds_identity_wallet_oauth_client` | 1 | Verifies the V013 migration inserts the `dev.malpercio.identitywallet` client row with correct `redirect_uris` and `dpop_bound_access_tokens: true`. | 28 + | MM-149.AC1.4 | Integration (ignored) | `apps/identity-wallet/src-tauri/src/oauth.rs` | `par_missing_code_challenge_returns_client_error` | 4 | Requires running relay. Sends a PAR POST without `code_challenge` field and asserts a 4xx response. | 29 + 30 + ### AC2: OAuth callback received and code exchanged 31 + 32 + | Criterion | Test Type | File | Test Name Pattern | Phase | Notes | 33 + |-----------|-----------|------|-------------------|-------|-------| 34 + | MM-149.AC2.1 | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `handle_deep_link_delivers_code_and_state` | 5 | Constructs a fake `AppState` with a pending flow, calls `handle_deep_link` with a matching callback URL, and asserts the `oneshot` receiver gets `Ok(CallbackParams { code, state })`. | 35 + | MM-149.AC2.2 | Human | -- | -- | 7 | Full token exchange requires a live relay with user consent in Safari. See Human Verification section. | 36 + | MM-149.AC2.3 | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `handle_deep_link_csrf_mismatch_returns_state_mismatch_error` | 5 | Calls `handle_deep_link` with a state param that does not match `flow.csrf_state`. Asserts the receiver gets `Err(OAuthError::StateMismatch)` and that `pending_auth` is cleared. | 37 + | MM-149.AC2.4 | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `handle_deep_link_replay_is_silently_ignored` | 5 | Calls `handle_deep_link` twice with the same URL. First call succeeds; second call sees `pending_auth = None` and returns without panic or send. | 38 + | MM-149.AC2.5 | Human | -- | -- | 5/7 | The `exchange_code_with_retry` function handles this, but testing it requires a relay that issues `use_dpop_nonce` at the token endpoint. See Human Verification section. The code path is structurally verified by the OAuthClient nonce-retry tests in AC5.2 (same retry pattern). | 39 + 40 + ### AC3: DPoP proofs are correctly formed 41 + 42 + | Criterion | Test Type | File | Test Name Pattern | Phase | Notes | 43 + |-----------|-----------|------|-------------------|-------|-------| 44 + | MM-149.AC3.1 | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `dpop_proof_header_has_required_fields` | 3 | Decodes the base64url header of a generated proof. Asserts `typ = "dpop+jwt"`, `alg = "ES256"`, `jwk.kty = "EC"`, `jwk.crv = "P-256"`, non-empty `x` and `y`. | 45 + | MM-149.AC3.2 | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `dpop_proof_claims_has_required_fields` | 3 | Decodes the base64url claims. Asserts `jti` is non-empty, `htm` and `htu` match inputs, `iat` is within 5 seconds of current time. | 46 + | MM-149.AC3.3 | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `dpop_proof_includes_ath_when_supplied` | 3 | Generates proof with `ath = Some("abc123")` and asserts `claims.ath = "abc123"`. Generates without ath and asserts `claims.ath` is absent. | 47 + | MM-149.AC3.4 | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `dpop_proof_includes_nonce_when_supplied` | 3 | Generates with `nonce = Some("nonce123")` and asserts presence. Generates without and asserts absence. | 48 + | MM-149.AC3.5 | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `dpop_proof_signature_verifies_against_embedded_jwk` | 3 | Extracts the JWK `x`/`y` coordinates from the proof header, reconstructs a `VerifyingKey`, and calls `.verify()` on the signing input. | 49 + 50 + **Supplementary test:** 51 + 52 + | Criterion | Test Type | File | Test Name Pattern | Phase | Notes | 53 + |-----------|-----------|------|-------------------|-------|-------| 54 + | (AC3.3 helper) | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | `compute_ath_matches_sha256_base64url` | 3 | Verifies `DPoPKeypair::compute_ath()` output matches independently computed `base64url(SHA-256(token))`. | 55 + 56 + ### AC4: Tokens stored securely and loaded on restart 57 + 58 + | Criterion | Test Type | File | Test Name Pattern | Phase | Notes | 59 + |-----------|-----------|------|-------------------|-------|-------| 60 + | MM-149.AC4.1 | Human | -- | -- | 5/7 | Keychain storage is exercised by `start_oauth_flow` after token exchange. Verification requires checking the iOS Simulator Keychain or confirming no `tracing::error` lines from `store_oauth_tokens`. See Human Verification section. | 61 + | MM-149.AC4.2 | Human | -- | -- | 7 | On app relaunch, `setup()` calls `load_oauth_tokens()` and emits `auth_ready`. Verification requires iOS Simulator relaunch. The `setup()` code path cannot be exercised in unit tests (requires Tauri runtime). See Human Verification section. | 62 + | MM-149.AC4.3 | Human | -- | -- | 7 | Tokens must NOT appear in `localStorage`, `sessionStorage`, or any JS-accessible storage. Operational check only. See Human Verification section. | 63 + 64 + ### AC5: Authenticated requests carry DPoP proofs 65 + 66 + | Criterion | Test Type | File | Test Name Pattern | Phase | Notes | 67 + |-----------|-----------|------|-------------------|-------|-------| 68 + | MM-149.AC5.1 | Unit (httpmock) | `apps/identity-wallet/src-tauri/src/oauth_client.rs` | `dpop_and_authorization_headers_present_on_get` | 6 | Uses `httpmock::MockServer` to intercept the outgoing GET. Asserts `Authorization: DPoP my_access_token` and `DPoP: <three-part-JWT>` headers are present. | 69 + | MM-149.AC5.2 | Unit (httpmock) | `apps/identity-wallet/src-tauri/src/oauth_client.rs` | `nonce_retry_sends_exactly_two_requests` | 6 | First mock returns 400 with `DPoP-Nonce: test-server-nonce`. Second mock returns 200. Asserts exactly 2 requests hit the server and the retry DPoP proof contains `nonce = "test-server-nonce"` in its claims. | 70 + | MM-149.AC5.3 | Unit (httpmock) | `apps/identity-wallet/src-tauri/src/oauth_client.rs` | `empty_access_token_does_not_panic` | 6 | Creates a session with `access_token = ""`. Asserts the request completes (server returns 401) without panicking. | 71 + 72 + ### AC6: Token refresh works transparently 73 + 74 + | Criterion | Test Type | File | Test Name Pattern | Phase | Notes | 75 + |-----------|-----------|------|-------------------|-------|-------| 76 + | MM-149.AC6.1 | Unit (httpmock) | `apps/identity-wallet/src-tauri/src/oauth_client.rs` | `lazy_refresh_fires_when_expiry_near` | 6 | Creates session with `expires_at = now + 30` (below the 60-second threshold). Mocks `/oauth/token` (200 with new tokens) and `/resource` (200). Asserts refresh was called before the resource request, and session updated with `new_access_token`. | 77 + | MM-149.AC6.2 | Unit (httpmock) | `apps/identity-wallet/src-tauri/src/oauth_client.rs` | `refresh_dpop_proof_has_no_ath_claim` | 6 | Calls `refresh_token()` directly. Captures the DPoP header from the mock. Decodes claims and asserts `ath` is null/absent. | 78 + | MM-149.AC6.3 | Unit (httpmock) | `apps/identity-wallet/src-tauri/src/oauth_client.rs` | `refresh_invalid_grant_returns_token_refresh_failed` | 6 | Mock returns 400 with `{"error": "invalid_grant"}`. Asserts result is `Err(OAuthError::TokenRefreshFailed)`. | 79 + 80 + ### AC7: Frontend authentication screens 81 + 82 + All AC7 criteria require human verification. See Human Verification section below. 83 + 84 + ### AC8: Failed auth recovery 85 + 86 + All AC8 criteria require human verification. See Human Verification section below. 87 + 88 + --- 89 + 90 + ## PKCE Utility Tests (No Direct AC, Foundation for AC1) 91 + 92 + These tests validate the PKCE primitives used by `start_oauth_flow` to satisfy AC1. 93 + 94 + | Test Name Pattern | Test Type | File | Phase | Notes | 95 + |-------------------|-----------|------|-------|-------| 96 + | `pkce_verifier_is_43_unreserved_chars` | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | 4 | Asserts base64url of 32 bytes = 43 chars, all RFC 7636 unreserved. | 97 + | `pkce_challenge_equals_sha256_base64url_of_verifier` | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | 4 | Independently computes `base64url(SHA-256(verifier))` and asserts equality. | 98 + | `state_param_is_22_chars` | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | 4 | Asserts base64url of 16 bytes = 22 chars. | 99 + | `pkce_verifiers_are_unique` | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | 4 | Two sequential `generate()` calls produce different verifiers. | 100 + 101 + --- 102 + 103 + ## Human Verification 104 + 105 + The following criteria cannot be automated because they require the iOS Simulator runtime, system browser interaction, or the full Tauri application lifecycle. Each includes a step-by-step verification approach. 106 + 107 + ### MM-149.AC1.2: Authorization URL opened in system browser 108 + 109 + **Justification:** Opening a URL in Safari via `tauri-plugin-opener` requires the iOS runtime. The Tauri `OpenerExt` API is not mockable in unit tests, and `cargo test` cannot observe Safari launching. 110 + 111 + **Verification steps:** 112 + 1. Start the relay: `cargo run -p relay` 113 + 2. Launch the app: `cd apps/identity-wallet && cargo tauri ios dev` 114 + 3. Complete onboarding through step 10 (DID ceremony + Shamir backup) 115 + 4. Tap "Continue" on the `complete` step 116 + 5. **Verify:** Safari opens with a URL containing `client_id=dev.malpercio.identitywallet` and `request_uri=urn:ietf:params:oauth:request_uri:...` 117 + 118 + ### MM-149.AC2.2: Token exchange succeeds 119 + 120 + **Justification:** The full code-for-token exchange requires a live relay, a completed user consent in Safari, and a deep-link callback routed through iOS. These cannot be orchestrated in a unit test. 121 + 122 + **Verification steps:** 123 + 1. Complete steps 1--5 from AC1.2 above (app transitions to `authenticating`, Safari opens) 124 + 2. Complete the authorization consent in Safari 125 + 3. **Verify:** Safari redirects to the app; app transitions from `authenticating` to `authenticated` 126 + 4. **Verify:** No error messages in the `cargo tauri ios dev` console 127 + 128 + ### MM-149.AC2.5: use_dpop_nonce retry on token exchange 129 + 130 + **Justification:** The relay always requires a DPoP nonce at the token endpoint. On the first token exchange attempt in `exchange_code_with_retry`, the nonce is absent; the relay returns 400 with `use_dpop_nonce`. The retry path is always exercised during normal operation, but verifying it requires a live relay. 131 + 132 + **Verification steps:** 133 + 1. Complete the full OAuth flow (steps 1--3 from AC2.2 above) 134 + 2. In the `cargo tauri ios dev` console output, search for the tracing log line: 135 + ``` 136 + DEBUG identity_wallet::oauth: retrying token exchange with server nonce nonce=... 137 + ``` 138 + 3. **Verify:** The log line appears, confirming the retry path was taken and succeeded (app reached `authenticated`) 139 + 140 + **Rationale for not automating:** The `exchange_code_with_retry` function is tightly coupled to `RelayClient::token_exchange` which returns a raw `reqwest::Response`. Mocking would require extracting the retry logic into a testable function, which the current design does not do. The same retry pattern IS tested in `OAuthClient::execute_with_retry` (AC5.2) using httpmock, providing structural confidence. 141 + 142 + ### MM-149.AC4.1: Tokens stored in iOS Keychain 143 + 144 + **Justification:** The `security-framework` crate's Keychain operations require macOS/iOS Keychain Services, which are not available in `cargo test` (unless running on macOS with Keychain access). The identity-wallet's test builds redirect Keychain calls to an in-memory store, so production Keychain persistence cannot be verified in CI. 145 + 146 + **Verification steps:** 147 + 1. Complete the full OAuth flow (AC2.2 steps above) 148 + 2. In the `cargo tauri ios dev` console, confirm **no** `tracing::error` lines mentioning "Keychain error" appear after "OAuth flow complete; session stored" 149 + 3. Alternatively, after a successful flow, force-quit the app and relaunch. If the app skips onboarding and shows `authenticated` directly, the Keychain store and load paths both work (this also covers AC4.2) 150 + 151 + ### MM-149.AC4.2: Tokens loaded on restart 152 + 153 + **Justification:** The `setup()` closure runs during Tauri app initialization, which requires the full Tauri runtime. It cannot be invoked from `cargo test`. 154 + 155 + **Verification steps:** 156 + 1. Complete the full OAuth flow to reach `authenticated` state 157 + 2. Force-quit the app (swipe up from app switcher or press Home + stop the Simulator process) 158 + 3. Relaunch the app (tap the icon or run `cargo tauri ios dev` again) 159 + 4. **Verify:** Onboarding is skipped; app shows `authenticated` directly 160 + 5. **Verify:** Console shows the `auth_ready` event emission log (if tracing is enabled) 161 + 162 + ### MM-149.AC4.3: Tokens not in JS storage 163 + 164 + **Justification:** This is a negative security assertion about the WKWebView's JavaScript context. There is no automated way to inspect `localStorage`/`sessionStorage` from Rust tests. 165 + 166 + **Verification steps:** 167 + 1. Complete the full OAuth flow to reach `authenticated` state 168 + 2. In Safari (macOS), open Web Inspector for the iOS Simulator (Develop menu > Simulator > identity-wallet) 169 + 3. In the Web Inspector Console, run: 170 + ```javascript 171 + JSON.stringify(localStorage) 172 + JSON.stringify(sessionStorage) 173 + ``` 174 + 4. **Verify:** Neither output contains `access_token`, `refresh_token`, or any OAuth credential strings 175 + 5. Also inspect IndexedDB and cookies in Web Inspector's Storage tab 176 + 6. **Verify:** No OAuth tokens present in any JS-accessible storage 177 + 178 + ### MM-149.AC7.1: Auto-advance to authenticating after onboarding 179 + 180 + **Justification:** Requires SvelteKit rendering in WKWebView (Tauri runtime) and user interaction through 10 onboarding steps. 181 + 182 + **Verification steps:** 183 + 1. Start the relay 184 + 2. Launch the app in the iOS Simulator (`cargo tauri ios dev`) 185 + 3. Complete all 10 onboarding steps (welcome through Shamir backup) 186 + 4. On the `complete` step, tap "Continue" 187 + 5. **Verify:** Screen transitions to the `authenticating` step (spinner visible, "Opening browser for authentication..." text) 188 + 6. **Verify:** Safari opens automatically 189 + 190 + ### MM-149.AC7.2: Successful auth transitions to authenticated 191 + 192 + **Verification steps:** 193 + 1. Continue from AC7.1 (Safari is open with the consent page) 194 + 2. Complete the authorization in Safari 195 + 3. **Verify:** App transitions from `authenticating` to `authenticated` (checkmark icon, "Your identity wallet is ready." text) 196 + 197 + ### MM-149.AC7.3: Relaunch with stored tokens skips onboarding 198 + 199 + **Verification steps:** 200 + 1. Same as AC4.2 (force-quit and relaunch) 201 + 2. **Verify:** The `welcome` step never appears; app starts at `authenticated` 202 + 203 + ### MM-149.AC7.4: Auth failure transitions to auth_failed 204 + 205 + **Verification steps:** 206 + 1. Stop the relay (so PAR will fail) 207 + 2. Launch the app fresh (uninstall first to clear Keychain) 208 + 3. Complete onboarding through step 10 209 + 4. Tap "Continue" 210 + 5. **Verify:** App transitions to `auth_failed` step (X icon, "Authentication Failed" heading, error code displayed) 211 + 212 + ### MM-149.AC8.1: "Try again" re-invokes start_oauth_flow 213 + 214 + **Verification steps:** 215 + 1. Reach the `auth_failed` state (AC7.4 steps above) 216 + 2. Start the relay (so the retry can succeed) 217 + 3. Tap "Try again" 218 + 4. **Verify:** App transitions to `authenticating` (spinner appears, browser opens) 219 + 5. **Verify:** No stale error state — `authError` is cleared before re-entering `authenticating` 220 + 221 + ### MM-149.AC8.2: "Start over" resets to step 1 222 + 223 + **Verification steps:** 224 + 1. Reach the `auth_failed` state (AC7.4 steps above) 225 + 2. Tap "Start over" 226 + 3. **Verify:** App transitions to the `welcome` step (first step of onboarding) 227 + 4. **Verify:** All form state is reset (no pre-filled fields from the previous attempt) 228 + 229 + --- 230 + 231 + ## Coverage Matrix 232 + 233 + | Criterion | Automated | Human | Implementation Phase | 234 + |-----------|-----------|-------|---------------------| 235 + | MM-149.AC1.1 | Integration (ignored) | -- | 4 | 236 + | MM-149.AC1.2 | -- | iOS Simulator | 5, 7 | 237 + | MM-149.AC1.3 | Unit (relay) | -- | 1 (+ existing relay tests) | 238 + | MM-149.AC1.4 | Integration (ignored) | -- | 4 | 239 + | MM-149.AC2.1 | Unit | -- | 5 | 240 + | MM-149.AC2.2 | -- | iOS Simulator | 5, 7 | 241 + | MM-149.AC2.3 | Unit | -- | 5 | 242 + | MM-149.AC2.4 | Unit | -- | 5 | 243 + | MM-149.AC2.5 | -- | iOS Simulator (log check) | 5, 7 | 244 + | MM-149.AC3.1 | Unit | -- | 3 | 245 + | MM-149.AC3.2 | Unit | -- | 3 | 246 + | MM-149.AC3.3 | Unit | -- | 3 | 247 + | MM-149.AC3.4 | Unit | -- | 3 | 248 + | MM-149.AC3.5 | Unit | -- | 3 | 249 + | MM-149.AC4.1 | -- | iOS Simulator | 5, 7 | 250 + | MM-149.AC4.2 | -- | iOS Simulator | 7 | 251 + | MM-149.AC4.3 | -- | iOS Simulator (Web Inspector) | 7 | 252 + | MM-149.AC5.1 | Unit (httpmock) | -- | 6 | 253 + | MM-149.AC5.2 | Unit (httpmock) | -- | 6 | 254 + | MM-149.AC5.3 | Unit (httpmock) | -- | 6 | 255 + | MM-149.AC6.1 | Unit (httpmock) | -- | 6 | 256 + | MM-149.AC6.2 | Unit (httpmock) | -- | 6 | 257 + | MM-149.AC6.3 | Unit (httpmock) | -- | 6 | 258 + | MM-149.AC7.1 | -- | iOS Simulator | 7 | 259 + | MM-149.AC7.2 | -- | iOS Simulator | 7 | 260 + | MM-149.AC7.3 | -- | iOS Simulator | 7 | 261 + | MM-149.AC7.4 | -- | iOS Simulator | 7 | 262 + | MM-149.AC8.1 | -- | iOS Simulator | 7 | 263 + | MM-149.AC8.2 | -- | iOS Simulator | 7 | 264 + 265 + --- 266 + 267 + ## Test Execution Commands 268 + 269 + ```bash 270 + # All automated tests (relay + identity-wallet) 271 + cargo test -p relay v013_seeds_identity_wallet_oauth_client 272 + cargo test -p identity-wallet 273 + 274 + # DPoP proof tests only 275 + cargo test -p identity-wallet dpop 276 + 277 + # PKCE tests only 278 + cargo test -p identity-wallet pkce 279 + 280 + # handle_deep_link tests only 281 + cargo test -p identity-wallet handle_deep_link 282 + 283 + # OAuthClient httpmock tests only 284 + cargo test -p identity-wallet oauth_client 285 + 286 + # Integration tests (require running relay at localhost:8080) 287 + cargo test -p identity-wallet par_ -- --include-ignored --nocapture 288 + 289 + # Full suite including integration tests 290 + cargo test -p identity-wallet -- --include-ignored --nocapture 291 + ```