this repo has no description

DPoP in frontend, why not

lewis cd09e6c7 41d703d8

+9
frontend/src/lib/auth.svelte.ts
··· 20 import { assertNever } from "./types/exhaustive.ts"; 21 import { 22 checkForOAuthCallback, 23 clearOAuthCallbackParams, 24 handleOAuthCallback, 25 refreshOAuthToken, ··· 274 setState(createLoading(getSavedAccounts(), previousSession)); 275 } 276 277 async function tryRefreshToken(): Promise<string | null> { 278 if (state.current.kind !== "authenticated") return null; 279 const currentSession = state.current.session; ··· 323 applyLocaleFromSession(session); 324 return { oauthLoginCompleted: true }; 325 } catch (e) { 326 setError({ 327 type: "oauth", 328 message: e instanceof Error ? e.message : "OAuth login failed", ··· 398 } 399 400 export async function loginWithOAuth(): Promise<Result<void, AuthError>> { 401 setLoading(); 402 try { 403 await startOAuthLogin();
··· 20 import { assertNever } from "./types/exhaustive.ts"; 21 import { 22 checkForOAuthCallback, 23 + clearAllOAuthState, 24 clearOAuthCallbackParams, 25 handleOAuthCallback, 26 refreshOAuthToken, ··· 275 setState(createLoading(getSavedAccounts(), previousSession)); 276 } 277 278 + export function clearError(): void { 279 + if (state.current.kind === "error") { 280 + setState(createUnauthenticated(getSavedAccounts())); 281 + } 282 + } 283 + 284 async function tryRefreshToken(): Promise<string | null> { 285 if (state.current.kind !== "authenticated") return null; 286 const currentSession = state.current.session; ··· 330 applyLocaleFromSession(session); 331 return { oauthLoginCompleted: true }; 332 } catch (e) { 333 + clearAllOAuthState(); 334 setError({ 335 type: "oauth", 336 message: e instanceof Error ? e.message : "OAuth login failed", ··· 406 } 407 408 export async function loginWithOAuth(): Promise<Result<void, AuthError>> { 409 + clearAllOAuthState(); 410 setLoading(); 411 try { 412 await startOAuthLogin();
+244 -39
frontend/src/lib/oauth.ts
··· 1 const OAUTH_STATE_KEY = "tranquil_pds_oauth_state"; 2 const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier"; 3 const SCOPES = [ 4 "atproto", 5 "repo:*?action=create", ··· 7 "repo:*?action=delete", 8 "blob:*/*", 9 ].join(" "); 10 const CLIENT_ID = !(import.meta.env.DEV) 11 ? `${globalThis.location.origin}/oauth/client-metadata.json` 12 : `http://localhost/?scope=${SCOPES}`; 13 const REDIRECT_URI = `${globalThis.location.origin}/app/`; 14 15 interface OAuthState { 16 state: string; 17 codeVerifier: string; 18 returnTo?: string; 19 } 20 21 function generateRandomString(length: number): string { ··· 73 sessionStorage.removeItem(OAUTH_VERIFIER_KEY); 74 } 75 76 export async function startOAuthLogin(): Promise<void> { 77 const state = generateState(); 78 const codeVerifier = generateCodeVerifier(); 79 const codeChallenge = await generateCodeChallenge(codeVerifier); 80 81 saveOAuthState({ state, codeVerifier }); 82 ··· 91 state: state, 92 code_challenge: codeChallenge, 93 code_challenge_method: "S256", 94 }), 95 }); 96 ··· 121 sub: string; 122 } 123 124 export async function handleOAuthCallback( 125 code: string, 126 state: string, ··· 135 throw new Error("OAuth state mismatch. Please try logging in again."); 136 } 137 138 - const tokenResponse = await fetch("/oauth/token", { 139 - method: "POST", 140 - headers: { "Content-Type": "application/x-www-form-urlencoded" }, 141 - body: new URLSearchParams({ 142 - grant_type: "authorization_code", 143 - client_id: CLIENT_ID, 144 - code: code, 145 - redirect_uri: REDIRECT_URI, 146 - code_verifier: savedState.codeVerifier, 147 - }), 148 }); 149 150 clearOAuthState(); 151 152 - if (!tokenResponse.ok) { 153 - const error = await tokenResponse.json().catch(() => ({ 154 - error: "Unknown error", 155 - })); 156 - throw new Error( 157 - error.error_description || error.error || 158 - "Failed to exchange code for tokens", 159 - ); 160 - } 161 - 162 - return tokenResponse.json(); 163 } 164 165 export async function refreshOAuthToken( 166 refreshToken: string, 167 ): Promise<OAuthTokens> { 168 - const tokenResponse = await fetch("/oauth/token", { 169 - method: "POST", 170 - headers: { "Content-Type": "application/x-www-form-urlencoded" }, 171 - body: new URLSearchParams({ 172 - grant_type: "refresh_token", 173 - client_id: CLIENT_ID, 174 - refresh_token: refreshToken, 175 - }), 176 }); 177 178 - if (!tokenResponse.ok) { 179 - const error = await tokenResponse.json().catch(() => ({ 180 - error: "Unknown error", 181 - })); 182 - throw new Error( 183 - error.error_description || error.error || "Failed to refresh token", 184 - ); 185 - } 186 - 187 - return tokenResponse.json(); 188 } 189 190 export function checkForOAuthCallback():
··· 1 const OAUTH_STATE_KEY = "tranquil_pds_oauth_state"; 2 const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier"; 3 + const DPOP_KEY_STORE = "tranquil_pds_dpop_keys"; 4 + const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce"; 5 + 6 const SCOPES = [ 7 "atproto", 8 "repo:*?action=create", ··· 10 "repo:*?action=delete", 11 "blob:*/*", 12 ].join(" "); 13 + 14 const CLIENT_ID = !(import.meta.env.DEV) 15 ? `${globalThis.location.origin}/oauth/client-metadata.json` 16 : `http://localhost/?scope=${SCOPES}`; 17 + 18 const REDIRECT_URI = `${globalThis.location.origin}/app/`; 19 20 interface OAuthState { 21 state: string; 22 codeVerifier: string; 23 returnTo?: string; 24 + } 25 + 26 + interface DPoPKeyPair { 27 + publicKey: CryptoKey; 28 + privateKey: CryptoKey; 29 + jwk: JsonWebKey; 30 } 31 32 function generateRandomString(length: number): string { ··· 84 sessionStorage.removeItem(OAUTH_VERIFIER_KEY); 85 } 86 87 + function clearDPoPNonce(): void { 88 + sessionStorage.removeItem(DPOP_NONCE_KEY); 89 + } 90 + 91 + export function clearAllOAuthState(): void { 92 + clearOAuthState(); 93 + clearDPoPNonce(); 94 + } 95 + 96 + async function openKeyStore(): Promise<IDBDatabase> { 97 + return new Promise((resolve, reject) => { 98 + const request = indexedDB.open(DPOP_KEY_STORE, 1); 99 + request.onerror = () => reject(request.error); 100 + request.onsuccess = () => resolve(request.result); 101 + request.onupgradeneeded = () => { 102 + const db = request.result; 103 + if (!db.objectStoreNames.contains("keys")) { 104 + db.createObjectStore("keys"); 105 + } 106 + }; 107 + }); 108 + } 109 + 110 + async function storeDPoPKeyPair(keyPair: DPoPKeyPair): Promise<void> { 111 + const db = await openKeyStore(); 112 + return new Promise((resolve, reject) => { 113 + const tx = db.transaction("keys", "readwrite"); 114 + const store = tx.objectStore("keys"); 115 + store.put(keyPair.publicKey, "publicKey"); 116 + store.put(keyPair.privateKey, "privateKey"); 117 + store.put(keyPair.jwk, "jwk"); 118 + tx.oncomplete = () => { 119 + db.close(); 120 + resolve(); 121 + }; 122 + tx.onerror = () => { 123 + db.close(); 124 + reject(tx.error); 125 + }; 126 + }); 127 + } 128 + 129 + async function loadDPoPKeyPair(): Promise<DPoPKeyPair | null> { 130 + try { 131 + const db = await openKeyStore(); 132 + return new Promise((resolve, reject) => { 133 + const tx = db.transaction("keys", "readonly"); 134 + const store = tx.objectStore("keys"); 135 + const publicKeyReq = store.get("publicKey"); 136 + const privateKeyReq = store.get("privateKey"); 137 + const jwkReq = store.get("jwk"); 138 + tx.oncomplete = () => { 139 + db.close(); 140 + if (publicKeyReq.result && privateKeyReq.result && jwkReq.result) { 141 + resolve({ 142 + publicKey: publicKeyReq.result, 143 + privateKey: privateKeyReq.result, 144 + jwk: jwkReq.result, 145 + }); 146 + } else { 147 + resolve(null); 148 + } 149 + }; 150 + tx.onerror = () => { 151 + db.close(); 152 + reject(tx.error); 153 + }; 154 + }); 155 + } catch { 156 + return null; 157 + } 158 + } 159 + 160 + async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { 161 + const keyPair = await crypto.subtle.generateKey( 162 + { name: "ECDSA", namedCurve: "P-256" }, 163 + true, 164 + ["sign", "verify"], 165 + ); 166 + const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 167 + return { 168 + publicKey: keyPair.publicKey, 169 + privateKey: keyPair.privateKey, 170 + jwk, 171 + }; 172 + } 173 + 174 + async function getOrCreateDPoPKeyPair(): Promise<DPoPKeyPair> { 175 + const existing = await loadDPoPKeyPair(); 176 + if (existing) return existing; 177 + 178 + const keyPair = await generateDPoPKeyPair(); 179 + await storeDPoPKeyPair(keyPair); 180 + return keyPair; 181 + } 182 + 183 + async function createDPoPProof( 184 + keyPair: DPoPKeyPair, 185 + method: string, 186 + url: string, 187 + nonce?: string, 188 + accessTokenHash?: string, 189 + ): Promise<string> { 190 + const header = { 191 + typ: "dpop+jwt", 192 + alg: "ES256", 193 + jwk: { 194 + kty: keyPair.jwk.kty, 195 + crv: keyPair.jwk.crv, 196 + x: keyPair.jwk.x, 197 + y: keyPair.jwk.y, 198 + }, 199 + }; 200 + 201 + const payload: Record<string, unknown> = { 202 + jti: generateRandomString(16), 203 + htm: method.toUpperCase(), 204 + htu: url.split("?")[0], 205 + iat: Math.floor(Date.now() / 1000), 206 + }; 207 + 208 + if (nonce) { 209 + payload.nonce = nonce; 210 + } 211 + 212 + if (accessTokenHash) { 213 + payload.ath = accessTokenHash; 214 + } 215 + 216 + const headerB64 = base64UrlEncode( 217 + new TextEncoder().encode(JSON.stringify(header)).buffer as ArrayBuffer, 218 + ); 219 + const payloadB64 = base64UrlEncode( 220 + new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer, 221 + ); 222 + const signingInput = `${headerB64}.${payloadB64}`; 223 + 224 + const signature = await crypto.subtle.sign( 225 + { name: "ECDSA", hash: "SHA-256" }, 226 + keyPair.privateKey, 227 + new TextEncoder().encode(signingInput), 228 + ); 229 + 230 + const sigBytes = new Uint8Array(signature); 231 + const signatureB64 = base64UrlEncode(sigBytes.buffer); 232 + 233 + return `${signingInput}.${signatureB64}`; 234 + } 235 + 236 + async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> { 237 + const canonical = JSON.stringify({ 238 + crv: jwk.crv, 239 + kty: jwk.kty, 240 + x: jwk.x, 241 + y: jwk.y, 242 + }); 243 + const hash = await sha256(canonical); 244 + return base64UrlEncode(hash); 245 + } 246 + 247 + function getDPoPNonce(): string | null { 248 + return sessionStorage.getItem(DPOP_NONCE_KEY); 249 + } 250 + 251 + function setDPoPNonce(nonce: string): void { 252 + sessionStorage.setItem(DPOP_NONCE_KEY, nonce); 253 + } 254 + 255 + function extractDPoPNonceFromResponse(response: Response): void { 256 + const nonce = response.headers.get("DPoP-Nonce"); 257 + if (nonce) { 258 + setDPoPNonce(nonce); 259 + } 260 + } 261 + 262 export async function startOAuthLogin(): Promise<void> { 263 + clearAllOAuthState(); 264 + 265 const state = generateState(); 266 const codeVerifier = generateCodeVerifier(); 267 const codeChallenge = await generateCodeChallenge(codeVerifier); 268 + 269 + const keyPair = await getOrCreateDPoPKeyPair(); 270 + const dpopJkt = await computeJwkThumbprint(keyPair.jwk); 271 272 saveOAuthState({ state, codeVerifier }); 273 ··· 282 state: state, 283 code_challenge: codeChallenge, 284 code_challenge_method: "S256", 285 + dpop_jkt: dpopJkt, 286 }), 287 }); 288 ··· 313 sub: string; 314 } 315 316 + async function tokenRequest( 317 + params: URLSearchParams, 318 + retryWithNonce = true, 319 + ): Promise<OAuthTokens> { 320 + const keyPair = await getOrCreateDPoPKeyPair(); 321 + const tokenEndpoint = `${globalThis.location.origin}/oauth/token`; 322 + 323 + const dpopProof = await createDPoPProof( 324 + keyPair, 325 + "POST", 326 + tokenEndpoint, 327 + getDPoPNonce() ?? undefined, 328 + ); 329 + 330 + const response = await fetch("/oauth/token", { 331 + method: "POST", 332 + headers: { 333 + "Content-Type": "application/x-www-form-urlencoded", 334 + "DPoP": dpopProof, 335 + }, 336 + body: params, 337 + }); 338 + 339 + extractDPoPNonceFromResponse(response); 340 + 341 + if (!response.ok) { 342 + const error = await response.json().catch(() => ({ error: "Unknown error" })); 343 + 344 + if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) { 345 + return tokenRequest(params, false); 346 + } 347 + 348 + throw new Error( 349 + error.error_description || error.error || "Token request failed", 350 + ); 351 + } 352 + 353 + return response.json(); 354 + } 355 + 356 export async function handleOAuthCallback( 357 code: string, 358 state: string, ··· 367 throw new Error("OAuth state mismatch. Please try logging in again."); 368 } 369 370 + const params = new URLSearchParams({ 371 + grant_type: "authorization_code", 372 + client_id: CLIENT_ID, 373 + code: code, 374 + redirect_uri: REDIRECT_URI, 375 + code_verifier: savedState.codeVerifier, 376 }); 377 378 clearOAuthState(); 379 380 + return tokenRequest(params); 381 } 382 383 export async function refreshOAuthToken( 384 refreshToken: string, 385 ): Promise<OAuthTokens> { 386 + const params = new URLSearchParams({ 387 + grant_type: "refresh_token", 388 + client_id: CLIENT_ID, 389 + refresh_token: refreshToken, 390 }); 391 392 + return tokenRequest(params); 393 } 394 395 export function checkForOAuthCallback():
+22 -4
frontend/src/routes/Dashboard.svelte
··· 285 justify-content: space-between; 286 align-items: center; 287 margin-bottom: var(--space-7); 288 } 289 290 header h1 { 291 margin: 0; 292 } 293 294 .account-dropdown { 295 position: relative; 296 } 297 298 .account-trigger { ··· 305 border-radius: var(--radius-md); 306 cursor: pointer; 307 color: var(--text-primary); 308 } 309 310 .account-trigger:hover:not(:disabled) { ··· 314 .account-trigger:disabled { 315 opacity: 0.6; 316 cursor: not-allowed; 317 - } 318 - 319 - .account-trigger .account-handle { 320 - font-weight: var(--font-medium); 321 } 322 323 .dropdown-arrow { ··· 383 padding: var(--space-6); 384 border-radius: var(--radius-xl); 385 margin-bottom: var(--space-7); 386 } 387 388 section h2 { ··· 400 dt { 401 font-weight: var(--font-medium); 402 color: var(--text-secondary); 403 } 404 405 dd { 406 margin: 0; 407 } 408 409 .mono {
··· 285 justify-content: space-between; 286 align-items: center; 287 margin-bottom: var(--space-7); 288 + gap: var(--space-4); 289 + } 290 + 291 + @media (max-width: 500px) { 292 + header { 293 + flex-direction: column-reverse; 294 + align-items: flex-start; 295 + } 296 } 297 298 header h1 { 299 margin: 0; 300 + min-width: 0; 301 } 302 303 .account-dropdown { 304 position: relative; 305 + max-width: 100%; 306 } 307 308 .account-trigger { ··· 315 border-radius: var(--radius-md); 316 cursor: pointer; 317 color: var(--text-primary); 318 + max-width: 100%; 319 + } 320 + 321 + .account-trigger .account-handle { 322 + font-weight: var(--font-medium); 323 + overflow: hidden; 324 + text-overflow: ellipsis; 325 + white-space: nowrap; 326 } 327 328 .account-trigger:hover:not(:disabled) { ··· 332 .account-trigger:disabled { 333 opacity: 0.6; 334 cursor: not-allowed; 335 } 336 337 .dropdown-arrow { ··· 397 padding: var(--space-6); 398 border-radius: var(--radius-xl); 399 margin-bottom: var(--space-7); 400 + overflow: hidden; 401 + min-width: 0; 402 } 403 404 section h2 { ··· 416 dt { 417 font-weight: var(--font-medium); 418 color: var(--text-secondary); 419 + max-width: 6rem; 420 } 421 422 dd { 423 margin: 0; 424 + min-width: 0; 425 } 426 427 .mono {
+9 -12
frontend/src/routes/Login.svelte
··· 6 getAuthState, 7 switchAccount, 8 forgetAccount, 9 matchAuthState, 10 type SavedAccount, 11 type AuthError, ··· 14 import { _ } from '../lib/i18n' 15 import { isOk, isErr } from '../lib/types/result' 16 import { unsafeAsDid, type Did } from '../lib/types/branded' 17 18 type PageState = 19 | { kind: 'login' } ··· 32 return auth.savedAccounts 33 } 34 35 - function getErrorMessage(): string | null { 36 - if (auth.kind === 'error') { 37 - return auth.error.message 38 - } 39 - return null 40 - } 41 - 42 function isLoading(): boolean { 43 return auth.kind === 'loading' 44 } 45 46 $effect(() => { 47 const accounts = getSavedAccounts() ··· 108 resendMessage = null 109 } 110 111 - const errorMessage = $derived(getErrorMessage()) 112 const savedAccounts = $derived(getSavedAccounts()) 113 const loading = $derived(isLoading()) 114 </script> 115 116 <div class="login-page"> 117 - {#if errorMessage} 118 - <div class="message error">{errorMessage}</div> 119 - {/if} 120 - 121 {#if pageState.kind === 'verification'} 122 <header class="page-header"> 123 <h1>{$_('verification.title')}</h1>
··· 6 getAuthState, 7 switchAccount, 8 forgetAccount, 9 + clearError, 10 matchAuthState, 11 type SavedAccount, 12 type AuthError, ··· 15 import { _ } from '../lib/i18n' 16 import { isOk, isErr } from '../lib/types/result' 17 import { unsafeAsDid, type Did } from '../lib/types/branded' 18 + import { toast } from '../lib/toast.svelte' 19 20 type PageState = 21 | { kind: 'login' } ··· 34 return auth.savedAccounts 35 } 36 37 function isLoading(): boolean { 38 return auth.kind === 'loading' 39 } 40 + 41 + $effect(() => { 42 + if (auth.kind === 'error') { 43 + toast.error(auth.error.message) 44 + clearError() 45 + } 46 + }) 47 48 $effect(() => { 49 const accounts = getSavedAccounts() ··· 110 resendMessage = null 111 } 112 113 const savedAccounts = $derived(getSavedAccounts()) 114 const loading = $derived(isLoading()) 115 </script> 116 117 <div class="login-page"> 118 {#if pageState.kind === 'verification'} 119 <header class="page-header"> 120 <h1>{$_('verification.title')}</h1>
+8
frontend/src/styles/base.css
··· 29 -webkit-font-smoothing: antialiased; 30 -moz-osx-font-smoothing: grayscale; 31 transition: background-color 0.3s ease; 32 } 33 34 h1, h2, h3, h4, h5, h6 { ··· 336 border: 1px solid var(--border-color); 337 border-radius: var(--radius-xl); 338 padding: var(--space-6); 339 } 340 341 .section { 342 background: var(--bg-secondary); 343 border-radius: var(--radius-xl); 344 padding: var(--space-6); 345 } 346 347 .section + .section { ··· 469 border-radius: var(--radius-xl); 470 padding: var(--space-6); 471 height: fit-content; 472 } 473 474 .info-panel h3 {
··· 29 -webkit-font-smoothing: antialiased; 30 -moz-osx-font-smoothing: grayscale; 31 transition: background-color 0.3s ease; 32 + overflow-wrap: anywhere; 33 + word-break: break-word; 34 } 35 36 h1, h2, h3, h4, h5, h6 { ··· 338 border: 1px solid var(--border-color); 339 border-radius: var(--radius-xl); 340 padding: var(--space-6); 341 + overflow: hidden; 342 + min-width: 0; 343 } 344 345 .section { 346 background: var(--bg-secondary); 347 border-radius: var(--radius-xl); 348 padding: var(--space-6); 349 + overflow: hidden; 350 + min-width: 0; 351 } 352 353 .section + .section { ··· 475 border-radius: var(--radius-xl); 476 padding: var(--space-6); 477 height: fit-content; 478 + overflow: hidden; 479 + min-width: 0; 480 } 481 482 .info-panel h3 {
+13 -15
frontend/src/tests/AppPasswords.test.ts
··· 4 import { 5 clearMocks, 6 errorResponse, 7 jsonResponse, 8 mockData, 9 mockEndpoint, ··· 51 beforeEach(() => { 52 setupAuthenticatedUser(); 53 }); 54 - it("shows loading text while fetching passwords", () => { 55 mockEndpoint( 56 "com.atproto.server.listAppPasswords", 57 () => ··· 59 setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100) 60 ), 61 ); 62 - render(AppPasswords); 63 - expect(screen.getByText(/loading/i)).toBeInTheDocument(); 64 }); 65 }); 66 describe("empty state", () => { ··· 236 .toBeInTheDocument(); 237 }); 238 }); 239 - it("shows error when creation fails", async () => { 240 mockEndpoint( 241 "com.atproto.server.createAppPassword", 242 () => errorResponse("InvalidRequest", "Name already exists", 400), ··· 250 }); 251 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 252 await waitFor(() => { 253 - expect(screen.getByText(/name already exists/i)).toBeInTheDocument(); 254 - expect(screen.getByText(/name already exists/i)).toHaveClass("error"); 255 }); 256 }); 257 }); ··· 358 expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument(); 359 }); 360 }); 361 - it("shows error when revocation fails", async () => { 362 globalThis.confirm = vi.fn(() => true); 363 mockEndpoint( 364 "com.atproto.server.listAppPasswords", ··· 374 }); 375 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 376 await waitFor(() => { 377 - expect(screen.getByText(/server error/i)).toBeInTheDocument(); 378 - expect(screen.getByText(/server error/i)).toHaveClass("error"); 379 }); 380 }); 381 }); ··· 383 beforeEach(() => { 384 setupAuthenticatedUser(); 385 }); 386 - it("shows error when loading passwords fails", async () => { 387 mockEndpoint( 388 "com.atproto.server.listAppPasswords", 389 () => errorResponse("InternalError", "Database connection failed", 500), 390 ); 391 render(AppPasswords); 392 await waitFor(() => { 393 - expect(screen.getByText(/database connection failed/i)) 394 - .toBeInTheDocument(); 395 - expect(screen.getByText(/database connection failed/i)).toHaveClass( 396 - "error", 397 - ); 398 }); 399 }); 400 });
··· 4 import { 5 clearMocks, 6 errorResponse, 7 + getErrorToasts, 8 jsonResponse, 9 mockData, 10 mockEndpoint, ··· 52 beforeEach(() => { 53 setupAuthenticatedUser(); 54 }); 55 + it("shows loading skeleton while fetching passwords", () => { 56 mockEndpoint( 57 "com.atproto.server.listAppPasswords", 58 () => ··· 60 setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100) 61 ), 62 ); 63 + const { container } = render(AppPasswords); 64 + expect(container.querySelectorAll(".skeleton-item").length).toBeGreaterThan(0); 65 }); 66 }); 67 describe("empty state", () => { ··· 237 .toBeInTheDocument(); 238 }); 239 }); 240 + it("shows error toast when creation fails", async () => { 241 mockEndpoint( 242 "com.atproto.server.createAppPassword", 243 () => errorResponse("InvalidRequest", "Name already exists", 400), ··· 251 }); 252 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 253 await waitFor(() => { 254 + const errors = getErrorToasts(); 255 + expect(errors.some((e) => /name already exists/i.test(e))).toBe(true); 256 }); 257 }); 258 }); ··· 359 expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument(); 360 }); 361 }); 362 + it("shows error toast when revocation fails", async () => { 363 globalThis.confirm = vi.fn(() => true); 364 mockEndpoint( 365 "com.atproto.server.listAppPasswords", ··· 375 }); 376 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 377 await waitFor(() => { 378 + const errors = getErrorToasts(); 379 + expect(errors.some((e) => /server error/i.test(e))).toBe(true); 380 }); 381 }); 382 }); ··· 384 beforeEach(() => { 385 setupAuthenticatedUser(); 386 }); 387 + it("shows error toast when loading passwords fails", async () => { 388 mockEndpoint( 389 "com.atproto.server.listAppPasswords", 390 () => errorResponse("InternalError", "Database connection failed", 500), 391 ); 392 render(AppPasswords); 393 await waitFor(() => { 394 + const errors = getErrorToasts(); 395 + expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true); 396 }); 397 }); 398 });
+14 -17
frontend/src/tests/Comms.test.ts
··· 4 import { 5 clearMocks, 6 errorResponse, 7 jsonResponse, 8 mockData, 9 mockEndpoint, ··· 71 () => jsonResponse({ notifications: [] }), 72 ); 73 }); 74 - it("shows loading text while fetching preferences", () => { 75 mockEndpoint( 76 "_account.getNotificationPrefs", 77 () => ··· 82 ) 83 ), 84 ); 85 - render(Comms); 86 - expect(screen.getByText(/loading/i)).toBeInTheDocument(); 87 }); 88 }); 89 describe("channel options", () => { ··· 354 .toBeInTheDocument(); 355 expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled(); 356 }); 357 - it("shows success message after saving", async () => { 358 mockEndpoint( 359 "_account.getNotificationPrefs", 360 () => jsonResponse(mockData.notificationPrefs()), ··· 372 screen.getByRole("button", { name: /save preferences/i }), 373 ); 374 await waitFor(() => { 375 - expect(screen.getByText(/preferences saved/i)) 376 - .toBeInTheDocument(); 377 }); 378 }); 379 - it("shows error when save fails", async () => { 380 mockEndpoint( 381 "_account.getNotificationPrefs", 382 () => jsonResponse(mockData.notificationPrefs()), ··· 395 screen.getByRole("button", { name: /save preferences/i }), 396 ); 397 await waitFor(() => { 398 - expect(screen.getByText(/invalid channel configuration/i)) 399 - .toBeInTheDocument(); 400 - expect( 401 - screen.getByText(/invalid channel configuration/i).closest( 402 - ".message", 403 - ), 404 - ).toHaveClass("error"); 405 }); 406 }); 407 it("reloads preferences after successful save", async () => { ··· 490 () => jsonResponse({ notifications: [] }), 491 ); 492 }); 493 - it("shows error when loading preferences fails", async () => { 494 mockEndpoint( 495 "_account.getNotificationPrefs", 496 () => errorResponse("InternalError", "Database connection failed", 500), 497 ); 498 render(Comms); 499 await waitFor(() => { 500 - expect(screen.getByText(/database connection failed/i)) 501 - .toBeInTheDocument(); 502 }); 503 }); 504 });
··· 4 import { 5 clearMocks, 6 errorResponse, 7 + getErrorToasts, 8 + getToasts, 9 jsonResponse, 10 mockData, 11 mockEndpoint, ··· 73 () => jsonResponse({ notifications: [] }), 74 ); 75 }); 76 + it("shows loading skeleton while fetching preferences", () => { 77 mockEndpoint( 78 "_account.getNotificationPrefs", 79 () => ··· 84 ) 85 ), 86 ); 87 + const { container } = render(Comms); 88 + expect(container.querySelectorAll(".skeleton-section").length).toBeGreaterThan(0); 89 }); 90 }); 91 describe("channel options", () => { ··· 356 .toBeInTheDocument(); 357 expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled(); 358 }); 359 + it("shows success toast after saving", async () => { 360 mockEndpoint( 361 "_account.getNotificationPrefs", 362 () => jsonResponse(mockData.notificationPrefs()), ··· 374 screen.getByRole("button", { name: /save preferences/i }), 375 ); 376 await waitFor(() => { 377 + const toasts = getToasts(); 378 + expect(toasts.some((t) => t.type === "success" && /saved/i.test(t.message))).toBe(true); 379 }); 380 }); 381 + it("shows error toast when save fails", async () => { 382 mockEndpoint( 383 "_account.getNotificationPrefs", 384 () => jsonResponse(mockData.notificationPrefs()), ··· 397 screen.getByRole("button", { name: /save preferences/i }), 398 ); 399 await waitFor(() => { 400 + const errors = getErrorToasts(); 401 + expect(errors.some((e) => /invalid channel configuration/i.test(e))).toBe(true); 402 }); 403 }); 404 it("reloads preferences after successful save", async () => { ··· 487 () => jsonResponse({ notifications: [] }), 488 ); 489 }); 490 + it("shows error toast when loading preferences fails", async () => { 491 mockEndpoint( 492 "_account.getNotificationPrefs", 493 () => errorResponse("InternalError", "Database connection failed", 500), 494 ); 495 render(Comms); 496 await waitFor(() => { 497 + const errors = getErrorToasts(); 498 + expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true); 499 }); 500 }); 501 });
+3 -2
frontend/src/tests/Dashboard.test.ts
··· 25 }); 26 }); 27 it("shows loading state while checking auth", () => { 28 - render(Dashboard); 29 - expect(screen.getByText(/loading/i)).toBeInTheDocument(); 30 }); 31 }); 32 describe("authenticated view", () => {
··· 25 }); 26 }); 27 it("shows loading state while checking auth", () => { 28 + const { container } = render(Dashboard); 29 + expect(container.querySelector(".skeleton-section")).toBeInTheDocument(); 30 + expect(container.querySelectorAll(".skeleton-card").length).toBeGreaterThan(0); 31 }); 32 }); 33 describe("authenticated view", () => {
+7 -3
frontend/src/tests/Login.test.ts
··· 15 unsafeAsHandle, 16 unsafeAsRefreshToken, 17 } from "../lib/types/branded.ts"; 18 19 describe("Login", () => { 20 beforeEach(() => { ··· 147 }); 148 149 describe("error handling", () => { 150 - it("displays error message when auth state has error", async () => { 151 _testSetState({ 152 session: null, 153 loading: false, ··· 156 }); 157 render(Login); 158 await waitFor(() => { 159 - expect(screen.getByText(/oauth login failed/i)).toBeInTheDocument(); 160 - expect(screen.getByText(/oauth login failed/i)).toHaveClass("error"); 161 }); 162 }); 163 });
··· 15 unsafeAsHandle, 16 unsafeAsRefreshToken, 17 } from "../lib/types/branded.ts"; 18 + import { getToasts } from "../lib/toast.svelte.ts"; 19 20 describe("Login", () => { 21 beforeEach(() => { ··· 148 }); 149 150 describe("error handling", () => { 151 + it("displays error message as toast when auth state has error", async () => { 152 _testSetState({ 153 session: null, 154 loading: false, ··· 157 }); 158 render(Login); 159 await waitFor(() => { 160 + const toasts = getToasts(); 161 + const errorToast = toasts.find( 162 + (t) => t.type === "error" && t.message.includes("OAuth login failed"), 163 + ); 164 + expect(errorToast).toBeDefined(); 165 }); 166 }); 167 });
+17 -15
frontend/src/tests/Settings.test.ts
··· 4 import { 5 clearMocks, 6 errorResponse, 7 jsonResponse, 8 mockData, 9 mockEndpoint, ··· 140 expect(capturedBody?.token).toBe("123456"); 141 }); 142 }); 143 - it("shows success message after email update", async () => { 144 mockEndpoint( 145 "com.atproto.server.requestEmailUpdate", 146 () => jsonResponse({ tokenRequired: true }), ··· 171 screen.getByRole("button", { name: /confirm email change/i }), 172 ); 173 await waitFor(() => { 174 - expect(screen.getByText(/email updated/i)) 175 - .toBeInTheDocument(); 176 }); 177 }); 178 it("shows cancel button to return to initial state", async () => { ··· 205 .toBeInTheDocument(); 206 }); 207 }); 208 - it("shows error when request fails", async () => { 209 mockEndpoint( 210 "com.atproto.server.requestEmailUpdate", 211 () => errorResponse("InvalidEmail", "Invalid email format", 400), ··· 219 screen.getByRole("button", { name: /change email/i }), 220 ); 221 await waitFor(() => { 222 - expect(screen.getByText(/invalid email format/i)).toBeInTheDocument(); 223 }); 224 }); 225 }); ··· 261 expect(screen.getByRole("button", { name: /change handle/i })) 262 .toBeInTheDocument(); 263 }); 264 - it("shows success message after handle change", async () => { 265 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 266 mockEndpoint( 267 "com.atproto.server.getSession", ··· 279 const button = screen.getByRole("button", { name: /change handle/i }); 280 await fireEvent.submit(button.closest("form")!); 281 await waitFor(() => { 282 - expect(screen.getByText(/handle updated/i)) 283 - .toBeInTheDocument(); 284 }); 285 }); 286 - it("shows error when handle change fails", async () => { 287 mockEndpoint( 288 "com.atproto.identity.updateHandle", 289 () => ··· 302 const button = screen.getByRole("button", { name: /change handle/i }); 303 await fireEvent.submit(button.closest("form")!); 304 await waitFor(() => { 305 - const errorMessage = screen.queryByText(/handle is already taken/i) || 306 - screen.queryByText(/handle update failed/i); 307 - expect(errorMessage).toBeInTheDocument(); 308 }); 309 }); 310 }); ··· 500 ).toBeInTheDocument(); 501 }); 502 }); 503 - it("shows error when deletion fails", async () => { 504 globalThis.confirm = vi.fn(() => true); 505 mockEndpoint( 506 "com.atproto.server.requestAccountDelete", ··· 532 screen.getByRole("button", { name: /permanently delete account/i }), 533 ); 534 await waitFor(() => { 535 - expect(screen.getByText(/invalid confirmation code/i)) 536 - .toBeInTheDocument(); 537 }); 538 }); 539 });
··· 4 import { 5 clearMocks, 6 errorResponse, 7 + getErrorToasts, 8 + getToasts, 9 jsonResponse, 10 mockData, 11 mockEndpoint, ··· 142 expect(capturedBody?.token).toBe("123456"); 143 }); 144 }); 145 + it("shows success toast after email update", async () => { 146 mockEndpoint( 147 "com.atproto.server.requestEmailUpdate", 148 () => jsonResponse({ tokenRequired: true }), ··· 173 screen.getByRole("button", { name: /confirm email change/i }), 174 ); 175 await waitFor(() => { 176 + const toasts = getToasts(); 177 + expect(toasts.some((t) => t.type === "success" && /email.*updated/i.test(t.message))).toBe(true); 178 }); 179 }); 180 it("shows cancel button to return to initial state", async () => { ··· 207 .toBeInTheDocument(); 208 }); 209 }); 210 + it("shows error toast when request fails", async () => { 211 mockEndpoint( 212 "com.atproto.server.requestEmailUpdate", 213 () => errorResponse("InvalidEmail", "Invalid email format", 400), ··· 221 screen.getByRole("button", { name: /change email/i }), 222 ); 223 await waitFor(() => { 224 + const errors = getErrorToasts(); 225 + expect(errors.some((e) => /invalid email format/i.test(e))).toBe(true); 226 }); 227 }); 228 }); ··· 264 expect(screen.getByRole("button", { name: /change handle/i })) 265 .toBeInTheDocument(); 266 }); 267 + it("shows success toast after handle change", async () => { 268 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 269 mockEndpoint( 270 "com.atproto.server.getSession", ··· 282 const button = screen.getByRole("button", { name: /change handle/i }); 283 await fireEvent.submit(button.closest("form")!); 284 await waitFor(() => { 285 + const toasts = getToasts(); 286 + expect(toasts.some((t) => t.type === "success" && /handle.*updated/i.test(t.message))).toBe(true); 287 }); 288 }); 289 + it("shows error toast when handle change fails", async () => { 290 mockEndpoint( 291 "com.atproto.identity.updateHandle", 292 () => ··· 305 const button = screen.getByRole("button", { name: /change handle/i }); 306 await fireEvent.submit(button.closest("form")!); 307 await waitFor(() => { 308 + const errors = getErrorToasts(); 309 + expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(true); 310 }); 311 }); 312 }); ··· 502 ).toBeInTheDocument(); 503 }); 504 }); 505 + it("shows error toast when deletion fails", async () => { 506 globalThis.confirm = vi.fn(() => true); 507 mockEndpoint( 508 "com.atproto.server.requestAccountDelete", ··· 534 screen.getByRole("button", { name: /permanently delete account/i }), 535 ); 536 await waitFor(() => { 537 + const errors = getErrorToasts(); 538 + expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(true); 539 }); 540 }); 541 });
+12 -1
frontend/src/tests/mocks.ts
··· 1 import { vi } from "vitest"; 2 import type { AppPassword, InviteCode, Session } from "../lib/api.ts"; 3 - import { _testSetState } from "../lib/auth.svelte.ts"; 4 import { 5 unsafeAsAccessToken, 6 unsafeAsDid, ··· 70 } 71 export function clearMocks(): void { 72 mockHandlers.clear(); 73 } 74 function extractEndpoint(url: string): string { 75 const match = url.match(/\/xrpc\/([^?]+)/); 76 return match ? match[1] : url;
··· 1 import { vi } from "vitest"; 2 import type { AppPassword, InviteCode, Session } from "../lib/api.ts"; 3 + import { _testSetState, _testResetState } from "../lib/auth.svelte.ts"; 4 + import { toast, clearAllToasts, getToasts } from "../lib/toast.svelte.ts"; 5 import { 6 unsafeAsAccessToken, 7 unsafeAsDid, ··· 71 } 72 export function clearMocks(): void { 73 mockHandlers.clear(); 74 + _testResetState(); 75 + clearAllToasts(); 76 } 77 + 78 + export function getErrorToasts(): string[] { 79 + return getToasts() 80 + .filter((t) => t.type === "error") 81 + .map((t) => t.message); 82 + } 83 + 84 + export { toast, getToasts }; 85 function extractEndpoint(url: string): string { 86 const match = url.match(/\/xrpc\/([^?]+)/); 87 return match ? match[1] : url;
+232
frontend/src/tests/oauth.test.ts
···
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { 3 + generateCodeChallenge, 4 + generateCodeVerifier, 5 + generateState, 6 + saveOAuthState, 7 + checkForOAuthCallback, 8 + clearOAuthCallbackParams, 9 + } from "../lib/oauth"; 10 + 11 + describe("OAuth utilities", () => { 12 + beforeEach(() => { 13 + sessionStorage.clear(); 14 + vi.restoreAllMocks(); 15 + }); 16 + 17 + describe("generateState", () => { 18 + it("generates a 64-character hex string", () => { 19 + const state = generateState(); 20 + expect(state).toMatch(/^[0-9a-f]{64}$/); 21 + }); 22 + 23 + it("generates unique values", () => { 24 + const states = new Set(Array.from({ length: 100 }, () => generateState())); 25 + expect(states.size).toBe(100); 26 + }); 27 + }); 28 + 29 + describe("generateCodeVerifier", () => { 30 + it("generates a 64-character hex string", () => { 31 + const verifier = generateCodeVerifier(); 32 + expect(verifier).toMatch(/^[0-9a-f]{64}$/); 33 + }); 34 + 35 + it("generates unique values", () => { 36 + const verifiers = new Set( 37 + Array.from({ length: 100 }, () => generateCodeVerifier()), 38 + ); 39 + expect(verifiers.size).toBe(100); 40 + }); 41 + }); 42 + 43 + describe("generateCodeChallenge", () => { 44 + it("generates a base64url-encoded SHA-256 hash", async () => { 45 + const verifier = "test-verifier-12345"; 46 + const challenge = await generateCodeChallenge(verifier); 47 + 48 + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); 49 + expect(challenge).not.toContain("+"); 50 + expect(challenge).not.toContain("/"); 51 + expect(challenge).not.toContain("="); 52 + }); 53 + 54 + it("produces consistent output for same input", async () => { 55 + const verifier = "consistent-test-verifier"; 56 + const challenge1 = await generateCodeChallenge(verifier); 57 + const challenge2 = await generateCodeChallenge(verifier); 58 + 59 + expect(challenge1).toBe(challenge2); 60 + }); 61 + 62 + it("produces different output for different inputs", async () => { 63 + const challenge1 = await generateCodeChallenge("verifier-1"); 64 + const challenge2 = await generateCodeChallenge("verifier-2"); 65 + 66 + expect(challenge1).not.toBe(challenge2); 67 + }); 68 + 69 + it("produces correct S256 challenge", async () => { 70 + const challenge = await generateCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); 71 + expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); 72 + }); 73 + }); 74 + 75 + describe("saveOAuthState", () => { 76 + it("stores state and verifier in sessionStorage", () => { 77 + saveOAuthState({ state: "test-state", codeVerifier: "test-verifier" }); 78 + 79 + expect(sessionStorage.getItem("tranquil_pds_oauth_state")).toBe( 80 + "test-state", 81 + ); 82 + expect(sessionStorage.getItem("tranquil_pds_oauth_verifier")).toBe( 83 + "test-verifier", 84 + ); 85 + }); 86 + }); 87 + 88 + describe("checkForOAuthCallback", () => { 89 + it("returns null when no code/state in URL", () => { 90 + Object.defineProperty(globalThis.location, "search", { 91 + value: "", 92 + writable: true, 93 + configurable: true, 94 + }); 95 + Object.defineProperty(globalThis.location, "pathname", { 96 + value: "/app/", 97 + writable: true, 98 + configurable: true, 99 + }); 100 + 101 + expect(checkForOAuthCallback()).toBeNull(); 102 + }); 103 + 104 + it("returns code and state when present in URL", () => { 105 + Object.defineProperty(globalThis.location, "search", { 106 + value: "?code=auth-code-123&state=state-456", 107 + writable: true, 108 + configurable: true, 109 + }); 110 + Object.defineProperty(globalThis.location, "pathname", { 111 + value: "/app/", 112 + writable: true, 113 + configurable: true, 114 + }); 115 + 116 + const result = checkForOAuthCallback(); 117 + expect(result).toEqual({ code: "auth-code-123", state: "state-456" }); 118 + }); 119 + 120 + it("returns null on migrate path even with code/state", () => { 121 + Object.defineProperty(globalThis.location, "search", { 122 + value: "?code=auth-code-123&state=state-456", 123 + writable: true, 124 + configurable: true, 125 + }); 126 + Object.defineProperty(globalThis.location, "pathname", { 127 + value: "/app/migrate", 128 + writable: true, 129 + configurable: true, 130 + }); 131 + 132 + expect(checkForOAuthCallback()).toBeNull(); 133 + }); 134 + 135 + it("returns null when only code is present", () => { 136 + Object.defineProperty(globalThis.location, "search", { 137 + value: "?code=auth-code-123", 138 + writable: true, 139 + configurable: true, 140 + }); 141 + Object.defineProperty(globalThis.location, "pathname", { 142 + value: "/app/", 143 + writable: true, 144 + configurable: true, 145 + }); 146 + 147 + expect(checkForOAuthCallback()).toBeNull(); 148 + }); 149 + 150 + it("returns null when only state is present", () => { 151 + Object.defineProperty(globalThis.location, "search", { 152 + value: "?state=state-456", 153 + writable: true, 154 + configurable: true, 155 + }); 156 + Object.defineProperty(globalThis.location, "pathname", { 157 + value: "/app/", 158 + writable: true, 159 + configurable: true, 160 + }); 161 + 162 + expect(checkForOAuthCallback()).toBeNull(); 163 + }); 164 + }); 165 + 166 + describe("clearOAuthCallbackParams", () => { 167 + it("removes query params from URL", () => { 168 + const replaceStateSpy = vi.spyOn(globalThis.history, "replaceState"); 169 + 170 + Object.defineProperty(globalThis.location, "href", { 171 + value: "http://localhost:3000/app/?code=123&state=456", 172 + writable: true, 173 + configurable: true, 174 + }); 175 + 176 + clearOAuthCallbackParams(); 177 + 178 + expect(replaceStateSpy).toHaveBeenCalled(); 179 + const callArgs = replaceStateSpy.mock.calls[0]; 180 + expect(callArgs[0]).toEqual({}); 181 + expect(callArgs[1]).toBe(""); 182 + const urlString = callArgs[2] as string; 183 + expect(urlString).toBe("http://localhost:3000/app/"); 184 + expect(urlString).not.toContain("?"); 185 + expect(urlString).not.toContain("code="); 186 + expect(urlString).not.toContain("state="); 187 + }); 188 + }); 189 + }); 190 + 191 + describe("DPoP proof generation", () => { 192 + it("base64url encoding produces valid output", async () => { 193 + const testData = new Uint8Array([72, 101, 108, 108, 111]); 194 + const buffer = testData.buffer; 195 + 196 + const binary = Array.from(testData, (byte) => String.fromCharCode(byte)).join(""); 197 + const base64url = btoa(binary) 198 + .replace(/\+/g, "-") 199 + .replace(/\//g, "_") 200 + .replace(/=+$/, ""); 201 + 202 + expect(base64url).toBe("SGVsbG8"); 203 + expect(base64url).not.toContain("+"); 204 + expect(base64url).not.toContain("/"); 205 + expect(base64url).not.toContain("="); 206 + }); 207 + 208 + it("JWK thumbprint uses correct key ordering for EC keys", () => { 209 + const jwk = { 210 + kty: "EC", 211 + crv: "P-256", 212 + x: "test-x", 213 + y: "test-y", 214 + }; 215 + 216 + const canonical = JSON.stringify({ 217 + crv: jwk.crv, 218 + kty: jwk.kty, 219 + x: jwk.x, 220 + y: jwk.y, 221 + }); 222 + 223 + expect(canonical).toBe('{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}'); 224 + 225 + const keys = Object.keys(JSON.parse(canonical)); 226 + expect(keys).toEqual(["crv", "kty", "x", "y"]); 227 + 228 + for (let i = 1; i < keys.length; i++) { 229 + expect(keys[i - 1] < keys[i]).toBe(true); 230 + } 231 + }); 232 + });