From b7c7bbba59ff8c49459ffddf7bdf1930726b348f Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 16 Feb 2026 18:08:15 +0000 Subject: [PATCH] fix: fix dpop resolution for dashboard login --- docker-compose.yml | 18 +++++++++++------- src/auth/middleware.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- src/error.rs | 13 ++++++++++++- web/src/lib/api.ts | 32 ++++++++++++++++++++++++++++++-- web/src/lib/auth-context.tsx | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------- web/src/lib/dpop.ts | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 file(s) changed, 325 insertion(s)(+), 33 deletion(s)(-) diff --git a/docker-compose.yml b/docker-compose.yml --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,10 +20,13 @@ aip: image: atcr.io/gamesgamesgamesgames.games/aip:2.2.4-dev.1 ports: - "8080:8080" + dns: + - 8.8.8.8 + - 8.8.4.4 environment: DATABASE_URL: postgres://aip:aip@postgres/aip STORAGE_BACKEND: postgres - EXTERNAL_BASE: http://localhost:8080 + # EXTERNAL_BASE: http://localhost:8080 DPOP_NONCE_SEED: ${DPOP_NONCE_SEED} OAUTH_SIGNING_KEYS: ${OAUTH_SIGNING_KEYS} ATPROTO_OAUTH_SIGNING_KEYS: ${ATPROTO_OAUTH_SIGNING_KEYS} @@ -39,7 +42,7 @@ ports: - "2480:2480" environment: TAP_DATABASE_URL: postgres://tap:tap@postgres/tap - TAP_RELAY_URL: https://bsky.network + TAP_RELAY_URL: https://relay1.us-east.bsky.network TAP_PLC_URL: https://plc.directory TAP_ADMIN_PASSWORD: ${TAP_ADMIN_PASSWORD} TAP_COLLECTION_FILTERS: "" @@ -61,9 +64,10 @@ - cargo-git:/usr/local/cargo/git - cargo-target:/app/target environment: DATABASE_URL: postgres://happyview:happyview@postgres/happyview - AIP_URL: https://aip.gamesgamesgamesgames.games + AIP_URL: http://aip:8080 TAP_URL: http://tap:2480 TAP_ADMIN_PASSWORD: ${TAP_ADMIN_PASSWORD} + RELAY_URL: https://relay1.us-east.bsky.network PORT: 3000 depends_on: postgres: @@ -83,10 +87,10 @@ volumes: - ./web:/app - web-node-modules:/app/node_modules environment: - - HOSTNAME=0.0.0.0 - - API_URL=http://happyview:3000 - - AIP_PROXY_URL=http://aip:8080 - - NEXT_PUBLIC_AIP_URL=http://localhost:8080 + HOSTNAME: 0.0.0.0 + API_URL: http://happyview:3000 + AIP_PROXY_URL: http://aip:8080 + # NEXT_PUBLIC_AIP_URL: http://localhost:8080 volumes: pgdata: diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -10,6 +10,7 @@ #[derive(Debug, Clone)] pub struct Claims { did: String, token: String, + dpop_proof: Option, } impl Claims { @@ -18,15 +19,24 @@ pub fn did(&self) -> &str { &self.did } - /// The raw Bearer token for forwarding to AIP's XRPC proxy. + /// The raw access token for forwarding to AIP's XRPC proxy. pub fn token(&self) -> &str { &self.token } + /// The DPoP proof from the client request, if present. + pub fn dpop_proof(&self) -> Option<&str> { + self.dpop_proof.as_deref() + } + /// Test-only constructor. #[cfg(test)] pub fn new_for_test(did: String, token: String) -> Self { - Self { did, token } + Self { + did, + token, + dpop_proof: None, + } } } @@ -51,26 +61,67 @@ .and_then(|v| v.to_str().ok()) .ok_or_else(|| AppError::Auth("missing Authorization header".into()))?; let token = header - .strip_prefix("Bearer ") + .strip_prefix("DPoP ") + .or_else(|| header.strip_prefix("Bearer ")) .ok_or_else(|| AppError::Auth("invalid Authorization scheme".into()))?; + let dpop_proof = parts + .headers + .get("dpop") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let userinfo_url = format!( "{}/oauth/userinfo", state.config.aip_url.trim_end_matches('/') ); - let resp = state + tracing::debug!( + url = %userinfo_url, + has_dpop_proof = dpop_proof.is_some(), + "forwarding token to AIP userinfo" + ); + + let mut req = state .http .get(&userinfo_url) - .header("authorization", format!("Bearer {token}")) - .send() - .await - .map_err(|e| AppError::Auth(format!("userinfo request failed: {e}")))?; + .header("authorization", format!("DPoP {token}")); + + if let Some(ref proof) = dpop_proof { + req = req.header("dpop", proof); + } + + let resp = req.send().await.map_err(|e| { + tracing::error!(url = %userinfo_url, error = %e, "AIP userinfo request failed to send"); + AppError::Auth(format!("userinfo request failed: {e}")) + })?; if !resp.status().is_success() { + let status = resp.status(); + let nonce = resp + .headers() + .get("dpop-nonce") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let body = resp.text().await.unwrap_or_default(); + + tracing::warn!( + url = %userinfo_url, + status = %status, + body = %body, + dpop_nonce = ?nonce, + has_dpop_proof = dpop_proof.is_some(), + "AIP userinfo request failed" + ); + + // Relay the nonce so the client can retry with it. + if let Some(ref nonce_str) = nonce { + return Err(AppError::AuthDpopNonce(nonce_str.clone())); + } + return Err(AppError::Auth(format!( - "userinfo returned {}", - resp.status() + "userinfo returned {}: {}", + status, body ))); } @@ -82,6 +133,7 @@ Ok(Claims { did: info.sub, token: token.to_string(), + dpop_proof, }) } } diff --git a/src/error.rs b/src/error.rs --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,8 @@ #[derive(Debug)] pub enum AppError { Auth(String), + /// Auth failure with a DPoP nonce that the client should retry with. + AuthDpopNonce(String), BadRequest(String), Forbidden(String), Internal(String), @@ -16,6 +18,7 @@ impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AppError::Auth(msg) => write!(f, "auth error: {msg}"), + AppError::AuthDpopNonce(nonce) => write!(f, "auth error: use_dpop_nonce ({nonce})"), AppError::BadRequest(msg) => write!(f, "bad request: {msg}"), AppError::Forbidden(msg) => write!(f, "forbidden: {msg}"), AppError::Internal(msg) => write!(f, "internal error: {msg}"), @@ -34,6 +37,14 @@ [(axum::http::header::CONTENT_TYPE, "application/json")], body, ) .into_response(), + AppError::AuthDpopNonce(nonce) => { + let body = serde_json::json!({ "error": "use_dpop_nonce", "dpop_nonce": nonce }); + let mut response = (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response(); + if let Ok(val) = axum::http::HeaderValue::from_str(&nonce) { + response.headers_mut().insert("dpop-nonce", val); + } + response + } other => { let (status, message) = match &other { AppError::Auth(msg) => (StatusCode::UNAUTHORIZED, msg.clone()), @@ -47,7 +58,7 @@ "internal server error".into(), ) } AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), - AppError::PdsError(..) => unreachable!(), + AppError::PdsError(..) | AppError::AuthDpopNonce(..) => unreachable!(), }; let body = serde_json::json!({ "error": message }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,3 +1,10 @@ +import { createDpopProof, setDpopNonce } from "./dpop" + +// The DPoP proof for admin API calls must target AIP's userinfo URL, +// because the backend forwards the proof to AIP for token validation. +const AIP_URL = process.env.NEXT_PUBLIC_AIP_URL || "" +const AIP_USERINFO_URL = `${AIP_URL}/oauth/userinfo` + export class ApiError extends Error { status: number constructor(status: number, message: string) { @@ -9,13 +16,19 @@ async function apiFetch( path: string, getToken: () => Promise, - options?: RequestInit + options?: RequestInit, + dpopNonce?: string ): Promise { const token = await getToken() if (!token) throw new ApiError(401, "Not authenticated") + // Proof targets AIP's userinfo endpoint (GET) since the backend + // forwards it there for token validation. + const dpopProof = await createDpopProof("GET", AIP_USERINFO_URL, token, dpopNonce) + const headers: Record = { - Authorization: `Bearer ${token}`, + Authorization: `DPoP ${token}`, + DPoP: dpopProof, } if ( options?.method === "POST" || @@ -29,6 +42,21 @@ const res = await fetch(path, { ...options, headers: { ...headers, ...options?.headers }, }) + + // If AIP requires a DPoP nonce, the backend relays it via both + // the dpop-nonce response header and the JSON body. Retry once. + if (res.status === 401 && !dpopNonce) { + const text = await res.text().catch(() => "") + let nonce = res.headers.get("dpop-nonce") + if (!nonce) { + try { nonce = JSON.parse(text).dpop_nonce } catch { /* not JSON */ } + } + if (nonce) { + setDpopNonce(nonce) + return apiFetch(path, getToken, options, nonce) + } + throw new ApiError(res.status, text) + } if (!res.ok) { const text = await res.text().catch(() => res.statusText) diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -8,6 +8,8 @@ useEffect, useState, } from "react" +import { clearDpopKeypair, createDpopProof, ensureDpopKeypair, setDpopNonce } from "./dpop" + interface AuthContextType { did: string | null getToken: () => Promise @@ -103,6 +105,7 @@ const code = params.get("code") const state = params.get("state") if (code && state) { + console.log("[auth] OAuth callback detected, exchanging code") await handleOAuthCallback(code, state, cancelled, { setAccessToken, setDid, @@ -111,9 +114,25 @@ } else { // Restore session from storage const savedToken = sessionStorage.getItem("oauth_access_token") const savedDid = sessionStorage.getItem("oauth_did") - if (savedToken && savedDid && !cancelled) { + const savedDpopKey = sessionStorage.getItem("dpop_private_jwk") + + console.log("[auth] Session restore check:", { + hasToken: !!savedToken, + hasDid: !!savedDid, + hasDpopKey: !!savedDpopKey, + }) + + if (savedToken && !savedDpopKey) { + console.log("[auth] Clearing pre-DPoP session") + sessionStorage.removeItem("oauth_access_token") + sessionStorage.removeItem("oauth_did") + sessionStorage.removeItem("oauth_client_id") + } else if (savedToken && savedDid && !cancelled) { + console.log("[auth] Restoring session from storage") setAccessToken(savedToken) setDid(savedDid) + } else { + console.log("[auth] No session to restore") } } } catch (e) { @@ -143,6 +162,8 @@ } setError(null) + await ensureDpopKeypair() + const redirectUri = `${window.location.origin}/` const clientId = await getOrRegisterClient(redirectUri) @@ -186,6 +207,7 @@ } } setAccessToken(null) setDid(null) + clearDpopKeypair() sessionStorage.removeItem("oauth_access_token") sessionStorage.removeItem("oauth_did") sessionStorage.removeItem("oauth_client_id") @@ -245,25 +267,55 @@ } const redirectUri = `${window.location.origin}/` - // Token exchange via proxied path (avoids CORS) - const resp = await fetch("/aip/oauth/token", { + // Token exchange via proxied path (avoids CORS). + // AIP may require a DPoP nonce — retry once if we get one back. + const tokenUrl = `${AIP_URL}/oauth/token` + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: codeVerifier, + }).toString() + + let tokenDpopProof = await createDpopProof("POST", tokenUrl) + let resp = await fetch("/aip/oauth/token", { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - client_id: clientId, - code_verifier: codeVerifier, - }).toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: tokenDpopProof, + }, + body: tokenBody, }) if (!resp.ok) { + // AIP returns the nonce via header and/or JSON body + let nonce = resp.headers.get("dpop-nonce") + if (!nonce) { + const errBody = await resp.text().catch(() => "") + try { nonce = JSON.parse(errBody).dpop_nonce ?? null } catch { /* not JSON */ } + if (!nonce) throw new Error(`Token exchange failed: ${errBody}`) + } + tokenDpopProof = await createDpopProof("POST", tokenUrl, undefined, nonce) + resp = await fetch("/aip/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + DPoP: tokenDpopProof, + }, + body: tokenBody, + }) + } + + if (!resp.ok) { const text = await resp.text() throw new Error(`Token exchange failed: ${text}`) } const tokens = await resp.json() + // Capture the DPoP nonce from the token response for use in subsequent requests + const dpopNonce = resp.headers.get("dpop-nonce") + if (dpopNonce) setDpopNonce(dpopNonce) // Clean URL and session storage window.history.replaceState({}, "", window.location.pathname) @@ -279,9 +331,37 @@ // Get DID from token response or userinfo let userDid: string | undefined = tokens.sub if (!userDid) { - const userinfoResp = await fetch("/aip/oauth/userinfo", { - headers: { Authorization: `Bearer ${accessToken}` }, + const userinfoUrl = `${AIP_URL}/oauth/userinfo` + // Use the nonce from the token response if available + let currentNonce = dpopNonce + let userinfoDpopProof = await createDpopProof("GET", userinfoUrl, accessToken, currentNonce ?? undefined) + + let userinfoResp = await fetch("/aip/oauth/userinfo", { + headers: { + Authorization: `DPoP ${accessToken}`, + DPoP: userinfoDpopProof, + }, }) + + // Retry with nonce if AIP requires one + if (!userinfoResp.ok) { + let nonce = userinfoResp.headers.get("dpop-nonce") + if (!nonce) { + const errBody = await userinfoResp.text().catch(() => "") + try { nonce = JSON.parse(errBody).dpop_nonce ?? null } catch { /* not JSON */ } + } + if (nonce) { + currentNonce = nonce + userinfoDpopProof = await createDpopProof("GET", userinfoUrl, accessToken, nonce) + userinfoResp = await fetch("/aip/oauth/userinfo", { + headers: { + Authorization: `DPoP ${accessToken}`, + DPoP: userinfoDpopProof, + }, + }) + } + } + if (userinfoResp.ok) { const info = await userinfoResp.json() userDid = info.sub diff --git a/web/src/lib/dpop.ts b/web/src/lib/dpop.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/dpop.ts @@ -0,0 +1,117 @@ +interface DpopKeyPair { + privateKey: CryptoKey + publicJwk: { kty: string; crv: string; x: string; y: string } +} + +let cachedKeypair: DpopKeyPair | null = null +let cachedNonce: string | null = null + +function base64urlEncode(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let binary = "" + for (const b of bytes) binary += String.fromCharCode(b) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +async function importKeypair(jwk: JsonWebKey): Promise { + const privateKey = await crypto.subtle.importKey( + "jwk", + jwk, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"] + ) + return { + privateKey, + publicJwk: { kty: jwk.kty!, crv: jwk.crv!, x: jwk.x!, y: jwk.y! }, + } +} + +export async function ensureDpopKeypair(): Promise { + if (cachedKeypair) return + + const stored = sessionStorage.getItem("dpop_private_jwk") + if (stored) { + cachedKeypair = await importKeypair(JSON.parse(stored)) + return + } + + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"] + ) + const jwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey) + sessionStorage.setItem("dpop_private_jwk", JSON.stringify(jwk)) + + cachedKeypair = { + privateKey: keyPair.privateKey, + publicJwk: { kty: jwk.kty!, crv: jwk.crv!, x: jwk.x!, y: jwk.y! }, + } +} + +export function setDpopNonce(nonce: string): void { + cachedNonce = nonce +} + +export function getDpopNonce(): string | null { + return cachedNonce +} + +export async function createDpopProof( + method: string, + url: string, + accessToken?: string, + nonce?: string +): Promise { + // Use the cached nonce if no explicit nonce is provided + const effectiveNonce = nonce ?? cachedNonce + await ensureDpopKeypair() + const keypair = cachedKeypair! + + const header = { + typ: "dpop+jwt", + alg: "ES256", + jwk: keypair.publicJwk, + } + + const claims: Record = { + jti: crypto.randomUUID(), + htm: method.toUpperCase(), + htu: url, + iat: Math.floor(Date.now() / 1000), + } + + if (accessToken) { + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(accessToken) + ) + claims.ath = base64urlEncode(hash) + } + + if (effectiveNonce) { + claims.nonce = effectiveNonce + } + + const enc = new TextEncoder() + const headerB64 = base64urlEncode( + enc.encode(JSON.stringify(header)).buffer as ArrayBuffer + ) + const claimsB64 = base64urlEncode( + enc.encode(JSON.stringify(claims)).buffer as ArrayBuffer + ) + + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + keypair.privateKey, + enc.encode(`${headerB64}.${claimsB64}`) + ) + + return `${headerB64}.${claimsB64}.${base64urlEncode(signature)}` +} + +export function clearDpopKeypair(): void { + cachedKeypair = null + sessionStorage.removeItem("dpop_private_jwk") +} -- tangled.sh