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.

docs: add MM-149 OAuth PKCE client design plan

Completed brainstorming session. Design includes:
- Single-command model: start_oauth_flow drives full round-trip via Tokio oneshot channel
- Manual JOSE DPoP proof construction (no new crate dependency)
- tauri-plugin-deep-link for custom URL scheme callback interception
- Relay V013 migration pre-registers identity-wallet OAuth client
- 7 implementation phases from relay migration through SvelteKit frontend

authored by

Malpercio and committed by
Tangled
c6c15f28 175d983f

+270
+270
docs/design-plans/2026-03-23-MM-149.md
··· 1 + # MM-149: OAuth PKCE Client Design 2 + 3 + ## Summary 4 + 5 + MM-149 implements the client side of the OAuth 2.0 PKCE + DPoP authorization flow for the identity-wallet app. The goal is to allow the app to authenticate a user against the relay and receive short-lived access tokens, while storing all credentials securely in the iOS Keychain — never in JavaScript-accessible storage. The relay already exposes OAuth endpoints; this ticket wires up the mobile client that calls them. 6 + 7 + The implementation spans three layers. At the relay layer, a database migration pre-registers the identity-wallet as a known OAuth client. At the Tauri backend layer, a single Rust command (`start_oauth_flow`) orchestrates the full round-trip: it generates PKCE and DPoP material, calls the relay's PAR endpoint, opens the system browser (Safari) for user consent, then parks on a Tokio channel waiting for the custom-scheme redirect to come back. A deep-link handler receives the redirect, validates the CSRF state parameter, and sends the authorization code down the channel; the command wakes, exchanges the code for tokens, and persists everything to Keychain. An `OAuthClient` wrapper then handles all subsequent authenticated requests by attaching fresh DPoP proofs, transparently refreshing the access token before it expires, and retrying once on `use_dpop_nonce` errors. At the SvelteKit frontend layer, two new onboarding steps and an error recovery screen drive the Tauri commands and reflect auth state to the user. 8 + 9 + ## Definition of Done 10 + 11 + 1. A relay DB migration pre-registers the identity-wallet as an OAuth client (`client_id = dev.malpercio.identitywallet`) 12 + 2. A new Tauri command `start_oauth_flow` drives the full PKCE+DPoP flow: generates PKCE verifier/challenge, generates (or loads) a DPoP keypair, calls PAR, opens the system browser to the consent screen with `login_hint`, and returns control to the app 13 + 3. Tauri intercepts the custom URL scheme redirect (`dev.malpercio.identitywallet:/oauth/callback`), extracts `code`+`state`, and exchanges them for tokens via an `exchange_oauth_code` command (with DPoP proof + `code_verifier`) 14 + 4. Tokens (`access_token`, `refresh_token`, DPoP keypair) stored in iOS Keychain 15 + 5. `RelayClient` (or a new authenticated wrapper) automatically attaches DPoP proofs to authenticated requests, retrying once on `use_dpop_nonce` 16 + 6. Background token refresh before the 5-min access token TTL expires 17 + 7. SvelteKit screens: a post-onboarding "Authenticate" step that triggers the flow and a callback handler that transitions to "authenticated" state 18 + 8. Failed auth redirects back to login 19 + 20 + ## Acceptance Criteria 21 + 22 + ### MM-149.AC1: PAR flow completes successfully 23 + - **MM-149.AC1.1 Success:** `start_oauth_flow` posts to `/oauth/par` with a valid DPoP proof and receives a `request_uri` (201 response) 24 + - **MM-149.AC1.2 Success:** Authorization URL opened in system browser includes `client_id` and `request_uri` parameters 25 + - **MM-149.AC1.3 Failure:** PAR request with unknown `client_id` returns a client error (relay rejects it) 26 + - **MM-149.AC1.4 Failure:** PAR request missing `code_challenge` returns a client error 27 + 28 + ### MM-149.AC2: OAuth callback received and code exchanged 29 + - **MM-149.AC2.1 Success:** Deep-link handler receives `dev.malpercio.identitywallet:/oauth/callback?code=...&state=...` and wakes the parked `start_oauth_flow` command 30 + - **MM-149.AC2.2 Success:** Token exchange succeeds — relay returns `access_token`, `refresh_token`, and `token_type: "DPoP"` 31 + - **MM-149.AC2.3 Failure:** `state` mismatch between generated param and callback param aborts with `StateMismatch` error 32 + - **MM-149.AC2.4 Failure:** A second (replayed) deep-link callback with the same scheme is silently ignored 33 + - **MM-149.AC2.5 Edge:** `use_dpop_nonce` error on token exchange triggers one retry with server-provided nonce; retry succeeds 34 + 35 + ### MM-149.AC3: DPoP proofs are correctly formed 36 + - **MM-149.AC3.1 Success:** DPoP proof header contains `typ: "dpop+jwt"`, `alg: "ES256"`, and a valid P-256 `jwk` 37 + - **MM-149.AC3.2 Success:** DPoP proof payload contains `jti`, `htm`, `htu`, `iat` 38 + - **MM-149.AC3.3 Success:** `ath` claim present and equals `base64url(sha256(access_token))` on resource requests 39 + - **MM-149.AC3.4 Success:** `nonce` claim present when a server nonce has been provided 40 + - **MM-149.AC3.5 Success:** Proof signature verifies against the `jwk` embedded in the header 41 + 42 + ### MM-149.AC4: Tokens stored securely and loaded on restart 43 + - **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 44 + - **MM-149.AC4.2 Success:** On app restart with valid Keychain tokens, `AppState.oauth_session` is populated without re-running the OAuth flow 45 + - **MM-149.AC4.3 Failure:** Tokens are not accessible from the SvelteKit webview (not in localStorage or any JS-accessible storage) 46 + 47 + ### MM-149.AC5: Authenticated requests carry DPoP proofs 48 + - **MM-149.AC5.1 Success:** Every `OAuthClient` request includes `Authorization: DPoP {token}` and a `DPoP` header with a fresh proof 49 + - **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 50 + - **MM-149.AC5.3 Failure:** Request after token is deliberately cleared returns an auth error, not a panic 51 + 52 + ### MM-149.AC6: Token refresh works transparently 53 + - **MM-149.AC6.1 Success:** When `expires_at < now + 60s`, a new token is fetched via refresh grant before the next request proceeds 54 + - **MM-149.AC6.2 Success:** Refresh grant POST includes a fresh DPoP proof without `ath` 55 + - **MM-149.AC6.3 Failure:** If refresh fails (e.g. relay returns `invalid_grant`), the error surfaces to the caller — no silent swallow 56 + 57 + ### MM-149.AC7: Frontend authentication screens 58 + - **MM-149.AC7.1 Success:** After onboarding step 10 completes, app auto-advances to `authenticating` step and calls `start_oauth_flow` 59 + - **MM-149.AC7.2 Success:** On `start_oauth_flow` resolution, app transitions to `authenticated` state 60 + - **MM-149.AC7.3 Success:** On app relaunch with stored tokens, app skips onboarding and shows `authenticated` state directly 61 + - **MM-149.AC7.4 Failure:** `start_oauth_flow` error transitions app to `auth_failed` step 62 + 63 + ### MM-149.AC8: Failed auth recovery 64 + - **MM-149.AC8.1 Success:** "Try again" button on `auth_failed` re-invokes `start_oauth_flow` cleanly (no stale state) 65 + - **MM-149.AC8.2 Success:** "Start over" button resets to step 1 of onboarding 66 + 67 + ## Glossary 68 + 69 + - **OAuth 2.0**: An authorization framework (RFC 6749) that lets an application obtain scoped access tokens on behalf of a user without handling the user's password directly. 70 + - **PKCE (Proof Key for Code Exchange)**: An OAuth 2.0 extension (RFC 7636) that prevents authorization code interception attacks. The client generates a random `code_verifier`, hashes it into a `code_challenge` sent with the initial request, and proves ownership by sending the original verifier at the token exchange step. 71 + - **DPoP (Demonstrating Proof of Possession)**: An OAuth 2.0 mechanism (RFC 9449) that binds access tokens to a specific client keypair. Every request carries a signed JWT (the "DPoP proof") that cryptographically links the bearer token to the sender's private key, preventing token theft from being useful without the key. 72 + - **PAR (Pushed Authorization Request)**: An OAuth 2.0 endpoint (RFC 9126, `/oauth/par`) where the client POSTs its authorization parameters server-side first and receives a short-lived `request_uri` reference. The browser is then redirected using only the reference, keeping sensitive parameters out of the URL. 73 + - **JOSE (JSON Object Signing and Encryption)**: A family of standards (RFC 7515 / 7517 / 7518) that defines how to represent signed JSON payloads (JWT/JWS) and key material (JWK). DPoP proofs are JOSE objects. 74 + - **JWT (JSON Web Token)**: A compact, URL-safe token format (RFC 7519) with a base64url-encoded header, payload, and signature. DPoP proofs are JWTs with `typ: "dpop+jwt"`. 75 + - **JWK (JSON Web Key)**: A JSON representation of a cryptographic key (RFC 7517). DPoP proofs embed the sender's public key as a JWK in the proof header so the server can verify the signature. 76 + - **P-256**: The NIST elliptic curve `secp256r1`, used here for DPoP keypairs. Produces ECDSA signatures identified as `alg: "ES256"` in JOSE. 77 + - **`ath` claim**: A DPoP proof payload field containing `base64url(sha256(access_token))`. Its presence on resource requests binds the proof to a specific token, preventing a stolen proof from being replayed with a different token. 78 + - **`use_dpop_nonce`**: An error code the relay returns (HTTP 400) when it requires the client to include a server-issued nonce in DPoP proofs. The server sends the nonce in a `DPoP-Nonce` response header; the client retries with the nonce included. 79 + - **CSRF state parameter**: A random, unguessable value included in the authorization request and echoed back in the redirect. The client validates it matches before proceeding, preventing cross-site request forgery against the callback URL. 80 + - **Custom URL scheme / deep link**: An iOS mechanism allowing an app to register a URI scheme (here `dev.malpercio.identitywallet://`) so that Safari can redirect back to the app after authorization, passing the authorization code and state as query parameters. 81 + - **`tauri-plugin-deep-link`**: A Tauri plugin that registers the custom URL scheme with iOS and fires a Rust callback (`on_open_url`) when the OS routes a matching URL to the app. 82 + - **`tauri-plugin-opener`**: A Tauri plugin that opens a URL in the system browser (Safari on iOS), used here because WKWebView blocks custom-scheme redirects. 83 + - **WKWebView**: iOS's embedded web view engine, used by Tauri's frontend renderer. It refuses to follow redirects to custom URL schemes, which is why the consent page must open in Safari rather than in-app. 84 + - **Tokio `oneshot` channel**: A single-use async message-passing primitive. Used here to park the `start_oauth_flow` command until the deep-link handler delivers the authorization code from a separate OS callback thread. 85 + - **iOS Keychain**: Apple's hardware-backed secure credential store. On iOS, it is sandboxed per-app and encrypted at rest. Used here to store DPoP private key bytes, access token, and refresh token. 86 + - **`security-framework` crate**: A Rust wrapper around Apple's Security framework, providing Keychain read/write access. 87 + - **`reqwest`**: An async HTTP client library for Rust, used as the underlying transport for both `RelayClient` (unauthenticated) and `OAuthClient` (authenticated). 88 + - **RFC 7591**: OAuth 2.0 Dynamic Client Registration. The document uses its client metadata field names (`application_type`, `token_endpoint_auth_method`, `dpop_bound_access_tokens`, etc.) to describe the seeded `oauth_clients` row. 89 + - **`INSERT OR IGNORE`**: SQLite syntax that inserts a row only if no conflicting row exists, making the migration idempotent on re-run. 90 + - **Tauri command**: A Rust `async fn` annotated with `#[tauri::command]` and registered in `invoke_handler`, callable from the SvelteKit frontend via `invoke('command_name')`. 91 + - **`AppState`**: A Tauri-managed shared state struct (registered via `.manage()`) that is injected into commands and handlers by the framework, used here to pass the pending OAuth channel and active session between the command and the deep-link callback. 92 + - **Single-command model**: The design pattern used here where one long-running Tauri command drives the entire OAuth round-trip instead of splitting it into separate start/callback commands, keeping PKCE verifier and state parameter entirely on the Rust side. 93 + - **SvelteKit**: A full-stack web framework built on Svelte. Used here for the identity-wallet frontend rendered inside the Tauri WKWebView. 94 + 95 + ## Architecture 96 + 97 + Full OAuth 2.0 PKCE + DPoP client flow across three layers: relay DB, Tauri Rust backend, and SvelteKit frontend. 98 + 99 + The flow uses a **single-command model**: `start_oauth_flow` drives the entire round-trip via a Tokio `oneshot` channel. The frontend calls `invoke('start_oauth_flow')` and awaits. The command generates PKCE and DPoP material, calls PAR, opens Safari, then parks on the channel receiver. When Safari completes the authorization and redirects to the custom URL scheme, the deep-link handler fires on a separate thread, extracts `code` + `state`, and sends them on the channel. The command wakes, exchanges the code for tokens, stores everything in Keychain, and returns — at which point the frontend Promise resolves. PKCE state never crosses the Rust/frontend boundary. 100 + 101 + WKWebView hard-blocks custom URL scheme redirects, so the consent page must open in Safari (system browser) via `window.open()`. The `tauri-plugin-deep-link` plugin registers the `dev.malpercio.identitywallet` URL scheme with iOS and fires `on_open_url` when Safari redirects back. 102 + 103 + **Component map:** 104 + 105 + ``` 106 + Relay DB (V013 migration) 107 + └─ oauth_clients row: client_id = "dev.malpercio.identitywallet" 108 + 109 + apps/identity-wallet/src-tauri/ 110 + ├─ src/lib.rs — plugin registration, on_open_url setup, AppState::manage() 111 + ├─ src/oauth.rs — PendingOAuthFlow, OAuthSession, DPoPKeypair, start_oauth_flow, handle_deep_link 112 + └─ src/oauth_client.rs — OAuthClient (authenticated HTTP, lazy refresh, use_dpop_nonce retry) 113 + 114 + apps/identity-wallet/src/ 115 + ├─ routes/+page.svelte — steps 11 (authenticating), 12 (authenticated), auth_failed 116 + └─ lib/ipc.ts — startOAuthFlow() wrapper 117 + ``` 118 + 119 + **Key contracts:** 120 + 121 + ```rust 122 + // AppState — registered via .manage(), shared across commands and handlers 123 + pub struct AppState { 124 + pub pending_auth: Mutex<Option<PendingOAuthFlow>>, 125 + pub oauth_session: Mutex<Option<OAuthSession>>, 126 + } 127 + 128 + // DPoP proof builder 129 + impl DPoPKeypair { 130 + pub fn get_or_create() -> Result<Self, OAuthError>; 131 + pub fn make_proof( 132 + &self, 133 + htm: &str, 134 + htu: &str, 135 + nonce: Option<&str>, 136 + ath: Option<&str>, // base64url(sha256(access_token)); None for token requests 137 + ) -> Result<String, OAuthError>; 138 + pub fn public_jwk_thumbprint(&self) -> String; // base64url(sha256(JWK canonical JSON)) 139 + } 140 + 141 + // Authenticated HTTP client 142 + impl OAuthClient { 143 + pub fn new(session: Arc<Mutex<OAuthSession>>) -> Self; 144 + pub async fn get(&self, path: &str) -> Result<reqwest::Response, OAuthError>; 145 + pub async fn post<B: Serialize>(&self, path: &str, body: &B) -> Result<reqwest::Response, OAuthError>; 146 + } 147 + ``` 148 + 149 + ## Existing Patterns 150 + 151 + **Keychain storage** (`src/keychain.rs`, `security-framework` crate, service `"ezpds-identity-wallet"`): DPoP private key bytes stored under account key `"oauth-dpop-key-priv"`, access token under `"oauth-access-token"`, refresh token under `"oauth-refresh-token"`. Same pattern as `"device-rotation-key-priv"`, `"session-token"`, `"did"`. 152 + 153 + **HTTP client** (`src/http.rs`, `reqwest` + `rustls-tls`): `OAuthClient` reuses the same TLS config as `RelayClient`. The existing `RelayClient` is unchanged — it continues serving unauthenticated relay calls (account creation, key fetch). `OAuthClient` is the new authenticated layer. 154 + 155 + **Error serialization** (`thiserror` + `serde`): `OAuthError` variants serialize as `{ "code": "SCREAMING_SNAKE_CASE" }`, matching `CreateAccountError`, `DeviceKeyError`, `DIDCeremonyError`. 156 + 157 + **P-256 key generation** (`crates/crypto`): `DPoPKeypair::get_or_create()` delegates to `crypto::generate_p256_keypair()` for new keypairs, following the same pattern as `device_key.rs`. 158 + 159 + **DB migrations** (`crates/relay/src/db/migrations/`): V013 follows the existing forward-only, `V{N}__description.sql` naming convention. 160 + 161 + **Frontend state machine** (`src/routes/+page.svelte`): Two new steps (11: `authenticating`, 12: `authenticated`) plus an `auth_failed` error step appended to the existing 10-step onboarding machine. 162 + 163 + ## Implementation Phases 164 + 165 + <!-- START_PHASE_1 --> 166 + ### Phase 1: Relay client registration 167 + 168 + **Goal:** Pre-register the identity-wallet as a valid OAuth client in the relay database. 169 + 170 + **Components:** 171 + - `crates/relay/src/db/migrations/V013__identity_wallet_oauth_client.sql` — `INSERT OR IGNORE` into `oauth_clients` with `client_id = 'dev.malpercio.identitywallet'` and RFC 7591 + ATProto client metadata JSON (`application_type: "native"`, `token_endpoint_auth_method: "none"`, `dpop_bound_access_tokens: true`, `redirect_uris`, `grant_types`, `scope`) 172 + 173 + **Dependencies:** None 174 + 175 + **Done when:** Migration runs without error; `SELECT * FROM oauth_clients WHERE client_id = 'dev.malpercio.identitywallet'` returns one row; a PAR request using this `client_id` is accepted by the relay (no "unknown client" error) 176 + <!-- END_PHASE_1 --> 177 + 178 + <!-- START_PHASE_2 --> 179 + ### Phase 2: Deep-link plumbing and AppState 180 + 181 + **Goal:** Wire up the deep-link plugin, register `AppState`, and establish the callback routing path. 182 + 183 + **Components:** 184 + - `apps/identity-wallet/src-tauri/Cargo.toml` — add `tauri-plugin-deep-link = "2"`, `tauri-plugin-opener = "2"` 185 + - `apps/identity-wallet/src-tauri/tauri.conf.json` — add `plugins.deep-link.mobile[0].scheme = "dev.malpercio.identitywallet"` 186 + - `apps/identity-wallet/src-tauri/src/lib.rs` — register both plugins, call `app.deep_link().on_open_url(...)`, register `AppState` via `.manage()` 187 + - `apps/identity-wallet/src-tauri/src/oauth.rs` — define `AppState`, `PendingOAuthFlow`, `CallbackParams`; stub `handle_deep_link(urls, state)` that filters for the callback scheme/path and sends on the pending channel 188 + 189 + **Dependencies:** Phase 1 (relay client registered) 190 + 191 + **Done when:** App builds for iOS; `xcrun simctl openurl booted "dev.malpercio.identitywallet:/oauth/callback?code=test&state=abc"` triggers the `on_open_url` handler (verified via tracing log) 192 + <!-- END_PHASE_2 --> 193 + 194 + <!-- START_PHASE_3 --> 195 + ### Phase 3: DPoP keypair and proof builder 196 + 197 + **Goal:** Implement the DPoP keypair type with Keychain persistence and manual JOSE proof construction. 198 + 199 + **Components:** 200 + - `apps/identity-wallet/src-tauri/src/oauth.rs` — `DPoPKeypair` struct with `get_or_create()` (load from `"oauth-dpop-key-priv"` Keychain key or generate via `crypto::generate_p256_keypair()`), `make_proof(htm, htu, nonce, ath)` (manual JOSE: base64url-encode header JSON + payload JSON, sign with `p256::ecdsa`, return `header.payload.sig`), `public_jwk()` and `public_jwk_thumbprint()` accessors 201 + - `apps/identity-wallet/src-tauri/src/keychain.rs` — add `store_dpop_key(bytes)`, `load_dpop_key() -> Option<[u8;32]>`, `store_oauth_tokens(access, refresh)`, `load_oauth_tokens() -> Option<(String, String)>` using existing Keychain service pattern 202 + 203 + **Dependencies:** Phase 2 (AppState defined) 204 + 205 + **Done when:** Tests verify: DPoP proof header has `typ = "dpop+jwt"`, `alg = "ES256"`, valid `jwk`; payload has `jti`, `htm`, `htu`, `iat`; `ath` present when supplied; `nonce` present when supplied; signature verifies against the embedded public key (covers MM-149.AC3) 206 + <!-- END_PHASE_3 --> 207 + 208 + <!-- START_PHASE_4 --> 209 + ### Phase 4: PKCE utilities and PAR call 210 + 211 + **Goal:** Implement PKCE generation and the PAR HTTP call. 212 + 213 + **Components:** 214 + - `apps/identity-wallet/src-tauri/src/oauth.rs` — `pkce::generate() -> (verifier: String, challenge: String)`: 32 OS-random bytes → base64url = verifier; `base64url(sha256(verifier))` = challenge; `generate_state_param() -> String`: 16 OS-random bytes → base64url 215 + - `apps/identity-wallet/src-tauri/src/http.rs` — `relay_client::par(challenge, state, dpop_proof, dpop_jkt) -> Result<ParResponse, OAuthError>`: POST `/oauth/par` form-urlencoded, returns `{ request_uri, expires_in }` 216 + 217 + **Dependencies:** Phase 3 (DPoP proof builder available) 218 + 219 + **Done when:** Tests verify: verifier is 43–128 unreserved chars; challenge equals `base64url(sha256(verifier))`; PAR call against a running relay returns 201 with a `request_uri` (integration test, covers MM-149.AC1) 220 + <!-- END_PHASE_4 --> 221 + 222 + <!-- START_PHASE_5 --> 223 + ### Phase 5: `start_oauth_flow` command and deep-link handler 224 + 225 + **Goal:** Implement the full single-command OAuth round-trip. 226 + 227 + **Components:** 228 + - `apps/identity-wallet/src-tauri/src/oauth.rs` — `start_oauth_flow(app, state)`: sequence PKCE + DPoP + PAR + Safari open + channel await + CSRF validation + token exchange (with `use_dpop_nonce` retry) + Keychain store + `AppState` update; `handle_deep_link` completes its implementation: `state.pending_auth.lock().take()` + send on channel 229 + - `apps/identity-wallet/src-tauri/src/lib.rs` — register `start_oauth_flow` in `invoke_handler` 230 + 231 + **Dependencies:** Phases 3 and 4 (DPoP + PKCE utilities, PAR call) 232 + 233 + **Done when:** End-to-end test (against a running relay with the seeded client): `start_oauth_flow` completes with tokens in Keychain; CSRF mismatch returns `OAuthError::StateMismatch`; replayed deep-link (second callback) is silently ignored; `use_dpop_nonce` retry succeeds (covers MM-149.AC1, AC2, AC5) 234 + <!-- END_PHASE_5 --> 235 + 236 + <!-- START_PHASE_6 --> 237 + ### Phase 6: `OAuthClient` — authenticated HTTP client 238 + 239 + **Goal:** Authenticated HTTP client with automatic DPoP proof attachment, lazy token refresh, and `use_dpop_nonce` retry. 240 + 241 + **Components:** 242 + - `apps/identity-wallet/src-tauri/src/oauth_client.rs` — `OAuthClient { inner: reqwest::Client, session: Arc<Mutex<OAuthSession>> }` with `get(path)`, `post(path, body)`, internal `prepare_headers(method, url)` (lazy refresh if < 60 s remaining; compute `ath`; attach `Authorization: DPoP {token}` and `DPoP: {proof}`), `execute_with_retry` (on `use_dpop_nonce` 400, extract `DPoP-Nonce` header, update `session.dpop_nonce`, retry once), `refresh_token()` (POST `/oauth/token` `grant_type=refresh_token` + fresh DPoP proof without `ath`) 243 + 244 + **Dependencies:** Phase 5 (OAuthSession type, DPoP proof builder, token storage) 245 + 246 + **Done when:** Tests verify: DPoP proof attached to every request; `ath` claim present and correct; lazy refresh fires when token has < 60 s remaining; `use_dpop_nonce` retry succeeds on first 400; second consecutive 400 returns error (covers MM-149.AC4, AC6) 247 + <!-- END_PHASE_6 --> 248 + 249 + <!-- START_PHASE_7 --> 250 + ### Phase 7: SvelteKit frontend 251 + 252 + **Goal:** Post-onboarding authentication screens and startup token check. 253 + 254 + **Components:** 255 + - `apps/identity-wallet/src/routes/+page.svelte` — add steps `authenticating` (spinner, auto-invokes `start_oauth_flow` after step 10 completes), `authenticated` (success confirmation, transitions to main app view), `auth_failed` (error message, "Try again" button re-invokes `start_oauth_flow`, "Start over" resets to step 1) 256 + - `apps/identity-wallet/src/lib/ipc.ts` — `export async function startOAuthFlow(): Promise<void>` 257 + - `apps/identity-wallet/src-tauri/src/lib.rs` — in `setup()`, load tokens from Keychain and populate `AppState.oauth_session`; emit `"auth_ready"` event to frontend if tokens exist (enabling app to skip onboarding to `authenticated` on relaunch) 258 + 259 + **Dependencies:** Phase 5 (start_oauth_flow command registered) 260 + 261 + **Done when:** Manual test: fresh install → completes onboarding → auto-advances to `authenticating` → browser opens → login → app transitions to `authenticated`; relaunch → skips onboarding, jumps to `authenticated`; auth failure → `auth_failed` screen with working retry (covers MM-149.AC7, AC8) 262 + <!-- END_PHASE_7 --> 263 + 264 + ## Additional Considerations 265 + 266 + **CSRF state validation:** The `state` parameter generated in `start_oauth_flow` must be compared in constant time (or at minimum verified present and matching) before the code exchange. A mismatch must abort the flow and return `OAuthError::StateMismatch`. 267 + 268 + **DPoP nonce persistence:** `session.dpop_nonce` is in-memory only. It resets to `None` on app restart. The first request after restart will receive a `use_dpop_nonce` error from the relay; the retry path handles this transparently. No Keychain storage needed for the nonce. 269 + 270 + **Concurrent `start_oauth_flow` calls:** If the user somehow triggers two concurrent calls, the second `lock().insert(...)` on `pending_auth` silently overwrites the first, abandoning the first call's `rx`. The first call will return `OAuthError::CallbackAbandoned` when its channel is dropped. This is acceptable — a second concurrent auth attempt is not a supported UX flow.