A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

Compare changes

Choose any two refs to compare.

+24
CHANGELOG.md
··· 6 6 7 7 ## [Unreleased] 8 8 9 + ## [0.6.0] - 2026-01-09 10 + 11 + ### Added 12 + 13 + - **Profile card on OAuth consent page** showing authorizing user's identity 14 + - Displays avatar, display name, and handle from Bluesky public API 15 + - Fetches profile client-side using `login_hint` parameter 16 + - Graceful degradation if fetch fails (shows handle only) 17 + 18 + ## [0.5.0] - 2026-01-08 19 + 20 + ### Added 21 + 22 + - **Direct OAuth authorization** without requiring Pushed Authorization Requests (PAR) 23 + - `/oauth/authorize` now accepts direct query parameters (client_id, redirect_uri, code_challenge, etc.) 24 + - Creates authorization request record on-the-fly, same as PAR flow 25 + - DPoP binding deferred to token exchange time for direct auth flows 26 + - Matches official AT Protocol PDS behavior 27 + 28 + ### Changed 29 + 30 + - AS metadata: `require_pushed_authorization_requests` now `false` 31 + - Extracted `validateAuthorizationParameters()` helper shared between PAR and direct auth 32 + 9 33 ## [0.4.0] - 2026-01-08 10 34 11 35 ### Added
+31
docker-compose.yml
··· 1 + services: 2 + plc: 3 + build: 4 + context: https://github.com/did-method-plc/did-method-plc.git 5 + dockerfile: packages/server/Dockerfile 6 + ports: 7 + - "2582:2582" 8 + environment: 9 + - DATABASE_URL=postgres://plc:plc@postgres:5432/plc 10 + - PORT=2582 11 + command: ["dumb-init", "node", "--enable-source-maps", "../dist/bin.js"] 12 + depends_on: 13 + postgres: 14 + condition: service_healthy 15 + 16 + postgres: 17 + image: postgres:16-alpine 18 + environment: 19 + - POSTGRES_USER=plc 20 + - POSTGRES_PASSWORD=plc 21 + - POSTGRES_DB=plc 22 + volumes: 23 + - plc_data:/var/lib/postgresql/data 24 + healthcheck: 25 + test: ["CMD-SHELL", "pg_isready -U plc"] 26 + interval: 2s 27 + timeout: 5s 28 + retries: 10 29 + 30 + volumes: 31 + plc_data:
+1 -1
package.json
··· 1 1 { 2 2 "name": "pds.js", 3 - "version": "0.4.0", 3 + "version": "0.6.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+19 -215
scripts/setup.js
··· 4 4 * PDS Setup Script 5 5 * 6 6 * Registers a did:plc, initializes the PDS, and notifies the relay. 7 - * Zero dependencies - uses Node.js built-ins only. 8 7 * 9 8 * Usage: node scripts/setup.js --handle alice --pds https://your-pds.workers.dev 10 9 */ 11 10 12 - import { webcrypto } from 'node:crypto'; 13 11 import { writeFileSync } from 'node:fs'; 12 + import { 13 + base32Encode, 14 + base64UrlEncode, 15 + bytesToHex, 16 + cborEncodeDagCbor, 17 + generateKeyPair, 18 + importPrivateKey, 19 + sign, 20 + } from '../src/pds.js'; 14 21 15 22 // === ARGUMENT PARSING === 16 23 ··· 57 64 return opts; 58 65 } 59 66 60 - // === KEY GENERATION === 61 - 62 - async function generateP256Keypair() { 63 - const keyPair = await webcrypto.subtle.generateKey( 64 - { name: 'ECDSA', namedCurve: 'P-256' }, 65 - true, 66 - ['sign', 'verify'], 67 - ); 68 - 69 - // Export private key as raw 32 bytes 70 - const privateJwk = await webcrypto.subtle.exportKey( 71 - 'jwk', 72 - keyPair.privateKey, 73 - ); 74 - const privateBytes = base64UrlDecode(privateJwk.d); 75 - 76 - // Export public key as uncompressed point (65 bytes) 77 - const publicRaw = await webcrypto.subtle.exportKey('raw', keyPair.publicKey); 78 - const publicBytes = new Uint8Array(publicRaw); 79 - 80 - // Compress public key to 33 bytes 81 - const compressedPublic = compressPublicKey(publicBytes); 82 - 83 - return { 84 - privateKey: privateBytes, 85 - publicKey: compressedPublic, 86 - cryptoKey: keyPair.privateKey, 87 - }; 88 - } 89 - 90 - function compressPublicKey(uncompressed) { 91 - // uncompressed is 65 bytes: 0x04 + x(32) + y(32) 92 - const x = uncompressed.slice(1, 33); 93 - const y = uncompressed.slice(33, 65); 94 - const prefix = (y[31] & 1) === 0 ? 0x02 : 0x03; 95 - const compressed = new Uint8Array(33); 96 - compressed[0] = prefix; 97 - compressed.set(x, 1); 98 - return compressed; 99 - } 100 - 101 - function base64UrlDecode(str) { 102 - const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); 103 - const binary = atob(base64); 104 - const bytes = new Uint8Array(binary.length); 105 - for (let i = 0; i < binary.length; i++) { 106 - bytes[i] = binary.charCodeAt(i); 107 - } 108 - return bytes; 109 - } 110 - 111 - function bytesToHex(bytes) { 112 - return Array.from(bytes) 113 - .map((b) => b.toString(16).padStart(2, '0')) 114 - .join(''); 115 - } 116 - 117 67 // === DID:KEY ENCODING === 118 68 119 69 // Multicodec prefix for P-256 public key (0x1200) ··· 164 114 return result; 165 115 } 166 116 167 - // === CBOR ENCODING (dag-cbor compliant for PLC operations) === 168 - 169 - function cborEncodeKey(key) { 170 - // Encode a string key to CBOR bytes (for sorting) 171 - const bytes = new TextEncoder().encode(key); 172 - const parts = []; 173 - const mt = 3 << 5; // major type 3 = text string 174 - if (bytes.length < 24) { 175 - parts.push(mt | bytes.length); 176 - } else if (bytes.length < 256) { 177 - parts.push(mt | 24, bytes.length); 178 - } else if (bytes.length < 65536) { 179 - parts.push(mt | 25, bytes.length >> 8, bytes.length & 0xff); 180 - } 181 - parts.push(...bytes); 182 - return new Uint8Array(parts); 183 - } 184 - 185 - function compareBytes(a, b) { 186 - // dag-cbor: bytewise lexicographic order of encoded keys 187 - const minLen = Math.min(a.length, b.length); 188 - for (let i = 0; i < minLen; i++) { 189 - if (a[i] !== b[i]) return a[i] - b[i]; 190 - } 191 - return a.length - b.length; 192 - } 193 - 194 - function cborEncode(value) { 195 - const parts = []; 196 - 197 - function encode(val) { 198 - if (val === null) { 199 - parts.push(0xf6); 200 - } else if (typeof val === 'string') { 201 - const bytes = new TextEncoder().encode(val); 202 - encodeHead(3, bytes.length); 203 - parts.push(...bytes); 204 - } else if (typeof val === 'number') { 205 - if (Number.isInteger(val) && val >= 0) { 206 - encodeHead(0, val); 207 - } 208 - } else if (val instanceof Uint8Array) { 209 - encodeHead(2, val.length); 210 - parts.push(...val); 211 - } else if (Array.isArray(val)) { 212 - encodeHead(4, val.length); 213 - for (const item of val) encode(item); 214 - } else if (typeof val === 'object') { 215 - // dag-cbor: sort keys by their CBOR-encoded bytes (length first, then lexicographic) 216 - const keys = Object.keys(val); 217 - const keysSorted = keys.sort((a, b) => 218 - compareBytes(cborEncodeKey(a), cborEncodeKey(b)), 219 - ); 220 - encodeHead(5, keysSorted.length); 221 - for (const key of keysSorted) { 222 - encode(key); 223 - encode(val[key]); 224 - } 225 - } 226 - } 227 - 228 - function encodeHead(majorType, length) { 229 - const mt = majorType << 5; 230 - if (length < 24) { 231 - parts.push(mt | length); 232 - } else if (length < 256) { 233 - parts.push(mt | 24, length); 234 - } else if (length < 65536) { 235 - parts.push(mt | 25, length >> 8, length & 0xff); 236 - } 237 - } 238 - 239 - encode(value); 240 - return new Uint8Array(parts); 241 - } 242 - 243 117 // === HASHING === 244 118 245 119 async function sha256(data) { 246 - const hash = await webcrypto.subtle.digest('SHA-256', data); 120 + const hash = await crypto.subtle.digest('SHA-256', data); 247 121 return new Uint8Array(hash); 248 122 } 249 123 250 124 // === PLC OPERATIONS === 251 125 252 - async function signPlcOperation(operation, privateKey) { 126 + async function signPlcOperation(operation, cryptoKey) { 253 127 // Encode operation without sig field 254 128 const { sig, ...opWithoutSig } = operation; 255 - const encoded = cborEncode(opWithoutSig); 129 + const encoded = cborEncodeDagCbor(opWithoutSig); 256 130 257 - // Sign with P-256 258 - const signature = await webcrypto.subtle.sign( 259 - { name: 'ECDSA', hash: 'SHA-256' }, 260 - privateKey, 261 - encoded, 262 - ); 263 - 264 - // Convert to low-S form and base64url encode 265 - const sigBytes = ensureLowS(new Uint8Array(signature)); 266 - return base64UrlEncode(sigBytes); 267 - } 268 - 269 - function ensureLowS(sig) { 270 - // P-256 order N 271 - const N = BigInt( 272 - '0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551', 273 - ); 274 - const halfN = N / 2n; 275 - 276 - const r = sig.slice(0, 32); 277 - const s = sig.slice(32, 64); 278 - 279 - // Convert s to BigInt 280 - let sInt = BigInt(`0x${bytesToHex(s)}`); 281 - 282 - // If s > N/2, replace with N - s 283 - if (sInt > halfN) { 284 - sInt = N - sInt; 285 - const newS = hexToBytes(sInt.toString(16).padStart(64, '0')); 286 - const result = new Uint8Array(64); 287 - result.set(r); 288 - result.set(newS, 32); 289 - return result; 290 - } 291 - 292 - return sig; 293 - } 294 - 295 - function hexToBytes(hex) { 296 - const bytes = new Uint8Array(hex.length / 2); 297 - for (let i = 0; i < hex.length; i += 2) { 298 - bytes[i / 2] = parseInt(hex.substr(i, 2), 16); 299 - } 300 - return bytes; 301 - } 302 - 303 - function base64UrlEncode(bytes) { 304 - const binary = String.fromCharCode(...bytes); 305 - return btoa(binary) 306 - .replace(/\+/g, '-') 307 - .replace(/\//g, '_') 308 - .replace(/=+$/, ''); 131 + // Sign with P-256 (sign() handles low-S normalization) 132 + const signature = await sign(cryptoKey, encoded); 133 + return base64UrlEncode(signature); 309 134 } 310 135 311 136 async function createGenesisOperation(opts) { ··· 339 164 340 165 async function deriveDidFromOperation(operation) { 341 166 // DID is computed from the FULL operation INCLUDING the signature 342 - const encoded = cborEncode(operation); 167 + const encoded = cborEncodeDagCbor(operation); 343 168 const hash = await sha256(encoded); 344 169 // DID is base32 of first 15 bytes of hash (= 24 base32 chars) 345 170 return `did:plc:${base32Encode(hash.slice(0, 15))}`; 346 - } 347 - 348 - function base32Encode(bytes) { 349 - const alphabet = 'abcdefghijklmnopqrstuvwxyz234567'; 350 - let result = ''; 351 - let bits = 0; 352 - let value = 0; 353 - 354 - for (const byte of bytes) { 355 - value = (value << 8) | byte; 356 - bits += 8; 357 - while (bits >= 5) { 358 - bits -= 5; 359 - result += alphabet[(value >> bits) & 31]; 360 - } 361 - } 362 - 363 - if (bits > 0) { 364 - result += alphabet[(value << (5 - bits)) & 31]; 365 - } 366 - 367 - return result; 368 171 } 369 172 370 173 // === PLC DIRECTORY REGISTRATION === ··· 479 282 480 283 // Step 1: Generate keypair 481 284 console.log('Generating P-256 keypair...'); 482 - const keyPair = await generateP256Keypair(); 285 + const keyPair = await generateKeyPair(); 286 + const cryptoKey = await importPrivateKey(keyPair.privateKey); 483 287 const didKey = publicKeyToDidKey(keyPair.publicKey); 484 288 console.log(` did:key: ${didKey}`); 485 289 console.log(''); ··· 490 294 didKey, 491 295 handle: opts.handle, 492 296 pdsUrl: opts.pds, 493 - cryptoKey: keyPair.cryptoKey, 297 + cryptoKey, 494 298 }); 495 299 const did = await deriveDidFromOperation(operation); 496 300 console.log(` DID: ${did}`);
+241 -78
src/pds.js
··· 32 32 // โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 33 33 34 34 // PDS version (keep in sync with package.json) 35 - const VERSION = '0.4.0'; 35 + const VERSION = '0.5.0'; 36 36 37 37 // CBOR primitive markers (RFC 8949) 38 38 const CBOR_FALSE = 0xf4; ··· 795 795 * @param {*} value 796 796 * @returns {Uint8Array} 797 797 */ 798 - function cborEncodeDagCbor(value) { 798 + export function cborEncodeDagCbor(value) { 799 799 /** @type {number[]} */ 800 800 const parts = []; 801 801 ··· 3692 3692 code_challenge_methods_supported: ['S256'], 3693 3693 token_endpoint_auth_methods_supported: ['none'], 3694 3694 dpop_signing_alg_values_supported: ['ES256'], 3695 - require_pushed_authorization_requests: true, 3695 + require_pushed_authorization_requests: false, 3696 3696 authorization_response_iss_parameter_supported: true, 3697 3697 client_id_metadata_document_supported: true, 3698 3698 protected_resources: [issuer], ··· 3728 3728 } 3729 3729 3730 3730 /** 3731 + * Validate OAuth authorization request parameters. 3732 + * Shared between PAR and direct authorization flows. 3733 + * @param {Object} params - The authorization parameters 3734 + * @param {string | undefined | null} params.clientId - The client_id 3735 + * @param {string | undefined | null} params.redirectUri - The redirect_uri 3736 + * @param {string | undefined | null} params.responseType - The response_type 3737 + * @param {string | undefined | null} params.codeChallenge - The code_challenge 3738 + * @param {string | undefined | null} params.codeChallengeMethod - The code_challenge_method 3739 + * @returns {Promise<{error: Response} | {clientMetadata: ClientMetadata}>} 3740 + */ 3741 + async validateAuthorizationParameters({ 3742 + clientId, 3743 + redirectUri, 3744 + responseType, 3745 + codeChallenge, 3746 + codeChallengeMethod, 3747 + }) { 3748 + if (!clientId) { 3749 + return { 3750 + error: errorResponse('invalid_request', 'client_id required', 400), 3751 + }; 3752 + } 3753 + if (!redirectUri) { 3754 + return { 3755 + error: errorResponse('invalid_request', 'redirect_uri required', 400), 3756 + }; 3757 + } 3758 + if (responseType !== 'code') { 3759 + return { 3760 + error: errorResponse( 3761 + 'unsupported_response_type', 3762 + 'response_type must be code', 3763 + 400, 3764 + ), 3765 + }; 3766 + } 3767 + if (!codeChallenge || codeChallengeMethod !== 'S256') { 3768 + return { 3769 + error: errorResponse('invalid_request', 'PKCE with S256 required', 400), 3770 + }; 3771 + } 3772 + 3773 + let clientMetadata; 3774 + try { 3775 + clientMetadata = await getClientMetadata(clientId); 3776 + } catch (err) { 3777 + return { error: errorResponse('invalid_client', err.message, 400) }; 3778 + } 3779 + 3780 + // Validate redirect_uri against registered URIs 3781 + const isLoopback = 3782 + clientId.startsWith('http://localhost') || 3783 + clientId.startsWith('http://127.0.0.1'); 3784 + const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { 3785 + if (isLoopback) { 3786 + try { 3787 + const registered = new URL(uri); 3788 + const requested = new URL(redirectUri); 3789 + return registered.origin === requested.origin; 3790 + } catch { 3791 + return false; 3792 + } 3793 + } 3794 + return uri === redirectUri; 3795 + }); 3796 + if (!redirectUriValid) { 3797 + return { 3798 + error: errorResponse( 3799 + 'invalid_request', 3800 + 'redirect_uri not registered for this client', 3801 + 400, 3802 + ), 3803 + }; 3804 + } 3805 + 3806 + return { clientMetadata }; 3807 + } 3808 + 3809 + /** 3731 3810 * Handle Pushed Authorization Request (PAR) endpoint. 3732 3811 * Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request. 3733 3812 * @param {Request} request - The incoming request ··· 3767 3846 const codeChallengeMethod = data.code_challenge_method; 3768 3847 const loginHint = data.login_hint; 3769 3848 3770 - if (!clientId) 3771 - return errorResponse('invalid_request', 'client_id required', 400); 3772 - if (!redirectUri) 3773 - return errorResponse('invalid_request', 'redirect_uri required', 400); 3774 - if (responseType !== 'code') 3775 - return errorResponse( 3776 - 'unsupported_response_type', 3777 - 'response_type must be code', 3778 - 400, 3779 - ); 3780 - if (!codeChallenge || codeChallengeMethod !== 'S256') { 3781 - return errorResponse('invalid_request', 'PKCE with S256 required', 400); 3782 - } 3783 - 3784 - let clientMetadata; 3785 - try { 3786 - clientMetadata = await getClientMetadata(clientId); 3787 - } catch (err) { 3788 - return errorResponse('invalid_client', err.message, 400); 3789 - } 3790 - 3791 - // Validate redirect_uri against registered URIs 3792 - // For loopback clients (RFC 8252), allow any path on the same origin 3793 - const isLoopback = 3794 - clientId.startsWith('http://localhost') || 3795 - clientId.startsWith('http://127.0.0.1'); 3796 - const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { 3797 - if (isLoopback) { 3798 - // For loopback, check origin match (any path allowed) 3799 - try { 3800 - const registered = new URL(uri); 3801 - const requested = new URL(redirectUri); 3802 - return registered.origin === requested.origin; 3803 - } catch { 3804 - return false; 3805 - } 3806 - } 3807 - return uri === redirectUri; 3849 + // Use shared validation 3850 + const validationResult = await this.validateAuthorizationParameters({ 3851 + clientId, 3852 + redirectUri, 3853 + responseType, 3854 + codeChallenge, 3855 + codeChallengeMethod, 3808 3856 }); 3809 - if (!redirectUriValid) { 3810 - return errorResponse( 3811 - 'invalid_request', 3812 - 'redirect_uri not registered for this client', 3813 - 400, 3814 - ); 3815 - } 3857 + if ('error' in validationResult) return validationResult.error; 3858 + const { clientMetadata } = validationResult; 3816 3859 3817 3860 const requestId = crypto.randomUUID(); 3818 3861 const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; ··· 3862 3905 3863 3906 /** 3864 3907 * Handle GET /oauth/authorize - displays the consent UI. 3865 - * Validates the request_uri from PAR and renders a login/consent form. 3908 + * Supports both PAR (request_uri) and direct authorization parameters. 3866 3909 * @param {URL} url - Parsed request URL 3867 3910 * @returns {Promise<Response>} HTML consent page 3868 3911 */ 3869 3912 async handleOAuthAuthorizeGet(url) { 3913 + // Opportunistically clean up expired authorization requests 3914 + this.cleanupExpiredAuthorizationRequests(); 3915 + 3870 3916 const requestUri = url.searchParams.get('request_uri'); 3871 3917 const clientId = url.searchParams.get('client_id'); 3872 3918 3873 - if (!requestUri || !clientId) { 3874 - return new Response('Missing parameters', { status: 400 }); 3919 + // If request_uri is present, use PAR flow 3920 + if (requestUri) { 3921 + if (!clientId) { 3922 + return new Response('Missing client_id parameter', { status: 400 }); 3923 + } 3924 + 3925 + const match = requestUri.match( 3926 + /^urn:ietf:params:oauth:request_uri:(.+)$/, 3927 + ); 3928 + if (!match) return new Response('Invalid request_uri', { status: 400 }); 3929 + 3930 + const rows = this.sql 3931 + .exec( 3932 + `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3933 + match[1], 3934 + clientId, 3935 + ) 3936 + .toArray(); 3937 + const authRequest = rows[0]; 3938 + 3939 + if (!authRequest) 3940 + return new Response('Request not found', { status: 400 }); 3941 + if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 3942 + return new Response('Request expired', { status: 400 }); 3943 + if (authRequest.code) 3944 + return new Response('Request already used', { status: 400 }); 3945 + 3946 + const clientMetadata = JSON.parse( 3947 + /** @type {string} */ (authRequest.client_metadata), 3948 + ); 3949 + const parameters = JSON.parse( 3950 + /** @type {string} */ (authRequest.parameters), 3951 + ); 3952 + 3953 + return new Response( 3954 + renderConsentPage({ 3955 + clientName: clientMetadata.client_name || clientId, 3956 + clientId: clientId || '', 3957 + scope: parameters.scope || 'atproto', 3958 + requestUri: requestUri || '', 3959 + loginHint: parameters.login_hint || '', 3960 + }), 3961 + { 3962 + status: 200, 3963 + headers: { 'Content-Type': 'text/html; charset=utf-8' }, 3964 + }, 3965 + ); 3875 3966 } 3876 3967 3877 - const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3878 - if (!match) return new Response('Invalid request_uri', { status: 400 }); 3968 + // Direct authorization flow - create request on-the-fly 3969 + if (!clientId) { 3970 + return new Response('Missing client_id parameter', { status: 400 }); 3971 + } 3879 3972 3880 - const rows = this.sql 3881 - .exec( 3882 - `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3883 - match[1], 3884 - clientId, 3885 - ) 3886 - .toArray(); 3887 - const authRequest = rows[0]; 3973 + const redirectUri = url.searchParams.get('redirect_uri'); 3974 + const responseType = url.searchParams.get('response_type'); 3975 + const responseMode = url.searchParams.get('response_mode'); 3976 + const scope = url.searchParams.get('scope'); 3977 + const state = url.searchParams.get('state'); 3978 + const codeChallenge = url.searchParams.get('code_challenge'); 3979 + const codeChallengeMethod = url.searchParams.get('code_challenge_method'); 3980 + const loginHint = url.searchParams.get('login_hint'); 3981 + 3982 + // Validate parameters using shared helper 3983 + const validationResult = await this.validateAuthorizationParameters({ 3984 + clientId, 3985 + redirectUri, 3986 + responseType, 3987 + codeChallenge, 3988 + codeChallengeMethod, 3989 + }); 3990 + if ('error' in validationResult) return validationResult.error; 3991 + const { clientMetadata } = validationResult; 3888 3992 3889 - if (!authRequest) return new Response('Request not found', { status: 400 }); 3890 - if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 3891 - return new Response('Request expired', { status: 400 }); 3892 - if (authRequest.code) 3893 - return new Response('Request already used', { status: 400 }); 3993 + // Create authorization request record (same as PAR but without DPoP) 3994 + const requestId = crypto.randomUUID(); 3995 + const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 3996 + const expiresIn = 600; 3997 + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 3894 3998 3895 - const clientMetadata = JSON.parse( 3896 - /** @type {string} */ (authRequest.client_metadata), 3897 - ); 3898 - const parameters = JSON.parse( 3899 - /** @type {string} */ (authRequest.parameters), 3999 + this.sql.exec( 4000 + `INSERT INTO authorization_requests ( 4001 + id, client_id, client_metadata, parameters, 4002 + code_challenge, code_challenge_method, dpop_jkt, 4003 + expires_at, created_at 4004 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 4005 + requestId, 4006 + clientId, 4007 + JSON.stringify(clientMetadata), 4008 + JSON.stringify({ 4009 + redirect_uri: redirectUri, 4010 + scope, 4011 + state, 4012 + response_mode: responseMode, 4013 + login_hint: loginHint, 4014 + }), 4015 + codeChallenge, 4016 + codeChallengeMethod, 4017 + null, // No DPoP for direct authorization - will be bound at token exchange 4018 + expiresAt, 4019 + new Date().toISOString(), 3900 4020 ); 3901 4021 3902 4022 return new Response( 3903 4023 renderConsentPage({ 3904 4024 clientName: clientMetadata.client_name || clientId, 3905 - clientId: clientId || '', 3906 - scope: parameters.scope || 'atproto', 3907 - requestUri: requestUri || '', 4025 + clientId: clientId, 4026 + scope: scope || 'atproto', 4027 + requestUri: newRequestUri, 4028 + loginHint: loginHint || '', 3908 4029 }), 3909 4030 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 3910 4031 ); ··· 4094 4215 return errorResponse('invalid_grant', 'Invalid code', 400); 4095 4216 if (authRequest.client_id !== clientId) 4096 4217 return errorResponse('invalid_grant', 'Client mismatch', 400); 4097 - if (authRequest.dpop_jkt !== dpop.jkt) 4218 + // For PAR flow, dpop_jkt is set at PAR time and must match 4219 + // For direct authorization, dpop_jkt is null and we bind to the token request's DPoP 4220 + if (authRequest.dpop_jkt !== null && authRequest.dpop_jkt !== dpop.jkt) { 4098 4221 return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 4222 + } 4099 4223 4100 4224 const parameters = JSON.parse( 4101 4225 /** @type {string} */ (authRequest.parameters), ··· 4883 5007 4884 5008 /** 4885 5009 * Render the OAuth consent page HTML. 4886 - * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 5010 + * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string }} params 4887 5011 * @returns {string} HTML page content 4888 5012 */ 4889 5013 function renderConsentPage({ ··· 4891 5015 clientId, 4892 5016 scope, 4893 5017 requestUri, 5018 + loginHint = '', 4894 5019 error = '', 4895 5020 }) { 4896 5021 const parsed = parseScopesForDisplay(scope); ··· 4930 5055 .blob-list li{margin:4px 0} 4931 5056 .warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0} 4932 5057 .warning small{color:#d4a000;display:block;margin-top:4px} 5058 + .profile-card{display:flex;align-items:center;gap:12px;padding:16px;background:#2a2a2a;border-radius:8px;margin-bottom:20px} 5059 + .profile-card.loading .avatar{background:#404040;animation:pulse 1.5s infinite} 5060 + .profile-card .avatar{width:48px;height:48px;border-radius:50%;background:#404040;flex-shrink:0} 5061 + .profile-card .avatar img{width:100%;height:100%;border-radius:50%;object-fit:cover} 5062 + .profile-card .info{min-width:0} 5063 + .profile-card .name{color:#fff;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 5064 + .profile-card .handle{color:#808080;font-size:14px} 5065 + @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}} 4933 5066 </style></head> 4934 - <body><h2>Sign in to authorize</h2> 5067 + <body> 5068 + ${ 5069 + loginHint 5070 + ? `<div class="profile-card loading" id="profile-card"> 5071 + <div class="avatar" id="profile-avatar"></div> 5072 + <div class="info"><div class="name" id="profile-name">Loading...</div> 5073 + <div class="handle" id="profile-handle">${escapeHtml(loginHint.startsWith('did:') ? loginHint : `@${loginHint}`)}</div></div> 5074 + </div>` 5075 + : '' 5076 + } 5077 + <h2>Sign in to authorize</h2> 4935 5078 <p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p> 4936 5079 ${renderPermissionsHtml(parsed)} 4937 5080 ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''} ··· 4941 5084 <label>Password</label><input type="password" name="password" required autofocus> 4942 5085 <div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 4943 5086 <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 4944 - </form></body></html>`; 5087 + </form> 5088 + ${ 5089 + loginHint 5090 + ? `<script> 5091 + (async()=>{ 5092 + const card=document.getElementById('profile-card'); 5093 + if(!card)return; 5094 + try{ 5095 + const r=await fetch('https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor='+encodeURIComponent(${JSON.stringify(loginHint)})); 5096 + if(!r.ok)throw new Error(); 5097 + const p=await r.json(); 5098 + document.getElementById('profile-avatar').innerHTML=p.avatar?'<img src="'+p.avatar+'" alt="">':''; 5099 + document.getElementById('profile-name').textContent=p.displayName||p.handle; 5100 + document.getElementById('profile-handle').textContent='@'+p.handle; 5101 + card.classList.remove('loading'); 5102 + }catch(e){card.classList.remove('loading')} 5103 + })(); 5104 + </script>` 5105 + : '' 5106 + } 5107 + </body></html>`; 4945 5108 } 4946 5109 4947 5110 /**
+157 -26
test/e2e.test.js
··· 40 40 } 41 41 42 42 /** 43 - * Make JSON request helper 43 + * Make JSON request helper (with retry for flaky wrangler dev 5xx errors) 44 44 */ 45 45 async function jsonPost(path, body, headers = {}) { 46 - const res = await fetch(`${BASE}${path}`, { 47 - method: 'POST', 48 - headers: { 'Content-Type': 'application/json', ...headers }, 49 - body: JSON.stringify(body), 50 - }); 51 - return { status: res.status, data: res.ok ? await res.json() : null }; 46 + for (let attempt = 0; attempt < 3; attempt++) { 47 + const res = await fetch(`${BASE}${path}`, { 48 + method: 'POST', 49 + headers: { 'Content-Type': 'application/json', ...headers }, 50 + body: JSON.stringify(body), 51 + }); 52 + // Retry on 5xx errors (wrangler dev flakiness) 53 + if (res.status >= 500 && attempt < 2) { 54 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 55 + continue; 56 + } 57 + return { status: res.status, data: res.ok ? await res.json() : null }; 58 + } 52 59 } 53 60 54 61 /** 55 - * Make form-encoded POST 62 + * Make form-encoded POST (with retry for flaky wrangler dev 5xx errors) 56 63 */ 57 64 async function formPost(path, params, headers = {}) { 58 - const res = await fetch(`${BASE}${path}`, { 59 - method: 'POST', 60 - headers: { 61 - 'Content-Type': 'application/x-www-form-urlencoded', 62 - ...headers, 63 - }, 64 - body: new URLSearchParams(params).toString(), 65 - }); 66 - const text = await res.text(); 67 - let data = null; 68 - try { 69 - data = JSON.parse(text); 70 - } catch { 71 - data = text; 65 + for (let attempt = 0; attempt < 3; attempt++) { 66 + const res = await fetch(`${BASE}${path}`, { 67 + method: 'POST', 68 + headers: { 69 + 'Content-Type': 'application/x-www-form-urlencoded', 70 + ...headers, 71 + }, 72 + body: new URLSearchParams(params).toString(), 73 + }); 74 + // Retry on 5xx errors (wrangler dev flakiness) 75 + if (res.status >= 500 && attempt < 2) { 76 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 77 + continue; 78 + } 79 + const text = await res.text(); 80 + let data = null; 81 + try { 82 + data = JSON.parse(text); 83 + } catch { 84 + data = text; 85 + } 86 + return { status: res.status, data }; 72 87 } 73 - return { status: res.status, data }; 74 88 } 75 89 76 90 describe('E2E Tests', () => { ··· 538 552 assert.strictEqual(data.jwks_uri, `${BASE}/oauth/jwks`); 539 553 assert.deepStrictEqual(data.scopes_supported, ['atproto']); 540 554 assert.deepStrictEqual(data.dpop_signing_alg_values_supported, ['ES256']); 541 - assert.strictEqual(data.require_pushed_authorization_requests, true); 555 + assert.strictEqual(data.require_pushed_authorization_requests, false); 542 556 assert.strictEqual(data.client_id_metadata_document_supported, true); 543 557 assert.deepStrictEqual(data.protected_resources, [BASE]); 544 558 }); ··· 1474 1488 authorizeUrl.searchParams.set('login_hint', DID); 1475 1489 1476 1490 const getRes = await fetch(authorizeUrl.toString()); 1477 - assert.strictEqual(getRes.status, 200, 'Direct authorize GET should succeed'); 1491 + assert.strictEqual( 1492 + getRes.status, 1493 + 200, 1494 + 'Direct authorize GET should succeed', 1495 + ); 1478 1496 1479 1497 const html = await getRes.text(); 1480 1498 assert.ok(html.includes('Authorize'), 'Should show consent page'); 1481 - assert.ok(html.includes('request_uri'), 'Should include request_uri in form'); 1499 + assert.ok( 1500 + html.includes('request_uri'), 1501 + 'Should include request_uri in form', 1502 + ); 1482 1503 }); 1483 1504 1484 1505 it('completes full direct authorization flow', async () => { ··· 1555 1576 const tokenData = await tokenRes.json(); 1556 1577 assert.ok(tokenData.access_token, 'Should have access_token'); 1557 1578 assert.strictEqual(tokenData.token_type, 'DPoP'); 1579 + }); 1580 + 1581 + it('consent page shows profile card when login_hint is provided', async () => { 1582 + const clientId = 'http://localhost:3000'; 1583 + const redirectUri = 'http://localhost:3000/callback'; 1584 + const codeVerifier = 'test-verifier-for-profile-card-test-min-43-chars!!'; 1585 + const challengeBuffer = await crypto.subtle.digest( 1586 + 'SHA-256', 1587 + new TextEncoder().encode(codeVerifier), 1588 + ); 1589 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1590 + 1591 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1592 + authorizeUrl.searchParams.set('client_id', clientId); 1593 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1594 + authorizeUrl.searchParams.set('response_type', 'code'); 1595 + authorizeUrl.searchParams.set('scope', 'atproto'); 1596 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1597 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1598 + authorizeUrl.searchParams.set('state', 'test-state'); 1599 + authorizeUrl.searchParams.set('login_hint', 'test.handle.example'); 1600 + 1601 + const res = await fetch(authorizeUrl.toString()); 1602 + const html = await res.text(); 1603 + 1604 + assert.ok( 1605 + html.includes('profile-card'), 1606 + 'Should include profile card element', 1607 + ); 1608 + assert.ok( 1609 + html.includes('@test.handle.example'), 1610 + 'Should show handle with @ prefix', 1611 + ); 1612 + assert.ok( 1613 + html.includes('app.bsky.actor.getProfile'), 1614 + 'Should include profile fetch script', 1615 + ); 1616 + }); 1617 + 1618 + it('consent page does not show profile card when login_hint is omitted', async () => { 1619 + const clientId = 'http://localhost:3000'; 1620 + const redirectUri = 'http://localhost:3000/callback'; 1621 + const codeVerifier = 'test-verifier-for-no-profile-test-min-43-chars!!'; 1622 + const challengeBuffer = await crypto.subtle.digest( 1623 + 'SHA-256', 1624 + new TextEncoder().encode(codeVerifier), 1625 + ); 1626 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1627 + 1628 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1629 + authorizeUrl.searchParams.set('client_id', clientId); 1630 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1631 + authorizeUrl.searchParams.set('response_type', 'code'); 1632 + authorizeUrl.searchParams.set('scope', 'atproto'); 1633 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1634 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1635 + authorizeUrl.searchParams.set('state', 'test-state'); 1636 + // No login_hint parameter 1637 + 1638 + const res = await fetch(authorizeUrl.toString()); 1639 + const html = await res.text(); 1640 + 1641 + // Check for the actual element (id="profile-card"), not the CSS class selector 1642 + assert.ok( 1643 + !html.includes('id="profile-card"'), 1644 + 'Should NOT include profile card element', 1645 + ); 1646 + assert.ok( 1647 + !html.includes('app.bsky.actor.getProfile'), 1648 + 'Should NOT include profile fetch script', 1649 + ); 1650 + }); 1651 + 1652 + it('consent page escapes dangerous characters in login_hint', async () => { 1653 + const clientId = 'http://localhost:3000'; 1654 + const redirectUri = 'http://localhost:3000/callback'; 1655 + const codeVerifier = 'test-verifier-for-xss-test-minimum-43-chars!!!!!'; 1656 + const challengeBuffer = await crypto.subtle.digest( 1657 + 'SHA-256', 1658 + new TextEncoder().encode(codeVerifier), 1659 + ); 1660 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1661 + 1662 + // Attempt XSS via login_hint with double quotes to break out of JSON.stringify 1663 + const maliciousHint = 'user");alert("xss'; 1664 + 1665 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1666 + authorizeUrl.searchParams.set('client_id', clientId); 1667 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1668 + authorizeUrl.searchParams.set('response_type', 'code'); 1669 + authorizeUrl.searchParams.set('scope', 'atproto'); 1670 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1671 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1672 + authorizeUrl.searchParams.set('state', 'test-state'); 1673 + authorizeUrl.searchParams.set('login_hint', maliciousHint); 1674 + 1675 + const res = await fetch(authorizeUrl.toString()); 1676 + const html = await res.text(); 1677 + 1678 + // JSON.stringify escapes double quotes, so the payload should be escaped 1679 + // The raw ");alert(" should NOT appear - it should be escaped as \");alert(\" 1680 + assert.ok( 1681 + !html.includes('");alert("'), 1682 + 'Should escape double quotes to prevent XSS breakout', 1683 + ); 1684 + // Verify the escaped version is present (backslash before the quote) 1685 + assert.ok( 1686 + html.includes('\\"'), 1687 + 'Should contain escaped characters from JSON.stringify', 1688 + ); 1558 1689 }); 1559 1690 }); 1560 1691
+121 -49
test/helpers/oauth.js
··· 8 8 const BASE = 'http://localhost:8787'; 9 9 10 10 /** 11 + * Fetch with retry for flaky wrangler dev 12 + * @param {string} url 13 + * @param {RequestInit} options 14 + * @param {number} maxAttempts 15 + * @returns {Promise<Response>} 16 + */ 17 + async function fetchWithRetry(url, options, maxAttempts = 3) { 18 + let lastError; 19 + for (let attempt = 0; attempt < maxAttempts; attempt++) { 20 + try { 21 + const res = await fetch(url, options); 22 + // Check if we got an HTML error page instead of expected response 23 + const contentType = res.headers.get('content-type') || ''; 24 + if (!res.ok && contentType.includes('text/html')) { 25 + // Wrangler dev error page - retry 26 + if (attempt < maxAttempts - 1) { 27 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 28 + continue; 29 + } 30 + } 31 + return res; 32 + } catch (err) { 33 + lastError = err; 34 + if (attempt < maxAttempts - 1) { 35 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 36 + } 37 + } 38 + } 39 + throw lastError || new Error('Fetch failed after retries'); 40 + } 41 + 42 + /** 11 43 * Get an OAuth token with a specific scope via full PAR -> authorize -> token flow 12 44 * @param {string} scope - The scope to request 13 45 * @param {string} did - The DID to authenticate as ··· 25 57 ); 26 58 const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 27 59 28 - // PAR request 29 - const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 30 - const parRes = await fetch(`${BASE}/oauth/par`, { 31 - method: 'POST', 32 - headers: { 33 - 'Content-Type': 'application/x-www-form-urlencoded', 34 - DPoP: parProof, 35 - }, 36 - body: new URLSearchParams({ 37 - client_id: clientId, 38 - redirect_uri: redirectUri, 39 - response_type: 'code', 40 - scope: scope, 41 - code_challenge: codeChallenge, 42 - code_challenge_method: 'S256', 43 - login_hint: did, 44 - }).toString(), 45 - }); 46 - const parData = await parRes.json(); 60 + // PAR request (with retry for flaky wrangler dev) 61 + let parData; 62 + for (let attempt = 0; attempt < 3; attempt++) { 63 + // Generate fresh DPoP proof for each attempt 64 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 65 + const parRes = await fetchWithRetry(`${BASE}/oauth/par`, { 66 + method: 'POST', 67 + headers: { 68 + 'Content-Type': 'application/x-www-form-urlencoded', 69 + DPoP: parProof, 70 + }, 71 + body: new URLSearchParams({ 72 + client_id: clientId, 73 + redirect_uri: redirectUri, 74 + response_type: 'code', 75 + scope: scope, 76 + code_challenge: codeChallenge, 77 + code_challenge_method: 'S256', 78 + login_hint: did, 79 + }).toString(), 80 + }); 81 + if (parRes.ok) { 82 + parData = await parRes.json(); 83 + break; 84 + } 85 + if (attempt < 2) { 86 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 87 + } else { 88 + const text = await parRes.text(); 89 + throw new Error( 90 + `PAR request failed: ${parRes.status} - ${text.slice(0, 100)}`, 91 + ); 92 + } 93 + } 47 94 48 - // Authorize 49 - const authRes = await fetch(`${BASE}/oauth/authorize`, { 50 - method: 'POST', 51 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 52 - body: new URLSearchParams({ 53 - request_uri: parData.request_uri, 54 - client_id: clientId, 55 - password: password, 56 - }).toString(), 57 - redirect: 'manual', 58 - }); 59 - const location = authRes.headers.get('location'); 60 - const authCode = new URL(location).searchParams.get('code'); 95 + // Authorize (with retry) 96 + let authCode; 97 + for (let attempt = 0; attempt < 3; attempt++) { 98 + const authRes = await fetchWithRetry(`${BASE}/oauth/authorize`, { 99 + method: 'POST', 100 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 101 + body: new URLSearchParams({ 102 + request_uri: parData.request_uri, 103 + client_id: clientId, 104 + password: password, 105 + }).toString(), 106 + redirect: 'manual', 107 + }); 108 + const location = authRes.headers.get('location'); 109 + if (location) { 110 + authCode = new URL(location).searchParams.get('code'); 111 + if (authCode) break; 112 + } 113 + if (attempt < 2) { 114 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 115 + } else { 116 + throw new Error('Authorize request failed to return code'); 117 + } 118 + } 61 119 62 - // Token exchange 63 - const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 64 - const tokenRes = await fetch(`${BASE}/oauth/token`, { 65 - method: 'POST', 66 - headers: { 67 - 'Content-Type': 'application/x-www-form-urlencoded', 68 - DPoP: tokenProof, 69 - }, 70 - body: new URLSearchParams({ 71 - grant_type: 'authorization_code', 72 - code: authCode, 73 - client_id: clientId, 74 - redirect_uri: redirectUri, 75 - code_verifier: codeVerifier, 76 - }).toString(), 77 - }); 78 - const tokenData = await tokenRes.json(); 120 + // Token exchange (with retry and fresh DPoP proof) 121 + let tokenData; 122 + for (let attempt = 0; attempt < 3; attempt++) { 123 + const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 124 + const tokenRes = await fetchWithRetry(`${BASE}/oauth/token`, { 125 + method: 'POST', 126 + headers: { 127 + 'Content-Type': 'application/x-www-form-urlencoded', 128 + DPoP: tokenProof, 129 + }, 130 + body: new URLSearchParams({ 131 + grant_type: 'authorization_code', 132 + code: authCode, 133 + client_id: clientId, 134 + redirect_uri: redirectUri, 135 + code_verifier: codeVerifier, 136 + }).toString(), 137 + }); 138 + if (tokenRes.ok) { 139 + tokenData = await tokenRes.json(); 140 + break; 141 + } 142 + if (attempt < 2) { 143 + await new Promise((r) => setTimeout(r, 100 * (attempt + 1))); 144 + } else { 145 + const text = await tokenRes.text(); 146 + throw new Error( 147 + `Token request failed: ${tokenRes.status} - ${text.slice(0, 100)}`, 148 + ); 149 + } 150 + } 79 151 80 152 return { 81 153 accessToken: tokenData.access_token,