diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index e1617df..8113310 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -20,6 +20,7 @@ import { err, isErr, isOk, ok, type Result } from "./types/result.ts"; import { assertNever } from "./types/exhaustive.ts"; import { checkForOAuthCallback, + clearAllOAuthState, clearOAuthCallbackParams, handleOAuthCallback, refreshOAuthToken, @@ -274,6 +275,12 @@ function setLoading(previousSession: Session | null = null): void { setState(createLoading(getSavedAccounts(), previousSession)); } +export function clearError(): void { + if (state.current.kind === "error") { + setState(createUnauthenticated(getSavedAccounts())); + } +} + async function tryRefreshToken(): Promise { if (state.current.kind !== "authenticated") return null; const currentSession = state.current.session; @@ -323,6 +330,7 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { applyLocaleFromSession(session); return { oauthLoginCompleted: true }; } catch (e) { + clearAllOAuthState(); setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed", @@ -398,6 +406,7 @@ export async function login( } export async function loginWithOAuth(): Promise> { + clearAllOAuthState(); setLoading(); try { await startOAuthLogin(); diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index a7e560c..ef15c2c 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -1,5 +1,8 @@ const OAUTH_STATE_KEY = "tranquil_pds_oauth_state"; const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier"; +const DPOP_KEY_STORE = "tranquil_pds_dpop_keys"; +const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce"; + const SCOPES = [ "atproto", "repo:*?action=create", @@ -7,9 +10,11 @@ const SCOPES = [ "repo:*?action=delete", "blob:*/*", ].join(" "); + const CLIENT_ID = !(import.meta.env.DEV) ? `${globalThis.location.origin}/oauth/client-metadata.json` : `http://localhost/?scope=${SCOPES}`; + const REDIRECT_URI = `${globalThis.location.origin}/app/`; interface OAuthState { @@ -18,6 +23,12 @@ interface OAuthState { returnTo?: string; } +interface DPoPKeyPair { + publicKey: CryptoKey; + privateKey: CryptoKey; + jwk: JsonWebKey; +} + function generateRandomString(length: number): string { const array = new Uint8Array(length); crypto.getRandomValues(array); @@ -73,11 +84,191 @@ function clearOAuthState(): void { sessionStorage.removeItem(OAUTH_VERIFIER_KEY); } +function clearDPoPNonce(): void { + sessionStorage.removeItem(DPOP_NONCE_KEY); +} + +export function clearAllOAuthState(): void { + clearOAuthState(); + clearDPoPNonce(); +} + +async function openKeyStore(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DPOP_KEY_STORE, 1); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains("keys")) { + db.createObjectStore("keys"); + } + }; + }); +} + +async function storeDPoPKeyPair(keyPair: DPoPKeyPair): Promise { + const db = await openKeyStore(); + return new Promise((resolve, reject) => { + const tx = db.transaction("keys", "readwrite"); + const store = tx.objectStore("keys"); + store.put(keyPair.publicKey, "publicKey"); + store.put(keyPair.privateKey, "privateKey"); + store.put(keyPair.jwk, "jwk"); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); +} + +async function loadDPoPKeyPair(): Promise { + try { + const db = await openKeyStore(); + return new Promise((resolve, reject) => { + const tx = db.transaction("keys", "readonly"); + const store = tx.objectStore("keys"); + const publicKeyReq = store.get("publicKey"); + const privateKeyReq = store.get("privateKey"); + const jwkReq = store.get("jwk"); + tx.oncomplete = () => { + db.close(); + if (publicKeyReq.result && privateKeyReq.result && jwkReq.result) { + resolve({ + publicKey: publicKeyReq.result, + privateKey: privateKeyReq.result, + jwk: jwkReq.result, + }); + } else { + resolve(null); + } + }; + tx.onerror = () => { + db.close(); + reject(tx.error); + }; + }); + } catch { + return null; + } +} + +async function generateDPoPKeyPair(): Promise { + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + return { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + jwk, + }; +} + +async function getOrCreateDPoPKeyPair(): Promise { + const existing = await loadDPoPKeyPair(); + if (existing) return existing; + + const keyPair = await generateDPoPKeyPair(); + await storeDPoPKeyPair(keyPair); + return keyPair; +} + +async function createDPoPProof( + keyPair: DPoPKeyPair, + method: string, + url: string, + nonce?: string, + accessTokenHash?: string, +): Promise { + const header = { + typ: "dpop+jwt", + alg: "ES256", + jwk: { + kty: keyPair.jwk.kty, + crv: keyPair.jwk.crv, + x: keyPair.jwk.x, + y: keyPair.jwk.y, + }, + }; + + const payload: Record = { + jti: generateRandomString(16), + htm: method.toUpperCase(), + htu: url.split("?")[0], + iat: Math.floor(Date.now() / 1000), + }; + + if (nonce) { + payload.nonce = nonce; + } + + if (accessTokenHash) { + payload.ath = accessTokenHash; + } + + const headerB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(header)).buffer as ArrayBuffer, + ); + const payloadB64 = base64UrlEncode( + new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer, + ); + const signingInput = `${headerB64}.${payloadB64}`; + + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + keyPair.privateKey, + new TextEncoder().encode(signingInput), + ); + + const sigBytes = new Uint8Array(signature); + const signatureB64 = base64UrlEncode(sigBytes.buffer); + + return `${signingInput}.${signatureB64}`; +} + +async function computeJwkThumbprint(jwk: JsonWebKey): Promise { + const canonical = JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y, + }); + const hash = await sha256(canonical); + return base64UrlEncode(hash); +} + +function getDPoPNonce(): string | null { + return sessionStorage.getItem(DPOP_NONCE_KEY); +} + +function setDPoPNonce(nonce: string): void { + sessionStorage.setItem(DPOP_NONCE_KEY, nonce); +} + +function extractDPoPNonceFromResponse(response: Response): void { + const nonce = response.headers.get("DPoP-Nonce"); + if (nonce) { + setDPoPNonce(nonce); + } +} + export async function startOAuthLogin(): Promise { + clearAllOAuthState(); + const state = generateState(); const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); + const keyPair = await getOrCreateDPoPKeyPair(); + const dpopJkt = await computeJwkThumbprint(keyPair.jwk); + saveOAuthState({ state, codeVerifier }); const parResponse = await fetch("/oauth/par", { @@ -91,6 +282,7 @@ export async function startOAuthLogin(): Promise { state: state, code_challenge: codeChallenge, code_challenge_method: "S256", + dpop_jkt: dpopJkt, }), }); @@ -121,6 +313,46 @@ export interface OAuthTokens { sub: string; } +async function tokenRequest( + params: URLSearchParams, + retryWithNonce = true, +): Promise { + const keyPair = await getOrCreateDPoPKeyPair(); + const tokenEndpoint = `${globalThis.location.origin}/oauth/token`; + + const dpopProof = await createDPoPProof( + keyPair, + "POST", + tokenEndpoint, + getDPoPNonce() ?? undefined, + ); + + const response = await fetch("/oauth/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "DPoP": dpopProof, + }, + body: params, + }); + + extractDPoPNonceFromResponse(response); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: "Unknown error" })); + + if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) { + return tokenRequest(params, false); + } + + throw new Error( + error.error_description || error.error || "Token request failed", + ); + } + + return response.json(); +} + export async function handleOAuthCallback( code: string, state: string, @@ -135,56 +367,29 @@ export async function handleOAuthCallback( throw new Error("OAuth state mismatch. Please try logging in again."); } - const tokenResponse = await fetch("/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: CLIENT_ID, - code: code, - redirect_uri: REDIRECT_URI, - code_verifier: savedState.codeVerifier, - }), + const params = new URLSearchParams({ + grant_type: "authorization_code", + client_id: CLIENT_ID, + code: code, + redirect_uri: REDIRECT_URI, + code_verifier: savedState.codeVerifier, }); clearOAuthState(); - if (!tokenResponse.ok) { - const error = await tokenResponse.json().catch(() => ({ - error: "Unknown error", - })); - throw new Error( - error.error_description || error.error || - "Failed to exchange code for tokens", - ); - } - - return tokenResponse.json(); + return tokenRequest(params); } export async function refreshOAuthToken( refreshToken: string, ): Promise { - const tokenResponse = await fetch("/oauth/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - client_id: CLIENT_ID, - refresh_token: refreshToken, - }), + const params = new URLSearchParams({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: refreshToken, }); - if (!tokenResponse.ok) { - const error = await tokenResponse.json().catch(() => ({ - error: "Unknown error", - })); - throw new Error( - error.error_description || error.error || "Failed to refresh token", - ); - } - - return tokenResponse.json(); + return tokenRequest(params); } export function checkForOAuthCallback(): diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 486a518..240ff10 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -285,14 +285,24 @@ justify-content: space-between; align-items: center; margin-bottom: var(--space-7); + gap: var(--space-4); + } + + @media (max-width: 500px) { + header { + flex-direction: column-reverse; + align-items: flex-start; + } } header h1 { margin: 0; + min-width: 0; } .account-dropdown { position: relative; + max-width: 100%; } .account-trigger { @@ -305,6 +315,14 @@ border-radius: var(--radius-md); cursor: pointer; color: var(--text-primary); + max-width: 100%; + } + + .account-trigger .account-handle { + font-weight: var(--font-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .account-trigger:hover:not(:disabled) { @@ -316,10 +334,6 @@ cursor: not-allowed; } - .account-trigger .account-handle { - font-weight: var(--font-medium); - } - .dropdown-arrow { font-size: 0.625rem; color: var(--text-secondary); @@ -383,6 +397,8 @@ padding: var(--space-6); border-radius: var(--radius-xl); margin-bottom: var(--space-7); + overflow: hidden; + min-width: 0; } section h2 { @@ -400,10 +416,12 @@ dt { font-weight: var(--font-medium); color: var(--text-secondary); + max-width: 6rem; } dd { margin: 0; + min-width: 0; } .mono { diff --git a/frontend/src/routes/Login.svelte b/frontend/src/routes/Login.svelte index fe9e6d3..da629c3 100644 --- a/frontend/src/routes/Login.svelte +++ b/frontend/src/routes/Login.svelte @@ -6,6 +6,7 @@ getAuthState, switchAccount, forgetAccount, + clearError, matchAuthState, type SavedAccount, type AuthError, @@ -14,6 +15,7 @@ import { _ } from '../lib/i18n' import { isOk, isErr } from '../lib/types/result' import { unsafeAsDid, type Did } from '../lib/types/branded' + import { toast } from '../lib/toast.svelte' type PageState = | { kind: 'login' } @@ -32,17 +34,17 @@ return auth.savedAccounts } - function getErrorMessage(): string | null { - if (auth.kind === 'error') { - return auth.error.message - } - return null - } - function isLoading(): boolean { return auth.kind === 'loading' } + $effect(() => { + if (auth.kind === 'error') { + toast.error(auth.error.message) + clearError() + } + }) + $effect(() => { const accounts = getSavedAccounts() const loading = isLoading() @@ -108,16 +110,11 @@ resendMessage = null } - const errorMessage = $derived(getErrorMessage()) const savedAccounts = $derived(getSavedAccounts()) const loading = $derived(isLoading())