···11+# MM-149 OAuth PKCE Client Implementation Plan
22+33+**Goal:** Pre-register the identity-wallet app as a known OAuth client in the relay database.
44+55+**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.
66+77+**Tech Stack:** SQLite (migration SQL), Rust/sqlx (migration runner in `crates/relay/src/db/mod.rs`)
88+99+**Scope:** 7 phases from original design (phase 1 of 7)
1010+1111+**Codebase verified:** 2026-03-23
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+### MM-149.AC1: PAR flow completes successfully
2020+- **MM-149.AC1.3 Failure:** PAR request with unknown `client_id` returns a client error (relay rejects it)
2121+2222+> 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).
2323+2424+---
2525+2626+<!-- START_TASK_1 -->
2727+### Task 1: Write the V013 migration SQL
2828+2929+**Verifies:** None (infrastructure — verified operationally)
3030+3131+**Files:**
3232+- Create: `crates/relay/src/db/migrations/V013__identity_wallet_oauth_client.sql`
3333+3434+**Step 1: Create the migration file**
3535+3636+```sql
3737+-- Seed the identity-wallet as a registered OAuth client.
3838+--
3939+-- client_metadata is a RFC 7591 JSON object. The PAR handler parses
4040+-- metadata["redirect_uris"] to validate the redirect_uri parameter.
4141+-- INSERT OR IGNORE makes this migration idempotent on re-run.
4242+INSERT OR IGNORE INTO oauth_clients (client_id, client_metadata, created_at)
4343+VALUES (
4444+ 'dev.malpercio.identitywallet',
4545+ json('{
4646+ "client_id": "dev.malpercio.identitywallet",
4747+ "application_type": "native",
4848+ "token_endpoint_auth_method": "none",
4949+ "dpop_bound_access_tokens": true,
5050+ "redirect_uris": ["dev.malpercio.identitywallet:/oauth/callback"],
5151+ "grant_types": ["authorization_code", "refresh_token"],
5252+ "scope": "atproto",
5353+ "client_name": "Malpercio Identity Wallet"
5454+ }'),
5555+ datetime('now')
5656+);
5757+```
5858+5959+**Step 2: Verify the migration file**
6060+6161+Confirm the file exists at the correct path:
6262+```bash
6363+ls crates/relay/src/db/migrations/V013__identity_wallet_oauth_client.sql
6464+```
6565+6666+Expected: file is listed.
6767+6868+<!-- END_TASK_1 -->
6969+7070+<!-- START_TASK_2 -->
7171+### Task 2: Register V013 in the migration runner
7272+7373+**Verifies:** None (infrastructure)
7474+7575+**Files:**
7676+- Modify: `crates/relay/src/db/mod.rs`
7777+7878+The migration runner maintains a static `MIGRATIONS` array. Each entry is `(version: i64, sql: &str)`. V012 is the current last entry.
7979+8080+**Step 1: Read the current MIGRATIONS array**
8181+8282+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:
8383+8484+```rust
8585+static MIGRATIONS: &[Migration] = &[
8686+ Migration {
8787+ version: 1,
8888+ sql: include_str!("migrations/V001__init.sql"),
8989+ },
9090+ // ...
9191+ Migration {
9292+ version: 12,
9393+ sql: include_str!("migrations/V012__oauth_token_endpoint.sql"),
9494+ },
9595+];
9696+```
9797+9898+**Step 2: Append V013**
9999+100100+Add a new `Migration` entry after the V012 entry:
101101+102102+```rust
103103+ Migration {
104104+ version: 13,
105105+ sql: include_str!("migrations/V013__identity_wallet_oauth_client.sql"),
106106+ },
107107+```
108108+109109+The full array tail should read:
110110+```rust
111111+ Migration {
112112+ version: 12,
113113+ sql: include_str!("migrations/V012__oauth_token_endpoint.sql"),
114114+ },
115115+ Migration {
116116+ version: 13,
117117+ sql: include_str!("migrations/V013__identity_wallet_oauth_client.sql"),
118118+ },
119119+];
120120+```
121121+122122+**Step 3: Build to verify the migration compiles**
123123+124124+```bash
125125+cargo build -p relay
126126+```
127127+128128+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.
129129+130130+<!-- END_TASK_2 -->
131131+132132+<!-- START_TASK_3 -->
133133+### Task 3: Add a migration test to verify the seed row
134134+135135+**Verifies:** MM-149.AC1.3 (PAR with this client_id is now accepted, not rejected as unknown)
136136+137137+**Files:**
138138+- Modify: `crates/relay/src/db/mod.rs` (add one test at the bottom of the file)
139139+140140+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.
141141+142142+**Step 1: Read the existing tests at the bottom of `crates/relay/src/db/mod.rs`**
143143+144144+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).
145145+146146+**Step 2: Add a test that asserts the seed row exists**
147147+148148+Add inside the existing `#[cfg(test)]` mod (or create one if absent):
149149+150150+```rust
151151+#[cfg(test)]
152152+mod tests {
153153+ use super::*;
154154+ use crate::db::oauth::get_oauth_client;
155155+156156+ #[tokio::test]
157157+ async fn v013_seeds_identity_wallet_oauth_client() {
158158+ let pool = in_memory_pool().await;
159159+ run_migrations(&pool).await.expect("migrations must apply cleanly");
160160+161161+ let row = get_oauth_client(&pool, "dev.malpercio.identitywallet")
162162+ .await
163163+ .expect("db query must not fail");
164164+165165+ assert!(
166166+ row.is_some(),
167167+ "V013 migration must insert the identity-wallet client row"
168168+ );
169169+170170+ let row = row.unwrap();
171171+ let metadata: serde_json::Value =
172172+ serde_json::from_str(&row.client_metadata).expect("client_metadata must be valid JSON");
173173+174174+ assert_eq!(
175175+ metadata["redirect_uris"][0].as_str(),
176176+ Some("dev.malpercio.identitywallet:/oauth/callback"),
177177+ "redirect_uri must match the custom URL scheme"
178178+ );
179179+ assert_eq!(
180180+ metadata["dpop_bound_access_tokens"].as_bool(),
181181+ Some(true),
182182+ "DPoP must be required for this client"
183183+ );
184184+ }
185185+}
186186+```
187187+188188+**Step 3: Run the test**
189189+190190+```bash
191191+cargo test -p relay v013_seeds_identity_wallet_oauth_client
192192+```
193193+194194+Expected output:
195195+```
196196+test db::tests::v013_seeds_identity_wallet_oauth_client ... ok
197197+```
198198+199199+**Step 4: Run all relay tests to confirm no regressions**
200200+201201+```bash
202202+cargo test -p relay
203203+```
204204+205205+Expected: all tests pass.
206206+207207+**Step 5: Commit**
208208+209209+```bash
210210+git add crates/relay/src/db/migrations/V013__identity_wallet_oauth_client.sql
211211+git add crates/relay/src/db/mod.rs
212212+git commit -m "feat(relay): register identity-wallet as OAuth client (MM-149 phase 1)"
213213+```
214214+215215+<!-- END_TASK_3 -->
···11+# MM-149 OAuth PKCE Client Implementation Plan
22+33+**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.
44+55+**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`.
66+77+**Tech Stack:** Rust/Tauri v2, `tauri-plugin-deep-link = "2"`, `tauri-plugin-opener = "2"`, `std::sync::Mutex`
88+99+**Scope:** 7 phases from original design (phase 2 of 7)
1010+1111+**Codebase verified:** 2026-03-23
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+> 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.
2020+2121+**Verifies:** None (infrastructure — verified operationally)
2222+2323+---
2424+2525+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2626+2727+<!-- START_TASK_1 -->
2828+### Task 1: Add plugin dependencies to Cargo.toml
2929+3030+**Verifies:** None (infrastructure)
3131+3232+**Files:**
3333+- Modify: `apps/identity-wallet/src-tauri/Cargo.toml`
3434+3535+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.
3636+3737+**Step 1: Add the two plugin crates to `[dependencies]`**
3838+3939+In `apps/identity-wallet/src-tauri/Cargo.toml`, after the `tauri = { version = "2", features = [] }` line, add:
4040+4141+```toml
4242+tauri-plugin-deep-link = "2"
4343+tauri-plugin-opener = "2"
4444+```
4545+4646+The `[dependencies]` section should now include:
4747+4848+```toml
4949+tauri = { version = "2", features = [] }
5050+tauri-plugin-deep-link = "2"
5151+tauri-plugin-opener = "2"
5252+```
5353+5454+**Step 2: Verify the crates download**
5555+5656+```bash
5757+cd apps/identity-wallet && cargo fetch
5858+```
5959+6060+Expected: exits without error. This confirms the crate versions resolve.
6161+6262+**Step 3: Build to confirm no compile errors**
6363+6464+```bash
6565+cargo build -p identity-wallet
6666+```
6767+6868+Expected: builds without errors (no new code yet, just new deps).
6969+7070+<!-- END_TASK_1 -->
7171+7272+<!-- START_TASK_2 -->
7373+### Task 2: Add deep-link plugin config to tauri.conf.json
7474+7575+**Verifies:** None (infrastructure)
7676+7777+**Files:**
7878+- Modify: `apps/identity-wallet/src-tauri/tauri.conf.json`
7979+8080+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.
8181+8282+**Step 1: Add the `plugins` section**
8383+8484+The full updated `tauri.conf.json`:
8585+8686+```json
8787+{
8888+ "$schema": "https://schema.tauri.app/config/2",
8989+ "productName": "Identity Wallet",
9090+ "version": "0.1.0",
9191+ "identifier": "dev.malpercio.identitywallet",
9292+ "build": {
9393+ "devUrl": "http://localhost:5173",
9494+ "frontendDist": "../dist",
9595+ "beforeDevCommand": "pnpm dev",
9696+ "beforeBuildCommand": "pnpm build"
9797+ },
9898+ "app": {
9999+ "windows": [
100100+ {
101101+ "title": "Identity Wallet",
102102+ "width": 400,
103103+ "height": 600,
104104+ "resizable": true
105105+ }
106106+ ]
107107+ },
108108+ "bundle": {
109109+ "active": true
110110+ },
111111+ "plugins": {
112112+ "deep-link": {
113113+ "mobile": [
114114+ {
115115+ "scheme": ["dev.malpercio.identitywallet"]
116116+ }
117117+ ]
118118+ }
119119+ }
120120+}
121121+```
122122+123123+**Step 2: Regenerate the Xcode project**
124124+125125+The Xcode project (`src-tauri/gen/apple/`) is machine-specific and gitignored. After any config change that affects the iOS build, regenerate it:
126126+127127+```bash
128128+cd apps/identity-wallet && cargo tauri ios init
129129+```
130130+131131+Then re-apply the two one-time Xcode patches from the CLAUDE.md:
132132+133133+```bash
134134+# Patch 1: Add Nix devenv PATH to the build phase
135135+# Replace <project-root> with the absolute path to the workspace root (e.g. /Users/you/workspace/malpercio-dev/ezpds)
136136+# Find the shellScript line in project.pbxproj and prepend the PATH export
137137+138138+# Patch 2: Disable user script sandboxing
139139+sed -i '' 's/ENABLE_USER_SCRIPT_SANDBOXING = YES/ENABLE_USER_SCRIPT_SANDBOXING = NO/g' \
140140+ src-tauri/gen/apple/identity-wallet.xcodeproj/project.pbxproj
141141+```
142142+143143+<!-- END_TASK_2 -->
144144+145145+<!-- END_SUBCOMPONENT_A -->
146146+147147+<!-- START_SUBCOMPONENT_B (tasks 3-4) -->
148148+149149+<!-- START_TASK_3 -->
150150+### Task 3: Create oauth.rs with AppState, PendingOAuthFlow, CallbackParams, and handle_deep_link stub
151151+152152+**Verifies:** None (infrastructure stub — verified by tracing log in Task 5)
153153+154154+**Files:**
155155+- Create: `apps/identity-wallet/src-tauri/src/oauth.rs`
156156+157157+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.
158158+159159+**Step 1: Create `apps/identity-wallet/src-tauri/src/oauth.rs`**
160160+161161+```rust
162162+// pattern: Mixed (unavoidable)
163163+//
164164+// Types: AppState, PendingOAuthFlow, OAuthSession, CallbackParams (Functional Core)
165165+// handle_deep_link: Imperative Shell (reads OS callback, routes to pending channel)
166166+167167+use std::sync::Mutex;
168168+use tracing;
169169+170170+// ── Shared state ──────────────────────────────────────────────────────────────
171171+172172+/// App-wide OAuth state registered via `.manage()` in lib.rs.
173173+///
174174+/// Both fields are Option-wrapped so the state is cleanly empty before any
175175+/// OAuth flow starts and after a flow completes.
176176+pub struct AppState {
177177+ /// The pending OAuth flow waiting for the deep-link callback.
178178+ /// Set by `start_oauth_flow` before opening Safari; cleared by `handle_deep_link`.
179179+ pub pending_auth: Mutex<Option<PendingOAuthFlow>>,
180180+ /// The active authenticated session after a successful token exchange.
181181+ /// Set by `start_oauth_flow` on success; read by `OAuthClient` for every request.
182182+ pub oauth_session: Mutex<Option<OAuthSession>>,
183183+}
184184+185185+impl AppState {
186186+ pub fn new() -> Self {
187187+ Self {
188188+ pending_auth: Mutex::new(None),
189189+ oauth_session: Mutex::new(None),
190190+ }
191191+ }
192192+}
193193+194194+// ── Pending flow (stub — filled out in Phase 5) ───────────────────────────────
195195+196196+/// State parked inside `AppState.pending_auth` while `start_oauth_flow` waits
197197+/// for the deep-link callback.
198198+///
199199+/// Phase 5 adds: oneshot::Sender<CallbackParams>, pkce_verifier, csrf_state.
200200+pub struct PendingOAuthFlow {
201201+ /// The CSRF state parameter generated at the start of the flow.
202202+ /// Used by `handle_deep_link` to validate the callback state.
203203+ pub csrf_state: String,
204204+}
205205+206206+// ── OAuth session (stub — filled out in Phase 5) ──────────────────────────────
207207+208208+/// Active OAuth session stored after a successful token exchange.
209209+///
210210+/// Phase 5 adds: access_token, refresh_token, expires_at, dpop_nonce.
211211+pub struct OAuthSession {
212212+ pub access_token: String,
213213+ pub refresh_token: String,
214214+}
215215+216216+// ── Callback params ───────────────────────────────────────────────────────────
217217+218218+/// Parameters extracted from the OAuth deep-link callback URL.
219219+pub struct CallbackParams {
220220+ pub code: String,
221221+ pub state: String,
222222+}
223223+224224+// ── Deep-link handler ─────────────────────────────────────────────────────────
225225+226226+/// Process URLs received from the deep-link plugin's `on_open_url` event.
227227+///
228228+/// Filters for the OAuth callback path and logs receipt. Phase 5 completes this
229229+/// by extracting `code`+`state` and sending them on the pending `oneshot` channel.
230230+pub fn handle_deep_link(urls: Vec<url::Url>, app_state: &AppState) {
231231+ for url in &urls {
232232+ let scheme = url.scheme();
233233+ let path = url.path();
234234+235235+ if scheme == "dev.malpercio.identitywallet" && path == "/oauth/callback" {
236236+ tracing::info!(url = %url, "OAuth deep-link callback received");
237237+238238+ // Phase 5: extract code+state, validate CSRF, send on oneshot channel.
239239+ // For now, just log that the callback arrived.
240240+ let _pending = app_state.pending_auth.lock().unwrap();
241241+ tracing::info!("pending_auth slot present: {}", _pending.is_some());
242242+243243+ return;
244244+ }
245245+246246+ tracing::debug!(url = %url, "ignoring non-OAuth deep-link");
247247+ }
248248+}
249249+```
250250+251251+**Step 2: Add `url = "2"` to Cargo.toml**
252252+253253+Add to `[dependencies]` in `apps/identity-wallet/src-tauri/Cargo.toml`:
254254+255255+```toml
256256+url = "2"
257257+```
258258+259259+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.
260260+261261+**Step 3: Verify the file compiles**
262262+263263+```bash
264264+cargo build -p identity-wallet
265265+```
266266+267267+Expected: builds without errors. The `url::Url` type resolves from the explicit `url = "2"` dependency.
268268+269269+<!-- END_TASK_3 -->
270270+271271+<!-- START_TASK_4 -->
272272+### Task 4: Register plugins, AppState, and on_open_url in lib.rs
273273+274274+**Verifies:** None (infrastructure — verified operationally in Task 5)
275275+276276+**Files:**
277277+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs`
278278+279279+**Step 1: Add the `oauth` module declaration at the top of lib.rs**
280280+281281+After the existing module declarations (lines 1-3), add:
282282+283283+```rust
284284+pub mod oauth;
285285+```
286286+287287+The top of lib.rs should now read:
288288+289289+```rust
290290+pub mod device_key;
291291+pub mod http;
292292+pub mod keychain;
293293+pub mod oauth;
294294+```
295295+296296+**Step 2: Update the `run()` function**
297297+298298+The current `run()` function (lines 398-409) is:
299299+300300+```rust
301301+#[cfg_attr(mobile, tauri::mobile_entry_point)]
302302+pub fn run() {
303303+ tauri::Builder::default()
304304+ .invoke_handler(tauri::generate_handler![
305305+ create_account,
306306+ get_or_create_device_key,
307307+ sign_with_device_key,
308308+ perform_did_ceremony,
309309+ ])
310310+ .run(tauri::generate_context!())
311311+ .expect("error while running tauri application");
312312+}
313313+```
314314+315315+Replace it with:
316316+317317+```rust
318318+#[cfg_attr(mobile, tauri::mobile_entry_point)]
319319+pub fn run() {
320320+ tauri::Builder::default()
321321+ .manage(oauth::AppState::new())
322322+ .plugin(tauri_plugin_deep_link::init())
323323+ .plugin(tauri_plugin_opener::init())
324324+ .setup(|app| {
325325+ let app_handle = app.app_handle().clone();
326326+ app.deep_link().on_open_url(move |event| {
327327+ let state = app_handle.state::<oauth::AppState>();
328328+ oauth::handle_deep_link(event.urls(), &state);
329329+ });
330330+ Ok(())
331331+ })
332332+ .invoke_handler(tauri::generate_handler![
333333+ create_account,
334334+ get_or_create_device_key,
335335+ sign_with_device_key,
336336+ perform_did_ceremony,
337337+ ])
338338+ .run(tauri::generate_context!())
339339+ .expect("error while running tauri application");
340340+}
341341+```
342342+343343+**Step 3: Add the missing use import for Tauri Manager**
344344+345345+`app_handle.state::<T>()` requires the `tauri::Manager` trait in scope. Add it to the existing use imports at the top of lib.rs:
346346+347347+```rust
348348+use tauri::Manager;
349349+```
350350+351351+**Step 4: Build to verify no compile errors**
352352+353353+```bash
354354+cargo build -p identity-wallet
355355+```
356356+357357+Expected: builds without errors.
358358+359359+<!-- END_TASK_4 -->
360360+361361+<!-- END_SUBCOMPONENT_B -->
362362+363363+<!-- START_TASK_5 -->
364364+### Task 5: Verify deep-link callback fires end-to-end
365365+366366+**Verifies:** None (operational — confirms the plumbing works before Phase 5 adds logic)
367367+368368+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.
369369+370370+**Step 1: Launch the app in the iOS Simulator**
371371+372372+```bash
373373+cd apps/identity-wallet
374374+cargo tauri ios dev
375375+```
376376+377377+Wait for the Simulator to open and the app to launch.
378378+379379+**Step 2: Trigger the deep-link callback**
380380+381381+In a separate terminal (while `cargo tauri ios dev` is running), run:
382382+383383+```bash
384384+xcrun simctl openurl booted "dev.malpercio.identitywallet:/oauth/callback?code=test&state=abc"
385385+```
386386+387387+**Step 3: Verify the handler fired**
388388+389389+In the `cargo tauri ios dev` terminal output, confirm you see:
390390+391391+```
392392+INFO identity_wallet::oauth: OAuth deep-link callback received url=dev.malpercio.identitywallet:/oauth/callback?code=test&state=abc
393393+INFO identity_wallet::oauth: pending_auth slot present: false
394394+```
395395+396396+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.
397397+398398+**Step 4: Commit**
399399+400400+```bash
401401+git add apps/identity-wallet/src-tauri/Cargo.toml
402402+git add apps/identity-wallet/src-tauri/tauri.conf.json
403403+git add apps/identity-wallet/src-tauri/src/lib.rs
404404+git add apps/identity-wallet/src-tauri/src/oauth.rs
405405+git commit -m "feat(identity-wallet): wire deep-link plugin and AppState for OAuth callback (MM-149 phase 2)"
406406+```
407407+408408+<!-- END_TASK_5 -->
···11+# MM-149 OAuth PKCE Client Implementation Plan
22+33+**Goal:** Implement the DPoP keypair type (Keychain-persisted P-256 key) and the manual JOSE proof builder, plus Keychain helpers for OAuth tokens.
44+55+**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.
66+77+**Tech Stack:** `p256 = "0.13"` (ecdsa + pkcs8 features), `sha2 = "0.10"`, `base64 = "0.21"` (URL_SAFE_NO_PAD), `uuid = "1"` (v4)
88+99+**Scope:** 7 phases from original design (phase 3 of 7)
1010+1111+**Codebase verified:** 2026-03-23
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+### MM-149.AC3: DPoP proofs are correctly formed
2020+- **MM-149.AC3.1 Success:** DPoP proof header contains `typ: "dpop+jwt"`, `alg: "ES256"`, and a valid P-256 `jwk`
2121+- **MM-149.AC3.2 Success:** DPoP proof payload contains `jti`, `htm`, `htu`, `iat`
2222+- **MM-149.AC3.3 Success:** `ath` claim present and equals `base64url(sha256(access_token))` on resource requests
2323+- **MM-149.AC3.4 Success:** `nonce` claim present when a server nonce has been provided
2424+- **MM-149.AC3.5 Success:** Proof signature verifies against the `jwk` embedded in the header
2525+2626+---
2727+2828+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2929+3030+<!-- START_TASK_1 -->
3131+### Task 1: Add sha2, base64, uuid dependencies to identity-wallet Cargo.toml
3232+3333+**Verifies:** None (infrastructure)
3434+3535+**Files:**
3636+- Modify: `apps/identity-wallet/src-tauri/Cargo.toml`
3737+3838+These crates are already in workspace dependencies (root `Cargo.toml` lines 65, 68, 71) but are not yet declared in identity-wallet.
3939+4040+**Step 1: Add to `[dependencies]`**
4141+4242+Add after the existing `serde_json = { workspace = true }` line:
4343+4444+```toml
4545+sha2 = { workspace = true }
4646+base64 = { workspace = true }
4747+uuid = { workspace = true }
4848+```
4949+5050+The `uuid` workspace dep already has `features = ["v4"]` so no extra features spec needed.
5151+5252+**Step 2: Build to verify**
5353+5454+```bash
5555+cargo build -p identity-wallet
5656+```
5757+5858+Expected: builds without errors.
5959+6060+<!-- END_TASK_1 -->
6161+6262+<!-- START_TASK_2 -->
6363+### Task 2: Add OAuth Keychain helpers to keychain.rs
6464+6565+**Verifies:** None (helpers used by Phase 5; no AC directly)
6666+6767+**Files:**
6868+- Modify: `apps/identity-wallet/src-tauri/src/keychain.rs`
6969+7070+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).
7171+7272+**Step 1: Read the bottom of `apps/identity-wallet/src-tauri/src/keychain.rs`**
7373+7474+Find where the existing helpers and constants are defined (the public `store_item`/`get_item`/`delete_item` API plus the `SERVICE` constant).
7575+7676+**Step 2: Add the four OAuth Keychain helpers**
7777+7878+Add at the end of the file (before any `#[cfg(test)]` block if one exists):
7979+8080+```rust
8181+// ── OAuth Keychain helpers ─────────────────────────────────────────────────────
8282+8383+const DPOP_KEY_PRIV_ACCOUNT: &str = "oauth-dpop-key-priv";
8484+const OAUTH_ACCESS_TOKEN_ACCOUNT: &str = "oauth-access-token";
8585+const OAUTH_REFRESH_TOKEN_ACCOUNT: &str = "oauth-refresh-token";
8686+8787+/// Store the DPoP private key scalar (32 bytes) in the Keychain.
8888+pub fn store_dpop_key(private_bytes: &[u8]) -> Result<(), KeychainError> {
8989+ store_item(DPOP_KEY_PRIV_ACCOUNT, private_bytes)
9090+}
9191+9292+/// Load the DPoP private key scalar from the Keychain.
9393+///
9494+/// Returns `None` if no key has been stored yet (first run).
9595+pub fn load_dpop_key() -> Option<[u8; 32]> {
9696+ match get_item(DPOP_KEY_PRIV_ACCOUNT) {
9797+ Ok(bytes) if bytes.len() == 32 => {
9898+ let mut arr = [0u8; 32];
9999+ arr.copy_from_slice(&bytes);
100100+ Some(arr)
101101+ }
102102+ Ok(_) => {
103103+ tracing::warn!("DPoP key in Keychain has unexpected length; treating as absent");
104104+ None
105105+ }
106106+ Err(e) if is_not_found(&e) => None,
107107+ Err(e) => {
108108+ tracing::error!(error = ?e, "Keychain error loading DPoP key");
109109+ None
110110+ }
111111+ }
112112+}
113113+114114+/// Store the OAuth access token and refresh token in the Keychain.
115115+pub fn store_oauth_tokens(access_token: &str, refresh_token: &str) -> Result<(), KeychainError> {
116116+ store_item(OAUTH_ACCESS_TOKEN_ACCOUNT, access_token.as_bytes())?;
117117+ store_item(OAUTH_REFRESH_TOKEN_ACCOUNT, refresh_token.as_bytes())?;
118118+ Ok(())
119119+}
120120+121121+/// Load the OAuth access token and refresh token from the Keychain.
122122+///
123123+/// Returns `None` if either token is missing (not yet authenticated).
124124+pub fn load_oauth_tokens() -> Option<(String, String)> {
125125+ let access = match get_item(OAUTH_ACCESS_TOKEN_ACCOUNT) {
126126+ Ok(b) => String::from_utf8(b).ok()?,
127127+ Err(e) if is_not_found(&e) => return None,
128128+ Err(e) => {
129129+ tracing::error!(error = ?e, "Keychain error loading access token");
130130+ return None;
131131+ }
132132+ };
133133+ let refresh = match get_item(OAUTH_REFRESH_TOKEN_ACCOUNT) {
134134+ Ok(b) => String::from_utf8(b).ok()?,
135135+ Err(e) if is_not_found(&e) => return None,
136136+ Err(e) => {
137137+ tracing::error!(error = ?e, "Keychain error loading refresh token");
138138+ return None;
139139+ }
140140+ };
141141+ Some((access, refresh))
142142+}
143143+```
144144+145145+**Step 3: Build to verify**
146146+147147+```bash
148148+cargo build -p identity-wallet
149149+```
150150+151151+Expected: builds without errors. The `tracing` crate is already a dependency.
152152+153153+<!-- END_TASK_2 -->
154154+155155+<!-- END_SUBCOMPONENT_A -->
156156+157157+<!-- START_SUBCOMPONENT_B (tasks 3-5) -->
158158+159159+<!-- START_TASK_3 -->
160160+### Task 3: Implement DPoPKeypair in oauth.rs
161161+162162+**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)
163163+164164+**Files:**
165165+- Modify: `apps/identity-wallet/src-tauri/src/oauth.rs`
166166+167167+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)`.
168168+169169+**Step 1: Add imports at the top of oauth.rs**
170170+171171+Add these imports to the top of the file, after the existing `use` statements:
172172+173173+```rust
174174+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
175175+use p256::ecdsa::{SigningKey, Signature, signature::Signer};
176176+use p256::elliptic_curve::sec1::ToEncodedPoint;
177177+use sha2::{Digest, Sha256};
178178+use std::time::{SystemTime, UNIX_EPOCH};
179179+use uuid::Uuid;
180180+```
181181+182182+**Step 2: Define DPoPKeypair and OAuthError**
183183+184184+Add after the existing `AppState` definition:
185185+186186+```rust
187187+// ── OAuth error ───────────────────────────────────────────────────────────────
188188+189189+/// Error type for all OAuth-related operations.
190190+///
191191+/// Variants serialize as `{ "code": "SCREAMING_SNAKE_CASE" }` to match the
192192+/// existing error pattern (`CreateAccountError`, `DeviceKeyError`, etc.).
193193+#[derive(Debug, thiserror::Error, serde::Serialize)]
194194+#[serde(rename_all = "SCREAMING_SNAKE_CASE", tag = "code")]
195195+pub enum OAuthError {
196196+ #[error("DPoP keypair generation failed")]
197197+ DpopKeyGenFailed,
198198+ #[error("DPoP keypair is invalid")]
199199+ DpopKeyInvalid,
200200+ #[error("DPoP proof construction failed")]
201201+ DpopProofFailed,
202202+ #[error("Keychain error")]
203203+ KeychainError,
204204+ #[error("State mismatch in OAuth callback")]
205205+ StateMismatch,
206206+ #[error("OAuth callback abandoned")]
207207+ CallbackAbandoned,
208208+ #[error("PAR request failed")]
209209+ ParFailed,
210210+ #[error("Token exchange failed")]
211211+ TokenExchangeFailed,
212212+ #[error("Token refresh failed")]
213213+ TokenRefreshFailed,
214214+ #[error("Not authenticated")]
215215+ NotAuthenticated,
216216+}
217217+218218+// ── DPoP keypair ─────────────────────────────────────────────────────────────
219219+220220+/// A P-256 keypair used to produce DPoP proofs.
221221+///
222222+/// The private key scalar (32 bytes) is persisted in the iOS Keychain under
223223+/// `"oauth-dpop-key-priv"`. The same key is used for all DPoP proofs across
224224+/// app sessions — it is never rotated by this implementation.
225225+pub struct DPoPKeypair {
226226+ signing_key: SigningKey,
227227+}
228228+229229+impl DPoPKeypair {
230230+ /// Load the DPoP keypair from Keychain, or generate and persist a new one.
231231+ pub fn get_or_create() -> Result<Self, OAuthError> {
232232+ if let Some(private_bytes) = crate::keychain::load_dpop_key() {
233233+ let signing_key = SigningKey::from_slice(&private_bytes)
234234+ .map_err(|_| OAuthError::DpopKeyInvalid)?;
235235+ return Ok(Self { signing_key });
236236+ }
237237+238238+ // Generate a new P-256 keypair via the shared crypto crate.
239239+ let keypair = crypto::generate_p256_keypair().map_err(|_| OAuthError::DpopKeyGenFailed)?;
240240+ // `private_key_bytes` is `Zeroizing<[u8; 32]>`, which derefs directly to `[u8; 32]`.
241241+ let private_bytes: [u8; 32] = *keypair.private_key_bytes;
242242+243243+ crate::keychain::store_dpop_key(&private_bytes)
244244+ .map_err(|_| OAuthError::KeychainError)?;
245245+246246+ let signing_key = SigningKey::from_slice(&private_bytes)
247247+ .map_err(|_| OAuthError::DpopKeyInvalid)?;
248248+ Ok(Self { signing_key })
249249+ }
250250+251251+ /// Build the public JWK for this keypair (EC, P-256, kty/crv/x/y only — no private fields).
252252+ ///
253253+ /// The relay's validator expects exactly: `{"kty":"EC","crv":"P-256","x":"<b64url>","y":"<b64url>"}`.
254254+ pub fn public_jwk(&self) -> serde_json::Value {
255255+ let verifying_key = self.signing_key.verifying_key();
256256+ let point = verifying_key.to_encoded_point(false); // false = uncompressed: 04 || x || y
257257+ let x = URL_SAFE_NO_PAD.encode(point.x().expect("P-256 uncompressed point has x"));
258258+ let y = URL_SAFE_NO_PAD.encode(point.y().expect("P-256 uncompressed point has y"));
259259+ serde_json::json!({
260260+ "kty": "EC",
261261+ "crv": "P-256",
262262+ "x": x,
263263+ "y": y,
264264+ })
265265+ }
266266+267267+ /// Compute the RFC 7638 JWK thumbprint: `base64url(SHA-256(canonical_jwk_json))`.
268268+ ///
269269+ /// The canonical JSON uses lexicographically-sorted keys (crv, kty, x, y) per RFC 7638 §3.2.
270270+ /// This matches the relay's `jwk_thumbprint()` function in `crates/relay/src/auth/dpop.rs`.
271271+ pub fn public_jwk_thumbprint(&self) -> String {
272272+ let jwk = self.public_jwk();
273273+ // Canonical member set per RFC 7638 §3.2 — lexicographic order for EC keys.
274274+ // serde_json internally represents JSON objects as BTreeMap, which serializes
275275+ // keys in lexicographic order. This is what RFC 7638 §3.2 requires for the
276276+ // canonical JSON. The key ordering here (crv < kty < x < y) is lexicographic.
277277+ let canonical = serde_json::json!({
278278+ "crv": jwk["crv"],
279279+ "kty": jwk["kty"],
280280+ "x": jwk["x"],
281281+ "y": jwk["y"],
282282+ });
283283+ let canonical_json = serde_json::to_string(&canonical)
284284+ .expect("canonical JWK serialization is infallible for known types");
285285+ let hash = Sha256::digest(canonical_json.as_bytes());
286286+ URL_SAFE_NO_PAD.encode(hash)
287287+ }
288288+289289+ /// Build a DPoP proof JWT for the given HTTP method, URL, and optional claims.
290290+ ///
291291+ /// - `htm`: HTTP method in uppercase, e.g. `"POST"` or `"GET"`
292292+ /// - `htu`: Full target URL without query string, e.g. `"https://relay.ezpds.com/oauth/token"`
293293+ /// - `nonce`: Server-issued nonce from a prior `use_dpop_nonce` 400 response (if any)
294294+ /// - `ath`: `base64url(SHA-256(access_token_ascii))` — required for resource requests; None for token requests
295295+ ///
296296+ /// Proof format: `base64url(header_json)`.`base64url(claims_json)`.`base64url(sig)`
297297+ /// where sig is the raw 64-byte R||S P-256 ECDSA signature of the signing input.
298298+ pub fn make_proof(
299299+ &self,
300300+ htm: &str,
301301+ htu: &str,
302302+ nonce: Option<&str>,
303303+ ath: Option<&str>,
304304+ ) -> Result<String, OAuthError> {
305305+ let jwk = self.public_jwk();
306306+307307+ // Header JSON.
308308+ let header = serde_json::json!({
309309+ "typ": "dpop+jwt",
310310+ "alg": "ES256",
311311+ "jwk": jwk,
312312+ });
313313+ let header_b64 = URL_SAFE_NO_PAD.encode(
314314+ serde_json::to_vec(&header).map_err(|_| OAuthError::DpopProofFailed)?,
315315+ );
316316+317317+ // Claims JSON.
318318+ let iat = SystemTime::now()
319319+ .duration_since(UNIX_EPOCH)
320320+ .map_err(|_| OAuthError::DpopProofFailed)?
321321+ .as_secs() as i64;
322322+323323+ let mut claims = serde_json::json!({
324324+ "jti": Uuid::new_v4().to_string(),
325325+ "htm": htm,
326326+ "htu": htu,
327327+ "iat": iat,
328328+ });
329329+330330+ if let Some(n) = nonce {
331331+ claims["nonce"] = serde_json::Value::String(n.to_string());
332332+ }
333333+ if let Some(a) = ath {
334334+ claims["ath"] = serde_json::Value::String(a.to_string());
335335+ }
336336+337337+ let claims_b64 = URL_SAFE_NO_PAD.encode(
338338+ serde_json::to_vec(&claims).map_err(|_| OAuthError::DpopProofFailed)?,
339339+ );
340340+341341+ // Sign `header_b64.claims_b64` bytes with P-256/SHA-256.
342342+ let signing_input = format!("{header_b64}.{claims_b64}");
343343+ let signature: Signature = self.signing_key.sign(signing_input.as_bytes());
344344+ // Normalize to low-S (consistent with the rest of the codebase, even though
345345+ // the relay's DPoP validator does not require it — low-S is harmless and keeps
346346+ // key usage consistent with ATProto expectations).
347347+ let signature = signature.normalize_s().unwrap_or(signature);
348348+ let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes().as_slice());
349349+350350+ Ok(format!("{signing_input}.{sig_b64}"))
351351+ }
352352+353353+ /// Compute `base64url(SHA-256(access_token))` — the `ath` claim for resource requests.
354354+ pub fn compute_ath(access_token: &str) -> String {
355355+ let hash = Sha256::digest(access_token.as_bytes());
356356+ URL_SAFE_NO_PAD.encode(hash)
357357+ }
358358+}
359359+```
360360+361361+**Step 3: Build to verify**
362362+363363+```bash
364364+cargo build -p identity-wallet
365365+```
366366+367367+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).
368368+369369+<!-- END_TASK_3 -->
370370+371371+<!-- START_TASK_4 -->
372372+### Task 4: Write tests for DPoPKeypair
373373+374374+**Verifies:** MM-149.AC3.1, MM-149.AC3.2, MM-149.AC3.3, MM-149.AC3.4, MM-149.AC3.5
375375+376376+**Files:**
377377+- Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` (add `#[cfg(test)]` module)
378378+379379+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.
380380+381381+**Tests must verify:**
382382+383383+- **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
384384+- **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
385385+- **MM-149.AC3.3:** Generate a proof with `ath = Some("abc123")`; check `claims.ath = "abc123"`; generate without ath; check `claims.ath` is absent
386386+- **MM-149.AC3.4:** Generate with `nonce = Some("testnonce")`; check `claims.nonce = "testnonce"`; generate without nonce; check `claims.nonce` is absent
387387+- **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`
388388+389389+**Step 1: Add the test module at the bottom of oauth.rs**
390390+391391+Add:
392392+393393+```rust
394394+#[cfg(test)]
395395+mod tests {
396396+ use super::*;
397397+ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
398398+ use p256::ecdsa::signature::Verifier;
399399+400400+ fn decode_jwt_part(b64: &str) -> serde_json::Value {
401401+ let bytes = URL_SAFE_NO_PAD.decode(b64).expect("valid base64url");
402402+ serde_json::from_slice(&bytes).expect("valid JSON")
403403+ }
404404+405405+ fn split_proof(proof: &str) -> (&str, &str, &str) {
406406+ let parts: Vec<&str> = proof.splitn(3, '.').collect();
407407+ assert_eq!(parts.len(), 3, "JWT must have 3 parts");
408408+ (parts[0], parts[1], parts[2])
409409+ }
410410+411411+ #[test]
412412+ fn dpop_proof_header_has_required_fields() {
413413+ // MM-149.AC3.1
414414+ let kp = DPoPKeypair::get_or_create().expect("keypair must generate");
415415+ let proof = kp.make_proof("POST", "https://example.com/oauth/token", None, None)
416416+ .expect("proof must build");
417417+ let (header_b64, _, _) = split_proof(&proof);
418418+ let header = decode_jwt_part(header_b64);
419419+420420+ assert_eq!(header["typ"].as_str(), Some("dpop+jwt"));
421421+ assert_eq!(header["alg"].as_str(), Some("ES256"));
422422+ assert_eq!(header["jwk"]["kty"].as_str(), Some("EC"));
423423+ assert_eq!(header["jwk"]["crv"].as_str(), Some("P-256"));
424424+ assert!(header["jwk"]["x"].as_str().map(|s| !s.is_empty()).unwrap_or(false));
425425+ assert!(header["jwk"]["y"].as_str().map(|s| !s.is_empty()).unwrap_or(false));
426426+ }
427427+428428+ #[test]
429429+ fn dpop_proof_claims_has_required_fields() {
430430+ // MM-149.AC3.2
431431+ let kp = DPoPKeypair::get_or_create().expect("keypair must generate");
432432+ let proof = kp.make_proof("GET", "https://example.com/xrpc/foo", None, None)
433433+ .expect("proof must build");
434434+ let (_, claims_b64, _) = split_proof(&proof);
435435+ let claims = decode_jwt_part(claims_b64);
436436+437437+ assert!(claims["jti"].as_str().map(|s| !s.is_empty()).unwrap_or(false));
438438+ assert_eq!(claims["htm"].as_str(), Some("GET"));
439439+ assert_eq!(claims["htu"].as_str(), Some("https://example.com/xrpc/foo"));
440440+ let now = std::time::SystemTime::now()
441441+ .duration_since(std::time::UNIX_EPOCH)
442442+ .unwrap()
443443+ .as_secs() as i64;
444444+ let iat = claims["iat"].as_i64().expect("iat must be integer");
445445+ assert!((now - iat).abs() < 5, "iat must be within 5 seconds of now");
446446+ }
447447+448448+ #[test]
449449+ fn dpop_proof_includes_ath_when_supplied() {
450450+ // MM-149.AC3.3
451451+ let kp = DPoPKeypair::get_or_create().expect("keypair must generate");
452452+ let proof_with = kp.make_proof("GET", "https://example.com/resource", None, Some("abc123"))
453453+ .expect("proof with ath must build");
454454+ let (_, claims_b64, _) = split_proof(&proof_with);
455455+ let claims = decode_jwt_part(claims_b64);
456456+ assert_eq!(claims["ath"].as_str(), Some("abc123"), "ath must be present");
457457+458458+ let proof_without = kp.make_proof("GET", "https://example.com/resource", None, None)
459459+ .expect("proof without ath must build");
460460+ let (_, claims_b64, _) = split_proof(&proof_without);
461461+ let claims = decode_jwt_part(claims_b64);
462462+ assert!(claims["ath"].is_null(), "ath must be absent when not supplied");
463463+ }
464464+465465+ #[test]
466466+ fn dpop_proof_includes_nonce_when_supplied() {
467467+ // MM-149.AC3.4
468468+ let kp = DPoPKeypair::get_or_create().expect("keypair must generate");
469469+ let proof = kp.make_proof("POST", "https://example.com/oauth/token", Some("nonce123"), None)
470470+ .expect("proof with nonce must build");
471471+ let (_, claims_b64, _) = split_proof(&proof);
472472+ let claims = decode_jwt_part(claims_b64);
473473+ assert_eq!(claims["nonce"].as_str(), Some("nonce123"), "nonce must be present");
474474+475475+ let proof_no = kp.make_proof("POST", "https://example.com/oauth/token", None, None)
476476+ .expect("proof without nonce must build");
477477+ let (_, claims_b64, _) = split_proof(&proof_no);
478478+ let claims = decode_jwt_part(claims_b64);
479479+ assert!(claims["nonce"].is_null(), "nonce must be absent when not supplied");
480480+ }
481481+482482+ #[test]
483483+ fn dpop_proof_signature_verifies_against_embedded_jwk() {
484484+ // MM-149.AC3.5
485485+ use p256::elliptic_curve::sec1::EncodedPoint;
486486+487487+ let kp = DPoPKeypair::get_or_create().expect("keypair must generate");
488488+ let proof = kp.make_proof("POST", "https://example.com/oauth/token", None, None)
489489+ .expect("proof must build");
490490+ let (header_b64, claims_b64, sig_b64) = split_proof(&proof);
491491+492492+ // Reconstruct verifying key from the embedded JWK.
493493+ let header = decode_jwt_part(header_b64);
494494+ let x_bytes = URL_SAFE_NO_PAD.decode(header["jwk"]["x"].as_str().unwrap()).unwrap();
495495+ let y_bytes = URL_SAFE_NO_PAD.decode(header["jwk"]["y"].as_str().unwrap()).unwrap();
496496+ // Build uncompressed point: 0x04 || x || y
497497+ let mut point_bytes = vec![0x04u8];
498498+ point_bytes.extend_from_slice(&x_bytes);
499499+ point_bytes.extend_from_slice(&y_bytes);
500500+ let point = EncodedPoint::from_bytes(&point_bytes).expect("valid uncompressed point");
501501+ let verifying_key = p256::ecdsa::VerifyingKey::from_encoded_point(&point)
502502+ .expect("valid verifying key from JWK");
503503+504504+ // Decode the signature.
505505+ let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).expect("valid base64url sig");
506506+ let signature = p256::ecdsa::Signature::from_bytes(sig_bytes.as_slice().into())
507507+ .expect("valid R||S signature bytes");
508508+509509+ // Verify the signature over the signing input.
510510+ let signing_input = format!("{header_b64}.{claims_b64}");
511511+ verifying_key.verify(signing_input.as_bytes(), &signature)
512512+ .expect("signature must verify against embedded JWK");
513513+ }
514514+515515+ #[test]
516516+ fn compute_ath_matches_sha256_base64url() {
517517+ let ath = DPoPKeypair::compute_ath("test_access_token");
518518+ // SHA-256("test_access_token") = known value
519519+ let expected = {
520520+ use sha2::{Digest, Sha256};
521521+ let hash = Sha256::digest(b"test_access_token");
522522+ URL_SAFE_NO_PAD.encode(hash)
523523+ };
524524+ assert_eq!(ath, expected);
525525+ }
526526+}
527527+```
528528+529529+**Step 2: Run the tests**
530530+531531+```bash
532532+cargo test -p identity-wallet dpop
533533+```
534534+535535+Expected output: all 5 tests pass.
536536+537537+**Step 3: Run all identity-wallet tests to confirm no regressions**
538538+539539+```bash
540540+cargo test -p identity-wallet
541541+```
542542+543543+Expected: all tests pass.
544544+545545+**Step 4: Commit**
546546+547547+```bash
548548+git add apps/identity-wallet/src-tauri/Cargo.toml
549549+git add apps/identity-wallet/src-tauri/src/keychain.rs
550550+git add apps/identity-wallet/src-tauri/src/oauth.rs
551551+git commit -m "feat(identity-wallet): DPoP keypair, proof builder, and OAuth Keychain helpers (MM-149 phase 3)"
552552+```
553553+554554+<!-- END_TASK_4 -->
555555+556556+<!-- END_SUBCOMPONENT_B -->
···11+# MM-149 OAuth PKCE Client Implementation Plan
22+33+**Goal:** Implement PKCE generation utilities and the PAR HTTP call that kicks off the authorization flow.
44+55+**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.
66+77+**Tech Stack:** `rand_core = "0.6"` (OsRng), `sha2 = "0.10"`, `base64 = "0.21"`, `reqwest 0.12` (+ `form` feature)
88+99+**Scope:** 7 phases from original design (phase 4 of 7)
1010+1111+**Codebase verified:** 2026-03-23
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+### MM-149.AC1: PAR flow completes successfully
2020+- **MM-149.AC1.1 Success:** `start_oauth_flow` posts to `/oauth/par` with a valid DPoP proof and receives a `request_uri` (201 response)
2121+- **MM-149.AC1.3 Failure:** PAR request with unknown `client_id` returns a client error (relay rejects it)
2222+- **MM-149.AC1.4 Failure:** PAR request missing `code_challenge` returns a client error
2323+2424+> 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.
2525+2626+---
2727+2828+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
2929+3030+<!-- START_TASK_1 -->
3131+### Task 1: Add rand_core and reqwest form feature
3232+3333+**Verifies:** None (infrastructure)
3434+3535+**Files:**
3636+- Modify: `apps/identity-wallet/src-tauri/Cargo.toml`
3737+3838+Two changes:
3939+1. `rand_core` workspace dep is needed for PKCE random bytes (`OsRng.fill_bytes()`). The workspace dep already has `features = ["getrandom"]`.
4040+2. `reqwest`'s `.form()` method requires the `form` cargo feature — without it, `.form()` does not exist on the request builder.
4141+4242+**Step 1: Add rand_core and update reqwest features**
4343+4444+In `apps/identity-wallet/src-tauri/Cargo.toml`, add `rand_core = { workspace = true }` and update reqwest's features:
4545+4646+Before:
4747+```toml
4848+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
4949+```
5050+5151+After:
5252+```toml
5353+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "form"] }
5454+rand_core = { workspace = true }
5555+```
5656+5757+**Step 2: Build to verify**
5858+5959+```bash
6060+cargo build -p identity-wallet
6161+```
6262+6363+Expected: builds without errors.
6464+6565+<!-- END_TASK_1 -->
6666+6767+<!-- START_TASK_2 -->
6868+### Task 2: Implement PKCE utilities in oauth.rs
6969+7070+**Verifies:** Part of MM-149.AC1.1 (pkce verifier/challenge used in PAR call); tested directly in Task 4
7171+7272+**Files:**
7373+- Modify: `apps/identity-wallet/src-tauri/src/oauth.rs`
7474+7575+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)))`.
7676+7777+**Step 1: Add imports**
7878+7979+Add at the top of oauth.rs, after the existing `use` statements:
8080+8181+```rust
8282+use rand_core::{OsRng, RngCore};
8383+```
8484+8585+**Step 2: Add the pkce module**
8686+8787+Add inside `oauth.rs` (after the `DPoPKeypair` impl, before `#[cfg(test)]`):
8888+8989+```rust
9090+// ── PKCE utilities ────────────────────────────────────────────────────────────
9191+9292+pub mod pkce {
9393+ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
9494+ use rand_core::{OsRng, RngCore};
9595+ use sha2::{Digest, Sha256};
9696+9797+ /// Generate a PKCE code_verifier and code_challenge pair.
9898+ ///
9999+ /// - `verifier`: 32 OS-random bytes base64url-encoded (43 chars, all unreserved per RFC 7636 §4.1)
100100+ /// - `challenge`: `base64url(SHA-256(verifier))` (S256 method per RFC 7636 §4.2)
101101+ ///
102102+ /// Returns `(verifier, challenge)`.
103103+ pub fn generate() -> (String, String) {
104104+ let mut bytes = [0u8; 32];
105105+ OsRng.fill_bytes(&mut bytes);
106106+ let verifier = URL_SAFE_NO_PAD.encode(bytes);
107107+ let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
108108+ (verifier, challenge)
109109+ }
110110+}
111111+112112+/// Generate a CSRF state parameter: 16 OS-random bytes base64url-encoded (22 chars).
113113+pub fn generate_state_param() -> String {
114114+ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
115115+ let mut bytes = [0u8; 16];
116116+ OsRng.fill_bytes(&mut bytes);
117117+ URL_SAFE_NO_PAD.encode(bytes)
118118+}
119119+```
120120+121121+**Step 3: Build to verify**
122122+123123+```bash
124124+cargo build -p identity-wallet
125125+```
126126+127127+Expected: builds without errors.
128128+129129+<!-- END_TASK_2 -->
130130+131131+<!-- END_SUBCOMPONENT_A -->
132132+133133+<!-- START_SUBCOMPONENT_B (tasks 3-4) -->
134134+135135+<!-- START_TASK_3 -->
136136+### Task 3: Add PAR call to RelayClient in http.rs
137137+138138+**Verifies:** MM-149.AC1.1 (PAR returns 201 with request_uri)
139139+140140+**Files:**
141141+- Modify: `apps/identity-wallet/src-tauri/src/http.rs`
142142+143143+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.
144144+145145+**Step 1: Read the full current `http.rs`**
146146+147147+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.
148148+149149+**Step 2: Add imports and ParResponse at the top of http.rs**
150150+151151+Add at the top (after the existing `use` statements):
152152+153153+```rust
154154+use crate::oauth::OAuthError;
155155+```
156156+157157+Add the `ParResponse` type after the existing struct definitions but before the `impl RelayClient` block:
158158+159159+```rust
160160+/// Successful response from `POST /oauth/par` (RFC 9126 §2.2).
161161+#[derive(Debug, serde::Deserialize)]
162162+pub struct ParResponse {
163163+ pub request_uri: String,
164164+ pub expires_in: u32,
165165+}
166166+```
167167+168168+**Step 3: Add the `par()` method to `impl RelayClient`**
169169+170170+Add after the existing `post_with_bearer()` method:
171171+172172+```rust
173173+/// POST `/oauth/par` — push the authorization request parameters to the relay.
174174+///
175175+/// Sends the required PKCE and OAuth parameters as `application/x-www-form-urlencoded`.
176176+/// Includes a `DPoP` proof header per RFC 9449 §6.
177177+///
178178+/// `dpop_jkt` is the JWK thumbprint of the DPoP key; included as a form field for
179179+/// servers that support PAR-level DPoP key binding (the relay currently ignores it,
180180+/// but it is spec-correct to send it).
181181+pub async fn par(
182182+ &self,
183183+ code_challenge: &str,
184184+ state_param: &str,
185185+ dpop_proof: &str,
186186+ dpop_jkt: &str,
187187+ login_hint: Option<&str>,
188188+) -> Result<ParResponse, OAuthError> {
189189+ let url = format!("{}/oauth/par", self.base_url);
190190+191191+ let mut fields = vec![
192192+ ("client_id", "dev.malpercio.identitywallet"),
193193+ ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"),
194194+ ("code_challenge", code_challenge),
195195+ ("code_challenge_method", "S256"),
196196+ ("state", state_param),
197197+ ("response_type", "code"),
198198+ ("scope", "atproto"),
199199+ ("dpop_jkt", dpop_jkt),
200200+ ];
201201+202202+ let hint_owned;
203203+ if let Some(hint) = login_hint {
204204+ hint_owned = hint.to_string();
205205+ fields.push(("login_hint", &hint_owned));
206206+ }
207207+208208+ let resp = self
209209+ .client
210210+ .post(&url)
211211+ .header("DPoP", dpop_proof)
212212+ .form(&fields)
213213+ .send()
214214+ .await
215215+ .map_err(|e| {
216216+ tracing::error!(error = %e, "PAR request network error");
217217+ OAuthError::ParFailed
218218+ })?;
219219+220220+ let status = resp.status();
221221+ if status.as_u16() != 201 {
222222+ let body = resp.text().await.unwrap_or_default();
223223+ tracing::error!(status = %status, body = %body, "PAR request failed");
224224+ return Err(OAuthError::ParFailed);
225225+ }
226226+227227+ resp.json::<ParResponse>().await.map_err(|e| {
228228+ tracing::error!(error = %e, "PAR response deserialization failed");
229229+ OAuthError::ParFailed
230230+ })
231231+}
232232+```
233233+234234+**Step 4: Build to verify**
235235+236236+```bash
237237+cargo build -p identity-wallet
238238+```
239239+240240+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).
241241+242242+<!-- END_TASK_3 -->
243243+244244+<!-- START_TASK_4 -->
245245+### Task 4: Write PKCE unit tests and PAR integration test
246246+247247+**Verifies:** MM-149.AC1.1, MM-149.AC1.3 (relay rejects unknown client), MM-149.AC1.4 (PAR without code_challenge returns 4xx)
248248+249249+**Files:**
250250+- Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` (add tests to existing `#[cfg(test)]` module)
251251+252252+**Step 1: Add PKCE unit tests to the existing `#[cfg(test)]` mod in oauth.rs**
253253+254254+Inside the existing `#[cfg(test)]` module (from Phase 3), add:
255255+256256+```rust
257257+ // PKCE tests
258258+ #[test]
259259+ fn pkce_verifier_is_43_unreserved_chars() {
260260+ let (verifier, _) = pkce::generate();
261261+ assert_eq!(verifier.len(), 43, "base64url of 32 bytes must be 43 chars");
262262+ // RFC 7636 §4.1: ALPHA / DIGIT / "-" / "." / "_" / "~"
263263+ assert!(
264264+ verifier.chars().all(|c| c.is_alphanumeric() || "-._~".contains(c)),
265265+ "verifier must consist only of unreserved chars: got {verifier}"
266266+ );
267267+ }
268268+269269+ #[test]
270270+ fn pkce_challenge_equals_sha256_base64url_of_verifier() {
271271+ use sha2::{Digest, Sha256};
272272+ let (verifier, challenge) = pkce::generate();
273273+ let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
274274+ assert_eq!(challenge, expected, "challenge must be base64url(sha256(verifier))");
275275+ }
276276+277277+ #[test]
278278+ fn state_param_is_22_chars() {
279279+ let state = generate_state_param();
280280+ assert_eq!(state.len(), 22, "base64url of 16 bytes must be 22 chars");
281281+ }
282282+283283+ #[test]
284284+ fn pkce_verifiers_are_unique() {
285285+ let (v1, _) = pkce::generate();
286286+ let (v2, _) = pkce::generate();
287287+ assert_ne!(v1, v2, "each generate() call must produce a different verifier");
288288+ }
289289+```
290290+291291+**Step 2: Add a PAR integration test (requires running relay)**
292292+293293+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.
294294+295295+Add to the same test module:
296296+297297+```rust
298298+ /// Integration test: PAR call against a running relay.
299299+ ///
300300+ /// Requires the relay to be running at http://localhost:8080 with the V013
301301+ /// migration applied (identity-wallet client registered).
302302+ ///
303303+ /// Run with: cargo test -p identity-wallet par_integration -- --include-ignored --nocapture
304304+ #[tokio::test]
305305+ #[ignore = "requires running relay at localhost:8080"]
306306+ async fn par_integration_returns_201_with_request_uri() {
307307+ let relay = crate::http::RelayClient::new();
308308+ let keypair = DPoPKeypair::get_or_create().expect("keypair must generate");
309309+ // `htu` is embedded in the DPoP proof JWT claims (the `htu` claim per RFC 9449 §4.2),
310310+ // not used for the HTTP request itself — `relay.par()` constructs the URL internally.
311311+ let htu = format!("{}/oauth/par", crate::http::RelayClient::base_url());
312312+ let dpop_proof = keypair.make_proof("POST", &htu, None, None)
313313+ .expect("DPoP proof must build");
314314+ let dpop_jkt = keypair.public_jwk_thumbprint();
315315+ let (_, challenge) = pkce::generate();
316316+ let state = generate_state_param();
317317+318318+ let resp = relay.par(&challenge, &state, &dpop_proof, &dpop_jkt, None)
319319+ .await
320320+ .expect("PAR must succeed");
321321+322322+ assert!(
323323+ resp.request_uri.starts_with("urn:ietf:params:oauth:request_uri:"),
324324+ "request_uri must use OAuth PAR URN scheme, got: {}",
325325+ resp.request_uri
326326+ );
327327+ assert_eq!(resp.expires_in, 60);
328328+ }
329329+```
330330+331331+**Step 2b: Add a negative integration test for AC1.4 (PAR without code_challenge)**
332332+333333+Add to the same test module, immediately after `par_integration_returns_201_with_request_uri`:
334334+335335+```rust
336336+ /// Integration test: PAR call missing code_challenge is rejected by relay.
337337+ ///
338338+ /// Verifies MM-149.AC1.4: the relay returns a client error (400) when
339339+ /// code_challenge is absent from the PAR request.
340340+ ///
341341+ /// Run with: cargo test -p identity-wallet par_missing_challenge -- --include-ignored --nocapture
342342+ #[tokio::test]
343343+ #[ignore = "requires running relay at localhost:8080"]
344344+ async fn par_missing_code_challenge_returns_client_error() {
345345+ // Build a minimal PAR form body with no code_challenge field.
346346+ let base_url = crate::http::RelayClient::base_url();
347347+ let url = format!("{base_url}/oauth/par");
348348+ let keypair = DPoPKeypair::get_or_create().expect("keypair must generate");
349349+ let dpop_proof = keypair
350350+ .make_proof("POST", &url, None, None)
351351+ .expect("DPoP proof must build");
352352+353353+ let client = reqwest::Client::new();
354354+ let resp = client
355355+ .post(&url)
356356+ .header("DPoP", dpop_proof)
357357+ .form(&[
358358+ ("client_id", "dev.malpercio.identitywallet"),
359359+ ("redirect_uri", "dev.malpercio.identitywallet:/oauth/callback"),
360360+ ("code_challenge_method", "S256"),
361361+ ("state", "somestate"),
362362+ ("response_type", "code"),
363363+ ("scope", "atproto"),
364364+ // code_challenge intentionally omitted
365365+ ])
366366+ .send()
367367+ .await
368368+ .expect("request must reach relay");
369369+370370+ assert!(
371371+ resp.status().is_client_error(),
372372+ "relay must reject PAR without code_challenge with 4xx, got: {}",
373373+ resp.status()
374374+ );
375375+ }
376376+```
377377+378378+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.
379379+380380+**Step 3: Run PKCE unit tests**
381381+382382+```bash
383383+cargo test -p identity-wallet pkce
384384+```
385385+386386+Expected: 4 tests pass (pkce_verifier, pkce_challenge, state_param, pkce_verifiers_unique).
387387+388388+**Step 4: Run all identity-wallet tests**
389389+390390+```bash
391391+cargo test -p identity-wallet
392392+```
393393+394394+Expected: all tests pass (the PAR integration tests are skipped due to `#[ignore]`).
395395+396396+**Step 5: (Optional, requires running relay) Run the PAR integration tests**
397397+398398+Start the relay in another terminal, then:
399399+400400+```bash
401401+cargo test -p identity-wallet par_ -- --include-ignored --nocapture
402402+```
403403+404404+Expected:
405405+- `par_integration_returns_201_with_request_uri` passes (AC1.1)
406406+- `par_missing_code_challenge_returns_client_error` passes with 400 (AC1.4)
407407+408408+**Step 6: Commit**
409409+410410+```bash
411411+git add apps/identity-wallet/src-tauri/Cargo.toml
412412+git add apps/identity-wallet/src-tauri/src/http.rs
413413+git add apps/identity-wallet/src-tauri/src/oauth.rs
414414+git commit -m "feat(identity-wallet): PKCE generation and PAR HTTP call (MM-149 phase 4)"
415415+```
416416+417417+<!-- END_TASK_4 -->
418418+419419+<!-- END_SUBCOMPONENT_B -->
···11+# Test Requirements: MM-149 OAuth PKCE Client
22+33+## Summary
44+55+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`.
66+77+**Test file inventory:**
88+99+| File | Test Count | Type |
1010+|------|-----------|------|
1111+| `crates/relay/src/db/mod.rs` | 1 | Unit |
1212+| `apps/identity-wallet/src-tauri/src/oauth.rs` | 12 | Unit (+ 2 ignored integration) |
1313+| `apps/identity-wallet/src-tauri/src/oauth_client.rs` | 6 | Unit (httpmock) |
1414+| Manual (iOS Simulator) | 6 criteria | Human verification |
1515+1616+---
1717+1818+## Automated Tests
1919+2020+### AC1: PAR flow completes successfully
2121+2222+| Criterion | Test Type | File | Test Name Pattern | Phase | Notes |
2323+|-----------|-----------|------|-------------------|-------|-------|
2424+| 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:`. |
2525+| MM-149.AC1.2 | Human | -- | -- | 5/7 | See Human Verification section. Authorization URL opened in Safari cannot be asserted from `cargo test`. |
2626+| 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. |
2727+| 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`. |
2828+| 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. |
2929+3030+### AC2: OAuth callback received and code exchanged
3131+3232+| Criterion | Test Type | File | Test Name Pattern | Phase | Notes |
3333+|-----------|-----------|------|-------------------|-------|-------|
3434+| 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 })`. |
3535+| MM-149.AC2.2 | Human | -- | -- | 7 | Full token exchange requires a live relay with user consent in Safari. See Human Verification section. |
3636+| 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. |
3737+| 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. |
3838+| 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). |
3939+4040+### AC3: DPoP proofs are correctly formed
4141+4242+| Criterion | Test Type | File | Test Name Pattern | Phase | Notes |
4343+|-----------|-----------|------|-------------------|-------|-------|
4444+| 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`. |
4545+| 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. |
4646+| 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. |
4747+| 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. |
4848+| 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. |
4949+5050+**Supplementary test:**
5151+5252+| Criterion | Test Type | File | Test Name Pattern | Phase | Notes |
5353+|-----------|-----------|------|-------------------|-------|-------|
5454+| (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))`. |
5555+5656+### AC4: Tokens stored securely and loaded on restart
5757+5858+| Criterion | Test Type | File | Test Name Pattern | Phase | Notes |
5959+|-----------|-----------|------|-------------------|-------|-------|
6060+| 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. |
6161+| 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. |
6262+| 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. |
6363+6464+### AC5: Authenticated requests carry DPoP proofs
6565+6666+| Criterion | Test Type | File | Test Name Pattern | Phase | Notes |
6767+|-----------|-----------|------|-------------------|-------|-------|
6868+| 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. |
6969+| 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. |
7070+| 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. |
7171+7272+### AC6: Token refresh works transparently
7373+7474+| Criterion | Test Type | File | Test Name Pattern | Phase | Notes |
7575+|-----------|-----------|------|-------------------|-------|-------|
7676+| 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`. |
7777+| 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. |
7878+| 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)`. |
7979+8080+### AC7: Frontend authentication screens
8181+8282+All AC7 criteria require human verification. See Human Verification section below.
8383+8484+### AC8: Failed auth recovery
8585+8686+All AC8 criteria require human verification. See Human Verification section below.
8787+8888+---
8989+9090+## PKCE Utility Tests (No Direct AC, Foundation for AC1)
9191+9292+These tests validate the PKCE primitives used by `start_oauth_flow` to satisfy AC1.
9393+9494+| Test Name Pattern | Test Type | File | Phase | Notes |
9595+|-------------------|-----------|------|-------|-------|
9696+| `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. |
9797+| `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. |
9898+| `state_param_is_22_chars` | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | 4 | Asserts base64url of 16 bytes = 22 chars. |
9999+| `pkce_verifiers_are_unique` | Unit | `apps/identity-wallet/src-tauri/src/oauth.rs` | 4 | Two sequential `generate()` calls produce different verifiers. |
100100+101101+---
102102+103103+## Human Verification
104104+105105+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.
106106+107107+### MM-149.AC1.2: Authorization URL opened in system browser
108108+109109+**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.
110110+111111+**Verification steps:**
112112+1. Start the relay: `cargo run -p relay`
113113+2. Launch the app: `cd apps/identity-wallet && cargo tauri ios dev`
114114+3. Complete onboarding through step 10 (DID ceremony + Shamir backup)
115115+4. Tap "Continue" on the `complete` step
116116+5. **Verify:** Safari opens with a URL containing `client_id=dev.malpercio.identitywallet` and `request_uri=urn:ietf:params:oauth:request_uri:...`
117117+118118+### MM-149.AC2.2: Token exchange succeeds
119119+120120+**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.
121121+122122+**Verification steps:**
123123+1. Complete steps 1--5 from AC1.2 above (app transitions to `authenticating`, Safari opens)
124124+2. Complete the authorization consent in Safari
125125+3. **Verify:** Safari redirects to the app; app transitions from `authenticating` to `authenticated`
126126+4. **Verify:** No error messages in the `cargo tauri ios dev` console
127127+128128+### MM-149.AC2.5: use_dpop_nonce retry on token exchange
129129+130130+**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.
131131+132132+**Verification steps:**
133133+1. Complete the full OAuth flow (steps 1--3 from AC2.2 above)
134134+2. In the `cargo tauri ios dev` console output, search for the tracing log line:
135135+ ```
136136+ DEBUG identity_wallet::oauth: retrying token exchange with server nonce nonce=...
137137+ ```
138138+3. **Verify:** The log line appears, confirming the retry path was taken and succeeded (app reached `authenticated`)
139139+140140+**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.
141141+142142+### MM-149.AC4.1: Tokens stored in iOS Keychain
143143+144144+**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.
145145+146146+**Verification steps:**
147147+1. Complete the full OAuth flow (AC2.2 steps above)
148148+2. In the `cargo tauri ios dev` console, confirm **no** `tracing::error` lines mentioning "Keychain error" appear after "OAuth flow complete; session stored"
149149+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)
150150+151151+### MM-149.AC4.2: Tokens loaded on restart
152152+153153+**Justification:** The `setup()` closure runs during Tauri app initialization, which requires the full Tauri runtime. It cannot be invoked from `cargo test`.
154154+155155+**Verification steps:**
156156+1. Complete the full OAuth flow to reach `authenticated` state
157157+2. Force-quit the app (swipe up from app switcher or press Home + stop the Simulator process)
158158+3. Relaunch the app (tap the icon or run `cargo tauri ios dev` again)
159159+4. **Verify:** Onboarding is skipped; app shows `authenticated` directly
160160+5. **Verify:** Console shows the `auth_ready` event emission log (if tracing is enabled)
161161+162162+### MM-149.AC4.3: Tokens not in JS storage
163163+164164+**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.
165165+166166+**Verification steps:**
167167+1. Complete the full OAuth flow to reach `authenticated` state
168168+2. In Safari (macOS), open Web Inspector for the iOS Simulator (Develop menu > Simulator > identity-wallet)
169169+3. In the Web Inspector Console, run:
170170+ ```javascript
171171+ JSON.stringify(localStorage)
172172+ JSON.stringify(sessionStorage)
173173+ ```
174174+4. **Verify:** Neither output contains `access_token`, `refresh_token`, or any OAuth credential strings
175175+5. Also inspect IndexedDB and cookies in Web Inspector's Storage tab
176176+6. **Verify:** No OAuth tokens present in any JS-accessible storage
177177+178178+### MM-149.AC7.1: Auto-advance to authenticating after onboarding
179179+180180+**Justification:** Requires SvelteKit rendering in WKWebView (Tauri runtime) and user interaction through 10 onboarding steps.
181181+182182+**Verification steps:**
183183+1. Start the relay
184184+2. Launch the app in the iOS Simulator (`cargo tauri ios dev`)
185185+3. Complete all 10 onboarding steps (welcome through Shamir backup)
186186+4. On the `complete` step, tap "Continue"
187187+5. **Verify:** Screen transitions to the `authenticating` step (spinner visible, "Opening browser for authentication..." text)
188188+6. **Verify:** Safari opens automatically
189189+190190+### MM-149.AC7.2: Successful auth transitions to authenticated
191191+192192+**Verification steps:**
193193+1. Continue from AC7.1 (Safari is open with the consent page)
194194+2. Complete the authorization in Safari
195195+3. **Verify:** App transitions from `authenticating` to `authenticated` (checkmark icon, "Your identity wallet is ready." text)
196196+197197+### MM-149.AC7.3: Relaunch with stored tokens skips onboarding
198198+199199+**Verification steps:**
200200+1. Same as AC4.2 (force-quit and relaunch)
201201+2. **Verify:** The `welcome` step never appears; app starts at `authenticated`
202202+203203+### MM-149.AC7.4: Auth failure transitions to auth_failed
204204+205205+**Verification steps:**
206206+1. Stop the relay (so PAR will fail)
207207+2. Launch the app fresh (uninstall first to clear Keychain)
208208+3. Complete onboarding through step 10
209209+4. Tap "Continue"
210210+5. **Verify:** App transitions to `auth_failed` step (X icon, "Authentication Failed" heading, error code displayed)
211211+212212+### MM-149.AC8.1: "Try again" re-invokes start_oauth_flow
213213+214214+**Verification steps:**
215215+1. Reach the `auth_failed` state (AC7.4 steps above)
216216+2. Start the relay (so the retry can succeed)
217217+3. Tap "Try again"
218218+4. **Verify:** App transitions to `authenticating` (spinner appears, browser opens)
219219+5. **Verify:** No stale error state — `authError` is cleared before re-entering `authenticating`
220220+221221+### MM-149.AC8.2: "Start over" resets to step 1
222222+223223+**Verification steps:**
224224+1. Reach the `auth_failed` state (AC7.4 steps above)
225225+2. Tap "Start over"
226226+3. **Verify:** App transitions to the `welcome` step (first step of onboarding)
227227+4. **Verify:** All form state is reset (no pre-filled fields from the previous attempt)
228228+229229+---
230230+231231+## Coverage Matrix
232232+233233+| Criterion | Automated | Human | Implementation Phase |
234234+|-----------|-----------|-------|---------------------|
235235+| MM-149.AC1.1 | Integration (ignored) | -- | 4 |
236236+| MM-149.AC1.2 | -- | iOS Simulator | 5, 7 |
237237+| MM-149.AC1.3 | Unit (relay) | -- | 1 (+ existing relay tests) |
238238+| MM-149.AC1.4 | Integration (ignored) | -- | 4 |
239239+| MM-149.AC2.1 | Unit | -- | 5 |
240240+| MM-149.AC2.2 | -- | iOS Simulator | 5, 7 |
241241+| MM-149.AC2.3 | Unit | -- | 5 |
242242+| MM-149.AC2.4 | Unit | -- | 5 |
243243+| MM-149.AC2.5 | -- | iOS Simulator (log check) | 5, 7 |
244244+| MM-149.AC3.1 | Unit | -- | 3 |
245245+| MM-149.AC3.2 | Unit | -- | 3 |
246246+| MM-149.AC3.3 | Unit | -- | 3 |
247247+| MM-149.AC3.4 | Unit | -- | 3 |
248248+| MM-149.AC3.5 | Unit | -- | 3 |
249249+| MM-149.AC4.1 | -- | iOS Simulator | 5, 7 |
250250+| MM-149.AC4.2 | -- | iOS Simulator | 7 |
251251+| MM-149.AC4.3 | -- | iOS Simulator (Web Inspector) | 7 |
252252+| MM-149.AC5.1 | Unit (httpmock) | -- | 6 |
253253+| MM-149.AC5.2 | Unit (httpmock) | -- | 6 |
254254+| MM-149.AC5.3 | Unit (httpmock) | -- | 6 |
255255+| MM-149.AC6.1 | Unit (httpmock) | -- | 6 |
256256+| MM-149.AC6.2 | Unit (httpmock) | -- | 6 |
257257+| MM-149.AC6.3 | Unit (httpmock) | -- | 6 |
258258+| MM-149.AC7.1 | -- | iOS Simulator | 7 |
259259+| MM-149.AC7.2 | -- | iOS Simulator | 7 |
260260+| MM-149.AC7.3 | -- | iOS Simulator | 7 |
261261+| MM-149.AC7.4 | -- | iOS Simulator | 7 |
262262+| MM-149.AC8.1 | -- | iOS Simulator | 7 |
263263+| MM-149.AC8.2 | -- | iOS Simulator | 7 |
264264+265265+---
266266+267267+## Test Execution Commands
268268+269269+```bash
270270+# All automated tests (relay + identity-wallet)
271271+cargo test -p relay v013_seeds_identity_wallet_oauth_client
272272+cargo test -p identity-wallet
273273+274274+# DPoP proof tests only
275275+cargo test -p identity-wallet dpop
276276+277277+# PKCE tests only
278278+cargo test -p identity-wallet pkce
279279+280280+# handle_deep_link tests only
281281+cargo test -p identity-wallet handle_deep_link
282282+283283+# OAuthClient httpmock tests only
284284+cargo test -p identity-wallet oauth_client
285285+286286+# Integration tests (require running relay at localhost:8080)
287287+cargo test -p identity-wallet par_ -- --include-ignored --nocapture
288288+289289+# Full suite including integration tests
290290+cargo test -p identity-wallet -- --include-ignored --nocapture
291291+```