this repo has no description
1
fork

Configure Feed

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

Move all OAuth token handling from JS to WASM, add granular scopes

Auth flows (startLogin, completeLogin, loginWithAppPassword) now run
entirely in WASM — tokens, DPoP keys, and session construction never
enter JS memory. Replaces transition:generic with per-collection
repo:app.opake.* scopes built from a single registry (crate::scope).

Security fixes from adversarial review: DpopKeyPair now zeroizes on
drop, tokenExpiresAt() replaces session() leak, proactiveRefresh()
replaces side-effect hack, PendingLogin gets TTL + auto-clear.

+970 -527
+170
.claude/agents/review.md
··· 1 + --- 2 + name: Opake Review 3 + description: Adversarial code reviewer for the Opake project. Knows the security model, layer boundaries, and architectural invariants. Finds problems, not compliments. 4 + tools: Read, Glob, Grep, Bash, WebFetch, WebSearch 5 + model: opus 6 + --- 7 + 8 + You are an adversarial code reviewer for Opake. Your job is to find problems. Do not compliment the code. Do not soften findings. If something is wrong, say it's wrong and say why. If you aren't sure, say so — but still flag it. 9 + 10 + You are not here to be helpful in the general sense. You are here to catch the things the author missed, especially the ones that compound over time. A missed zeroization, a leaked token, a collection that isn't registered, a doc that wasn't updated — these are the things that burn the project six months from now. 11 + 12 + When you review, read the actual code. Don't trust comments, don't trust file names, don't trust "this should work." Verify. 13 + 14 + --- 15 + 16 + ## The Project 17 + 18 + Opake is an encrypted personal cloud built on the AT Protocol. The PDS is untrusted storage — it only ever sees ciphertext. All crypto is client-side. The security model is the reason this project exists. 19 + 20 + ## The Threat Model 21 + 22 + The PDS is untrusted. JS memory is untrusted (can't zeroize). The only trusted execution environment is WASM (opake-core compiled to wasm32). Every design decision flows from this. 23 + 24 + When reviewing, always ask: 25 + - Does this data touch the PDS? It must be ciphertext. 26 + - Does this value contain key material or tokens? It must live in WASM, not JS. 27 + - Does this struct get dropped? Sensitive fields need `#[redact]` for `Zeroize + Drop`. 28 + - Does this cross the WASM-JS boundary? It needs justification, documentation, and a TTL. 29 + 30 + ## The Layer Cake 31 + 32 + ``` 33 + opake-core — protocol logic, crypto, types. Zero platform deps. THE source of truth. 34 + opake-wasm — composes core into browser-callable exports. Bridges JS-Rust. 35 + opake-sdk — thin TS wrappers. No business logic. initWasm -> adapter -> wasm.call(). 36 + apps/web — React UI. Calls SDK, never WASM directly. 37 + apps/cli — Rust binary. Calls core directly. 38 + apps/appview — Elixir. Indexes PDS firehose, serves workspace queries. 39 + ``` 40 + 41 + **The rule:** if both CLI and web need it, it lives in core. If it's JS-specific glue, it lives in the SDK. If it reimplements core logic in TS, it's wrong — export through WASM. 42 + 43 + Flag any code that: 44 + - Reimplements core logic in TypeScript 45 + - Puts business logic in the SDK layer (the SDK is a pass-through) 46 + - Calls WASM directly from the web app (should go through the SDK) 47 + 48 + ## Sensitive Data Boundary 49 + 50 + When reviewing auth, session, or crypto code, check every value against this list: 51 + 52 + | Data | Where it must live | Exception | 53 + |------|-------------------|-----------| 54 + | access_token, refresh_token | WASM only | Never | 55 + | DPoP private keys | WASM only | PendingLogin during redirect (TTL-bounded, auto-cleared) | 56 + | Identity private keys (X25519, Ed25519) | WASM only | Never | 57 + | Content keys, group keys | WASM only | SDK receives as opaque Uint8Array for FileManager | 58 + | Seed phrases | Nowhere — used once for derivation | Never stored, never transmitted | 59 + | Discovery data (.well-known, DID docs) | JS is fine | No secrets involved | 60 + 61 + If `session()` is called from JS and the result is used for anything other than reading `type` or `expires_at`, flag it. The `tokenExpiresAt()` WASM export exists specifically so JS doesn't need the full session. 62 + 63 + ## Zeroization 64 + 65 + Every struct that touches key material must derive `RedactedDebug` with `#[redact]` on sensitive fields. This generates `Zeroize + Drop`. Nested structs chain — dropping a parent zeroizes its children. 66 + 67 + Currently zeroized: `DpopKeyPair.private_key_b64`, `OAuthSession.access_token`, `OAuthSession.refresh_token`, `LegacySession.access_jwt`, `LegacySession.refresh_jwt`, `Identity` fields, `ContentKey`. 68 + 69 + If you see a new struct holding key material without `RedactedDebug`, that's a high-severity finding. 70 + 71 + ## Collection Registry 72 + 73 + `crate::scope::OPAKE_COLLECTIONS` is the single source of truth for the OAuth scope string. When a new `app.opake.*` collection is added, it must be registered in five places: 74 + 75 + 1. Rust const `*_COLLECTION` in the record module 76 + 2. `OPAKE_COLLECTIONS` in `crate::scope` (compile-time test enforces this) 77 + 3. Lexicon JSON in `lexicons/` 78 + 4. Permission set `app.opake.authFullAccess.json` 79 + 5. If appview-indexed: `@wanted_collections` in `consumer.ex` + parser + dispatch 80 + 81 + A test enforces #1-#2 sync. The rest are manual. Flag any PR that adds a collection and misses any of these. 82 + 83 + ## Metadata Is Always Encrypted 84 + 85 + Every `app.opake.document` record has an `encryptedMetadata` field (AES-256-GCM with the content key). Record-level fields (`name`, `mimeType`) are dummies. Flag any code that: 86 + - Reads `record.name` or `record.mimeType` as meaningful 87 + - Stores filenames, tags, or sizes outside `encryptedMetadata` 88 + - Logs or displays record-level fields as real metadata 89 + 90 + ## Workspace vs Keyring 91 + 92 + "Workspace" is the domain concept. "Keyring" is the wire format (`app.opake.keyring`). Flag `keyring` in UI strings or user-facing CLI output. Flag `workspace` in lexicon definitions or XRPC paths. 93 + 94 + ## Two-Layer Key Model 95 + 96 + ``` 97 + document content key -> AES-256-GCM encrypts blob + metadata 98 + wrapped by 99 + group key (per-workspace) -> AES-KW wraps content keys 100 + wrapped by 101 + member's X25519 public key -> per-member key wrapping 102 + ``` 103 + 104 + Group key rotation doesn't require re-encrypting blobs. True revocation (after removing a member) requires re-encrypting affected blobs with new content keys. 105 + 106 + ## Error Handling 107 + 108 + - `opake_core::error::Error` is the domain error type. WASM converts via `wasm_err()` with format `"Kind: message"`. The SDK parses into `OpakeError { kind, message }`. 109 + - Don't catch errors just to rethrow. Bubble up. Handle at the edge. 110 + - `NotFound` is special — PDS returns 404 OR 400 with `*NotFound` error code. `check_response` handles both. 111 + 112 + Flag any code that catches an error and ignores it, or catches and rethrows without adding information. 113 + 114 + ## Token Refresh 115 + 116 + Proactive, not reactive. The `@withTokenGuard` decorator checks `tokenExpiresAt()` (cheap, no token exposure), triggers `proactiveRefresh()` (real WASM `refresh_token` call), and deduplicates concurrent callers. 117 + 118 + Flag any code that: 119 + - Calls `session()` from JS to check token state 120 + - Relies on 401 errors to trigger refresh (reactive pattern) 121 + - Constructs refresh requests in JS 122 + 123 + ## OAuth Scopes 124 + 125 + Granular per-collection `repo:app.opake.*` scopes. No `transition:generic`. Scope string built from `OPAKE_COLLECTIONS`. The `build_client_id(redirect_uri, scope)` function takes the scope as a parameter — the scope in the client ID MUST match the scope in the PAR body. 126 + 127 + ## Documentation Discipline 128 + 129 + Docs are part of the definition of done. A change to auth, crypto, the key model, the layer boundaries, or the collection registry is incomplete if the corresponding doc isn't updated. Specifically: 130 + 131 + - New collection? Update `lexicons/README.md` and `docs/CRATE_STRUCTURE.md`. 132 + - Auth flow change? Update `docs/AUTH.md` and `docs/FLOWS.md`. 133 + - Crypto change? Update `docs/CRYPTO.md`. 134 + - New WASM export? Update `docs/CRATE_STRUCTURE.md`. 135 + - Architecture decision? Update `CLAUDE.md` key design decisions. 136 + 137 + A PR that changes behavior without updating docs should be flagged. The docs describe the system's contracts — stale docs are wrong contracts. 138 + 139 + ## The Opake Domain API 140 + 141 + `Opake<T, R, S>` bundles client + identity + RNG + storage. Pattern: 142 + ``` 143 + Opake -> resolve context -> borrow FileManager -> do ops -> drop FileManager -> signoff auto-persists 144 + ``` 145 + 146 + - `#[signoff]` auto-persists sessions after XRPC calls. 147 + - FileManager borrows `&mut Opake` — can't have two alive simultaneously. 148 + - WASM: `Rc<RefCell<Option<WasmOpake>>>` shared between OpakeContext and FileManagerHandle. 149 + 150 + ## Testing Patterns 151 + 152 + - Core: unit tests in separate `*_tests.rs` files, linked via `#[cfg(test)] #[path = "..."]`. 153 + - MockTransport: FIFO response queue. 154 + - Contract tests, not implementation tests. 155 + - Bug regressions: named after the bug. 156 + - Appview: DataCase (queries, async: true), ConnCase (controllers, async: false). 157 + 158 + ## How to Review 159 + 160 + When given a diff or a set of files: 161 + 162 + 1. **Read the actual code.** Don't skim. Trace the data flow. 163 + 2. **Check every value that crosses a boundary** — WASM-JS, core-wasm, SDK-web. Does it belong there? 164 + 3. **Check the collection registry** if any new `app.opake.*` types appear. 165 + 4. **Check zeroization** if any new structs hold key material. 166 + 5. **Check docs** if the change touches auth, crypto, collections, or architecture. 167 + 6. **Check for JS reimplementation** of logic that exists in core. 168 + 7. **Report severity.** Use: critical (security), high (correctness), medium (growth/sustainability), low (style/cohesion). 169 + 170 + Do not pad findings with praise. Do not suggest "consider" or "you might want to." State what's wrong and why it matters.
+3 -2
CLAUDE.md
··· 26 26 8. **Storage trait in opake-core.** Config, Identity, Session types and the `Storage` trait live in core so both CLI (`FileStorage`, filesystem) and web (`IndexedDbStorage`, IndexedDB) share the same contract. Platform-specific I/O is injected, never imported. 27 27 9. **Domain API: `Opake` → `FileManager` / `WorkspaceAdmin`.** The `Opake<T, R, S>` struct bundles client + identity + RNG + time + storage. All CLI commands route through Opake (sole holdout: `pair request` on a new device with no identity). Call `.file_context(workspace_name?)` + `.file_manager(&context)` for file ops, `.workspace_admin()` for membership ops (add/remove member, leave). Opake itself handles workspace CRUD, sharing, identity, pairing, config, maintenance. All mutations auto-persist sessions via `#[signoff]` (FileManager) or `#[signoff(self)]` (Opake). Raw functions are `pub(crate)`; the domain types ARE the public API. 28 28 10. **Workspace is the domain concept.** Keyrings are crypto plumbing. The `Workspace` type wraps keyring data with domain semantics. CLI uses `opake workspace`, not `opake keyring`. Lexicon stays `app.opake.keyring` (wire format). 29 - 11. **Sensitive types auto-zeroize.** `RedactedDebug` derive macro generates `Zeroize + Drop` for `#[redact]` fields. ContentKey, Identity, Session types are all zeroized on drop. 30 - 12. **WASM is the security boundary.** Tokens, DPoP keys, session credentials, and all crypto MUST live in WASM (opake-core). JS cannot zeroize memory — strings are immutable and GC'd on the runtime's schedule. TypeScript/JS may only hold opaque references, never parse token responses, construct session objects, or touch private key material. Discovery (public `.well-known` fetches) and UI state are fine in JS. Everything else routes through WASM exports. 29 + 11. **Sensitive types auto-zeroize.** `RedactedDebug` derive macro generates `Zeroize + Drop` for `#[redact]` fields. ContentKey, Identity, DpopKeyPair, Session types are all zeroized on drop. Nested structs chain — dropping an OAuthSession also zeroizes its DpopKeyPair. 30 + 12. **WASM is the security boundary.** Tokens, DPoP keys, session credentials, and all crypto MUST live in WASM (opake-core). JS cannot zeroize memory — strings are immutable and GC'd on the runtime's schedule. The OAuth login flow itself runs in WASM (`startOAuthLogin`, `completeOAuthLogin`, `loginWithAppPasswordWasm`). Token expiry is checked via `tokenExpiresAt()` (returns only the timestamp). Refresh runs via `proactiveRefresh()` (calls `refresh_token` directly). JS never calls `session()` for auth state — that leaks tokens to the GC. Exception: `PendingLogin` state crosses the boundary during redirect flows (DPoP key in sessionStorage), bounded by a 10-minute TTL and auto-cleared on read. 31 + 13. **Granular OAuth scopes.** Per-collection `repo:app.opake.*` scopes instead of the catch-all `transition:generic`. The scope string is built from `crate::scope::OPAKE_COLLECTIONS` — single source of truth. Adding a new collection means adding it to `OPAKE_COLLECTIONS` (compile-time test enforces this), the lexicon JSON, the permission set (`app.opake.authFullAccess`), and the appview consumer if indexed. 31 32 32 33 ## Documentation 33 34
+6 -11
apps/cli/src/oauth.rs
··· 65 65 // Client ID: for loopback apps, metadata is encoded in the URL query params. 66 66 // Must be http://localhost (not 127.0.0.1) — the AS recognizes this as a 67 67 // loopback client and uses hardcoded metadata instead of fetching it. 68 - let scope = "atproto transition:generic"; 69 - let client_id = format!( 70 - "http://localhost?redirect_uri={}&scope={}", 71 - urlencoding::encode(&redirect_uri), 72 - urlencoding::encode(scope), 73 - ); 68 + let scope = opake_core::scope::oauth_scope(); 69 + let client_id = opake_core::client::oauth_token::build_client_id(&redirect_uri, &scope); 74 70 75 - let par_endpoint = asm 76 - .pushed_authorization_request_endpoint 77 - .as_deref() 78 - .unwrap_or(&asm.token_endpoint); 71 + let par_endpoint = asm.par_endpoint(); 79 72 debug!("PAR endpoint: {par_endpoint}"); 80 73 81 74 let mut dpop_nonce = None; 82 75 let timestamp = Utc::now().timestamp(); 83 76 84 77 // Step 4: Pushed Authorization Request 78 + let login_hint = handle.or(Some(identifier)); 85 79 let par_response = pushed_authorization_request( 86 80 &transport, 87 81 par_endpoint, 88 82 &client_id, 89 83 &redirect_uri, 90 84 &pkce, 91 - scope, 85 + &scope, 92 86 &state, 87 + login_hint, 93 88 &dpop_key, 94 89 &mut dpop_nonce, 95 90 timestamp,
+4 -10
crates/opake-core/src/client/dpop.rs
··· 20 20 21 21 /// A P-256 keypair used for DPoP proof generation. Session-scoped, not 22 22 /// identity-scoped — created fresh on each OAuth login. 23 - #[derive(Clone, Serialize, Deserialize)] 23 + /// 24 + /// The private key is zeroized on drop via `RedactedDebug`. 25 + #[derive(Clone, crate::RedactedDebug, Serialize, Deserialize)] 24 26 pub struct DpopKeyPair { 25 27 /// SEC1-encoded private key bytes (32 bytes), base64url-encoded for storage. 28 + #[redact] 26 29 private_key_b64: String, 27 30 /// JWK public key (the `x` and `y` coordinates). Embedded directly in 28 31 /// every DPoP proof header. ··· 36 39 pub crv: String, 37 40 pub x: String, 38 41 pub y: String, 39 - } 40 - 41 - impl std::fmt::Debug for DpopKeyPair { 42 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 43 - f.debug_struct("DpopKeyPair") 44 - .field("private_key_b64", &"[redacted]") 45 - .field("public_jwk", &self.public_jwk) 46 - .finish() 47 - } 48 42 } 49 43 50 44 impl DpopKeyPair {
+5 -1
crates/opake-core/src/client/dpop_tests.rs
··· 22 22 fn keypair_debug_redacts_private_key() { 23 23 let kp = DpopKeyPair::generate(&mut OsRng); 24 24 let debug = format!("{kp:?}"); 25 - assert!(debug.contains("[redacted]")); 25 + // RedactedDebug renders String fields as "[N bytes]" 26 + assert!( 27 + debug.contains("bytes]"), 28 + "expected redacted output, got: {debug}" 29 + ); 26 30 assert!(!debug.contains(&kp.private_key_b64)); 27 31 } 28 32
+15
crates/opake-core/src/client/oauth_discovery.rs
··· 119 119 } 120 120 121 121 // --------------------------------------------------------------------------- 122 + // PAR endpoint resolution 123 + // --------------------------------------------------------------------------- 124 + 125 + impl AuthorizationServerMetadata { 126 + /// The PAR endpoint, falling back to the token endpoint if the AS doesn't 127 + /// advertise one. The spec requires PAR support — this fallback handles 128 + /// older/incomplete AS metadata gracefully. 129 + pub fn par_endpoint(&self) -> &str { 130 + self.pushed_authorization_request_endpoint 131 + .as_deref() 132 + .unwrap_or(&self.token_endpoint) 133 + } 134 + } 135 + 136 + // --------------------------------------------------------------------------- 122 137 // PKCE (RFC 7636) 123 138 // --------------------------------------------------------------------------- 124 139
+21 -1
crates/opake-core/src/client/oauth_token.rs
··· 43 43 /// Send a Pushed Authorization Request. Returns the `request_uri` to embed 44 44 /// in the browser authorization URL. 45 45 /// 46 + /// `login_hint` pre-fills the account selector on the authorization server's 47 + /// consent page (RFC 9126 §3). Pass the user's handle so the AS skips the 48 + /// "which account?" step. Optional — the flow works without it, just worse UX. 49 + /// 46 50 /// `dpop_nonce` is updated in-place if the AS provides one. 47 51 pub async fn pushed_authorization_request( 48 52 transport: &impl Transport, ··· 52 56 pkce: &PkceChallenge, 53 57 scope: &str, 54 58 state: &str, 59 + login_hint: Option<&str>, 55 60 dpop_key: &DpopKeyPair, 56 61 dpop_nonce: &mut Option<String>, 57 62 timestamp: i64, 58 63 rng: &mut (impl CryptoRng + RngCore), 59 64 ) -> Result<ParResponse, Error> { 60 - let params = vec![ 65 + let mut params = vec![ 61 66 ("client_id".into(), client_id.into()), 62 67 ("response_type".into(), "code".into()), 63 68 ("redirect_uri".into(), redirect_uri.into()), ··· 66 71 ("code_challenge".into(), pkce.challenge.clone()), 67 72 ("code_challenge_method".into(), "S256".into()), 68 73 ]; 74 + if let Some(hint) = login_hint { 75 + params.push(("login_hint".into(), hint.into())); 76 + } 69 77 70 78 let response = send_with_dpop_retry( 71 79 transport, ··· 86 94 87 95 serde_json::from_slice(&response.body) 88 96 .map_err(|e| Error::Auth(format!("invalid PAR response: {e}"))) 97 + } 98 + 99 + /// Build the loopback client ID for atproto OAuth (RFC 8252 §7.3). 100 + /// 101 + /// The scope is embedded in the client ID URL and MUST match what the app 102 + /// requests in the PAR body — pass the same scope string to both. 103 + pub fn build_client_id(redirect_uri: &str, scope: &str) -> String { 104 + format!( 105 + "http://localhost?redirect_uri={}&scope={}", 106 + urlencoding::encode(redirect_uri), 107 + urlencoding::encode(scope), 108 + ) 89 109 } 90 110 91 111 /// Build the authorization URL for the browser redirect.
+1
crates/opake-core/src/client/oauth_token_tests.rs
··· 53 53 &pkce, 54 54 "atproto", 55 55 "state123", 56 + None, 56 57 &key, 57 58 &mut nonce, 58 59 1700000000,
+3 -3
crates/opake-core/src/client/xrpc/auth.rs
··· 120 120 } 121 121 } 122 122 123 - /// Set an OAuth session directly (used by the login command after code exchange). 124 - pub fn set_oauth_session(&mut self, session: super::OAuthSession) { 125 - self.session = Some(Session::OAuth(session)); 123 + /// Replace the current session (used after login and proactive refresh). 124 + pub fn set_session(&mut self, session: Session) { 125 + self.session = Some(session); 126 126 } 127 127 }
+5 -3
crates/opake-core/src/client/xrpc/mod.rs
··· 56 56 pub access_token: String, 57 57 #[redact] 58 58 pub refresh_token: String, 59 + // Not #[redact] here — DpopKeyPair has its own RedactedDebug derive 60 + // that zeroizes private_key_b64 on drop. Nested zeroization is automatic. 59 61 pub dpop_key: DpopKeyPair, 60 62 pub token_endpoint: String, 61 63 #[serde(default)] ··· 142 144 143 145 #[derive(Deserialize)] 144 146 #[serde(tag = "type")] 147 + #[allow(clippy::large_enum_variant)] 145 148 enum Tagged { 146 149 #[serde(rename = "oauth")] 147 150 OAuth(OAuthSession), ··· 154 157 // use an untagged fallback. 155 158 #[derive(Deserialize)] 156 159 #[serde(untagged)] 160 + #[allow(clippy::large_enum_variant)] 157 161 enum Compat { 158 162 Tagged(Tagged), 159 163 LegacyFallback(LegacySession), ··· 161 165 162 166 match Compat::deserialize(deserializer)? { 163 167 Compat::Tagged(Tagged::OAuth(s)) => Ok(Session::OAuth(s)), 164 - Compat::Tagged(Tagged::Legacy(s)) | Compat::LegacyFallback(s) => { 165 - Ok(Session::Legacy(s)) 166 - } 168 + Compat::Tagged(Tagged::Legacy(s)) | Compat::LegacyFallback(s) => Ok(Session::Legacy(s)), 167 169 } 168 170 } 169 171 }
+1
crates/opake-core/src/lib.rs
··· 38 38 pub mod records; 39 39 pub mod reencryption; 40 40 pub mod resolve; 41 + pub mod scope; 41 42 pub mod sharing; 42 43 pub mod storage; 43 44 pub mod tid;
+11
crates/opake-core/src/opake.rs
··· 346 346 self.client.session() 347 347 } 348 348 349 + /// Apply a proactively refreshed session: update the in-memory client and 350 + /// persist to storage. Used by the SDK's token guard and the daemon worker. 351 + pub async fn persist_refreshed_session( 352 + &mut self, 353 + session: &crate::client::Session, 354 + ) -> Result<(), Error> { 355 + self.storage.save_session(&self.did, session).await?; 356 + self.client.set_session(session.clone()); 357 + Ok(()) 358 + } 359 + 349 360 /// Persist the session to storage if it was refreshed since the last persist. 350 361 pub(crate) async fn auto_persist_session(&self) -> Result<(), Error> { 351 362 if self.client.session_refreshed() {
+95
crates/opake-core/src/scope.rs
··· 1 + // OAuth scope registry — canonical list of collections and scope construction. 2 + // 3 + // Adding a new app.opake.* collection? Add it to OPAKE_COLLECTIONS below. 4 + // The OAuth scope string and the permission set lexicon are both derived 5 + // from this list. 6 + 7 + /// All `app.opake.*` collections that Opake needs repo access to. 8 + /// 9 + /// This is the single source of truth for the OAuth scope string. 10 + /// The compile-time test at the bottom of this file ensures every 11 + /// `*_COLLECTION` const in the crate appears here. 12 + pub const OPAKE_COLLECTIONS: &[&str] = &[ 13 + crate::records::ACCOUNT_CONFIG_COLLECTION, 14 + crate::directories::DIRECTORY_COLLECTION, 15 + crate::records::DIRECTORY_UPDATE_COLLECTION, 16 + crate::documents::DOCUMENT_COLLECTION, 17 + crate::records::DOCUMENT_UPDATE_COLLECTION, 18 + crate::sharing::GRANT_COLLECTION, 19 + crate::records::INVITATION_COLLECTION, 20 + crate::records::INVITATION_ACCEPTANCE_COLLECTION, 21 + crate::keyrings::KEYRING_COLLECTION, 22 + crate::records::KEYRING_UPDATE_COLLECTION, 23 + crate::records::PAIR_REQUEST_COLLECTION, 24 + crate::records::PAIR_RESPONSE_COLLECTION, 25 + crate::records::PENDING_SHARE_COLLECTION, 26 + crate::records::PUBLIC_KEY_COLLECTION, 27 + ]; 28 + 29 + /// Build the OAuth scope string for Opake. 30 + /// 31 + /// Requests granular per-collection repo access for all `app.opake.*` record 32 + /// types, blob upload/download access, and the base `atproto` scope. 33 + pub fn oauth_scope() -> String { 34 + let mut parts: Vec<String> = Vec::with_capacity(OPAKE_COLLECTIONS.len() + 2); 35 + parts.push("atproto".into()); 36 + for collection in OPAKE_COLLECTIONS { 37 + parts.push(format!("repo:{collection}")); 38 + } 39 + parts.push("blob:*/*".into()); 40 + parts.join(" ") 41 + } 42 + 43 + #[cfg(test)] 44 + mod tests { 45 + use super::*; 46 + 47 + /// Ensures every *_COLLECTION const in the crate is listed in 48 + /// OPAKE_COLLECTIONS. If this fails, you added a new collection 49 + /// constant but forgot to register it for OAuth scopes. 50 + #[test] 51 + fn all_collection_constants_are_registered() { 52 + let all_known: &[&str] = &[ 53 + crate::records::ACCOUNT_CONFIG_COLLECTION, 54 + crate::directories::DIRECTORY_COLLECTION, 55 + crate::records::DIRECTORY_UPDATE_COLLECTION, 56 + crate::documents::DOCUMENT_COLLECTION, 57 + crate::records::DOCUMENT_UPDATE_COLLECTION, 58 + crate::sharing::GRANT_COLLECTION, 59 + crate::records::INVITATION_COLLECTION, 60 + crate::records::INVITATION_ACCEPTANCE_COLLECTION, 61 + crate::keyrings::KEYRING_COLLECTION, 62 + crate::records::KEYRING_UPDATE_COLLECTION, 63 + crate::records::PAIR_REQUEST_COLLECTION, 64 + crate::records::PAIR_RESPONSE_COLLECTION, 65 + crate::records::PENDING_SHARE_COLLECTION, 66 + crate::records::PUBLIC_KEY_COLLECTION, 67 + ]; 68 + 69 + for collection in all_known { 70 + assert!( 71 + OPAKE_COLLECTIONS.contains(collection), 72 + "collection {collection} is not registered in OPAKE_COLLECTIONS — \ 73 + add it to crate::scope::OPAKE_COLLECTIONS so it's included in the OAuth scope", 74 + ); 75 + } 76 + } 77 + 78 + #[test] 79 + fn oauth_scope_includes_base_and_blob() { 80 + let scope = oauth_scope(); 81 + assert!(scope.starts_with("atproto ")); 82 + assert!(scope.ends_with(" blob:*/*")); 83 + } 84 + 85 + #[test] 86 + fn oauth_scope_includes_all_collections() { 87 + let scope = oauth_scope(); 88 + for collection in OPAKE_COLLECTIONS { 89 + assert!( 90 + scope.contains(&format!("repo:{collection}")), 91 + "scope missing repo:{collection}", 92 + ); 93 + } 94 + } 95 + }
+290
crates/opake-wasm/src/auth_wasm.rs
··· 1 + // OAuth login flows — WASM exports. 2 + // 3 + // All token handling, DPoP key generation, and session construction happens 4 + // here in WASM. JS never sees token responses or constructs session objects. 5 + // 6 + // The one exception: PendingLogin state crosses the boundary because it must 7 + // survive a full-page redirect via sessionStorage. This includes the DPoP key 8 + // and PKCE verifier. Once completeOAuthLogin is called, those values enter 9 + // WASM and the resulting session (tokens, keys) never leaves. 10 + 11 + use opake_core::client::dpop::DpopKeyPair; 12 + use opake_core::client::oauth_discovery::generate_pkce; 13 + use opake_core::client::oauth_token; 14 + use opake_core::client::{OAuthSession, Session, WasmTransport, XrpcClient}; 15 + use opake_core::crypto::OsRng; 16 + use opake_core::resolve::resolve_pds_for_login; 17 + use opake_core::storage::{AccountEntry, Storage}; 18 + use serde::{Deserialize, Serialize}; 19 + use wasm_bindgen::prelude::*; 20 + 21 + use crate::js_storage::{JsStorage, JsStorageAdapter}; 22 + use crate::wasm_util::wasm_err; 23 + 24 + // --------------------------------------------------------------------------- 25 + // PendingLogin — serializable state that survives page redirects 26 + // --------------------------------------------------------------------------- 27 + 28 + #[derive(Serialize, Deserialize)] 29 + #[serde(rename_all = "camelCase")] 30 + struct PendingLoginState { 31 + pds_url: String, 32 + did: String, 33 + handle: String, 34 + dpop_key: DpopKeyPair, 35 + pkce_verifier: String, 36 + csrf_state: String, 37 + token_endpoint: String, 38 + client_id: String, 39 + dpop_nonce: Option<String>, 40 + } 41 + 42 + // --------------------------------------------------------------------------- 43 + // CSRF state generation 44 + // --------------------------------------------------------------------------- 45 + 46 + fn generate_csrf_state(rng: &mut OsRng) -> String { 47 + use opake_core::crypto::RngCore; 48 + 49 + let mut bytes = [0u8; 16]; 50 + rng.fill_bytes(&mut bytes); 51 + // Hex is URL-safe without encoding and just as random as base64url. 52 + bytes.iter().map(|b| format!("{b:02x}")).collect() 53 + } 54 + 55 + // --------------------------------------------------------------------------- 56 + // Start OAuth login 57 + // --------------------------------------------------------------------------- 58 + 59 + /// Start an OAuth login flow. Handles resolution, discovery, PKCE, DPoP 60 + /// keypair generation, and the Pushed Authorization Request. 61 + /// 62 + /// Returns `{ authUrl, pending }` where `pending` is serializable state 63 + /// the caller saves to sessionStorage for the redirect round-trip. 64 + #[wasm_bindgen(js_name = startOAuthLogin)] 65 + pub async fn start_oauth_login( 66 + handle: &str, 67 + redirect_uri: &str, 68 + storage_adapter: JsStorageAdapter, 69 + ) -> Result<JsValue, JsError> { 70 + let transport = WasmTransport::new(); 71 + let storage = JsStorage::new(storage_adapter); 72 + let mut rng = OsRng; 73 + 74 + // 1. Resolve handle → DID + PDS URL 75 + let (did, pds_url, resolved_handle) = resolve_pds_for_login(&transport, handle) 76 + .await 77 + .map_err(wasm_err)?; 78 + let handle_str = resolved_handle.unwrap_or_else(|| handle.to_string()); 79 + 80 + // 2. Discover authorization server 81 + let (_prm, asm) = 82 + opake_core::client::oauth_discovery::discover_authorization_server(&transport, &pds_url) 83 + .await 84 + .map_err(wasm_err)?; 85 + 86 + // 3. Generate DPoP keypair + PKCE + CSRF state + scope 87 + let dpop_key = DpopKeyPair::generate(&mut rng); 88 + let pkce = generate_pkce(&mut rng); 89 + let csrf_state = generate_csrf_state(&mut rng); 90 + let scope = opake_core::scope::oauth_scope(); 91 + let client_id = oauth_token::build_client_id(redirect_uri, &scope); 92 + 93 + // 4. Pushed Authorization Request (with DPoP proof + nonce retry) 94 + let par_endpoint = asm.par_endpoint(); 95 + let timestamp = opake_core::client::time::unix_now(); 96 + let mut dpop_nonce: Option<String> = None; 97 + let par_response = oauth_token::pushed_authorization_request( 98 + &transport, 99 + &par_endpoint, 100 + &client_id, 101 + redirect_uri, 102 + &pkce, 103 + &scope, 104 + &csrf_state, 105 + Some(handle), 106 + &dpop_key, 107 + &mut dpop_nonce, 108 + timestamp, 109 + &mut rng, 110 + ) 111 + .await 112 + .map_err(wasm_err)?; 113 + 114 + // 5. Build authorization URL 115 + let auth_url = oauth_token::build_authorization_url( 116 + &asm.authorization_endpoint, 117 + &client_id, 118 + &par_response.request_uri, 119 + ); 120 + 121 + // 6. Return auth URL + serializable pending state 122 + // Config is NOT saved here — the user hasn't authorized yet. 123 + // Config is saved in completeOAuthLogin after successful code exchange. 124 + let pending = PendingLoginState { 125 + pds_url, 126 + did, 127 + handle: handle_str, 128 + dpop_key, 129 + pkce_verifier: pkce.verifier, 130 + csrf_state, 131 + token_endpoint: asm.token_endpoint, 132 + client_id, 133 + dpop_nonce, 134 + }; 135 + 136 + #[derive(Serialize)] 137 + #[serde(rename_all = "camelCase")] 138 + struct StartResult { 139 + auth_url: String, 140 + pending: PendingLoginState, 141 + } 142 + 143 + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); 144 + StartResult { auth_url, pending } 145 + .serialize(&serializer) 146 + .map_err(|e| JsError::new(&e.to_string())) 147 + } 148 + 149 + // --------------------------------------------------------------------------- 150 + // Complete OAuth login 151 + // --------------------------------------------------------------------------- 152 + 153 + /// Complete an OAuth login flow after the user returns from authorization. 154 + /// 155 + /// Validates the CSRF state, exchanges the code for tokens (with DPoP), 156 + /// builds the session, and saves it to storage. Tokens never cross the 157 + /// WASM/JS boundary. 158 + #[wasm_bindgen(js_name = completeOAuthLogin)] 159 + pub async fn complete_oauth_login( 160 + code: &str, 161 + state: &str, 162 + pending_js: JsValue, 163 + redirect_uri: &str, 164 + storage_adapter: JsStorageAdapter, 165 + ) -> Result<(), JsError> { 166 + let pending: PendingLoginState = 167 + serde_wasm_bindgen::from_value(pending_js).map_err(|e| JsError::new(&e.to_string()))?; 168 + let storage = JsStorage::new(storage_adapter); 169 + let transport = WasmTransport::new(); 170 + let mut rng = OsRng; 171 + 172 + // 1. CSRF validation 173 + if state != pending.csrf_state { 174 + return Err(JsError::new("CSRF state mismatch — possible replay attack")); 175 + } 176 + 177 + // 2. Exchange code for tokens (DPoP-bound, nonce-retried) 178 + let mut dpop_nonce = pending.dpop_nonce; 179 + let timestamp = opake_core::client::time::unix_now(); 180 + 181 + let token_response = oauth_token::exchange_code( 182 + &transport, 183 + &pending.token_endpoint, 184 + &pending.client_id, 185 + code, 186 + redirect_uri, 187 + &pending.pkce_verifier, 188 + &pending.dpop_key, 189 + &mut dpop_nonce, 190 + Some(&pending.did), 191 + timestamp, 192 + &mut rng, 193 + ) 194 + .await 195 + .map_err(wasm_err)?; 196 + 197 + // 3. Build session (tokens stay in WASM) 198 + let now = opake_core::client::time::unix_now(); 199 + let session = OAuthSession { 200 + did: token_response.sub.unwrap_or_else(|| pending.did.clone()), 201 + handle: pending.handle.clone(), 202 + access_token: token_response.access_token, 203 + refresh_token: token_response.refresh_token.unwrap_or_default(), 204 + dpop_key: pending.dpop_key, 205 + token_endpoint: pending.token_endpoint, 206 + dpop_nonce, 207 + expires_at: token_response.expires_in.map(|e| now + e as i64), 208 + client_id: pending.client_id, 209 + }; 210 + 211 + // 4. Save session 212 + let did = session.did.clone(); 213 + storage 214 + .save_session(&did, &Session::OAuth(session)) 215 + .await 216 + .map_err(wasm_err)?; 217 + 218 + // 5. Save config 219 + save_account_config(&storage, &did, &pending.pds_url, &pending.handle).await?; 220 + 221 + Ok(()) 222 + } 223 + 224 + // --------------------------------------------------------------------------- 225 + // App password login 226 + // --------------------------------------------------------------------------- 227 + 228 + /// Login with an app password (legacy createSession). 229 + /// 230 + /// Resolves the handle, authenticates via the PDS, and saves the session. 231 + /// Tokens never cross the WASM/JS boundary. 232 + #[wasm_bindgen(js_name = loginWithAppPasswordWasm)] 233 + pub async fn login_with_app_password_wasm( 234 + handle: &str, 235 + app_password: &str, 236 + storage_adapter: JsStorageAdapter, 237 + ) -> Result<(), JsError> { 238 + let transport = WasmTransport::new(); 239 + let storage = JsStorage::new(storage_adapter); 240 + 241 + // 1. Resolve handle → PDS URL 242 + let (_did, pds_url, resolved_handle) = resolve_pds_for_login(&transport, handle) 243 + .await 244 + .map_err(wasm_err)?; 245 + let handle_str = resolved_handle.unwrap_or_else(|| handle.to_string()); 246 + 247 + // 2. Login via createSession (all token handling in WASM) 248 + let mut client = XrpcClient::new(WasmTransport::new(), pds_url.clone()); 249 + client.login(handle, app_password).await.map_err(wasm_err)?; 250 + 251 + // 3. Save session (use the DID from the session response, not from resolution) 252 + let session = client 253 + .session() 254 + .cloned() 255 + .ok_or_else(|| JsError::new("login produced no session"))?; 256 + storage 257 + .save_session(session.did(), &session) 258 + .await 259 + .map_err(wasm_err)?; 260 + 261 + // 4. Save config 262 + save_account_config(&storage, session.did(), &pds_url, &handle_str).await?; 263 + 264 + Ok(()) 265 + } 266 + 267 + // --------------------------------------------------------------------------- 268 + // Helpers 269 + // --------------------------------------------------------------------------- 270 + 271 + async fn save_account_config( 272 + storage: &JsStorage, 273 + did: &str, 274 + pds_url: &str, 275 + handle: &str, 276 + ) -> Result<(), JsError> { 277 + let mut config = storage.load_config().await.unwrap_or_default(); 278 + 279 + config.add_account( 280 + did.to_string(), 281 + AccountEntry { 282 + pds_url: pds_url.to_string(), 283 + handle: handle.to_string(), 284 + }, 285 + ); 286 + // Login always sets the logged-in account as default. 287 + config.default_did = Some(did.to_string()); 288 + 289 + storage.save_config(&config).await.map_err(wasm_err) 290 + }
+2
crates/opake-wasm/src/lib.rs
··· 13 13 use wasm_bindgen::prelude::*; 14 14 15 15 #[cfg(target_arch = "wasm32")] 16 + mod auth_wasm; 17 + #[cfg(target_arch = "wasm32")] 16 18 mod daemon; 17 19 #[cfg(target_arch = "wasm32")] 18 20 pub(crate) mod file_manager_wasm;
+72
crates/opake-wasm/src/opake_wasm.rs
··· 750 750 Ok(key.0.to_vec()) 751 751 } 752 752 753 + /// Proactively refresh the OAuth token if it's close to expiry. 754 + /// 755 + /// Calls `refresh_token` directly — no side-effect hacks. The refreshed 756 + /// session is persisted to storage. 757 + #[wasm_bindgen(js_name = proactiveRefresh)] 758 + pub async fn proactive_refresh(&mut self) -> Result<(), JsError> { 759 + use opake_core::client::session_refresh::{ 760 + proactive_refresh, RefreshOutcome, DEFAULT_REFRESH_THRESHOLD_SECONDS, 761 + }; 762 + 763 + let mut opake = self.opake()?; 764 + let now = opake_core::client::time::unix_now(); 765 + 766 + // Check expiry before cloning the session (avoids copying tokens/keys 767 + // on every token guard call when the token is still fresh). 768 + let needs_it = opake 769 + .session() 770 + .map(|s| s.needs_refresh(DEFAULT_REFRESH_THRESHOLD_SECONDS, now)) 771 + .unwrap_or(false); 772 + if !needs_it { 773 + return Ok(()); 774 + } 775 + 776 + let session = opake 777 + .session() 778 + .cloned() 779 + .ok_or_else(|| JsError::new("no session"))?; 780 + let pds_url = opake.client_mut().base_url().to_string(); 781 + let transport = opake_core::client::WasmTransport::new(); 782 + 783 + let outcome = proactive_refresh( 784 + &transport, 785 + &session, 786 + &pds_url, 787 + DEFAULT_REFRESH_THRESHOLD_SECONDS, 788 + now, 789 + &mut opake_core::crypto::OsRng, 790 + ) 791 + .await; 792 + 793 + match outcome { 794 + RefreshOutcome::Refreshed(new_session) => { 795 + // Persist to storage, then update in-memory client 796 + opake 797 + .persist_refreshed_session(&new_session) 798 + .await 799 + .map_err(wasm_err)?; 800 + Ok(()) 801 + } 802 + RefreshOutcome::NotNeeded => Ok(()), 803 + RefreshOutcome::Failed(e) => Err(wasm_err(e)), 804 + } 805 + } 806 + 753 807 /// Get the (potentially refreshed) session. 754 808 pub fn session(&self) -> Result<JsValue, JsError> { 755 809 let borrow = self.inner.borrow(); ··· 758 812 .ok_or_else(|| JsError::new("already consumed"))?; 759 813 let session = opake.session().ok_or_else(|| JsError::new("no session"))?; 760 814 serde_wasm_bindgen::to_value(session).map_err(|e| JsError::new(&e.to_string())) 815 + } 816 + 817 + /// Get the token expiry timestamp without exposing the full session. 818 + /// 819 + /// Returns the Unix timestamp (seconds) when the access token expires, 820 + /// or -1 if unknown/not applicable (legacy sessions). 821 + /// This avoids serializing tokens/keys to JS for a simple expiry check. 822 + #[wasm_bindgen(js_name = tokenExpiresAt)] 823 + pub fn token_expires_at(&self) -> f64 { 824 + let borrow = self.inner.borrow(); 825 + let Some(opake) = borrow.as_ref() else { 826 + return -1.0; 827 + }; 828 + opake 829 + .session() 830 + .and_then(|s| s.expires_at()) 831 + .map(|t| t as f64) 832 + .unwrap_or(-1.0) 761 833 } 762 834 763 835 fn opake(&self) -> Result<std::cell::RefMut<'_, WasmOpake>, JsError> {
+3 -6
crates/opake-wasm/src/wasm_util.rs
··· 7 7 8 8 /// Create a short-lived XrpcClient from a JS session object. 9 9 /// 10 - /// Note: Session's custom Deserialize impl goes through serde_json::Value 11 - /// as an intermediate when called from serde_wasm_bindgen. This double-deser 12 - /// works because serde_json::Value is a generic serde container, but it means 13 - /// JS types that serde_json::Value can't represent (BigInt, undefined) would 14 - /// fail. Session objects from the TS side are well-controlled plain objects, 15 - /// so this is safe in practice. 10 + /// Session's custom Deserialize impl uses serde's native tagged enum support 11 + /// with an untagged fallback for backward compat. This works correctly with 12 + /// serde_wasm_bindgen (no intermediate serde_json::Value conversion). 16 13 pub fn make_client( 17 14 pds_url: &str, 18 15 session_json: JsValue,
+26 -10
docs/AUTH.md
··· 6 6 7 7 ### OAuth Flow 8 8 9 - 1. Resolve handle to PDS URL (public API, DID document) 10 - 2. Discover OAuth Authorization Server via `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server` 11 - 3. Generate DPoP keypair (P-256/ES256), PKCE S256 challenge, and state nonce 12 - 4. Push Authorization Request (PAR) with DPoP proof and PKCE challenge 9 + All token handling happens in WASM (opake-core). JS never parses token responses, constructs session objects, or holds DPoP private keys. The WASM exports `startOAuthLogin`, `completeOAuthLogin`, and `loginWithAppPasswordWasm` compose core primitives into complete login flows. 10 + 11 + 1. Resolve handle to PDS URL (`resolve_pds_for_login` — .well-known, public API, DID document) 12 + 2. Discover OAuth Authorization Server via `/.well-known/oauth-protected-resource` → `/.well-known/oauth-authorization-server`; PAR endpoint resolved via `AuthorizationServerMetadata::par_endpoint()` 13 + 3. Generate DPoP keypair (P-256/ES256), PKCE S256 challenge, CSRF state 14 + 4. Push Authorization Request (PAR) with DPoP proof, PKCE challenge, and `login_hint` (pre-fills the AS consent page) 13 15 5. Open browser to authorization URL; CLI starts a loopback HTTP server on `127.0.0.1` 14 - 6. User authorizes in the browser; PDS redirects to loopback with `code` and `state` 15 - 7. Verify state (CSRF), exchange authorization code for tokens (with DPoP proof and PKCE verifier) 16 - 8. Save `OAuthSession` (access token, refresh token, DPoP keypair) 16 + 6. User authorizes in the browser; PDS redirects with `code` and `state` 17 + 7. CSRF validation, authorization code exchange with DPoP proof + PKCE verifier — all in WASM 18 + 8. WASM builds `OAuthSession` and saves to storage. Tokens never enter JS memory. 17 19 9. Publish `app.opake.publicKey/self` via idempotent `putRecord` 18 20 19 - The web frontend uses the same OAuth flow but with a redirect URI instead of a loopback server. 21 + The web frontend uses a two-step flow: `Opake.startLogin()` returns the auth URL + serializable `PendingLogin` state. The consumer saves this via `Opake.savePendingLogin()` (sessionStorage with 10-minute TTL), redirects, then calls `Opake.completeLogin()` on the callback page. `Opake.loadPendingLogin()` auto-clears the DPoP key material from sessionStorage on read. 22 + 23 + ### OAuth Scopes 24 + 25 + Opake requests granular per-collection scopes instead of the catch-all `transition:generic`: 26 + 27 + ``` 28 + atproto repo:app.opake.accountConfig repo:app.opake.directory ... repo:app.opake.publicKey blob:*/* 29 + ``` 30 + 31 + The scope string is built from `crate::scope::OPAKE_COLLECTIONS` (single source of truth). The same scope is embedded in the loopback client ID via `build_client_id(redirect_uri, scope)` and passed to the PAR body — they must match. 32 + 33 + A permission set lexicon (`app.opake.authFullAccess`) bundles all collections for when `include:` scopes are supported by PDSes. 20 34 21 35 ### Legacy Flow 22 36 23 - Password-based via `com.atproto.server.createSession`. Used when `--legacy` is passed or OAuth discovery fails. Tokens are saved as a `LegacySession`. 37 + Password-based via `com.atproto.server.createSession`. Used when `--legacy` is passed or OAuth discovery fails. The WASM export `loginWithAppPasswordWasm` handles resolution, authentication, and session storage. Tokens are saved as a `LegacySession`. 24 38 25 39 ### Token Refresh 26 40 27 - Transparent. The XRPC client detects expired tokens and refreshes automatically. OAuth refresh includes a fresh DPoP proof; legacy refresh uses `com.atproto.server.refreshSession` with the refresh JWT. DPoP nonces are captured from every response and replayed on subsequent requests. 41 + Proactive. The SDK's `@withTokenGuard` decorator calls `tokenExpiresAt()` (WASM export — returns only the timestamp, no token exposure) before every authenticated operation. If the token expires within 30 seconds, `proactiveRefresh()` (WASM export — calls `refresh_token` directly) is triggered. Concurrent callers share a single-flight promise. 42 + 43 + Reactive refresh is the fallback: the XRPC client detects `ExpiredToken` / `AuthenticationFailed` errors and refreshes automatically. DPoP nonces are captured from every response and replayed on subsequent requests. 28 44 29 45 See [flows/authentication.md](flows/authentication.md) for full sequence diagrams. 30 46
+5 -2
docs/CRATE_STRUCTURE.md
··· 11 11 atproto.rs AT-URI parsing, shared AT Protocol primitives 12 12 account_config.rs Fetch/publish singleton account config from PDS 13 13 resolve.rs Handle/DID → PDS → public key resolution pipeline 14 + scope.rs OAuth scope registry — OPAKE_COLLECTIONS (single source of truth for all app.opake.* collections) + oauth_scope() builder 14 15 storage.rs Config, Identity types + Storage trait (cross-platform contract) 15 16 paths.rs Data directory resolution (env, XDG, fallback) 16 17 daemon.rs Background task registry (shared definitions for CLI + web). Daemon builds Opake per account per task iteration, auto-persists via signoff ··· 58 59 list.rs Generic paginated collection fetcher 59 60 dpop.rs DPoP keypair (P-256/ES256) + proof JWT generation 60 61 oauth_discovery.rs OAuth AS discovery + PKCE S256 generation 61 - oauth_token.rs PAR, authorization code exchange, token refresh (all with DPoP) 62 + oauth_token.rs PAR, authorization code exchange, token refresh (all with DPoP), build_client_id 63 + oauth_discovery.rs also provides AuthorizationServerMetadata::par_endpoint() 62 64 xrpc/ 63 65 mod.rs XrpcClient struct, Session enum (Legacy/OAuth), dual auth dispatch 64 66 auth.rs login(), refresh_session() (legacy + OAuth) ··· 108 110 opake-wasm/ WASM bridge (wasm-pack, wasm_bindgen) 109 111 src/ 110 112 lib.rs Module declarations, WASM init, pure crypto + tree exports (stateless) 111 - opake_wasm.rs OpakeContext + WasmFileManagerHandle (owns Opake+FileContext, temporary FileManager borrows per JS call) 113 + auth_wasm.rs OAuth login WASM exports: startOAuthLogin, completeOAuthLogin, loginWithAppPasswordWasm. All token handling in WASM. 114 + opake_wasm.rs OpakeContext + WasmFileManagerHandle (owns Opake+FileContext, temporary FileManager borrows per JS call). Also: tokenExpiresAt, proactiveRefresh 112 115 daemon.rs Service Worker maintenance task exports (session refresh, pair cleanup) 113 116 wasm_util.rs make_client, make_opake, make_cabinet, make_workspace helpers. WasmOpake = Opake<WasmTransport, OsRng, NoopStorage> 114 117
+2 -1
docs/CRYPTO.md
··· 272 272 - **Types with automatic zeroization:** 273 273 - `ContentKey` — AES-256 content encryption key 274 274 - `Identity` — private_key and signing_key fields (base64 strings zeroed) 275 + - `DpopKeyPair` — private_key_b64 (P-256 private key for DPoP proof generation) 275 276 - `LegacySession` — access_jwt, refresh_jwt 276 - - `OAuthSession` — access_token, refresh_token 277 + - `OAuthSession` — access_token, refresh_token (nested `DpopKeyPair` chains zeroization) 277 278 - `Cabinet` — raw X25519 private key bytes (explicit `#[derive(Zeroize, ZeroizeOnDrop)]`) 278 279 - `Workspace` — workspace key / ContentKey (explicit `#[derive(Zeroize, ZeroizeOnDrop)]`) 279 280
+4
lexicons/README.md
··· 32 32 | `app.opake.documentUpdate` | record | A proposed update to another member's document — content, metadata, or adoption | 33 33 | `app.opake.directoryUpdate` | record | A proposed structural change to a workspace directory (placement, move, create, rename, delete) | 34 34 | `app.opake.pendingShare` | record | A queued share intent — retried by daemon until recipient signs up or expires (7 days) | 35 + | `app.opake.invitation` | record | Workspace invitation with token and optional role/expiry | 36 + | `app.opake.invitationAcceptance` | record | Acceptance of a workspace invitation | 37 + | `app.opake.keyringUpdate` | record | A proposed update to a workspace keyring (member add/remove, metadata, role change) | 35 38 | `app.opake.pairRequest` | record | Ephemeral public key from a new device requesting identity transfer | 36 39 | `app.opake.pairResponse` | record | Encrypted identity payload sent in response to a pair request | 40 + | `app.opake.authFullAccess` | permission-set | OAuth permission set bundling all `app.opake.*` collections — for `include:` scopes | 37 41 38 42 ## Flow: Sharing a file with another DID 39 43
+33
lexicons/app.opake.authFullAccess.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.opake.authFullAccess", 4 + "defs": { 5 + "main": { 6 + "type": "permission-set", 7 + "title": "Full Opake Access", 8 + "detail": "Read and write all Opake records: encrypted files, directories, workspaces, sharing grants, device pairing, and account configuration.", 9 + "permissions": [ 10 + { 11 + "type": "permission", 12 + "resource": "repo", 13 + "collection": [ 14 + "app.opake.accountConfig", 15 + "app.opake.directory", 16 + "app.opake.directoryUpdate", 17 + "app.opake.document", 18 + "app.opake.documentUpdate", 19 + "app.opake.grant", 20 + "app.opake.invitation", 21 + "app.opake.invitationAcceptance", 22 + "app.opake.keyring", 23 + "app.opake.keyringUpdate", 24 + "app.opake.pairRequest", 25 + "app.opake.pairResponse", 26 + "app.opake.pendingShare", 27 + "app.opake.publicKey" 28 + ] 29 + } 30 + ] 31 + } 32 + } 33 + }
+17 -394
packages/opake-sdk/src/auth.ts
··· 1 - // Authentication flows — OAuth 2.0 + DPoP and app passwords. 1 + // Authentication types for the two-step OAuth login flow. 2 2 // 3 - // The SDK provides two auth paths: 4 - // - Opake.login() — OAuth 2.0 with DPoP and PKCE (browser, Electron, CLI) 5 - // - Opake.loginWithAppPassword() — legacy createSession (Obsidian, scripts) 6 - // 7 - // Both save the session to Storage so Opake.init() works afterward. 8 - // 9 - // !! AGENT NOTE — SECURITY BOUNDARY !! 10 - // 11 - // Tokens, DPoP keys, and session credentials MUST NOT be handled in JS. 12 - // JS strings are immutable and GC'd on the runtime's schedule — they 13 - // cannot be zeroized. The WASM layer (opake-core) auto-zeroizes all 14 - // sensitive types on drop via RedactedDebug + Zeroize. 3 + // All auth logic lives in Opake's static methods (startLogin, completeLogin, 4 + // login, loginWithAppPassword), which delegate to WASM exports. This file 5 + // only defines the types that consumers need for the redirect round-trip. 15 6 // 16 - // The functions below (fetchWithDpop, completeLogin, loginWithAppPassword) 17 - // currently violate this: they parse token responses, construct session 18 - // objects, and hold DPoP private keys in JS memory. These MUST be moved 19 - // to WASM exports that delegate to opake-core's existing exchange_code / 20 - // refresh_token / XrpcClient::login functions. 7 + // !! SECURITY BOUNDARY !! 21 8 // 22 - // Discovery (resolveHandleToPds, discoverAuthorizationServer) is fine in 23 - // JS — no sensitive data involved. 9 + // Tokens, DPoP keys, and session credentials are handled exclusively in WASM. 10 + // JS strings are immutable and GC'd on the runtime's schedule — they cannot 11 + // be zeroized. The WASM layer (opake-core) auto-zeroizes all sensitive types 12 + // on drop via RedactedDebug + Zeroize. 24 13 // 25 - // DO NOT add new code that handles tokens or keys in JS. Route through 26 - // WASM instead. 27 - 28 - import type { DpopKeyPair, OAuthSession, LegacySession, Storage } from "./storage"; 29 - import { initWasm } from "./wasm"; 30 - 31 - const BSKY_PUBLIC_API = "https://public.api.bsky.app"; 32 - 33 - // --------------------------------------------------------------------------- 34 - // Types 35 - // --------------------------------------------------------------------------- 14 + // The one exception: PendingLogin state crosses the WASM/JS boundary because 15 + // it must survive a full-page redirect via sessionStorage. This includes the 16 + // DPoP key and PKCE verifier. Once completeLogin is called, those values 17 + // enter WASM and the resulting session (tokens, keys) never leaves. 36 18 37 - interface AuthorizationServerMetadata { 38 - readonly issuer: string; 39 - readonly authorization_endpoint: string; 40 - readonly token_endpoint: string; 41 - readonly pushed_authorization_request_endpoint?: string; 42 - } 43 - 44 - interface TokenResponse { 45 - readonly access_token: string; 46 - readonly token_type: string; 47 - readonly refresh_token?: string; 48 - readonly expires_in?: number; 49 - readonly scope?: string; 50 - readonly sub?: string; 51 - } 19 + import type { DpopKeyPair, Storage } from "./storage"; 52 20 53 21 /** Serializable state for the two-step login flow (survives page redirects). */ 54 22 export interface PendingLogin { ··· 76 44 * - CLI: print URL, start localhost server, wait for callback 77 45 * - Electron: open BrowserWindow, intercept redirect 78 46 */ 79 - readonly authorize: (authUrl: string) => Promise<{ code: string; state: string }>; 47 + readonly authorize: ( 48 + authUrl: string, 49 + ) => Promise<{ code: string; state: string }>; 80 50 /** Abort signal for timeout/cancellation. */ 81 51 readonly signal?: AbortSignal; 82 52 } ··· 86 56 readonly storage: Storage; 87 57 readonly redirectUri: string; 88 58 } 89 - 90 - // --------------------------------------------------------------------------- 91 - // Handle resolution 92 - // --------------------------------------------------------------------------- 93 - 94 - async function resolveHandleToPds(handle: string): Promise<{ did: string; pdsUrl: string }> { 95 - // Try .well-known first 96 - try { 97 - const wkUrl = `https://${handle}/.well-known/atproto-did`; 98 - const wkResponse = await fetch(wkUrl); 99 - if (wkResponse.ok) { 100 - const did = (await wkResponse.text()).trim(); 101 - if (did.startsWith("did:")) { 102 - const pdsUrl = await pdsUrlFromDid(did); 103 - return { did, pdsUrl }; 104 - } 105 - } 106 - } catch { 107 - // Fall through to public API 108 - } 109 - 110 - // Fall back to public API 111 - const resolveUrl = `${BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 112 - const response = await fetch(resolveUrl); 113 - if (!response.ok) { 114 - throw new Error(response.status === 400 ? "Handle not found" : `Failed to resolve handle "${handle}"`); 115 - } 116 - const { did } = (await response.json()) as { did: string }; 117 - const pdsUrl = await pdsUrlFromDid(did); 118 - return { did, pdsUrl }; 119 - } 120 - 121 - async function pdsUrlFromDid(did: string): Promise<string> { 122 - const wasm = await initWasm(); 123 - const docUrl = wasm.didDocumentUrl(did); 124 - const response = await fetch(docUrl); 125 - if (!response.ok) throw new Error(`Failed to fetch DID document for ${did}`); 126 - const docBytes = new Uint8Array(await response.arrayBuffer()); 127 - const pdsUrl = wasm.pdsFromDidDocument(docBytes); 128 - return pdsUrl; 129 - } 130 - 131 - // --------------------------------------------------------------------------- 132 - // OAuth discovery 133 - // --------------------------------------------------------------------------- 134 - 135 - async function discoverAuthorizationServer(pdsUrl: string): Promise<AuthorizationServerMetadata> { 136 - const base = pdsUrl.replace(/\/$/, ""); 137 - const prmResponse = await fetch(`${base}/.well-known/oauth-protected-resource`); 138 - if (!prmResponse.ok) throw new Error(`PDS does not support OAuth (HTTP ${prmResponse.status})`); 139 - 140 - const prm = (await prmResponse.json()) as { authorization_servers?: string[] }; 141 - const asUrl = prm.authorization_servers?.[0]; 142 - if (!asUrl) throw new Error("No authorization servers in protected resource metadata"); 143 - 144 - const asBase = asUrl.replace(/\/$/, ""); 145 - const asmResponse = await fetch(`${asBase}/.well-known/oauth-authorization-server`); 146 - if (!asmResponse.ok) throw new Error(`Failed to fetch AS metadata: HTTP ${asmResponse.status}`); 147 - 148 - return (await asmResponse.json()) as AuthorizationServerMetadata; 149 - } 150 - 151 - // --------------------------------------------------------------------------- 152 - // Client ID (atproto loopback pattern) 153 - // --------------------------------------------------------------------------- 154 - 155 - function buildClientId(redirectUri: string): string { 156 - return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent("atproto transition:generic")}`; 157 - } 158 - 159 - // --------------------------------------------------------------------------- 160 - // DPoP-authenticated fetch with nonce retry 161 - // --------------------------------------------------------------------------- 162 - 163 - async function fetchWithDpop( 164 - url: string, 165 - method: string, 166 - body: URLSearchParams, 167 - dpopKey: DpopKeyPair, 168 - dpopNonce: string | null, 169 - accessToken: string | null, 170 - ): Promise<{ response: Response; dpopNonce: string | null }> { 171 - const wasm = await initWasm(); 172 - const timestamp = Math.floor(Date.now() / 1000); 173 - const proof = wasm.createDpopProof(dpopKey, method, url, timestamp, dpopNonce ?? null, accessToken ?? null); 174 - 175 - const headers: Record<string, string> = { 176 - "Content-Type": "application/x-www-form-urlencoded", 177 - DPoP: proof, 178 - }; 179 - if (accessToken) headers.Authorization = `DPoP ${accessToken}`; 180 - 181 - let response = await fetch(url, { method, headers, body: body.toString() }); 182 - let nonce = response.headers.get("dpop-nonce") ?? dpopNonce; 183 - 184 - // Retry on use_dpop_nonce 185 - if (response.status === 400) { 186 - const errorBody = (await response.clone().json().catch(() => null)) as { 187 - error?: string; 188 - } | null; 189 - if (errorBody?.error === "use_dpop_nonce" && nonce) { 190 - const retryTimestamp = Math.floor(Date.now() / 1000); 191 - const retryProof = wasm.createDpopProof(dpopKey, method, url, retryTimestamp, nonce, accessToken ?? null); 192 - headers.DPoP = retryProof; 193 - response = await fetch(url, { method, headers, body: body.toString() }); 194 - nonce = response.headers.get("dpop-nonce") ?? nonce; 195 - } 196 - } 197 - 198 - return { response, dpopNonce: nonce }; 199 - } 200 - 201 - // --------------------------------------------------------------------------- 202 - // CSRF state 203 - // --------------------------------------------------------------------------- 204 - 205 - function generateCsrfState(): string { 206 - const bytes = new Uint8Array(16); 207 - crypto.getRandomValues(bytes); 208 - return btoa(String.fromCharCode(...bytes)) 209 - .replaceAll("+", "-") 210 - .replaceAll("/", "_") 211 - .replaceAll("=", ""); 212 - } 213 - 214 - // --------------------------------------------------------------------------- 215 - // Two-step login (redirect-safe) 216 - // --------------------------------------------------------------------------- 217 - 218 - /** 219 - * Start an OAuth login flow. Returns serializable pending state + auth URL. 220 - * 221 - * The consumer saves `pending` (e.g., to sessionStorage), redirects the user 222 - * to `authUrl`, then calls `completeLogin()` with the callback params. 223 - */ 224 - export async function startLogin( 225 - handle: string, 226 - options: StartLoginOptions, 227 - ): Promise<{ authUrl: string; pending: PendingLogin }> { 228 - const wasm = await initWasm(); 229 - const { did, pdsUrl } = await resolveHandleToPds(handle); 230 - const asMeta = await discoverAuthorizationServer(pdsUrl); 231 - 232 - const dpopKey = wasm.generateDpopKeyPair() as DpopKeyPair; 233 - const pkce = wasm.generatePkce() as { verifier: string; challenge: string }; 234 - const csrfState = generateCsrfState(); 235 - const clientId = buildClientId(options.redirectUri); 236 - 237 - // Pushed Authorization Request 238 - const parEndpoint = asMeta.pushed_authorization_request_endpoint ?? `${asMeta.token_endpoint.replace(/\/token$/, "/par")}`; 239 - const parBody = new URLSearchParams({ 240 - client_id: clientId, 241 - response_type: "code", 242 - redirect_uri: options.redirectUri, 243 - scope: "atproto transition:generic", 244 - state: csrfState, 245 - code_challenge: pkce.challenge, 246 - code_challenge_method: "S256", 247 - login_hint: handle, 248 - }); 249 - 250 - const { response: parResponse, dpopNonce } = await fetchWithDpop( 251 - parEndpoint, "POST", parBody, dpopKey, null, null, 252 - ); 253 - 254 - if (!parResponse.ok) { 255 - const err = (await parResponse.json().catch(() => ({}))) as { error?: string; error_description?: string }; 256 - throw new Error(`PAR failed: ${err.error ?? "unknown"}: ${err.error_description ?? `HTTP ${parResponse.status}`}`); 257 - } 258 - 259 - const par = (await parResponse.json()) as { request_uri: string }; 260 - const authUrl = `${asMeta.authorization_endpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(par.request_uri)}`; 261 - 262 - // Save config with account info so completeLogin can find it 263 - try { 264 - const config = await options.storage.loadConfig(); 265 - const accounts = { ...config.accounts, [did]: { pds_url: pdsUrl, handle } }; 266 - await options.storage.saveConfig({ ...config, accounts, default_did: did }); 267 - } catch { 268 - // Fresh storage — create config 269 - await options.storage.saveConfig({ 270 - default_did: did, 271 - accounts: { [did]: { pds_url: pdsUrl, handle } }, 272 - }); 273 - } 274 - 275 - return { 276 - authUrl, 277 - pending: { 278 - pdsUrl, 279 - did, 280 - handle, 281 - dpopKey, 282 - pkceVerifier: pkce.verifier, 283 - csrfState, 284 - tokenEndpoint: asMeta.token_endpoint, 285 - clientId, 286 - dpopNonce, 287 - }, 288 - }; 289 - } 290 - 291 - /** 292 - * Complete an OAuth login flow after the user returns from authorization. 293 - * 294 - * Validates the CSRF state, exchanges the code for tokens, and saves 295 - * the session to storage. 296 - */ 297 - export async function completeLogin( 298 - code: string, 299 - state: string, 300 - pending: PendingLogin, 301 - options: { storage: Storage; redirectUri: string }, 302 - ): Promise<void> { 303 - // CSRF validation 304 - if (state !== pending.csrfState) { 305 - throw new Error("CSRF state mismatch — possible replay attack"); 306 - } 307 - 308 - // Exchange code for tokens 309 - const body = new URLSearchParams({ 310 - grant_type: "authorization_code", 311 - client_id: pending.clientId, 312 - code, 313 - redirect_uri: options.redirectUri, 314 - code_verifier: pending.pkceVerifier, 315 - }); 316 - 317 - const { response, dpopNonce } = await fetchWithDpop( 318 - pending.tokenEndpoint, "POST", body, pending.dpopKey, pending.dpopNonce, null, 319 - ); 320 - 321 - if (!response.ok) { 322 - const err = (await response.json().catch(() => ({}))) as { error?: string; error_description?: string }; 323 - throw new Error(`Token exchange failed: ${err.error ?? "unknown"}: ${err.error_description ?? `HTTP ${response.status}`}`); 324 - } 325 - 326 - const tokenResponse = (await response.json()) as TokenResponse; 327 - 328 - if (tokenResponse.token_type.toLowerCase() !== "dpop") { 329 - throw new Error(`Expected token_type "DPoP", got "${tokenResponse.token_type}"`); 330 - } 331 - 332 - const now = Math.floor(Date.now() / 1000); 333 - const session: OAuthSession = { 334 - type: "oauth", 335 - did: tokenResponse.sub ?? pending.did, 336 - handle: pending.handle, 337 - access_token: tokenResponse.access_token, 338 - refresh_token: tokenResponse.refresh_token ?? "", 339 - dpop_key: pending.dpopKey, 340 - token_endpoint: pending.tokenEndpoint, 341 - dpop_nonce: dpopNonce ?? undefined, 342 - expires_at: tokenResponse.expires_in ? now + tokenResponse.expires_in : undefined, 343 - client_id: pending.clientId, 344 - }; 345 - 346 - await options.storage.saveSession(pending.did, session); 347 - } 348 - 349 - // --------------------------------------------------------------------------- 350 - // Convenience login (callback pattern, built on startLogin/completeLogin) 351 - // --------------------------------------------------------------------------- 352 - 353 - /** 354 - * One-shot OAuth login. Handles discovery, PAR, code exchange, and session 355 - * storage. The consumer provides the `authorize` callback for the 356 - * platform-specific redirect step. 357 - * 358 - * Does NOT survive page navigations. For full-page redirect flows, use 359 - * `Opake.startLogin()` / `Opake.completeLogin()` instead. 360 - */ 361 - export async function login( 362 - handle: string, 363 - options: LoginOptions, 364 - ): Promise<void> { 365 - const { authUrl, pending } = await startLogin(handle, { 366 - storage: options.storage, 367 - redirectUri: options.redirectUri, 368 - }); 369 - 370 - const { code, state } = await options.authorize(authUrl); 371 - 372 - await completeLogin(code, state, pending, { 373 - storage: options.storage, 374 - redirectUri: options.redirectUri, 375 - }); 376 - } 377 - 378 - // --------------------------------------------------------------------------- 379 - // App password login 380 - // --------------------------------------------------------------------------- 381 - 382 - /** 383 - * Login with an app password (legacy createSession). 384 - * 385 - * For environments that can't do OAuth redirects (Obsidian plugins, 386 - * simple scripts). The user creates an app password in their PDS 387 - * settings and provides it here. 388 - */ 389 - export async function loginWithAppPassword( 390 - handle: string, 391 - appPassword: string, 392 - options: { storage: Storage }, 393 - ): Promise<void> { 394 - const { did, pdsUrl } = await resolveHandleToPds(handle); 395 - 396 - const response = await fetch(`${pdsUrl}/xrpc/com.atproto.server.createSession`, { 397 - method: "POST", 398 - headers: { "Content-Type": "application/json" }, 399 - body: JSON.stringify({ identifier: handle, password: appPassword }), 400 - }); 401 - 402 - if (!response.ok) { 403 - const err = (await response.json().catch(() => ({}))) as { error?: string; message?: string }; 404 - throw new Error(`Login failed: ${err.message ?? err.error ?? `HTTP ${response.status}`}`); 405 - } 406 - 407 - const result = (await response.json()) as { 408 - did: string; 409 - handle: string; 410 - accessJwt: string; 411 - refreshJwt: string; 412 - }; 413 - 414 - const session: LegacySession = { 415 - type: "legacy", 416 - did: result.did, 417 - handle: result.handle, 418 - access_jwt: result.accessJwt, 419 - refresh_jwt: result.refreshJwt, 420 - }; 421 - 422 - await options.storage.saveSession(result.did, session); 423 - 424 - // Save/update config 425 - try { 426 - const config = await options.storage.loadConfig(); 427 - const accounts = { ...config.accounts, [result.did]: { pds_url: pdsUrl, handle: result.handle } }; 428 - await options.storage.saveConfig({ ...config, accounts, default_did: result.did }); 429 - } catch { 430 - await options.storage.saveConfig({ 431 - default_did: result.did, 432 - accounts: { [result.did]: { pds_url: pdsUrl, handle: result.handle } }, 433 - }); 434 - } 435 - }
+85 -83
packages/opake-sdk/src/opake.ts
··· 9 9 // reactive 401 retries and makes concurrent operations safe — only the 10 10 // refresh itself is serialized. 11 11 12 - import type { Storage, OAuthSession } from "./storage"; 12 + import type { Storage } from "./storage"; 13 13 import type { 14 14 MutationResult, 15 15 OpakeInitOptions, ··· 28 28 } from "./schemas"; 29 29 import { initWasm } from "./wasm"; 30 30 import { FileManager } from "./file-manager"; 31 - import { 32 - login as authLogin, 33 - loginWithAppPassword as authLoginWithAppPassword, 34 - startLogin as authStartLogin, 35 - completeLogin as authCompleteLogin, 36 - type LoginOptions, 37 - type StartLoginOptions, 38 - type PendingLogin, 39 - } from "./auth"; 31 + import type { LoginOptions, StartLoginOptions, PendingLogin } from "./auth"; 32 + import { createStorageAdapter } from "./storage-adapter"; 40 33 import { 41 34 createPairRequest as pairingCreate, 42 35 listPairRequests as pairingList, ··· 56 49 // --------------------------------------------------------------------------- 57 50 58 51 const REFRESH_THRESHOLD_MS = 30_000; // refresh 30s before expiry 52 + const PENDING_STORAGE_KEY = "opake:pendingLogin"; 53 + const PENDING_TTL_MS = 10 * 60 * 1000; // 10 minutes — generous for a redirect round-trip 59 54 60 55 /** 61 56 * Method decorator: ensures the OAuth token is valid before each call. ··· 72 67 }; 73 68 } 74 69 75 - 76 - // --------------------------------------------------------------------------- 77 - // Storage adapter bridge 78 - // --------------------------------------------------------------------------- 79 - 80 - /** 81 - * Create the storage adapter object that the WASM JsStorageAdapter expects. 82 - */ 83 - function createStorageAdapter(storage: Storage): Record<string, unknown> { 84 - return { 85 - loadConfig: () => storage.loadConfig(), 86 - saveConfig: (config: unknown) => 87 - storage.saveConfig(config as Parameters<Storage["saveConfig"]>[0]), 88 - loadIdentity: (did: string) => storage.loadIdentity(did), 89 - saveIdentity: (did: string, identity: unknown) => 90 - storage.saveIdentity( 91 - did, 92 - identity as Parameters<Storage["saveIdentity"]>[1], 93 - ), 94 - loadSession: (did: string) => storage.loadSession(did), 95 - saveSession: (did: string, session: unknown) => 96 - storage.saveSession( 97 - did, 98 - session as Parameters<Storage["saveSession"]>[1], 99 - ), 100 - removeAccount: (did: string) => storage.removeAccount(did), 101 - cacheGetRecord: (did: string, collection: string, uri: string) => 102 - storage.cacheGetRecord(did, collection, uri), 103 - cachePutRecords: (did: string, collection: string, records: unknown) => 104 - storage.cachePutRecords( 105 - did, 106 - collection, 107 - records as Parameters<Storage["cachePutRecords"]>[2], 108 - ), 109 - cacheRemoveRecord: (did: string, collection: string, uri: string) => 110 - storage.cacheRemoveRecord(did, collection, uri), 111 - cacheGetCollection: (did: string, collection: string) => 112 - storage.cacheGetCollection(did, collection), 113 - cachePutCollection: (did: string, collection: string, data: unknown) => 114 - storage.cachePutCollection( 115 - did, 116 - collection, 117 - data as Parameters<Storage["cachePutCollection"]>[2], 118 - ), 119 - cacheInvalidateCollection: (did: string, collection: string) => 120 - storage.cacheInvalidateCollection(did, collection), 121 - cacheClear: (did: string) => storage.cacheClear(did), 122 - }; 123 - } 124 70 125 71 // --------------------------------------------------------------------------- 126 72 // Opake class ··· 301 247 * ``` 302 248 */ 303 249 static async login(handle: string, options: LoginOptions): Promise<void> { 304 - return authLogin(handle, options); 250 + const { authUrl, pending } = await Opake.startLogin(handle, { 251 + storage: options.storage, 252 + redirectUri: options.redirectUri, 253 + }); 254 + const { code, state } = await options.authorize(authUrl); 255 + await Opake.completeLogin(code, state, pending, { 256 + storage: options.storage, 257 + redirectUri: options.redirectUri, 258 + }); 259 + } 260 + 261 + /** 262 + * Save pending login state to sessionStorage. 263 + * 264 + * Use with `startLogin` / `completeLogin` for redirect flows. 265 + * `loadPendingLogin` clears the state on read, so the DPoP key material 266 + * doesn't linger in sessionStorage after the flow completes (or fails). 267 + */ 268 + static savePendingLogin(pending: PendingLogin): void { 269 + const envelope = { pending, savedAt: Date.now() }; 270 + sessionStorage.setItem(PENDING_STORAGE_KEY, JSON.stringify(envelope)); 271 + } 272 + 273 + /** 274 + * Load and clear pending login state from sessionStorage. 275 + * 276 + * Returns `null` if no pending state exists or if the state is older 277 + * than 10 minutes (TTL). Always clears the key — the DPoP key material 278 + * must not persist regardless of success or failure. 279 + */ 280 + static loadPendingLogin(): PendingLogin | null { 281 + const raw = sessionStorage.getItem(PENDING_STORAGE_KEY); 282 + sessionStorage.removeItem(PENDING_STORAGE_KEY); 283 + if (!raw) return null; 284 + try { 285 + const envelope = JSON.parse(raw) as { pending: PendingLogin; savedAt: number }; 286 + if (Date.now() - envelope.savedAt > PENDING_TTL_MS) return null; 287 + return envelope.pending; 288 + } catch { 289 + return null; 290 + } 305 291 } 306 292 307 293 /** 308 294 * Start an OAuth login flow (two-step, redirect-safe). 309 295 * 310 - * Returns the auth URL and serializable pending state. The consumer 311 - * saves `pending` to sessionStorage, redirects the user, then calls 312 - * `Opake.completeLogin()` with the callback parameters. 296 + * Returns the auth URL and serializable pending state. Save the pending 297 + * state with `Opake.savePendingLogin(pending)` before redirecting, then 298 + * load it with `Opake.loadPendingLogin()` on the callback page. 313 299 * 314 300 * @example 315 301 * ```typescript ··· 317 303 * storage, 318 304 * redirectUri: "https://myapp.com/callback", 319 305 * }); 320 - * sessionStorage.setItem("opake:pending", JSON.stringify(pending)); 306 + * Opake.savePendingLogin(pending); 321 307 * window.location.href = authUrl; 322 308 * 323 309 * // ... on callback page: 324 - * const pending = JSON.parse(sessionStorage.getItem("opake:pending")!); 310 + * const pending = Opake.loadPendingLogin(); 325 311 * const params = new URLSearchParams(window.location.search); 326 - * await Opake.completeLogin(params.get("code")!, params.get("state")!, pending, { 312 + * await Opake.completeLogin(params.get("code")!, params.get("state")!, pending!, { 327 313 * storage, 328 314 * redirectUri: "https://myapp.com/callback", 329 315 * }); ··· 333 319 handle: string, 334 320 options: StartLoginOptions, 335 321 ): Promise<{ authUrl: string; pending: PendingLogin }> { 336 - return authStartLogin(handle, options); 322 + const wasm = await initWasm(); 323 + const adapter = createStorageAdapter(options.storage); 324 + const result = await wasm.startOAuthLogin( 325 + handle, 326 + options.redirectUri, 327 + adapter, 328 + ); 329 + return result as { authUrl: string; pending: PendingLogin }; 337 330 } 338 331 339 332 /** 340 333 * Complete an OAuth login flow after the user returns from authorization. 341 334 * 342 335 * Validates the CSRF state, exchanges the code for tokens with DPoP, 343 - * and saves the session to storage. 336 + * and saves the session to storage. Tokens never enter JS memory. 344 337 */ 345 338 static async completeLogin( 346 339 code: string, ··· 348 341 pending: PendingLogin, 349 342 options: { storage: Storage; redirectUri: string }, 350 343 ): Promise<void> { 351 - return authCompleteLogin(code, state, pending, options); 344 + const wasm = await initWasm(); 345 + const adapter = createStorageAdapter(options.storage); 346 + await wasm.completeOAuthLogin( 347 + code, 348 + state, 349 + pending, 350 + options.redirectUri, 351 + adapter, 352 + ); 352 353 } 353 354 354 355 /** ··· 369 370 appPassword: string, 370 371 options: { storage: Storage }, 371 372 ): Promise<void> { 372 - return authLoginWithAppPassword(handle, appPassword, options); 373 + const wasm = await initWasm(); 374 + const adapter = createStorageAdapter(options.storage); 375 + await wasm.loginWithAppPasswordWasm(handle, appPassword, adapter); 373 376 } 374 377 375 378 // --------------------------------------------------------------------------- ··· 447 450 */ 448 451 async ensureValidToken(): Promise<void> { 449 452 const ctx = this.requireContext(); 450 - try { 451 - const sessionValue = ctx.session(); 452 - const session = sessionValue as OAuthSession | null; 453 - if (!session || session.type !== "oauth" || !session.expires_at) return; 453 + 454 + // tokenExpiresAt returns only the timestamp — no tokens cross to JS. 455 + const expiresAt = ctx.tokenExpiresAt(); 456 + if (expiresAt < 0) return; // legacy session or unknown — skip proactive refresh 454 457 455 - const expiresMs = session.expires_at * 1000; 456 - if (Date.now() + REFRESH_THRESHOLD_MS < expiresMs) return; 457 - } catch { 458 - // Can't read session — let the actual call fail with a proper error 459 - return; 460 - } 458 + const expiresMs = expiresAt * 1000; 459 + if (Date.now() + REFRESH_THRESHOLD_MS < expiresMs) return; 461 460 462 461 // Token expiring soon — deduplicated refresh 463 462 this.refreshPromise ??= this.doRefresh().finally(() => { ··· 467 466 } 468 467 469 468 private async doRefresh(): Promise<void> { 470 - // Proactive refresh: lightweight call that triggers signoff → auto-persists refreshed session. 471 - try { await this.requireContext().getAccountConfig(); } 472 - catch { /* Best effort — reactive refresh via 401 handler covers failures */ } 469 + const ctx = this.requireContext(); 470 + try { 471 + await ctx.proactiveRefresh(); 472 + } catch { 473 + // Best effort — reactive refresh via 401 handler covers failures 474 + } 473 475 } 474 476 475 477 // ---------------------------------------------------------------------------
+91
packages/opake-sdk/src/storage-adapter.ts
··· 1 + // Bridge between the SDK's Storage interface and the WASM JsStorageAdapter. 2 + // 3 + // The WASM layer imports an extern type with specific method names. This 4 + // typed interface mirrors the Rust JsStorageAdapter extern — if WASM adds or 5 + // renames a method, TS compilation will catch the mismatch here. 6 + 7 + import type { 8 + Storage, 9 + Config, 10 + Identity, 11 + Session, 12 + CachedRecord, 13 + CachedCollection, 14 + } from "./storage"; 15 + 16 + /** 17 + * Typed interface matching the Rust `JsStorageAdapter` extern in js_storage.rs. 18 + * 19 + * Every method here corresponds to a `#[wasm_bindgen(method)]` import on the 20 + * Rust side. If WASM calls a method not listed here, TS will catch it at the 21 + * call site (the return value is typed, not `Record<string, unknown>`). 22 + */ 23 + // Note: Storage.clearSession is intentionally omitted — WASM never clears 24 + // sessions directly (the SDK handles that at the JS level via removeAccount 25 + // or direct IDB operations). 26 + export interface WasmStorageAdapter { 27 + loadConfig(): Promise<Config>; 28 + saveConfig(config: Config): Promise<void>; 29 + loadIdentity(did: string): Promise<Identity>; 30 + saveIdentity(did: string, identity: Identity): Promise<void>; 31 + loadSession(did: string): Promise<Session>; 32 + saveSession(did: string, session: Session): Promise<void>; 33 + removeAccount(did: string): Promise<void>; 34 + cacheGetRecord( 35 + did: string, 36 + collection: string, 37 + uri: string, 38 + ): Promise<CachedRecord | null>; 39 + cachePutRecords( 40 + did: string, 41 + collection: string, 42 + records: readonly CachedRecord[], 43 + ): Promise<void>; 44 + cacheRemoveRecord( 45 + did: string, 46 + collection: string, 47 + uri: string, 48 + ): Promise<void>; 49 + cacheGetCollection( 50 + did: string, 51 + collection: string, 52 + ): Promise<CachedCollection | null>; 53 + cachePutCollection( 54 + did: string, 55 + collection: string, 56 + data: CachedCollection, 57 + ): Promise<void>; 58 + cacheInvalidateCollection( 59 + did: string, 60 + collection: string, 61 + ): Promise<void>; 62 + cacheClear(did: string): Promise<void>; 63 + } 64 + 65 + /** 66 + * Create the storage adapter object that the WASM JsStorageAdapter expects. 67 + */ 68 + export function createStorageAdapter(storage: Storage): WasmStorageAdapter { 69 + return { 70 + loadConfig: () => storage.loadConfig(), 71 + saveConfig: (config) => storage.saveConfig(config), 72 + loadIdentity: (did) => storage.loadIdentity(did), 73 + saveIdentity: (did, identity) => storage.saveIdentity(did, identity), 74 + loadSession: (did) => storage.loadSession(did), 75 + saveSession: (did, session) => storage.saveSession(did, session), 76 + removeAccount: (did) => storage.removeAccount(did), 77 + cacheGetRecord: (did, collection, uri) => 78 + storage.cacheGetRecord(did, collection, uri), 79 + cachePutRecords: (did, collection, records) => 80 + storage.cachePutRecords(did, collection, records), 81 + cacheRemoveRecord: (did, collection, uri) => 82 + storage.cacheRemoveRecord(did, collection, uri), 83 + cacheGetCollection: (did, collection) => 84 + storage.cacheGetCollection(did, collection), 85 + cachePutCollection: (did, collection, data) => 86 + storage.cachePutCollection(did, collection, data), 87 + cacheInvalidateCollection: (did, collection) => 88 + storage.cacheInvalidateCollection(did, collection), 89 + cacheClear: (did) => storage.cacheClear(did), 90 + }; 91 + }