A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

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

fix: fix dpop resolution for dashboard login

Trezy cb2ed510 3559b2bc

+325 -33
+11 -7
docker-compose.yml
··· 20 20 image: atcr.io/gamesgamesgamesgames.games/aip:2.2.4-dev.1 21 21 ports: 22 22 - "8080:8080" 23 + dns: 24 + - 8.8.8.8 25 + - 8.8.4.4 23 26 environment: 24 27 DATABASE_URL: postgres://aip:aip@postgres/aip 25 28 STORAGE_BACKEND: postgres 26 - EXTERNAL_BASE: http://localhost:8080 29 + # EXTERNAL_BASE: http://localhost:8080 27 30 DPOP_NONCE_SEED: ${DPOP_NONCE_SEED} 28 31 OAUTH_SIGNING_KEYS: ${OAUTH_SIGNING_KEYS} 29 32 ATPROTO_OAUTH_SIGNING_KEYS: ${ATPROTO_OAUTH_SIGNING_KEYS} ··· 39 42 - "2480:2480" 40 43 environment: 41 44 TAP_DATABASE_URL: postgres://tap:tap@postgres/tap 42 - TAP_RELAY_URL: https://bsky.network 45 + TAP_RELAY_URL: https://relay1.us-east.bsky.network 43 46 TAP_PLC_URL: https://plc.directory 44 47 TAP_ADMIN_PASSWORD: ${TAP_ADMIN_PASSWORD} 45 48 TAP_COLLECTION_FILTERS: "" ··· 61 64 - cargo-target:/app/target 62 65 environment: 63 66 DATABASE_URL: postgres://happyview:happyview@postgres/happyview 64 - AIP_URL: https://aip.gamesgamesgamesgames.games 67 + AIP_URL: http://aip:8080 65 68 TAP_URL: http://tap:2480 66 69 TAP_ADMIN_PASSWORD: ${TAP_ADMIN_PASSWORD} 70 + RELAY_URL: https://relay1.us-east.bsky.network 67 71 PORT: 3000 68 72 depends_on: 69 73 postgres: ··· 83 87 - ./web:/app 84 88 - web-node-modules:/app/node_modules 85 89 environment: 86 - - HOSTNAME=0.0.0.0 87 - - API_URL=http://happyview:3000 88 - - AIP_PROXY_URL=http://aip:8080 89 - - NEXT_PUBLIC_AIP_URL=http://localhost:8080 90 + HOSTNAME: 0.0.0.0 91 + API_URL: http://happyview:3000 92 + AIP_PROXY_URL: http://aip:8080 93 + # NEXT_PUBLIC_AIP_URL: http://localhost:8080 90 94 91 95 volumes: 92 96 pgdata:
+62 -10
src/auth/middleware.rs
··· 10 10 pub struct Claims { 11 11 did: String, 12 12 token: String, 13 + dpop_proof: Option<String>, 13 14 } 14 15 15 16 impl Claims { ··· 18 19 &self.did 19 20 } 20 21 21 - /// The raw Bearer token for forwarding to AIP's XRPC proxy. 22 + /// The raw access token for forwarding to AIP's XRPC proxy. 22 23 pub fn token(&self) -> &str { 23 24 &self.token 24 25 } 25 26 27 + /// The DPoP proof from the client request, if present. 28 + pub fn dpop_proof(&self) -> Option<&str> { 29 + self.dpop_proof.as_deref() 30 + } 31 + 26 32 /// Test-only constructor. 27 33 #[cfg(test)] 28 34 pub fn new_for_test(did: String, token: String) -> Self { 29 - Self { did, token } 35 + Self { 36 + did, 37 + token, 38 + dpop_proof: None, 39 + } 30 40 } 31 41 } 32 42 ··· 51 61 .ok_or_else(|| AppError::Auth("missing Authorization header".into()))?; 52 62 53 63 let token = header 54 - .strip_prefix("Bearer ") 64 + .strip_prefix("DPoP ") 65 + .or_else(|| header.strip_prefix("Bearer ")) 55 66 .ok_or_else(|| AppError::Auth("invalid Authorization scheme".into()))?; 56 67 68 + let dpop_proof = parts 69 + .headers 70 + .get("dpop") 71 + .and_then(|v| v.to_str().ok()) 72 + .map(|s| s.to_string()); 73 + 57 74 let userinfo_url = format!( 58 75 "{}/oauth/userinfo", 59 76 state.config.aip_url.trim_end_matches('/') 60 77 ); 61 78 62 - let resp = state 79 + tracing::debug!( 80 + url = %userinfo_url, 81 + has_dpop_proof = dpop_proof.is_some(), 82 + "forwarding token to AIP userinfo" 83 + ); 84 + 85 + let mut req = state 63 86 .http 64 87 .get(&userinfo_url) 65 - .header("authorization", format!("Bearer {token}")) 66 - .send() 67 - .await 68 - .map_err(|e| AppError::Auth(format!("userinfo request failed: {e}")))?; 88 + .header("authorization", format!("DPoP {token}")); 89 + 90 + if let Some(ref proof) = dpop_proof { 91 + req = req.header("dpop", proof); 92 + } 93 + 94 + let resp = req.send().await.map_err(|e| { 95 + tracing::error!(url = %userinfo_url, error = %e, "AIP userinfo request failed to send"); 96 + AppError::Auth(format!("userinfo request failed: {e}")) 97 + })?; 69 98 70 99 if !resp.status().is_success() { 100 + let status = resp.status(); 101 + let nonce = resp 102 + .headers() 103 + .get("dpop-nonce") 104 + .and_then(|v| v.to_str().ok()) 105 + .map(String::from); 106 + let body = resp.text().await.unwrap_or_default(); 107 + 108 + tracing::warn!( 109 + url = %userinfo_url, 110 + status = %status, 111 + body = %body, 112 + dpop_nonce = ?nonce, 113 + has_dpop_proof = dpop_proof.is_some(), 114 + "AIP userinfo request failed" 115 + ); 116 + 117 + // Relay the nonce so the client can retry with it. 118 + if let Some(ref nonce_str) = nonce { 119 + return Err(AppError::AuthDpopNonce(nonce_str.clone())); 120 + } 121 + 71 122 return Err(AppError::Auth(format!( 72 - "userinfo returned {}", 73 - resp.status() 123 + "userinfo returned {}: {}", 124 + status, body 74 125 ))); 75 126 } 76 127 ··· 82 133 Ok(Claims { 83 134 did: info.sub, 84 135 token: token.to_string(), 136 + dpop_proof, 85 137 }) 86 138 } 87 139 }
+12 -1
src/error.rs
··· 5 5 #[derive(Debug)] 6 6 pub enum AppError { 7 7 Auth(String), 8 + /// Auth failure with a DPoP nonce that the client should retry with. 9 + AuthDpopNonce(String), 8 10 BadRequest(String), 9 11 Forbidden(String), 10 12 Internal(String), ··· 16 18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 17 19 match self { 18 20 AppError::Auth(msg) => write!(f, "auth error: {msg}"), 21 + AppError::AuthDpopNonce(nonce) => write!(f, "auth error: use_dpop_nonce ({nonce})"), 19 22 AppError::BadRequest(msg) => write!(f, "bad request: {msg}"), 20 23 AppError::Forbidden(msg) => write!(f, "forbidden: {msg}"), 21 24 AppError::Internal(msg) => write!(f, "internal error: {msg}"), ··· 34 37 body, 35 38 ) 36 39 .into_response(), 40 + AppError::AuthDpopNonce(nonce) => { 41 + let body = serde_json::json!({ "error": "use_dpop_nonce", "dpop_nonce": nonce }); 42 + let mut response = (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response(); 43 + if let Ok(val) = axum::http::HeaderValue::from_str(&nonce) { 44 + response.headers_mut().insert("dpop-nonce", val); 45 + } 46 + response 47 + } 37 48 other => { 38 49 let (status, message) = match &other { 39 50 AppError::Auth(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), ··· 47 58 ) 48 59 } 49 60 AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), 50 - AppError::PdsError(..) => unreachable!(), 61 + AppError::PdsError(..) | AppError::AuthDpopNonce(..) => unreachable!(), 51 62 }; 52 63 53 64 let body = serde_json::json!({ "error": message });
+30 -2
web/src/lib/api.ts
··· 1 + import { createDpopProof, setDpopNonce } from "./dpop" 2 + 3 + // The DPoP proof for admin API calls must target AIP's userinfo URL, 4 + // because the backend forwards the proof to AIP for token validation. 5 + const AIP_URL = process.env.NEXT_PUBLIC_AIP_URL || "" 6 + const AIP_USERINFO_URL = `${AIP_URL}/oauth/userinfo` 7 + 1 8 export class ApiError extends Error { 2 9 status: number 3 10 constructor(status: number, message: string) { ··· 9 16 async function apiFetch<T = unknown>( 10 17 path: string, 11 18 getToken: () => Promise<string | null>, 12 - options?: RequestInit 19 + options?: RequestInit, 20 + dpopNonce?: string 13 21 ): Promise<T> { 14 22 const token = await getToken() 15 23 if (!token) throw new ApiError(401, "Not authenticated") 16 24 25 + // Proof targets AIP's userinfo endpoint (GET) since the backend 26 + // forwards it there for token validation. 27 + const dpopProof = await createDpopProof("GET", AIP_USERINFO_URL, token, dpopNonce) 28 + 17 29 const headers: Record<string, string> = { 18 - Authorization: `Bearer ${token}`, 30 + Authorization: `DPoP ${token}`, 31 + DPoP: dpopProof, 19 32 } 20 33 if ( 21 34 options?.method === "POST" || ··· 29 42 ...options, 30 43 headers: { ...headers, ...options?.headers }, 31 44 }) 45 + 46 + // If AIP requires a DPoP nonce, the backend relays it via both 47 + // the dpop-nonce response header and the JSON body. Retry once. 48 + if (res.status === 401 && !dpopNonce) { 49 + const text = await res.text().catch(() => "") 50 + let nonce = res.headers.get("dpop-nonce") 51 + if (!nonce) { 52 + try { nonce = JSON.parse(text).dpop_nonce } catch { /* not JSON */ } 53 + } 54 + if (nonce) { 55 + setDpopNonce(nonce) 56 + return apiFetch<T>(path, getToken, options, nonce) 57 + } 58 + throw new ApiError(res.status, text) 59 + } 32 60 33 61 if (!res.ok) { 34 62 const text = await res.text().catch(() => res.statusText)
+93 -13
web/src/lib/auth-context.tsx
··· 8 8 useState, 9 9 } from "react" 10 10 11 + import { clearDpopKeypair, createDpopProof, ensureDpopKeypair, setDpopNonce } from "./dpop" 12 + 11 13 interface AuthContextType { 12 14 did: string | null 13 15 getToken: () => Promise<string | null> ··· 103 105 const state = params.get("state") 104 106 105 107 if (code && state) { 108 + console.log("[auth] OAuth callback detected, exchanging code") 106 109 await handleOAuthCallback(code, state, cancelled, { 107 110 setAccessToken, 108 111 setDid, ··· 111 114 // Restore session from storage 112 115 const savedToken = sessionStorage.getItem("oauth_access_token") 113 116 const savedDid = sessionStorage.getItem("oauth_did") 114 - if (savedToken && savedDid && !cancelled) { 117 + const savedDpopKey = sessionStorage.getItem("dpop_private_jwk") 118 + 119 + console.log("[auth] Session restore check:", { 120 + hasToken: !!savedToken, 121 + hasDid: !!savedDid, 122 + hasDpopKey: !!savedDpopKey, 123 + }) 124 + 125 + if (savedToken && !savedDpopKey) { 126 + console.log("[auth] Clearing pre-DPoP session") 127 + sessionStorage.removeItem("oauth_access_token") 128 + sessionStorage.removeItem("oauth_did") 129 + sessionStorage.removeItem("oauth_client_id") 130 + } else if (savedToken && savedDid && !cancelled) { 131 + console.log("[auth] Restoring session from storage") 115 132 setAccessToken(savedToken) 116 133 setDid(savedDid) 134 + } else { 135 + console.log("[auth] No session to restore") 117 136 } 118 137 } 119 138 } catch (e) { ··· 143 162 144 163 setError(null) 145 164 165 + await ensureDpopKeypair() 166 + 146 167 const redirectUri = `${window.location.origin}/` 147 168 const clientId = await getOrRegisterClient(redirectUri) 148 169 ··· 186 207 } 187 208 setAccessToken(null) 188 209 setDid(null) 210 + clearDpopKeypair() 189 211 sessionStorage.removeItem("oauth_access_token") 190 212 sessionStorage.removeItem("oauth_did") 191 213 sessionStorage.removeItem("oauth_client_id") ··· 245 267 246 268 const redirectUri = `${window.location.origin}/` 247 269 248 - // Token exchange via proxied path (avoids CORS) 249 - const resp = await fetch("/aip/oauth/token", { 270 + // Token exchange via proxied path (avoids CORS). 271 + // AIP may require a DPoP nonce — retry once if we get one back. 272 + const tokenUrl = `${AIP_URL}/oauth/token` 273 + const tokenBody = new URLSearchParams({ 274 + grant_type: "authorization_code", 275 + code, 276 + redirect_uri: redirectUri, 277 + client_id: clientId, 278 + code_verifier: codeVerifier, 279 + }).toString() 280 + 281 + let tokenDpopProof = await createDpopProof("POST", tokenUrl) 282 + let resp = await fetch("/aip/oauth/token", { 250 283 method: "POST", 251 - headers: { "Content-Type": "application/x-www-form-urlencoded" }, 252 - body: new URLSearchParams({ 253 - grant_type: "authorization_code", 254 - code, 255 - redirect_uri: redirectUri, 256 - client_id: clientId, 257 - code_verifier: codeVerifier, 258 - }).toString(), 284 + headers: { 285 + "Content-Type": "application/x-www-form-urlencoded", 286 + DPoP: tokenDpopProof, 287 + }, 288 + body: tokenBody, 259 289 }) 260 290 261 291 if (!resp.ok) { 292 + // AIP returns the nonce via header and/or JSON body 293 + let nonce = resp.headers.get("dpop-nonce") 294 + if (!nonce) { 295 + const errBody = await resp.text().catch(() => "") 296 + try { nonce = JSON.parse(errBody).dpop_nonce ?? null } catch { /* not JSON */ } 297 + if (!nonce) throw new Error(`Token exchange failed: ${errBody}`) 298 + } 299 + tokenDpopProof = await createDpopProof("POST", tokenUrl, undefined, nonce) 300 + resp = await fetch("/aip/oauth/token", { 301 + method: "POST", 302 + headers: { 303 + "Content-Type": "application/x-www-form-urlencoded", 304 + DPoP: tokenDpopProof, 305 + }, 306 + body: tokenBody, 307 + }) 308 + } 309 + 310 + if (!resp.ok) { 262 311 const text = await resp.text() 263 312 throw new Error(`Token exchange failed: ${text}`) 264 313 } 265 314 266 315 const tokens = await resp.json() 316 + // Capture the DPoP nonce from the token response for use in subsequent requests 317 + const dpopNonce = resp.headers.get("dpop-nonce") 318 + if (dpopNonce) setDpopNonce(dpopNonce) 267 319 268 320 // Clean URL and session storage 269 321 window.history.replaceState({}, "", window.location.pathname) ··· 279 331 // Get DID from token response or userinfo 280 332 let userDid: string | undefined = tokens.sub 281 333 if (!userDid) { 282 - const userinfoResp = await fetch("/aip/oauth/userinfo", { 283 - headers: { Authorization: `Bearer ${accessToken}` }, 334 + const userinfoUrl = `${AIP_URL}/oauth/userinfo` 335 + // Use the nonce from the token response if available 336 + let currentNonce = dpopNonce 337 + let userinfoDpopProof = await createDpopProof("GET", userinfoUrl, accessToken, currentNonce ?? undefined) 338 + 339 + let userinfoResp = await fetch("/aip/oauth/userinfo", { 340 + headers: { 341 + Authorization: `DPoP ${accessToken}`, 342 + DPoP: userinfoDpopProof, 343 + }, 284 344 }) 345 + 346 + // Retry with nonce if AIP requires one 347 + if (!userinfoResp.ok) { 348 + let nonce = userinfoResp.headers.get("dpop-nonce") 349 + if (!nonce) { 350 + const errBody = await userinfoResp.text().catch(() => "") 351 + try { nonce = JSON.parse(errBody).dpop_nonce ?? null } catch { /* not JSON */ } 352 + } 353 + if (nonce) { 354 + currentNonce = nonce 355 + userinfoDpopProof = await createDpopProof("GET", userinfoUrl, accessToken, nonce) 356 + userinfoResp = await fetch("/aip/oauth/userinfo", { 357 + headers: { 358 + Authorization: `DPoP ${accessToken}`, 359 + DPoP: userinfoDpopProof, 360 + }, 361 + }) 362 + } 363 + } 364 + 285 365 if (userinfoResp.ok) { 286 366 const info = await userinfoResp.json() 287 367 userDid = info.sub
+117
web/src/lib/dpop.ts
··· 1 + interface DpopKeyPair { 2 + privateKey: CryptoKey 3 + publicJwk: { kty: string; crv: string; x: string; y: string } 4 + } 5 + 6 + let cachedKeypair: DpopKeyPair | null = null 7 + let cachedNonce: string | null = null 8 + 9 + function base64urlEncode(buffer: ArrayBuffer): string { 10 + const bytes = new Uint8Array(buffer) 11 + let binary = "" 12 + for (const b of bytes) binary += String.fromCharCode(b) 13 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") 14 + } 15 + 16 + async function importKeypair(jwk: JsonWebKey): Promise<DpopKeyPair> { 17 + const privateKey = await crypto.subtle.importKey( 18 + "jwk", 19 + jwk, 20 + { name: "ECDSA", namedCurve: "P-256" }, 21 + false, 22 + ["sign"] 23 + ) 24 + return { 25 + privateKey, 26 + publicJwk: { kty: jwk.kty!, crv: jwk.crv!, x: jwk.x!, y: jwk.y! }, 27 + } 28 + } 29 + 30 + export async function ensureDpopKeypair(): Promise<void> { 31 + if (cachedKeypair) return 32 + 33 + const stored = sessionStorage.getItem("dpop_private_jwk") 34 + if (stored) { 35 + cachedKeypair = await importKeypair(JSON.parse(stored)) 36 + return 37 + } 38 + 39 + const keyPair = await crypto.subtle.generateKey( 40 + { name: "ECDSA", namedCurve: "P-256" }, 41 + true, 42 + ["sign", "verify"] 43 + ) 44 + const jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey) 45 + sessionStorage.setItem("dpop_private_jwk", JSON.stringify(jwk)) 46 + 47 + cachedKeypair = { 48 + privateKey: keyPair.privateKey, 49 + publicJwk: { kty: jwk.kty!, crv: jwk.crv!, x: jwk.x!, y: jwk.y! }, 50 + } 51 + } 52 + 53 + export function setDpopNonce(nonce: string): void { 54 + cachedNonce = nonce 55 + } 56 + 57 + export function getDpopNonce(): string | null { 58 + return cachedNonce 59 + } 60 + 61 + export async function createDpopProof( 62 + method: string, 63 + url: string, 64 + accessToken?: string, 65 + nonce?: string 66 + ): Promise<string> { 67 + // Use the cached nonce if no explicit nonce is provided 68 + const effectiveNonce = nonce ?? cachedNonce 69 + await ensureDpopKeypair() 70 + const keypair = cachedKeypair! 71 + 72 + const header = { 73 + typ: "dpop+jwt", 74 + alg: "ES256", 75 + jwk: keypair.publicJwk, 76 + } 77 + 78 + const claims: Record<string, unknown> = { 79 + jti: crypto.randomUUID(), 80 + htm: method.toUpperCase(), 81 + htu: url, 82 + iat: Math.floor(Date.now() / 1000), 83 + } 84 + 85 + if (accessToken) { 86 + const hash = await crypto.subtle.digest( 87 + "SHA-256", 88 + new TextEncoder().encode(accessToken) 89 + ) 90 + claims.ath = base64urlEncode(hash) 91 + } 92 + 93 + if (effectiveNonce) { 94 + claims.nonce = effectiveNonce 95 + } 96 + 97 + const enc = new TextEncoder() 98 + const headerB64 = base64urlEncode( 99 + enc.encode(JSON.stringify(header)).buffer as ArrayBuffer 100 + ) 101 + const claimsB64 = base64urlEncode( 102 + enc.encode(JSON.stringify(claims)).buffer as ArrayBuffer 103 + ) 104 + 105 + const signature = await crypto.subtle.sign( 106 + { name: "ECDSA", hash: "SHA-256" }, 107 + keypair.privateKey, 108 + enc.encode(`${headerB64}.${claimsB64}`) 109 + ) 110 + 111 + return `${headerB64}.${claimsB64}.${base64urlEncode(signature)}` 112 + } 113 + 114 + export function clearDpopKeypair(): void { 115 + cachedKeypair = null 116 + sessionStorage.removeItem("dpop_private_jwk") 117 + }