Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

feat: add secure public OAuth with DPoP and quickslice-client-js SDK

Server changes:
- Add DPoP (Demonstration of Proof-of-Possession) token binding
- Add JTI replay protection for DPoP proofs
- Add DPoP validation middleware for resource endpoints
- Update token endpoint to validate and bind DPoP proofs

Client SDK (quickslice-client-js):
- High-level auth0-spa-js style API for browser SPAs
- OAuth PKCE flow with DPoP proof generation
- Non-extractable P-256 keys stored in IndexedDB
- Multi-tab token refresh coordination
- GraphQL query/mutation helpers with automatic DPoP auth
- Available via jsDelivr CDN or npm

Example updates:
- Refactor statusphere example to use quickslice-client-js
- Reduce example from ~1300 lines to ~800 lines

+7176 -690
+1552
dev-docs/plans/2025-12-07-quickslice-client-js.md
··· 1 + # quickslice-client-js Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Extract auth code from statusphere example into a reusable client SDK that can be loaded via jsDelivr CDN. 6 + 7 + **Architecture:** High-level client SDK similar to auth0-spa-js. Single `QuicksliceClient` class wraps OAuth PKCE + DPoP authentication and GraphQL requests. All complexity (key management, token refresh, multi-tab coordination) hidden behind simple methods. 8 + 9 + **Tech Stack:** TypeScript, esbuild (UMD + ESM builds), Web Crypto API, IndexedDB, localStorage/sessionStorage 10 + 11 + --- 12 + 13 + ## Task 1: Project Scaffolding 14 + 15 + **Files:** 16 + - Create: `quickslice-client-js/package.json` 17 + - Create: `quickslice-client-js/tsconfig.json` 18 + - Create: `quickslice-client-js/build.mjs` 19 + - Create: `quickslice-client-js/.gitignore` 20 + 21 + **Step 1: Create package.json** 22 + 23 + ```json 24 + { 25 + "name": "quickslice-client-js", 26 + "version": "0.1.0", 27 + "description": "Quickslice client SDK for browser SPAs", 28 + "main": "dist/quickslice-client.js", 29 + "module": "dist/quickslice-client.esm.js", 30 + "types": "dist/index.d.ts", 31 + "files": [ 32 + "dist" 33 + ], 34 + "scripts": { 35 + "build": "node build.mjs", 36 + "watch": "node build.mjs --watch" 37 + }, 38 + "devDependencies": { 39 + "esbuild": "^0.24.0", 40 + "typescript": "^5.3.0" 41 + }, 42 + "keywords": [ 43 + "quickslice", 44 + "oauth", 45 + "dpop", 46 + "atproto" 47 + ], 48 + "license": "MIT" 49 + } 50 + ``` 51 + 52 + **Step 2: Create tsconfig.json** 53 + 54 + ```json 55 + { 56 + "compilerOptions": { 57 + "target": "ES2020", 58 + "module": "ESNext", 59 + "moduleResolution": "bundler", 60 + "lib": ["ES2020", "DOM"], 61 + "strict": true, 62 + "declaration": true, 63 + "declarationDir": "dist", 64 + "emitDeclarationOnly": true, 65 + "outDir": "dist", 66 + "rootDir": "src", 67 + "skipLibCheck": true, 68 + "esModuleInterop": true 69 + }, 70 + "include": ["src/**/*"], 71 + "exclude": ["node_modules", "dist"] 72 + } 73 + ``` 74 + 75 + **Step 3: Create build.mjs** 76 + 77 + ```javascript 78 + import * as esbuild from 'esbuild'; 79 + 80 + const watch = process.argv.includes('--watch'); 81 + 82 + const sharedConfig = { 83 + entryPoints: ['src/index.ts'], 84 + bundle: true, 85 + sourcemap: true, 86 + target: ['es2020'], 87 + }; 88 + 89 + // UMD build (for CDN/script tag) 90 + const umdBuild = { 91 + ...sharedConfig, 92 + outfile: 'dist/quickslice-client.js', 93 + format: 'iife', 94 + globalName: 'QuicksliceClient', 95 + }; 96 + 97 + // UMD minified 98 + const umdMinBuild = { 99 + ...sharedConfig, 100 + outfile: 'dist/quickslice-client.min.js', 101 + format: 'iife', 102 + globalName: 'QuicksliceClient', 103 + minify: true, 104 + sourcemap: false, 105 + }; 106 + 107 + // ESM build (for bundlers) 108 + const esmBuild = { 109 + ...sharedConfig, 110 + outfile: 'dist/quickslice-client.esm.js', 111 + format: 'esm', 112 + }; 113 + 114 + async function build() { 115 + if (watch) { 116 + const ctx = await esbuild.context(umdBuild); 117 + await ctx.watch(); 118 + console.log('Watching for changes...'); 119 + } else { 120 + await Promise.all([ 121 + esbuild.build(umdBuild), 122 + esbuild.build(umdMinBuild), 123 + esbuild.build(esmBuild), 124 + ]); 125 + console.log('Build complete!'); 126 + } 127 + } 128 + 129 + build().catch((err) => { 130 + console.error(err); 131 + process.exit(1); 132 + }); 133 + ``` 134 + 135 + **Step 4: Create .gitignore** 136 + 137 + ``` 138 + node_modules/ 139 + ``` 140 + 141 + Note: We intentionally do NOT ignore `dist/` since we commit built files for jsDelivr. 142 + 143 + **Step 5: Install dependencies** 144 + 145 + Run: `cd quickslice-client-js && npm install` 146 + 147 + **Step 6: Commit** 148 + 149 + ```bash 150 + git add quickslice-client-js/ 151 + git commit -m "chore: scaffold quickslice-client-js package" 152 + ``` 153 + 154 + --- 155 + 156 + ## Task 2: Base64 URL and Crypto Utilities 157 + 158 + **Files:** 159 + - Create: `quickslice-client-js/src/utils/base64url.ts` 160 + - Create: `quickslice-client-js/src/utils/crypto.ts` 161 + 162 + **Step 1: Create base64url.ts** 163 + 164 + ```typescript 165 + /** 166 + * Base64 URL encode a buffer (Uint8Array or ArrayBuffer) 167 + */ 168 + export function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string { 169 + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); 170 + let binary = ''; 171 + for (let i = 0; i < bytes.length; i++) { 172 + binary += String.fromCharCode(bytes[i]); 173 + } 174 + return btoa(binary) 175 + .replace(/\+/g, '-') 176 + .replace(/\//g, '_') 177 + .replace(/=+$/, ''); 178 + } 179 + 180 + /** 181 + * Generate a random base64url string 182 + */ 183 + export function generateRandomString(byteLength: number): string { 184 + const bytes = new Uint8Array(byteLength); 185 + crypto.getRandomValues(bytes); 186 + return base64UrlEncode(bytes); 187 + } 188 + ``` 189 + 190 + **Step 2: Create crypto.ts** 191 + 192 + ```typescript 193 + import { base64UrlEncode } from './base64url'; 194 + 195 + /** 196 + * SHA-256 hash, returned as base64url string 197 + */ 198 + export async function sha256Base64Url(data: string): Promise<string> { 199 + const encoder = new TextEncoder(); 200 + const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data)); 201 + return base64UrlEncode(hash); 202 + } 203 + 204 + /** 205 + * Sign a JWT with an ECDSA P-256 private key 206 + */ 207 + export async function signJwt( 208 + header: Record<string, unknown>, 209 + payload: Record<string, unknown>, 210 + privateKey: CryptoKey 211 + ): Promise<string> { 212 + const encoder = new TextEncoder(); 213 + 214 + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); 215 + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); 216 + 217 + const signingInput = `${headerB64}.${payloadB64}`; 218 + 219 + const signature = await crypto.subtle.sign( 220 + { name: 'ECDSA', hash: 'SHA-256' }, 221 + privateKey, 222 + encoder.encode(signingInput) 223 + ); 224 + 225 + const signatureB64 = base64UrlEncode(signature); 226 + 227 + return `${signingInput}.${signatureB64}`; 228 + } 229 + ``` 230 + 231 + **Step 3: Commit** 232 + 233 + ```bash 234 + git add quickslice-client-js/src/utils/ 235 + git commit -m "feat(client): add base64url and crypto utilities" 236 + ``` 237 + 238 + --- 239 + 240 + ## Task 3: Storage Utilities 241 + 242 + **Files:** 243 + - Create: `quickslice-client-js/src/storage/keys.ts` 244 + - Create: `quickslice-client-js/src/storage/storage.ts` 245 + - Create: `quickslice-client-js/src/storage/lock.ts` 246 + 247 + **Step 1: Create keys.ts** 248 + 249 + ```typescript 250 + /** 251 + * Storage key constants 252 + */ 253 + export const STORAGE_KEYS = { 254 + accessToken: 'quickslice_access_token', 255 + refreshToken: 'quickslice_refresh_token', 256 + tokenExpiresAt: 'quickslice_token_expires_at', 257 + clientId: 'quickslice_client_id', 258 + userDid: 'quickslice_user_did', 259 + codeVerifier: 'quickslice_code_verifier', 260 + oauthState: 'quickslice_oauth_state', 261 + } as const; 262 + 263 + export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; 264 + ``` 265 + 266 + **Step 2: Create storage.ts** 267 + 268 + ```typescript 269 + import { STORAGE_KEYS, StorageKey } from './keys'; 270 + 271 + /** 272 + * Hybrid storage utility - sessionStorage for OAuth flow state, 273 + * localStorage for tokens (shared across tabs) 274 + */ 275 + export const storage = { 276 + get(key: StorageKey): string | null { 277 + // OAuth flow state stays in sessionStorage (per-tab) 278 + if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) { 279 + return sessionStorage.getItem(key); 280 + } 281 + // Tokens go in localStorage (shared across tabs) 282 + return localStorage.getItem(key); 283 + }, 284 + 285 + set(key: StorageKey, value: string): void { 286 + if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) { 287 + sessionStorage.setItem(key, value); 288 + } else { 289 + localStorage.setItem(key, value); 290 + } 291 + }, 292 + 293 + remove(key: StorageKey): void { 294 + sessionStorage.removeItem(key); 295 + localStorage.removeItem(key); 296 + }, 297 + 298 + clear(): void { 299 + Object.values(STORAGE_KEYS).forEach((key) => { 300 + sessionStorage.removeItem(key); 301 + localStorage.removeItem(key); 302 + }); 303 + }, 304 + }; 305 + ``` 306 + 307 + **Step 3: Create lock.ts** 308 + 309 + ```typescript 310 + const LOCK_TIMEOUT = 5000; // 5 seconds 311 + const LOCK_PREFIX = 'quickslice_lock_'; 312 + 313 + function sleep(ms: number): Promise<void> { 314 + return new Promise((resolve) => setTimeout(resolve, ms)); 315 + } 316 + 317 + /** 318 + * Acquire a lock using localStorage for multi-tab coordination 319 + */ 320 + export async function acquireLock( 321 + key: string, 322 + timeout = LOCK_TIMEOUT 323 + ): Promise<string | null> { 324 + const lockKey = LOCK_PREFIX + key; 325 + const lockValue = `${Date.now()}_${Math.random()}`; 326 + const deadline = Date.now() + timeout; 327 + 328 + while (Date.now() < deadline) { 329 + const existing = localStorage.getItem(lockKey); 330 + 331 + if (existing) { 332 + // Check if lock is stale (older than timeout) 333 + const [timestamp] = existing.split('_'); 334 + if (Date.now() - parseInt(timestamp) > LOCK_TIMEOUT) { 335 + // Lock is stale, remove it 336 + localStorage.removeItem(lockKey); 337 + } else { 338 + // Lock is held, wait and retry 339 + await sleep(50); 340 + continue; 341 + } 342 + } 343 + 344 + // Try to acquire 345 + localStorage.setItem(lockKey, lockValue); 346 + 347 + // Verify we got it (handle race condition) 348 + await sleep(10); 349 + if (localStorage.getItem(lockKey) === lockValue) { 350 + return lockValue; // Lock acquired 351 + } 352 + } 353 + 354 + return null; // Failed to acquire 355 + } 356 + 357 + /** 358 + * Release a lock 359 + */ 360 + export function releaseLock(key: string, lockValue: string): void { 361 + const lockKey = LOCK_PREFIX + key; 362 + // Only release if we still hold it 363 + if (localStorage.getItem(lockKey) === lockValue) { 364 + localStorage.removeItem(lockKey); 365 + } 366 + } 367 + ``` 368 + 369 + **Step 4: Commit** 370 + 371 + ```bash 372 + git add quickslice-client-js/src/storage/ 373 + git commit -m "feat(client): add storage utilities with multi-tab lock" 374 + ``` 375 + 376 + --- 377 + 378 + ## Task 4: DPoP Implementation 379 + 380 + **Files:** 381 + - Create: `quickslice-client-js/src/auth/dpop.ts` 382 + 383 + **Step 1: Create dpop.ts** 384 + 385 + ```typescript 386 + import { base64UrlEncode, generateRandomString } from '../utils/base64url'; 387 + import { sha256Base64Url, signJwt } from '../utils/crypto'; 388 + 389 + const DB_NAME = 'quickslice-oauth'; 390 + const DB_VERSION = 1; 391 + const KEY_STORE = 'dpop-keys'; 392 + const KEY_ID = 'dpop-key'; 393 + 394 + interface DPoPKeyData { 395 + id: string; 396 + privateKey: CryptoKey; 397 + publicJwk: JsonWebKey; 398 + createdAt: number; 399 + } 400 + 401 + let dbPromise: Promise<IDBDatabase> | null = null; 402 + 403 + function openDatabase(): Promise<IDBDatabase> { 404 + if (dbPromise) return dbPromise; 405 + 406 + dbPromise = new Promise((resolve, reject) => { 407 + const request = indexedDB.open(DB_NAME, DB_VERSION); 408 + 409 + request.onerror = () => reject(request.error); 410 + request.onsuccess = () => resolve(request.result); 411 + 412 + request.onupgradeneeded = (event) => { 413 + const db = (event.target as IDBOpenDBRequest).result; 414 + if (!db.objectStoreNames.contains(KEY_STORE)) { 415 + db.createObjectStore(KEY_STORE, { keyPath: 'id' }); 416 + } 417 + }; 418 + }); 419 + 420 + return dbPromise; 421 + } 422 + 423 + async function getDPoPKey(): Promise<DPoPKeyData | null> { 424 + const db = await openDatabase(); 425 + return new Promise((resolve, reject) => { 426 + const tx = db.transaction(KEY_STORE, 'readonly'); 427 + const store = tx.objectStore(KEY_STORE); 428 + const request = store.get(KEY_ID); 429 + 430 + request.onerror = () => reject(request.error); 431 + request.onsuccess = () => resolve(request.result || null); 432 + }); 433 + } 434 + 435 + async function storeDPoPKey( 436 + privateKey: CryptoKey, 437 + publicJwk: JsonWebKey 438 + ): Promise<void> { 439 + const db = await openDatabase(); 440 + return new Promise((resolve, reject) => { 441 + const tx = db.transaction(KEY_STORE, 'readwrite'); 442 + const store = tx.objectStore(KEY_STORE); 443 + const request = store.put({ 444 + id: KEY_ID, 445 + privateKey, 446 + publicJwk, 447 + createdAt: Date.now(), 448 + }); 449 + 450 + request.onerror = () => reject(request.error); 451 + request.onsuccess = () => resolve(); 452 + }); 453 + } 454 + 455 + export async function getOrCreateDPoPKey(): Promise<DPoPKeyData> { 456 + const keyData = await getDPoPKey(); 457 + 458 + if (keyData) { 459 + return keyData; 460 + } 461 + 462 + // Generate new P-256 key pair 463 + const keyPair = await crypto.subtle.generateKey( 464 + { name: 'ECDSA', namedCurve: 'P-256' }, 465 + false, // NOT extractable - critical for security 466 + ['sign'] 467 + ); 468 + 469 + // Export public key as JWK 470 + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); 471 + 472 + // Store in IndexedDB 473 + await storeDPoPKey(keyPair.privateKey, publicJwk); 474 + 475 + return { 476 + id: KEY_ID, 477 + privateKey: keyPair.privateKey, 478 + publicJwk, 479 + createdAt: Date.now(), 480 + }; 481 + } 482 + 483 + /** 484 + * Create a DPoP proof JWT 485 + */ 486 + export async function createDPoPProof( 487 + method: string, 488 + url: string, 489 + accessToken: string | null = null 490 + ): Promise<string> { 491 + const keyData = await getOrCreateDPoPKey(); 492 + 493 + // Strip WebCrypto-specific fields from JWK for interoperability 494 + const { kty, crv, x, y } = keyData.publicJwk; 495 + const minimalJwk = { kty, crv, x, y }; 496 + 497 + const header = { 498 + alg: 'ES256', 499 + typ: 'dpop+jwt', 500 + jwk: minimalJwk, 501 + }; 502 + 503 + const payload: Record<string, unknown> = { 504 + jti: generateRandomString(16), 505 + htm: method, 506 + htu: url, 507 + iat: Math.floor(Date.now() / 1000), 508 + }; 509 + 510 + // Add access token hash if provided (for resource requests) 511 + if (accessToken) { 512 + payload.ath = await sha256Base64Url(accessToken); 513 + } 514 + 515 + return await signJwt(header, payload, keyData.privateKey); 516 + } 517 + 518 + /** 519 + * Clear DPoP keys from IndexedDB 520 + */ 521 + export async function clearDPoPKeys(): Promise<void> { 522 + const db = await openDatabase(); 523 + return new Promise((resolve, reject) => { 524 + const tx = db.transaction(KEY_STORE, 'readwrite'); 525 + const store = tx.objectStore(KEY_STORE); 526 + const request = store.clear(); 527 + 528 + request.onerror = () => reject(request.error); 529 + request.onsuccess = () => resolve(); 530 + }); 531 + } 532 + ``` 533 + 534 + **Step 2: Commit** 535 + 536 + ```bash 537 + git add quickslice-client-js/src/auth/dpop.ts 538 + git commit -m "feat(client): add DPoP proof generation with IndexedDB key storage" 539 + ``` 540 + 541 + --- 542 + 543 + ## Task 5: PKCE Utilities 544 + 545 + **Files:** 546 + - Create: `quickslice-client-js/src/auth/pkce.ts` 547 + 548 + **Step 1: Create pkce.ts** 549 + 550 + ```typescript 551 + import { base64UrlEncode, generateRandomString } from '../utils/base64url'; 552 + 553 + /** 554 + * Generate a PKCE code verifier (32 random bytes, base64url encoded) 555 + */ 556 + export function generateCodeVerifier(): string { 557 + return generateRandomString(32); 558 + } 559 + 560 + /** 561 + * Generate a PKCE code challenge from a verifier (SHA-256, base64url encoded) 562 + */ 563 + export async function generateCodeChallenge(verifier: string): Promise<string> { 564 + const encoder = new TextEncoder(); 565 + const data = encoder.encode(verifier); 566 + const hash = await crypto.subtle.digest('SHA-256', data); 567 + return base64UrlEncode(hash); 568 + } 569 + 570 + /** 571 + * Generate a random state parameter for CSRF protection 572 + */ 573 + export function generateState(): string { 574 + return generateRandomString(16); 575 + } 576 + ``` 577 + 578 + **Step 2: Commit** 579 + 580 + ```bash 581 + git add quickslice-client-js/src/auth/pkce.ts 582 + git commit -m "feat(client): add PKCE code verifier/challenge utilities" 583 + ``` 584 + 585 + --- 586 + 587 + ## Task 6: OAuth Token Management 588 + 589 + **Files:** 590 + - Create: `quickslice-client-js/src/auth/tokens.ts` 591 + 592 + **Step 1: Create tokens.ts** 593 + 594 + ```typescript 595 + import { storage } from '../storage/storage'; 596 + import { STORAGE_KEYS } from '../storage/keys'; 597 + import { acquireLock, releaseLock } from '../storage/lock'; 598 + import { createDPoPProof } from './dpop'; 599 + 600 + const TOKEN_REFRESH_BUFFER_MS = 60000; // 60 seconds before expiry 601 + 602 + function sleep(ms: number): Promise<void> { 603 + return new Promise((resolve) => setTimeout(resolve, ms)); 604 + } 605 + 606 + /** 607 + * Refresh tokens using the refresh token 608 + */ 609 + async function refreshTokens(tokenUrl: string): Promise<string> { 610 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 611 + const clientId = storage.get(STORAGE_KEYS.clientId); 612 + 613 + if (!refreshToken || !clientId) { 614 + throw new Error('No refresh token available'); 615 + } 616 + 617 + const dpopProof = await createDPoPProof('POST', tokenUrl); 618 + 619 + const response = await fetch(tokenUrl, { 620 + method: 'POST', 621 + headers: { 622 + 'Content-Type': 'application/x-www-form-urlencoded', 623 + DPoP: dpopProof, 624 + }, 625 + body: new URLSearchParams({ 626 + grant_type: 'refresh_token', 627 + refresh_token: refreshToken, 628 + client_id: clientId, 629 + }), 630 + }); 631 + 632 + if (!response.ok) { 633 + const errorData = await response.json().catch(() => ({})); 634 + throw new Error( 635 + `Token refresh failed: ${errorData.error_description || response.statusText}` 636 + ); 637 + } 638 + 639 + const tokens = await response.json(); 640 + 641 + // Store new tokens (rotation - new refresh token each time) 642 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 643 + if (tokens.refresh_token) { 644 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 645 + } 646 + 647 + const expiresAt = Date.now() + tokens.expires_in * 1000; 648 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 649 + 650 + return tokens.access_token; 651 + } 652 + 653 + /** 654 + * Get a valid access token, refreshing if necessary. 655 + * Uses multi-tab locking to prevent duplicate refresh requests. 656 + */ 657 + export async function getValidAccessToken(tokenUrl: string): Promise<string> { 658 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 659 + const expiresAt = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || '0'); 660 + 661 + // Check if token is still valid (with buffer) 662 + if (accessToken && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) { 663 + return accessToken; 664 + } 665 + 666 + // Need to refresh - acquire lock first 667 + const clientId = storage.get(STORAGE_KEYS.clientId); 668 + const lockKey = `token_refresh_${clientId}`; 669 + const lockValue = await acquireLock(lockKey); 670 + 671 + if (!lockValue) { 672 + // Failed to acquire lock, another tab is refreshing 673 + // Wait a bit and check cache again 674 + await sleep(100); 675 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 676 + const freshExpiry = parseInt( 677 + storage.get(STORAGE_KEYS.tokenExpiresAt) || '0' 678 + ); 679 + if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) { 680 + return freshToken; 681 + } 682 + throw new Error('Failed to refresh token'); 683 + } 684 + 685 + try { 686 + // Double-check after acquiring lock 687 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 688 + const freshExpiry = parseInt( 689 + storage.get(STORAGE_KEYS.tokenExpiresAt) || '0' 690 + ); 691 + if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) { 692 + return freshToken; 693 + } 694 + 695 + // Actually refresh 696 + return await refreshTokens(tokenUrl); 697 + } finally { 698 + releaseLock(lockKey, lockValue); 699 + } 700 + } 701 + 702 + /** 703 + * Store tokens from OAuth response 704 + */ 705 + export function storeTokens(tokens: { 706 + access_token: string; 707 + refresh_token?: string; 708 + expires_in: number; 709 + sub?: string; 710 + }): void { 711 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 712 + if (tokens.refresh_token) { 713 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 714 + } 715 + 716 + const expiresAt = Date.now() + tokens.expires_in * 1000; 717 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 718 + 719 + if (tokens.sub) { 720 + storage.set(STORAGE_KEYS.userDid, tokens.sub); 721 + } 722 + } 723 + 724 + /** 725 + * Check if we have a valid session 726 + */ 727 + export function hasValidSession(): boolean { 728 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 729 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 730 + return !!(accessToken || refreshToken); 731 + } 732 + ``` 733 + 734 + **Step 2: Commit** 735 + 736 + ```bash 737 + git add quickslice-client-js/src/auth/tokens.ts 738 + git commit -m "feat(client): add token management with multi-tab refresh coordination" 739 + ``` 740 + 741 + --- 742 + 743 + ## Task 7: OAuth Flow (Login/Callback/Logout) 744 + 745 + **Files:** 746 + - Create: `quickslice-client-js/src/auth/oauth.ts` 747 + 748 + **Step 1: Create oauth.ts** 749 + 750 + ```typescript 751 + import { storage } from '../storage/storage'; 752 + import { STORAGE_KEYS } from '../storage/keys'; 753 + import { createDPoPProof, clearDPoPKeys } from './dpop'; 754 + import { generateCodeVerifier, generateCodeChallenge, generateState } from './pkce'; 755 + import { storeTokens } from './tokens'; 756 + 757 + export interface LoginOptions { 758 + handle?: string; 759 + } 760 + 761 + /** 762 + * Initiate OAuth login flow with PKCE 763 + */ 764 + export async function initiateLogin( 765 + authorizeUrl: string, 766 + clientId: string, 767 + options: LoginOptions = {} 768 + ): Promise<void> { 769 + const codeVerifier = generateCodeVerifier(); 770 + const codeChallenge = await generateCodeChallenge(codeVerifier); 771 + const state = generateState(); 772 + 773 + // Store for callback 774 + storage.set(STORAGE_KEYS.codeVerifier, codeVerifier); 775 + storage.set(STORAGE_KEYS.oauthState, state); 776 + storage.set(STORAGE_KEYS.clientId, clientId); 777 + 778 + // Build redirect URI (current page without query params) 779 + const redirectUri = window.location.origin + window.location.pathname; 780 + 781 + // Build authorization URL 782 + const params = new URLSearchParams({ 783 + client_id: clientId, 784 + redirect_uri: redirectUri, 785 + response_type: 'code', 786 + code_challenge: codeChallenge, 787 + code_challenge_method: 'S256', 788 + state: state, 789 + }); 790 + 791 + if (options.handle) { 792 + params.set('login_hint', options.handle); 793 + } 794 + 795 + window.location.href = `${authorizeUrl}?${params.toString()}`; 796 + } 797 + 798 + /** 799 + * Handle OAuth callback - exchange code for tokens 800 + * Returns true if callback was handled, false if not a callback 801 + */ 802 + export async function handleOAuthCallback(tokenUrl: string): Promise<boolean> { 803 + const params = new URLSearchParams(window.location.search); 804 + const code = params.get('code'); 805 + const state = params.get('state'); 806 + const error = params.get('error'); 807 + 808 + if (error) { 809 + throw new Error( 810 + `OAuth error: ${error} - ${params.get('error_description') || ''}` 811 + ); 812 + } 813 + 814 + if (!code || !state) { 815 + return false; // Not a callback 816 + } 817 + 818 + // Verify state 819 + const storedState = storage.get(STORAGE_KEYS.oauthState); 820 + if (state !== storedState) { 821 + throw new Error('OAuth state mismatch - possible CSRF attack'); 822 + } 823 + 824 + // Get stored values 825 + const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 826 + const clientId = storage.get(STORAGE_KEYS.clientId); 827 + const redirectUri = window.location.origin + window.location.pathname; 828 + 829 + if (!codeVerifier || !clientId) { 830 + throw new Error('Missing OAuth session data'); 831 + } 832 + 833 + // Exchange code for tokens with DPoP 834 + const dpopProof = await createDPoPProof('POST', tokenUrl); 835 + 836 + const tokenResponse = await fetch(tokenUrl, { 837 + method: 'POST', 838 + headers: { 839 + 'Content-Type': 'application/x-www-form-urlencoded', 840 + DPoP: dpopProof, 841 + }, 842 + body: new URLSearchParams({ 843 + grant_type: 'authorization_code', 844 + code: code, 845 + redirect_uri: redirectUri, 846 + client_id: clientId, 847 + code_verifier: codeVerifier, 848 + }), 849 + }); 850 + 851 + if (!tokenResponse.ok) { 852 + const errorData = await tokenResponse.json().catch(() => ({})); 853 + throw new Error( 854 + `Token exchange failed: ${errorData.error_description || tokenResponse.statusText}` 855 + ); 856 + } 857 + 858 + const tokens = await tokenResponse.json(); 859 + 860 + // Store tokens 861 + storeTokens(tokens); 862 + 863 + // Clean up OAuth state 864 + storage.remove(STORAGE_KEYS.codeVerifier); 865 + storage.remove(STORAGE_KEYS.oauthState); 866 + 867 + // Clear URL params 868 + window.history.replaceState({}, document.title, window.location.pathname); 869 + 870 + return true; 871 + } 872 + 873 + /** 874 + * Logout - clear all stored data 875 + */ 876 + export async function logout(options: { reload?: boolean } = {}): Promise<void> { 877 + storage.clear(); 878 + await clearDPoPKeys(); 879 + 880 + if (options.reload !== false) { 881 + window.location.reload(); 882 + } 883 + } 884 + ``` 885 + 886 + **Step 2: Commit** 887 + 888 + ```bash 889 + git add quickslice-client-js/src/auth/oauth.ts 890 + git commit -m "feat(client): add OAuth login/callback/logout flows" 891 + ``` 892 + 893 + --- 894 + 895 + ## Task 8: GraphQL Utilities 896 + 897 + **Files:** 898 + - Create: `quickslice-client-js/src/graphql.ts` 899 + 900 + **Step 1: Create graphql.ts** 901 + 902 + ```typescript 903 + import { createDPoPProof } from './auth/dpop'; 904 + import { getValidAccessToken } from './auth/tokens'; 905 + 906 + export interface GraphQLResponse<T = unknown> { 907 + data?: T; 908 + errors?: Array<{ message: string; path?: string[] }>; 909 + } 910 + 911 + /** 912 + * Execute a GraphQL query or mutation 913 + */ 914 + export async function graphqlRequest<T = unknown>( 915 + graphqlUrl: string, 916 + tokenUrl: string, 917 + query: string, 918 + variables: Record<string, unknown> = {}, 919 + requireAuth = false 920 + ): Promise<T> { 921 + const headers: Record<string, string> = { 922 + 'Content-Type': 'application/json', 923 + }; 924 + 925 + if (requireAuth) { 926 + const token = await getValidAccessToken(tokenUrl); 927 + if (!token) { 928 + throw new Error('Not authenticated'); 929 + } 930 + 931 + // Create DPoP proof bound to this request 932 + const dpopProof = await createDPoPProof('POST', graphqlUrl, token); 933 + 934 + headers['Authorization'] = `DPoP ${token}`; 935 + headers['DPoP'] = dpopProof; 936 + } 937 + 938 + const response = await fetch(graphqlUrl, { 939 + method: 'POST', 940 + headers, 941 + body: JSON.stringify({ query, variables }), 942 + }); 943 + 944 + if (!response.ok) { 945 + throw new Error(`GraphQL request failed: ${response.statusText}`); 946 + } 947 + 948 + const result: GraphQLResponse<T> = await response.json(); 949 + 950 + if (result.errors && result.errors.length > 0) { 951 + throw new Error(`GraphQL error: ${result.errors[0].message}`); 952 + } 953 + 954 + return result.data as T; 955 + } 956 + ``` 957 + 958 + **Step 2: Commit** 959 + 960 + ```bash 961 + git add quickslice-client-js/src/graphql.ts 962 + git commit -m "feat(client): add GraphQL request utility with DPoP auth" 963 + ``` 964 + 965 + --- 966 + 967 + ## Task 9: Error Classes 968 + 969 + **Files:** 970 + - Create: `quickslice-client-js/src/errors.ts` 971 + 972 + **Step 1: Create errors.ts** 973 + 974 + ```typescript 975 + /** 976 + * Base error class for Quickslice client errors 977 + */ 978 + export class QuicksliceError extends Error { 979 + constructor(message: string) { 980 + super(message); 981 + this.name = 'QuicksliceError'; 982 + } 983 + } 984 + 985 + /** 986 + * Thrown when authentication is required but user is not logged in 987 + */ 988 + export class LoginRequiredError extends QuicksliceError { 989 + constructor(message = 'Login required') { 990 + super(message); 991 + this.name = 'LoginRequiredError'; 992 + } 993 + } 994 + 995 + /** 996 + * Thrown when network request fails 997 + */ 998 + export class NetworkError extends QuicksliceError { 999 + constructor(message: string) { 1000 + super(message); 1001 + this.name = 'NetworkError'; 1002 + } 1003 + } 1004 + 1005 + /** 1006 + * Thrown when OAuth flow fails 1007 + */ 1008 + export class OAuthError extends QuicksliceError { 1009 + public code: string; 1010 + public description?: string; 1011 + 1012 + constructor(code: string, description?: string) { 1013 + super(`OAuth error: ${code}${description ? ` - ${description}` : ''}`); 1014 + this.name = 'OAuthError'; 1015 + this.code = code; 1016 + this.description = description; 1017 + } 1018 + } 1019 + ``` 1020 + 1021 + **Step 2: Commit** 1022 + 1023 + ```bash 1024 + git add quickslice-client-js/src/errors.ts 1025 + git commit -m "feat(client): add error classes" 1026 + ``` 1027 + 1028 + --- 1029 + 1030 + ## Task 10: Main Client Class 1031 + 1032 + **Files:** 1033 + - Create: `quickslice-client-js/src/client.ts` 1034 + 1035 + **Step 1: Create client.ts** 1036 + 1037 + ```typescript 1038 + import { storage } from './storage/storage'; 1039 + import { STORAGE_KEYS } from './storage/keys'; 1040 + import { getOrCreateDPoPKey } from './auth/dpop'; 1041 + import { initiateLogin, handleOAuthCallback, logout as doLogout, LoginOptions } from './auth/oauth'; 1042 + import { getValidAccessToken, hasValidSession } from './auth/tokens'; 1043 + import { graphqlRequest } from './graphql'; 1044 + 1045 + export interface QuicksliceClientOptions { 1046 + server: string; 1047 + clientId: string; 1048 + } 1049 + 1050 + export interface User { 1051 + did: string; 1052 + } 1053 + 1054 + export class QuicksliceClient { 1055 + private server: string; 1056 + private clientId: string; 1057 + private graphqlUrl: string; 1058 + private authorizeUrl: string; 1059 + private tokenUrl: string; 1060 + private initialized = false; 1061 + 1062 + constructor(options: QuicksliceClientOptions) { 1063 + this.server = options.server.replace(/\/$/, ''); // Remove trailing slash 1064 + this.clientId = options.clientId; 1065 + 1066 + this.graphqlUrl = `${this.server}/graphql`; 1067 + this.authorizeUrl = `${this.server}/oauth/authorize`; 1068 + this.tokenUrl = `${this.server}/oauth/token`; 1069 + } 1070 + 1071 + /** 1072 + * Initialize the client - must be called before other methods 1073 + */ 1074 + async init(): Promise<void> { 1075 + if (this.initialized) return; 1076 + 1077 + // Ensure DPoP key exists 1078 + await getOrCreateDPoPKey(); 1079 + 1080 + this.initialized = true; 1081 + } 1082 + 1083 + /** 1084 + * Start OAuth login flow 1085 + */ 1086 + async loginWithRedirect(options: LoginOptions = {}): Promise<void> { 1087 + await this.init(); 1088 + await initiateLogin(this.authorizeUrl, this.clientId, options); 1089 + } 1090 + 1091 + /** 1092 + * Handle OAuth callback after redirect 1093 + * Returns true if callback was handled 1094 + */ 1095 + async handleRedirectCallback(): Promise<boolean> { 1096 + await this.init(); 1097 + return await handleOAuthCallback(this.tokenUrl); 1098 + } 1099 + 1100 + /** 1101 + * Logout and clear all stored data 1102 + */ 1103 + async logout(options: { reload?: boolean } = {}): Promise<void> { 1104 + await doLogout(options); 1105 + } 1106 + 1107 + /** 1108 + * Check if user is authenticated 1109 + */ 1110 + async isAuthenticated(): Promise<boolean> { 1111 + return hasValidSession(); 1112 + } 1113 + 1114 + /** 1115 + * Get current user's DID (from stored token data) 1116 + * For richer profile info, use client.query() with your own schema 1117 + */ 1118 + getUser(): User | null { 1119 + if (!hasValidSession()) { 1120 + return null; 1121 + } 1122 + 1123 + const did = storage.get(STORAGE_KEYS.userDid); 1124 + if (!did) { 1125 + return null; 1126 + } 1127 + 1128 + return { did }; 1129 + } 1130 + 1131 + /** 1132 + * Get access token (auto-refreshes if needed) 1133 + */ 1134 + async getAccessToken(): Promise<string> { 1135 + await this.init(); 1136 + return await getValidAccessToken(this.tokenUrl); 1137 + } 1138 + 1139 + /** 1140 + * Execute a GraphQL query (authenticated) 1141 + */ 1142 + async query<T = unknown>( 1143 + query: string, 1144 + variables: Record<string, unknown> = {} 1145 + ): Promise<T> { 1146 + await this.init(); 1147 + return await graphqlRequest<T>( 1148 + this.graphqlUrl, 1149 + this.tokenUrl, 1150 + query, 1151 + variables, 1152 + true 1153 + ); 1154 + } 1155 + 1156 + /** 1157 + * Execute a GraphQL mutation (authenticated) 1158 + */ 1159 + async mutate<T = unknown>( 1160 + mutation: string, 1161 + variables: Record<string, unknown> = {} 1162 + ): Promise<T> { 1163 + return this.query<T>(mutation, variables); 1164 + } 1165 + 1166 + /** 1167 + * Execute a public GraphQL query (no auth) 1168 + */ 1169 + async publicQuery<T = unknown>( 1170 + query: string, 1171 + variables: Record<string, unknown> = {} 1172 + ): Promise<T> { 1173 + await this.init(); 1174 + return await graphqlRequest<T>( 1175 + this.graphqlUrl, 1176 + this.tokenUrl, 1177 + query, 1178 + variables, 1179 + false 1180 + ); 1181 + } 1182 + } 1183 + ``` 1184 + 1185 + **Step 2: Commit** 1186 + 1187 + ```bash 1188 + git add quickslice-client-js/src/client.ts 1189 + git commit -m "feat(client): add main QuicksliceClient class" 1190 + ``` 1191 + 1192 + --- 1193 + 1194 + ## Task 11: Entry Point and Exports 1195 + 1196 + **Files:** 1197 + - Create: `quickslice-client-js/src/index.ts` 1198 + 1199 + **Step 1: Create index.ts** 1200 + 1201 + ```typescript 1202 + export { QuicksliceClient, QuicksliceClientOptions, User } from './client'; 1203 + export { 1204 + QuicksliceError, 1205 + LoginRequiredError, 1206 + NetworkError, 1207 + OAuthError, 1208 + } from './errors'; 1209 + 1210 + import { QuicksliceClient, QuicksliceClientOptions } from './client'; 1211 + 1212 + /** 1213 + * Create and initialize a Quickslice client 1214 + */ 1215 + export async function createQuicksliceClient( 1216 + options: QuicksliceClientOptions 1217 + ): Promise<QuicksliceClient> { 1218 + const client = new QuicksliceClient(options); 1219 + await client.init(); 1220 + return client; 1221 + } 1222 + ``` 1223 + 1224 + **Step 2: Commit** 1225 + 1226 + ```bash 1227 + git add quickslice-client-js/src/index.ts 1228 + git commit -m "feat(client): add entry point with createQuicksliceClient factory" 1229 + ``` 1230 + 1231 + --- 1232 + 1233 + ## Task 12: Build and Test 1234 + 1235 + **Step 1: Build the library** 1236 + 1237 + Run: `cd quickslice-client-js && npm run build` 1238 + 1239 + Expected output: 1240 + ``` 1241 + Build complete! 1242 + ``` 1243 + 1244 + **Step 2: Verify dist files exist** 1245 + 1246 + Run: `ls -la quickslice-client-js/dist/` 1247 + 1248 + Expected: Should see `quickslice-client.js`, `quickslice-client.min.js`, `quickslice-client.esm.js` 1249 + 1250 + **Step 3: Generate TypeScript declarations** 1251 + 1252 + Run: `cd quickslice-client-js && npx tsc` 1253 + 1254 + **Step 4: Commit dist files** 1255 + 1256 + ```bash 1257 + git add quickslice-client-js/dist/ 1258 + git commit -m "chore(client): add built distribution files" 1259 + ``` 1260 + 1261 + --- 1262 + 1263 + ## Task 13: Update Statusphere Example 1264 + 1265 + **Files:** 1266 + - Modify: `examples/01-statusphere/index.html` 1267 + 1268 + **Step 1: Replace inline auth code with library import** 1269 + 1270 + Replace the entire `<script>` section (lines ~370-970) with: 1271 + 1272 + ```html 1273 + <script src="../../quickslice-client-js/dist/quickslice-client.min.js"></script> 1274 + <script> 1275 + // Configuration 1276 + const SERVER_URL = "http://localhost:8080"; 1277 + const CLIENT_ID = "client_hmO069YjiCuJg5rnogC5-A"; // TODO: Replace with actual client ID 1278 + 1279 + let client; 1280 + 1281 + // ============================================================================= 1282 + // INITIALIZATION 1283 + // ============================================================================= 1284 + 1285 + async function main() { 1286 + client = await QuicksliceClient.createQuicksliceClient({ 1287 + server: SERVER_URL, 1288 + clientId: CLIENT_ID, 1289 + }); 1290 + 1291 + // Handle OAuth callback 1292 + if (window.location.search.includes("code=")) { 1293 + try { 1294 + await client.handleRedirectCallback(); 1295 + } catch (error) { 1296 + console.error("OAuth callback error:", error); 1297 + showError(error.message); 1298 + return; 1299 + } 1300 + } 1301 + 1302 + // Initial render 1303 + await renderApp(); 1304 + } 1305 + 1306 + async function renderApp() { 1307 + if (await client.isAuthenticated()) { 1308 + const user = client.getUser(); 1309 + // Fetch additional profile data if needed via client.query() 1310 + renderLoggedIn(user); 1311 + await loadStatuses(); 1312 + } else { 1313 + renderLoggedOut(); 1314 + } 1315 + } 1316 + 1317 + // ============================================================================= 1318 + // AUTH HANDLERS 1319 + // ============================================================================= 1320 + 1321 + async function handleLogin(event) { 1322 + event.preventDefault(); 1323 + const handle = document.getElementById("handle").value.trim(); 1324 + if (!handle) return; 1325 + 1326 + try { 1327 + await client.loginWithRedirect({ handle }); 1328 + } catch (error) { 1329 + console.error("Login error:", error); 1330 + showError(error.message); 1331 + } 1332 + } 1333 + 1334 + function handleLogout() { 1335 + client.logout(); 1336 + } 1337 + 1338 + // ============================================================================= 1339 + // DATA FETCHING 1340 + // ============================================================================= 1341 + 1342 + async function loadStatuses() { 1343 + try { 1344 + const data = await client.publicQuery(` 1345 + query GetStatuses { 1346 + xyzStatusphereStatus(first: 20, sortBy: [{ field: "createdAt", direction: DESC }]) { 1347 + edges { 1348 + node { 1349 + status 1350 + createdAt 1351 + did 1352 + appBskyActorProfileByDid { 1353 + displayName 1354 + avatar 1355 + } 1356 + } 1357 + } 1358 + } 1359 + } 1360 + `); 1361 + renderStatuses(data.xyzStatusphereStatus.edges); 1362 + } catch (error) { 1363 + console.error("Failed to load statuses:", error); 1364 + } 1365 + } 1366 + 1367 + async function postStatus(emoji) { 1368 + try { 1369 + await client.mutate(` 1370 + mutation CreateStatus($status: String!, $createdAt: DateTime!) { 1371 + createXyzStatusphereStatus(input: { status: $status, createdAt: $createdAt }) { 1372 + status 1373 + } 1374 + } 1375 + `, { 1376 + status: emoji, 1377 + createdAt: new Date().toISOString(), 1378 + }); 1379 + await loadStatuses(); 1380 + } catch (error) { 1381 + console.error("Failed to post status:", error); 1382 + showError(error.message); 1383 + } 1384 + } 1385 + 1386 + // ... rest of UI rendering code stays the same ... 1387 + ``` 1388 + 1389 + **Step 2: Keep UI rendering functions, remove auth functions** 1390 + 1391 + Keep these functions from the original file: 1392 + - `renderLoggedIn()` 1393 + - `renderLoggedOut()` 1394 + - `renderStatuses()` 1395 + - `showError()` 1396 + - `escapeHtml()` 1397 + - Event listeners 1398 + 1399 + Remove these (now provided by library): 1400 + - All IndexedDB code 1401 + - All DPoP code 1402 + - All lock code 1403 + - All storage code 1404 + - All PKCE code 1405 + - All OAuth functions 1406 + - `graphqlQuery()` function 1407 + 1408 + **Step 3: Commit** 1409 + 1410 + ```bash 1411 + git add examples/01-statusphere/index.html 1412 + git commit -m "refactor(example): use quickslice-client-js library" 1413 + ``` 1414 + 1415 + --- 1416 + 1417 + ## Task 14: Add README 1418 + 1419 + **Files:** 1420 + - Create: `quickslice-client-js/README.md` 1421 + 1422 + **Step 1: Create README.md** 1423 + 1424 + ```markdown 1425 + # quickslice-client-js 1426 + 1427 + Browser client SDK for Quickslice applications. 1428 + 1429 + ## Installation 1430 + 1431 + ### Via CDN (jsDelivr) 1432 + 1433 + ```html 1434 + <script src="https://cdn.jsdelivr.net/gh/yourorg/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 1435 + ``` 1436 + 1437 + ### Via npm 1438 + 1439 + ```bash 1440 + npm install quickslice-client-js 1441 + ``` 1442 + 1443 + ## Usage 1444 + 1445 + ```javascript 1446 + // Initialize client 1447 + const client = await QuicksliceClient.createQuicksliceClient({ 1448 + server: 'https://api.example.com', 1449 + clientId: 'client_abc123', 1450 + }); 1451 + 1452 + // Handle OAuth callback (on page load) 1453 + if (window.location.search.includes('code=')) { 1454 + await client.handleRedirectCallback(); 1455 + window.history.replaceState({}, '', '/'); 1456 + } 1457 + 1458 + // Check auth state 1459 + if (await client.isAuthenticated()) { 1460 + const user = client.getUser(); 1461 + console.log(`Logged in as ${user.did}`); 1462 + 1463 + // Fetch richer profile with your own query 1464 + const profile = await client.query(`query { viewer { handle } }`); 1465 + } 1466 + 1467 + // Login 1468 + document.getElementById('login').onclick = async () => { 1469 + await client.loginWithRedirect({ handle: 'alice.bsky.social' }); 1470 + }; 1471 + 1472 + // Logout 1473 + document.getElementById('logout').onclick = () => { 1474 + client.logout(); 1475 + }; 1476 + 1477 + // GraphQL queries 1478 + const data = await client.query(` 1479 + query { 1480 + viewer { did handle } 1481 + } 1482 + `); 1483 + 1484 + // GraphQL mutations 1485 + await client.mutate(` 1486 + mutation CreatePost($text: String!) { 1487 + createPost(input: { text: $text }) { id } 1488 + } 1489 + `, { text: 'Hello world!' }); 1490 + 1491 + // Public queries (no auth) 1492 + const publicData = await client.publicQuery(` 1493 + query { posts(first: 10) { edges { node { text } } } } 1494 + `); 1495 + ``` 1496 + 1497 + ## API 1498 + 1499 + ### `createQuicksliceClient(options)` 1500 + 1501 + Factory function to create and initialize a client. 1502 + 1503 + Options: 1504 + - `server` (required): Quickslice server URL 1505 + - `clientId` (required): Pre-registered client ID 1506 + 1507 + ### `QuicksliceClient` 1508 + 1509 + #### Auth Methods 1510 + 1511 + - `loginWithRedirect(options?)` - Start OAuth login flow 1512 + - `handleRedirectCallback()` - Process OAuth callback 1513 + - `logout(options?)` - Clear session and reload 1514 + - `isAuthenticated()` - Check if logged in 1515 + - `getUser()` - Get current user's DID (sync, returns `{ did }`) 1516 + - `getAccessToken()` - Get access token (auto-refreshes) 1517 + 1518 + #### GraphQL Methods 1519 + 1520 + - `query(query, variables?)` - Execute authenticated query 1521 + - `mutate(mutation, variables?)` - Execute authenticated mutation 1522 + - `publicQuery(query, variables?)` - Execute unauthenticated query 1523 + 1524 + ## Security 1525 + 1526 + - PKCE for OAuth authorization code flow 1527 + - DPoP (Demonstration of Proof-of-Possession) token binding 1528 + - Non-extractable P-256 keys stored in IndexedDB 1529 + - Multi-tab token refresh coordination 1530 + - CSRF protection via state parameter 1531 + 1532 + ## License 1533 + 1534 + MIT 1535 + ``` 1536 + 1537 + **Step 2: Commit** 1538 + 1539 + ```bash 1540 + git add quickslice-client-js/README.md 1541 + git commit -m "docs(client): add README with usage examples" 1542 + ``` 1543 + 1544 + --- 1545 + 1546 + Plan complete and saved to `dev-docs/plans/2025-12-07-quickslice-client-js.md`. Two execution options: 1547 + 1548 + **1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration 1549 + 1550 + **2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints 1551 + 1552 + Which approach?
+1520
dev-docs/plans/2025-12-07-secure-public-oauth.md
··· 1 + # Secure Public OAuth with DPoP Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Update the 01-statusphere example to use DPoP-bound tokens with refresh token rotation, multi-tab coordination, and persistent storage across page refreshes. 6 + 7 + **Architecture:** Client stores DPoP private key in IndexedDB (non-extractable WebCrypto key) and tokens in localStorage. Every request includes a fresh DPoP proof signed by the private key. Server validates DPoP proofs and binds tokens to the key thumbprint. Refresh tokens rotate on each use. 8 + 9 + **Tech Stack:** Gleam/Erlang (server), Vanilla JS with WebCrypto API (client), browser-tabs-lock pattern, IndexedDB, localStorage 10 + 11 + --- 12 + 13 + ## Part 1: Server-Side DPoP Validation 14 + 15 + ### Task 1: Add DPoP Proof Verification to jose_ffi.erl 16 + 17 + **Files:** 18 + - Modify: `server/src/jose_ffi.erl` 19 + 20 + **Step 1: Add verify_dpop_proof export** 21 + 22 + Add to the export list at line 2: 23 + 24 + ```erlang 25 + -export([generate_dpop_proof/5, sha256_hash/1, compute_jwk_thumbprint/1, sha256_base64url/1, verify_dpop_proof/4]). 26 + ``` 27 + 28 + **Step 2: Add the verification function** 29 + 30 + Add after `compute_jwk_thumbprint/1` function (after line 128): 31 + 32 + ```erlang 33 + %% Verify a DPoP proof JWT 34 + %% Args: DPoPProof (binary), ExpectedMethod (binary), ExpectedUrl (binary), MaxAgeSeconds (integer) 35 + %% Returns: {ok, #{jkt => Thumbprint, jti => Jti, iat => Iat}} | {error, Reason} 36 + verify_dpop_proof(DPoPProof, ExpectedMethod, ExpectedUrl, MaxAgeSeconds) -> 37 + try 38 + %% Parse the JWT without verification first to get the header 39 + {_JWS, JWTMap} = jose_jwt:peek(DPoPProof), 40 + 41 + %% Get the JWS to extract the header 42 + {JWSMap, _} = jose_jws:from_binary(DPoPProof), 43 + 44 + %% Extract the JWK from the header 45 + case maps:get(<<"jwk">>, JWSMap, undefined) of 46 + undefined -> 47 + {error, <<"Missing jwk in DPoP header">>}; 48 + JWKMap -> 49 + %% Verify typ is dpop+jwt 50 + case maps:get(<<"typ">>, JWSMap, undefined) of 51 + <<"dpop+jwt">> -> 52 + %% Reconstruct JWK for verification 53 + JWK = jose_jwk:from_map(JWKMap), 54 + 55 + %% Verify the signature 56 + case jose_jwt:verify(JWK, DPoPProof) of 57 + {true, JWT, _JWS2} -> 58 + Claims = jose_jwt:to_map(JWT), 59 + validate_dpop_claims(Claims, JWK, ExpectedMethod, ExpectedUrl, MaxAgeSeconds); 60 + {false, _, _} -> 61 + {error, <<"Invalid DPoP signature">>} 62 + end; 63 + Other -> 64 + {error, iolist_to_binary([<<"Invalid typ: expected dpop+jwt, got ">>, 65 + io_lib:format("~p", [Other])])} 66 + end 67 + end 68 + catch 69 + error:Reason -> 70 + {error, iolist_to_binary([<<"DPoP verification failed: ">>, 71 + io_lib:format("~p", [Reason])])}; 72 + _:Error -> 73 + {error, iolist_to_binary([<<"DPoP verification error: ">>, 74 + io_lib:format("~p", [Error])])} 75 + end. 76 + 77 + %% Internal: Validate DPoP claims 78 + validate_dpop_claims({_Kind, Claims}, JWK, ExpectedMethod, ExpectedUrl, MaxAgeSeconds) -> 79 + Now = erlang:system_time(second), 80 + 81 + %% Extract required claims 82 + Htm = maps:get(<<"htm">>, Claims, undefined), 83 + Htu = maps:get(<<"htu">>, Claims, undefined), 84 + Jti = maps:get(<<"jti">>, Claims, undefined), 85 + Iat = maps:get(<<"iat">>, Claims, undefined), 86 + 87 + %% Validate all required claims exist 88 + case {Htm, Htu, Jti, Iat} of 89 + {undefined, _, _, _} -> {error, <<"Missing htm claim">>}; 90 + {_, undefined, _, _} -> {error, <<"Missing htu claim">>}; 91 + {_, _, undefined, _} -> {error, <<"Missing jti claim">>}; 92 + {_, _, _, undefined} -> {error, <<"Missing iat claim">>}; 93 + _ -> 94 + %% Validate htm matches 95 + case Htm =:= ExpectedMethod of 96 + false -> 97 + {error, iolist_to_binary([<<"htm mismatch: expected ">>, ExpectedMethod, 98 + <<", got ">>, Htm])}; 99 + true -> 100 + %% Validate htu matches (normalize URLs) 101 + case normalize_url(Htu) =:= normalize_url(ExpectedUrl) of 102 + false -> 103 + {error, iolist_to_binary([<<"htu mismatch: expected ">>, ExpectedUrl, 104 + <<", got ">>, Htu])}; 105 + true -> 106 + %% Validate iat is within acceptable range 107 + case abs(Now - Iat) =< MaxAgeSeconds of 108 + false -> 109 + {error, <<"iat outside acceptable time window">>}; 110 + true -> 111 + %% Compute JKT 112 + {_Module, Thumbprint} = jose_jwk:thumbprint(JWK), 113 + {ok, #{ 114 + jkt => Thumbprint, 115 + jti => Jti, 116 + iat => Iat 117 + }} 118 + end 119 + end 120 + end 121 + end. 122 + 123 + %% Internal: Normalize URL for comparison (remove trailing slash, fragments) 124 + normalize_url(Url) when is_binary(Url) -> 125 + %% Remove fragment 126 + case binary:split(Url, <<"#">>) of 127 + [Base | _] -> 128 + %% Remove trailing slash 129 + case binary:last(Base) of 130 + $/ -> binary:part(Base, 0, byte_size(Base) - 1); 131 + _ -> Base 132 + end; 133 + _ -> Url 134 + end. 135 + ``` 136 + 137 + **Step 3: Run tests to verify compilation** 138 + 139 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 140 + Expected: Build succeeds 141 + 142 + **Step 4: Commit** 143 + 144 + ```bash 145 + git add server/src/jose_ffi.erl 146 + git commit -m "feat(oauth): add DPoP proof verification to jose_ffi" 147 + ``` 148 + 149 + --- 150 + 151 + ### Task 2: Create DPoP Validator Gleam Module 152 + 153 + **Files:** 154 + - Create: `server/src/lib/oauth/dpop/validator.gleam` 155 + 156 + **Step 1: Create the validator module** 157 + 158 + ```gleam 159 + /// DPoP proof validation 160 + /// Validates DPoP proofs according to RFC 9449 161 + import gleam/option.{type Option, None, Some} 162 + import gleam/result 163 + 164 + /// Result of successful DPoP validation 165 + pub type DPoPValidationResult { 166 + DPoPValidationResult( 167 + /// JWK thumbprint (SHA-256) of the public key 168 + jkt: String, 169 + /// Unique identifier for replay protection 170 + jti: String, 171 + /// Issued-at timestamp 172 + iat: Int, 173 + ) 174 + } 175 + 176 + /// Verify a DPoP proof JWT 177 + /// 178 + /// # Arguments 179 + /// * `dpop_proof` - The DPoP proof JWT from the DPoP header 180 + /// * `method` - Expected HTTP method (e.g., "POST") 181 + /// * `url` - Expected URL being accessed 182 + /// * `max_age_seconds` - Maximum allowed age of the proof (typically 300 = 5 minutes) 183 + /// 184 + /// # Returns 185 + /// * `Ok(DPoPValidationResult)` - Validation succeeded 186 + /// * `Error(String)` - Validation failed with reason 187 + pub fn verify_dpop_proof( 188 + dpop_proof: String, 189 + method: String, 190 + url: String, 191 + max_age_seconds: Int, 192 + ) -> Result(DPoPValidationResult, String) { 193 + verify_dpop_proof_internal(dpop_proof, method, url, max_age_seconds) 194 + |> result.map(fn(result) { 195 + DPoPValidationResult( 196 + jkt: result.jkt, 197 + jti: result.jti, 198 + iat: result.iat, 199 + ) 200 + }) 201 + } 202 + 203 + /// Internal FFI result type 204 + type InternalResult { 205 + InternalResult(jkt: String, jti: String, iat: Int) 206 + } 207 + 208 + @external(erlang, "dpop_validator_ffi", "verify_dpop_proof") 209 + fn verify_dpop_proof_internal( 210 + dpop_proof: String, 211 + method: String, 212 + url: String, 213 + max_age_seconds: Int, 214 + ) -> Result(InternalResult, String) 215 + 216 + /// Extract DPoP header from request headers 217 + pub fn get_dpop_header( 218 + headers: List(#(String, String)), 219 + ) -> Option(String) { 220 + case headers { 221 + [] -> None 222 + [#(name, value), ..rest] -> { 223 + case name { 224 + "dpop" -> Some(value) 225 + "DPoP" -> Some(value) 226 + _ -> get_dpop_header(rest) 227 + } 228 + } 229 + } 230 + } 231 + ``` 232 + 233 + **Step 2: Create the FFI bridge** 234 + 235 + Create file: `server/src/dpop_validator_ffi.erl` 236 + 237 + ```erlang 238 + -module(dpop_validator_ffi). 239 + -export([verify_dpop_proof/4]). 240 + 241 + %% Bridge to jose_ffi:verify_dpop_proof with Gleam-compatible return types 242 + verify_dpop_proof(DPoPProof, Method, Url, MaxAgeSeconds) -> 243 + case jose_ffi:verify_dpop_proof(DPoPProof, Method, Url, MaxAgeSeconds) of 244 + {ok, #{jkt := Jkt, jti := Jti, iat := Iat}} -> 245 + {ok, {internal_result, Jkt, Jti, Iat}}; 246 + {error, Reason} when is_binary(Reason) -> 247 + {error, Reason}; 248 + {error, Reason} -> 249 + {error, iolist_to_binary(io_lib:format("~p", [Reason]))} 250 + end. 251 + ``` 252 + 253 + **Step 3: Run build to verify** 254 + 255 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 256 + Expected: Build succeeds 257 + 258 + **Step 4: Commit** 259 + 260 + ```bash 261 + git add server/src/lib/oauth/dpop/validator.gleam server/src/dpop_validator_ffi.erl 262 + git commit -m "feat(oauth): add DPoP validator module" 263 + ``` 264 + 265 + --- 266 + 267 + ### Task 3: Add JTI Replay Protection Repository 268 + 269 + **Files:** 270 + - Create: `server/src/database/repositories/oauth_dpop_jti.gleam` 271 + - Modify: `server/src/database/schema/tables.gleam` 272 + 273 + **Step 1: Add table creation function to tables.gleam** 274 + 275 + Add after `create_oauth_dpop_nonce_table` function: 276 + 277 + ```gleam 278 + /// Creates the oauth_dpop_jti table for DPoP JTI replay protection 279 + pub fn create_oauth_dpop_jti_table( 280 + conn: sqlight.Connection, 281 + ) -> Result(Nil, sqlight.Error) { 282 + let create_table_sql = 283 + " 284 + CREATE TABLE IF NOT EXISTS oauth_dpop_jti ( 285 + jti TEXT PRIMARY KEY, 286 + created_at INTEGER NOT NULL 287 + ) 288 + " 289 + 290 + let create_created_at_index_sql = 291 + " 292 + CREATE INDEX IF NOT EXISTS idx_oauth_dpop_jti_created_at 293 + ON oauth_dpop_jti(created_at) 294 + " 295 + 296 + use _ <- result.try(sqlight.exec(create_table_sql, conn)) 297 + sqlight.exec(create_created_at_index_sql, conn) 298 + } 299 + ``` 300 + 301 + **Step 2: Create the repository module** 302 + 303 + ```gleam 304 + /// OAuth DPoP JTI replay protection repository 305 + /// Tracks used JTI values to prevent replay attacks 306 + import gleam/dynamic/decode 307 + import gleam/list 308 + import gleam/result 309 + import sqlight 310 + 311 + /// Check if a JTI has been used and mark it as used atomically 312 + /// Returns Ok(True) if the JTI was successfully recorded (not previously used) 313 + /// Returns Ok(False) if the JTI was already used (replay attack) 314 + pub fn use_jti( 315 + conn: sqlight.Connection, 316 + jti: String, 317 + created_at: Int, 318 + ) -> Result(Bool, sqlight.Error) { 319 + // Try to insert - will fail if JTI already exists due to PRIMARY KEY constraint 320 + let sql = 321 + "INSERT OR IGNORE INTO oauth_dpop_jti (jti, created_at) VALUES (?, ?)" 322 + 323 + use rows <- result.try(sqlight.query( 324 + sql, 325 + on: conn, 326 + with: [sqlight.text(jti), sqlight.int(created_at)], 327 + expecting: decode.dynamic, 328 + )) 329 + 330 + // Check if insert succeeded by checking changes 331 + let check_sql = "SELECT changes()" 332 + use changes_rows <- result.try(sqlight.query( 333 + check_sql, 334 + on: conn, 335 + with: [], 336 + expecting: decode.at([0], decode.int), 337 + )) 338 + 339 + case list.first(changes_rows) { 340 + Ok(1) -> Ok(True) // Insert succeeded, JTI was new 341 + Ok(0) -> Ok(False) // Insert ignored, JTI was duplicate 342 + _ -> Ok(False) 343 + } 344 + } 345 + 346 + /// Delete expired JTI entries 347 + /// Should be called periodically to clean up old entries 348 + pub fn delete_expired( 349 + conn: sqlight.Connection, 350 + before: Int, 351 + ) -> Result(Int, sqlight.Error) { 352 + let sql = "DELETE FROM oauth_dpop_jti WHERE created_at < ?" 353 + 354 + use _ <- result.try(sqlight.query( 355 + sql, 356 + on: conn, 357 + with: [sqlight.int(before)], 358 + expecting: decode.dynamic, 359 + )) 360 + 361 + // Get count of deleted rows 362 + let check_sql = "SELECT changes()" 363 + use changes_rows <- result.try(sqlight.query( 364 + check_sql, 365 + on: conn, 366 + with: [], 367 + expecting: decode.at([0], decode.int), 368 + )) 369 + 370 + case list.first(changes_rows) { 371 + Ok(count) -> Ok(count) 372 + Error(_) -> Ok(0) 373 + } 374 + } 375 + ``` 376 + 377 + **Step 3: Run build to verify** 378 + 379 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build` 380 + Expected: Build succeeds 381 + 382 + **Step 4: Commit** 383 + 384 + ```bash 385 + git add server/src/database/repositories/oauth_dpop_jti.gleam server/src/database/schema/tables.gleam 386 + git commit -m "feat(oauth): add JTI replay protection repository" 387 + ``` 388 + 389 + --- 390 + 391 + ### Task 4: Update Token Endpoint for DPoP Validation 392 + 393 + **Files:** 394 + - Modify: `server/src/handlers/oauth/token.gleam` 395 + 396 + **Step 1: Add imports at top of file** 397 + 398 + Add after existing imports: 399 + 400 + ```gleam 401 + import database/repositories/oauth_dpop_jti 402 + import lib/oauth/dpop/validator 403 + ``` 404 + 405 + **Step 2: Add DPoP extraction and validation helper** 406 + 407 + Add after `validate_client_authentication` function (around line 109): 408 + 409 + ```gleam 410 + /// Extract and validate DPoP proof from request 411 + /// Returns the JKT (key thumbprint) if valid, or an error response 412 + fn validate_dpop_for_token_endpoint( 413 + req: wisp.Request, 414 + conn: sqlight.Connection, 415 + client: types.OAuthClient, 416 + ) -> Result(Option(String), wisp.Response) { 417 + // Get DPoP header 418 + let dpop_header = validator.get_dpop_header(wisp.get_headers(req)) 419 + 420 + case dpop_header, client.token_endpoint_auth_method { 421 + // Public clients MUST use DPoP 422 + None, types.AuthNone -> 423 + Error(error_response( 424 + 400, 425 + "invalid_request", 426 + "DPoP proof required for public clients", 427 + )) 428 + 429 + // DPoP provided - validate it 430 + Some(dpop_proof), _ -> { 431 + // Build the token endpoint URL 432 + // TODO: Get from config 433 + let token_url = "http://localhost:8080/oauth/token" 434 + 435 + case validator.verify_dpop_proof(dpop_proof, "POST", token_url, 300) { 436 + Error(reason) -> 437 + Error(error_response(400, "invalid_dpop_proof", reason)) 438 + Ok(result) -> { 439 + // Check JTI hasn't been used (replay protection) 440 + case oauth_dpop_jti.use_jti(conn, result.jti, result.iat) { 441 + Error(err) -> 442 + Error(error_response( 443 + 500, 444 + "server_error", 445 + "Database error: " <> string.inspect(err), 446 + )) 447 + Ok(False) -> 448 + Error(error_response( 449 + 400, 450 + "invalid_dpop_proof", 451 + "DPoP proof has already been used (replay detected)", 452 + )) 453 + Ok(True) -> 454 + Ok(Some(result.jkt)) 455 + } 456 + } 457 + } 458 + } 459 + 460 + // Confidential client without DPoP - allowed 461 + None, _ -> Ok(None) 462 + } 463 + } 464 + ``` 465 + 466 + **Step 3: Update handle_authorization_code to use DPoP** 467 + 468 + In `handle_authorization_code` function, after the client authentication validation (around line 172), add DPoP validation: 469 + 470 + Replace: 471 + ```gleam 472 + Ok(_) -> { 473 + // Get authorization code 474 + ``` 475 + 476 + With: 477 + ```gleam 478 + Ok(_) -> { 479 + // Validate DPoP if present/required 480 + case validate_dpop_for_token_endpoint(req, conn, client) { 481 + Error(err) -> err 482 + Ok(dpop_jkt) -> { 483 + // Get authorization code 484 + ``` 485 + 486 + Then update the `OAuthAccessToken` creation (around line 211) to use `dpop_jkt`: 487 + 488 + Replace: 489 + ```gleam 490 + let access_token = 491 + OAuthAccessToken( 492 + token: access_token_value, 493 + token_type: Bearer, 494 + ... 495 + dpop_jkt: None, 496 + ) 497 + ``` 498 + 499 + With: 500 + ```gleam 501 + let token_type = case dpop_jkt { 502 + Some(_) -> types.DPoP 503 + None -> Bearer 504 + } 505 + 506 + let access_token = 507 + OAuthAccessToken( 508 + token: access_token_value, 509 + token_type: token_type, 510 + ... 511 + dpop_jkt: dpop_jkt, 512 + ) 513 + ``` 514 + 515 + And update the `token_response` call to use the correct token type string: 516 + 517 + Replace: 518 + ```gleam 519 + token_response( 520 + access_token_value, 521 + "Bearer", 522 + ``` 523 + 524 + With: 525 + ```gleam 526 + let token_type_str = case dpop_jkt { 527 + Some(_) -> "DPoP" 528 + None -> "Bearer" 529 + } 530 + token_response( 531 + access_token_value, 532 + token_type_str, 533 + ``` 534 + 535 + Close the extra case block at the end of `handle_authorization_code`: 536 + 537 + Add before the final closing braces: 538 + ```gleam 539 + } 540 + } 541 + ``` 542 + 543 + **Step 4: Update handle_refresh_token similarly** 544 + 545 + Apply the same pattern to `handle_refresh_token`: 546 + 1. Add DPoP validation after client authentication 547 + 2. Verify the DPoP JKT matches the original token's JKT (for bound tokens) 548 + 3. Bind new tokens to the same JKT 549 + 550 + **Step 5: Update handle function signature to pass request** 551 + 552 + The `handle_authorization_code` and `handle_refresh_token` functions need access to the request for DPoP header extraction. Update their signatures and calls. 553 + 554 + **Step 6: Run build and tests** 555 + 556 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam build && gleam test` 557 + Expected: Build succeeds, tests pass 558 + 559 + **Step 7: Commit** 560 + 561 + ```bash 562 + git add server/src/handlers/oauth/token.gleam 563 + git commit -m "feat(oauth): add DPoP validation to token endpoint" 564 + ``` 565 + 566 + --- 567 + 568 + ### Task 5: Add DPoP Token Type to Database Types 569 + 570 + **Files:** 571 + - Modify: `server/src/database/types.gleam` 572 + 573 + **Step 1: Add DPoP to TokenType enum** 574 + 575 + Find the `TokenType` enum (if it exists) or add it: 576 + 577 + ```gleam 578 + /// OAuth token types 579 + pub type TokenType { 580 + Bearer 581 + DPoP 582 + } 583 + ``` 584 + 585 + **Step 2: Commit** 586 + 587 + ```bash 588 + git add server/src/database/types.gleam 589 + git commit -m "feat(oauth): add DPoP token type" 590 + ``` 591 + 592 + --- 593 + 594 + ### Task 6: Initialize JTI Table on Server Startup 595 + 596 + **Files:** 597 + - Modify: `server/src/server.gleam` (or wherever tables are initialized) 598 + 599 + **Step 1: Add table creation call** 600 + 601 + Find where other OAuth tables are created and add: 602 + 603 + ```gleam 604 + use _ <- result.try(tables.create_oauth_dpop_jti_table(conn)) 605 + ``` 606 + 607 + **Step 2: Commit** 608 + 609 + ```bash 610 + git add server/src/server.gleam 611 + git commit -m "feat(oauth): initialize JTI table on startup" 612 + ``` 613 + 614 + --- 615 + 616 + ## Part 2: Client-Side DPoP Implementation 617 + 618 + ### Task 7: Add IndexedDB Helpers for DPoP Key Storage 619 + 620 + **Files:** 621 + - Modify: `examples/01-statusphere/index.html` 622 + 623 + **Step 1: Add IndexedDB constants and helpers** 624 + 625 + Add after the `STORAGE_KEYS` constant (around line 415): 626 + 627 + ```javascript 628 + // ============================================================================= 629 + // INDEXEDDB FOR DPOP KEYS 630 + // ============================================================================= 631 + 632 + const DB_NAME = "statusphere-oauth"; 633 + const DB_VERSION = 1; 634 + const KEY_STORE = "dpop-keys"; 635 + const KEY_ID = "dpop-key"; 636 + 637 + let dbPromise = null; 638 + 639 + function openDatabase() { 640 + if (dbPromise) return dbPromise; 641 + 642 + dbPromise = new Promise((resolve, reject) => { 643 + const request = indexedDB.open(DB_NAME, DB_VERSION); 644 + 645 + request.onerror = () => reject(request.error); 646 + request.onsuccess = () => resolve(request.result); 647 + 648 + request.onupgradeneeded = (event) => { 649 + const db = event.target.result; 650 + if (!db.objectStoreNames.contains(KEY_STORE)) { 651 + db.createObjectStore(KEY_STORE, { keyPath: "id" }); 652 + } 653 + }; 654 + }); 655 + 656 + return dbPromise; 657 + } 658 + 659 + async function getDPoPKey() { 660 + const db = await openDatabase(); 661 + return new Promise((resolve, reject) => { 662 + const tx = db.transaction(KEY_STORE, "readonly"); 663 + const store = tx.objectStore(KEY_STORE); 664 + const request = store.get(KEY_ID); 665 + 666 + request.onerror = () => reject(request.error); 667 + request.onsuccess = () => resolve(request.result || null); 668 + }); 669 + } 670 + 671 + async function storeDPoPKey(privateKey, publicJwk) { 672 + const db = await openDatabase(); 673 + return new Promise((resolve, reject) => { 674 + const tx = db.transaction(KEY_STORE, "readwrite"); 675 + const store = tx.objectStore(KEY_STORE); 676 + const request = store.put({ 677 + id: KEY_ID, 678 + privateKey: privateKey, 679 + publicJwk: publicJwk, 680 + createdAt: Date.now() 681 + }); 682 + 683 + request.onerror = () => reject(request.error); 684 + request.onsuccess = () => resolve(); 685 + }); 686 + } 687 + 688 + async function getOrCreateDPoPKey() { 689 + let keyData = await getDPoPKey(); 690 + 691 + if (keyData) { 692 + return keyData; 693 + } 694 + 695 + // Generate new P-256 key pair 696 + const keyPair = await crypto.subtle.generateKey( 697 + { name: "ECDSA", namedCurve: "P-256" }, 698 + false, // NOT extractable - critical for security 699 + ["sign"] 700 + ); 701 + 702 + // Export public key as JWK 703 + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 704 + 705 + // Store in IndexedDB 706 + await storeDPoPKey(keyPair.privateKey, publicJwk); 707 + 708 + return { 709 + privateKey: keyPair.privateKey, 710 + publicJwk: publicJwk, 711 + createdAt: Date.now() 712 + }; 713 + } 714 + ``` 715 + 716 + **Step 2: Run local test** 717 + 718 + Open the HTML file in a browser and check console for errors. 719 + 720 + **Step 3: Commit** 721 + 722 + ```bash 723 + git add examples/01-statusphere/index.html 724 + git commit -m "feat(client): add IndexedDB helpers for DPoP key storage" 725 + ``` 726 + 727 + --- 728 + 729 + ### Task 8: Add DPoP Proof Generation 730 + 731 + **Files:** 732 + - Modify: `examples/01-statusphere/index.html` 733 + 734 + **Step 1: Add DPoP proof generation function** 735 + 736 + Add after the IndexedDB helpers: 737 + 738 + ```javascript 739 + // ============================================================================= 740 + // DPOP PROOF GENERATION 741 + // ============================================================================= 742 + 743 + async function createDPoPProof(method, url, accessToken = null) { 744 + const keyData = await getOrCreateDPoPKey(); 745 + 746 + // Header 747 + const header = { 748 + alg: "ES256", 749 + typ: "dpop+jwt", 750 + jwk: keyData.publicJwk 751 + }; 752 + 753 + // Payload 754 + const payload = { 755 + jti: generateRandomId(), 756 + htm: method, 757 + htu: url, 758 + iat: Math.floor(Date.now() / 1000) 759 + }; 760 + 761 + // Add access token hash if provided (for resource requests) 762 + if (accessToken) { 763 + payload.ath = await sha256Base64Url(accessToken); 764 + } 765 + 766 + // Sign the JWT 767 + return await signJwt(header, payload, keyData.privateKey); 768 + } 769 + 770 + function generateRandomId() { 771 + const bytes = new Uint8Array(16); 772 + crypto.getRandomValues(bytes); 773 + return base64UrlEncode(bytes); 774 + } 775 + 776 + async function sha256Base64Url(data) { 777 + const encoder = new TextEncoder(); 778 + const hash = await crypto.subtle.digest("SHA-256", encoder.encode(data)); 779 + return base64UrlEncode(hash); 780 + } 781 + 782 + async function signJwt(header, payload, privateKey) { 783 + const encoder = new TextEncoder(); 784 + 785 + // Encode header and payload 786 + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); 787 + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); 788 + 789 + // Create signing input 790 + const signingInput = `${headerB64}.${payloadB64}`; 791 + 792 + // Sign 793 + const signature = await crypto.subtle.sign( 794 + { name: "ECDSA", hash: "SHA-256" }, 795 + privateKey, 796 + encoder.encode(signingInput) 797 + ); 798 + 799 + // Convert signature from IEEE P1363 to DER format is NOT needed for JWS 800 + // JWS uses the raw R||S format which is what WebCrypto provides 801 + const signatureB64 = base64UrlEncode(signature); 802 + 803 + return `${signingInput}.${signatureB64}`; 804 + } 805 + ``` 806 + 807 + **Step 2: Commit** 808 + 809 + ```bash 810 + git add examples/01-statusphere/index.html 811 + git commit -m "feat(client): add DPoP proof generation with WebCrypto" 812 + ``` 813 + 814 + --- 815 + 816 + ### Task 9: Add Multi-Tab Lock Coordination 817 + 818 + **Files:** 819 + - Modify: `examples/01-statusphere/index.html` 820 + 821 + **Step 1: Add browser-tabs-lock implementation** 822 + 823 + Add after DPoP proof generation: 824 + 825 + ```javascript 826 + // ============================================================================= 827 + // MULTI-TAB LOCK COORDINATION 828 + // ============================================================================= 829 + 830 + // Simple lock implementation using localStorage 831 + // Based on browser-tabs-lock pattern 832 + const LOCK_TIMEOUT = 5000; // 5 seconds 833 + const LOCK_PREFIX = "statusphere_lock_"; 834 + 835 + async function acquireLock(key, timeout = LOCK_TIMEOUT) { 836 + const lockKey = LOCK_PREFIX + key; 837 + const lockValue = `${Date.now()}_${Math.random()}`; 838 + const deadline = Date.now() + timeout; 839 + 840 + while (Date.now() < deadline) { 841 + const existing = localStorage.getItem(lockKey); 842 + 843 + if (existing) { 844 + // Check if lock is stale (older than timeout) 845 + const [timestamp] = existing.split("_"); 846 + if (Date.now() - parseInt(timestamp) > LOCK_TIMEOUT) { 847 + // Lock is stale, remove it 848 + localStorage.removeItem(lockKey); 849 + } else { 850 + // Lock is held, wait and retry 851 + await sleep(50); 852 + continue; 853 + } 854 + } 855 + 856 + // Try to acquire 857 + localStorage.setItem(lockKey, lockValue); 858 + 859 + // Verify we got it (handle race condition) 860 + await sleep(10); 861 + if (localStorage.getItem(lockKey) === lockValue) { 862 + return lockValue; // Lock acquired 863 + } 864 + } 865 + 866 + return null; // Failed to acquire 867 + } 868 + 869 + function releaseLock(key, lockValue) { 870 + const lockKey = LOCK_PREFIX + key; 871 + // Only release if we still hold it 872 + if (localStorage.getItem(lockKey) === lockValue) { 873 + localStorage.removeItem(lockKey); 874 + } 875 + } 876 + 877 + function sleep(ms) { 878 + return new Promise(resolve => setTimeout(resolve, ms)); 879 + } 880 + ``` 881 + 882 + **Step 2: Commit** 883 + 884 + ```bash 885 + git add examples/01-statusphere/index.html 886 + git commit -m "feat(client): add multi-tab lock coordination" 887 + ``` 888 + 889 + --- 890 + 891 + ### Task 10: Update Token Storage to Use localStorage 892 + 893 + **Files:** 894 + - Modify: `examples/01-statusphere/index.html` 895 + 896 + **Step 1: Update storage object** 897 + 898 + Replace the `storage` object (around line 421): 899 + 900 + ```javascript 901 + const storage = { 902 + get(key) { 903 + // OAuth flow state stays in sessionStorage (per-tab) 904 + if (key === STORAGE_KEYS.codeVerifier || 905 + key === STORAGE_KEYS.oauthState) { 906 + return sessionStorage.getItem(key); 907 + } 908 + // Tokens go in localStorage (shared across tabs) 909 + return localStorage.getItem(key); 910 + }, 911 + set(key, value) { 912 + if (key === STORAGE_KEYS.codeVerifier || 913 + key === STORAGE_KEYS.oauthState) { 914 + sessionStorage.setItem(key, value); 915 + } else { 916 + localStorage.setItem(key, value); 917 + } 918 + }, 919 + remove(key) { 920 + sessionStorage.removeItem(key); 921 + localStorage.removeItem(key); 922 + }, 923 + clear() { 924 + Object.values(STORAGE_KEYS).forEach((key) => { 925 + sessionStorage.removeItem(key); 926 + localStorage.removeItem(key); 927 + }); 928 + }, 929 + }; 930 + ``` 931 + 932 + **Step 2: Add token expiry tracking** 933 + 934 + Add new storage key: 935 + 936 + ```javascript 937 + const STORAGE_KEYS = { 938 + accessToken: "qs_access_token", 939 + refreshToken: "qs_refresh_token", 940 + userDid: "qs_user_did", 941 + codeVerifier: "qs_code_verifier", 942 + oauthState: "qs_oauth_state", 943 + clientId: "qs_client_id", 944 + tokenExpiresAt: "qs_token_expires_at", // NEW 945 + dpopJkt: "qs_dpop_jkt", // NEW 946 + }; 947 + ``` 948 + 949 + **Step 3: Commit** 950 + 951 + ```bash 952 + git add examples/01-statusphere/index.html 953 + git commit -m "feat(client): update token storage to use localStorage with expiry" 954 + ``` 955 + 956 + --- 957 + 958 + ### Task 11: Update OAuth Token Exchange to Use DPoP 959 + 960 + **Files:** 961 + - Modify: `examples/01-statusphere/index.html` 962 + 963 + **Step 1: Update handleOAuthCallback to send DPoP proof** 964 + 965 + Replace the token exchange code in `handleOAuthCallback` (around line 536): 966 + 967 + ```javascript 968 + // Exchange code for tokens with DPoP 969 + const dpopProof = await createDPoPProof("POST", OAUTH_TOKEN_URL); 970 + 971 + const tokenResponse = await fetch(OAUTH_TOKEN_URL, { 972 + method: "POST", 973 + headers: { 974 + "Content-Type": "application/x-www-form-urlencoded", 975 + "DPoP": dpopProof 976 + }, 977 + body: new URLSearchParams({ 978 + grant_type: "authorization_code", 979 + code: code, 980 + redirect_uri: redirectUri, 981 + client_id: clientId, 982 + code_verifier: codeVerifier, 983 + }), 984 + }); 985 + ``` 986 + 987 + **Step 2: Update token storage to include expiry** 988 + 989 + After receiving tokens, store expiry: 990 + 991 + ```javascript 992 + const tokens = await tokenResponse.json(); 993 + 994 + // Store tokens 995 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 996 + if (tokens.refresh_token) { 997 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 998 + } 999 + 1000 + // Store expiry time 1001 + const expiresAt = Date.now() + (tokens.expires_in * 1000); 1002 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 1003 + ``` 1004 + 1005 + **Step 3: Commit** 1006 + 1007 + ```bash 1008 + git add examples/01-statusphere/index.html 1009 + git commit -m "feat(client): send DPoP proof with token exchange" 1010 + ``` 1011 + 1012 + --- 1013 + 1014 + ### Task 12: Add Token Refresh with DPoP 1015 + 1016 + **Files:** 1017 + - Modify: `examples/01-statusphere/index.html` 1018 + 1019 + **Step 1: Add token refresh function** 1020 + 1021 + Add after `handleOAuthCallback`: 1022 + 1023 + ```javascript 1024 + async function refreshTokens() { 1025 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 1026 + const clientId = storage.get(STORAGE_KEYS.clientId); 1027 + 1028 + if (!refreshToken || !clientId) { 1029 + throw new Error("No refresh token available"); 1030 + } 1031 + 1032 + const dpopProof = await createDPoPProof("POST", OAUTH_TOKEN_URL); 1033 + 1034 + const response = await fetch(OAUTH_TOKEN_URL, { 1035 + method: "POST", 1036 + headers: { 1037 + "Content-Type": "application/x-www-form-urlencoded", 1038 + "DPoP": dpopProof 1039 + }, 1040 + body: new URLSearchParams({ 1041 + grant_type: "refresh_token", 1042 + refresh_token: refreshToken, 1043 + client_id: clientId, 1044 + }), 1045 + }); 1046 + 1047 + if (!response.ok) { 1048 + const errorData = await response.json().catch(() => ({})); 1049 + throw new Error( 1050 + `Token refresh failed: ${errorData.error_description || response.statusText}` 1051 + ); 1052 + } 1053 + 1054 + const tokens = await response.json(); 1055 + 1056 + // Store new tokens (rotation - new refresh token each time) 1057 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 1058 + if (tokens.refresh_token) { 1059 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 1060 + } 1061 + 1062 + const expiresAt = Date.now() + (tokens.expires_in * 1000); 1063 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 1064 + 1065 + return tokens.access_token; 1066 + } 1067 + ``` 1068 + 1069 + **Step 2: Add getAccessToken with auto-refresh and locking** 1070 + 1071 + ```javascript 1072 + async function getValidAccessToken() { 1073 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 1074 + const expiresAt = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || "0"); 1075 + 1076 + // Check if token is still valid (with 60 second buffer) 1077 + if (accessToken && Date.now() < expiresAt - 60000) { 1078 + return accessToken; 1079 + } 1080 + 1081 + // Need to refresh - acquire lock first 1082 + const clientId = storage.get(STORAGE_KEYS.clientId); 1083 + const lockKey = `token_refresh_${clientId}`; 1084 + const lockValue = await acquireLock(lockKey); 1085 + 1086 + if (!lockValue) { 1087 + // Failed to acquire lock, another tab is refreshing 1088 + // Wait a bit and check cache again 1089 + await sleep(100); 1090 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 1091 + const freshExpiry = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || "0"); 1092 + if (freshToken && Date.now() < freshExpiry - 60000) { 1093 + return freshToken; 1094 + } 1095 + throw new Error("Failed to refresh token"); 1096 + } 1097 + 1098 + try { 1099 + // Double-check after acquiring lock 1100 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 1101 + const freshExpiry = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || "0"); 1102 + if (freshToken && Date.now() < freshExpiry - 60000) { 1103 + return freshToken; 1104 + } 1105 + 1106 + // Actually refresh 1107 + return await refreshTokens(); 1108 + } finally { 1109 + releaseLock(lockKey, lockValue); 1110 + } 1111 + } 1112 + ``` 1113 + 1114 + **Step 3: Commit** 1115 + 1116 + ```bash 1117 + git add examples/01-statusphere/index.html 1118 + git commit -m "feat(client): add token refresh with DPoP and multi-tab locking" 1119 + ``` 1120 + 1121 + --- 1122 + 1123 + ### Task 13: Update GraphQL Requests to Use DPoP 1124 + 1125 + **Files:** 1126 + - Modify: `examples/01-statusphere/index.html` 1127 + 1128 + **Step 1: Update graphqlQuery to use DPoP** 1129 + 1130 + Replace the `graphqlQuery` function: 1131 + 1132 + ```javascript 1133 + async function graphqlQuery(query, variables = {}, requireAuth = false) { 1134 + const headers = { 1135 + "Content-Type": "application/json", 1136 + }; 1137 + 1138 + if (requireAuth) { 1139 + const token = await getValidAccessToken(); 1140 + if (!token) { 1141 + throw new Error("Not authenticated"); 1142 + } 1143 + 1144 + // Create DPoP proof bound to this request 1145 + const dpopProof = await createDPoPProof("POST", GRAPHQL_URL, token); 1146 + 1147 + headers["Authorization"] = `DPoP ${token}`; 1148 + headers["DPoP"] = dpopProof; 1149 + } 1150 + 1151 + const response = await fetch(GRAPHQL_URL, { 1152 + method: "POST", 1153 + headers, 1154 + body: JSON.stringify({ query, variables }), 1155 + }); 1156 + 1157 + if (!response.ok) { 1158 + throw new Error(`GraphQL request failed: ${response.statusText}`); 1159 + } 1160 + 1161 + const result = await response.json(); 1162 + 1163 + if (result.errors && result.errors.length > 0) { 1164 + throw new Error(`GraphQL error: ${result.errors[0].message}`); 1165 + } 1166 + 1167 + return result.data; 1168 + } 1169 + ``` 1170 + 1171 + **Step 2: Commit** 1172 + 1173 + ```bash 1174 + git add examples/01-statusphere/index.html 1175 + git commit -m "feat(client): update GraphQL requests to use DPoP authorization" 1176 + ``` 1177 + 1178 + --- 1179 + 1180 + ### Task 14: Update Main Initialization 1181 + 1182 + **Files:** 1183 + - Modify: `examples/01-statusphere/index.html` 1184 + 1185 + **Step 1: Initialize DPoP key on app start** 1186 + 1187 + Update the `main` function to initialize the DPoP key early: 1188 + 1189 + ```javascript 1190 + async function main() { 1191 + try { 1192 + // Initialize DPoP key first (creates if doesn't exist) 1193 + await getOrCreateDPoPKey(); 1194 + console.log("DPoP key initialized"); 1195 + } catch (error) { 1196 + console.error("Failed to initialize DPoP key:", error); 1197 + showError("Failed to initialize secure key storage. Please use a modern browser."); 1198 + return; 1199 + } 1200 + 1201 + try { 1202 + // Check if this is an OAuth callback 1203 + const isCallback = await handleOAuthCallback(); 1204 + if (isCallback) { 1205 + console.log("OAuth callback handled successfully"); 1206 + } 1207 + } catch (error) { 1208 + showError(`Authentication failed: ${error.message}`); 1209 + storage.clear(); 1210 + } 1211 + 1212 + // ... rest of existing main() code ... 1213 + } 1214 + ``` 1215 + 1216 + **Step 2: Commit** 1217 + 1218 + ```bash 1219 + git add examples/01-statusphere/index.html 1220 + git commit -m "feat(client): initialize DPoP key on app start" 1221 + ``` 1222 + 1223 + --- 1224 + 1225 + ## Part 3: Server Resource Endpoint Protection 1226 + 1227 + ### Task 15: Add DPoP Validation Middleware for GraphQL 1228 + 1229 + **Files:** 1230 + - Create: `server/src/middleware/dpop.gleam` (or add to existing auth middleware) 1231 + 1232 + **Step 1: Create DPoP validation for resource requests** 1233 + 1234 + ```gleam 1235 + /// DPoP validation middleware for protected resources 1236 + import database/repositories/oauth_access_tokens 1237 + import database/repositories/oauth_dpop_jti 1238 + import gleam/option.{type Option, None, Some} 1239 + import gleam/result 1240 + import gleam/string 1241 + import lib/oauth/dpop/validator 1242 + import sqlight 1243 + import wisp 1244 + 1245 + /// Validate DPoP-bound access token 1246 + /// Returns the user_id if valid, or an error response 1247 + pub fn validate_dpop_access( 1248 + req: wisp.Request, 1249 + conn: sqlight.Connection, 1250 + resource_url: String, 1251 + ) -> Result(String, wisp.Response) { 1252 + // Extract Authorization header 1253 + let auth_header = wisp.get_header(req, "authorization") 1254 + 1255 + case auth_header { 1256 + None -> Error(unauthorized("Missing Authorization header")) 1257 + Some(header) -> { 1258 + // Parse "DPoP <token>" or "Bearer <token>" 1259 + case string.split(header, " ") { 1260 + ["DPoP", token] -> validate_dpop_token(req, conn, token, resource_url) 1261 + ["Bearer", token] -> validate_bearer_token(conn, token) 1262 + _ -> Error(unauthorized("Invalid Authorization header format")) 1263 + } 1264 + } 1265 + } 1266 + } 1267 + 1268 + fn validate_dpop_token( 1269 + req: wisp.Request, 1270 + conn: sqlight.Connection, 1271 + token: String, 1272 + resource_url: String, 1273 + ) -> Result(String, wisp.Response) { 1274 + // Get DPoP proof from header 1275 + case validator.get_dpop_header(wisp.get_headers(req)) { 1276 + None -> Error(unauthorized("Missing DPoP proof for DPoP-bound token")) 1277 + Some(dpop_proof) -> { 1278 + // Verify the DPoP proof 1279 + let method = wisp.method_to_string(req.method) 1280 + case validator.verify_dpop_proof(dpop_proof, method, resource_url, 300) { 1281 + Error(reason) -> Error(unauthorized("Invalid DPoP proof: " <> reason)) 1282 + Ok(dpop_result) -> { 1283 + // Check JTI for replay 1284 + case oauth_dpop_jti.use_jti(conn, dpop_result.jti, dpop_result.iat) { 1285 + Error(_) -> Error(server_error("Database error")) 1286 + Ok(False) -> Error(unauthorized("DPoP proof replay detected")) 1287 + Ok(True) -> { 1288 + // Get the access token and verify JKT matches 1289 + case oauth_access_tokens.get(conn, token) { 1290 + Error(_) -> Error(server_error("Database error")) 1291 + Ok(None) -> Error(unauthorized("Invalid access token")) 1292 + Ok(Some(access_token)) -> { 1293 + case access_token.dpop_jkt { 1294 + None -> Error(unauthorized("Token is not DPoP-bound")) 1295 + Some(jkt) -> { 1296 + case jkt == dpop_result.jkt { 1297 + False -> Error(unauthorized("DPoP key mismatch")) 1298 + True -> { 1299 + case access_token.user_id { 1300 + None -> Error(unauthorized("Token has no user")) 1301 + Some(user_id) -> Ok(user_id) 1302 + } 1303 + } 1304 + } 1305 + } 1306 + } 1307 + } 1308 + } 1309 + } 1310 + } 1311 + } 1312 + } 1313 + } 1314 + } 1315 + } 1316 + 1317 + fn validate_bearer_token( 1318 + conn: sqlight.Connection, 1319 + token: String, 1320 + ) -> Result(String, wisp.Response) { 1321 + case oauth_access_tokens.get(conn, token) { 1322 + Error(_) -> Error(server_error("Database error")) 1323 + Ok(None) -> Error(unauthorized("Invalid access token")) 1324 + Ok(Some(access_token)) -> { 1325 + // DPoP-bound tokens MUST use DPoP authorization 1326 + case access_token.dpop_jkt { 1327 + Some(_) -> Error(unauthorized("DPoP-bound token requires DPoP authorization")) 1328 + None -> { 1329 + case access_token.user_id { 1330 + None -> Error(unauthorized("Token has no user")) 1331 + Some(user_id) -> Ok(user_id) 1332 + } 1333 + } 1334 + } 1335 + } 1336 + } 1337 + } 1338 + 1339 + fn unauthorized(message: String) -> wisp.Response { 1340 + wisp.response(401) 1341 + |> wisp.set_header("content-type", "application/json") 1342 + |> wisp.set_body(wisp.Text("{\"error\":\"" <> message <> "\"}")) 1343 + } 1344 + 1345 + fn server_error(message: String) -> wisp.Response { 1346 + wisp.response(500) 1347 + |> wisp.set_header("content-type", "application/json") 1348 + |> wisp.set_body(wisp.Text("{\"error\":\"" <> message <> "\"}")) 1349 + } 1350 + ``` 1351 + 1352 + **Step 2: Commit** 1353 + 1354 + ```bash 1355 + git add server/src/middleware/dpop.gleam 1356 + git commit -m "feat(oauth): add DPoP validation middleware for resource endpoints" 1357 + ``` 1358 + 1359 + --- 1360 + 1361 + ## Part 4: Testing 1362 + 1363 + ### Task 16: Add DPoP Validator Tests 1364 + 1365 + **Files:** 1366 + - Create: `server/test/oauth/dpop_validator_test.gleam` 1367 + 1368 + **Step 1: Write tests for DPoP validation** 1369 + 1370 + ```gleam 1371 + import gleeunit/should 1372 + import lib/oauth/dpop/validator 1373 + import lib/oauth/dpop/keygen 1374 + import lib/oauth/dpop/generator 1375 + import gleam/option 1376 + 1377 + pub fn verify_valid_dpop_proof_test() { 1378 + // Generate a key pair 1379 + let assert Ok(jwk_json) = keygen.generate_dpop_keypair() 1380 + 1381 + // Generate a proof 1382 + let assert Ok(proof) = generator.generate_dpop_proof_with_nonce( 1383 + "POST", 1384 + "https://example.com/oauth/token", 1385 + "", // No access token for token exchange 1386 + jwk_json, 1387 + option.None 1388 + ) 1389 + 1390 + // Verify the proof 1391 + let result = validator.verify_dpop_proof( 1392 + proof, 1393 + "POST", 1394 + "https://example.com/oauth/token", 1395 + 300 1396 + ) 1397 + 1398 + should.be_ok(result) 1399 + } 1400 + 1401 + pub fn reject_wrong_method_test() { 1402 + let assert Ok(jwk_json) = keygen.generate_dpop_keypair() 1403 + let assert Ok(proof) = generator.generate_dpop_proof_with_nonce( 1404 + "POST", 1405 + "https://example.com/oauth/token", 1406 + "", 1407 + jwk_json, 1408 + option.None 1409 + ) 1410 + 1411 + // Verify with wrong method 1412 + let result = validator.verify_dpop_proof( 1413 + proof, 1414 + "GET", // Wrong method 1415 + "https://example.com/oauth/token", 1416 + 300 1417 + ) 1418 + 1419 + should.be_error(result) 1420 + } 1421 + 1422 + pub fn reject_wrong_url_test() { 1423 + let assert Ok(jwk_json) = keygen.generate_dpop_keypair() 1424 + let assert Ok(proof) = generator.generate_dpop_proof_with_nonce( 1425 + "POST", 1426 + "https://example.com/oauth/token", 1427 + "", 1428 + jwk_json, 1429 + option.None 1430 + ) 1431 + 1432 + // Verify with wrong URL 1433 + let result = validator.verify_dpop_proof( 1434 + proof, 1435 + "POST", 1436 + "https://different.com/oauth/token", // Wrong URL 1437 + 300 1438 + ) 1439 + 1440 + should.be_error(result) 1441 + } 1442 + ``` 1443 + 1444 + **Step 2: Run tests** 1445 + 1446 + Run: `cd /Users/chadmiller/code/quickslice/server && gleam test` 1447 + Expected: All tests pass 1448 + 1449 + **Step 3: Commit** 1450 + 1451 + ```bash 1452 + git add server/test/oauth/dpop_validator_test.gleam 1453 + git commit -m "test(oauth): add DPoP validator tests" 1454 + ``` 1455 + 1456 + --- 1457 + 1458 + ### Task 17: Manual End-to-End Test 1459 + 1460 + **Step 1: Start the server** 1461 + 1462 + Run: `cd /Users/chadmiller/code/quickslice && make run` 1463 + 1464 + **Step 2: Register a public OAuth client** 1465 + 1466 + Navigate to `http://localhost:8080/admin/settings` and create: 1467 + - Name: "Statusphere DPoP Test" 1468 + - Token Endpoint Auth Method: Public (none) 1469 + - Redirect URIs: `http://127.0.0.1:3000/` 1470 + 1471 + **Step 3: Serve the example** 1472 + 1473 + Run: `cd /Users/chadmiller/code/quickslice/examples/01-statusphere && npx http-server . -p 3000` 1474 + 1475 + **Step 4: Test the flow** 1476 + 1477 + 1. Open `http://127.0.0.1:3000` 1478 + 2. Open browser DevTools Network tab 1479 + 3. Enter client ID and Bluesky handle 1480 + 4. Complete OAuth flow 1481 + 5. Verify: 1482 + - Token request includes `DPoP` header 1483 + - Token response has `token_type: "DPoP"` 1484 + - GraphQL requests include `Authorization: DPoP` and `DPoP` headers 1485 + 6. Open same URL in new tab - verify tokens are shared 1486 + 7. Refresh page - verify session persists 1487 + 1488 + **Step 5: Commit final changes** 1489 + 1490 + ```bash 1491 + git add -A 1492 + git commit -m "feat: complete secure public OAuth with DPoP implementation" 1493 + ``` 1494 + 1495 + --- 1496 + 1497 + ## Summary 1498 + 1499 + This plan implements: 1500 + 1501 + 1. **Server-side DPoP validation** (Tasks 1-6) 1502 + - DPoP proof verification in `jose_ffi.erl` 1503 + - Gleam validator module 1504 + - JTI replay protection 1505 + - Token endpoint integration 1506 + - DPoP token type support 1507 + 1508 + 2. **Client-side DPoP implementation** (Tasks 7-14) 1509 + - IndexedDB for non-extractable DPoP key storage 1510 + - WebCrypto-based DPoP proof generation 1511 + - Multi-tab lock coordination 1512 + - localStorage for token persistence 1513 + - Automatic token refresh with DPoP 1514 + 1515 + 3. **Resource endpoint protection** (Task 15) 1516 + - DPoP validation middleware for GraphQL 1517 + 1518 + 4. **Testing** (Tasks 16-17) 1519 + - Unit tests for DPoP validation 1520 + - End-to-end manual testing
+303 -470
examples/01-statusphere/index.html
··· 366 366 </main> 367 367 <div id="error-banner" class="hidden"></div> 368 368 </div> 369 + 370 + <!-- Quickslice Client SDK --> 371 + <script src="../../quickslice-client-js/dist/quickslice-client.min.js"></script> 372 + 369 373 <script> 370 374 // ============================================================================= 371 - // CONSTANTS 375 + // CONFIGURATION 372 376 // ============================================================================= 373 377 374 - const GRAPHQL_URL = "http://localhost:8080/graphql"; 375 - const OAUTH_AUTHORIZE_URL = "http://localhost:8080/oauth/authorize"; 376 - const OAUTH_TOKEN_URL = "http://localhost:8080/oauth/token"; 378 + const SERVER_URL = "http://localhost:8080"; 377 379 378 380 const EMOJIS = [ 379 381 "👍", ··· 405 407 "💯", 406 408 ]; 407 409 408 - const STORAGE_KEYS = { 409 - accessToken: "qs_access_token", 410 - refreshToken: "qs_refresh_token", 411 - userDid: "qs_user_did", 412 - codeVerifier: "qs_code_verifier", 413 - oauthState: "qs_oauth_state", 414 - clientId: "qs_client_id", 415 - }; 410 + // Client instance 411 + let client; 416 412 417 413 // ============================================================================= 418 - // STORAGE UTILITIES 414 + // INITIALIZATION 419 415 // ============================================================================= 420 416 421 - const storage = { 422 - get(key) { 423 - return sessionStorage.getItem(key); 424 - }, 425 - set(key, value) { 426 - sessionStorage.setItem(key, value); 427 - }, 428 - remove(key) { 429 - sessionStorage.removeItem(key); 430 - }, 431 - clear() { 432 - Object.values(STORAGE_KEYS).forEach((key) => 433 - sessionStorage.removeItem(key), 434 - ); 435 - }, 436 - }; 417 + async function main() { 418 + // Get client ID from localStorage if previously saved 419 + const savedClientId = localStorage.getItem("statusphere_client_id"); 437 420 438 - // ============================================================================= 439 - // PKCE UTILITIES 440 - // ============================================================================= 421 + // Check if this is an OAuth callback 422 + if (window.location.search.includes("code=")) { 423 + if (!savedClientId) { 424 + showError( 425 + "OAuth callback received but no client ID found. Please login again.", 426 + ); 427 + renderLoginForm(); 428 + return; 429 + } 441 430 442 - function base64UrlEncode(buffer) { 443 - const bytes = new Uint8Array(buffer); 444 - let binary = ""; 445 - for (let i = 0; i < bytes.length; i++) { 446 - binary += String.fromCharCode(bytes[i]); 431 + try { 432 + client = await QuicksliceClient.createQuicksliceClient({ 433 + server: SERVER_URL, 434 + clientId: savedClientId, 435 + }); 436 + await client.handleRedirectCallback(); 437 + console.log("OAuth callback handled successfully"); 438 + } catch (error) { 439 + console.error("OAuth callback error:", error); 440 + showError(`Authentication failed: ${error.message}`); 441 + renderLoginForm(); 442 + renderEmojiPicker(null, false); 443 + await loadAndRenderStatuses(); 444 + return; 445 + } 446 + } else if (savedClientId) { 447 + // Initialize client with saved ID 448 + try { 449 + client = await QuicksliceClient.createQuicksliceClient({ 450 + server: SERVER_URL, 451 + clientId: savedClientId, 452 + }); 453 + } catch (error) { 454 + console.error("Failed to initialize client:", error); 455 + } 447 456 } 448 - return btoa(binary) 449 - .replace(/\+/g, "-") 450 - .replace(/\//g, "_") 451 - .replace(/=+$/, ""); 452 - } 453 457 454 - async function generateCodeVerifier() { 455 - const randomBytes = new Uint8Array(32); 456 - crypto.getRandomValues(randomBytes); 457 - return base64UrlEncode(randomBytes); 458 + // Render based on auth state 459 + await renderApp(); 458 460 } 459 461 460 - async function generateCodeChallenge(verifier) { 461 - const encoder = new TextEncoder(); 462 - const data = encoder.encode(verifier); 463 - const hash = await crypto.subtle.digest("SHA-256", data); 464 - return base64UrlEncode(hash); 465 - } 462 + async function renderApp() { 463 + const isLoggedIn = client && (await client.isAuthenticated()); 464 + 465 + if (isLoggedIn) { 466 + try { 467 + const viewer = await fetchViewer(); 468 + renderUserCard(viewer); 469 + } catch (error) { 470 + console.error("Failed to fetch viewer:", error); 471 + renderUserCard(null); 472 + } 473 + } else { 474 + renderLoginForm(); 475 + } 466 476 467 - function generateState() { 468 - const randomBytes = new Uint8Array(16); 469 - crypto.getRandomValues(randomBytes); 470 - return base64UrlEncode(randomBytes); 477 + // Render emoji picker (enabled only if logged in) 478 + renderEmojiPicker(null, isLoggedIn); 479 + 480 + // Load statuses 481 + await loadAndRenderStatuses(); 471 482 } 472 483 473 484 // ============================================================================= 474 - // OAUTH FUNCTIONS 485 + // DATA FETCHING 475 486 // ============================================================================= 476 487 477 - async function initiateLogin(clientId, handle) { 478 - const codeVerifier = await generateCodeVerifier(); 479 - const codeChallenge = await generateCodeChallenge(codeVerifier); 480 - const state = generateState(); 481 - 482 - // Store for callback 483 - storage.set(STORAGE_KEYS.codeVerifier, codeVerifier); 484 - storage.set(STORAGE_KEYS.oauthState, state); 485 - storage.set(STORAGE_KEYS.clientId, clientId); 486 - 487 - // Build redirect URI (current page without query params) 488 - const redirectUri = window.location.origin + window.location.pathname; 489 - 490 - // Build authorization URL 491 - const params = new URLSearchParams({ 492 - client_id: clientId, 493 - redirect_uri: redirectUri, 494 - response_type: "code", 495 - code_challenge: codeChallenge, 496 - code_challenge_method: "S256", 497 - state: state, 498 - login_hint: handle, 499 - }); 500 - 501 - window.location.href = `${OAUTH_AUTHORIZE_URL}?${params.toString()}`; 502 - } 503 - 504 - async function handleOAuthCallback() { 505 - const params = new URLSearchParams(window.location.search); 506 - const code = params.get("code"); 507 - const state = params.get("state"); 508 - const error = params.get("error"); 488 + async function fetchStatuses() { 489 + const query = ` 490 + query GetStatuses { 491 + xyzStatusphereStatus( 492 + first: 20 493 + sortBy: [{ field: "createdAt", direction: DESC }] 494 + ) { 495 + edges { 496 + node { 497 + uri 498 + did 499 + status 500 + createdAt 501 + appBskyActorProfileByDid { 502 + actorHandle 503 + displayName 504 + } 505 + } 506 + } 507 + } 508 + } 509 + `; 509 510 510 - if (error) { 511 - throw new Error( 512 - `OAuth error: ${error} - ${params.get("error_description") || ""}`, 511 + // Use client if available, otherwise create a temporary one for public query 512 + if (client) { 513 + const data = await client.publicQuery(query); 514 + return data.xyzStatusphereStatus?.edges?.map((e) => e.node) || []; 515 + } else { 516 + // For unauthenticated users, make a direct fetch 517 + const response = await fetch(`${SERVER_URL}/graphql`, { 518 + method: "POST", 519 + headers: { "Content-Type": "application/json" }, 520 + body: JSON.stringify({ query }), 521 + }); 522 + const result = await response.json(); 523 + return ( 524 + result.data?.xyzStatusphereStatus?.edges?.map((e) => e.node) || [] 513 525 ); 514 526 } 527 + } 515 528 516 - if (!code || !state) { 517 - return false; // Not a callback 518 - } 529 + async function fetchViewer() { 530 + const query = ` 531 + query { 532 + viewer { 533 + did 534 + handle 535 + appBskyActorProfileByDid { 536 + displayName 537 + avatar { url } 538 + } 539 + } 540 + } 541 + `; 519 542 520 - // Verify state 521 - const storedState = storage.get(STORAGE_KEYS.oauthState); 522 - if (state !== storedState) { 523 - throw new Error("OAuth state mismatch - possible CSRF attack"); 524 - } 525 - 526 - // Get stored values 527 - const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 528 - const clientId = storage.get(STORAGE_KEYS.clientId); 529 - const redirectUri = window.location.origin + window.location.pathname; 530 - 531 - if (!codeVerifier || !clientId) { 532 - throw new Error("Missing OAuth session data"); 533 - } 534 - 535 - // Exchange code for tokens 536 - const tokenResponse = await fetch(OAUTH_TOKEN_URL, { 537 - method: "POST", 538 - headers: { 539 - "Content-Type": "application/x-www-form-urlencoded", 540 - }, 541 - body: new URLSearchParams({ 542 - grant_type: "authorization_code", 543 - code: code, 544 - redirect_uri: redirectUri, 545 - client_id: clientId, 546 - code_verifier: codeVerifier, 547 - }), 548 - }); 549 - 550 - if (!tokenResponse.ok) { 551 - const errorData = await tokenResponse.json().catch(() => ({})); 552 - throw new Error( 553 - `Token exchange failed: ${errorData.error_description || tokenResponse.statusText}`, 554 - ); 555 - } 556 - 557 - const tokens = await tokenResponse.json(); 558 - 559 - // Store tokens 560 - storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 561 - if (tokens.refresh_token) { 562 - storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 563 - } 564 - 565 - // Extract DID from token response (sub claim) or we'll fetch it later 566 - if (tokens.sub) { 567 - storage.set(STORAGE_KEYS.userDid, tokens.sub); 568 - } 569 - 570 - // Clean up OAuth state 571 - storage.remove(STORAGE_KEYS.codeVerifier); 572 - storage.remove(STORAGE_KEYS.oauthState); 573 - 574 - // Clear URL params 575 - window.history.replaceState( 576 - {}, 577 - document.title, 578 - window.location.pathname, 579 - ); 580 - 581 - return true; 543 + const data = await client.query(query); 544 + return data?.viewer; 582 545 } 583 546 584 - function logout() { 585 - storage.clear(); 586 - window.location.reload(); 587 - } 547 + async function postStatus(emoji) { 548 + const mutation = ` 549 + mutation CreateStatus($status: String!, $createdAt: DateTime!) { 550 + createXyzStatusphereStatus( 551 + input: { status: $status, createdAt: $createdAt } 552 + ) { 553 + uri 554 + status 555 + createdAt 556 + } 557 + } 558 + `; 588 559 589 - function isLoggedIn() { 590 - return !!storage.get(STORAGE_KEYS.accessToken); 591 - } 560 + const variables = { 561 + status: emoji, 562 + createdAt: new Date().toISOString(), 563 + }; 592 564 593 - function getAccessToken() { 594 - return storage.get(STORAGE_KEYS.accessToken); 565 + return await client.mutate(mutation, variables); 595 566 } 596 567 597 - function getUserDid() { 598 - return storage.get(STORAGE_KEYS.userDid); 568 + async function loadAndRenderStatuses() { 569 + renderLoading("status-feed"); 570 + try { 571 + const statuses = await fetchStatuses(); 572 + renderStatusFeed(statuses); 573 + } catch (error) { 574 + console.error("Failed to fetch statuses:", error); 575 + document.getElementById("status-feed").innerHTML = ` 576 + <div class="card"> 577 + <p class="loading" style="color: var(--error-text);"> 578 + Failed to load statuses. Is the quickslice server running at ${SERVER_URL}? 579 + </p> 580 + </div> 581 + `; 582 + } 599 583 } 600 584 601 585 // ============================================================================= 602 - // GRAPHQL UTILITIES 586 + // EVENT HANDLERS 603 587 // ============================================================================= 604 588 605 - async function graphqlQuery(query, variables = {}, requireAuth = false) { 606 - const headers = { 607 - "Content-Type": "application/json", 608 - }; 589 + async function handleLogin(event) { 590 + event.preventDefault(); 609 591 610 - if (requireAuth) { 611 - const token = getAccessToken(); 612 - if (!token) { 613 - throw new Error("Not authenticated"); 614 - } 615 - headers["Authorization"] = `Bearer ${token}`; 592 + const clientId = document.getElementById("client-id").value.trim(); 593 + const handle = document.getElementById("handle").value.trim(); 594 + 595 + if (!clientId || !handle) { 596 + showError("Please enter both Client ID and Handle"); 597 + return; 616 598 } 617 599 618 - const response = await fetch(GRAPHQL_URL, { 619 - method: "POST", 620 - headers, 621 - body: JSON.stringify({ query, variables }), 622 - }); 600 + try { 601 + // Save client ID for callback 602 + localStorage.setItem("statusphere_client_id", clientId); 623 603 624 - if (!response.ok) { 625 - throw new Error(`GraphQL request failed: ${response.statusText}`); 626 - } 604 + client = await QuicksliceClient.createQuicksliceClient({ 605 + server: SERVER_URL, 606 + clientId: clientId, 607 + }); 627 608 628 - const result = await response.json(); 609 + await client.loginWithRedirect({ handle }); 610 + } catch (error) { 611 + showError(`Login failed: ${error.message}`); 612 + } 613 + } 629 614 630 - if (result.errors && result.errors.length > 0) { 631 - throw new Error(`GraphQL error: ${result.errors[0].message}`); 615 + async function selectStatus(emoji) { 616 + if (!client || !(await client.isAuthenticated())) { 617 + showError("Please login to set your status"); 618 + return; 632 619 } 633 620 634 - return result.data; 635 - } 621 + try { 622 + // Disable buttons while posting 623 + document 624 + .querySelectorAll(".emoji-btn") 625 + .forEach((btn) => (btn.disabled = true)); 636 626 637 - // ============================================================================= 638 - // DATA FETCHING 639 - // ============================================================================= 627 + await postStatus(emoji); 640 628 641 - async function fetchStatuses() { 642 - const query = ` 643 - query GetStatuses { 644 - xyzStatusphereStatus( 645 - first: 20 646 - sortBy: [{ field: "createdAt", direction: DESC }] 647 - ) { 648 - edges { 649 - node { 650 - uri 651 - did 652 - status 653 - createdAt 654 - appBskyActorProfileByDid { 655 - actorHandle 656 - displayName 657 - } 658 - } 629 + // Refresh the page to show new status 630 + window.location.reload(); 631 + } catch (error) { 632 + showError(`Failed to post status: ${error.message}`); 633 + // Re-enable buttons 634 + document 635 + .querySelectorAll(".emoji-btn") 636 + .forEach((btn) => (btn.disabled = false)); 659 637 } 660 638 } 661 - } 662 - `; 663 639 664 - const data = await graphqlQuery(query); 665 - return data.xyzStatusphereStatus?.edges?.map((e) => e.node) || []; 666 - } 667 - 668 - async function fetchViewer() { 669 - const query = ` 670 - query { 671 - viewer { 672 - did 673 - handle 674 - appBskyActorProfileByDid { 675 - displayName 676 - avatar { url } 640 + function logout() { 641 + localStorage.removeItem("statusphere_client_id"); 642 + if (client) { 643 + client.logout(); 644 + } else { 645 + window.location.reload(); 677 646 } 678 647 } 679 - } 680 - `; 681 - 682 - const data = await graphqlQuery(query, {}, true); 683 - return data?.viewer; 684 - } 685 - 686 - async function postStatus(emoji) { 687 - const mutation = ` 688 - mutation CreateStatus($status: String!, $createdAt: DateTime!) { 689 - createXyzStatusphereStatus( 690 - input: { status: $status, createdAt: $createdAt } 691 - ) { 692 - uri 693 - status 694 - createdAt 695 - } 696 - } 697 - `; 698 - 699 - const variables = { 700 - status: emoji, 701 - createdAt: new Date().toISOString(), 702 - }; 703 - 704 - const data = await graphqlQuery(mutation, variables, true); 705 - return data.createXyzStatusphereStatus; 706 - } 707 648 708 649 // ============================================================================= 709 650 // UI RENDERING ··· 712 653 function showError(message) { 713 654 const banner = document.getElementById("error-banner"); 714 655 banner.innerHTML = ` 715 - <span>${escapeHtml(message)}</span> 716 - <button onclick="hideError()">&times;</button> 717 - `; 656 + <span>${escapeHtml(message)}</span> 657 + <button onclick="hideError()">&times;</button> 658 + `; 718 659 banner.classList.remove("hidden"); 719 660 } 720 661 ··· 747 688 748 689 function renderLoginForm() { 749 690 const container = document.getElementById("auth-section"); 750 - const savedClientId = storage.get(STORAGE_KEYS.clientId) || ""; 691 + const savedClientId = 692 + localStorage.getItem("statusphere_client_id") || ""; 751 693 752 694 container.innerHTML = ` 753 - <div class="card"> 754 - <form class="login-form" onsubmit="handleLogin(event)"> 755 - <div class="form-group"> 756 - <label for="client-id">OAuth Client ID</label> 757 - <input 758 - type="text" 759 - id="client-id" 760 - placeholder="your-client-id" 761 - value="${escapeHtml(savedClientId)}" 762 - required 763 - > 764 - </div> 765 - <div class="form-group"> 766 - <label for="handle">Bluesky Handle</label> 767 - <input 768 - type="text" 769 - id="handle" 770 - placeholder="you.bsky.social" 771 - required 772 - > 773 - </div> 774 - <button type="submit" class="btn btn-primary">Login with Bluesky</button> 775 - </form> 776 - <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;"> 777 - Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a> 778 - </p> 779 - </div> 780 - `; 695 + <div class="card"> 696 + <form class="login-form" onsubmit="handleLogin(event)"> 697 + <div class="form-group"> 698 + <label for="client-id">OAuth Client ID</label> 699 + <input 700 + type="text" 701 + id="client-id" 702 + placeholder="client_abc123..." 703 + value="${escapeHtml(savedClientId)}" 704 + required 705 + > 706 + </div> 707 + <div class="form-group"> 708 + <label for="handle">Bluesky Handle</label> 709 + <input 710 + type="text" 711 + id="handle" 712 + placeholder="you.bsky.social" 713 + required 714 + > 715 + </div> 716 + <button type="submit" class="btn btn-primary">Login with Bluesky</button> 717 + </form> 718 + <p style="margin-top: 1rem; font-size: 0.875rem; color: var(--gray-500); text-align: center;"> 719 + Don't have a Bluesky account? <a href="https://bsky.app" target="_blank">Sign up</a> 720 + </p> 721 + </div> 722 + `; 781 723 } 782 724 783 - function renderUserCard(profile) { 725 + function renderUserCard(viewer) { 784 726 const container = document.getElementById("auth-section"); 785 - const displayName = profile?.displayName || "User"; 786 - const handle = profile?.actorHandle || "unknown"; 787 - const avatarUrl = profile?.avatar?.url; 727 + const displayName = 728 + viewer?.appBskyActorProfileByDid?.displayName || "User"; 729 + const handle = viewer?.handle || "unknown"; 730 + const avatarUrl = viewer?.appBskyActorProfileByDid?.avatar?.url; 788 731 789 732 container.innerHTML = ` 790 - <div class="card user-card"> 791 - <div class="user-info"> 792 - <div class="user-avatar"> 793 - ${ 794 - avatarUrl 795 - ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` 796 - : "👤" 797 - } 798 - </div> 799 - <div> 800 - <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 801 - <div class="user-handle">@${escapeHtml(handle)}</div> 802 - </div> 803 - </div> 804 - <button class="btn btn-secondary" onclick="logout()">Logout</button> 805 - </div> 806 - `; 733 + <div class="card user-card"> 734 + <div class="user-info"> 735 + <div class="user-avatar"> 736 + ${avatarUrl ? `<img src="${escapeHtml(avatarUrl)}" alt="Avatar">` : "👤"} 737 + </div> 738 + <div> 739 + <div class="user-name">Hi, ${escapeHtml(displayName)}!</div> 740 + <div class="user-handle">@${escapeHtml(handle)}</div> 741 + </div> 742 + </div> 743 + <button class="btn btn-secondary" onclick="logout()">Logout</button> 744 + </div> 745 + `; 807 746 } 808 747 809 748 function renderEmojiPicker(currentStatus, enabled = true) { 810 749 const container = document.getElementById("emoji-picker"); 811 750 812 751 container.innerHTML = ` 813 - <div class="card"> 814 - <div class="emoji-grid"> 815 - ${EMOJIS.map( 816 - (emoji) => ` 817 - <button 818 - class="emoji-btn ${emoji === currentStatus ? "selected" : ""}" 819 - onclick="selectStatus('${emoji}')" 820 - ${!enabled ? "disabled" : ""} 821 - title="${enabled ? "Set status" : "Login to set status"}" 822 - > 823 - ${emoji} 824 - </button> 825 - `, 826 - ).join("")} 827 - </div> 828 - </div> 829 - `; 752 + <div class="card"> 753 + <div class="emoji-grid"> 754 + ${EMOJIS.map( 755 + (emoji) => ` 756 + <button 757 + class="emoji-btn ${emoji === currentStatus ? "selected" : ""}" 758 + onclick="selectStatus('${emoji}')" 759 + ${!enabled ? "disabled" : ""} 760 + title="${enabled ? "Set status" : "Login to set status"}" 761 + > 762 + ${emoji} 763 + </button> 764 + `, 765 + ).join("")} 766 + </div> 767 + </div> 768 + `; 830 769 } 831 770 832 771 function renderStatusFeed(statuses) { ··· 834 773 835 774 if (statuses.length === 0) { 836 775 container.innerHTML = ` 837 - <div class="card"> 838 - <p class="loading">No statuses yet. Be the first to post!</p> 839 - </div> 840 - `; 776 + <div class="card"> 777 + <p class="loading">No statuses yet. Be the first to post!</p> 778 + </div> 779 + `; 841 780 return; 842 781 } 843 782 844 783 container.innerHTML = ` 845 - <div class="card"> 846 - <h2 class="feed-title">Recent Statuses</h2> 847 - <ul class="status-list"> 848 - ${statuses 849 - .map((status) => { 850 - const handle = 851 - status.appBskyActorProfileByDid?.actorHandle || status.did; 852 - const displayHandle = handle.startsWith("did:") 853 - ? handle.substring(0, 20) + "..." 854 - : handle; 855 - const profileUrl = handle.startsWith("did:") 856 - ? `https://bsky.app/profile/${status.did}` 857 - : `https://bsky.app/profile/${handle}`; 784 + <div class="card"> 785 + <h2 class="feed-title">Recent Statuses</h2> 786 + <ul class="status-list"> 787 + ${statuses 788 + .map((status) => { 789 + const handle = 790 + status.appBskyActorProfileByDid?.actorHandle || status.did; 791 + const displayHandle = handle.startsWith("did:") 792 + ? handle.substring(0, 20) + "..." 793 + : handle; 794 + const profileUrl = handle.startsWith("did:") 795 + ? `https://bsky.app/profile/${status.did}` 796 + : `https://bsky.app/profile/${handle}`; 858 797 859 - return ` 860 - <li class="status-item"> 861 - <span class="status-emoji">${status.status}</span> 862 - <div class="status-content"> 863 - <span class="status-text"> 864 - <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(displayHandle)}</a> 865 - is feeling ${status.status} 866 - </span> 867 - <div class="status-date">${formatDate(status.createdAt)}</div> 868 - </div> 869 - </li> 870 - `; 871 - }) 872 - .join("")} 873 - </ul> 874 - </div> 875 - `; 798 + return ` 799 + <li class="status-item"> 800 + <span class="status-emoji">${status.status}</span> 801 + <div class="status-content"> 802 + <span class="status-text"> 803 + <a href="${profileUrl}" target="_blank" class="status-author">@${escapeHtml(displayHandle)}</a> 804 + is feeling ${status.status} 805 + </span> 806 + <div class="status-date">${formatDate(status.createdAt)}</div> 807 + </div> 808 + </li> 809 + `; 810 + }) 811 + .join("")} 812 + </ul> 813 + </div> 814 + `; 876 815 } 877 816 878 817 function renderLoading(container) { 879 818 document.getElementById(container).innerHTML = ` 880 - <div class="card"> 881 - <p class="loading">Loading...</p> 882 - </div> 883 - `; 884 - } 885 - 886 - // ============================================================================= 887 - // EVENT HANDLERS 888 - // ============================================================================= 889 - 890 - async function handleLogin(event) { 891 - event.preventDefault(); 892 - 893 - const clientId = document.getElementById("client-id").value.trim(); 894 - const handle = document.getElementById("handle").value.trim(); 895 - 896 - if (!clientId || !handle) { 897 - showError("Please enter both Client ID and Handle"); 898 - return; 899 - } 900 - 901 - try { 902 - await initiateLogin(clientId, handle); 903 - } catch (error) { 904 - showError(`Login failed: ${error.message}`); 905 - } 906 - } 907 - 908 - async function selectStatus(emoji) { 909 - if (!isLoggedIn()) { 910 - showError("Please login to set your status"); 911 - return; 912 - } 913 - 914 - try { 915 - // Disable buttons while posting 916 - document 917 - .querySelectorAll(".emoji-btn") 918 - .forEach((btn) => (btn.disabled = true)); 919 - 920 - await postStatus(emoji); 921 - 922 - // Refresh the page to show new status 923 - window.location.reload(); 924 - } catch (error) { 925 - showError(`Failed to post status: ${error.message}`); 926 - // Re-enable buttons 927 - document 928 - .querySelectorAll(".emoji-btn") 929 - .forEach((btn) => (btn.disabled = false)); 930 - } 931 - } 932 - 933 - // ============================================================================= 934 - // MAIN INITIALIZATION 935 - // ============================================================================= 936 - 937 - async function main() { 938 - try { 939 - // Check if this is an OAuth callback 940 - const isCallback = await handleOAuthCallback(); 941 - if (isCallback) { 942 - console.log("OAuth callback handled successfully"); 943 - } 944 - } catch (error) { 945 - showError(`Authentication failed: ${error.message}`); 946 - storage.clear(); 947 - } 948 - 949 - // Render auth section 950 - if (isLoggedIn()) { 951 - try { 952 - const viewer = await fetchViewer(); 953 - if (viewer) { 954 - const profile = { 955 - did: viewer.did, 956 - actorHandle: viewer.handle, 957 - displayName: viewer.appBskyActorProfileByDid?.displayName, 958 - avatar: viewer.appBskyActorProfileByDid?.avatar, 959 - }; 960 - renderUserCard(profile); 961 - } else { 962 - renderUserCard(null); 963 - } 964 - } catch (error) { 965 - console.error("Failed to fetch viewer:", error); 966 - renderUserCard(null); 967 - } 968 - } else { 969 - renderLoginForm(); 970 - } 971 - 972 - // Render emoji picker (enabled only if logged in) 973 - renderEmojiPicker(null, isLoggedIn()); 974 - 975 - // Fetch and render statuses 976 - renderLoading("status-feed"); 977 - try { 978 - const statuses = await fetchStatuses(); 979 - renderStatusFeed(statuses); 980 - } catch (error) { 981 - console.error("Failed to fetch statuses:", error); 982 - document.getElementById("status-feed").innerHTML = ` 983 - <div class="card"> 984 - <p class="loading" style="color: var(--error-text);"> 985 - Failed to load statuses. Is the quickslice server running at http://localhost:8080? 986 - </p> 987 - </div> 988 - `; 989 - } 819 + <div class="card"> 820 + <p class="loading">Loading...</p> 821 + </div> 822 + `; 990 823 } 991 824 992 825 // Run on page load
+1
quickslice-client-js/.gitignore
··· 1 + node_modules/
+110
quickslice-client-js/README.md
··· 1 + # quickslice-client-js 2 + 3 + Browser client SDK for Quickslice applications. 4 + 5 + ## Installation 6 + 7 + ### Via CDN (jsDelivr) 8 + 9 + ```html 10 + <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 11 + ``` 12 + 13 + ### Via npm 14 + 15 + ```bash 16 + npm install quickslice-client-js 17 + ``` 18 + 19 + ## Usage 20 + 21 + ```javascript 22 + // Initialize client 23 + const client = await QuicksliceClient.createQuicksliceClient({ 24 + server: 'https://api.example.com', 25 + clientId: 'client_abc123', 26 + }); 27 + 28 + // Handle OAuth callback (on page load) 29 + if (window.location.search.includes('code=')) { 30 + await client.handleRedirectCallback(); 31 + window.history.replaceState({}, '', '/'); 32 + } 33 + 34 + // Check auth state 35 + if (await client.isAuthenticated()) { 36 + const user = client.getUser(); 37 + console.log(`Logged in as ${user.did}`); 38 + 39 + // Fetch richer profile with your own query 40 + const profile = await client.query(`query { viewer { handle } }`); 41 + } 42 + 43 + // Login 44 + document.getElementById('login').onclick = async () => { 45 + await client.loginWithRedirect({ handle: 'alice.bsky.social' }); 46 + }; 47 + 48 + // Logout 49 + document.getElementById('logout').onclick = () => { 50 + client.logout(); 51 + }; 52 + 53 + // GraphQL queries 54 + const data = await client.query(` 55 + query { 56 + viewer { did handle } 57 + } 58 + `); 59 + 60 + // GraphQL mutations 61 + await client.mutate(` 62 + mutation CreatePost($text: String!) { 63 + createPost(input: { text: $text }) { id } 64 + } 65 + `, { text: 'Hello world!' }); 66 + 67 + // Public queries (no auth) 68 + const publicData = await client.publicQuery(` 69 + query { posts(first: 10) { edges { node { text } } } } 70 + `); 71 + ``` 72 + 73 + ## API 74 + 75 + ### `createQuicksliceClient(options)` 76 + 77 + Factory function to create and initialize a client. 78 + 79 + Options: 80 + - `server` (required): Quickslice server URL 81 + - `clientId` (required): Pre-registered client ID 82 + 83 + ### `QuicksliceClient` 84 + 85 + #### Auth Methods 86 + 87 + - `loginWithRedirect(options?)` - Start OAuth login flow 88 + - `handleRedirectCallback()` - Process OAuth callback 89 + - `logout(options?)` - Clear session and reload 90 + - `isAuthenticated()` - Check if logged in 91 + - `getUser()` - Get current user's DID (sync, returns `{ did }`) 92 + - `getAccessToken()` - Get access token (auto-refreshes) 93 + 94 + #### GraphQL Methods 95 + 96 + - `query(query, variables?)` - Execute authenticated query 97 + - `mutate(mutation, variables?)` - Execute authenticated mutation 98 + - `publicQuery(query, variables?)` - Execute unauthenticated query 99 + 100 + ## Security 101 + 102 + - PKCE for OAuth authorization code flow 103 + - DPoP (Demonstration of Proof-of-Possession) token binding 104 + - Non-extractable P-256 keys stored in IndexedDB 105 + - Multi-tab token refresh coordination 106 + - CSRF protection via state parameter 107 + 108 + ## License 109 + 110 + MIT
+55
quickslice-client-js/build.mjs
··· 1 + import * as esbuild from 'esbuild'; 2 + 3 + const watch = process.argv.includes('--watch'); 4 + 5 + const sharedConfig = { 6 + entryPoints: ['src/index.ts'], 7 + bundle: true, 8 + sourcemap: true, 9 + target: ['es2020'], 10 + }; 11 + 12 + // UMD build (for CDN/script tag) 13 + const umdBuild = { 14 + ...sharedConfig, 15 + outfile: 'dist/quickslice-client.js', 16 + format: 'iife', 17 + globalName: 'QuicksliceClient', 18 + }; 19 + 20 + // UMD minified 21 + const umdMinBuild = { 22 + ...sharedConfig, 23 + outfile: 'dist/quickslice-client.min.js', 24 + format: 'iife', 25 + globalName: 'QuicksliceClient', 26 + minify: true, 27 + sourcemap: false, 28 + }; 29 + 30 + // ESM build (for bundlers) 31 + const esmBuild = { 32 + ...sharedConfig, 33 + outfile: 'dist/quickslice-client.esm.js', 34 + format: 'esm', 35 + }; 36 + 37 + async function build() { 38 + if (watch) { 39 + const ctx = await esbuild.context(umdBuild); 40 + await ctx.watch(); 41 + console.log('Watching for changes...'); 42 + } else { 43 + await Promise.all([ 44 + esbuild.build(umdBuild), 45 + esbuild.build(umdMinBuild), 46 + esbuild.build(esmBuild), 47 + ]); 48 + console.log('Build complete!'); 49 + } 50 + } 51 + 52 + build().catch((err) => { 53 + console.error(err); 54 + process.exit(1); 55 + });
+16
quickslice-client-js/dist/auth/dpop.d.ts
··· 1 + interface DPoPKeyData { 2 + id: string; 3 + privateKey: CryptoKey; 4 + publicJwk: JsonWebKey; 5 + createdAt: number; 6 + } 7 + export declare function getOrCreateDPoPKey(): Promise<DPoPKeyData>; 8 + /** 9 + * Create a DPoP proof JWT 10 + */ 11 + export declare function createDPoPProof(method: string, url: string, accessToken?: string | null): Promise<string>; 12 + /** 13 + * Clear DPoP keys from IndexedDB 14 + */ 15 + export declare function clearDPoPKeys(): Promise<void>; 16 + export {};
+18
quickslice-client-js/dist/auth/oauth.d.ts
··· 1 + export interface LoginOptions { 2 + handle?: string; 3 + } 4 + /** 5 + * Initiate OAuth login flow with PKCE 6 + */ 7 + export declare function initiateLogin(authorizeUrl: string, clientId: string, options?: LoginOptions): Promise<void>; 8 + /** 9 + * Handle OAuth callback - exchange code for tokens 10 + * Returns true if callback was handled, false if not a callback 11 + */ 12 + export declare function handleOAuthCallback(tokenUrl: string): Promise<boolean>; 13 + /** 14 + * Logout - clear all stored data 15 + */ 16 + export declare function logout(options?: { 17 + reload?: boolean; 18 + }): Promise<void>;
+12
quickslice-client-js/dist/auth/pkce.d.ts
··· 1 + /** 2 + * Generate a PKCE code verifier (32 random bytes, base64url encoded) 3 + */ 4 + export declare function generateCodeVerifier(): string; 5 + /** 6 + * Generate a PKCE code challenge from a verifier (SHA-256, base64url encoded) 7 + */ 8 + export declare function generateCodeChallenge(verifier: string): Promise<string>; 9 + /** 10 + * Generate a random state parameter for CSRF protection 11 + */ 12 + export declare function generateState(): string;
+18
quickslice-client-js/dist/auth/tokens.d.ts
··· 1 + /** 2 + * Get a valid access token, refreshing if necessary. 3 + * Uses multi-tab locking to prevent duplicate refresh requests. 4 + */ 5 + export declare function getValidAccessToken(tokenUrl: string): Promise<string>; 6 + /** 7 + * Store tokens from OAuth response 8 + */ 9 + export declare function storeTokens(tokens: { 10 + access_token: string; 11 + refresh_token?: string; 12 + expires_in: number; 13 + sub?: string; 14 + }): void; 15 + /** 16 + * Check if we have a valid session 17 + */ 18 + export declare function hasValidSession(): boolean;
+61
quickslice-client-js/dist/client.d.ts
··· 1 + import { LoginOptions } from './auth/oauth'; 2 + export interface QuicksliceClientOptions { 3 + server: string; 4 + clientId: string; 5 + } 6 + export interface User { 7 + did: string; 8 + } 9 + export declare class QuicksliceClient { 10 + private server; 11 + private clientId; 12 + private graphqlUrl; 13 + private authorizeUrl; 14 + private tokenUrl; 15 + private initialized; 16 + constructor(options: QuicksliceClientOptions); 17 + /** 18 + * Initialize the client - must be called before other methods 19 + */ 20 + init(): Promise<void>; 21 + /** 22 + * Start OAuth login flow 23 + */ 24 + loginWithRedirect(options?: LoginOptions): Promise<void>; 25 + /** 26 + * Handle OAuth callback after redirect 27 + * Returns true if callback was handled 28 + */ 29 + handleRedirectCallback(): Promise<boolean>; 30 + /** 31 + * Logout and clear all stored data 32 + */ 33 + logout(options?: { 34 + reload?: boolean; 35 + }): Promise<void>; 36 + /** 37 + * Check if user is authenticated 38 + */ 39 + isAuthenticated(): Promise<boolean>; 40 + /** 41 + * Get current user's DID (from stored token data) 42 + * For richer profile info, use client.query() with your own schema 43 + */ 44 + getUser(): User | null; 45 + /** 46 + * Get access token (auto-refreshes if needed) 47 + */ 48 + getAccessToken(): Promise<string>; 49 + /** 50 + * Execute a GraphQL query (authenticated) 51 + */ 52 + query<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T>; 53 + /** 54 + * Execute a GraphQL mutation (authenticated) 55 + */ 56 + mutate<T = unknown>(mutation: string, variables?: Record<string, unknown>): Promise<T>; 57 + /** 58 + * Execute a public GraphQL query (no auth) 59 + */ 60 + publicQuery<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T>; 61 + }
+26
quickslice-client-js/dist/errors.d.ts
··· 1 + /** 2 + * Base error class for Quickslice client errors 3 + */ 4 + export declare class QuicksliceError extends Error { 5 + constructor(message: string); 6 + } 7 + /** 8 + * Thrown when authentication is required but user is not logged in 9 + */ 10 + export declare class LoginRequiredError extends QuicksliceError { 11 + constructor(message?: string); 12 + } 13 + /** 14 + * Thrown when network request fails 15 + */ 16 + export declare class NetworkError extends QuicksliceError { 17 + constructor(message: string); 18 + } 19 + /** 20 + * Thrown when OAuth flow fails 21 + */ 22 + export declare class OAuthError extends QuicksliceError { 23 + code: string; 24 + description?: string; 25 + constructor(code: string, description?: string); 26 + }
+11
quickslice-client-js/dist/graphql.d.ts
··· 1 + export interface GraphQLResponse<T = unknown> { 2 + data?: T; 3 + errors?: Array<{ 4 + message: string; 5 + path?: string[]; 6 + }>; 7 + } 8 + /** 9 + * Execute a GraphQL query or mutation 10 + */ 11 + export declare function graphqlRequest<T = unknown>(graphqlUrl: string, tokenUrl: string, query: string, variables?: Record<string, unknown>, requireAuth?: boolean): Promise<T>;
+7
quickslice-client-js/dist/index.d.ts
··· 1 + export { QuicksliceClient, QuicksliceClientOptions, User } from './client'; 2 + export { QuicksliceError, LoginRequiredError, NetworkError, OAuthError, } from './errors'; 3 + import { QuicksliceClient, QuicksliceClientOptions } from './client'; 4 + /** 5 + * Create and initialize a Quickslice client 6 + */ 7 + export declare function createQuicksliceClient(options: QuicksliceClientOptions): Promise<QuicksliceClient>;
+562
quickslice-client-js/dist/quickslice-client.esm.js
··· 1 + // src/storage/keys.ts 2 + var STORAGE_KEYS = { 3 + accessToken: "quickslice_access_token", 4 + refreshToken: "quickslice_refresh_token", 5 + tokenExpiresAt: "quickslice_token_expires_at", 6 + clientId: "quickslice_client_id", 7 + userDid: "quickslice_user_did", 8 + codeVerifier: "quickslice_code_verifier", 9 + oauthState: "quickslice_oauth_state" 10 + }; 11 + 12 + // src/storage/storage.ts 13 + var storage = { 14 + get(key) { 15 + if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) { 16 + return sessionStorage.getItem(key); 17 + } 18 + return localStorage.getItem(key); 19 + }, 20 + set(key, value) { 21 + if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) { 22 + sessionStorage.setItem(key, value); 23 + } else { 24 + localStorage.setItem(key, value); 25 + } 26 + }, 27 + remove(key) { 28 + sessionStorage.removeItem(key); 29 + localStorage.removeItem(key); 30 + }, 31 + clear() { 32 + Object.values(STORAGE_KEYS).forEach((key) => { 33 + sessionStorage.removeItem(key); 34 + localStorage.removeItem(key); 35 + }); 36 + } 37 + }; 38 + 39 + // src/utils/base64url.ts 40 + function base64UrlEncode(buffer) { 41 + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); 42 + let binary = ""; 43 + for (let i = 0; i < bytes.length; i++) { 44 + binary += String.fromCharCode(bytes[i]); 45 + } 46 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 47 + } 48 + function generateRandomString(byteLength) { 49 + const bytes = new Uint8Array(byteLength); 50 + crypto.getRandomValues(bytes); 51 + return base64UrlEncode(bytes); 52 + } 53 + 54 + // src/utils/crypto.ts 55 + async function sha256Base64Url(data) { 56 + const encoder = new TextEncoder(); 57 + const hash = await crypto.subtle.digest("SHA-256", encoder.encode(data)); 58 + return base64UrlEncode(hash); 59 + } 60 + async function signJwt(header, payload, privateKey) { 61 + const encoder = new TextEncoder(); 62 + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); 63 + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); 64 + const signingInput = `${headerB64}.${payloadB64}`; 65 + const signature = await crypto.subtle.sign( 66 + { name: "ECDSA", hash: "SHA-256" }, 67 + privateKey, 68 + encoder.encode(signingInput) 69 + ); 70 + const signatureB64 = base64UrlEncode(signature); 71 + return `${signingInput}.${signatureB64}`; 72 + } 73 + 74 + // src/auth/dpop.ts 75 + var DB_NAME = "quickslice-oauth"; 76 + var DB_VERSION = 1; 77 + var KEY_STORE = "dpop-keys"; 78 + var KEY_ID = "dpop-key"; 79 + var dbPromise = null; 80 + function openDatabase() { 81 + if (dbPromise) return dbPromise; 82 + dbPromise = new Promise((resolve, reject) => { 83 + const request = indexedDB.open(DB_NAME, DB_VERSION); 84 + request.onerror = () => reject(request.error); 85 + request.onsuccess = () => resolve(request.result); 86 + request.onupgradeneeded = (event) => { 87 + const db = event.target.result; 88 + if (!db.objectStoreNames.contains(KEY_STORE)) { 89 + db.createObjectStore(KEY_STORE, { keyPath: "id" }); 90 + } 91 + }; 92 + }); 93 + return dbPromise; 94 + } 95 + async function getDPoPKey() { 96 + const db = await openDatabase(); 97 + return new Promise((resolve, reject) => { 98 + const tx = db.transaction(KEY_STORE, "readonly"); 99 + const store = tx.objectStore(KEY_STORE); 100 + const request = store.get(KEY_ID); 101 + request.onerror = () => reject(request.error); 102 + request.onsuccess = () => resolve(request.result || null); 103 + }); 104 + } 105 + async function storeDPoPKey(privateKey, publicJwk) { 106 + const db = await openDatabase(); 107 + return new Promise((resolve, reject) => { 108 + const tx = db.transaction(KEY_STORE, "readwrite"); 109 + const store = tx.objectStore(KEY_STORE); 110 + const request = store.put({ 111 + id: KEY_ID, 112 + privateKey, 113 + publicJwk, 114 + createdAt: Date.now() 115 + }); 116 + request.onerror = () => reject(request.error); 117 + request.onsuccess = () => resolve(); 118 + }); 119 + } 120 + async function getOrCreateDPoPKey() { 121 + const keyData = await getDPoPKey(); 122 + if (keyData) { 123 + return keyData; 124 + } 125 + const keyPair = await crypto.subtle.generateKey( 126 + { name: "ECDSA", namedCurve: "P-256" }, 127 + false, 128 + // NOT extractable - critical for security 129 + ["sign"] 130 + ); 131 + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 132 + await storeDPoPKey(keyPair.privateKey, publicJwk); 133 + return { 134 + id: KEY_ID, 135 + privateKey: keyPair.privateKey, 136 + publicJwk, 137 + createdAt: Date.now() 138 + }; 139 + } 140 + async function createDPoPProof(method, url, accessToken = null) { 141 + const keyData = await getOrCreateDPoPKey(); 142 + const { kty, crv, x, y } = keyData.publicJwk; 143 + const minimalJwk = { kty, crv, x, y }; 144 + const header = { 145 + alg: "ES256", 146 + typ: "dpop+jwt", 147 + jwk: minimalJwk 148 + }; 149 + const payload = { 150 + jti: generateRandomString(16), 151 + htm: method, 152 + htu: url, 153 + iat: Math.floor(Date.now() / 1e3) 154 + }; 155 + if (accessToken) { 156 + payload.ath = await sha256Base64Url(accessToken); 157 + } 158 + return await signJwt(header, payload, keyData.privateKey); 159 + } 160 + async function clearDPoPKeys() { 161 + const db = await openDatabase(); 162 + return new Promise((resolve, reject) => { 163 + const tx = db.transaction(KEY_STORE, "readwrite"); 164 + const store = tx.objectStore(KEY_STORE); 165 + const request = store.clear(); 166 + request.onerror = () => reject(request.error); 167 + request.onsuccess = () => resolve(); 168 + }); 169 + } 170 + 171 + // src/auth/pkce.ts 172 + function generateCodeVerifier() { 173 + return generateRandomString(32); 174 + } 175 + async function generateCodeChallenge(verifier) { 176 + const encoder = new TextEncoder(); 177 + const data = encoder.encode(verifier); 178 + const hash = await crypto.subtle.digest("SHA-256", data); 179 + return base64UrlEncode(hash); 180 + } 181 + function generateState() { 182 + return generateRandomString(16); 183 + } 184 + 185 + // src/storage/lock.ts 186 + var LOCK_TIMEOUT = 5e3; 187 + var LOCK_PREFIX = "quickslice_lock_"; 188 + function sleep(ms) { 189 + return new Promise((resolve) => setTimeout(resolve, ms)); 190 + } 191 + async function acquireLock(key, timeout = LOCK_TIMEOUT) { 192 + const lockKey = LOCK_PREFIX + key; 193 + const lockValue = `${Date.now()}_${Math.random()}`; 194 + const deadline = Date.now() + timeout; 195 + while (Date.now() < deadline) { 196 + const existing = localStorage.getItem(lockKey); 197 + if (existing) { 198 + const [timestamp] = existing.split("_"); 199 + if (Date.now() - parseInt(timestamp) > LOCK_TIMEOUT) { 200 + localStorage.removeItem(lockKey); 201 + } else { 202 + await sleep(50); 203 + continue; 204 + } 205 + } 206 + localStorage.setItem(lockKey, lockValue); 207 + await sleep(10); 208 + if (localStorage.getItem(lockKey) === lockValue) { 209 + return lockValue; 210 + } 211 + } 212 + return null; 213 + } 214 + function releaseLock(key, lockValue) { 215 + const lockKey = LOCK_PREFIX + key; 216 + if (localStorage.getItem(lockKey) === lockValue) { 217 + localStorage.removeItem(lockKey); 218 + } 219 + } 220 + 221 + // src/auth/tokens.ts 222 + var TOKEN_REFRESH_BUFFER_MS = 6e4; 223 + function sleep2(ms) { 224 + return new Promise((resolve) => setTimeout(resolve, ms)); 225 + } 226 + async function refreshTokens(tokenUrl) { 227 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 228 + const clientId = storage.get(STORAGE_KEYS.clientId); 229 + if (!refreshToken || !clientId) { 230 + throw new Error("No refresh token available"); 231 + } 232 + const dpopProof = await createDPoPProof("POST", tokenUrl); 233 + const response = await fetch(tokenUrl, { 234 + method: "POST", 235 + headers: { 236 + "Content-Type": "application/x-www-form-urlencoded", 237 + DPoP: dpopProof 238 + }, 239 + body: new URLSearchParams({ 240 + grant_type: "refresh_token", 241 + refresh_token: refreshToken, 242 + client_id: clientId 243 + }) 244 + }); 245 + if (!response.ok) { 246 + const errorData = await response.json().catch(() => ({})); 247 + throw new Error( 248 + `Token refresh failed: ${errorData.error_description || response.statusText}` 249 + ); 250 + } 251 + const tokens = await response.json(); 252 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 253 + if (tokens.refresh_token) { 254 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 255 + } 256 + const expiresAt = Date.now() + tokens.expires_in * 1e3; 257 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 258 + return tokens.access_token; 259 + } 260 + async function getValidAccessToken(tokenUrl) { 261 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 262 + const expiresAt = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || "0"); 263 + if (accessToken && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) { 264 + return accessToken; 265 + } 266 + const clientId = storage.get(STORAGE_KEYS.clientId); 267 + const lockKey = `token_refresh_${clientId}`; 268 + const lockValue = await acquireLock(lockKey); 269 + if (!lockValue) { 270 + await sleep2(100); 271 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 272 + const freshExpiry = parseInt( 273 + storage.get(STORAGE_KEYS.tokenExpiresAt) || "0" 274 + ); 275 + if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) { 276 + return freshToken; 277 + } 278 + throw new Error("Failed to refresh token"); 279 + } 280 + try { 281 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 282 + const freshExpiry = parseInt( 283 + storage.get(STORAGE_KEYS.tokenExpiresAt) || "0" 284 + ); 285 + if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) { 286 + return freshToken; 287 + } 288 + return await refreshTokens(tokenUrl); 289 + } finally { 290 + releaseLock(lockKey, lockValue); 291 + } 292 + } 293 + function storeTokens(tokens) { 294 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 295 + if (tokens.refresh_token) { 296 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 297 + } 298 + const expiresAt = Date.now() + tokens.expires_in * 1e3; 299 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 300 + if (tokens.sub) { 301 + storage.set(STORAGE_KEYS.userDid, tokens.sub); 302 + } 303 + } 304 + function hasValidSession() { 305 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 306 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 307 + return !!(accessToken || refreshToken); 308 + } 309 + 310 + // src/auth/oauth.ts 311 + async function initiateLogin(authorizeUrl, clientId, options = {}) { 312 + const codeVerifier = generateCodeVerifier(); 313 + const codeChallenge = await generateCodeChallenge(codeVerifier); 314 + const state = generateState(); 315 + storage.set(STORAGE_KEYS.codeVerifier, codeVerifier); 316 + storage.set(STORAGE_KEYS.oauthState, state); 317 + storage.set(STORAGE_KEYS.clientId, clientId); 318 + const redirectUri = window.location.origin + window.location.pathname; 319 + const params = new URLSearchParams({ 320 + client_id: clientId, 321 + redirect_uri: redirectUri, 322 + response_type: "code", 323 + code_challenge: codeChallenge, 324 + code_challenge_method: "S256", 325 + state 326 + }); 327 + if (options.handle) { 328 + params.set("login_hint", options.handle); 329 + } 330 + window.location.href = `${authorizeUrl}?${params.toString()}`; 331 + } 332 + async function handleOAuthCallback(tokenUrl) { 333 + const params = new URLSearchParams(window.location.search); 334 + const code = params.get("code"); 335 + const state = params.get("state"); 336 + const error = params.get("error"); 337 + if (error) { 338 + throw new Error( 339 + `OAuth error: ${error} - ${params.get("error_description") || ""}` 340 + ); 341 + } 342 + if (!code || !state) { 343 + return false; 344 + } 345 + const storedState = storage.get(STORAGE_KEYS.oauthState); 346 + if (state !== storedState) { 347 + throw new Error("OAuth state mismatch - possible CSRF attack"); 348 + } 349 + const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 350 + const clientId = storage.get(STORAGE_KEYS.clientId); 351 + const redirectUri = window.location.origin + window.location.pathname; 352 + if (!codeVerifier || !clientId) { 353 + throw new Error("Missing OAuth session data"); 354 + } 355 + const dpopProof = await createDPoPProof("POST", tokenUrl); 356 + const tokenResponse = await fetch(tokenUrl, { 357 + method: "POST", 358 + headers: { 359 + "Content-Type": "application/x-www-form-urlencoded", 360 + DPoP: dpopProof 361 + }, 362 + body: new URLSearchParams({ 363 + grant_type: "authorization_code", 364 + code, 365 + redirect_uri: redirectUri, 366 + client_id: clientId, 367 + code_verifier: codeVerifier 368 + }) 369 + }); 370 + if (!tokenResponse.ok) { 371 + const errorData = await tokenResponse.json().catch(() => ({})); 372 + throw new Error( 373 + `Token exchange failed: ${errorData.error_description || tokenResponse.statusText}` 374 + ); 375 + } 376 + const tokens = await tokenResponse.json(); 377 + storeTokens(tokens); 378 + storage.remove(STORAGE_KEYS.codeVerifier); 379 + storage.remove(STORAGE_KEYS.oauthState); 380 + window.history.replaceState({}, document.title, window.location.pathname); 381 + return true; 382 + } 383 + async function logout(options = {}) { 384 + storage.clear(); 385 + await clearDPoPKeys(); 386 + if (options.reload !== false) { 387 + window.location.reload(); 388 + } 389 + } 390 + 391 + // src/graphql.ts 392 + async function graphqlRequest(graphqlUrl, tokenUrl, query, variables = {}, requireAuth = false) { 393 + const headers = { 394 + "Content-Type": "application/json" 395 + }; 396 + if (requireAuth) { 397 + const token = await getValidAccessToken(tokenUrl); 398 + if (!token) { 399 + throw new Error("Not authenticated"); 400 + } 401 + const dpopProof = await createDPoPProof("POST", graphqlUrl, token); 402 + headers["Authorization"] = `DPoP ${token}`; 403 + headers["DPoP"] = dpopProof; 404 + } 405 + const response = await fetch(graphqlUrl, { 406 + method: "POST", 407 + headers, 408 + body: JSON.stringify({ query, variables }) 409 + }); 410 + if (!response.ok) { 411 + throw new Error(`GraphQL request failed: ${response.statusText}`); 412 + } 413 + const result = await response.json(); 414 + if (result.errors && result.errors.length > 0) { 415 + throw new Error(`GraphQL error: ${result.errors[0].message}`); 416 + } 417 + return result.data; 418 + } 419 + 420 + // src/client.ts 421 + var QuicksliceClient = class { 422 + constructor(options) { 423 + this.initialized = false; 424 + this.server = options.server.replace(/\/$/, ""); 425 + this.clientId = options.clientId; 426 + this.graphqlUrl = `${this.server}/graphql`; 427 + this.authorizeUrl = `${this.server}/oauth/authorize`; 428 + this.tokenUrl = `${this.server}/oauth/token`; 429 + } 430 + /** 431 + * Initialize the client - must be called before other methods 432 + */ 433 + async init() { 434 + if (this.initialized) return; 435 + await getOrCreateDPoPKey(); 436 + this.initialized = true; 437 + } 438 + /** 439 + * Start OAuth login flow 440 + */ 441 + async loginWithRedirect(options = {}) { 442 + await this.init(); 443 + await initiateLogin(this.authorizeUrl, this.clientId, options); 444 + } 445 + /** 446 + * Handle OAuth callback after redirect 447 + * Returns true if callback was handled 448 + */ 449 + async handleRedirectCallback() { 450 + await this.init(); 451 + return await handleOAuthCallback(this.tokenUrl); 452 + } 453 + /** 454 + * Logout and clear all stored data 455 + */ 456 + async logout(options = {}) { 457 + await logout(options); 458 + } 459 + /** 460 + * Check if user is authenticated 461 + */ 462 + async isAuthenticated() { 463 + return hasValidSession(); 464 + } 465 + /** 466 + * Get current user's DID (from stored token data) 467 + * For richer profile info, use client.query() with your own schema 468 + */ 469 + getUser() { 470 + if (!hasValidSession()) { 471 + return null; 472 + } 473 + const did = storage.get(STORAGE_KEYS.userDid); 474 + if (!did) { 475 + return null; 476 + } 477 + return { did }; 478 + } 479 + /** 480 + * Get access token (auto-refreshes if needed) 481 + */ 482 + async getAccessToken() { 483 + await this.init(); 484 + return await getValidAccessToken(this.tokenUrl); 485 + } 486 + /** 487 + * Execute a GraphQL query (authenticated) 488 + */ 489 + async query(query, variables = {}) { 490 + await this.init(); 491 + return await graphqlRequest( 492 + this.graphqlUrl, 493 + this.tokenUrl, 494 + query, 495 + variables, 496 + true 497 + ); 498 + } 499 + /** 500 + * Execute a GraphQL mutation (authenticated) 501 + */ 502 + async mutate(mutation, variables = {}) { 503 + return this.query(mutation, variables); 504 + } 505 + /** 506 + * Execute a public GraphQL query (no auth) 507 + */ 508 + async publicQuery(query, variables = {}) { 509 + await this.init(); 510 + return await graphqlRequest( 511 + this.graphqlUrl, 512 + this.tokenUrl, 513 + query, 514 + variables, 515 + false 516 + ); 517 + } 518 + }; 519 + 520 + // src/errors.ts 521 + var QuicksliceError = class extends Error { 522 + constructor(message) { 523 + super(message); 524 + this.name = "QuicksliceError"; 525 + } 526 + }; 527 + var LoginRequiredError = class extends QuicksliceError { 528 + constructor(message = "Login required") { 529 + super(message); 530 + this.name = "LoginRequiredError"; 531 + } 532 + }; 533 + var NetworkError = class extends QuicksliceError { 534 + constructor(message) { 535 + super(message); 536 + this.name = "NetworkError"; 537 + } 538 + }; 539 + var OAuthError = class extends QuicksliceError { 540 + constructor(code, description) { 541 + super(`OAuth error: ${code}${description ? ` - ${description}` : ""}`); 542 + this.name = "OAuthError"; 543 + this.code = code; 544 + this.description = description; 545 + } 546 + }; 547 + 548 + // src/index.ts 549 + async function createQuicksliceClient(options) { 550 + const client = new QuicksliceClient(options); 551 + await client.init(); 552 + return client; 553 + } 554 + export { 555 + LoginRequiredError, 556 + NetworkError, 557 + OAuthError, 558 + QuicksliceClient, 559 + QuicksliceError, 560 + createQuicksliceClient 561 + }; 562 + //# sourceMappingURL=quickslice-client.esm.js.map
+7
quickslice-client-js/dist/quickslice-client.esm.js.map
··· 1 + { 2 + "version": 3, 3 + "sources": ["../src/storage/keys.ts", "../src/storage/storage.ts", "../src/utils/base64url.ts", "../src/utils/crypto.ts", "../src/auth/dpop.ts", "../src/auth/pkce.ts", "../src/storage/lock.ts", "../src/auth/tokens.ts", "../src/auth/oauth.ts", "../src/graphql.ts", "../src/client.ts", "../src/errors.ts", "../src/index.ts"], 4 + "sourcesContent": ["/**\n * Storage key constants\n */\nexport const STORAGE_KEYS = {\n accessToken: 'quickslice_access_token',\n refreshToken: 'quickslice_refresh_token',\n tokenExpiresAt: 'quickslice_token_expires_at',\n clientId: 'quickslice_client_id',\n userDid: 'quickslice_user_did',\n codeVerifier: 'quickslice_code_verifier',\n oauthState: 'quickslice_oauth_state',\n} as const;\n\nexport type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];\n", "import { STORAGE_KEYS, StorageKey } from './keys';\n\n/**\n * Hybrid storage utility - sessionStorage for OAuth flow state,\n * localStorage for tokens (shared across tabs)\n */\nexport const storage = {\n get(key: StorageKey): string | null {\n // OAuth flow state stays in sessionStorage (per-tab)\n if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) {\n return sessionStorage.getItem(key);\n }\n // Tokens go in localStorage (shared across tabs)\n return localStorage.getItem(key);\n },\n\n set(key: StorageKey, value: string): void {\n if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) {\n sessionStorage.setItem(key, value);\n } else {\n localStorage.setItem(key, value);\n }\n },\n\n remove(key: StorageKey): void {\n sessionStorage.removeItem(key);\n localStorage.removeItem(key);\n },\n\n clear(): void {\n Object.values(STORAGE_KEYS).forEach((key) => {\n sessionStorage.removeItem(key);\n localStorage.removeItem(key);\n });\n },\n};\n", "/**\n * Base64 URL encode a buffer (Uint8Array or ArrayBuffer)\n */\nexport function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string {\n const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary)\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n}\n\n/**\n * Generate a random base64url string\n */\nexport function generateRandomString(byteLength: number): string {\n const bytes = new Uint8Array(byteLength);\n crypto.getRandomValues(bytes);\n return base64UrlEncode(bytes);\n}\n", "import { base64UrlEncode } from './base64url';\n\n/**\n * SHA-256 hash, returned as base64url string\n */\nexport async function sha256Base64Url(data: string): Promise<string> {\n const encoder = new TextEncoder();\n const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data));\n return base64UrlEncode(hash);\n}\n\n/**\n * Sign a JWT with an ECDSA P-256 private key\n */\nexport async function signJwt(\n header: Record<string, unknown>,\n payload: Record<string, unknown>,\n privateKey: CryptoKey\n): Promise<string> {\n const encoder = new TextEncoder();\n\n const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));\n const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));\n\n const signingInput = `${headerB64}.${payloadB64}`;\n\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n privateKey,\n encoder.encode(signingInput)\n );\n\n const signatureB64 = base64UrlEncode(signature);\n\n return `${signingInput}.${signatureB64}`;\n}\n", "import { generateRandomString } from '../utils/base64url';\nimport { sha256Base64Url, signJwt } from '../utils/crypto';\n\nconst DB_NAME = 'quickslice-oauth';\nconst DB_VERSION = 1;\nconst KEY_STORE = 'dpop-keys';\nconst KEY_ID = 'dpop-key';\n\ninterface DPoPKeyData {\n id: string;\n privateKey: CryptoKey;\n publicJwk: JsonWebKey;\n createdAt: number;\n}\n\nlet dbPromise: Promise<IDBDatabase> | null = null;\n\nfunction openDatabase(): Promise<IDBDatabase> {\n if (dbPromise) return dbPromise;\n\n dbPromise = new Promise((resolve, reject) => {\n const request = indexedDB.open(DB_NAME, DB_VERSION);\n\n request.onerror = () => reject(request.error);\n request.onsuccess = () => resolve(request.result);\n\n request.onupgradeneeded = (event) => {\n const db = (event.target as IDBOpenDBRequest).result;\n if (!db.objectStoreNames.contains(KEY_STORE)) {\n db.createObjectStore(KEY_STORE, { keyPath: 'id' });\n }\n };\n });\n\n return dbPromise;\n}\n\nasync function getDPoPKey(): Promise<DPoPKeyData | null> {\n const db = await openDatabase();\n return new Promise((resolve, reject) => {\n const tx = db.transaction(KEY_STORE, 'readonly');\n const store = tx.objectStore(KEY_STORE);\n const request = store.get(KEY_ID);\n\n request.onerror = () => reject(request.error);\n request.onsuccess = () => resolve(request.result || null);\n });\n}\n\nasync function storeDPoPKey(\n privateKey: CryptoKey,\n publicJwk: JsonWebKey\n): Promise<void> {\n const db = await openDatabase();\n return new Promise((resolve, reject) => {\n const tx = db.transaction(KEY_STORE, 'readwrite');\n const store = tx.objectStore(KEY_STORE);\n const request = store.put({\n id: KEY_ID,\n privateKey,\n publicJwk,\n createdAt: Date.now(),\n });\n\n request.onerror = () => reject(request.error);\n request.onsuccess = () => resolve();\n });\n}\n\nexport async function getOrCreateDPoPKey(): Promise<DPoPKeyData> {\n const keyData = await getDPoPKey();\n\n if (keyData) {\n return keyData;\n }\n\n // Generate new P-256 key pair\n const keyPair = await crypto.subtle.generateKey(\n { name: 'ECDSA', namedCurve: 'P-256' },\n false, // NOT extractable - critical for security\n ['sign']\n );\n\n // Export public key as JWK\n const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);\n\n // Store in IndexedDB\n await storeDPoPKey(keyPair.privateKey, publicJwk);\n\n return {\n id: KEY_ID,\n privateKey: keyPair.privateKey,\n publicJwk,\n createdAt: Date.now(),\n };\n}\n\n/**\n * Create a DPoP proof JWT\n */\nexport async function createDPoPProof(\n method: string,\n url: string,\n accessToken: string | null = null\n): Promise<string> {\n const keyData = await getOrCreateDPoPKey();\n\n // Strip WebCrypto-specific fields from JWK for interoperability\n const { kty, crv, x, y } = keyData.publicJwk;\n const minimalJwk = { kty, crv, x, y };\n\n const header = {\n alg: 'ES256',\n typ: 'dpop+jwt',\n jwk: minimalJwk,\n };\n\n const payload: Record<string, unknown> = {\n jti: generateRandomString(16),\n htm: method,\n htu: url,\n iat: Math.floor(Date.now() / 1000),\n };\n\n // Add access token hash if provided (for resource requests)\n if (accessToken) {\n payload.ath = await sha256Base64Url(accessToken);\n }\n\n return await signJwt(header, payload, keyData.privateKey);\n}\n\n/**\n * Clear DPoP keys from IndexedDB\n */\nexport async function clearDPoPKeys(): Promise<void> {\n const db = await openDatabase();\n return new Promise((resolve, reject) => {\n const tx = db.transaction(KEY_STORE, 'readwrite');\n const store = tx.objectStore(KEY_STORE);\n const request = store.clear();\n\n request.onerror = () => reject(request.error);\n request.onsuccess = () => resolve();\n });\n}\n", "import { base64UrlEncode, generateRandomString } from '../utils/base64url';\n\n/**\n * Generate a PKCE code verifier (32 random bytes, base64url encoded)\n */\nexport function generateCodeVerifier(): string {\n return generateRandomString(32);\n}\n\n/**\n * Generate a PKCE code challenge from a verifier (SHA-256, base64url encoded)\n */\nexport async function generateCodeChallenge(verifier: string): Promise<string> {\n const encoder = new TextEncoder();\n const data = encoder.encode(verifier);\n const hash = await crypto.subtle.digest('SHA-256', data);\n return base64UrlEncode(hash);\n}\n\n/**\n * Generate a random state parameter for CSRF protection\n */\nexport function generateState(): string {\n return generateRandomString(16);\n}\n", "const LOCK_TIMEOUT = 5000; // 5 seconds\nconst LOCK_PREFIX = 'quickslice_lock_';\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Acquire a lock using localStorage for multi-tab coordination\n */\nexport async function acquireLock(\n key: string,\n timeout = LOCK_TIMEOUT\n): Promise<string | null> {\n const lockKey = LOCK_PREFIX + key;\n const lockValue = `${Date.now()}_${Math.random()}`;\n const deadline = Date.now() + timeout;\n\n while (Date.now() < deadline) {\n const existing = localStorage.getItem(lockKey);\n\n if (existing) {\n // Check if lock is stale (older than timeout)\n const [timestamp] = existing.split('_');\n if (Date.now() - parseInt(timestamp) > LOCK_TIMEOUT) {\n // Lock is stale, remove it\n localStorage.removeItem(lockKey);\n } else {\n // Lock is held, wait and retry\n await sleep(50);\n continue;\n }\n }\n\n // Try to acquire\n localStorage.setItem(lockKey, lockValue);\n\n // Verify we got it (handle race condition)\n await sleep(10);\n if (localStorage.getItem(lockKey) === lockValue) {\n return lockValue; // Lock acquired\n }\n }\n\n return null; // Failed to acquire\n}\n\n/**\n * Release a lock\n */\nexport function releaseLock(key: string, lockValue: string): void {\n const lockKey = LOCK_PREFIX + key;\n // Only release if we still hold it\n if (localStorage.getItem(lockKey) === lockValue) {\n localStorage.removeItem(lockKey);\n }\n}\n", "import { storage } from '../storage/storage';\nimport { STORAGE_KEYS } from '../storage/keys';\nimport { acquireLock, releaseLock } from '../storage/lock';\nimport { createDPoPProof } from './dpop';\n\nconst TOKEN_REFRESH_BUFFER_MS = 60000; // 60 seconds before expiry\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Refresh tokens using the refresh token\n */\nasync function refreshTokens(tokenUrl: string): Promise<string> {\n const refreshToken = storage.get(STORAGE_KEYS.refreshToken);\n const clientId = storage.get(STORAGE_KEYS.clientId);\n\n if (!refreshToken || !clientId) {\n throw new Error('No refresh token available');\n }\n\n const dpopProof = await createDPoPProof('POST', tokenUrl);\n\n const response = await fetch(tokenUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n DPoP: dpopProof,\n },\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: refreshToken,\n client_id: clientId,\n }),\n });\n\n if (!response.ok) {\n const errorData = await response.json().catch(() => ({}));\n throw new Error(\n `Token refresh failed: ${errorData.error_description || response.statusText}`\n );\n }\n\n const tokens = await response.json();\n\n // Store new tokens (rotation - new refresh token each time)\n storage.set(STORAGE_KEYS.accessToken, tokens.access_token);\n if (tokens.refresh_token) {\n storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token);\n }\n\n const expiresAt = Date.now() + tokens.expires_in * 1000;\n storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString());\n\n return tokens.access_token;\n}\n\n/**\n * Get a valid access token, refreshing if necessary.\n * Uses multi-tab locking to prevent duplicate refresh requests.\n */\nexport async function getValidAccessToken(tokenUrl: string): Promise<string> {\n const accessToken = storage.get(STORAGE_KEYS.accessToken);\n const expiresAt = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || '0');\n\n // Check if token is still valid (with buffer)\n if (accessToken && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) {\n return accessToken;\n }\n\n // Need to refresh - acquire lock first\n const clientId = storage.get(STORAGE_KEYS.clientId);\n const lockKey = `token_refresh_${clientId}`;\n const lockValue = await acquireLock(lockKey);\n\n if (!lockValue) {\n // Failed to acquire lock, another tab is refreshing\n // Wait a bit and check cache again\n await sleep(100);\n const freshToken = storage.get(STORAGE_KEYS.accessToken);\n const freshExpiry = parseInt(\n storage.get(STORAGE_KEYS.tokenExpiresAt) || '0'\n );\n if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) {\n return freshToken;\n }\n throw new Error('Failed to refresh token');\n }\n\n try {\n // Double-check after acquiring lock\n const freshToken = storage.get(STORAGE_KEYS.accessToken);\n const freshExpiry = parseInt(\n storage.get(STORAGE_KEYS.tokenExpiresAt) || '0'\n );\n if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) {\n return freshToken;\n }\n\n // Actually refresh\n return await refreshTokens(tokenUrl);\n } finally {\n releaseLock(lockKey, lockValue);\n }\n}\n\n/**\n * Store tokens from OAuth response\n */\nexport function storeTokens(tokens: {\n access_token: string;\n refresh_token?: string;\n expires_in: number;\n sub?: string;\n}): void {\n storage.set(STORAGE_KEYS.accessToken, tokens.access_token);\n if (tokens.refresh_token) {\n storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token);\n }\n\n const expiresAt = Date.now() + tokens.expires_in * 1000;\n storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString());\n\n if (tokens.sub) {\n storage.set(STORAGE_KEYS.userDid, tokens.sub);\n }\n}\n\n/**\n * Check if we have a valid session\n */\nexport function hasValidSession(): boolean {\n const accessToken = storage.get(STORAGE_KEYS.accessToken);\n const refreshToken = storage.get(STORAGE_KEYS.refreshToken);\n return !!(accessToken || refreshToken);\n}\n", "import { storage } from '../storage/storage';\nimport { STORAGE_KEYS } from '../storage/keys';\nimport { createDPoPProof, clearDPoPKeys } from './dpop';\nimport { generateCodeVerifier, generateCodeChallenge, generateState } from './pkce';\nimport { storeTokens } from './tokens';\n\nexport interface LoginOptions {\n handle?: string;\n}\n\n/**\n * Initiate OAuth login flow with PKCE\n */\nexport async function initiateLogin(\n authorizeUrl: string,\n clientId: string,\n options: LoginOptions = {}\n): Promise<void> {\n const codeVerifier = generateCodeVerifier();\n const codeChallenge = await generateCodeChallenge(codeVerifier);\n const state = generateState();\n\n // Store for callback\n storage.set(STORAGE_KEYS.codeVerifier, codeVerifier);\n storage.set(STORAGE_KEYS.oauthState, state);\n storage.set(STORAGE_KEYS.clientId, clientId);\n\n // Build redirect URI (current page without query params)\n const redirectUri = window.location.origin + window.location.pathname;\n\n // Build authorization URL\n const params = new URLSearchParams({\n client_id: clientId,\n redirect_uri: redirectUri,\n response_type: 'code',\n code_challenge: codeChallenge,\n code_challenge_method: 'S256',\n state: state,\n });\n\n if (options.handle) {\n params.set('login_hint', options.handle);\n }\n\n window.location.href = `${authorizeUrl}?${params.toString()}`;\n}\n\n/**\n * Handle OAuth callback - exchange code for tokens\n * Returns true if callback was handled, false if not a callback\n */\nexport async function handleOAuthCallback(tokenUrl: string): Promise<boolean> {\n const params = new URLSearchParams(window.location.search);\n const code = params.get('code');\n const state = params.get('state');\n const error = params.get('error');\n\n if (error) {\n throw new Error(\n `OAuth error: ${error} - ${params.get('error_description') || ''}`\n );\n }\n\n if (!code || !state) {\n return false; // Not a callback\n }\n\n // Verify state\n const storedState = storage.get(STORAGE_KEYS.oauthState);\n if (state !== storedState) {\n throw new Error('OAuth state mismatch - possible CSRF attack');\n }\n\n // Get stored values\n const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier);\n const clientId = storage.get(STORAGE_KEYS.clientId);\n const redirectUri = window.location.origin + window.location.pathname;\n\n if (!codeVerifier || !clientId) {\n throw new Error('Missing OAuth session data');\n }\n\n // Exchange code for tokens with DPoP\n const dpopProof = await createDPoPProof('POST', tokenUrl);\n\n const tokenResponse = await fetch(tokenUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n DPoP: dpopProof,\n },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code: code,\n redirect_uri: redirectUri,\n client_id: clientId,\n code_verifier: codeVerifier,\n }),\n });\n\n if (!tokenResponse.ok) {\n const errorData = await tokenResponse.json().catch(() => ({}));\n throw new Error(\n `Token exchange failed: ${errorData.error_description || tokenResponse.statusText}`\n );\n }\n\n const tokens = await tokenResponse.json();\n\n // Store tokens\n storeTokens(tokens);\n\n // Clean up OAuth state\n storage.remove(STORAGE_KEYS.codeVerifier);\n storage.remove(STORAGE_KEYS.oauthState);\n\n // Clear URL params\n window.history.replaceState({}, document.title, window.location.pathname);\n\n return true;\n}\n\n/**\n * Logout - clear all stored data\n */\nexport async function logout(options: { reload?: boolean } = {}): Promise<void> {\n storage.clear();\n await clearDPoPKeys();\n\n if (options.reload !== false) {\n window.location.reload();\n }\n}\n", "import { createDPoPProof } from './auth/dpop';\nimport { getValidAccessToken } from './auth/tokens';\n\nexport interface GraphQLResponse<T = unknown> {\n data?: T;\n errors?: Array<{ message: string; path?: string[] }>;\n}\n\n/**\n * Execute a GraphQL query or mutation\n */\nexport async function graphqlRequest<T = unknown>(\n graphqlUrl: string,\n tokenUrl: string,\n query: string,\n variables: Record<string, unknown> = {},\n requireAuth = false\n): Promise<T> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n\n if (requireAuth) {\n const token = await getValidAccessToken(tokenUrl);\n if (!token) {\n throw new Error('Not authenticated');\n }\n\n // Create DPoP proof bound to this request\n const dpopProof = await createDPoPProof('POST', graphqlUrl, token);\n\n headers['Authorization'] = `DPoP ${token}`;\n headers['DPoP'] = dpopProof;\n }\n\n const response = await fetch(graphqlUrl, {\n method: 'POST',\n headers,\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n throw new Error(`GraphQL request failed: ${response.statusText}`);\n }\n\n const result: GraphQLResponse<T> = await response.json();\n\n if (result.errors && result.errors.length > 0) {\n throw new Error(`GraphQL error: ${result.errors[0].message}`);\n }\n\n return result.data as T;\n}\n", "import { storage } from './storage/storage';\nimport { STORAGE_KEYS } from './storage/keys';\nimport { getOrCreateDPoPKey } from './auth/dpop';\nimport { initiateLogin, handleOAuthCallback, logout as doLogout, LoginOptions } from './auth/oauth';\nimport { getValidAccessToken, hasValidSession } from './auth/tokens';\nimport { graphqlRequest } from './graphql';\n\nexport interface QuicksliceClientOptions {\n server: string;\n clientId: string;\n}\n\nexport interface User {\n did: string;\n}\n\nexport class QuicksliceClient {\n private server: string;\n private clientId: string;\n private graphqlUrl: string;\n private authorizeUrl: string;\n private tokenUrl: string;\n private initialized = false;\n\n constructor(options: QuicksliceClientOptions) {\n this.server = options.server.replace(/\\/$/, ''); // Remove trailing slash\n this.clientId = options.clientId;\n\n this.graphqlUrl = `${this.server}/graphql`;\n this.authorizeUrl = `${this.server}/oauth/authorize`;\n this.tokenUrl = `${this.server}/oauth/token`;\n }\n\n /**\n * Initialize the client - must be called before other methods\n */\n async init(): Promise<void> {\n if (this.initialized) return;\n\n // Ensure DPoP key exists\n await getOrCreateDPoPKey();\n\n this.initialized = true;\n }\n\n /**\n * Start OAuth login flow\n */\n async loginWithRedirect(options: LoginOptions = {}): Promise<void> {\n await this.init();\n await initiateLogin(this.authorizeUrl, this.clientId, options);\n }\n\n /**\n * Handle OAuth callback after redirect\n * Returns true if callback was handled\n */\n async handleRedirectCallback(): Promise<boolean> {\n await this.init();\n return await handleOAuthCallback(this.tokenUrl);\n }\n\n /**\n * Logout and clear all stored data\n */\n async logout(options: { reload?: boolean } = {}): Promise<void> {\n await doLogout(options);\n }\n\n /**\n * Check if user is authenticated\n */\n async isAuthenticated(): Promise<boolean> {\n return hasValidSession();\n }\n\n /**\n * Get current user's DID (from stored token data)\n * For richer profile info, use client.query() with your own schema\n */\n getUser(): User | null {\n if (!hasValidSession()) {\n return null;\n }\n\n const did = storage.get(STORAGE_KEYS.userDid);\n if (!did) {\n return null;\n }\n\n return { did };\n }\n\n /**\n * Get access token (auto-refreshes if needed)\n */\n async getAccessToken(): Promise<string> {\n await this.init();\n return await getValidAccessToken(this.tokenUrl);\n }\n\n /**\n * Execute a GraphQL query (authenticated)\n */\n async query<T = unknown>(\n query: string,\n variables: Record<string, unknown> = {}\n ): Promise<T> {\n await this.init();\n return await graphqlRequest<T>(\n this.graphqlUrl,\n this.tokenUrl,\n query,\n variables,\n true\n );\n }\n\n /**\n * Execute a GraphQL mutation (authenticated)\n */\n async mutate<T = unknown>(\n mutation: string,\n variables: Record<string, unknown> = {}\n ): Promise<T> {\n return this.query<T>(mutation, variables);\n }\n\n /**\n * Execute a public GraphQL query (no auth)\n */\n async publicQuery<T = unknown>(\n query: string,\n variables: Record<string, unknown> = {}\n ): Promise<T> {\n await this.init();\n return await graphqlRequest<T>(\n this.graphqlUrl,\n this.tokenUrl,\n query,\n variables,\n false\n );\n }\n}\n", "/**\n * Base error class for Quickslice client errors\n */\nexport class QuicksliceError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'QuicksliceError';\n }\n}\n\n/**\n * Thrown when authentication is required but user is not logged in\n */\nexport class LoginRequiredError extends QuicksliceError {\n constructor(message = 'Login required') {\n super(message);\n this.name = 'LoginRequiredError';\n }\n}\n\n/**\n * Thrown when network request fails\n */\nexport class NetworkError extends QuicksliceError {\n constructor(message: string) {\n super(message);\n this.name = 'NetworkError';\n }\n}\n\n/**\n * Thrown when OAuth flow fails\n */\nexport class OAuthError extends QuicksliceError {\n public code: string;\n public description?: string;\n\n constructor(code: string, description?: string) {\n super(`OAuth error: ${code}${description ? ` - ${description}` : ''}`);\n this.name = 'OAuthError';\n this.code = code;\n this.description = description;\n }\n}\n", "export { QuicksliceClient, QuicksliceClientOptions, User } from './client';\nexport {\n QuicksliceError,\n LoginRequiredError,\n NetworkError,\n OAuthError,\n} from './errors';\n\nimport { QuicksliceClient, QuicksliceClientOptions } from './client';\n\n/**\n * Create and initialize a Quickslice client\n */\nexport async function createQuicksliceClient(\n options: QuicksliceClientOptions\n): Promise<QuicksliceClient> {\n const client = new QuicksliceClient(options);\n await client.init();\n return client;\n}\n"], 5 + "mappings": "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", 6 + "names": ["sleep"] 7 + }
+587
quickslice-client-js/dist/quickslice-client.js
··· 1 + "use strict"; 2 + var QuicksliceClient = (() => { 3 + var __defProp = Object.defineProperty; 4 + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; 5 + var __getOwnPropNames = Object.getOwnPropertyNames; 6 + var __hasOwnProp = Object.prototype.hasOwnProperty; 7 + var __export = (target, all) => { 8 + for (var name in all) 9 + __defProp(target, name, { get: all[name], enumerable: true }); 10 + }; 11 + var __copyProps = (to, from, except, desc) => { 12 + if (from && typeof from === "object" || typeof from === "function") { 13 + for (let key of __getOwnPropNames(from)) 14 + if (!__hasOwnProp.call(to, key) && key !== except) 15 + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); 16 + } 17 + return to; 18 + }; 19 + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); 20 + 21 + // src/index.ts 22 + var index_exports = {}; 23 + __export(index_exports, { 24 + LoginRequiredError: () => LoginRequiredError, 25 + NetworkError: () => NetworkError, 26 + OAuthError: () => OAuthError, 27 + QuicksliceClient: () => QuicksliceClient, 28 + QuicksliceError: () => QuicksliceError, 29 + createQuicksliceClient: () => createQuicksliceClient 30 + }); 31 + 32 + // src/storage/keys.ts 33 + var STORAGE_KEYS = { 34 + accessToken: "quickslice_access_token", 35 + refreshToken: "quickslice_refresh_token", 36 + tokenExpiresAt: "quickslice_token_expires_at", 37 + clientId: "quickslice_client_id", 38 + userDid: "quickslice_user_did", 39 + codeVerifier: "quickslice_code_verifier", 40 + oauthState: "quickslice_oauth_state" 41 + }; 42 + 43 + // src/storage/storage.ts 44 + var storage = { 45 + get(key) { 46 + if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) { 47 + return sessionStorage.getItem(key); 48 + } 49 + return localStorage.getItem(key); 50 + }, 51 + set(key, value) { 52 + if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) { 53 + sessionStorage.setItem(key, value); 54 + } else { 55 + localStorage.setItem(key, value); 56 + } 57 + }, 58 + remove(key) { 59 + sessionStorage.removeItem(key); 60 + localStorage.removeItem(key); 61 + }, 62 + clear() { 63 + Object.values(STORAGE_KEYS).forEach((key) => { 64 + sessionStorage.removeItem(key); 65 + localStorage.removeItem(key); 66 + }); 67 + } 68 + }; 69 + 70 + // src/utils/base64url.ts 71 + function base64UrlEncode(buffer) { 72 + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); 73 + let binary = ""; 74 + for (let i = 0; i < bytes.length; i++) { 75 + binary += String.fromCharCode(bytes[i]); 76 + } 77 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 78 + } 79 + function generateRandomString(byteLength) { 80 + const bytes = new Uint8Array(byteLength); 81 + crypto.getRandomValues(bytes); 82 + return base64UrlEncode(bytes); 83 + } 84 + 85 + // src/utils/crypto.ts 86 + async function sha256Base64Url(data) { 87 + const encoder = new TextEncoder(); 88 + const hash = await crypto.subtle.digest("SHA-256", encoder.encode(data)); 89 + return base64UrlEncode(hash); 90 + } 91 + async function signJwt(header, payload, privateKey) { 92 + const encoder = new TextEncoder(); 93 + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); 94 + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); 95 + const signingInput = `${headerB64}.${payloadB64}`; 96 + const signature = await crypto.subtle.sign( 97 + { name: "ECDSA", hash: "SHA-256" }, 98 + privateKey, 99 + encoder.encode(signingInput) 100 + ); 101 + const signatureB64 = base64UrlEncode(signature); 102 + return `${signingInput}.${signatureB64}`; 103 + } 104 + 105 + // src/auth/dpop.ts 106 + var DB_NAME = "quickslice-oauth"; 107 + var DB_VERSION = 1; 108 + var KEY_STORE = "dpop-keys"; 109 + var KEY_ID = "dpop-key"; 110 + var dbPromise = null; 111 + function openDatabase() { 112 + if (dbPromise) return dbPromise; 113 + dbPromise = new Promise((resolve, reject) => { 114 + const request = indexedDB.open(DB_NAME, DB_VERSION); 115 + request.onerror = () => reject(request.error); 116 + request.onsuccess = () => resolve(request.result); 117 + request.onupgradeneeded = (event) => { 118 + const db = event.target.result; 119 + if (!db.objectStoreNames.contains(KEY_STORE)) { 120 + db.createObjectStore(KEY_STORE, { keyPath: "id" }); 121 + } 122 + }; 123 + }); 124 + return dbPromise; 125 + } 126 + async function getDPoPKey() { 127 + const db = await openDatabase(); 128 + return new Promise((resolve, reject) => { 129 + const tx = db.transaction(KEY_STORE, "readonly"); 130 + const store = tx.objectStore(KEY_STORE); 131 + const request = store.get(KEY_ID); 132 + request.onerror = () => reject(request.error); 133 + request.onsuccess = () => resolve(request.result || null); 134 + }); 135 + } 136 + async function storeDPoPKey(privateKey, publicJwk) { 137 + const db = await openDatabase(); 138 + return new Promise((resolve, reject) => { 139 + const tx = db.transaction(KEY_STORE, "readwrite"); 140 + const store = tx.objectStore(KEY_STORE); 141 + const request = store.put({ 142 + id: KEY_ID, 143 + privateKey, 144 + publicJwk, 145 + createdAt: Date.now() 146 + }); 147 + request.onerror = () => reject(request.error); 148 + request.onsuccess = () => resolve(); 149 + }); 150 + } 151 + async function getOrCreateDPoPKey() { 152 + const keyData = await getDPoPKey(); 153 + if (keyData) { 154 + return keyData; 155 + } 156 + const keyPair = await crypto.subtle.generateKey( 157 + { name: "ECDSA", namedCurve: "P-256" }, 158 + false, 159 + // NOT extractable - critical for security 160 + ["sign"] 161 + ); 162 + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 163 + await storeDPoPKey(keyPair.privateKey, publicJwk); 164 + return { 165 + id: KEY_ID, 166 + privateKey: keyPair.privateKey, 167 + publicJwk, 168 + createdAt: Date.now() 169 + }; 170 + } 171 + async function createDPoPProof(method, url, accessToken = null) { 172 + const keyData = await getOrCreateDPoPKey(); 173 + const { kty, crv, x, y } = keyData.publicJwk; 174 + const minimalJwk = { kty, crv, x, y }; 175 + const header = { 176 + alg: "ES256", 177 + typ: "dpop+jwt", 178 + jwk: minimalJwk 179 + }; 180 + const payload = { 181 + jti: generateRandomString(16), 182 + htm: method, 183 + htu: url, 184 + iat: Math.floor(Date.now() / 1e3) 185 + }; 186 + if (accessToken) { 187 + payload.ath = await sha256Base64Url(accessToken); 188 + } 189 + return await signJwt(header, payload, keyData.privateKey); 190 + } 191 + async function clearDPoPKeys() { 192 + const db = await openDatabase(); 193 + return new Promise((resolve, reject) => { 194 + const tx = db.transaction(KEY_STORE, "readwrite"); 195 + const store = tx.objectStore(KEY_STORE); 196 + const request = store.clear(); 197 + request.onerror = () => reject(request.error); 198 + request.onsuccess = () => resolve(); 199 + }); 200 + } 201 + 202 + // src/auth/pkce.ts 203 + function generateCodeVerifier() { 204 + return generateRandomString(32); 205 + } 206 + async function generateCodeChallenge(verifier) { 207 + const encoder = new TextEncoder(); 208 + const data = encoder.encode(verifier); 209 + const hash = await crypto.subtle.digest("SHA-256", data); 210 + return base64UrlEncode(hash); 211 + } 212 + function generateState() { 213 + return generateRandomString(16); 214 + } 215 + 216 + // src/storage/lock.ts 217 + var LOCK_TIMEOUT = 5e3; 218 + var LOCK_PREFIX = "quickslice_lock_"; 219 + function sleep(ms) { 220 + return new Promise((resolve) => setTimeout(resolve, ms)); 221 + } 222 + async function acquireLock(key, timeout = LOCK_TIMEOUT) { 223 + const lockKey = LOCK_PREFIX + key; 224 + const lockValue = `${Date.now()}_${Math.random()}`; 225 + const deadline = Date.now() + timeout; 226 + while (Date.now() < deadline) { 227 + const existing = localStorage.getItem(lockKey); 228 + if (existing) { 229 + const [timestamp] = existing.split("_"); 230 + if (Date.now() - parseInt(timestamp) > LOCK_TIMEOUT) { 231 + localStorage.removeItem(lockKey); 232 + } else { 233 + await sleep(50); 234 + continue; 235 + } 236 + } 237 + localStorage.setItem(lockKey, lockValue); 238 + await sleep(10); 239 + if (localStorage.getItem(lockKey) === lockValue) { 240 + return lockValue; 241 + } 242 + } 243 + return null; 244 + } 245 + function releaseLock(key, lockValue) { 246 + const lockKey = LOCK_PREFIX + key; 247 + if (localStorage.getItem(lockKey) === lockValue) { 248 + localStorage.removeItem(lockKey); 249 + } 250 + } 251 + 252 + // src/auth/tokens.ts 253 + var TOKEN_REFRESH_BUFFER_MS = 6e4; 254 + function sleep2(ms) { 255 + return new Promise((resolve) => setTimeout(resolve, ms)); 256 + } 257 + async function refreshTokens(tokenUrl) { 258 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 259 + const clientId = storage.get(STORAGE_KEYS.clientId); 260 + if (!refreshToken || !clientId) { 261 + throw new Error("No refresh token available"); 262 + } 263 + const dpopProof = await createDPoPProof("POST", tokenUrl); 264 + const response = await fetch(tokenUrl, { 265 + method: "POST", 266 + headers: { 267 + "Content-Type": "application/x-www-form-urlencoded", 268 + DPoP: dpopProof 269 + }, 270 + body: new URLSearchParams({ 271 + grant_type: "refresh_token", 272 + refresh_token: refreshToken, 273 + client_id: clientId 274 + }) 275 + }); 276 + if (!response.ok) { 277 + const errorData = await response.json().catch(() => ({})); 278 + throw new Error( 279 + `Token refresh failed: ${errorData.error_description || response.statusText}` 280 + ); 281 + } 282 + const tokens = await response.json(); 283 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 284 + if (tokens.refresh_token) { 285 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 286 + } 287 + const expiresAt = Date.now() + tokens.expires_in * 1e3; 288 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 289 + return tokens.access_token; 290 + } 291 + async function getValidAccessToken(tokenUrl) { 292 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 293 + const expiresAt = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || "0"); 294 + if (accessToken && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) { 295 + return accessToken; 296 + } 297 + const clientId = storage.get(STORAGE_KEYS.clientId); 298 + const lockKey = `token_refresh_${clientId}`; 299 + const lockValue = await acquireLock(lockKey); 300 + if (!lockValue) { 301 + await sleep2(100); 302 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 303 + const freshExpiry = parseInt( 304 + storage.get(STORAGE_KEYS.tokenExpiresAt) || "0" 305 + ); 306 + if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) { 307 + return freshToken; 308 + } 309 + throw new Error("Failed to refresh token"); 310 + } 311 + try { 312 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 313 + const freshExpiry = parseInt( 314 + storage.get(STORAGE_KEYS.tokenExpiresAt) || "0" 315 + ); 316 + if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) { 317 + return freshToken; 318 + } 319 + return await refreshTokens(tokenUrl); 320 + } finally { 321 + releaseLock(lockKey, lockValue); 322 + } 323 + } 324 + function storeTokens(tokens) { 325 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 326 + if (tokens.refresh_token) { 327 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 328 + } 329 + const expiresAt = Date.now() + tokens.expires_in * 1e3; 330 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 331 + if (tokens.sub) { 332 + storage.set(STORAGE_KEYS.userDid, tokens.sub); 333 + } 334 + } 335 + function hasValidSession() { 336 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 337 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 338 + return !!(accessToken || refreshToken); 339 + } 340 + 341 + // src/auth/oauth.ts 342 + async function initiateLogin(authorizeUrl, clientId, options = {}) { 343 + const codeVerifier = generateCodeVerifier(); 344 + const codeChallenge = await generateCodeChallenge(codeVerifier); 345 + const state = generateState(); 346 + storage.set(STORAGE_KEYS.codeVerifier, codeVerifier); 347 + storage.set(STORAGE_KEYS.oauthState, state); 348 + storage.set(STORAGE_KEYS.clientId, clientId); 349 + const redirectUri = window.location.origin + window.location.pathname; 350 + const params = new URLSearchParams({ 351 + client_id: clientId, 352 + redirect_uri: redirectUri, 353 + response_type: "code", 354 + code_challenge: codeChallenge, 355 + code_challenge_method: "S256", 356 + state 357 + }); 358 + if (options.handle) { 359 + params.set("login_hint", options.handle); 360 + } 361 + window.location.href = `${authorizeUrl}?${params.toString()}`; 362 + } 363 + async function handleOAuthCallback(tokenUrl) { 364 + const params = new URLSearchParams(window.location.search); 365 + const code = params.get("code"); 366 + const state = params.get("state"); 367 + const error = params.get("error"); 368 + if (error) { 369 + throw new Error( 370 + `OAuth error: ${error} - ${params.get("error_description") || ""}` 371 + ); 372 + } 373 + if (!code || !state) { 374 + return false; 375 + } 376 + const storedState = storage.get(STORAGE_KEYS.oauthState); 377 + if (state !== storedState) { 378 + throw new Error("OAuth state mismatch - possible CSRF attack"); 379 + } 380 + const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 381 + const clientId = storage.get(STORAGE_KEYS.clientId); 382 + const redirectUri = window.location.origin + window.location.pathname; 383 + if (!codeVerifier || !clientId) { 384 + throw new Error("Missing OAuth session data"); 385 + } 386 + const dpopProof = await createDPoPProof("POST", tokenUrl); 387 + const tokenResponse = await fetch(tokenUrl, { 388 + method: "POST", 389 + headers: { 390 + "Content-Type": "application/x-www-form-urlencoded", 391 + DPoP: dpopProof 392 + }, 393 + body: new URLSearchParams({ 394 + grant_type: "authorization_code", 395 + code, 396 + redirect_uri: redirectUri, 397 + client_id: clientId, 398 + code_verifier: codeVerifier 399 + }) 400 + }); 401 + if (!tokenResponse.ok) { 402 + const errorData = await tokenResponse.json().catch(() => ({})); 403 + throw new Error( 404 + `Token exchange failed: ${errorData.error_description || tokenResponse.statusText}` 405 + ); 406 + } 407 + const tokens = await tokenResponse.json(); 408 + storeTokens(tokens); 409 + storage.remove(STORAGE_KEYS.codeVerifier); 410 + storage.remove(STORAGE_KEYS.oauthState); 411 + window.history.replaceState({}, document.title, window.location.pathname); 412 + return true; 413 + } 414 + async function logout(options = {}) { 415 + storage.clear(); 416 + await clearDPoPKeys(); 417 + if (options.reload !== false) { 418 + window.location.reload(); 419 + } 420 + } 421 + 422 + // src/graphql.ts 423 + async function graphqlRequest(graphqlUrl, tokenUrl, query, variables = {}, requireAuth = false) { 424 + const headers = { 425 + "Content-Type": "application/json" 426 + }; 427 + if (requireAuth) { 428 + const token = await getValidAccessToken(tokenUrl); 429 + if (!token) { 430 + throw new Error("Not authenticated"); 431 + } 432 + const dpopProof = await createDPoPProof("POST", graphqlUrl, token); 433 + headers["Authorization"] = `DPoP ${token}`; 434 + headers["DPoP"] = dpopProof; 435 + } 436 + const response = await fetch(graphqlUrl, { 437 + method: "POST", 438 + headers, 439 + body: JSON.stringify({ query, variables }) 440 + }); 441 + if (!response.ok) { 442 + throw new Error(`GraphQL request failed: ${response.statusText}`); 443 + } 444 + const result = await response.json(); 445 + if (result.errors && result.errors.length > 0) { 446 + throw new Error(`GraphQL error: ${result.errors[0].message}`); 447 + } 448 + return result.data; 449 + } 450 + 451 + // src/client.ts 452 + var QuicksliceClient = class { 453 + constructor(options) { 454 + this.initialized = false; 455 + this.server = options.server.replace(/\/$/, ""); 456 + this.clientId = options.clientId; 457 + this.graphqlUrl = `${this.server}/graphql`; 458 + this.authorizeUrl = `${this.server}/oauth/authorize`; 459 + this.tokenUrl = `${this.server}/oauth/token`; 460 + } 461 + /** 462 + * Initialize the client - must be called before other methods 463 + */ 464 + async init() { 465 + if (this.initialized) return; 466 + await getOrCreateDPoPKey(); 467 + this.initialized = true; 468 + } 469 + /** 470 + * Start OAuth login flow 471 + */ 472 + async loginWithRedirect(options = {}) { 473 + await this.init(); 474 + await initiateLogin(this.authorizeUrl, this.clientId, options); 475 + } 476 + /** 477 + * Handle OAuth callback after redirect 478 + * Returns true if callback was handled 479 + */ 480 + async handleRedirectCallback() { 481 + await this.init(); 482 + return await handleOAuthCallback(this.tokenUrl); 483 + } 484 + /** 485 + * Logout and clear all stored data 486 + */ 487 + async logout(options = {}) { 488 + await logout(options); 489 + } 490 + /** 491 + * Check if user is authenticated 492 + */ 493 + async isAuthenticated() { 494 + return hasValidSession(); 495 + } 496 + /** 497 + * Get current user's DID (from stored token data) 498 + * For richer profile info, use client.query() with your own schema 499 + */ 500 + getUser() { 501 + if (!hasValidSession()) { 502 + return null; 503 + } 504 + const did = storage.get(STORAGE_KEYS.userDid); 505 + if (!did) { 506 + return null; 507 + } 508 + return { did }; 509 + } 510 + /** 511 + * Get access token (auto-refreshes if needed) 512 + */ 513 + async getAccessToken() { 514 + await this.init(); 515 + return await getValidAccessToken(this.tokenUrl); 516 + } 517 + /** 518 + * Execute a GraphQL query (authenticated) 519 + */ 520 + async query(query, variables = {}) { 521 + await this.init(); 522 + return await graphqlRequest( 523 + this.graphqlUrl, 524 + this.tokenUrl, 525 + query, 526 + variables, 527 + true 528 + ); 529 + } 530 + /** 531 + * Execute a GraphQL mutation (authenticated) 532 + */ 533 + async mutate(mutation, variables = {}) { 534 + return this.query(mutation, variables); 535 + } 536 + /** 537 + * Execute a public GraphQL query (no auth) 538 + */ 539 + async publicQuery(query, variables = {}) { 540 + await this.init(); 541 + return await graphqlRequest( 542 + this.graphqlUrl, 543 + this.tokenUrl, 544 + query, 545 + variables, 546 + false 547 + ); 548 + } 549 + }; 550 + 551 + // src/errors.ts 552 + var QuicksliceError = class extends Error { 553 + constructor(message) { 554 + super(message); 555 + this.name = "QuicksliceError"; 556 + } 557 + }; 558 + var LoginRequiredError = class extends QuicksliceError { 559 + constructor(message = "Login required") { 560 + super(message); 561 + this.name = "LoginRequiredError"; 562 + } 563 + }; 564 + var NetworkError = class extends QuicksliceError { 565 + constructor(message) { 566 + super(message); 567 + this.name = "NetworkError"; 568 + } 569 + }; 570 + var OAuthError = class extends QuicksliceError { 571 + constructor(code, description) { 572 + super(`OAuth error: ${code}${description ? ` - ${description}` : ""}`); 573 + this.name = "OAuthError"; 574 + this.code = code; 575 + this.description = description; 576 + } 577 + }; 578 + 579 + // src/index.ts 580 + async function createQuicksliceClient(options) { 581 + const client = new QuicksliceClient(options); 582 + await client.init(); 583 + return client; 584 + } 585 + return __toCommonJS(index_exports); 586 + })(); 587 + //# sourceMappingURL=quickslice-client.js.map
+7
quickslice-client-js/dist/quickslice-client.js.map
··· 1 + { 2 + "version": 3, 3 + "sources": ["../src/index.ts", "../src/storage/keys.ts", "../src/storage/storage.ts", "../src/utils/base64url.ts", "../src/utils/crypto.ts", "../src/auth/dpop.ts", "../src/auth/pkce.ts", "../src/storage/lock.ts", "../src/auth/tokens.ts", "../src/auth/oauth.ts", "../src/graphql.ts", "../src/client.ts", "../src/errors.ts"], 4 + "sourcesContent": ["export { QuicksliceClient, QuicksliceClientOptions, User } from './client';\nexport {\n QuicksliceError,\n LoginRequiredError,\n NetworkError,\n OAuthError,\n} from './errors';\n\nimport { QuicksliceClient, QuicksliceClientOptions } from './client';\n\n/**\n * Create and initialize a Quickslice client\n */\nexport async function createQuicksliceClient(\n options: QuicksliceClientOptions\n): Promise<QuicksliceClient> {\n const client = new QuicksliceClient(options);\n await client.init();\n return client;\n}\n", "/**\n * Storage key constants\n */\nexport const STORAGE_KEYS = {\n accessToken: 'quickslice_access_token',\n refreshToken: 'quickslice_refresh_token',\n tokenExpiresAt: 'quickslice_token_expires_at',\n clientId: 'quickslice_client_id',\n userDid: 'quickslice_user_did',\n codeVerifier: 'quickslice_code_verifier',\n oauthState: 'quickslice_oauth_state',\n} as const;\n\nexport type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];\n", "import { STORAGE_KEYS, StorageKey } from './keys';\n\n/**\n * Hybrid storage utility - sessionStorage for OAuth flow state,\n * localStorage for tokens (shared across tabs)\n */\nexport const storage = {\n get(key: StorageKey): string | null {\n // OAuth flow state stays in sessionStorage (per-tab)\n if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) {\n return sessionStorage.getItem(key);\n }\n // Tokens go in localStorage (shared across tabs)\n return localStorage.getItem(key);\n },\n\n set(key: StorageKey, value: string): void {\n if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) {\n sessionStorage.setItem(key, value);\n } else {\n localStorage.setItem(key, value);\n }\n },\n\n remove(key: StorageKey): void {\n sessionStorage.removeItem(key);\n localStorage.removeItem(key);\n },\n\n clear(): void {\n Object.values(STORAGE_KEYS).forEach((key) => {\n sessionStorage.removeItem(key);\n localStorage.removeItem(key);\n });\n },\n};\n", "/**\n * Base64 URL encode a buffer (Uint8Array or ArrayBuffer)\n */\nexport function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string {\n const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary)\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n}\n\n/**\n * Generate a random base64url string\n */\nexport function generateRandomString(byteLength: number): string {\n const bytes = new Uint8Array(byteLength);\n crypto.getRandomValues(bytes);\n return base64UrlEncode(bytes);\n}\n", "import { base64UrlEncode } from './base64url';\n\n/**\n * SHA-256 hash, returned as base64url string\n */\nexport async function sha256Base64Url(data: string): Promise<string> {\n const encoder = new TextEncoder();\n const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data));\n return base64UrlEncode(hash);\n}\n\n/**\n * Sign a JWT with an ECDSA P-256 private key\n */\nexport async function signJwt(\n header: Record<string, unknown>,\n payload: Record<string, unknown>,\n privateKey: CryptoKey\n): Promise<string> {\n const encoder = new TextEncoder();\n\n const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));\n const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));\n\n const signingInput = `${headerB64}.${payloadB64}`;\n\n const signature = await crypto.subtle.sign(\n { name: 'ECDSA', hash: 'SHA-256' },\n privateKey,\n encoder.encode(signingInput)\n );\n\n const signatureB64 = base64UrlEncode(signature);\n\n return `${signingInput}.${signatureB64}`;\n}\n", "import { generateRandomString } from '../utils/base64url';\nimport { sha256Base64Url, signJwt } from '../utils/crypto';\n\nconst DB_NAME = 'quickslice-oauth';\nconst DB_VERSION = 1;\nconst KEY_STORE = 'dpop-keys';\nconst KEY_ID = 'dpop-key';\n\ninterface DPoPKeyData {\n id: string;\n privateKey: CryptoKey;\n publicJwk: JsonWebKey;\n createdAt: number;\n}\n\nlet dbPromise: Promise<IDBDatabase> | null = null;\n\nfunction openDatabase(): Promise<IDBDatabase> {\n if (dbPromise) return dbPromise;\n\n dbPromise = new Promise((resolve, reject) => {\n const request = indexedDB.open(DB_NAME, DB_VERSION);\n\n request.onerror = () => reject(request.error);\n request.onsuccess = () => resolve(request.result);\n\n request.onupgradeneeded = (event) => {\n const db = (event.target as IDBOpenDBRequest).result;\n if (!db.objectStoreNames.contains(KEY_STORE)) {\n db.createObjectStore(KEY_STORE, { keyPath: 'id' });\n }\n };\n });\n\n return dbPromise;\n}\n\nasync function getDPoPKey(): Promise<DPoPKeyData | null> {\n const db = await openDatabase();\n return new Promise((resolve, reject) => {\n const tx = db.transaction(KEY_STORE, 'readonly');\n const store = tx.objectStore(KEY_STORE);\n const request = store.get(KEY_ID);\n\n request.onerror = () => reject(request.error);\n request.onsuccess = () => resolve(request.result || null);\n });\n}\n\nasync function storeDPoPKey(\n privateKey: CryptoKey,\n publicJwk: JsonWebKey\n): Promise<void> {\n const db = await openDatabase();\n return new Promise((resolve, reject) => {\n const tx = db.transaction(KEY_STORE, 'readwrite');\n const store = tx.objectStore(KEY_STORE);\n const request = store.put({\n id: KEY_ID,\n privateKey,\n publicJwk,\n createdAt: Date.now(),\n });\n\n request.onerror = () => reject(request.error);\n request.onsuccess = () => resolve();\n });\n}\n\nexport async function getOrCreateDPoPKey(): Promise<DPoPKeyData> {\n const keyData = await getDPoPKey();\n\n if (keyData) {\n return keyData;\n }\n\n // Generate new P-256 key pair\n const keyPair = await crypto.subtle.generateKey(\n { name: 'ECDSA', namedCurve: 'P-256' },\n false, // NOT extractable - critical for security\n ['sign']\n );\n\n // Export public key as JWK\n const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);\n\n // Store in IndexedDB\n await storeDPoPKey(keyPair.privateKey, publicJwk);\n\n return {\n id: KEY_ID,\n privateKey: keyPair.privateKey,\n publicJwk,\n createdAt: Date.now(),\n };\n}\n\n/**\n * Create a DPoP proof JWT\n */\nexport async function createDPoPProof(\n method: string,\n url: string,\n accessToken: string | null = null\n): Promise<string> {\n const keyData = await getOrCreateDPoPKey();\n\n // Strip WebCrypto-specific fields from JWK for interoperability\n const { kty, crv, x, y } = keyData.publicJwk;\n const minimalJwk = { kty, crv, x, y };\n\n const header = {\n alg: 'ES256',\n typ: 'dpop+jwt',\n jwk: minimalJwk,\n };\n\n const payload: Record<string, unknown> = {\n jti: generateRandomString(16),\n htm: method,\n htu: url,\n iat: Math.floor(Date.now() / 1000),\n };\n\n // Add access token hash if provided (for resource requests)\n if (accessToken) {\n payload.ath = await sha256Base64Url(accessToken);\n }\n\n return await signJwt(header, payload, keyData.privateKey);\n}\n\n/**\n * Clear DPoP keys from IndexedDB\n */\nexport async function clearDPoPKeys(): Promise<void> {\n const db = await openDatabase();\n return new Promise((resolve, reject) => {\n const tx = db.transaction(KEY_STORE, 'readwrite');\n const store = tx.objectStore(KEY_STORE);\n const request = store.clear();\n\n request.onerror = () => reject(request.error);\n request.onsuccess = () => resolve();\n });\n}\n", "import { base64UrlEncode, generateRandomString } from '../utils/base64url';\n\n/**\n * Generate a PKCE code verifier (32 random bytes, base64url encoded)\n */\nexport function generateCodeVerifier(): string {\n return generateRandomString(32);\n}\n\n/**\n * Generate a PKCE code challenge from a verifier (SHA-256, base64url encoded)\n */\nexport async function generateCodeChallenge(verifier: string): Promise<string> {\n const encoder = new TextEncoder();\n const data = encoder.encode(verifier);\n const hash = await crypto.subtle.digest('SHA-256', data);\n return base64UrlEncode(hash);\n}\n\n/**\n * Generate a random state parameter for CSRF protection\n */\nexport function generateState(): string {\n return generateRandomString(16);\n}\n", "const LOCK_TIMEOUT = 5000; // 5 seconds\nconst LOCK_PREFIX = 'quickslice_lock_';\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Acquire a lock using localStorage for multi-tab coordination\n */\nexport async function acquireLock(\n key: string,\n timeout = LOCK_TIMEOUT\n): Promise<string | null> {\n const lockKey = LOCK_PREFIX + key;\n const lockValue = `${Date.now()}_${Math.random()}`;\n const deadline = Date.now() + timeout;\n\n while (Date.now() < deadline) {\n const existing = localStorage.getItem(lockKey);\n\n if (existing) {\n // Check if lock is stale (older than timeout)\n const [timestamp] = existing.split('_');\n if (Date.now() - parseInt(timestamp) > LOCK_TIMEOUT) {\n // Lock is stale, remove it\n localStorage.removeItem(lockKey);\n } else {\n // Lock is held, wait and retry\n await sleep(50);\n continue;\n }\n }\n\n // Try to acquire\n localStorage.setItem(lockKey, lockValue);\n\n // Verify we got it (handle race condition)\n await sleep(10);\n if (localStorage.getItem(lockKey) === lockValue) {\n return lockValue; // Lock acquired\n }\n }\n\n return null; // Failed to acquire\n}\n\n/**\n * Release a lock\n */\nexport function releaseLock(key: string, lockValue: string): void {\n const lockKey = LOCK_PREFIX + key;\n // Only release if we still hold it\n if (localStorage.getItem(lockKey) === lockValue) {\n localStorage.removeItem(lockKey);\n }\n}\n", "import { storage } from '../storage/storage';\nimport { STORAGE_KEYS } from '../storage/keys';\nimport { acquireLock, releaseLock } from '../storage/lock';\nimport { createDPoPProof } from './dpop';\n\nconst TOKEN_REFRESH_BUFFER_MS = 60000; // 60 seconds before expiry\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Refresh tokens using the refresh token\n */\nasync function refreshTokens(tokenUrl: string): Promise<string> {\n const refreshToken = storage.get(STORAGE_KEYS.refreshToken);\n const clientId = storage.get(STORAGE_KEYS.clientId);\n\n if (!refreshToken || !clientId) {\n throw new Error('No refresh token available');\n }\n\n const dpopProof = await createDPoPProof('POST', tokenUrl);\n\n const response = await fetch(tokenUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n DPoP: dpopProof,\n },\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n refresh_token: refreshToken,\n client_id: clientId,\n }),\n });\n\n if (!response.ok) {\n const errorData = await response.json().catch(() => ({}));\n throw new Error(\n `Token refresh failed: ${errorData.error_description || response.statusText}`\n );\n }\n\n const tokens = await response.json();\n\n // Store new tokens (rotation - new refresh token each time)\n storage.set(STORAGE_KEYS.accessToken, tokens.access_token);\n if (tokens.refresh_token) {\n storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token);\n }\n\n const expiresAt = Date.now() + tokens.expires_in * 1000;\n storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString());\n\n return tokens.access_token;\n}\n\n/**\n * Get a valid access token, refreshing if necessary.\n * Uses multi-tab locking to prevent duplicate refresh requests.\n */\nexport async function getValidAccessToken(tokenUrl: string): Promise<string> {\n const accessToken = storage.get(STORAGE_KEYS.accessToken);\n const expiresAt = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || '0');\n\n // Check if token is still valid (with buffer)\n if (accessToken && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) {\n return accessToken;\n }\n\n // Need to refresh - acquire lock first\n const clientId = storage.get(STORAGE_KEYS.clientId);\n const lockKey = `token_refresh_${clientId}`;\n const lockValue = await acquireLock(lockKey);\n\n if (!lockValue) {\n // Failed to acquire lock, another tab is refreshing\n // Wait a bit and check cache again\n await sleep(100);\n const freshToken = storage.get(STORAGE_KEYS.accessToken);\n const freshExpiry = parseInt(\n storage.get(STORAGE_KEYS.tokenExpiresAt) || '0'\n );\n if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) {\n return freshToken;\n }\n throw new Error('Failed to refresh token');\n }\n\n try {\n // Double-check after acquiring lock\n const freshToken = storage.get(STORAGE_KEYS.accessToken);\n const freshExpiry = parseInt(\n storage.get(STORAGE_KEYS.tokenExpiresAt) || '0'\n );\n if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) {\n return freshToken;\n }\n\n // Actually refresh\n return await refreshTokens(tokenUrl);\n } finally {\n releaseLock(lockKey, lockValue);\n }\n}\n\n/**\n * Store tokens from OAuth response\n */\nexport function storeTokens(tokens: {\n access_token: string;\n refresh_token?: string;\n expires_in: number;\n sub?: string;\n}): void {\n storage.set(STORAGE_KEYS.accessToken, tokens.access_token);\n if (tokens.refresh_token) {\n storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token);\n }\n\n const expiresAt = Date.now() + tokens.expires_in * 1000;\n storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString());\n\n if (tokens.sub) {\n storage.set(STORAGE_KEYS.userDid, tokens.sub);\n }\n}\n\n/**\n * Check if we have a valid session\n */\nexport function hasValidSession(): boolean {\n const accessToken = storage.get(STORAGE_KEYS.accessToken);\n const refreshToken = storage.get(STORAGE_KEYS.refreshToken);\n return !!(accessToken || refreshToken);\n}\n", "import { storage } from '../storage/storage';\nimport { STORAGE_KEYS } from '../storage/keys';\nimport { createDPoPProof, clearDPoPKeys } from './dpop';\nimport { generateCodeVerifier, generateCodeChallenge, generateState } from './pkce';\nimport { storeTokens } from './tokens';\n\nexport interface LoginOptions {\n handle?: string;\n}\n\n/**\n * Initiate OAuth login flow with PKCE\n */\nexport async function initiateLogin(\n authorizeUrl: string,\n clientId: string,\n options: LoginOptions = {}\n): Promise<void> {\n const codeVerifier = generateCodeVerifier();\n const codeChallenge = await generateCodeChallenge(codeVerifier);\n const state = generateState();\n\n // Store for callback\n storage.set(STORAGE_KEYS.codeVerifier, codeVerifier);\n storage.set(STORAGE_KEYS.oauthState, state);\n storage.set(STORAGE_KEYS.clientId, clientId);\n\n // Build redirect URI (current page without query params)\n const redirectUri = window.location.origin + window.location.pathname;\n\n // Build authorization URL\n const params = new URLSearchParams({\n client_id: clientId,\n redirect_uri: redirectUri,\n response_type: 'code',\n code_challenge: codeChallenge,\n code_challenge_method: 'S256',\n state: state,\n });\n\n if (options.handle) {\n params.set('login_hint', options.handle);\n }\n\n window.location.href = `${authorizeUrl}?${params.toString()}`;\n}\n\n/**\n * Handle OAuth callback - exchange code for tokens\n * Returns true if callback was handled, false if not a callback\n */\nexport async function handleOAuthCallback(tokenUrl: string): Promise<boolean> {\n const params = new URLSearchParams(window.location.search);\n const code = params.get('code');\n const state = params.get('state');\n const error = params.get('error');\n\n if (error) {\n throw new Error(\n `OAuth error: ${error} - ${params.get('error_description') || ''}`\n );\n }\n\n if (!code || !state) {\n return false; // Not a callback\n }\n\n // Verify state\n const storedState = storage.get(STORAGE_KEYS.oauthState);\n if (state !== storedState) {\n throw new Error('OAuth state mismatch - possible CSRF attack');\n }\n\n // Get stored values\n const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier);\n const clientId = storage.get(STORAGE_KEYS.clientId);\n const redirectUri = window.location.origin + window.location.pathname;\n\n if (!codeVerifier || !clientId) {\n throw new Error('Missing OAuth session data');\n }\n\n // Exchange code for tokens with DPoP\n const dpopProof = await createDPoPProof('POST', tokenUrl);\n\n const tokenResponse = await fetch(tokenUrl, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n DPoP: dpopProof,\n },\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n code: code,\n redirect_uri: redirectUri,\n client_id: clientId,\n code_verifier: codeVerifier,\n }),\n });\n\n if (!tokenResponse.ok) {\n const errorData = await tokenResponse.json().catch(() => ({}));\n throw new Error(\n `Token exchange failed: ${errorData.error_description || tokenResponse.statusText}`\n );\n }\n\n const tokens = await tokenResponse.json();\n\n // Store tokens\n storeTokens(tokens);\n\n // Clean up OAuth state\n storage.remove(STORAGE_KEYS.codeVerifier);\n storage.remove(STORAGE_KEYS.oauthState);\n\n // Clear URL params\n window.history.replaceState({}, document.title, window.location.pathname);\n\n return true;\n}\n\n/**\n * Logout - clear all stored data\n */\nexport async function logout(options: { reload?: boolean } = {}): Promise<void> {\n storage.clear();\n await clearDPoPKeys();\n\n if (options.reload !== false) {\n window.location.reload();\n }\n}\n", "import { createDPoPProof } from './auth/dpop';\nimport { getValidAccessToken } from './auth/tokens';\n\nexport interface GraphQLResponse<T = unknown> {\n data?: T;\n errors?: Array<{ message: string; path?: string[] }>;\n}\n\n/**\n * Execute a GraphQL query or mutation\n */\nexport async function graphqlRequest<T = unknown>(\n graphqlUrl: string,\n tokenUrl: string,\n query: string,\n variables: Record<string, unknown> = {},\n requireAuth = false\n): Promise<T> {\n const headers: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n\n if (requireAuth) {\n const token = await getValidAccessToken(tokenUrl);\n if (!token) {\n throw new Error('Not authenticated');\n }\n\n // Create DPoP proof bound to this request\n const dpopProof = await createDPoPProof('POST', graphqlUrl, token);\n\n headers['Authorization'] = `DPoP ${token}`;\n headers['DPoP'] = dpopProof;\n }\n\n const response = await fetch(graphqlUrl, {\n method: 'POST',\n headers,\n body: JSON.stringify({ query, variables }),\n });\n\n if (!response.ok) {\n throw new Error(`GraphQL request failed: ${response.statusText}`);\n }\n\n const result: GraphQLResponse<T> = await response.json();\n\n if (result.errors && result.errors.length > 0) {\n throw new Error(`GraphQL error: ${result.errors[0].message}`);\n }\n\n return result.data as T;\n}\n", "import { storage } from './storage/storage';\nimport { STORAGE_KEYS } from './storage/keys';\nimport { getOrCreateDPoPKey } from './auth/dpop';\nimport { initiateLogin, handleOAuthCallback, logout as doLogout, LoginOptions } from './auth/oauth';\nimport { getValidAccessToken, hasValidSession } from './auth/tokens';\nimport { graphqlRequest } from './graphql';\n\nexport interface QuicksliceClientOptions {\n server: string;\n clientId: string;\n}\n\nexport interface User {\n did: string;\n}\n\nexport class QuicksliceClient {\n private server: string;\n private clientId: string;\n private graphqlUrl: string;\n private authorizeUrl: string;\n private tokenUrl: string;\n private initialized = false;\n\n constructor(options: QuicksliceClientOptions) {\n this.server = options.server.replace(/\\/$/, ''); // Remove trailing slash\n this.clientId = options.clientId;\n\n this.graphqlUrl = `${this.server}/graphql`;\n this.authorizeUrl = `${this.server}/oauth/authorize`;\n this.tokenUrl = `${this.server}/oauth/token`;\n }\n\n /**\n * Initialize the client - must be called before other methods\n */\n async init(): Promise<void> {\n if (this.initialized) return;\n\n // Ensure DPoP key exists\n await getOrCreateDPoPKey();\n\n this.initialized = true;\n }\n\n /**\n * Start OAuth login flow\n */\n async loginWithRedirect(options: LoginOptions = {}): Promise<void> {\n await this.init();\n await initiateLogin(this.authorizeUrl, this.clientId, options);\n }\n\n /**\n * Handle OAuth callback after redirect\n * Returns true if callback was handled\n */\n async handleRedirectCallback(): Promise<boolean> {\n await this.init();\n return await handleOAuthCallback(this.tokenUrl);\n }\n\n /**\n * Logout and clear all stored data\n */\n async logout(options: { reload?: boolean } = {}): Promise<void> {\n await doLogout(options);\n }\n\n /**\n * Check if user is authenticated\n */\n async isAuthenticated(): Promise<boolean> {\n return hasValidSession();\n }\n\n /**\n * Get current user's DID (from stored token data)\n * For richer profile info, use client.query() with your own schema\n */\n getUser(): User | null {\n if (!hasValidSession()) {\n return null;\n }\n\n const did = storage.get(STORAGE_KEYS.userDid);\n if (!did) {\n return null;\n }\n\n return { did };\n }\n\n /**\n * Get access token (auto-refreshes if needed)\n */\n async getAccessToken(): Promise<string> {\n await this.init();\n return await getValidAccessToken(this.tokenUrl);\n }\n\n /**\n * Execute a GraphQL query (authenticated)\n */\n async query<T = unknown>(\n query: string,\n variables: Record<string, unknown> = {}\n ): Promise<T> {\n await this.init();\n return await graphqlRequest<T>(\n this.graphqlUrl,\n this.tokenUrl,\n query,\n variables,\n true\n );\n }\n\n /**\n * Execute a GraphQL mutation (authenticated)\n */\n async mutate<T = unknown>(\n mutation: string,\n variables: Record<string, unknown> = {}\n ): Promise<T> {\n return this.query<T>(mutation, variables);\n }\n\n /**\n * Execute a public GraphQL query (no auth)\n */\n async publicQuery<T = unknown>(\n query: string,\n variables: Record<string, unknown> = {}\n ): Promise<T> {\n await this.init();\n return await graphqlRequest<T>(\n this.graphqlUrl,\n this.tokenUrl,\n query,\n variables,\n false\n );\n }\n}\n", "/**\n * Base error class for Quickslice client errors\n */\nexport class QuicksliceError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'QuicksliceError';\n }\n}\n\n/**\n * Thrown when authentication is required but user is not logged in\n */\nexport class LoginRequiredError extends QuicksliceError {\n constructor(message = 'Login required') {\n super(message);\n this.name = 'LoginRequiredError';\n }\n}\n\n/**\n * Thrown when network request fails\n */\nexport class NetworkError extends QuicksliceError {\n constructor(message: string) {\n super(message);\n this.name = 'NetworkError';\n }\n}\n\n/**\n * Thrown when OAuth flow fails\n */\nexport class OAuthError extends QuicksliceError {\n public code: string;\n public description?: string;\n\n constructor(code: string, description?: string) {\n super(`OAuth error: ${code}${description ? ` - ${description}` : ''}`);\n this.name = 'OAuthError';\n this.code = code;\n this.description = description;\n }\n}\n"], 5 + "mappings": "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", 6 + "names": ["sleep"] 7 + }
+1
quickslice-client-js/dist/quickslice-client.min.js
··· 1 + "use strict";var QuicksliceClient=(()=>{var _=Object.defineProperty;var Y=Object.getOwnPropertyDescriptor;var M=Object.getOwnPropertyNames;var F=Object.prototype.hasOwnProperty;var H=(t,e)=>{for(var r in e)_(t,r,{get:e[r],enumerable:!0})},W=(t,e,r,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of M(e))!F.call(t,s)&&s!==r&&_(t,s,{get:()=>e[s],enumerable:!(o=Y(e,s))||o.enumerable});return t};var X=t=>W(_({},"__esModule",{value:!0}),t);var se={};H(se,{LoginRequiredError:()=>S,NetworkError:()=>T,OAuthError:()=>x,QuicksliceClient:()=>m,QuicksliceError:()=>h,createQuicksliceClient:()=>ie});var n={accessToken:"quickslice_access_token",refreshToken:"quickslice_refresh_token",tokenExpiresAt:"quickslice_token_expires_at",clientId:"quickslice_client_id",userDid:"quickslice_user_did",codeVerifier:"quickslice_code_verifier",oauthState:"quickslice_oauth_state"};var a={get(t){return t===n.codeVerifier||t===n.oauthState?sessionStorage.getItem(t):localStorage.getItem(t)},set(t,e){t===n.codeVerifier||t===n.oauthState?sessionStorage.setItem(t,e):localStorage.setItem(t,e)},remove(t){sessionStorage.removeItem(t),localStorage.removeItem(t)},clear(){Object.values(n).forEach(t=>{sessionStorage.removeItem(t),localStorage.removeItem(t)})}};function d(t){let e=t instanceof Uint8Array?t:new Uint8Array(t),r="";for(let o=0;o<e.length;o++)r+=String.fromCharCode(e[o]);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function k(t){let e=new Uint8Array(t);return crypto.getRandomValues(e),d(e)}async function K(t){let e=new TextEncoder,r=await crypto.subtle.digest("SHA-256",e.encode(t));return d(r)}async function I(t,e,r){let o=new TextEncoder,s=d(o.encode(JSON.stringify(t))),i=d(o.encode(JSON.stringify(e))),c=`${s}.${i}`,l=await crypto.subtle.sign({name:"ECDSA",hash:"SHA-256"},r,o.encode(c)),u=d(l);return`${c}.${u}`}var Z="quickslice-oauth",ee=1,g="dpop-keys",E="dpop-key",y=null;function b(){return y||(y=new Promise((t,e)=>{let r=indexedDB.open(Z,ee);r.onerror=()=>e(r.error),r.onsuccess=()=>t(r.result),r.onupgradeneeded=o=>{let s=o.target.result;s.objectStoreNames.contains(g)||s.createObjectStore(g,{keyPath:"id"})}}),y)}async function te(){let t=await b();return new Promise((e,r)=>{let i=t.transaction(g,"readonly").objectStore(g).get(E);i.onerror=()=>r(i.error),i.onsuccess=()=>e(i.result||null)})}async function re(t,e){let r=await b();return new Promise((o,s)=>{let l=r.transaction(g,"readwrite").objectStore(g).put({id:E,privateKey:t,publicJwk:e,createdAt:Date.now()});l.onerror=()=>s(l.error),l.onsuccess=()=>o()})}async function D(){let t=await te();if(t)return t;let e=await crypto.subtle.generateKey({name:"ECDSA",namedCurve:"P-256"},!1,["sign"]),r=await crypto.subtle.exportKey("jwk",e.publicKey);return await re(e.privateKey,r),{id:E,privateKey:e.privateKey,publicJwk:r,createdAt:Date.now()}}async function f(t,e,r=null){let o=await D(),{kty:s,crv:i,x:c,y:l}=o.publicJwk,w={alg:"ES256",typ:"dpop+jwt",jwk:{kty:s,crv:i,x:c,y:l}},p={jti:k(16),htm:t,htu:e,iat:Math.floor(Date.now()/1e3)};return r&&(p.ath=await K(r)),await I(w,p,o.privateKey)}async function R(){let t=await b();return new Promise((e,r)=>{let i=t.transaction(g,"readwrite").objectStore(g).clear();i.onerror=()=>r(i.error),i.onsuccess=()=>e()})}function C(){return k(32)}async function U(t){let r=new TextEncoder().encode(t),o=await crypto.subtle.digest("SHA-256",r);return d(o)}function q(){return k(16)}var $="quickslice_lock_";function L(t){return new Promise(e=>setTimeout(e,t))}async function V(t,e=5e3){let r=$+t,o=`${Date.now()}_${Math.random()}`,s=Date.now()+e;for(;Date.now()<s;){let i=localStorage.getItem(r);if(i){let[c]=i.split("_");if(Date.now()-parseInt(c)>5e3)localStorage.removeItem(r);else{await L(50);continue}}if(localStorage.setItem(r,o),await L(10),localStorage.getItem(r)===o)return o}return null}function j(t,e){let r=$+t;localStorage.getItem(r)===e&&localStorage.removeItem(r)}var O=6e4;function oe(t){return new Promise(e=>setTimeout(e,t))}async function ne(t){let e=a.get(n.refreshToken),r=a.get(n.clientId);if(!e||!r)throw new Error("No refresh token available");let o=await f("POST",t),s=await fetch(t,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded",DPoP:o},body:new URLSearchParams({grant_type:"refresh_token",refresh_token:e,client_id:r})});if(!s.ok){let l=await s.json().catch(()=>({}));throw new Error(`Token refresh failed: ${l.error_description||s.statusText}`)}let i=await s.json();a.set(n.accessToken,i.access_token),i.refresh_token&&a.set(n.refreshToken,i.refresh_token);let c=Date.now()+i.expires_in*1e3;return a.set(n.tokenExpiresAt,c.toString()),i.access_token}async function P(t){let e=a.get(n.accessToken),r=parseInt(a.get(n.tokenExpiresAt)||"0");if(e&&Date.now()<r-O)return e;let s=`token_refresh_${a.get(n.clientId)}`,i=await V(s);if(!i){await oe(100);let c=a.get(n.accessToken),l=parseInt(a.get(n.tokenExpiresAt)||"0");if(c&&Date.now()<l-O)return c;throw new Error("Failed to refresh token")}try{let c=a.get(n.accessToken),l=parseInt(a.get(n.tokenExpiresAt)||"0");return c&&Date.now()<l-O?c:await ne(t)}finally{j(s,i)}}function B(t){a.set(n.accessToken,t.access_token),t.refresh_token&&a.set(n.refreshToken,t.refresh_token);let e=Date.now()+t.expires_in*1e3;a.set(n.tokenExpiresAt,e.toString()),t.sub&&a.set(n.userDid,t.sub)}function v(){let t=a.get(n.accessToken),e=a.get(n.refreshToken);return!!(t||e)}async function Q(t,e,r={}){let o=C(),s=await U(o),i=q();a.set(n.codeVerifier,o),a.set(n.oauthState,i),a.set(n.clientId,e);let c=window.location.origin+window.location.pathname,l=new URLSearchParams({client_id:e,redirect_uri:c,response_type:"code",code_challenge:s,code_challenge_method:"S256",state:i});r.handle&&l.set("login_hint",r.handle),window.location.href=`${t}?${l.toString()}`}async function J(t){let e=new URLSearchParams(window.location.search),r=e.get("code"),o=e.get("state"),s=e.get("error");if(s)throw new Error(`OAuth error: ${s} - ${e.get("error_description")||""}`);if(!r||!o)return!1;let i=a.get(n.oauthState);if(o!==i)throw new Error("OAuth state mismatch - possible CSRF attack");let c=a.get(n.codeVerifier),l=a.get(n.clientId),u=window.location.origin+window.location.pathname;if(!c||!l)throw new Error("Missing OAuth session data");let w=await f("POST",t),p=await fetch(t,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded",DPoP:w},body:new URLSearchParams({grant_type:"authorization_code",code:r,redirect_uri:u,client_id:l,code_verifier:c})});if(!p.ok){let z=await p.json().catch(()=>({}));throw new Error(`Token exchange failed: ${z.error_description||p.statusText}`)}let N=await p.json();return B(N),a.remove(n.codeVerifier),a.remove(n.oauthState),window.history.replaceState({},document.title,window.location.pathname),!0}async function G(t={}){a.clear(),await R(),t.reload!==!1&&window.location.reload()}async function A(t,e,r,o={},s=!1){let i={"Content-Type":"application/json"};if(s){let u=await P(e);if(!u)throw new Error("Not authenticated");let w=await f("POST",t,u);i.Authorization=`DPoP ${u}`,i.DPoP=w}let c=await fetch(t,{method:"POST",headers:i,body:JSON.stringify({query:r,variables:o})});if(!c.ok)throw new Error(`GraphQL request failed: ${c.statusText}`);let l=await c.json();if(l.errors&&l.errors.length>0)throw new Error(`GraphQL error: ${l.errors[0].message}`);return l.data}var m=class{constructor(e){this.initialized=!1;this.server=e.server.replace(/\/$/,""),this.clientId=e.clientId,this.graphqlUrl=`${this.server}/graphql`,this.authorizeUrl=`${this.server}/oauth/authorize`,this.tokenUrl=`${this.server}/oauth/token`}async init(){this.initialized||(await D(),this.initialized=!0)}async loginWithRedirect(e={}){await this.init(),await Q(this.authorizeUrl,this.clientId,e)}async handleRedirectCallback(){return await this.init(),await J(this.tokenUrl)}async logout(e={}){await G(e)}async isAuthenticated(){return v()}getUser(){if(!v())return null;let e=a.get(n.userDid);return e?{did:e}:null}async getAccessToken(){return await this.init(),await P(this.tokenUrl)}async query(e,r={}){return await this.init(),await A(this.graphqlUrl,this.tokenUrl,e,r,!0)}async mutate(e,r={}){return this.query(e,r)}async publicQuery(e,r={}){return await this.init(),await A(this.graphqlUrl,this.tokenUrl,e,r,!1)}};var h=class extends Error{constructor(e){super(e),this.name="QuicksliceError"}},S=class extends h{constructor(e="Login required"){super(e),this.name="LoginRequiredError"}},T=class extends h{constructor(e){super(e),this.name="NetworkError"}},x=class extends h{constructor(e,r){super(`OAuth error: ${e}${r?` - ${r}`:""}`),this.name="OAuthError",this.code=e,this.description=r}};async function ie(t){let e=new m(t);return await e.init(),e}return X(se);})();
+13
quickslice-client-js/dist/storage/keys.d.ts
··· 1 + /** 2 + * Storage key constants 3 + */ 4 + export declare const STORAGE_KEYS: { 5 + readonly accessToken: "quickslice_access_token"; 6 + readonly refreshToken: "quickslice_refresh_token"; 7 + readonly tokenExpiresAt: "quickslice_token_expires_at"; 8 + readonly clientId: "quickslice_client_id"; 9 + readonly userDid: "quickslice_user_did"; 10 + readonly codeVerifier: "quickslice_code_verifier"; 11 + readonly oauthState: "quickslice_oauth_state"; 12 + }; 13 + export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
+8
quickslice-client-js/dist/storage/lock.d.ts
··· 1 + /** 2 + * Acquire a lock using localStorage for multi-tab coordination 3 + */ 4 + export declare function acquireLock(key: string, timeout?: number): Promise<string | null>; 5 + /** 6 + * Release a lock 7 + */ 8 + export declare function releaseLock(key: string, lockValue: string): void;
+11
quickslice-client-js/dist/storage/storage.d.ts
··· 1 + import { StorageKey } from './keys'; 2 + /** 3 + * Hybrid storage utility - sessionStorage for OAuth flow state, 4 + * localStorage for tokens (shared across tabs) 5 + */ 6 + export declare const storage: { 7 + get(key: StorageKey): string | null; 8 + set(key: StorageKey, value: string): void; 9 + remove(key: StorageKey): void; 10 + clear(): void; 11 + };
+8
quickslice-client-js/dist/utils/base64url.d.ts
··· 1 + /** 2 + * Base64 URL encode a buffer (Uint8Array or ArrayBuffer) 3 + */ 4 + export declare function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string; 5 + /** 6 + * Generate a random base64url string 7 + */ 8 + export declare function generateRandomString(byteLength: number): string;
+8
quickslice-client-js/dist/utils/crypto.d.ts
··· 1 + /** 2 + * SHA-256 hash, returned as base64url string 3 + */ 4 + export declare function sha256Base64Url(data: string): Promise<string>; 5 + /** 6 + * Sign a JWT with an ECDSA P-256 private key 7 + */ 8 + export declare function signJwt(header: Record<string, unknown>, payload: Record<string, unknown>, privateKey: CryptoKey): Promise<string>;
+497
quickslice-client-js/package-lock.json
··· 1 + { 2 + "name": "quickslice-client-js", 3 + "version": "0.1.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "quickslice-client-js", 9 + "version": "0.1.0", 10 + "license": "MIT", 11 + "devDependencies": { 12 + "esbuild": "^0.24.0", 13 + "typescript": "^5.3.0" 14 + } 15 + }, 16 + "node_modules/@esbuild/aix-ppc64": { 17 + "version": "0.24.2", 18 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", 19 + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", 20 + "cpu": [ 21 + "ppc64" 22 + ], 23 + "dev": true, 24 + "license": "MIT", 25 + "optional": true, 26 + "os": [ 27 + "aix" 28 + ], 29 + "engines": { 30 + "node": ">=18" 31 + } 32 + }, 33 + "node_modules/@esbuild/android-arm": { 34 + "version": "0.24.2", 35 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", 36 + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", 37 + "cpu": [ 38 + "arm" 39 + ], 40 + "dev": true, 41 + "license": "MIT", 42 + "optional": true, 43 + "os": [ 44 + "android" 45 + ], 46 + "engines": { 47 + "node": ">=18" 48 + } 49 + }, 50 + "node_modules/@esbuild/android-arm64": { 51 + "version": "0.24.2", 52 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", 53 + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", 54 + "cpu": [ 55 + "arm64" 56 + ], 57 + "dev": true, 58 + "license": "MIT", 59 + "optional": true, 60 + "os": [ 61 + "android" 62 + ], 63 + "engines": { 64 + "node": ">=18" 65 + } 66 + }, 67 + "node_modules/@esbuild/android-x64": { 68 + "version": "0.24.2", 69 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", 70 + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", 71 + "cpu": [ 72 + "x64" 73 + ], 74 + "dev": true, 75 + "license": "MIT", 76 + "optional": true, 77 + "os": [ 78 + "android" 79 + ], 80 + "engines": { 81 + "node": ">=18" 82 + } 83 + }, 84 + "node_modules/@esbuild/darwin-arm64": { 85 + "version": "0.24.2", 86 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", 87 + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", 88 + "cpu": [ 89 + "arm64" 90 + ], 91 + "dev": true, 92 + "license": "MIT", 93 + "optional": true, 94 + "os": [ 95 + "darwin" 96 + ], 97 + "engines": { 98 + "node": ">=18" 99 + } 100 + }, 101 + "node_modules/@esbuild/darwin-x64": { 102 + "version": "0.24.2", 103 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", 104 + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", 105 + "cpu": [ 106 + "x64" 107 + ], 108 + "dev": true, 109 + "license": "MIT", 110 + "optional": true, 111 + "os": [ 112 + "darwin" 113 + ], 114 + "engines": { 115 + "node": ">=18" 116 + } 117 + }, 118 + "node_modules/@esbuild/freebsd-arm64": { 119 + "version": "0.24.2", 120 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", 121 + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", 122 + "cpu": [ 123 + "arm64" 124 + ], 125 + "dev": true, 126 + "license": "MIT", 127 + "optional": true, 128 + "os": [ 129 + "freebsd" 130 + ], 131 + "engines": { 132 + "node": ">=18" 133 + } 134 + }, 135 + "node_modules/@esbuild/freebsd-x64": { 136 + "version": "0.24.2", 137 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", 138 + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", 139 + "cpu": [ 140 + "x64" 141 + ], 142 + "dev": true, 143 + "license": "MIT", 144 + "optional": true, 145 + "os": [ 146 + "freebsd" 147 + ], 148 + "engines": { 149 + "node": ">=18" 150 + } 151 + }, 152 + "node_modules/@esbuild/linux-arm": { 153 + "version": "0.24.2", 154 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", 155 + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", 156 + "cpu": [ 157 + "arm" 158 + ], 159 + "dev": true, 160 + "license": "MIT", 161 + "optional": true, 162 + "os": [ 163 + "linux" 164 + ], 165 + "engines": { 166 + "node": ">=18" 167 + } 168 + }, 169 + "node_modules/@esbuild/linux-arm64": { 170 + "version": "0.24.2", 171 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", 172 + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", 173 + "cpu": [ 174 + "arm64" 175 + ], 176 + "dev": true, 177 + "license": "MIT", 178 + "optional": true, 179 + "os": [ 180 + "linux" 181 + ], 182 + "engines": { 183 + "node": ">=18" 184 + } 185 + }, 186 + "node_modules/@esbuild/linux-ia32": { 187 + "version": "0.24.2", 188 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", 189 + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", 190 + "cpu": [ 191 + "ia32" 192 + ], 193 + "dev": true, 194 + "license": "MIT", 195 + "optional": true, 196 + "os": [ 197 + "linux" 198 + ], 199 + "engines": { 200 + "node": ">=18" 201 + } 202 + }, 203 + "node_modules/@esbuild/linux-loong64": { 204 + "version": "0.24.2", 205 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", 206 + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", 207 + "cpu": [ 208 + "loong64" 209 + ], 210 + "dev": true, 211 + "license": "MIT", 212 + "optional": true, 213 + "os": [ 214 + "linux" 215 + ], 216 + "engines": { 217 + "node": ">=18" 218 + } 219 + }, 220 + "node_modules/@esbuild/linux-mips64el": { 221 + "version": "0.24.2", 222 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", 223 + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", 224 + "cpu": [ 225 + "mips64el" 226 + ], 227 + "dev": true, 228 + "license": "MIT", 229 + "optional": true, 230 + "os": [ 231 + "linux" 232 + ], 233 + "engines": { 234 + "node": ">=18" 235 + } 236 + }, 237 + "node_modules/@esbuild/linux-ppc64": { 238 + "version": "0.24.2", 239 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", 240 + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", 241 + "cpu": [ 242 + "ppc64" 243 + ], 244 + "dev": true, 245 + "license": "MIT", 246 + "optional": true, 247 + "os": [ 248 + "linux" 249 + ], 250 + "engines": { 251 + "node": ">=18" 252 + } 253 + }, 254 + "node_modules/@esbuild/linux-riscv64": { 255 + "version": "0.24.2", 256 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", 257 + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", 258 + "cpu": [ 259 + "riscv64" 260 + ], 261 + "dev": true, 262 + "license": "MIT", 263 + "optional": true, 264 + "os": [ 265 + "linux" 266 + ], 267 + "engines": { 268 + "node": ">=18" 269 + } 270 + }, 271 + "node_modules/@esbuild/linux-s390x": { 272 + "version": "0.24.2", 273 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", 274 + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", 275 + "cpu": [ 276 + "s390x" 277 + ], 278 + "dev": true, 279 + "license": "MIT", 280 + "optional": true, 281 + "os": [ 282 + "linux" 283 + ], 284 + "engines": { 285 + "node": ">=18" 286 + } 287 + }, 288 + "node_modules/@esbuild/linux-x64": { 289 + "version": "0.24.2", 290 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", 291 + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", 292 + "cpu": [ 293 + "x64" 294 + ], 295 + "dev": true, 296 + "license": "MIT", 297 + "optional": true, 298 + "os": [ 299 + "linux" 300 + ], 301 + "engines": { 302 + "node": ">=18" 303 + } 304 + }, 305 + "node_modules/@esbuild/netbsd-arm64": { 306 + "version": "0.24.2", 307 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", 308 + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", 309 + "cpu": [ 310 + "arm64" 311 + ], 312 + "dev": true, 313 + "license": "MIT", 314 + "optional": true, 315 + "os": [ 316 + "netbsd" 317 + ], 318 + "engines": { 319 + "node": ">=18" 320 + } 321 + }, 322 + "node_modules/@esbuild/netbsd-x64": { 323 + "version": "0.24.2", 324 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", 325 + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", 326 + "cpu": [ 327 + "x64" 328 + ], 329 + "dev": true, 330 + "license": "MIT", 331 + "optional": true, 332 + "os": [ 333 + "netbsd" 334 + ], 335 + "engines": { 336 + "node": ">=18" 337 + } 338 + }, 339 + "node_modules/@esbuild/openbsd-arm64": { 340 + "version": "0.24.2", 341 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", 342 + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", 343 + "cpu": [ 344 + "arm64" 345 + ], 346 + "dev": true, 347 + "license": "MIT", 348 + "optional": true, 349 + "os": [ 350 + "openbsd" 351 + ], 352 + "engines": { 353 + "node": ">=18" 354 + } 355 + }, 356 + "node_modules/@esbuild/openbsd-x64": { 357 + "version": "0.24.2", 358 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", 359 + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", 360 + "cpu": [ 361 + "x64" 362 + ], 363 + "dev": true, 364 + "license": "MIT", 365 + "optional": true, 366 + "os": [ 367 + "openbsd" 368 + ], 369 + "engines": { 370 + "node": ">=18" 371 + } 372 + }, 373 + "node_modules/@esbuild/sunos-x64": { 374 + "version": "0.24.2", 375 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", 376 + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", 377 + "cpu": [ 378 + "x64" 379 + ], 380 + "dev": true, 381 + "license": "MIT", 382 + "optional": true, 383 + "os": [ 384 + "sunos" 385 + ], 386 + "engines": { 387 + "node": ">=18" 388 + } 389 + }, 390 + "node_modules/@esbuild/win32-arm64": { 391 + "version": "0.24.2", 392 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", 393 + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", 394 + "cpu": [ 395 + "arm64" 396 + ], 397 + "dev": true, 398 + "license": "MIT", 399 + "optional": true, 400 + "os": [ 401 + "win32" 402 + ], 403 + "engines": { 404 + "node": ">=18" 405 + } 406 + }, 407 + "node_modules/@esbuild/win32-ia32": { 408 + "version": "0.24.2", 409 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", 410 + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", 411 + "cpu": [ 412 + "ia32" 413 + ], 414 + "dev": true, 415 + "license": "MIT", 416 + "optional": true, 417 + "os": [ 418 + "win32" 419 + ], 420 + "engines": { 421 + "node": ">=18" 422 + } 423 + }, 424 + "node_modules/@esbuild/win32-x64": { 425 + "version": "0.24.2", 426 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", 427 + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", 428 + "cpu": [ 429 + "x64" 430 + ], 431 + "dev": true, 432 + "license": "MIT", 433 + "optional": true, 434 + "os": [ 435 + "win32" 436 + ], 437 + "engines": { 438 + "node": ">=18" 439 + } 440 + }, 441 + "node_modules/esbuild": { 442 + "version": "0.24.2", 443 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", 444 + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", 445 + "dev": true, 446 + "hasInstallScript": true, 447 + "license": "MIT", 448 + "bin": { 449 + "esbuild": "bin/esbuild" 450 + }, 451 + "engines": { 452 + "node": ">=18" 453 + }, 454 + "optionalDependencies": { 455 + "@esbuild/aix-ppc64": "0.24.2", 456 + "@esbuild/android-arm": "0.24.2", 457 + "@esbuild/android-arm64": "0.24.2", 458 + "@esbuild/android-x64": "0.24.2", 459 + "@esbuild/darwin-arm64": "0.24.2", 460 + "@esbuild/darwin-x64": "0.24.2", 461 + "@esbuild/freebsd-arm64": "0.24.2", 462 + "@esbuild/freebsd-x64": "0.24.2", 463 + "@esbuild/linux-arm": "0.24.2", 464 + "@esbuild/linux-arm64": "0.24.2", 465 + "@esbuild/linux-ia32": "0.24.2", 466 + "@esbuild/linux-loong64": "0.24.2", 467 + "@esbuild/linux-mips64el": "0.24.2", 468 + "@esbuild/linux-ppc64": "0.24.2", 469 + "@esbuild/linux-riscv64": "0.24.2", 470 + "@esbuild/linux-s390x": "0.24.2", 471 + "@esbuild/linux-x64": "0.24.2", 472 + "@esbuild/netbsd-arm64": "0.24.2", 473 + "@esbuild/netbsd-x64": "0.24.2", 474 + "@esbuild/openbsd-arm64": "0.24.2", 475 + "@esbuild/openbsd-x64": "0.24.2", 476 + "@esbuild/sunos-x64": "0.24.2", 477 + "@esbuild/win32-arm64": "0.24.2", 478 + "@esbuild/win32-ia32": "0.24.2", 479 + "@esbuild/win32-x64": "0.24.2" 480 + } 481 + }, 482 + "node_modules/typescript": { 483 + "version": "5.9.3", 484 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 485 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 486 + "dev": true, 487 + "license": "Apache-2.0", 488 + "bin": { 489 + "tsc": "bin/tsc", 490 + "tsserver": "bin/tsserver" 491 + }, 492 + "engines": { 493 + "node": ">=14.17" 494 + } 495 + } 496 + } 497 + }
+26
quickslice-client-js/package.json
··· 1 + { 2 + "name": "quickslice-client-js", 3 + "version": "0.1.0", 4 + "description": "Quickslice client SDK for browser SPAs", 5 + "main": "dist/quickslice-client.js", 6 + "module": "dist/quickslice-client.esm.js", 7 + "types": "dist/index.d.ts", 8 + "files": [ 9 + "dist" 10 + ], 11 + "scripts": { 12 + "build": "node build.mjs && tsc", 13 + "watch": "node build.mjs --watch" 14 + }, 15 + "devDependencies": { 16 + "esbuild": "^0.24.0", 17 + "typescript": "^5.3.0" 18 + }, 19 + "keywords": [ 20 + "quickslice", 21 + "oauth", 22 + "dpop", 23 + "atproto" 24 + ], 25 + "license": "MIT" 26 + }
+146
quickslice-client-js/src/auth/dpop.ts
··· 1 + import { generateRandomString } from '../utils/base64url'; 2 + import { sha256Base64Url, signJwt } from '../utils/crypto'; 3 + 4 + const DB_NAME = 'quickslice-oauth'; 5 + const DB_VERSION = 1; 6 + const KEY_STORE = 'dpop-keys'; 7 + const KEY_ID = 'dpop-key'; 8 + 9 + interface DPoPKeyData { 10 + id: string; 11 + privateKey: CryptoKey; 12 + publicJwk: JsonWebKey; 13 + createdAt: number; 14 + } 15 + 16 + let dbPromise: Promise<IDBDatabase> | null = null; 17 + 18 + function openDatabase(): Promise<IDBDatabase> { 19 + if (dbPromise) return dbPromise; 20 + 21 + dbPromise = new Promise((resolve, reject) => { 22 + const request = indexedDB.open(DB_NAME, DB_VERSION); 23 + 24 + request.onerror = () => reject(request.error); 25 + request.onsuccess = () => resolve(request.result); 26 + 27 + request.onupgradeneeded = (event) => { 28 + const db = (event.target as IDBOpenDBRequest).result; 29 + if (!db.objectStoreNames.contains(KEY_STORE)) { 30 + db.createObjectStore(KEY_STORE, { keyPath: 'id' }); 31 + } 32 + }; 33 + }); 34 + 35 + return dbPromise; 36 + } 37 + 38 + async function getDPoPKey(): Promise<DPoPKeyData | null> { 39 + const db = await openDatabase(); 40 + return new Promise((resolve, reject) => { 41 + const tx = db.transaction(KEY_STORE, 'readonly'); 42 + const store = tx.objectStore(KEY_STORE); 43 + const request = store.get(KEY_ID); 44 + 45 + request.onerror = () => reject(request.error); 46 + request.onsuccess = () => resolve(request.result || null); 47 + }); 48 + } 49 + 50 + async function storeDPoPKey( 51 + privateKey: CryptoKey, 52 + publicJwk: JsonWebKey 53 + ): Promise<void> { 54 + const db = await openDatabase(); 55 + return new Promise((resolve, reject) => { 56 + const tx = db.transaction(KEY_STORE, 'readwrite'); 57 + const store = tx.objectStore(KEY_STORE); 58 + const request = store.put({ 59 + id: KEY_ID, 60 + privateKey, 61 + publicJwk, 62 + createdAt: Date.now(), 63 + }); 64 + 65 + request.onerror = () => reject(request.error); 66 + request.onsuccess = () => resolve(); 67 + }); 68 + } 69 + 70 + export async function getOrCreateDPoPKey(): Promise<DPoPKeyData> { 71 + const keyData = await getDPoPKey(); 72 + 73 + if (keyData) { 74 + return keyData; 75 + } 76 + 77 + // Generate new P-256 key pair 78 + const keyPair = await crypto.subtle.generateKey( 79 + { name: 'ECDSA', namedCurve: 'P-256' }, 80 + false, // NOT extractable - critical for security 81 + ['sign'] 82 + ); 83 + 84 + // Export public key as JWK 85 + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); 86 + 87 + // Store in IndexedDB 88 + await storeDPoPKey(keyPair.privateKey, publicJwk); 89 + 90 + return { 91 + id: KEY_ID, 92 + privateKey: keyPair.privateKey, 93 + publicJwk, 94 + createdAt: Date.now(), 95 + }; 96 + } 97 + 98 + /** 99 + * Create a DPoP proof JWT 100 + */ 101 + export async function createDPoPProof( 102 + method: string, 103 + url: string, 104 + accessToken: string | null = null 105 + ): Promise<string> { 106 + const keyData = await getOrCreateDPoPKey(); 107 + 108 + // Strip WebCrypto-specific fields from JWK for interoperability 109 + const { kty, crv, x, y } = keyData.publicJwk; 110 + const minimalJwk = { kty, crv, x, y }; 111 + 112 + const header = { 113 + alg: 'ES256', 114 + typ: 'dpop+jwt', 115 + jwk: minimalJwk, 116 + }; 117 + 118 + const payload: Record<string, unknown> = { 119 + jti: generateRandomString(16), 120 + htm: method, 121 + htu: url, 122 + iat: Math.floor(Date.now() / 1000), 123 + }; 124 + 125 + // Add access token hash if provided (for resource requests) 126 + if (accessToken) { 127 + payload.ath = await sha256Base64Url(accessToken); 128 + } 129 + 130 + return await signJwt(header, payload, keyData.privateKey); 131 + } 132 + 133 + /** 134 + * Clear DPoP keys from IndexedDB 135 + */ 136 + export async function clearDPoPKeys(): Promise<void> { 137 + const db = await openDatabase(); 138 + return new Promise((resolve, reject) => { 139 + const tx = db.transaction(KEY_STORE, 'readwrite'); 140 + const store = tx.objectStore(KEY_STORE); 141 + const request = store.clear(); 142 + 143 + request.onerror = () => reject(request.error); 144 + request.onsuccess = () => resolve(); 145 + }); 146 + }
+133
quickslice-client-js/src/auth/oauth.ts
··· 1 + import { storage } from '../storage/storage'; 2 + import { STORAGE_KEYS } from '../storage/keys'; 3 + import { createDPoPProof, clearDPoPKeys } from './dpop'; 4 + import { generateCodeVerifier, generateCodeChallenge, generateState } from './pkce'; 5 + import { storeTokens } from './tokens'; 6 + 7 + export interface LoginOptions { 8 + handle?: string; 9 + } 10 + 11 + /** 12 + * Initiate OAuth login flow with PKCE 13 + */ 14 + export async function initiateLogin( 15 + authorizeUrl: string, 16 + clientId: string, 17 + options: LoginOptions = {} 18 + ): Promise<void> { 19 + const codeVerifier = generateCodeVerifier(); 20 + const codeChallenge = await generateCodeChallenge(codeVerifier); 21 + const state = generateState(); 22 + 23 + // Store for callback 24 + storage.set(STORAGE_KEYS.codeVerifier, codeVerifier); 25 + storage.set(STORAGE_KEYS.oauthState, state); 26 + storage.set(STORAGE_KEYS.clientId, clientId); 27 + 28 + // Build redirect URI (current page without query params) 29 + const redirectUri = window.location.origin + window.location.pathname; 30 + 31 + // Build authorization URL 32 + const params = new URLSearchParams({ 33 + client_id: clientId, 34 + redirect_uri: redirectUri, 35 + response_type: 'code', 36 + code_challenge: codeChallenge, 37 + code_challenge_method: 'S256', 38 + state: state, 39 + }); 40 + 41 + if (options.handle) { 42 + params.set('login_hint', options.handle); 43 + } 44 + 45 + window.location.href = `${authorizeUrl}?${params.toString()}`; 46 + } 47 + 48 + /** 49 + * Handle OAuth callback - exchange code for tokens 50 + * Returns true if callback was handled, false if not a callback 51 + */ 52 + export async function handleOAuthCallback(tokenUrl: string): Promise<boolean> { 53 + const params = new URLSearchParams(window.location.search); 54 + const code = params.get('code'); 55 + const state = params.get('state'); 56 + const error = params.get('error'); 57 + 58 + if (error) { 59 + throw new Error( 60 + `OAuth error: ${error} - ${params.get('error_description') || ''}` 61 + ); 62 + } 63 + 64 + if (!code || !state) { 65 + return false; // Not a callback 66 + } 67 + 68 + // Verify state 69 + const storedState = storage.get(STORAGE_KEYS.oauthState); 70 + if (state !== storedState) { 71 + throw new Error('OAuth state mismatch - possible CSRF attack'); 72 + } 73 + 74 + // Get stored values 75 + const codeVerifier = storage.get(STORAGE_KEYS.codeVerifier); 76 + const clientId = storage.get(STORAGE_KEYS.clientId); 77 + const redirectUri = window.location.origin + window.location.pathname; 78 + 79 + if (!codeVerifier || !clientId) { 80 + throw new Error('Missing OAuth session data'); 81 + } 82 + 83 + // Exchange code for tokens with DPoP 84 + const dpopProof = await createDPoPProof('POST', tokenUrl); 85 + 86 + const tokenResponse = await fetch(tokenUrl, { 87 + method: 'POST', 88 + headers: { 89 + 'Content-Type': 'application/x-www-form-urlencoded', 90 + DPoP: dpopProof, 91 + }, 92 + body: new URLSearchParams({ 93 + grant_type: 'authorization_code', 94 + code: code, 95 + redirect_uri: redirectUri, 96 + client_id: clientId, 97 + code_verifier: codeVerifier, 98 + }), 99 + }); 100 + 101 + if (!tokenResponse.ok) { 102 + const errorData = await tokenResponse.json().catch(() => ({})); 103 + throw new Error( 104 + `Token exchange failed: ${errorData.error_description || tokenResponse.statusText}` 105 + ); 106 + } 107 + 108 + const tokens = await tokenResponse.json(); 109 + 110 + // Store tokens 111 + storeTokens(tokens); 112 + 113 + // Clean up OAuth state 114 + storage.remove(STORAGE_KEYS.codeVerifier); 115 + storage.remove(STORAGE_KEYS.oauthState); 116 + 117 + // Clear URL params 118 + window.history.replaceState({}, document.title, window.location.pathname); 119 + 120 + return true; 121 + } 122 + 123 + /** 124 + * Logout - clear all stored data 125 + */ 126 + export async function logout(options: { reload?: boolean } = {}): Promise<void> { 127 + storage.clear(); 128 + await clearDPoPKeys(); 129 + 130 + if (options.reload !== false) { 131 + window.location.reload(); 132 + } 133 + }
+25
quickslice-client-js/src/auth/pkce.ts
··· 1 + import { base64UrlEncode, generateRandomString } from '../utils/base64url'; 2 + 3 + /** 4 + * Generate a PKCE code verifier (32 random bytes, base64url encoded) 5 + */ 6 + export function generateCodeVerifier(): string { 7 + return generateRandomString(32); 8 + } 9 + 10 + /** 11 + * Generate a PKCE code challenge from a verifier (SHA-256, base64url encoded) 12 + */ 13 + export async function generateCodeChallenge(verifier: string): Promise<string> { 14 + const encoder = new TextEncoder(); 15 + const data = encoder.encode(verifier); 16 + const hash = await crypto.subtle.digest('SHA-256', data); 17 + return base64UrlEncode(hash); 18 + } 19 + 20 + /** 21 + * Generate a random state parameter for CSRF protection 22 + */ 23 + export function generateState(): string { 24 + return generateRandomString(16); 25 + }
+137
quickslice-client-js/src/auth/tokens.ts
··· 1 + import { storage } from '../storage/storage'; 2 + import { STORAGE_KEYS } from '../storage/keys'; 3 + import { acquireLock, releaseLock } from '../storage/lock'; 4 + import { createDPoPProof } from './dpop'; 5 + 6 + const TOKEN_REFRESH_BUFFER_MS = 60000; // 60 seconds before expiry 7 + 8 + function sleep(ms: number): Promise<void> { 9 + return new Promise((resolve) => setTimeout(resolve, ms)); 10 + } 11 + 12 + /** 13 + * Refresh tokens using the refresh token 14 + */ 15 + async function refreshTokens(tokenUrl: string): Promise<string> { 16 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 17 + const clientId = storage.get(STORAGE_KEYS.clientId); 18 + 19 + if (!refreshToken || !clientId) { 20 + throw new Error('No refresh token available'); 21 + } 22 + 23 + const dpopProof = await createDPoPProof('POST', tokenUrl); 24 + 25 + const response = await fetch(tokenUrl, { 26 + method: 'POST', 27 + headers: { 28 + 'Content-Type': 'application/x-www-form-urlencoded', 29 + DPoP: dpopProof, 30 + }, 31 + body: new URLSearchParams({ 32 + grant_type: 'refresh_token', 33 + refresh_token: refreshToken, 34 + client_id: clientId, 35 + }), 36 + }); 37 + 38 + if (!response.ok) { 39 + const errorData = await response.json().catch(() => ({})); 40 + throw new Error( 41 + `Token refresh failed: ${errorData.error_description || response.statusText}` 42 + ); 43 + } 44 + 45 + const tokens = await response.json(); 46 + 47 + // Store new tokens (rotation - new refresh token each time) 48 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 49 + if (tokens.refresh_token) { 50 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 51 + } 52 + 53 + const expiresAt = Date.now() + tokens.expires_in * 1000; 54 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 55 + 56 + return tokens.access_token; 57 + } 58 + 59 + /** 60 + * Get a valid access token, refreshing if necessary. 61 + * Uses multi-tab locking to prevent duplicate refresh requests. 62 + */ 63 + export async function getValidAccessToken(tokenUrl: string): Promise<string> { 64 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 65 + const expiresAt = parseInt(storage.get(STORAGE_KEYS.tokenExpiresAt) || '0'); 66 + 67 + // Check if token is still valid (with buffer) 68 + if (accessToken && Date.now() < expiresAt - TOKEN_REFRESH_BUFFER_MS) { 69 + return accessToken; 70 + } 71 + 72 + // Need to refresh - acquire lock first 73 + const clientId = storage.get(STORAGE_KEYS.clientId); 74 + const lockKey = `token_refresh_${clientId}`; 75 + const lockValue = await acquireLock(lockKey); 76 + 77 + if (!lockValue) { 78 + // Failed to acquire lock, another tab is refreshing 79 + // Wait a bit and check cache again 80 + await sleep(100); 81 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 82 + const freshExpiry = parseInt( 83 + storage.get(STORAGE_KEYS.tokenExpiresAt) || '0' 84 + ); 85 + if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) { 86 + return freshToken; 87 + } 88 + throw new Error('Failed to refresh token'); 89 + } 90 + 91 + try { 92 + // Double-check after acquiring lock 93 + const freshToken = storage.get(STORAGE_KEYS.accessToken); 94 + const freshExpiry = parseInt( 95 + storage.get(STORAGE_KEYS.tokenExpiresAt) || '0' 96 + ); 97 + if (freshToken && Date.now() < freshExpiry - TOKEN_REFRESH_BUFFER_MS) { 98 + return freshToken; 99 + } 100 + 101 + // Actually refresh 102 + return await refreshTokens(tokenUrl); 103 + } finally { 104 + releaseLock(lockKey, lockValue); 105 + } 106 + } 107 + 108 + /** 109 + * Store tokens from OAuth response 110 + */ 111 + export function storeTokens(tokens: { 112 + access_token: string; 113 + refresh_token?: string; 114 + expires_in: number; 115 + sub?: string; 116 + }): void { 117 + storage.set(STORAGE_KEYS.accessToken, tokens.access_token); 118 + if (tokens.refresh_token) { 119 + storage.set(STORAGE_KEYS.refreshToken, tokens.refresh_token); 120 + } 121 + 122 + const expiresAt = Date.now() + tokens.expires_in * 1000; 123 + storage.set(STORAGE_KEYS.tokenExpiresAt, expiresAt.toString()); 124 + 125 + if (tokens.sub) { 126 + storage.set(STORAGE_KEYS.userDid, tokens.sub); 127 + } 128 + } 129 + 130 + /** 131 + * Check if we have a valid session 132 + */ 133 + export function hasValidSession(): boolean { 134 + const accessToken = storage.get(STORAGE_KEYS.accessToken); 135 + const refreshToken = storage.get(STORAGE_KEYS.refreshToken); 136 + return !!(accessToken || refreshToken); 137 + }
+145
quickslice-client-js/src/client.ts
··· 1 + import { storage } from './storage/storage'; 2 + import { STORAGE_KEYS } from './storage/keys'; 3 + import { getOrCreateDPoPKey } from './auth/dpop'; 4 + import { initiateLogin, handleOAuthCallback, logout as doLogout, LoginOptions } from './auth/oauth'; 5 + import { getValidAccessToken, hasValidSession } from './auth/tokens'; 6 + import { graphqlRequest } from './graphql'; 7 + 8 + export interface QuicksliceClientOptions { 9 + server: string; 10 + clientId: string; 11 + } 12 + 13 + export interface User { 14 + did: string; 15 + } 16 + 17 + export class QuicksliceClient { 18 + private server: string; 19 + private clientId: string; 20 + private graphqlUrl: string; 21 + private authorizeUrl: string; 22 + private tokenUrl: string; 23 + private initialized = false; 24 + 25 + constructor(options: QuicksliceClientOptions) { 26 + this.server = options.server.replace(/\/$/, ''); // Remove trailing slash 27 + this.clientId = options.clientId; 28 + 29 + this.graphqlUrl = `${this.server}/graphql`; 30 + this.authorizeUrl = `${this.server}/oauth/authorize`; 31 + this.tokenUrl = `${this.server}/oauth/token`; 32 + } 33 + 34 + /** 35 + * Initialize the client - must be called before other methods 36 + */ 37 + async init(): Promise<void> { 38 + if (this.initialized) return; 39 + 40 + // Ensure DPoP key exists 41 + await getOrCreateDPoPKey(); 42 + 43 + this.initialized = true; 44 + } 45 + 46 + /** 47 + * Start OAuth login flow 48 + */ 49 + async loginWithRedirect(options: LoginOptions = {}): Promise<void> { 50 + await this.init(); 51 + await initiateLogin(this.authorizeUrl, this.clientId, options); 52 + } 53 + 54 + /** 55 + * Handle OAuth callback after redirect 56 + * Returns true if callback was handled 57 + */ 58 + async handleRedirectCallback(): Promise<boolean> { 59 + await this.init(); 60 + return await handleOAuthCallback(this.tokenUrl); 61 + } 62 + 63 + /** 64 + * Logout and clear all stored data 65 + */ 66 + async logout(options: { reload?: boolean } = {}): Promise<void> { 67 + await doLogout(options); 68 + } 69 + 70 + /** 71 + * Check if user is authenticated 72 + */ 73 + async isAuthenticated(): Promise<boolean> { 74 + return hasValidSession(); 75 + } 76 + 77 + /** 78 + * Get current user's DID (from stored token data) 79 + * For richer profile info, use client.query() with your own schema 80 + */ 81 + getUser(): User | null { 82 + if (!hasValidSession()) { 83 + return null; 84 + } 85 + 86 + const did = storage.get(STORAGE_KEYS.userDid); 87 + if (!did) { 88 + return null; 89 + } 90 + 91 + return { did }; 92 + } 93 + 94 + /** 95 + * Get access token (auto-refreshes if needed) 96 + */ 97 + async getAccessToken(): Promise<string> { 98 + await this.init(); 99 + return await getValidAccessToken(this.tokenUrl); 100 + } 101 + 102 + /** 103 + * Execute a GraphQL query (authenticated) 104 + */ 105 + async query<T = unknown>( 106 + query: string, 107 + variables: Record<string, unknown> = {} 108 + ): Promise<T> { 109 + await this.init(); 110 + return await graphqlRequest<T>( 111 + this.graphqlUrl, 112 + this.tokenUrl, 113 + query, 114 + variables, 115 + true 116 + ); 117 + } 118 + 119 + /** 120 + * Execute a GraphQL mutation (authenticated) 121 + */ 122 + async mutate<T = unknown>( 123 + mutation: string, 124 + variables: Record<string, unknown> = {} 125 + ): Promise<T> { 126 + return this.query<T>(mutation, variables); 127 + } 128 + 129 + /** 130 + * Execute a public GraphQL query (no auth) 131 + */ 132 + async publicQuery<T = unknown>( 133 + query: string, 134 + variables: Record<string, unknown> = {} 135 + ): Promise<T> { 136 + await this.init(); 137 + return await graphqlRequest<T>( 138 + this.graphqlUrl, 139 + this.tokenUrl, 140 + query, 141 + variables, 142 + false 143 + ); 144 + } 145 + }
+44
quickslice-client-js/src/errors.ts
··· 1 + /** 2 + * Base error class for Quickslice client errors 3 + */ 4 + export class QuicksliceError extends Error { 5 + constructor(message: string) { 6 + super(message); 7 + this.name = 'QuicksliceError'; 8 + } 9 + } 10 + 11 + /** 12 + * Thrown when authentication is required but user is not logged in 13 + */ 14 + export class LoginRequiredError extends QuicksliceError { 15 + constructor(message = 'Login required') { 16 + super(message); 17 + this.name = 'LoginRequiredError'; 18 + } 19 + } 20 + 21 + /** 22 + * Thrown when network request fails 23 + */ 24 + export class NetworkError extends QuicksliceError { 25 + constructor(message: string) { 26 + super(message); 27 + this.name = 'NetworkError'; 28 + } 29 + } 30 + 31 + /** 32 + * Thrown when OAuth flow fails 33 + */ 34 + export class OAuthError extends QuicksliceError { 35 + public code: string; 36 + public description?: string; 37 + 38 + constructor(code: string, description?: string) { 39 + super(`OAuth error: ${code}${description ? ` - ${description}` : ''}`); 40 + this.name = 'OAuthError'; 41 + this.code = code; 42 + this.description = description; 43 + } 44 + }
+53
quickslice-client-js/src/graphql.ts
··· 1 + import { createDPoPProof } from './auth/dpop'; 2 + import { getValidAccessToken } from './auth/tokens'; 3 + 4 + export interface GraphQLResponse<T = unknown> { 5 + data?: T; 6 + errors?: Array<{ message: string; path?: string[] }>; 7 + } 8 + 9 + /** 10 + * Execute a GraphQL query or mutation 11 + */ 12 + export async function graphqlRequest<T = unknown>( 13 + graphqlUrl: string, 14 + tokenUrl: string, 15 + query: string, 16 + variables: Record<string, unknown> = {}, 17 + requireAuth = false 18 + ): Promise<T> { 19 + const headers: Record<string, string> = { 20 + 'Content-Type': 'application/json', 21 + }; 22 + 23 + if (requireAuth) { 24 + const token = await getValidAccessToken(tokenUrl); 25 + if (!token) { 26 + throw new Error('Not authenticated'); 27 + } 28 + 29 + // Create DPoP proof bound to this request 30 + const dpopProof = await createDPoPProof('POST', graphqlUrl, token); 31 + 32 + headers['Authorization'] = `DPoP ${token}`; 33 + headers['DPoP'] = dpopProof; 34 + } 35 + 36 + const response = await fetch(graphqlUrl, { 37 + method: 'POST', 38 + headers, 39 + body: JSON.stringify({ query, variables }), 40 + }); 41 + 42 + if (!response.ok) { 43 + throw new Error(`GraphQL request failed: ${response.statusText}`); 44 + } 45 + 46 + const result: GraphQLResponse<T> = await response.json(); 47 + 48 + if (result.errors && result.errors.length > 0) { 49 + throw new Error(`GraphQL error: ${result.errors[0].message}`); 50 + } 51 + 52 + return result.data as T; 53 + }
+20
quickslice-client-js/src/index.ts
··· 1 + export { QuicksliceClient, QuicksliceClientOptions, User } from './client'; 2 + export { 3 + QuicksliceError, 4 + LoginRequiredError, 5 + NetworkError, 6 + OAuthError, 7 + } from './errors'; 8 + 9 + import { QuicksliceClient, QuicksliceClientOptions } from './client'; 10 + 11 + /** 12 + * Create and initialize a Quickslice client 13 + */ 14 + export async function createQuicksliceClient( 15 + options: QuicksliceClientOptions 16 + ): Promise<QuicksliceClient> { 17 + const client = new QuicksliceClient(options); 18 + await client.init(); 19 + return client; 20 + }
+14
quickslice-client-js/src/storage/keys.ts
··· 1 + /** 2 + * Storage key constants 3 + */ 4 + export const STORAGE_KEYS = { 5 + accessToken: 'quickslice_access_token', 6 + refreshToken: 'quickslice_refresh_token', 7 + tokenExpiresAt: 'quickslice_token_expires_at', 8 + clientId: 'quickslice_client_id', 9 + userDid: 'quickslice_user_did', 10 + codeVerifier: 'quickslice_code_verifier', 11 + oauthState: 'quickslice_oauth_state', 12 + } as const; 13 + 14 + export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS];
+57
quickslice-client-js/src/storage/lock.ts
··· 1 + const LOCK_TIMEOUT = 5000; // 5 seconds 2 + const LOCK_PREFIX = 'quickslice_lock_'; 3 + 4 + function sleep(ms: number): Promise<void> { 5 + return new Promise((resolve) => setTimeout(resolve, ms)); 6 + } 7 + 8 + /** 9 + * Acquire a lock using localStorage for multi-tab coordination 10 + */ 11 + export async function acquireLock( 12 + key: string, 13 + timeout = LOCK_TIMEOUT 14 + ): Promise<string | null> { 15 + const lockKey = LOCK_PREFIX + key; 16 + const lockValue = `${Date.now()}_${Math.random()}`; 17 + const deadline = Date.now() + timeout; 18 + 19 + while (Date.now() < deadline) { 20 + const existing = localStorage.getItem(lockKey); 21 + 22 + if (existing) { 23 + // Check if lock is stale (older than timeout) 24 + const [timestamp] = existing.split('_'); 25 + if (Date.now() - parseInt(timestamp) > LOCK_TIMEOUT) { 26 + // Lock is stale, remove it 27 + localStorage.removeItem(lockKey); 28 + } else { 29 + // Lock is held, wait and retry 30 + await sleep(50); 31 + continue; 32 + } 33 + } 34 + 35 + // Try to acquire 36 + localStorage.setItem(lockKey, lockValue); 37 + 38 + // Verify we got it (handle race condition) 39 + await sleep(10); 40 + if (localStorage.getItem(lockKey) === lockValue) { 41 + return lockValue; // Lock acquired 42 + } 43 + } 44 + 45 + return null; // Failed to acquire 46 + } 47 + 48 + /** 49 + * Release a lock 50 + */ 51 + export function releaseLock(key: string, lockValue: string): void { 52 + const lockKey = LOCK_PREFIX + key; 53 + // Only release if we still hold it 54 + if (localStorage.getItem(lockKey) === lockValue) { 55 + localStorage.removeItem(lockKey); 56 + } 57 + }
+36
quickslice-client-js/src/storage/storage.ts
··· 1 + import { STORAGE_KEYS, StorageKey } from './keys'; 2 + 3 + /** 4 + * Hybrid storage utility - sessionStorage for OAuth flow state, 5 + * localStorage for tokens (shared across tabs) 6 + */ 7 + export const storage = { 8 + get(key: StorageKey): string | null { 9 + // OAuth flow state stays in sessionStorage (per-tab) 10 + if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) { 11 + return sessionStorage.getItem(key); 12 + } 13 + // Tokens go in localStorage (shared across tabs) 14 + return localStorage.getItem(key); 15 + }, 16 + 17 + set(key: StorageKey, value: string): void { 18 + if (key === STORAGE_KEYS.codeVerifier || key === STORAGE_KEYS.oauthState) { 19 + sessionStorage.setItem(key, value); 20 + } else { 21 + localStorage.setItem(key, value); 22 + } 23 + }, 24 + 25 + remove(key: StorageKey): void { 26 + sessionStorage.removeItem(key); 27 + localStorage.removeItem(key); 28 + }, 29 + 30 + clear(): void { 31 + Object.values(STORAGE_KEYS).forEach((key) => { 32 + sessionStorage.removeItem(key); 33 + localStorage.removeItem(key); 34 + }); 35 + }, 36 + };
+23
quickslice-client-js/src/utils/base64url.ts
··· 1 + /** 2 + * Base64 URL encode a buffer (Uint8Array or ArrayBuffer) 3 + */ 4 + export function base64UrlEncode(buffer: ArrayBuffer | Uint8Array): string { 5 + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); 6 + let binary = ''; 7 + for (let i = 0; i < bytes.length; i++) { 8 + binary += String.fromCharCode(bytes[i]); 9 + } 10 + return btoa(binary) 11 + .replace(/\+/g, '-') 12 + .replace(/\//g, '_') 13 + .replace(/=+$/, ''); 14 + } 15 + 16 + /** 17 + * Generate a random base64url string 18 + */ 19 + export function generateRandomString(byteLength: number): string { 20 + const bytes = new Uint8Array(byteLength); 21 + crypto.getRandomValues(bytes); 22 + return base64UrlEncode(bytes); 23 + }
+36
quickslice-client-js/src/utils/crypto.ts
··· 1 + import { base64UrlEncode } from './base64url'; 2 + 3 + /** 4 + * SHA-256 hash, returned as base64url string 5 + */ 6 + export async function sha256Base64Url(data: string): Promise<string> { 7 + const encoder = new TextEncoder(); 8 + const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data)); 9 + return base64UrlEncode(hash); 10 + } 11 + 12 + /** 13 + * Sign a JWT with an ECDSA P-256 private key 14 + */ 15 + export async function signJwt( 16 + header: Record<string, unknown>, 17 + payload: Record<string, unknown>, 18 + privateKey: CryptoKey 19 + ): Promise<string> { 20 + const encoder = new TextEncoder(); 21 + 22 + const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header))); 23 + const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload))); 24 + 25 + const signingInput = `${headerB64}.${payloadB64}`; 26 + 27 + const signature = await crypto.subtle.sign( 28 + { name: 'ECDSA', hash: 'SHA-256' }, 29 + privateKey, 30 + encoder.encode(signingInput) 31 + ); 32 + 33 + const signatureB64 = base64UrlEncode(signature); 34 + 35 + return `${signingInput}.${signatureB64}`; 36 + }
+18
quickslice-client-js/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2020", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "lib": ["ES2020", "DOM"], 7 + "strict": true, 8 + "declaration": true, 9 + "declarationDir": "dist", 10 + "emitDeclarationOnly": true, 11 + "outDir": "dist", 12 + "rootDir": "src", 13 + "skipLibCheck": true, 14 + "esModuleInterop": true 15 + }, 16 + "include": ["src/**/*"], 17 + "exclude": ["node_modules", "dist"] 18 + }
+73
server/src/database/repositories/oauth_dpop_jti.gleam
··· 1 + /// OAuth DPoP JTI replay protection repository 2 + /// Tracks used JTI values to prevent replay attacks 3 + import gleam/dynamic/decode 4 + import gleam/list 5 + import gleam/result 6 + import sqlight 7 + 8 + /// Check if a JTI has been used and mark it as used atomically 9 + /// Returns Ok(True) if the JTI was successfully recorded (not previously used) 10 + /// Returns Ok(False) if the JTI was already used (replay attack) 11 + pub fn use_jti( 12 + conn: sqlight.Connection, 13 + jti: String, 14 + created_at: Int, 15 + ) -> Result(Bool, sqlight.Error) { 16 + // Try to insert - will fail if JTI already exists due to PRIMARY KEY constraint 17 + let sql = 18 + "INSERT OR IGNORE INTO oauth_dpop_jti (jti, created_at) VALUES (?, ?)" 19 + 20 + use _ <- result.try(sqlight.query( 21 + sql, 22 + on: conn, 23 + with: [sqlight.text(jti), sqlight.int(created_at)], 24 + expecting: decode.dynamic, 25 + )) 26 + 27 + // Check if insert succeeded by checking changes 28 + let check_sql = "SELECT changes()" 29 + use changes_rows <- result.try(sqlight.query( 30 + check_sql, 31 + on: conn, 32 + with: [], 33 + expecting: decode.at([0], decode.int), 34 + )) 35 + 36 + case list.first(changes_rows) { 37 + Ok(1) -> Ok(True) 38 + // Insert succeeded, JTI was new 39 + Ok(0) -> Ok(False) 40 + // Insert ignored, JTI was duplicate 41 + _ -> Ok(False) 42 + } 43 + } 44 + 45 + /// Delete expired JTI entries 46 + /// Should be called periodically to clean up old entries 47 + pub fn delete_expired( 48 + conn: sqlight.Connection, 49 + before: Int, 50 + ) -> Result(Int, sqlight.Error) { 51 + let sql = "DELETE FROM oauth_dpop_jti WHERE created_at < ?" 52 + 53 + use _ <- result.try(sqlight.query( 54 + sql, 55 + on: conn, 56 + with: [sqlight.int(before)], 57 + expecting: decode.dynamic, 58 + )) 59 + 60 + // Get count of deleted rows 61 + let check_sql = "SELECT changes()" 62 + use changes_rows <- result.try(sqlight.query( 63 + check_sql, 64 + on: conn, 65 + with: [], 66 + expecting: decode.at([0], decode.int), 67 + )) 68 + 69 + case list.first(changes_rows) { 70 + Ok(count) -> Ok(count) 71 + Error(_) -> Ok(0) 72 + } 73 + }
+36 -17
server/src/database/schema/migrations.gleam
··· 150 150 tables.create_admin_session_table(conn) 151 151 } 152 152 153 + /// Migration v9: Add oauth_dpop_jti table for DPoP replay protection 154 + fn migration_v9(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { 155 + logging.log(logging.Info, "Running migration v9 (oauth_dpop_jti table)...") 156 + tables.create_oauth_dpop_jti_table(conn) 157 + } 158 + 153 159 /// Runs all pending migrations based on current schema version 154 160 pub fn run_migrations(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { 155 161 use _ <- result.try(create_schema_version_table(conn)) ··· 171 177 use _ <- result.try(apply_migration(conn, 5, migration_v5)) 172 178 use _ <- result.try(apply_migration(conn, 6, migration_v6)) 173 179 use _ <- result.try(apply_migration(conn, 7, migration_v7)) 174 - apply_migration(conn, 8, migration_v8) 180 + use _ <- result.try(apply_migration(conn, 8, migration_v8)) 181 + apply_migration(conn, 9, migration_v9) 175 182 } 176 183 177 - // Run v2, v3, v4, v5, v6, v7, and v8 migrations 184 + // Run v2 through v9 migrations 178 185 1 -> { 179 186 use _ <- result.try(apply_migration(conn, 2, migration_v2)) 180 187 use _ <- result.try(apply_migration(conn, 3, migration_v3)) ··· 182 189 use _ <- result.try(apply_migration(conn, 5, migration_v5)) 183 190 use _ <- result.try(apply_migration(conn, 6, migration_v6)) 184 191 use _ <- result.try(apply_migration(conn, 7, migration_v7)) 185 - apply_migration(conn, 8, migration_v8) 192 + use _ <- result.try(apply_migration(conn, 8, migration_v8)) 193 + apply_migration(conn, 9, migration_v9) 186 194 } 187 195 188 - // Run v3, v4, v5, v6, v7, and v8 migrations 196 + // Run v3 through v9 migrations 189 197 2 -> { 190 198 use _ <- result.try(apply_migration(conn, 3, migration_v3)) 191 199 use _ <- result.try(apply_migration(conn, 4, migration_v4)) 192 200 use _ <- result.try(apply_migration(conn, 5, migration_v5)) 193 201 use _ <- result.try(apply_migration(conn, 6, migration_v6)) 194 202 use _ <- result.try(apply_migration(conn, 7, migration_v7)) 195 - apply_migration(conn, 8, migration_v8) 203 + use _ <- result.try(apply_migration(conn, 8, migration_v8)) 204 + apply_migration(conn, 9, migration_v9) 196 205 } 197 206 198 - // Run v4, v5, v6, v7, and v8 migrations 207 + // Run v4 through v9 migrations 199 208 3 -> { 200 209 use _ <- result.try(apply_migration(conn, 4, migration_v4)) 201 210 use _ <- result.try(apply_migration(conn, 5, migration_v5)) 202 211 use _ <- result.try(apply_migration(conn, 6, migration_v6)) 203 212 use _ <- result.try(apply_migration(conn, 7, migration_v7)) 204 - apply_migration(conn, 8, migration_v8) 213 + use _ <- result.try(apply_migration(conn, 8, migration_v8)) 214 + apply_migration(conn, 9, migration_v9) 205 215 } 206 216 207 - // Run v5, v6, v7, and v8 migrations 217 + // Run v5 through v9 migrations 208 218 4 -> { 209 219 use _ <- result.try(apply_migration(conn, 5, migration_v5)) 210 220 use _ <- result.try(apply_migration(conn, 6, migration_v6)) 211 221 use _ <- result.try(apply_migration(conn, 7, migration_v7)) 212 - apply_migration(conn, 8, migration_v8) 222 + use _ <- result.try(apply_migration(conn, 8, migration_v8)) 223 + apply_migration(conn, 9, migration_v9) 213 224 } 214 225 215 - // Run v6, v7, and v8 migrations 226 + // Run v6 through v9 migrations 216 227 5 -> { 217 228 use _ <- result.try(apply_migration(conn, 6, migration_v6)) 218 229 use _ <- result.try(apply_migration(conn, 7, migration_v7)) 219 - apply_migration(conn, 8, migration_v8) 230 + use _ <- result.try(apply_migration(conn, 8, migration_v8)) 231 + apply_migration(conn, 9, migration_v9) 220 232 } 221 233 222 - // Run v7 and v8 migrations 234 + // Run v7 through v9 migrations 223 235 6 -> { 224 236 use _ <- result.try(apply_migration(conn, 7, migration_v7)) 225 - apply_migration(conn, 8, migration_v8) 237 + use _ <- result.try(apply_migration(conn, 8, migration_v8)) 238 + apply_migration(conn, 9, migration_v9) 226 239 } 227 240 228 - // Run v8 migration 229 - 7 -> apply_migration(conn, 8, migration_v8) 241 + // Run v8 and v9 migrations 242 + 7 -> { 243 + use _ <- result.try(apply_migration(conn, 8, migration_v8)) 244 + apply_migration(conn, 9, migration_v9) 245 + } 246 + 247 + // Run v9 migration 248 + 8 -> apply_migration(conn, 9, migration_v9) 230 249 231 250 // Already at latest version 232 - 8 -> { 233 - logging.log(logging.Info, "Schema is up to date (v8)") 251 + 9 -> { 252 + logging.log(logging.Info, "Schema is up to date (v9)") 234 253 Ok(Nil) 235 254 } 236 255
+22
server/src/database/schema/tables.gleam
··· 346 346 sqlight.exec(create_expires_at_index_sql, conn) 347 347 } 348 348 349 + /// Creates the oauth_dpop_jti table for DPoP JTI replay protection 350 + pub fn create_oauth_dpop_jti_table( 351 + conn: sqlight.Connection, 352 + ) -> Result(Nil, sqlight.Error) { 353 + let create_table_sql = 354 + " 355 + CREATE TABLE IF NOT EXISTS oauth_dpop_jti ( 356 + jti TEXT PRIMARY KEY, 357 + created_at INTEGER NOT NULL 358 + ) 359 + " 360 + 361 + let create_created_at_index_sql = 362 + " 363 + CREATE INDEX IF NOT EXISTS idx_oauth_dpop_jti_created_at 364 + ON oauth_dpop_jti(created_at) 365 + " 366 + 367 + use _ <- result.try(sqlight.exec(create_table_sql, conn)) 368 + sqlight.exec(create_created_at_index_sql, conn) 369 + } 370 + 349 371 /// Creates the oauth_auth_request table for client authorization requests during bridge flow 350 372 pub fn create_oauth_auth_request_table( 351 373 conn: sqlight.Connection,
+14
server/src/dpop_validator_ffi.erl
··· 1 + -module(dpop_validator_ffi). 2 + -export([verify_dpop_proof/4]). 3 + 4 + %% Bridge to jose_ffi:verify_dpop_proof with Gleam-compatible return types 5 + verify_dpop_proof(DPoPProof, Method, Url, MaxAgeSeconds) -> 6 + case jose_ffi:verify_dpop_proof(DPoPProof, Method, Url, MaxAgeSeconds) of 7 + {ok, #{jkt := Jkt, jti := Jti, iat := Iat}} -> 8 + %% Return DPoPValidationResult directly (Gleam atom: d_po_p_validation_result) 9 + {ok, {d_po_p_validation_result, Jkt, Jti, Iat}}; 10 + {error, Reason} when is_binary(Reason) -> 11 + {error, Reason}; 12 + {error, Reason} -> 13 + {error, iolist_to_binary(io_lib:format("~p", [Reason]))} 14 + end.
+11 -7
server/src/handlers/graphql.gleam
··· 44 44 plc_url: String, 45 45 ) -> wisp.Response { 46 46 // Extract Authorization header (optional for queries, required for mutations) 47 - // Strip "Bearer " prefix if present 47 + // Strip "Bearer " or "DPoP " prefix if present 48 48 let auth_token = 49 49 list.key_find(req.headers, "authorization") 50 - |> result.map(strip_bearer_prefix) 50 + |> result.map(strip_auth_prefix) 51 51 52 52 // Read request body 53 53 case wisp.read_body_bits(req) { ··· 85 85 plc_url: String, 86 86 ) -> wisp.Response { 87 87 // Extract Authorization header (optional for queries, required for mutations) 88 - // Strip "Bearer " prefix if present 88 + // Strip "Bearer " or "DPoP " prefix if present 89 89 let auth_token = 90 90 list.key_find(req.headers, "authorization") 91 - |> result.map(strip_bearer_prefix) 91 + |> result.map(strip_auth_prefix) 92 92 93 93 // Support GET requests with query parameter (no variables for GET) 94 94 let query_params = wisp.get_query(req) ··· 151 151 Ok(#(query, json_str)) 152 152 } 153 153 154 - /// Strip "Bearer " prefix from Authorization header value 155 - fn strip_bearer_prefix(auth_header: String) -> String { 154 + /// Strip "Bearer " or "DPoP " prefix from Authorization header value 155 + fn strip_auth_prefix(auth_header: String) -> String { 156 156 case string.starts_with(auth_header, "Bearer ") { 157 157 True -> string.drop_start(auth_header, 7) 158 - False -> auth_header 158 + False -> 159 + case string.starts_with(auth_header, "DPoP ") { 160 + True -> string.drop_start(auth_header, 5) 161 + False -> auth_header 162 + } 159 163 } 160 164 } 161 165
+324 -186
server/src/handlers/oauth/token.gleam
··· 4 4 import database/repositories/oauth_access_tokens 5 5 import database/repositories/oauth_authorization_code 6 6 import database/repositories/oauth_clients 7 + import database/repositories/oauth_dpop_jti 7 8 import database/repositories/oauth_refresh_tokens 8 9 import database/types.{ 9 - type OAuthAuthorizationCode, type OAuthRefreshToken, Bearer, OAuthAccessToken, 10 - OAuthRefreshToken, Plain, S256, 10 + type OAuthAuthorizationCode, type OAuthClient, type OAuthRefreshToken, Bearer, 11 + DPoP, OAuthAccessToken, OAuthRefreshToken, Plain, S256, 11 12 } 12 13 import gleam/json 13 14 import gleam/list ··· 15 16 import gleam/result 16 17 import gleam/string 17 18 import gleam/uri 19 + import lib/oauth/dpop/validator as dpop_validator 18 20 import lib/oauth/pkce 19 21 import lib/oauth/scopes/validator as scope_validator 20 22 import lib/oauth/token_generator ··· 108 110 } 109 111 } 110 112 113 + /// Extract and validate DPoP proof from request 114 + /// Returns the JKT (key thumbprint) if valid, or an error response 115 + fn validate_dpop_for_token_endpoint( 116 + req: wisp.Request, 117 + conn: sqlight.Connection, 118 + client: OAuthClient, 119 + external_base_url: String, 120 + ) -> Result(Option(String), wisp.Response) { 121 + // Get DPoP header 122 + let dpop_header = dpop_validator.get_dpop_header(req.headers) 123 + 124 + case dpop_header, client.token_endpoint_auth_method { 125 + // Public clients MUST use DPoP 126 + None, types.AuthNone -> 127 + Error(error_response( 128 + 400, 129 + "invalid_request", 130 + "DPoP proof required for public clients", 131 + )) 132 + 133 + // DPoP provided - validate it 134 + Some(dpop_proof), _ -> { 135 + // Build the token endpoint URL 136 + let token_url = external_base_url <> "/oauth/token" 137 + 138 + case 139 + dpop_validator.verify_dpop_proof(dpop_proof, "POST", token_url, 300) 140 + { 141 + Error(reason) -> 142 + Error(error_response(400, "invalid_dpop_proof", reason)) 143 + Ok(result) -> { 144 + // Check JTI hasn't been used (replay protection) 145 + case oauth_dpop_jti.use_jti(conn, result.jti, result.iat) { 146 + Error(err) -> 147 + Error(error_response( 148 + 500, 149 + "server_error", 150 + "Database error: " <> string.inspect(err), 151 + )) 152 + Ok(False) -> 153 + Error(error_response( 154 + 400, 155 + "invalid_dpop_proof", 156 + "DPoP proof has already been used (replay detected)", 157 + )) 158 + Ok(True) -> Ok(Some(result.jkt)) 159 + } 160 + } 161 + } 162 + } 163 + 164 + // Confidential client without DPoP - allowed 165 + None, _ -> Ok(None) 166 + } 167 + } 168 + 111 169 /// Handle POST /oauth/token 112 - pub fn handle(req: wisp.Request, conn: sqlight.Connection) -> wisp.Response { 170 + pub fn handle( 171 + req: wisp.Request, 172 + conn: sqlight.Connection, 173 + external_base_url: String, 174 + ) -> wisp.Response { 113 175 // Read request body 114 176 use body <- wisp.require_string_body(req) 115 177 ··· 123 185 None -> error_response(400, "invalid_request", "grant_type is required") 124 186 Some(grant_type) -> { 125 187 case grant_type { 126 - "authorization_code" -> handle_authorization_code(params, conn) 127 - "refresh_token" -> handle_refresh_token(params, conn) 188 + "authorization_code" -> 189 + handle_authorization_code(req, params, conn, external_base_url) 190 + "refresh_token" -> 191 + handle_refresh_token(req, params, conn, external_base_url) 128 192 _ -> 129 193 error_response( 130 194 400, ··· 140 204 141 205 /// Handle authorization_code grant 142 206 fn handle_authorization_code( 207 + req: wisp.Request, 143 208 params: List(#(String, String)), 144 209 conn: sqlight.Connection, 210 + external_base_url: String, 145 211 ) -> wisp.Response { 146 212 // Extract required parameters 147 213 let code_value = get_param(params, "code") ··· 170 236 case validate_client_authentication(client, params) { 171 237 Error(err) -> err 172 238 Ok(_) -> { 173 - // Get authorization code 174 - case oauth_authorization_code.get(conn, code_val) { 175 - Error(err) -> 176 - error_response( 177 - 500, 178 - "server_error", 179 - "Database error: " <> string.inspect(err), 180 - ) 181 - Ok(None) -> 182 - error_response( 183 - 400, 184 - "invalid_grant", 185 - "Invalid authorization code", 186 - ) 187 - Ok(Some(code)) -> { 188 - // Validate authorization code 189 - case 190 - validate_authorization_code(code, cid, ruri, code_verifier) 191 - { 192 - Error(err) -> err 193 - Ok(_) -> { 194 - // Mark code as used 195 - case oauth_authorization_code.mark_used(conn, code_val) { 196 - Error(err) -> 197 - error_response( 198 - 500, 199 - "server_error", 200 - "Database error: " <> string.inspect(err), 201 - ) 239 + // Validate DPoP if present/required 240 + case 241 + validate_dpop_for_token_endpoint( 242 + req, 243 + conn, 244 + client, 245 + external_base_url, 246 + ) 247 + { 248 + Error(err) -> err 249 + Ok(dpop_jkt) -> { 250 + // Get authorization code 251 + case oauth_authorization_code.get(conn, code_val) { 252 + Error(err) -> 253 + error_response( 254 + 500, 255 + "server_error", 256 + "Database error: " <> string.inspect(err), 257 + ) 258 + Ok(None) -> 259 + error_response( 260 + 400, 261 + "invalid_grant", 262 + "Invalid authorization code", 263 + ) 264 + Ok(Some(code)) -> { 265 + // Validate authorization code 266 + case 267 + validate_authorization_code( 268 + code, 269 + cid, 270 + ruri, 271 + code_verifier, 272 + ) 273 + { 274 + Error(err) -> err 202 275 Ok(_) -> { 203 - // Generate tokens 204 - let access_token_value = 205 - token_generator.generate_access_token() 206 - let refresh_token_value = 207 - token_generator.generate_refresh_token() 208 - let now = token_generator.current_timestamp() 209 - 210 - let access_token = 211 - OAuthAccessToken( 212 - token: access_token_value, 213 - token_type: Bearer, 214 - client_id: cid, 215 - user_id: Some(code.user_id), 216 - session_id: code.session_id, 217 - session_iteration: code.session_iteration, 218 - scope: code.scope, 219 - created_at: now, 220 - expires_at: token_generator.expiration_timestamp( 221 - client.access_token_expiration, 222 - ), 223 - revoked: False, 224 - dpop_jkt: None, 225 - ) 226 - 227 - let refresh_token = 228 - OAuthRefreshToken( 229 - token: refresh_token_value, 230 - access_token: access_token_value, 231 - client_id: cid, 232 - user_id: code.user_id, 233 - session_id: code.session_id, 234 - session_iteration: code.session_iteration, 235 - scope: code.scope, 236 - created_at: now, 237 - expires_at: case client.refresh_token_expiration { 238 - 0 -> None 239 - exp -> 240 - Some(token_generator.expiration_timestamp(exp)) 241 - }, 242 - revoked: False, 243 - ) 244 - 245 - // Store tokens 246 - case oauth_access_tokens.insert(conn, access_token) { 276 + // Mark code as used 277 + case 278 + oauth_authorization_code.mark_used(conn, code_val) 279 + { 247 280 Error(err) -> 248 281 error_response( 249 282 500, 250 283 "server_error", 251 - "Failed to store access token: " 252 - <> string.inspect(err), 284 + "Database error: " <> string.inspect(err), 253 285 ) 254 286 Ok(_) -> { 287 + // Generate tokens 288 + let access_token_value = 289 + token_generator.generate_access_token() 290 + let refresh_token_value = 291 + token_generator.generate_refresh_token() 292 + let now = token_generator.current_timestamp() 293 + 294 + // Determine token type based on DPoP 295 + let token_type = case dpop_jkt { 296 + Some(_) -> DPoP 297 + None -> Bearer 298 + } 299 + 300 + let access_token = 301 + OAuthAccessToken( 302 + token: access_token_value, 303 + token_type: token_type, 304 + client_id: cid, 305 + user_id: Some(code.user_id), 306 + session_id: code.session_id, 307 + session_iteration: code.session_iteration, 308 + scope: code.scope, 309 + created_at: now, 310 + expires_at: token_generator.expiration_timestamp( 311 + client.access_token_expiration, 312 + ), 313 + revoked: False, 314 + dpop_jkt: dpop_jkt, 315 + ) 316 + 317 + let refresh_token = 318 + OAuthRefreshToken( 319 + token: refresh_token_value, 320 + access_token: access_token_value, 321 + client_id: cid, 322 + user_id: code.user_id, 323 + session_id: code.session_id, 324 + session_iteration: code.session_iteration, 325 + scope: code.scope, 326 + created_at: now, 327 + expires_at: case 328 + client.refresh_token_expiration 329 + { 330 + 0 -> None 331 + exp -> 332 + Some(token_generator.expiration_timestamp( 333 + exp, 334 + )) 335 + }, 336 + revoked: False, 337 + ) 338 + 339 + // Store tokens 255 340 case 256 - oauth_refresh_tokens.insert(conn, refresh_token) 341 + oauth_access_tokens.insert(conn, access_token) 257 342 { 258 343 Error(err) -> 259 344 error_response( 260 345 500, 261 346 "server_error", 262 - "Failed to store refresh token: " 347 + "Failed to store access token: " 263 348 <> string.inspect(err), 264 349 ) 265 350 Ok(_) -> { 266 - // Return token response 267 - token_response( 268 - access_token_value, 269 - "Bearer", 270 - client.access_token_expiration, 271 - Some(refresh_token_value), 272 - code.scope, 273 - ) 351 + case 352 + oauth_refresh_tokens.insert( 353 + conn, 354 + refresh_token, 355 + ) 356 + { 357 + Error(err) -> 358 + error_response( 359 + 500, 360 + "server_error", 361 + "Failed to store refresh token: " 362 + <> string.inspect(err), 363 + ) 364 + Ok(_) -> { 365 + // Return token response 366 + let token_type_str = case dpop_jkt { 367 + Some(_) -> "DPoP" 368 + None -> "Bearer" 369 + } 370 + token_response( 371 + access_token_value, 372 + token_type_str, 373 + client.access_token_expiration, 374 + Some(refresh_token_value), 375 + code.scope, 376 + ) 377 + } 378 + } 274 379 } 275 380 } 276 381 } ··· 291 396 292 397 /// Handle refresh_token grant 293 398 fn handle_refresh_token( 399 + req: wisp.Request, 294 400 params: List(#(String, String)), 295 401 conn: sqlight.Connection, 402 + external_base_url: String, 296 403 ) -> wisp.Response { 297 404 // Extract required parameters 298 405 let refresh_token_value = get_param(params, "refresh_token") ··· 335 442 case validate_client_authentication(client, params) { 336 443 Error(err) -> err 337 444 Ok(_) -> { 338 - // Get refresh token 339 - case oauth_refresh_tokens.get(conn, rt_value) { 340 - Error(err) -> 341 - error_response( 342 - 500, 343 - "server_error", 344 - "Database error: " <> string.inspect(err), 345 - ) 346 - Ok(None) -> 347 - error_response( 348 - 400, 349 - "invalid_grant", 350 - "Invalid refresh token", 351 - ) 352 - Ok(Some(old_refresh_token)) -> { 353 - // Validate refresh token 354 - case validate_refresh_token(old_refresh_token, cid) { 355 - Error(err) -> err 356 - Ok(_) -> { 357 - // Revoke old refresh token 358 - case oauth_refresh_tokens.revoke(conn, rt_value) { 359 - Error(err) -> 360 - error_response( 361 - 500, 362 - "server_error", 363 - "Database error: " <> string.inspect(err), 364 - ) 445 + // Validate DPoP if present/required 446 + case 447 + validate_dpop_for_token_endpoint( 448 + req, 449 + conn, 450 + client, 451 + external_base_url, 452 + ) 453 + { 454 + Error(err) -> err 455 + Ok(dpop_jkt) -> { 456 + // Get refresh token 457 + case oauth_refresh_tokens.get(conn, rt_value) { 458 + Error(err) -> 459 + error_response( 460 + 500, 461 + "server_error", 462 + "Database error: " <> string.inspect(err), 463 + ) 464 + Ok(None) -> 465 + error_response( 466 + 400, 467 + "invalid_grant", 468 + "Invalid refresh token", 469 + ) 470 + Ok(Some(old_refresh_token)) -> { 471 + // Validate refresh token 472 + case 473 + validate_refresh_token(old_refresh_token, cid) 474 + { 475 + Error(err) -> err 365 476 Ok(_) -> { 366 - // Generate new tokens 367 - let new_access_token_value = 368 - token_generator.generate_access_token() 369 - let new_refresh_token_value = 370 - token_generator.generate_refresh_token() 371 - let now = token_generator.current_timestamp() 372 - 373 - // Use requested scope or fall back to original 374 - let scope = case requested_scope { 375 - Some(_) -> requested_scope 376 - None -> old_refresh_token.scope 377 - } 378 - 379 - let access_token = 380 - OAuthAccessToken( 381 - token: new_access_token_value, 382 - token_type: Bearer, 383 - client_id: cid, 384 - user_id: Some(old_refresh_token.user_id), 385 - session_id: old_refresh_token.session_id, 386 - session_iteration: old_refresh_token.session_iteration, 387 - scope: scope, 388 - created_at: now, 389 - expires_at: token_generator.expiration_timestamp( 390 - client.access_token_expiration, 391 - ), 392 - revoked: False, 393 - dpop_jkt: None, 394 - ) 395 - 396 - let refresh_token = 397 - OAuthRefreshToken( 398 - token: new_refresh_token_value, 399 - access_token: new_access_token_value, 400 - client_id: cid, 401 - user_id: old_refresh_token.user_id, 402 - session_id: old_refresh_token.session_id, 403 - session_iteration: old_refresh_token.session_iteration, 404 - scope: scope, 405 - created_at: now, 406 - expires_at: case 407 - client.refresh_token_expiration 408 - { 409 - 0 -> None 410 - exp -> 411 - Some( 412 - token_generator.expiration_timestamp( 413 - exp, 414 - ), 415 - ) 416 - }, 417 - revoked: False, 418 - ) 419 - 420 - // Store new tokens 477 + // Revoke old refresh token 421 478 case 422 - oauth_access_tokens.insert(conn, access_token) 479 + oauth_refresh_tokens.revoke(conn, rt_value) 423 480 { 424 481 Error(err) -> 425 482 error_response( 426 483 500, 427 484 "server_error", 428 - "Failed to store access token: " 429 - <> string.inspect(err), 485 + "Database error: " <> string.inspect(err), 430 486 ) 431 487 Ok(_) -> { 488 + // Generate new tokens 489 + let new_access_token_value = 490 + token_generator.generate_access_token() 491 + let new_refresh_token_value = 492 + token_generator.generate_refresh_token() 493 + let now = 494 + token_generator.current_timestamp() 495 + 496 + // Use requested scope or fall back to original 497 + let scope = case requested_scope { 498 + Some(_) -> requested_scope 499 + None -> old_refresh_token.scope 500 + } 501 + 502 + // Determine token type based on DPoP 503 + let token_type = case dpop_jkt { 504 + Some(_) -> DPoP 505 + None -> Bearer 506 + } 507 + 508 + let access_token = 509 + OAuthAccessToken( 510 + token: new_access_token_value, 511 + token_type: token_type, 512 + client_id: cid, 513 + user_id: Some(old_refresh_token.user_id), 514 + session_id: old_refresh_token.session_id, 515 + session_iteration: old_refresh_token.session_iteration, 516 + scope: scope, 517 + created_at: now, 518 + expires_at: token_generator.expiration_timestamp( 519 + client.access_token_expiration, 520 + ), 521 + revoked: False, 522 + dpop_jkt: dpop_jkt, 523 + ) 524 + 525 + let refresh_token = 526 + OAuthRefreshToken( 527 + token: new_refresh_token_value, 528 + access_token: new_access_token_value, 529 + client_id: cid, 530 + user_id: old_refresh_token.user_id, 531 + session_id: old_refresh_token.session_id, 532 + session_iteration: old_refresh_token.session_iteration, 533 + scope: scope, 534 + created_at: now, 535 + expires_at: case 536 + client.refresh_token_expiration 537 + { 538 + 0 -> None 539 + exp -> 540 + Some( 541 + token_generator.expiration_timestamp( 542 + exp, 543 + ), 544 + ) 545 + }, 546 + revoked: False, 547 + ) 548 + 549 + // Store new tokens 432 550 case 433 - oauth_refresh_tokens.insert( 551 + oauth_access_tokens.insert( 434 552 conn, 435 - refresh_token, 553 + access_token, 436 554 ) 437 555 { 438 556 Error(err) -> 439 557 error_response( 440 558 500, 441 559 "server_error", 442 - "Failed to store refresh token: " 560 + "Failed to store access token: " 443 561 <> string.inspect(err), 444 562 ) 445 563 Ok(_) -> { 446 - // Return token response 447 - token_response( 448 - new_access_token_value, 449 - "Bearer", 450 - client.access_token_expiration, 451 - Some(new_refresh_token_value), 452 - scope, 453 - ) 564 + case 565 + oauth_refresh_tokens.insert( 566 + conn, 567 + refresh_token, 568 + ) 569 + { 570 + Error(err) -> 571 + error_response( 572 + 500, 573 + "server_error", 574 + "Failed to store refresh token: " 575 + <> string.inspect(err), 576 + ) 577 + Ok(_) -> { 578 + // Return token response 579 + let token_type_str = case dpop_jkt { 580 + Some(_) -> "DPoP" 581 + None -> "Bearer" 582 + } 583 + token_response( 584 + new_access_token_value, 585 + token_type_str, 586 + client.access_token_expiration, 587 + Some(new_refresh_token_value), 588 + scope, 589 + ) 590 + } 591 + } 454 592 } 455 593 } 456 594 }
+109 -1
server/src/jose_ffi.erl
··· 1 1 -module(jose_ffi). 2 - -export([generate_dpop_proof/5, sha256_hash/1, compute_jwk_thumbprint/1, sha256_base64url/1]). 2 + -export([generate_dpop_proof/5, sha256_hash/1, compute_jwk_thumbprint/1, sha256_base64url/1, verify_dpop_proof/4]). 3 3 4 4 %% Generate a DPoP proof JWT token 5 5 %% Args: Method (binary), URL (binary), AccessToken (binary), JWKJson (binary), ServerNonce (binary) ··· 127 127 compute_jwk_thumbprint(JWKJson) when is_list(JWKJson) -> 128 128 compute_jwk_thumbprint(list_to_binary(JWKJson)). 129 129 130 + %% Verify a DPoP proof JWT 131 + %% Args: DPoPProof (binary), ExpectedMethod (binary), ExpectedUrl (binary), MaxAgeSeconds (integer) 132 + %% Returns: {ok, #{jkt => Thumbprint, jti => Jti, iat => Iat}} | {error, Reason} 133 + verify_dpop_proof(DPoPProof, ExpectedMethod, ExpectedUrl, MaxAgeSeconds) -> 134 + try 135 + %% Split JWT into parts (compact serialization: header.payload.signature) 136 + case binary:split(DPoPProof, <<".">>, [global]) of 137 + [HeaderB64, _PayloadB64, _SignatureB64] -> 138 + %% Decode the header (base64url) 139 + HeaderJson = base64:decode(HeaderB64, #{mode => urlsafe, padding => false}), 140 + HeaderMap = json:decode(HeaderJson), 141 + 142 + %% Extract the JWK from the header 143 + case maps:get(<<"jwk">>, HeaderMap, undefined) of 144 + undefined -> 145 + {error, <<"Missing jwk in DPoP header">>}; 146 + JWKMap -> 147 + %% Verify typ is dpop+jwt 148 + case maps:get(<<"typ">>, HeaderMap, undefined) of 149 + <<"dpop+jwt">> -> 150 + %% Reconstruct JWK for verification 151 + %% jose_jwk:from_map expects base64url encoding natively 152 + JWK = jose_jwk:from_map(JWKMap), 153 + 154 + %% Verify the signature using jose 155 + case jose_jwt:verify(JWK, DPoPProof) of 156 + {true, JWT, _JWS} -> 157 + Claims = jose_jwt:to_map(JWT), 158 + validate_dpop_claims(Claims, JWK, ExpectedMethod, ExpectedUrl, MaxAgeSeconds); 159 + {false, _, _} -> 160 + {error, <<"Invalid DPoP signature">>} 161 + end; 162 + Other -> 163 + {error, iolist_to_binary([<<"Invalid typ: expected dpop+jwt, got ">>, 164 + io_lib:format("~p", [Other])])} 165 + end 166 + end; 167 + _ -> 168 + {error, <<"Invalid JWT format">>} 169 + end 170 + catch 171 + error:Reason:Stacktrace -> 172 + io:format("[DPoP] Error: ~p~nStacktrace: ~p~n", [Reason, Stacktrace]), 173 + {error, iolist_to_binary([<<"DPoP verification failed: ">>, 174 + io_lib:format("~p", [Reason])])}; 175 + _:Error -> 176 + {error, iolist_to_binary([<<"DPoP verification error: ">>, 177 + io_lib:format("~p", [Error])])} 178 + end. 179 + 180 + %% Internal: Validate DPoP claims 181 + validate_dpop_claims({_Kind, Claims}, JWK, ExpectedMethod, ExpectedUrl, MaxAgeSeconds) -> 182 + Now = erlang:system_time(second), 183 + 184 + %% Extract required claims 185 + Htm = maps:get(<<"htm">>, Claims, undefined), 186 + Htu = maps:get(<<"htu">>, Claims, undefined), 187 + Jti = maps:get(<<"jti">>, Claims, undefined), 188 + Iat = maps:get(<<"iat">>, Claims, undefined), 189 + 190 + %% Validate all required claims exist 191 + case {Htm, Htu, Jti, Iat} of 192 + {undefined, _, _, _} -> {error, <<"Missing htm claim">>}; 193 + {_, undefined, _, _} -> {error, <<"Missing htu claim">>}; 194 + {_, _, undefined, _} -> {error, <<"Missing jti claim">>}; 195 + {_, _, _, undefined} -> {error, <<"Missing iat claim">>}; 196 + _ -> 197 + %% Validate htm matches 198 + case Htm =:= ExpectedMethod of 199 + false -> 200 + {error, iolist_to_binary([<<"htm mismatch: expected ">>, ExpectedMethod, 201 + <<", got ">>, Htm])}; 202 + true -> 203 + %% Validate htu matches (normalize URLs) 204 + case normalize_url(Htu) =:= normalize_url(ExpectedUrl) of 205 + false -> 206 + {error, iolist_to_binary([<<"htu mismatch: expected ">>, ExpectedUrl, 207 + <<", got ">>, Htu])}; 208 + true -> 209 + %% Validate iat is within acceptable range 210 + case abs(Now - Iat) =< MaxAgeSeconds of 211 + false -> 212 + {error, <<"iat outside acceptable time window">>}; 213 + true -> 214 + %% Compute JKT (SHA-256 thumbprint of the JWK) 215 + Thumbprint = jose_jwk:thumbprint(JWK), 216 + {ok, #{ 217 + jkt => Thumbprint, 218 + jti => Jti, 219 + iat => Iat 220 + }} 221 + end 222 + end 223 + end 224 + end. 225 + 226 + %% Internal: Normalize URL for comparison (remove trailing slash, fragments) 227 + normalize_url(Url) when is_binary(Url) -> 228 + %% Remove fragment 229 + case binary:split(Url, <<"#">>) of 230 + [Base | _] -> 231 + %% Remove trailing slash 232 + case byte_size(Base) > 0 andalso binary:last(Base) of 233 + $/ -> binary:part(Base, 0, byte_size(Base) - 1); 234 + _ -> Base 235 + end; 236 + _ -> Url 237 + end.
+48
server/src/lib/oauth/dpop/validator.gleam
··· 1 + /// DPoP proof validation 2 + /// Validates DPoP proofs according to RFC 9449 3 + import gleam/option.{type Option, None, Some} 4 + 5 + /// Result of successful DPoP validation 6 + pub type DPoPValidationResult { 7 + DPoPValidationResult( 8 + /// JWK thumbprint (SHA-256) of the public key 9 + jkt: String, 10 + /// Unique identifier for replay protection 11 + jti: String, 12 + /// Issued-at timestamp 13 + iat: Int, 14 + ) 15 + } 16 + 17 + /// Verify a DPoP proof JWT 18 + /// 19 + /// # Arguments 20 + /// * `dpop_proof` - The DPoP proof JWT from the DPoP header 21 + /// * `method` - Expected HTTP method (e.g., "POST") 22 + /// * `url` - Expected URL being accessed 23 + /// * `max_age_seconds` - Maximum allowed age of the proof (typically 300 = 5 minutes) 24 + /// 25 + /// # Returns 26 + /// * `Ok(DPoPValidationResult)` - Validation succeeded 27 + /// * `Error(String)` - Validation failed with reason 28 + @external(erlang, "dpop_validator_ffi", "verify_dpop_proof") 29 + pub fn verify_dpop_proof( 30 + dpop_proof: String, 31 + method: String, 32 + url: String, 33 + max_age_seconds: Int, 34 + ) -> Result(DPoPValidationResult, String) 35 + 36 + /// Extract DPoP header from request headers 37 + pub fn get_dpop_header(headers: List(#(String, String))) -> Option(String) { 38 + case headers { 39 + [] -> None 40 + [#(name, value), ..rest] -> { 41 + case name { 42 + "dpop" -> Some(value) 43 + "DPoP" -> Some(value) 44 + _ -> get_dpop_header(rest) 45 + } 46 + } 47 + } 48 + }
+159
server/src/middleware/dpop.gleam
··· 1 + /// DPoP validation middleware for protected resources 2 + import database/repositories/oauth_access_tokens 3 + import database/repositories/oauth_dpop_jti 4 + import gleam/http.{Delete, Get, Head, Options, Patch, Post, Put} 5 + import gleam/http/request 6 + import gleam/option.{None, Some} 7 + import gleam/string 8 + import lib/oauth/dpop/validator 9 + import lib/oauth/token_generator 10 + import sqlight 11 + import wisp 12 + 13 + /// Validate DPoP-bound access token 14 + /// Returns the user_id if valid, or an error response 15 + pub fn validate_dpop_access( 16 + req: wisp.Request, 17 + conn: sqlight.Connection, 18 + resource_url: String, 19 + ) -> Result(String, wisp.Response) { 20 + // Extract Authorization header 21 + case request.get_header(req, "authorization") { 22 + Error(_) -> Error(unauthorized("Missing Authorization header")) 23 + Ok(header) -> { 24 + // Parse "DPoP <token>" or "Bearer <token>" 25 + case string.split(header, " ") { 26 + ["DPoP", token] -> validate_dpop_token(req, conn, token, resource_url) 27 + ["Bearer", token] -> validate_bearer_token(conn, token) 28 + _ -> Error(unauthorized("Invalid Authorization header format")) 29 + } 30 + } 31 + } 32 + } 33 + 34 + fn validate_dpop_token( 35 + req: wisp.Request, 36 + conn: sqlight.Connection, 37 + token: String, 38 + resource_url: String, 39 + ) -> Result(String, wisp.Response) { 40 + // Get DPoP proof from header 41 + case validator.get_dpop_header(req.headers) { 42 + None -> Error(unauthorized("Missing DPoP proof for DPoP-bound token")) 43 + Some(dpop_proof) -> { 44 + // Verify the DPoP proof 45 + let method = method_to_string(req.method) 46 + case validator.verify_dpop_proof(dpop_proof, method, resource_url, 300) { 47 + Error(reason) -> Error(unauthorized("Invalid DPoP proof: " <> reason)) 48 + Ok(dpop_result) -> { 49 + // Check JTI for replay 50 + case oauth_dpop_jti.use_jti(conn, dpop_result.jti, dpop_result.iat) { 51 + Error(_) -> Error(server_error("Database error")) 52 + Ok(False) -> Error(unauthorized("DPoP proof replay detected")) 53 + Ok(True) -> { 54 + // Get the access token and verify JKT matches 55 + case oauth_access_tokens.get(conn, token) { 56 + Error(_) -> Error(server_error("Database error")) 57 + Ok(None) -> Error(unauthorized("Invalid access token")) 58 + Ok(Some(access_token)) -> { 59 + // Check if token is expired 60 + case token_generator.is_expired(access_token.expires_at) { 61 + True -> Error(unauthorized("Access token has expired")) 62 + False -> { 63 + // Check if token is revoked 64 + case access_token.revoked { 65 + True -> 66 + Error(unauthorized("Access token has been revoked")) 67 + False -> { 68 + case access_token.dpop_jkt { 69 + None -> 70 + Error(unauthorized("Token is not DPoP-bound")) 71 + Some(jkt) -> { 72 + case jkt == dpop_result.jkt { 73 + False -> 74 + Error(unauthorized("DPoP key mismatch")) 75 + True -> { 76 + case access_token.user_id { 77 + None -> 78 + Error(unauthorized("Token has no user")) 79 + Some(user_id) -> Ok(user_id) 80 + } 81 + } 82 + } 83 + } 84 + } 85 + } 86 + } 87 + } 88 + } 89 + } 90 + } 91 + } 92 + } 93 + } 94 + } 95 + } 96 + } 97 + } 98 + 99 + fn validate_bearer_token( 100 + conn: sqlight.Connection, 101 + token: String, 102 + ) -> Result(String, wisp.Response) { 103 + case oauth_access_tokens.get(conn, token) { 104 + Error(_) -> Error(server_error("Database error")) 105 + Ok(None) -> Error(unauthorized("Invalid access token")) 106 + Ok(Some(access_token)) -> { 107 + // Check if token is expired 108 + case token_generator.is_expired(access_token.expires_at) { 109 + True -> Error(unauthorized("Access token has expired")) 110 + False -> { 111 + // Check if token is revoked 112 + case access_token.revoked { 113 + True -> Error(unauthorized("Access token has been revoked")) 114 + False -> { 115 + // DPoP-bound tokens MUST use DPoP authorization 116 + case access_token.dpop_jkt { 117 + Some(_) -> 118 + Error(unauthorized( 119 + "DPoP-bound token requires DPoP authorization", 120 + )) 121 + None -> { 122 + case access_token.user_id { 123 + None -> Error(unauthorized("Token has no user")) 124 + Some(user_id) -> Ok(user_id) 125 + } 126 + } 127 + } 128 + } 129 + } 130 + } 131 + } 132 + } 133 + } 134 + } 135 + 136 + fn method_to_string(method: http.Method) -> String { 137 + case method { 138 + Get -> "GET" 139 + Post -> "POST" 140 + Put -> "PUT" 141 + Delete -> "DELETE" 142 + Patch -> "PATCH" 143 + Head -> "HEAD" 144 + Options -> "OPTIONS" 145 + _ -> "GET" 146 + } 147 + } 148 + 149 + fn unauthorized(message: String) -> wisp.Response { 150 + wisp.response(401) 151 + |> wisp.set_header("content-type", "application/json") 152 + |> wisp.set_body(wisp.Text("{\"error\":\"" <> message <> "\"}")) 153 + } 154 + 155 + fn server_error(message: String) -> wisp.Response { 156 + wisp.response(500) 157 + |> wisp.set_header("content-type", "application/json") 158 + |> wisp.set_body(wisp.Text("{\"error\":\"" <> message <> "\"}")) 159 + }
+4 -3
server/src/server.gleam
··· 589 589 ) 590 590 } 591 591 592 - ["oauth", "token"] -> oauth_token_handler.handle(req, ctx.db) 592 + ["oauth", "token"] -> 593 + oauth_token_handler.handle(req, ctx.db, ctx.external_base_url) 593 594 ["oauth", "atp", "callback"] -> { 594 595 let redirect_uri = ctx.external_base_url <> "/oauth/atp/callback" 595 596 let client_id = case ctx.oauth_loopback_mode { ··· 641 642 |> wisp.set_header("access-control-allow-methods", "GET, POST, OPTIONS") 642 643 |> wisp.set_header( 643 644 "access-control-allow-headers", 644 - "Content-Type, Authorization", 645 + "Content-Type, Authorization, DPoP", 645 646 ) 646 647 |> wisp.set_body(wisp.Text("")) 647 648 } ··· 653 654 |> wisp.set_header("access-control-allow-methods", "GET, POST, OPTIONS") 654 655 |> wisp.set_header( 655 656 "access-control-allow-headers", 656 - "Content-Type, Authorization", 657 + "Content-Type, Authorization, DPoP", 657 658 ) 658 659 } 659 660 }
+38
server/test/oauth/dpop_validator_test.gleam
··· 1 + import gleam/option 2 + import gleeunit/should 3 + import lib/oauth/dpop/validator 4 + 5 + // Note: Full DPoP proof verification tests require real P-256 keys which are 6 + // tested through integration tests. These unit tests focus on the header 7 + // extraction logic which is the core of this module. 8 + 9 + pub fn get_dpop_header_test() { 10 + let headers = [ 11 + #("content-type", "application/json"), 12 + #("dpop", "eyJhbGciOiJFUzI1NiJ9.test"), 13 + #("authorization", "Bearer token"), 14 + ] 15 + 16 + let result = validator.get_dpop_header(headers) 17 + should.equal(result, option.Some("eyJhbGciOiJFUzI1NiJ9.test")) 18 + } 19 + 20 + pub fn get_dpop_header_case_insensitive_test() { 21 + let headers = [ 22 + #("content-type", "application/json"), 23 + #("DPoP", "eyJhbGciOiJFUzI1NiJ9.test"), 24 + ] 25 + 26 + let result = validator.get_dpop_header(headers) 27 + should.equal(result, option.Some("eyJhbGciOiJFUzI1NiJ9.test")) 28 + } 29 + 30 + pub fn get_dpop_header_missing_test() { 31 + let headers = [ 32 + #("content-type", "application/json"), 33 + #("authorization", "Bearer token"), 34 + ] 35 + 36 + let result = validator.get_dpop_header(headers) 37 + should.equal(result, option.None) 38 + }
+6 -6
server/test/oauth/token_test.gleam
··· 19 19 |> simulate.header("content-type", "application/x-www-form-urlencoded") 20 20 |> simulate.string_body("client_id=test") 21 21 22 - let response = token.handle(req, conn) 22 + let response = token.handle(req, conn, "http://localhost:8080") 23 23 response.status |> should.equal(400) 24 24 } 25 25 ··· 31 31 |> simulate.header("content-type", "application/x-www-form-urlencoded") 32 32 |> simulate.string_body("grant_type=password&client_id=test") 33 33 34 - let response = token.handle(req, conn) 34 + let response = token.handle(req, conn, "http://localhost:8080") 35 35 response.status |> should.equal(400) 36 36 } 37 37 ··· 87 87 "grant_type=refresh_token&client_id=test-client&refresh_token=test-refresh-token&scope=invalid:::", 88 88 ) 89 89 90 - let response = token.handle(req, conn) 90 + let response = token.handle(req, conn, "http://localhost:8080") 91 91 92 92 // Should return 400 with invalid_scope error 93 93 response.status |> should.equal(400) ··· 136 136 "grant_type=authorization_code&client_id=confidential-client&code=test-code&redirect_uri=https://example.com/callback&code_verifier=test", 137 137 ) 138 138 139 - let response = token.handle(req, conn) 139 + let response = token.handle(req, conn, "http://localhost:8080") 140 140 141 141 // Should return 401 unauthorized 142 142 response.status |> should.equal(401) ··· 183 183 "grant_type=authorization_code&client_id=confidential-client&client_secret=wrong-secret&code=test-code&redirect_uri=https://example.com/callback", 184 184 ) 185 185 186 - let response = token.handle(req, conn) 186 + let response = token.handle(req, conn, "http://localhost:8080") 187 187 188 188 response.status |> should.equal(401) 189 189 ··· 231 231 "grant_type=authorization_code&client_id=public-client&code=test-code&redirect_uri=https://example.com/callback&code_verifier=test", 232 232 ) 233 233 234 - let response = token.handle(req, conn) 234 + let response = token.handle(req, conn, "http://localhost:8080") 235 235 236 236 // Should NOT be 401 (auth passed, will fail on invalid code instead) 237 237 response.status |> should.not_equal(401)