An encrypted personal cloud built on the AT Protocol.

Wire browser OAuth 2.0 with DPoP through WASM and redirect flow

sans-self.org faf741a7 1484cdb8

Waiting for spindle ...
+982 -61
+1
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html) 13 13 14 14 ### Added 15 + - Implement web login flow with AT Protocol OAuth [#167](https://issues.opake.app/issues/167.html) 15 16 - Add AT Protocol OAuth (DPoP) for CLI authentication [#175](https://issues.opake.app/issues/175.html) 16 17 - Wire opake-core WASM into web frontend [#163](https://issues.opake.app/issues/163.html) 17 18 - Add cat command to read and display file contents [#154](https://issues.opake.app/issues/154.html)
+59
crates/opake-wasm/src/lib.rs
··· 3 3 opake_core::binding_check().to_owned() 4 4 } 5 5 6 + use opake_core::client::dpop::DpopKeyPair; 7 + use opake_core::client::oauth_discovery::generate_pkce; 6 8 use opake_core::crypto::{ContentKey, EncryptedPayload, OsRng, X25519PrivateKey, X25519PublicKey}; 7 9 use opake_core::records::WrappedKey; 10 + use opake_core::storage::Identity; 8 11 use serde::Serialize; 9 12 use wasm_bindgen::prelude::*; 10 13 ··· 110 113 .map_err(|_| JsError::new("content key must be exactly 32 bytes"))?; 111 114 Ok(ContentKey(arr)) 112 115 } 116 + 117 + // --------------------------------------------------------------------------- 118 + // OAuth / DPoP exports 119 + // --------------------------------------------------------------------------- 120 + 121 + #[wasm_bindgen(js_name = generateDpopKeyPair)] 122 + pub fn generate_dpop_key_pair() -> Result<JsValue, JsError> { 123 + let keypair = DpopKeyPair::generate(&mut OsRng); 124 + serde_wasm_bindgen::to_value(&keypair).map_err(|e| JsError::new(&e.to_string())) 125 + } 126 + 127 + #[wasm_bindgen(js_name = createDpopProof)] 128 + pub fn create_dpop_proof_js( 129 + keypair_json: JsValue, 130 + method: &str, 131 + url: &str, 132 + timestamp: f64, 133 + nonce: Option<String>, 134 + access_token: Option<String>, 135 + ) -> Result<String, JsError> { 136 + let keypair: DpopKeyPair = 137 + serde_wasm_bindgen::from_value(keypair_json).map_err(|e| JsError::new(&e.to_string()))?; 138 + opake_core::client::dpop::create_dpop_proof( 139 + &keypair, 140 + method, 141 + url, 142 + timestamp as i64, 143 + nonce.as_deref(), 144 + access_token.as_deref(), 145 + &mut OsRng, 146 + ) 147 + .map_err(|e| JsError::new(&e.to_string())) 148 + } 149 + 150 + /// DTO for PkceChallenge — the core type doesn't derive Serialize. 151 + #[derive(Serialize)] 152 + struct PkceChallengeDto { 153 + verifier: String, 154 + challenge: String, 155 + } 156 + 157 + #[wasm_bindgen(js_name = generatePkce)] 158 + pub fn generate_pkce_js() -> Result<JsValue, JsError> { 159 + let pkce = generate_pkce(&mut OsRng); 160 + let dto = PkceChallengeDto { 161 + verifier: pkce.verifier, 162 + challenge: pkce.challenge, 163 + }; 164 + serde_wasm_bindgen::to_value(&dto).map_err(|e| JsError::new(&e.to_string())) 165 + } 166 + 167 + #[wasm_bindgen(js_name = generateIdentity)] 168 + pub fn generate_identity_js(did: &str) -> Result<JsValue, JsError> { 169 + let identity = Identity::generate(did, &mut OsRng); 170 + serde_wasm_bindgen::to_value(&identity).map_err(|e| JsError::new(&e.to_string())) 171 + }
+88
web/src/lib/api.ts
··· 1 + // XRPC and AppView API helpers. 2 + 3 + import { wrap, type Remote } from "comlink"; 4 + import type { CryptoApi } from "@/workers/crypto.worker"; 5 + import type { OAuthSession, Session } from "@/lib/storage-types"; 6 + 1 7 interface ApiConfig { 2 8 pdsUrl: string; 3 9 appviewUrl: string; ··· 7 13 pdsUrl: import.meta.env.VITE_PDS_URL ?? "https://pds.sans-self.org", 8 14 appviewUrl: import.meta.env.VITE_APPVIEW_URL ?? "https://appview.opake.app", 9 15 }; 16 + 17 + // --------------------------------------------------------------------------- 18 + // Unauthenticated XRPC 19 + // --------------------------------------------------------------------------- 10 20 11 21 interface XrpcParams { 12 22 lexicon: string; ··· 37 47 38 48 return response.json(); 39 49 } 50 + 51 + // --------------------------------------------------------------------------- 52 + // Authenticated XRPC (DPoP or Legacy) 53 + // --------------------------------------------------------------------------- 54 + 55 + let workerInstance: Remote<CryptoApi> | null = null; 56 + 57 + function getWorker(): Remote<CryptoApi> { 58 + if (!workerInstance) { 59 + const raw = new Worker( 60 + new URL("../workers/crypto.worker.ts", import.meta.url), 61 + { type: "module" }, 62 + ); 63 + workerInstance = wrap<CryptoApi>(raw); 64 + } 65 + return workerInstance; 66 + } 67 + 68 + interface AuthenticatedXrpcParams { 69 + pdsUrl: string; 70 + lexicon: string; 71 + method?: "GET" | "POST"; 72 + body?: unknown; 73 + } 74 + 75 + export async function authenticatedXrpc( 76 + params: AuthenticatedXrpcParams, 77 + session: Session, 78 + ): Promise<unknown> { 79 + const { pdsUrl, lexicon, method = "GET", body } = params; 80 + const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}`; 81 + 82 + const headers: Record<string, string> = { 83 + "Content-Type": "application/json", 84 + }; 85 + 86 + if (session.type === "oauth") { 87 + await attachDpopAuth(headers, session, method, url); 88 + } else { 89 + headers.Authorization = `Bearer ${session.accessJwt}`; 90 + } 91 + 92 + const response = await fetch(url, { 93 + method, 94 + headers, 95 + body: body ? JSON.stringify(body) : undefined, 96 + }); 97 + 98 + if (!response.ok) { 99 + throw new Error(`XRPC ${lexicon}: ${response.status}`); 100 + } 101 + 102 + return response.json(); 103 + } 104 + 105 + async function attachDpopAuth( 106 + headers: Record<string, string>, 107 + session: OAuthSession, 108 + method: string, 109 + url: string, 110 + ): Promise<void> { 111 + const worker = getWorker(); 112 + const timestamp = Math.floor(Date.now() / 1000); 113 + const proof = await worker.createDpopProof( 114 + session.dpopKey, 115 + method, 116 + url, 117 + timestamp, 118 + session.dpopNonce, 119 + session.accessToken, 120 + ); 121 + headers.Authorization = `DPoP ${session.accessToken}`; 122 + headers.DPoP = proof; 123 + } 124 + 125 + // --------------------------------------------------------------------------- 126 + // AppView (unauthenticated) 127 + // --------------------------------------------------------------------------- 40 128 41 129 export async function appview( 42 130 path: string,
+21
web/src/lib/crypto-types.ts
··· 12 12 ciphertext: Uint8Array; 13 13 nonce: Uint8Array; 14 14 } 15 + 16 + // Mirrors: opake-core DpopPublicJwk (client/dpop.rs) 17 + export interface DpopPublicJwk { 18 + kty: string; 19 + crv: string; 20 + x: string; 21 + y: string; 22 + } 23 + 24 + // Mirrors: opake-core DpopKeyPair (client/dpop.rs) 25 + // Serialized via serde — field names match Rust's #[serde(rename)] 26 + export interface DpopKeyPair { 27 + privateKey: string; // base64url P-256 secret 28 + publicJwk: DpopPublicJwk; 29 + } 30 + 31 + // Mirrors: opake-core PkceChallenge (client/oauth_discovery.rs) 32 + export interface PkceChallenge { 33 + verifier: string; 34 + challenge: string; 35 + }
+368
web/src/lib/oauth.ts
··· 1 + // Browser OAuth 2.0 + DPoP orchestration for AT Protocol. 2 + // 3 + // HTTP calls use plain fetch. Crypto (DPoP proofs, PKCE, keypair gen) is 4 + // delegated to the WASM worker via the CryptoWorker type. 5 + 6 + import type { Remote } from "comlink"; 7 + import type { CryptoApi } from "@/workers/crypto.worker"; 8 + import type { DpopKeyPair } from "@/lib/crypto-types"; 9 + 10 + type CryptoWorker = Remote<CryptoApi>; 11 + 12 + // --------------------------------------------------------------------------- 13 + // Types 14 + // --------------------------------------------------------------------------- 15 + 16 + export interface AuthorizationServerMetadata { 17 + issuer: string; 18 + authorization_endpoint: string; 19 + token_endpoint: string; 20 + pushed_authorization_request_endpoint?: string; 21 + scopes_supported: string[]; 22 + response_types_supported: string[]; 23 + grant_types_supported: string[]; 24 + code_challenge_methods_supported: string[]; 25 + dpop_signing_alg_values_supported: string[]; 26 + token_endpoint_auth_methods_supported: string[]; 27 + require_pushed_authorization_requests: boolean; 28 + } 29 + 30 + export interface TokenResponse { 31 + access_token: string; 32 + token_type: string; 33 + refresh_token?: string; 34 + expires_in?: number; 35 + scope?: string; 36 + sub?: string; 37 + } 38 + 39 + export interface OAuthPendingState { 40 + pdsUrl: string; 41 + handle: string; 42 + dpopKey: DpopKeyPair; 43 + pkceVerifier: string; 44 + csrfState: string; 45 + tokenEndpoint: string; 46 + clientId: string; 47 + dpopNonce: string | null; 48 + } 49 + 50 + const PENDING_STATE_KEY = "opake:oauth_pending"; 51 + const BSKY_PUBLIC_API = "https://public.api.bsky.app"; 52 + 53 + // --------------------------------------------------------------------------- 54 + // Handle → PDS resolution 55 + // --------------------------------------------------------------------------- 56 + 57 + export async function resolveHandleToPds( 58 + handle: string, 59 + ): Promise<{ did: string; pdsUrl: string }> { 60 + const resolveUrl = `${BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 61 + const response = await fetch(resolveUrl); 62 + if (!response.ok) { 63 + throw new Error(`Failed to resolve handle "${handle}": HTTP ${response.status}`); 64 + } 65 + const { did } = (await response.json()) as { did: string }; 66 + 67 + const pdsUrl = await pdsUrlFromDid(did); 68 + return { did, pdsUrl }; 69 + } 70 + 71 + async function pdsUrlFromDid(did: string): Promise<string> { 72 + const docUrl = did.startsWith("did:plc:") 73 + ? `https://plc.directory/${did}` 74 + : did.startsWith("did:web:") 75 + ? `https://${did.slice("did:web:".length)}/.well-known/did.json` 76 + : null; 77 + 78 + if (!docUrl) throw new Error(`Unsupported DID method: ${did}`); 79 + 80 + const response = await fetch(docUrl); 81 + if (!response.ok) { 82 + throw new Error(`Failed to fetch DID document for ${did}: HTTP ${response.status}`); 83 + } 84 + 85 + const doc = (await response.json()) as { 86 + service?: Array<{ id: string; serviceEndpoint: string }>; 87 + }; 88 + 89 + const pds = doc.service?.find((s) => s.id === "#atproto_pds"); 90 + if (!pds) throw new Error(`No #atproto_pds service in DID document for ${did}`); 91 + 92 + return pds.serviceEndpoint; 93 + } 94 + 95 + // --------------------------------------------------------------------------- 96 + // OAuth discovery 97 + // --------------------------------------------------------------------------- 98 + 99 + export async function discoverAuthorizationServer( 100 + pdsUrl: string, 101 + ): Promise<AuthorizationServerMetadata> { 102 + const base = pdsUrl.replace(/\/$/, ""); 103 + 104 + const prmResponse = await fetch(`${base}/.well-known/oauth-protected-resource`); 105 + if (!prmResponse.ok) { 106 + throw new Error(`PDS does not support OAuth (HTTP ${prmResponse.status})`); 107 + } 108 + const prm = (await prmResponse.json()) as { 109 + authorization_servers?: string[]; 110 + }; 111 + 112 + const asUrl = prm.authorization_servers?.[0]; 113 + if (!asUrl) throw new Error("No authorization servers in protected resource metadata"); 114 + 115 + const asBase = asUrl.replace(/\/$/, ""); 116 + const asmResponse = await fetch(`${asBase}/.well-known/oauth-authorization-server`); 117 + if (!asmResponse.ok) { 118 + throw new Error(`Failed to fetch AS metadata: HTTP ${asmResponse.status}`); 119 + } 120 + 121 + return (await asmResponse.json()) as AuthorizationServerMetadata; 122 + } 123 + 124 + // --------------------------------------------------------------------------- 125 + // Client ID (atproto loopback pattern) 126 + // --------------------------------------------------------------------------- 127 + 128 + export function buildClientId(redirectUri: string): string { 129 + return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}`; 130 + } 131 + 132 + export function buildRedirectUri(): string { 133 + return `${window.location.origin}/oauth/callback`; 134 + } 135 + 136 + // --------------------------------------------------------------------------- 137 + // DPoP-authenticated fetch with nonce retry 138 + // --------------------------------------------------------------------------- 139 + 140 + async function fetchWithDpop( 141 + url: string, 142 + method: string, 143 + body: URLSearchParams, 144 + dpopKey: DpopKeyPair, 145 + dpopNonce: string | null, 146 + accessToken: string | null, 147 + worker: CryptoWorker, 148 + ): Promise<{ response: Response; dpopNonce: string | null }> { 149 + const timestamp = Math.floor(Date.now() / 1000); 150 + const proof = await worker.createDpopProof( 151 + dpopKey, method, url, timestamp, dpopNonce, accessToken, 152 + ); 153 + 154 + const headers: Record<string, string> = { 155 + "Content-Type": "application/x-www-form-urlencoded", 156 + DPoP: proof, 157 + }; 158 + if (accessToken) { 159 + headers.Authorization = `DPoP ${accessToken}`; 160 + } 161 + 162 + let response = await fetch(url, { method, headers, body: body.toString() }); 163 + let nonce = response.headers.get("dpop-nonce") ?? dpopNonce; 164 + 165 + // Retry on use_dpop_nonce 166 + if (response.status === 400) { 167 + const errorBody = await response.clone().json().catch(() => null) as { 168 + error?: string; 169 + error_description?: string; 170 + } | null; 171 + 172 + if (errorBody?.error === "use_dpop_nonce" && nonce) { 173 + const retryProof = await worker.createDpopProof( 174 + dpopKey, method, url, timestamp, nonce, accessToken, 175 + ); 176 + headers.DPoP = retryProof; 177 + response = await fetch(url, { method, headers, body: body.toString() }); 178 + nonce = response.headers.get("dpop-nonce") ?? nonce; 179 + } 180 + } 181 + 182 + return { response, dpopNonce: nonce }; 183 + } 184 + 185 + // --------------------------------------------------------------------------- 186 + // Pushed Authorization Request (PAR) 187 + // --------------------------------------------------------------------------- 188 + 189 + export async function pushedAuthorizationRequest( 190 + parEndpoint: string, 191 + clientId: string, 192 + redirectUri: string, 193 + pkceChallenge: string, 194 + state: string, 195 + dpopKey: DpopKeyPair, 196 + dpopNonce: string | null, 197 + worker: CryptoWorker, 198 + ): Promise<{ requestUri: string; expiresIn: number; dpopNonce: string | null }> { 199 + const body = new URLSearchParams({ 200 + client_id: clientId, 201 + response_type: "code", 202 + redirect_uri: redirectUri, 203 + scope: "atproto", 204 + state, 205 + code_challenge: pkceChallenge, 206 + code_challenge_method: "S256", 207 + }); 208 + 209 + const { response, dpopNonce: nonce } = await fetchWithDpop( 210 + parEndpoint, "POST", body, dpopKey, dpopNonce, null, worker, 211 + ); 212 + 213 + if (!response.ok) { 214 + const err = await response.json().catch(() => ({})) as { 215 + error?: string; 216 + error_description?: string; 217 + }; 218 + throw new Error( 219 + `PAR failed: ${err.error ?? "unknown"}: ${err.error_description ?? `HTTP ${response.status}`}`, 220 + ); 221 + } 222 + 223 + const par = (await response.json()) as { request_uri: string; expires_in: number }; 224 + return { requestUri: par.request_uri, expiresIn: par.expires_in, dpopNonce: nonce }; 225 + } 226 + 227 + // --------------------------------------------------------------------------- 228 + // Authorization URL 229 + // --------------------------------------------------------------------------- 230 + 231 + export function buildAuthorizationUrl( 232 + authorizationEndpoint: string, 233 + clientId: string, 234 + requestUri: string, 235 + ): string { 236 + return `${authorizationEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(requestUri)}`; 237 + } 238 + 239 + // --------------------------------------------------------------------------- 240 + // Code exchange 241 + // --------------------------------------------------------------------------- 242 + 243 + export async function exchangeCode( 244 + tokenEndpoint: string, 245 + clientId: string, 246 + code: string, 247 + redirectUri: string, 248 + pkceVerifier: string, 249 + dpopKey: DpopKeyPair, 250 + dpopNonce: string | null, 251 + worker: CryptoWorker, 252 + ): Promise<{ tokenResponse: TokenResponse; dpopNonce: string | null }> { 253 + const body = new URLSearchParams({ 254 + grant_type: "authorization_code", 255 + client_id: clientId, 256 + code, 257 + redirect_uri: redirectUri, 258 + code_verifier: pkceVerifier, 259 + }); 260 + 261 + const { response, dpopNonce: nonce } = await fetchWithDpop( 262 + tokenEndpoint, "POST", body, dpopKey, dpopNonce, null, worker, 263 + ); 264 + 265 + if (!response.ok) { 266 + const err = await response.json().catch(() => ({})) as { 267 + error?: string; 268 + error_description?: string; 269 + }; 270 + throw new Error( 271 + `Token exchange failed: ${err.error ?? "unknown"}: ${err.error_description ?? `HTTP ${response.status}`}`, 272 + ); 273 + } 274 + 275 + const tokenResponse = (await response.json()) as TokenResponse; 276 + 277 + if (tokenResponse.token_type.toLowerCase() !== "dpop") { 278 + throw new Error(`Expected token_type "DPoP", got "${tokenResponse.token_type}"`); 279 + } 280 + 281 + return { tokenResponse, dpopNonce: nonce }; 282 + } 283 + 284 + // --------------------------------------------------------------------------- 285 + // Publish public key (putRecord with DPoP auth) 286 + // --------------------------------------------------------------------------- 287 + 288 + export async function publishPublicKey( 289 + pdsUrl: string, 290 + did: string, 291 + publicKey: string, 292 + verifyKey: string | null, 293 + accessToken: string, 294 + dpopKey: DpopKeyPair, 295 + dpopNonce: string | null, 296 + worker: CryptoWorker, 297 + ): Promise<void> { 298 + const base = pdsUrl.replace(/\/$/, ""); 299 + const url = `${base}/xrpc/com.atproto.repo.putRecord`; 300 + 301 + const record: Record<string, unknown> = { 302 + $type: "app.opake.cloud.publicKey", 303 + version: 1, 304 + algo: "x25519", 305 + publicKey: { $bytes: publicKey }, 306 + createdAt: new Date().toISOString(), 307 + }; 308 + if (verifyKey) { 309 + record.signingKey = { $bytes: verifyKey }; 310 + record.signingAlgo = "ed25519"; 311 + } 312 + 313 + const timestamp = Math.floor(Date.now() / 1000); 314 + const proof = await worker.createDpopProof( 315 + dpopKey, "POST", url, timestamp, dpopNonce, accessToken, 316 + ); 317 + 318 + const response = await fetch(url, { 319 + method: "POST", 320 + headers: { 321 + "Content-Type": "application/json", 322 + Authorization: `DPoP ${accessToken}`, 323 + DPoP: proof, 324 + }, 325 + body: JSON.stringify({ 326 + repo: did, 327 + collection: "app.opake.cloud.publicKey", 328 + rkey: "self", 329 + record, 330 + }), 331 + }); 332 + 333 + if (!response.ok) { 334 + const body = await response.text().catch(() => ""); 335 + throw new Error(`Failed to publish public key: HTTP ${response.status} ${body}`); 336 + } 337 + } 338 + 339 + // --------------------------------------------------------------------------- 340 + // Pre-redirect state (sessionStorage) 341 + // --------------------------------------------------------------------------- 342 + 343 + export function savePendingState(state: OAuthPendingState): void { 344 + sessionStorage.setItem(PENDING_STATE_KEY, JSON.stringify(state)); 345 + } 346 + 347 + export function loadPendingState(): OAuthPendingState | null { 348 + const raw = sessionStorage.getItem(PENDING_STATE_KEY); 349 + if (!raw) return null; 350 + return JSON.parse(raw) as OAuthPendingState; 351 + } 352 + 353 + export function clearPendingState(): void { 354 + sessionStorage.removeItem(PENDING_STATE_KEY); 355 + } 356 + 357 + // --------------------------------------------------------------------------- 358 + // CSRF state generation (browser-native crypto) 359 + // --------------------------------------------------------------------------- 360 + 361 + export function generateCsrfState(): string { 362 + const bytes = new Uint8Array(16); 363 + crypto.getRandomValues(bytes); 364 + return btoa(String.fromCharCode(...bytes)) 365 + .replace(/\+/g, "-") 366 + .replace(/\//g, "_") 367 + .replace(/=+$/, ""); 368 + }
+22 -1
web/src/lib/storage-types.ts
··· 1 1 // TypeScript equivalents of opake-core storage types. 2 2 // Mirrors: crates/opake-core/src/storage.rs 3 3 4 + import type { DpopKeyPair } from "./crypto-types"; 5 + 4 6 export interface Config { 5 7 defaultDid: string | null; 6 8 accounts: Record<string, AccountConfig>; ··· 20 22 verifyKey: string | null; // base64 Ed25519 21 23 } 22 24 23 - export interface Session { 25 + // Mirrors: opake-core Session enum (client/xrpc/mod.rs) 26 + // Discriminated union — the `type` tag matches Rust's #[serde(tag = "type")] 27 + 28 + export interface LegacySession { 29 + type: "legacy"; 24 30 did: string; 25 31 handle: string; 26 32 accessJwt: string; 27 33 refreshJwt: string; 28 34 } 35 + 36 + export interface OAuthSession { 37 + type: "oauth"; 38 + did: string; 39 + handle: string; 40 + accessToken: string; 41 + refreshToken: string; 42 + dpopKey: DpopKeyPair; 43 + tokenEndpoint: string; 44 + dpopNonce: string | null; 45 + expiresAt: number | null; 46 + clientId: string; 47 + } 48 + 49 + export type Session = LegacySession | OAuthSession;
+21 -3
web/src/routeTree.gen.ts
··· 12 12 import { Route as LoginRouteImport } from './routes/login' 13 13 import { Route as CabinetRouteImport } from './routes/cabinet' 14 14 import { Route as IndexRouteImport } from './routes/index' 15 + import { Route as OauthCallbackRouteImport } from './routes/oauth.callback' 15 16 16 17 const LoginRoute = LoginRouteImport.update({ 17 18 id: '/login', ··· 26 27 const IndexRoute = IndexRouteImport.update({ 27 28 id: '/', 28 29 path: '/', 30 + getParentRoute: () => rootRouteImport, 31 + } as any) 32 + const OauthCallbackRoute = OauthCallbackRouteImport.update({ 33 + id: '/oauth/callback', 34 + path: '/oauth/callback', 29 35 getParentRoute: () => rootRouteImport, 30 36 } as any) 31 37 ··· 33 39 '/': typeof IndexRoute 34 40 '/cabinet': typeof CabinetRoute 35 41 '/login': typeof LoginRoute 42 + '/oauth/callback': typeof OauthCallbackRoute 36 43 } 37 44 export interface FileRoutesByTo { 38 45 '/': typeof IndexRoute 39 46 '/cabinet': typeof CabinetRoute 40 47 '/login': typeof LoginRoute 48 + '/oauth/callback': typeof OauthCallbackRoute 41 49 } 42 50 export interface FileRoutesById { 43 51 __root__: typeof rootRouteImport 44 52 '/': typeof IndexRoute 45 53 '/cabinet': typeof CabinetRoute 46 54 '/login': typeof LoginRoute 55 + '/oauth/callback': typeof OauthCallbackRoute 47 56 } 48 57 export interface FileRouteTypes { 49 58 fileRoutesByFullPath: FileRoutesByFullPath 50 - fullPaths: '/' | '/cabinet' | '/login' 59 + fullPaths: '/' | '/cabinet' | '/login' | '/oauth/callback' 51 60 fileRoutesByTo: FileRoutesByTo 52 - to: '/' | '/cabinet' | '/login' 53 - id: '__root__' | '/' | '/cabinet' | '/login' 61 + to: '/' | '/cabinet' | '/login' | '/oauth/callback' 62 + id: '__root__' | '/' | '/cabinet' | '/login' | '/oauth/callback' 54 63 fileRoutesById: FileRoutesById 55 64 } 56 65 export interface RootRouteChildren { 57 66 IndexRoute: typeof IndexRoute 58 67 CabinetRoute: typeof CabinetRoute 59 68 LoginRoute: typeof LoginRoute 69 + OauthCallbackRoute: typeof OauthCallbackRoute 60 70 } 61 71 62 72 declare module '@tanstack/react-router' { ··· 82 92 preLoaderRoute: typeof IndexRouteImport 83 93 parentRoute: typeof rootRouteImport 84 94 } 95 + '/oauth/callback': { 96 + id: '/oauth/callback' 97 + path: '/oauth/callback' 98 + fullPath: '/oauth/callback' 99 + preLoaderRoute: typeof OauthCallbackRouteImport 100 + parentRoute: typeof rootRouteImport 101 + } 85 102 } 86 103 } 87 104 ··· 89 106 IndexRoute: IndexRoute, 90 107 CabinetRoute: CabinetRoute, 91 108 LoginRoute: LoginRoute, 109 + OauthCallbackRoute: OauthCallbackRoute, 92 110 } 93 111 export const routeTree = rootRouteImport 94 112 ._addFileChildren(rootRouteChildren)
+7
web/src/routes/__root.tsx
··· 1 1 import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router"; 2 + import { useAuthStore } from "@/stores/auth"; 2 3 3 4 function RootLayout() { 4 5 return <Outlet />; ··· 26 27 } 27 28 28 29 export const Route = createRootRoute({ 30 + beforeLoad: async () => { 31 + const state = useAuthStore.getState(); 32 + if (state.phase === "initializing") { 33 + await state.boot(); 34 + } 35 + }, 29 36 component: RootLayout, 30 37 errorComponent: RootError, 31 38 });
+2 -2
web/src/routes/cabinet.tsx
··· 95 95 96 96 export const Route = createFileRoute("/cabinet")({ 97 97 beforeLoad: () => { 98 - const { currentDid } = useAuthStore.getState(); 99 - if (!currentDid) throw redirect({ to: "/login" }); 98 + const state = useAuthStore.getState(); 99 + if (state.phase !== "ready") throw redirect({ to: "/login" }); 100 100 }, 101 101 component: CabinetPage, 102 102 });
+29 -10
web/src/routes/login.tsx
··· 5 5 6 6 function LoginPage() { 7 7 const navigate = useNavigate(); 8 - const login = useAuthStore((s) => s.login); 9 - const [handle, setHandle] = useState("alice.bsky.social"); 8 + const phase = useAuthStore((s) => s.phase); 9 + const startLogin = useAuthStore((s) => s.startLogin); 10 + const [handle, setHandle] = useState(""); 11 + const isLoading = phase === "authenticating"; 12 + const errorMessage = phase === "error" ? useAuthStore.getState() : null; 10 13 11 14 const handleSubmit = async (e: React.FormEvent) => { 12 15 e.preventDefault(); 13 - await login(handle, ""); 14 - navigate({ to: "/cabinet" }); 16 + if (!handle.trim()) return; 17 + await startLogin(handle.trim()); 18 + // startLogin redirects to the AS — we won't reach here unless it errors 15 19 }; 16 20 17 21 return ( ··· 27 31 Sign in to Opake 28 32 </h1> 29 33 <p className="mb-5 text-caption text-text-muted"> 30 - Enter your PDS handle to continue. 34 + Enter your AT Protocol handle to continue. 31 35 </p> 32 36 <label className="input input-bordered mb-3 flex items-center gap-2"> 33 37 <input 34 38 type="text" 35 - placeholder="handle.bsky.social" 39 + placeholder="you.bsky.social" 36 40 value={handle} 37 41 onChange={(e) => setHandle(e.target.value)} 38 42 className="grow" 39 43 required 44 + disabled={isLoading} 45 + aria-label="AT Protocol handle" 40 46 /> 41 47 </label> 42 - <button type="submit" className="btn btn-neutral w-full"> 43 - Sign in 48 + {phase === "error" && errorMessage && ( 49 + <p className="mb-3 text-caption text-error" role="alert"> 50 + {"message" in errorMessage ? errorMessage.message : "Login failed"} 51 + </p> 52 + )} 53 + <button 54 + type="submit" 55 + className="btn btn-neutral w-full" 56 + disabled={isLoading} 57 + > 58 + {isLoading ? ( 59 + <span className="loading loading-spinner loading-sm" /> 60 + ) : ( 61 + "Sign in" 62 + )} 44 63 </button> 45 64 </form> 46 65 </div> ··· 49 68 50 69 export const Route = createFileRoute("/login")({ 51 70 beforeLoad: () => { 52 - const { currentDid } = useAuthStore.getState(); 53 - if (currentDid) throw redirect({ to: "/cabinet" }); 71 + const state = useAuthStore.getState(); 72 + if (state.phase === "ready") throw redirect({ to: "/cabinet" }); 54 73 }, 55 74 component: LoginPage, 56 75 });
+52
web/src/routes/oauth.callback.tsx
··· 1 + import { useEffect } from "react"; 2 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 + import { useAuthStore } from "@/stores/auth"; 4 + 5 + function OAuthCallbackPage() { 6 + const navigate = useNavigate(); 7 + const phase = useAuthStore((s) => s.phase); 8 + const completeLogin = useAuthStore((s) => s.completeLogin); 9 + 10 + useEffect(() => { 11 + const params = new URLSearchParams(window.location.search); 12 + const code = params.get("code"); 13 + const state = params.get("state"); 14 + const error = params.get("error"); 15 + 16 + if (error) { 17 + const description = params.get("error_description") ?? error; 18 + navigate({ to: "/login", search: { error: description } }); 19 + return; 20 + } 21 + 22 + if (!code || !state) { 23 + navigate({ to: "/login", search: { error: "Missing OAuth parameters" } }); 24 + return; 25 + } 26 + 27 + completeLogin(code, state); 28 + }, [completeLogin, navigate]); 29 + 30 + useEffect(() => { 31 + if (phase === "ready") { 32 + navigate({ to: "/cabinet" }); 33 + } else if (phase === "error") { 34 + const state = useAuthStore.getState(); 35 + const message = "message" in state ? state.message : "Authentication failed"; 36 + navigate({ to: "/login", search: { error: message } }); 37 + } 38 + }, [phase, navigate]); 39 + 40 + return ( 41 + <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 42 + <div className="flex flex-col items-center gap-4"> 43 + <span className="loading loading-spinner loading-lg" /> 44 + <p className="text-sm text-text-muted">Completing authentication…</p> 45 + </div> 46 + </div> 47 + ); 48 + } 49 + 50 + export const Route = createFileRoute("/oauth/callback")({ 51 + component: OAuthCallbackPage, 52 + });
+246 -26
web/src/stores/auth.ts
··· 1 + // Auth store — real OAuth 2.0 + DPoP flow via Zustand. 2 + // 3 + // State machine: initializing → unauthenticated ↔ authenticating → ready 4 + // ↘ error 5 + 1 6 import { create } from "zustand"; 7 + import { wrap, type Remote } from "comlink"; 8 + import type { CryptoApi } from "@/workers/crypto.worker"; 9 + import type { OAuthSession, Config } from "@/lib/storage-types"; 10 + import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 11 + import { 12 + resolveHandleToPds, 13 + discoverAuthorizationServer, 14 + pushedAuthorizationRequest, 15 + buildAuthorizationUrl, 16 + buildClientId, 17 + buildRedirectUri, 18 + exchangeCode, 19 + publishPublicKey, 20 + savePendingState, 21 + loadPendingState, 22 + clearPendingState, 23 + generateCsrfState, 24 + } from "@/lib/oauth"; 2 25 3 - interface Account { 4 - did: string; 5 - handle: string; 26 + // --------------------------------------------------------------------------- 27 + // State types 28 + // --------------------------------------------------------------------------- 29 + 30 + type AuthPhase = 31 + | { phase: "initializing" } 32 + | { phase: "unauthenticated" } 33 + | { phase: "authenticating" } 34 + | { phase: "ready"; did: string; handle: string; pdsUrl: string } 35 + | { phase: "error"; message: string }; 36 + 37 + interface AuthActions { 38 + boot(): Promise<void>; 39 + startLogin(handle: string): Promise<void>; 40 + completeLogin(code: string, state: string): Promise<void>; 41 + logout(): Promise<void>; 6 42 } 7 43 8 - interface AuthState { 9 - accounts: Account[]; 10 - currentDid: string | null; 11 - login: (handle: string, password: string) => Promise<void>; 12 - logout: () => void; 13 - setDefault: (did: string) => void; 44 + type AuthState = AuthPhase & AuthActions; 45 + 46 + // --------------------------------------------------------------------------- 47 + // Singletons (created once, shared across store actions) 48 + // --------------------------------------------------------------------------- 49 + 50 + const storage = new IndexedDbStorage(); 51 + 52 + let workerInstance: Remote<CryptoApi> | null = null; 53 + 54 + function getWorker(): Remote<CryptoApi> { 55 + if (!workerInstance) { 56 + const raw = new Worker( 57 + new URL("../workers/crypto.worker.ts", import.meta.url), 58 + { type: "module" }, 59 + ); 60 + workerInstance = wrap<CryptoApi>(raw); 61 + } 62 + return workerInstance; 14 63 } 15 64 16 - export const useAuthStore = create<AuthState>((set) => ({ 17 - accounts: [], 18 - currentDid: null, 65 + // --------------------------------------------------------------------------- 66 + // Store 67 + // --------------------------------------------------------------------------- 19 68 20 - login: async (_handle: string, _password: string) => { 21 - // Stub — replaced by real OAuth/DPoP flow later 22 - const mockAccount: Account = { 23 - did: "did:plc:mock123", 24 - handle: "mock.bsky.social", 25 - }; 26 - set((state) => ({ 27 - accounts: [...state.accounts, mockAccount], 28 - currentDid: mockAccount.did, 29 - })); 69 + export const useAuthStore = create<AuthState>((set, get) => ({ 70 + phase: "initializing", 71 + 72 + boot: async () => { 73 + try { 74 + const config = await storage.loadConfig(); 75 + if (!config.defaultDid) { 76 + set({ phase: "unauthenticated" }); 77 + return; 78 + } 79 + 80 + const did = config.defaultDid; 81 + const account = config.accounts[did]; 82 + if (!account) { 83 + set({ phase: "unauthenticated" }); 84 + return; 85 + } 86 + 87 + // Verify session exists 88 + await storage.loadSession(did); 89 + set({ phase: "ready", did, handle: account.handle, pdsUrl: account.pdsUrl }); 90 + } catch { 91 + set({ phase: "unauthenticated" }); 92 + } 30 93 }, 31 94 32 - logout: () => { 33 - set({ accounts: [], currentDid: null }); 95 + startLogin: async (handle: string) => { 96 + set({ phase: "authenticating" }); 97 + const worker = getWorker(); 98 + 99 + try { 100 + // Resolve handle → PDS 101 + const { did: _did, pdsUrl } = await resolveHandleToPds(handle); 102 + 103 + // OAuth discovery 104 + const asm = await discoverAuthorizationServer(pdsUrl); 105 + 106 + // Generate crypto material 107 + const dpopKey = await worker.generateDpopKeyPair(); 108 + const pkce = await worker.generatePkce(); 109 + const csrfState = generateCsrfState(); 110 + 111 + // Client ID + redirect URI 112 + const redirectUri = buildRedirectUri(); 113 + const clientId = buildClientId(redirectUri); 114 + 115 + const parEndpoint = 116 + asm.pushed_authorization_request_endpoint ?? asm.token_endpoint; 117 + 118 + // PAR 119 + const { requestUri, dpopNonce } = await pushedAuthorizationRequest( 120 + parEndpoint, 121 + clientId, 122 + redirectUri, 123 + pkce.challenge, 124 + csrfState, 125 + dpopKey, 126 + null, 127 + worker, 128 + ); 129 + 130 + // Persist pre-redirect state 131 + savePendingState({ 132 + pdsUrl, 133 + handle, 134 + dpopKey, 135 + pkceVerifier: pkce.verifier, 136 + csrfState, 137 + tokenEndpoint: asm.token_endpoint, 138 + clientId, 139 + dpopNonce, 140 + }); 141 + 142 + // Redirect to AS 143 + const authUrl = buildAuthorizationUrl( 144 + asm.authorization_endpoint, 145 + clientId, 146 + requestUri, 147 + ); 148 + window.location.href = authUrl; 149 + } catch (error) { 150 + const message = error instanceof Error ? error.message : String(error); 151 + set({ phase: "error", message }); 152 + } 34 153 }, 35 154 36 - setDefault: (did: string) => { 37 - set({ currentDid: did }); 155 + completeLogin: async (code: string, callbackState: string) => { 156 + if (get().phase === "ready") return; 157 + set({ phase: "authenticating" }); 158 + const worker = getWorker(); 159 + 160 + try { 161 + const pending = loadPendingState(); 162 + if (!pending) throw new Error("No pending OAuth state — start login again"); 163 + 164 + // CSRF verification 165 + if (callbackState !== pending.csrfState) { 166 + clearPendingState(); 167 + throw new Error("OAuth state mismatch — possible CSRF attack"); 168 + } 169 + 170 + const redirectUri = buildRedirectUri(); 171 + 172 + // Exchange code for tokens 173 + const { tokenResponse, dpopNonce } = await exchangeCode( 174 + pending.tokenEndpoint, 175 + pending.clientId, 176 + code, 177 + redirectUri, 178 + pending.pkceVerifier, 179 + pending.dpopKey, 180 + pending.dpopNonce, 181 + worker, 182 + ); 183 + 184 + const did = tokenResponse.sub; 185 + if (!did) throw new Error("Token response missing `sub` claim"); 186 + 187 + const timestamp = Math.floor(Date.now() / 1000); 188 + const expiresAt = tokenResponse.expires_in 189 + ? timestamp + tokenResponse.expires_in 190 + : null; 191 + 192 + const session: OAuthSession = { 193 + type: "oauth", 194 + did, 195 + handle: pending.handle, 196 + accessToken: tokenResponse.access_token, 197 + refreshToken: tokenResponse.refresh_token ?? "", 198 + dpopKey: pending.dpopKey, 199 + tokenEndpoint: pending.tokenEndpoint, 200 + dpopNonce, 201 + expiresAt, 202 + clientId: pending.clientId, 203 + }; 204 + 205 + // Generate identity keypair 206 + const identity = await worker.generateIdentity(did); 207 + 208 + // Publish public key to PDS 209 + await publishPublicKey( 210 + pending.pdsUrl, 211 + did, 212 + identity.publicKey, 213 + identity.verifyKey, 214 + session.accessToken, 215 + session.dpopKey, 216 + session.dpopNonce, 217 + worker, 218 + ); 219 + 220 + // Persist everything to IndexedDB 221 + const config: Config = await storage.loadConfig().catch(() => ({ 222 + defaultDid: null, 223 + accounts: {}, 224 + appviewUrl: null, 225 + })); 226 + config.defaultDid = did; 227 + config.accounts[did] = { pdsUrl: pending.pdsUrl, handle: pending.handle }; 228 + 229 + await storage.saveConfig(config); 230 + await storage.saveSession(did, session); 231 + await storage.saveIdentity(did, identity); 232 + 233 + clearPendingState(); 234 + 235 + set({ 236 + phase: "ready", 237 + did, 238 + handle: pending.handle, 239 + pdsUrl: pending.pdsUrl, 240 + }); 241 + } catch (error) { 242 + clearPendingState(); 243 + const message = error instanceof Error ? error.message : String(error); 244 + set({ phase: "error", message }); 245 + } 246 + }, 247 + 248 + logout: async () => { 249 + const current = get(); 250 + if (current.phase === "ready") { 251 + try { 252 + await storage.removeAccount(current.did); 253 + } catch { 254 + // best-effort cleanup 255 + } 256 + } 257 + set({ phase: "unauthenticated" }); 38 258 }, 39 259 }));
+38 -1
web/src/workers/crypto.worker.ts
··· 8 8 unwrapKey, 9 9 wrapContentKeyForKeyring, 10 10 unwrapContentKeyFromKeyring, 11 + generateDpopKeyPair as wasmGenerateDpopKeyPair, 12 + createDpopProof as wasmCreateDpopProof, 13 + generatePkce as wasmGeneratePkce, 14 + generateIdentity as wasmGenerateIdentity, 11 15 } from "@/wasm/opake-wasm/opake"; 12 - import type { EncryptedPayload, WrappedKey } from "@/lib/crypto-types"; 16 + import type { EncryptedPayload, WrappedKey, DpopKeyPair, PkceChallenge } from "@/lib/crypto-types"; 17 + import type { Identity } from "@/lib/storage-types"; 13 18 14 19 await init(); 15 20 ··· 62 67 groupKey: Uint8Array, 63 68 ): Uint8Array { 64 69 return unwrapContentKeyFromKeyring(wrapped, groupKey); 70 + }, 71 + 72 + // OAuth / DPoP 73 + 74 + generateDpopKeyPair(): DpopKeyPair { 75 + return wasmGenerateDpopKeyPair() as DpopKeyPair; 76 + }, 77 + 78 + createDpopProof( 79 + keypair: DpopKeyPair, 80 + method: string, 81 + url: string, 82 + timestamp: number, 83 + nonce: string | null, 84 + accessToken: string | null, 85 + ): string { 86 + return wasmCreateDpopProof( 87 + keypair, 88 + method, 89 + url, 90 + timestamp, 91 + nonce ?? undefined, 92 + accessToken ?? undefined, 93 + ); 94 + }, 95 + 96 + generatePkce(): PkceChallenge { 97 + return wasmGeneratePkce() as PkceChallenge; 98 + }, 99 + 100 + generateIdentity(did: string): Identity { 101 + return wasmGenerateIdentity(did) as Identity; 65 102 }, 66 103 }; 67 104
+5 -1
web/tests/lib/indexeddb-storage.test.ts
··· 28 28 }; 29 29 30 30 const testSession: Session = { 31 + type: "legacy", 31 32 did: "did:plc:alice", 32 33 handle: "alice.test", 33 34 accessJwt: "eyJ.access.token", ··· 134 135 const refreshed: Session = { ...testSession, accessJwt: "new.jwt" }; 135 136 await storage.saveSession("did:plc:alice", refreshed); 136 137 const loaded = await storage.loadSession("did:plc:alice"); 137 - expect(loaded.accessJwt).toBe("new.jwt"); 138 + expect(loaded.type).toBe("legacy"); 139 + if (loaded.type === "legacy") { 140 + expect(loaded.accessJwt).toBe("new.jwt"); 141 + } 138 142 }); 139 143 }); 140 144
+23 -17
web/tests/stores/auth.test.ts
··· 3 3 4 4 describe("auth store", () => { 5 5 beforeEach(() => { 6 - useAuthStore.setState({ accounts: [], currentDid: null }); 6 + useAuthStore.setState({ phase: "unauthenticated" }); 7 7 }); 8 8 9 - it("starts with no session", () => { 9 + it("starts in initializing phase", () => { 10 + useAuthStore.setState({ phase: "initializing" }); 10 11 const state = useAuthStore.getState(); 11 - expect(state.accounts).toEqual([]); 12 - expect(state.currentDid).toBeNull(); 12 + expect(state.phase).toBe("initializing"); 13 13 }); 14 14 15 - it("setDefault updates currentDid", () => { 16 - useAuthStore.getState().setDefault("did:plc:abc123"); 17 - expect(useAuthStore.getState().currentDid).toBe("did:plc:abc123"); 15 + it("can transition to unauthenticated", () => { 16 + const state = useAuthStore.getState(); 17 + expect(state.phase).toBe("unauthenticated"); 18 18 }); 19 19 20 - it("login adds an account and sets current", async () => { 21 - await useAuthStore.getState().login("test.bsky.social", "password"); 22 - const state = useAuthStore.getState(); 23 - expect(state.accounts).toHaveLength(1); 24 - expect(state.currentDid).toBe("did:plc:mock123"); 20 + it("logout returns to unauthenticated", async () => { 21 + useAuthStore.setState({ 22 + phase: "ready", 23 + did: "did:plc:test", 24 + handle: "test.bsky.social", 25 + pdsUrl: "https://pds.test", 26 + }); 27 + // logout does best-effort storage cleanup which will fail without 28 + // IndexedDB, but the state transition should still happen 29 + await useAuthStore.getState().logout(); 30 + expect(useAuthStore.getState().phase).toBe("unauthenticated"); 25 31 }); 26 32 27 - it("logout clears everything", async () => { 28 - await useAuthStore.getState().login("test.bsky.social", "password"); 29 - useAuthStore.getState().logout(); 33 + it("exposes boot, startLogin, completeLogin, logout actions", () => { 30 34 const state = useAuthStore.getState(); 31 - expect(state.accounts).toEqual([]); 32 - expect(state.currentDid).toBeNull(); 35 + expect(typeof state.boot).toBe("function"); 36 + expect(typeof state.startLogin).toBe("function"); 37 + expect(typeof state.completeLogin).toBe("function"); 38 + expect(typeof state.logout).toBe("function"); 33 39 }); 34 40 });