this repo has no description

Compare changes

Choose any two refs to compare.

+57
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 + 33 + ## [0.4.0] - 2026-01-08 34 + 35 + ### Added 36 + 37 + - **Foreign DID proxying** via `atproto-proxy` header 38 + - `parseAtprotoProxyHeader()` parses `did:web:api.bsky.app#bsky_appview` format 39 + - `getKnownServiceUrl()` maps known service DIDs to URLs 40 + - `proxyToService()` generic proxy utility with header forwarding 41 + - Repo endpoints (getRecord, listRecords, describeRepo) support explicit proxying 42 + - Returns appropriate errors for malformed headers or unknown services 43 + - Unit tests for proxy utilities 44 + - E2E tests for foreign DID proxying behavior 45 + 46 + ### Changed 47 + 48 + - Refactored `handleAppViewProxy` to use shared `proxyToService` utility 49 + 50 + ## [0.3.0] - 2026-01-08 51 + 52 + ### Added 53 + 54 + - **Granular OAuth scope enforcement** on repo and blob endpoints 55 + - `parseRepoScope()` parses `repo:collection?action=create&action=update` format 56 + - `parseBlobScope()` parses `blob:image/*` format with MIME wildcards 57 + - `ScopePermissions` class for checking repo/blob permissions 58 + - Enforced on createRecord, putRecord, deleteRecord, applyWrites, uploadBlob 59 + - **Consent page permissions table** displaying scopes in human-readable format 60 + - Identity-only: "wants to uniquely identify you" message 61 + - Granular scopes: Table with Collection + Create/Update/Delete columns 62 + - Full access: Warning banner for `transition:generic` 63 + - `parseScopesForDisplay()` helper for consent page rendering 64 + - E2E tests for scope enforcement and consent page display 65 + 9 66 ## [0.2.0] - 2026-01-07 10 67 11 68 ### 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:
+902
docs/plans/2026-01-07-scope-validation.md
··· 1 + # OAuth Scope Validation Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Implement granular OAuth scope validation matching the official atproto PDS behavior for repo, blob, and transition scopes. 6 + 7 + **Architecture:** Add a `ScopePermissions` class that parses scope strings and provides `allowsRepo(collection, action)` and `allowsBlob(mime)` methods. Replace `hasRequiredScope()` calls with permission checks at each write endpoint. Support `atproto` and `transition:generic` as full-access scopes. 8 + 9 + **Tech Stack:** Pure JavaScript, no dependencies. Node.js test runner for TDD. 10 + 11 + --- 12 + 13 + ## Task 1: Parse Repo Scopes 14 + 15 + **Files:** 16 + - Modify: `src/pds.js` (add after `hasRequiredScope` function ~line 4565) 17 + - Test: `test/pds.test.js` (add new describe block) 18 + 19 + **Step 1: Write the failing tests** 20 + 21 + Add to `test/pds.test.js`: 22 + 23 + ```javascript 24 + import { 25 + // ... existing imports ... 26 + parseRepoScope, 27 + } from '../src/pds.js'; 28 + 29 + describe('Scope Parsing', () => { 30 + describe('parseRepoScope', () => { 31 + test('parses wildcard collection with single action', () => { 32 + const result = parseRepoScope('repo:*:create'); 33 + assert.deepStrictEqual(result, { 34 + collections: ['*'], 35 + actions: ['create'], 36 + }); 37 + }); 38 + 39 + test('parses specific collection with single action', () => { 40 + const result = parseRepoScope('repo:app.bsky.feed.post:create'); 41 + assert.deepStrictEqual(result, { 42 + collections: ['app.bsky.feed.post'], 43 + actions: ['create'], 44 + }); 45 + }); 46 + 47 + test('parses multiple actions', () => { 48 + const result = parseRepoScope('repo:*:create,update,delete'); 49 + assert.deepStrictEqual(result, { 50 + collections: ['*'], 51 + actions: ['create', 'update', 'delete'], 52 + }); 53 + }); 54 + 55 + test('returns null for non-repo scope', () => { 56 + assert.strictEqual(parseRepoScope('atproto'), null); 57 + assert.strictEqual(parseRepoScope('blob:image/*'), null); 58 + assert.strictEqual(parseRepoScope('transition:generic'), null); 59 + }); 60 + 61 + test('returns null for invalid repo scope', () => { 62 + assert.strictEqual(parseRepoScope('repo:'), null); 63 + assert.strictEqual(parseRepoScope('repo:foo'), null); 64 + assert.strictEqual(parseRepoScope('repo::create'), null); 65 + }); 66 + }); 67 + }); 68 + ``` 69 + 70 + **Step 2: Run tests to verify they fail** 71 + 72 + Run: `npm test` 73 + Expected: FAIL with "parseRepoScope is not exported" 74 + 75 + **Step 3: Write minimal implementation** 76 + 77 + Add to `src/pds.js` after the `hasRequiredScope` function (~line 4565): 78 + 79 + ```javascript 80 + /** 81 + * Parse a repo scope string into its components. 82 + * Format: repo:<collection>:<action>[,<action>...] 83 + * @param {string} scope - The scope string to parse 84 + * @returns {{ collections: string[], actions: string[] } | null} Parsed scope or null if invalid 85 + */ 86 + function parseRepoScope(scope) { 87 + if (!scope.startsWith('repo:')) return null; 88 + 89 + const rest = scope.slice(5); // Remove 'repo:' 90 + const colonIdx = rest.lastIndexOf(':'); 91 + if (colonIdx === -1 || colonIdx === 0 || colonIdx === rest.length - 1) { 92 + return null; 93 + } 94 + 95 + const collection = rest.slice(0, colonIdx); 96 + const actionsStr = rest.slice(colonIdx + 1); 97 + 98 + if (!collection || !actionsStr) return null; 99 + 100 + const actions = actionsStr.split(',').filter(a => a); 101 + if (actions.length === 0) return null; 102 + 103 + return { 104 + collections: [collection], 105 + actions, 106 + }; 107 + } 108 + ``` 109 + 110 + Add `parseRepoScope` to the exports at the end of the file. 111 + 112 + **Step 4: Run tests to verify they pass** 113 + 114 + Run: `npm test` 115 + Expected: PASS 116 + 117 + **Step 5: Commit** 118 + 119 + ```bash 120 + git add src/pds.js test/pds.test.js 121 + git commit -m "feat(scope): add parseRepoScope function" 122 + ``` 123 + 124 + --- 125 + 126 + ## Task 2: Parse Blob Scopes with MIME Matching 127 + 128 + **Files:** 129 + - Modify: `src/pds.js` 130 + - Test: `test/pds.test.js` 131 + 132 + **Step 1: Write the failing tests** 133 + 134 + Add to test file: 135 + 136 + ```javascript 137 + import { 138 + // ... existing imports ... 139 + parseBlobScope, 140 + matchesMime, 141 + } from '../src/pds.js'; 142 + 143 + describe('parseBlobScope', () => { 144 + test('parses wildcard MIME', () => { 145 + const result = parseBlobScope('blob:*/*'); 146 + assert.deepStrictEqual(result, { accept: ['*/*'] }); 147 + }); 148 + 149 + test('parses type wildcard', () => { 150 + const result = parseBlobScope('blob:image/*'); 151 + assert.deepStrictEqual(result, { accept: ['image/*'] }); 152 + }); 153 + 154 + test('parses specific MIME', () => { 155 + const result = parseBlobScope('blob:image/png'); 156 + assert.deepStrictEqual(result, { accept: ['image/png'] }); 157 + }); 158 + 159 + test('parses multiple MIMEs', () => { 160 + const result = parseBlobScope('blob:image/png,image/jpeg'); 161 + assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] }); 162 + }); 163 + 164 + test('returns null for non-blob scope', () => { 165 + assert.strictEqual(parseBlobScope('atproto'), null); 166 + assert.strictEqual(parseBlobScope('repo:*:create'), null); 167 + }); 168 + }); 169 + 170 + describe('matchesMime', () => { 171 + test('wildcard matches everything', () => { 172 + assert.strictEqual(matchesMime('*/*', 'image/png'), true); 173 + assert.strictEqual(matchesMime('*/*', 'video/mp4'), true); 174 + }); 175 + 176 + test('type wildcard matches same type', () => { 177 + assert.strictEqual(matchesMime('image/*', 'image/png'), true); 178 + assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true); 179 + assert.strictEqual(matchesMime('image/*', 'video/mp4'), false); 180 + }); 181 + 182 + test('exact match', () => { 183 + assert.strictEqual(matchesMime('image/png', 'image/png'), true); 184 + assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false); 185 + }); 186 + 187 + test('case insensitive', () => { 188 + assert.strictEqual(matchesMime('image/PNG', 'image/png'), true); 189 + assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true); 190 + }); 191 + }); 192 + ``` 193 + 194 + **Step 2: Run tests to verify they fail** 195 + 196 + Run: `npm test` 197 + Expected: FAIL 198 + 199 + **Step 3: Write minimal implementation** 200 + 201 + ```javascript 202 + /** 203 + * Parse a blob scope string into its components. 204 + * Format: blob:<mime>[,<mime>...] 205 + * @param {string} scope - The scope string to parse 206 + * @returns {{ accept: string[] } | null} Parsed scope or null if invalid 207 + */ 208 + function parseBlobScope(scope) { 209 + if (!scope.startsWith('blob:')) return null; 210 + 211 + const mimeStr = scope.slice(5); // Remove 'blob:' 212 + if (!mimeStr) return null; 213 + 214 + const accept = mimeStr.split(',').filter(m => m); 215 + if (accept.length === 0) return null; 216 + 217 + return { accept }; 218 + } 219 + 220 + /** 221 + * Check if a MIME pattern matches an actual MIME type. 222 + * @param {string} pattern - MIME pattern (e.g., 'image/*', '*/*', 'image/png') 223 + * @param {string} mime - Actual MIME type to check 224 + * @returns {boolean} Whether the pattern matches 225 + */ 226 + function matchesMime(pattern, mime) { 227 + const p = pattern.toLowerCase(); 228 + const m = mime.toLowerCase(); 229 + 230 + if (p === '*/*') return true; 231 + 232 + if (p.endsWith('/*')) { 233 + const pType = p.slice(0, -2); 234 + const mType = m.split('/')[0]; 235 + return pType === mType; 236 + } 237 + 238 + return p === m; 239 + } 240 + ``` 241 + 242 + Add exports. 243 + 244 + **Step 4: Run tests to verify they pass** 245 + 246 + Run: `npm test` 247 + Expected: PASS 248 + 249 + **Step 5: Commit** 250 + 251 + ```bash 252 + git add src/pds.js test/pds.test.js 253 + git commit -m "feat(scope): add parseBlobScope and matchesMime functions" 254 + ``` 255 + 256 + --- 257 + 258 + ## Task 3: Create ScopePermissions Class 259 + 260 + **Files:** 261 + - Modify: `src/pds.js` 262 + - Test: `test/pds.test.js` 263 + 264 + **Step 1: Write the failing tests** 265 + 266 + ```javascript 267 + import { 268 + // ... existing imports ... 269 + ScopePermissions, 270 + } from '../src/pds.js'; 271 + 272 + describe('ScopePermissions', () => { 273 + describe('static scopes', () => { 274 + test('atproto grants full access', () => { 275 + const perms = new ScopePermissions('atproto'); 276 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 277 + assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 278 + assert.strictEqual(perms.allowsBlob('image/png'), true); 279 + assert.strictEqual(perms.allowsBlob('video/mp4'), true); 280 + }); 281 + 282 + test('transition:generic grants full repo/blob access', () => { 283 + const perms = new ScopePermissions('transition:generic'); 284 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 285 + assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 286 + assert.strictEqual(perms.allowsBlob('image/png'), true); 287 + }); 288 + }); 289 + 290 + describe('repo scopes', () => { 291 + test('wildcard collection allows any collection', () => { 292 + const perms = new ScopePermissions('repo:*:create'); 293 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 294 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'create'), true); 295 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); 296 + }); 297 + 298 + test('specific collection restricts to that collection', () => { 299 + const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); 300 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 301 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'create'), false); 302 + }); 303 + 304 + test('multiple actions', () => { 305 + const perms = new ScopePermissions('repo:*:create,update'); 306 + assert.strictEqual(perms.allowsRepo('x', 'create'), true); 307 + assert.strictEqual(perms.allowsRepo('x', 'update'), true); 308 + assert.strictEqual(perms.allowsRepo('x', 'delete'), false); 309 + }); 310 + 311 + test('multiple scopes combine', () => { 312 + const perms = new ScopePermissions('repo:app.bsky.feed.post:create repo:app.bsky.feed.like:delete'); 313 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 314 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'delete'), true); 315 + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); 316 + }); 317 + }); 318 + 319 + describe('blob scopes', () => { 320 + test('wildcard allows any MIME', () => { 321 + const perms = new ScopePermissions('blob:*/*'); 322 + assert.strictEqual(perms.allowsBlob('image/png'), true); 323 + assert.strictEqual(perms.allowsBlob('video/mp4'), true); 324 + }); 325 + 326 + test('type wildcard restricts to type', () => { 327 + const perms = new ScopePermissions('blob:image/*'); 328 + assert.strictEqual(perms.allowsBlob('image/png'), true); 329 + assert.strictEqual(perms.allowsBlob('image/jpeg'), true); 330 + assert.strictEqual(perms.allowsBlob('video/mp4'), false); 331 + }); 332 + 333 + test('specific MIME restricts exactly', () => { 334 + const perms = new ScopePermissions('blob:image/png'); 335 + assert.strictEqual(perms.allowsBlob('image/png'), true); 336 + assert.strictEqual(perms.allowsBlob('image/jpeg'), false); 337 + }); 338 + }); 339 + 340 + describe('empty/no scope', () => { 341 + test('no scope denies everything', () => { 342 + const perms = new ScopePermissions(''); 343 + assert.strictEqual(perms.allowsRepo('x', 'create'), false); 344 + assert.strictEqual(perms.allowsBlob('image/png'), false); 345 + }); 346 + 347 + test('undefined scope denies everything', () => { 348 + const perms = new ScopePermissions(undefined); 349 + assert.strictEqual(perms.allowsRepo('x', 'create'), false); 350 + }); 351 + }); 352 + 353 + describe('assertRepo', () => { 354 + test('throws ScopeMissingError when denied', () => { 355 + const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); 356 + assert.throws( 357 + () => perms.assertRepo('app.bsky.feed.like', 'create'), 358 + { message: /Missing required scope/ } 359 + ); 360 + }); 361 + 362 + test('does not throw when allowed', () => { 363 + const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); 364 + assert.doesNotThrow(() => perms.assertRepo('app.bsky.feed.post', 'create')); 365 + }); 366 + }); 367 + 368 + describe('assertBlob', () => { 369 + test('throws ScopeMissingError when denied', () => { 370 + const perms = new ScopePermissions('blob:image/*'); 371 + assert.throws( 372 + () => perms.assertBlob('video/mp4'), 373 + { message: /Missing required scope/ } 374 + ); 375 + }); 376 + 377 + test('does not throw when allowed', () => { 378 + const perms = new ScopePermissions('blob:image/*'); 379 + assert.doesNotThrow(() => perms.assertBlob('image/png')); 380 + }); 381 + }); 382 + }); 383 + ``` 384 + 385 + **Step 2: Run tests to verify they fail** 386 + 387 + Run: `npm test` 388 + Expected: FAIL 389 + 390 + **Step 3: Write minimal implementation** 391 + 392 + ```javascript 393 + /** 394 + * Error thrown when a required scope is missing. 395 + */ 396 + class ScopeMissingError extends Error { 397 + /** 398 + * @param {string} scope - The missing scope 399 + */ 400 + constructor(scope) { 401 + super(`Missing required scope "${scope}"`); 402 + this.name = 'ScopeMissingError'; 403 + this.scope = scope; 404 + this.status = 403; 405 + } 406 + } 407 + 408 + /** 409 + * Parses and checks OAuth scope permissions. 410 + */ 411 + class ScopePermissions { 412 + /** 413 + * @param {string | undefined} scopeString - Space-separated scope string 414 + */ 415 + constructor(scopeString) { 416 + /** @type {Set<string>} */ 417 + this.scopes = new Set(scopeString ? scopeString.split(' ').filter(s => s) : []); 418 + 419 + /** @type {Array<{ collections: string[], actions: string[] }>} */ 420 + this.repoPermissions = []; 421 + 422 + /** @type {Array<{ accept: string[] }>} */ 423 + this.blobPermissions = []; 424 + 425 + for (const scope of this.scopes) { 426 + const repo = parseRepoScope(scope); 427 + if (repo) this.repoPermissions.push(repo); 428 + 429 + const blob = parseBlobScope(scope); 430 + if (blob) this.blobPermissions.push(blob); 431 + } 432 + } 433 + 434 + /** 435 + * Check if full access is granted (atproto or transition:generic). 436 + * @returns {boolean} 437 + */ 438 + hasFullAccess() { 439 + return this.scopes.has('atproto') || this.scopes.has('transition:generic'); 440 + } 441 + 442 + /** 443 + * Check if a repo operation is allowed. 444 + * @param {string} collection - The collection NSID 445 + * @param {string} action - The action (create, update, delete) 446 + * @returns {boolean} 447 + */ 448 + allowsRepo(collection, action) { 449 + if (this.hasFullAccess()) return true; 450 + 451 + for (const perm of this.repoPermissions) { 452 + const collectionMatch = perm.collections.includes('*') || perm.collections.includes(collection); 453 + const actionMatch = perm.actions.includes(action); 454 + if (collectionMatch && actionMatch) return true; 455 + } 456 + 457 + return false; 458 + } 459 + 460 + /** 461 + * Assert that a repo operation is allowed, throwing if not. 462 + * @param {string} collection - The collection NSID 463 + * @param {string} action - The action (create, update, delete) 464 + * @throws {ScopeMissingError} 465 + */ 466 + assertRepo(collection, action) { 467 + if (!this.allowsRepo(collection, action)) { 468 + throw new ScopeMissingError(`repo:${collection}:${action}`); 469 + } 470 + } 471 + 472 + /** 473 + * Check if a blob operation is allowed. 474 + * @param {string} mime - The MIME type of the blob 475 + * @returns {boolean} 476 + */ 477 + allowsBlob(mime) { 478 + if (this.hasFullAccess()) return true; 479 + 480 + for (const perm of this.blobPermissions) { 481 + for (const pattern of perm.accept) { 482 + if (matchesMime(pattern, mime)) return true; 483 + } 484 + } 485 + 486 + return false; 487 + } 488 + 489 + /** 490 + * Assert that a blob operation is allowed, throwing if not. 491 + * @param {string} mime - The MIME type of the blob 492 + * @throws {ScopeMissingError} 493 + */ 494 + assertBlob(mime) { 495 + if (!this.allowsBlob(mime)) { 496 + throw new ScopeMissingError(`blob:${mime}`); 497 + } 498 + } 499 + } 500 + ``` 501 + 502 + Add exports. 503 + 504 + **Step 4: Run tests to verify they pass** 505 + 506 + Run: `npm test` 507 + Expected: PASS 508 + 509 + **Step 5: Commit** 510 + 511 + ```bash 512 + git add src/pds.js test/pds.test.js 513 + git commit -m "feat(scope): add ScopePermissions class with repo/blob checking" 514 + ``` 515 + 516 + --- 517 + 518 + ## Task 4: Integrate Scope Checking into createRecord 519 + 520 + **Files:** 521 + - Modify: `src/pds.js` (handleRepoWrite function and createRecord handler) 522 + - Test: `test/e2e.test.js` (add scope enforcement tests) 523 + 524 + **Step 1: Understand the current flow** 525 + 526 + The `handleRepoWrite` function at line ~4597 currently does: 527 + ```javascript 528 + if (!hasRequiredScope(auth.scope, 'atproto')) { 529 + return errorResponse('Forbidden', 'Insufficient scope for repo write', 403); 530 + } 531 + ``` 532 + 533 + This needs to be replaced with per-endpoint scope checking. The collection is in `body.collection`. 534 + 535 + **Step 2: Modify handleRepoWrite to accept collection and action** 536 + 537 + Update `handleRepoWrite` in `src/pds.js`: 538 + 539 + ```javascript 540 + /** 541 + * @param {Request} request 542 + * @param {Env} env 543 + * @param {string} collection - The collection being written to 544 + * @param {string} action - The action being performed (create, update, delete) 545 + */ 546 + async function handleRepoWrite(request, env, collection, action) { 547 + const auth = await requireAuth(request, env); 548 + if ('error' in auth) return auth.error; 549 + 550 + // Validate scope for repo write using granular permissions 551 + if (auth.scope !== undefined) { 552 + const permissions = new ScopePermissions(auth.scope); 553 + if (!permissions.allowsRepo(collection, action)) { 554 + return errorResponse( 555 + 'Forbidden', 556 + `Missing required scope "repo:${collection}:${action}"`, 557 + 403, 558 + ); 559 + } 560 + } 561 + // Legacy tokens without scope are trusted (backward compat) 562 + 563 + // ... rest of function 564 + } 565 + ``` 566 + 567 + **Step 3: Update createRecord to pass collection and action** 568 + 569 + Find the createRecord handler in the routes object and update it to extract collection before calling handleRepoWrite. 570 + 571 + Since createRecord is POST, the collection comes from the body. We need to restructure slightly: 572 + 573 + ```javascript 574 + // In the route handler for com.atproto.repo.createRecord 575 + async (request, env) => { 576 + const auth = await requireAuth(request, env); 577 + if ('error' in auth) return auth.error; 578 + 579 + const body = await request.json(); 580 + const collection = body.collection; 581 + 582 + if (!collection) { 583 + return errorResponse('InvalidRequest', 'missing collection param', 400); 584 + } 585 + 586 + // Validate scope 587 + if (auth.scope !== undefined) { 588 + const permissions = new ScopePermissions(auth.scope); 589 + if (!permissions.allowsRepo(collection, 'create')) { 590 + return errorResponse( 591 + 'Forbidden', 592 + `Missing required scope "repo:${collection}:create"`, 593 + 403, 594 + ); 595 + } 596 + } 597 + 598 + // Continue with existing logic... 599 + } 600 + ``` 601 + 602 + **Step 4: Write E2E test for scope enforcement** 603 + 604 + Add to `test/e2e.test.js`: 605 + 606 + ```javascript 607 + describe('Scope Enforcement', () => { 608 + test('createRecord denied with insufficient scope', async () => { 609 + // Create OAuth token with limited scope 610 + const limitedToken = await getOAuthToken('repo:app.bsky.feed.like:create'); 611 + 612 + const response = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, { 613 + method: 'POST', 614 + headers: { 615 + 'Content-Type': 'application/json', 616 + 'Authorization': `DPoP ${limitedToken}`, 617 + 'DPoP': dpopProof, 618 + }, 619 + body: JSON.stringify({ 620 + repo: TEST_DID, 621 + collection: 'app.bsky.feed.post', // Not allowed by scope 622 + record: { text: 'test', createdAt: new Date().toISOString() }, 623 + }), 624 + }); 625 + 626 + assert.strictEqual(response.status, 403); 627 + const body = await response.json(); 628 + assert.ok(body.message.includes('Missing required scope')); 629 + }); 630 + 631 + test('createRecord allowed with matching scope', async () => { 632 + const validToken = await getOAuthToken('repo:app.bsky.feed.post:create'); 633 + 634 + const response = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, { 635 + method: 'POST', 636 + headers: { 637 + 'Content-Type': 'application/json', 638 + 'Authorization': `DPoP ${validToken}`, 639 + 'DPoP': dpopProof, 640 + }, 641 + body: JSON.stringify({ 642 + repo: TEST_DID, 643 + collection: 'app.bsky.feed.post', 644 + record: { text: 'test', createdAt: new Date().toISOString() }, 645 + }), 646 + }); 647 + 648 + assert.strictEqual(response.status, 200); 649 + }); 650 + }); 651 + ``` 652 + 653 + **Step 5: Run E2E tests** 654 + 655 + Run: `npm run test:e2e` 656 + Expected: PASS 657 + 658 + **Step 6: Commit** 659 + 660 + ```bash 661 + git add src/pds.js test/e2e.test.js 662 + git commit -m "feat(scope): enforce granular scopes on createRecord" 663 + ``` 664 + 665 + --- 666 + 667 + ## Task 5: Integrate Scope Checking into putRecord 668 + 669 + **Files:** 670 + - Modify: `src/pds.js` 671 + 672 + **Step 1: Update putRecord handler** 673 + 674 + putRecord requires BOTH create AND update permissions (since it can do either): 675 + 676 + ```javascript 677 + // In putRecord handler 678 + if (auth.scope !== undefined) { 679 + const permissions = new ScopePermissions(auth.scope); 680 + if (!permissions.allowsRepo(collection, 'create') || !permissions.allowsRepo(collection, 'update')) { 681 + const missing = !permissions.allowsRepo(collection, 'create') ? 'create' : 'update'; 682 + return errorResponse( 683 + 'Forbidden', 684 + `Missing required scope "repo:${collection}:${missing}"`, 685 + 403, 686 + ); 687 + } 688 + } 689 + ``` 690 + 691 + **Step 2: Run tests** 692 + 693 + Run: `npm test && npm run test:e2e` 694 + Expected: PASS 695 + 696 + **Step 3: Commit** 697 + 698 + ```bash 699 + git add src/pds.js 700 + git commit -m "feat(scope): enforce granular scopes on putRecord" 701 + ``` 702 + 703 + --- 704 + 705 + ## Task 6: Integrate Scope Checking into deleteRecord 706 + 707 + **Files:** 708 + - Modify: `src/pds.js` 709 + 710 + **Step 1: Update deleteRecord handler** 711 + 712 + ```javascript 713 + // In deleteRecord handler 714 + if (auth.scope !== undefined) { 715 + const permissions = new ScopePermissions(auth.scope); 716 + if (!permissions.allowsRepo(collection, 'delete')) { 717 + return errorResponse( 718 + 'Forbidden', 719 + `Missing required scope "repo:${collection}:delete"`, 720 + 403, 721 + ); 722 + } 723 + } 724 + ``` 725 + 726 + **Step 2: Run tests** 727 + 728 + Run: `npm test && npm run test:e2e` 729 + Expected: PASS 730 + 731 + **Step 3: Commit** 732 + 733 + ```bash 734 + git add src/pds.js 735 + git commit -m "feat(scope): enforce granular scopes on deleteRecord" 736 + ``` 737 + 738 + --- 739 + 740 + ## Task 7: Integrate Scope Checking into applyWrites 741 + 742 + **Files:** 743 + - Modify: `src/pds.js` 744 + 745 + **Step 1: Update applyWrites handler** 746 + 747 + applyWrites must check each write operation individually: 748 + 749 + ```javascript 750 + // In applyWrites handler 751 + if (auth.scope !== undefined) { 752 + const permissions = new ScopePermissions(auth.scope); 753 + 754 + for (const write of writes) { 755 + const collection = write.collection; 756 + let action; 757 + 758 + if (write.$type === 'com.atproto.repo.applyWrites#create') { 759 + action = 'create'; 760 + } else if (write.$type === 'com.atproto.repo.applyWrites#update') { 761 + action = 'update'; 762 + } else if (write.$type === 'com.atproto.repo.applyWrites#delete') { 763 + action = 'delete'; 764 + } else { 765 + continue; 766 + } 767 + 768 + if (!permissions.allowsRepo(collection, action)) { 769 + return errorResponse( 770 + 'Forbidden', 771 + `Missing required scope "repo:${collection}:${action}"`, 772 + 403, 773 + ); 774 + } 775 + } 776 + } 777 + ``` 778 + 779 + **Step 2: Run tests** 780 + 781 + Run: `npm test && npm run test:e2e` 782 + Expected: PASS 783 + 784 + **Step 3: Commit** 785 + 786 + ```bash 787 + git add src/pds.js 788 + git commit -m "feat(scope): enforce granular scopes on applyWrites" 789 + ``` 790 + 791 + --- 792 + 793 + ## Task 8: Integrate Scope Checking into uploadBlob 794 + 795 + **Files:** 796 + - Modify: `src/pds.js` (handleBlobUpload function) 797 + 798 + **Step 1: Update handleBlobUpload** 799 + 800 + The MIME type comes from the Content-Type header: 801 + 802 + ```javascript 803 + async function handleBlobUpload(request, env) { 804 + const auth = await requireAuth(request, env); 805 + if ('error' in auth) return auth.error; 806 + 807 + const contentType = request.headers.get('content-type') || 'application/octet-stream'; 808 + 809 + // Validate scope for blob upload 810 + if (auth.scope !== undefined) { 811 + const permissions = new ScopePermissions(auth.scope); 812 + if (!permissions.allowsBlob(contentType)) { 813 + return errorResponse( 814 + 'Forbidden', 815 + `Missing required scope "blob:${contentType}"`, 816 + 403, 817 + ); 818 + } 819 + } 820 + 821 + // ... rest of function 822 + } 823 + ``` 824 + 825 + **Step 2: Run tests** 826 + 827 + Run: `npm test && npm run test:e2e` 828 + Expected: PASS 829 + 830 + **Step 3: Commit** 831 + 832 + ```bash 833 + git add src/pds.js 834 + git commit -m "feat(scope): enforce granular scopes on uploadBlob with MIME matching" 835 + ``` 836 + 837 + --- 838 + 839 + ## Task 9: Remove Old hasRequiredScope Calls 840 + 841 + **Files:** 842 + - Modify: `src/pds.js` 843 + 844 + **Step 1: Search and remove old calls** 845 + 846 + Find all remaining uses of `hasRequiredScope` and either: 847 + - Remove them (if replaced by ScopePermissions) 848 + - Keep for legacy non-OAuth paths if needed 849 + 850 + **Step 2: Run all tests** 851 + 852 + Run: `npm test && npm run test:e2e` 853 + Expected: PASS 854 + 855 + **Step 3: Commit** 856 + 857 + ```bash 858 + git add src/pds.js 859 + git commit -m "refactor(scope): remove deprecated hasRequiredScope function" 860 + ``` 861 + 862 + --- 863 + 864 + ## Task 10: Update scope-comparison.md 865 + 866 + **Files:** 867 + - Modify: `docs/scope-comparison.md` 868 + 869 + **Step 1: Update status in comparison doc** 870 + 871 + Change the pds.js column entries to reflect new implementation: 872 + 873 + - `atproto`: "Full access" 874 + - `transition:generic`: "Full access" 875 + - `repo:<collection>:<action>`: "Full parsing + enforcement" 876 + - `blob:<mime>`: "Full parsing + enforcement" 877 + 878 + **Step 2: Commit** 879 + 880 + ```bash 881 + git add docs/scope-comparison.md 882 + git commit -m "docs: update scope comparison with implementation status" 883 + ``` 884 + 885 + --- 886 + 887 + ## Summary 888 + 889 + | Task | Description | Est. Time | 890 + |------|-------------|-----------| 891 + | 1 | Parse repo scopes | 5 min | 892 + | 2 | Parse blob scopes + MIME matching | 5 min | 893 + | 3 | ScopePermissions class | 10 min | 894 + | 4 | Integrate into createRecord | 10 min | 895 + | 5 | Integrate into putRecord | 5 min | 896 + | 6 | Integrate into deleteRecord | 5 min | 897 + | 7 | Integrate into applyWrites | 10 min | 898 + | 8 | Integrate into uploadBlob | 5 min | 899 + | 9 | Remove old hasRequiredScope | 5 min | 900 + | 10 | Update docs | 5 min | 901 + 902 + **Total: ~65 minutes**
+633
docs/plans/2026-01-08-direct-authorization.md
··· 1 + # Direct Authorization Support Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Support direct OAuth authorization requests (without PAR) to match the official AT Protocol PDS behavior. 6 + 7 + **Architecture:** When `/oauth/authorize` receives direct parameters instead of a `request_uri`, create an authorization request record on-the-fly (same as PAR does internally), then render the consent page. The token endpoint will bind DPoP at exchange time for direct auth flows. 8 + 9 + **Tech Stack:** JavaScript, Cloudflare Workers, SQLite 10 + 11 + --- 12 + 13 + ## Task 1: Add Tests for Direct Authorization 14 + 15 + **Files:** 16 + - Modify: `test/e2e.test.js` 17 + 18 + **Step 1: Write failing test for direct authorization GET** 19 + 20 + Add this test in the `OAuth endpoints` describe block (after existing OAuth tests around line 1452): 21 + 22 + ```javascript 23 + it('supports direct authorization without PAR', async () => { 24 + const clientId = `http://localhost:${mockClientPort}/client-metadata.json`; 25 + const redirectUri = `http://localhost:${mockClientPort}/callback`; 26 + const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 27 + const codeChallenge = await generateCodeChallenge(codeVerifier); 28 + const state = 'test-direct-auth-state'; 29 + 30 + // Step 1: GET authorize with direct parameters (no PAR) 31 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 32 + authorizeUrl.searchParams.set('client_id', clientId); 33 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 34 + authorizeUrl.searchParams.set('response_type', 'code'); 35 + authorizeUrl.searchParams.set('scope', 'atproto'); 36 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 37 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 38 + authorizeUrl.searchParams.set('state', state); 39 + authorizeUrl.searchParams.set('login_hint', DID); 40 + 41 + const getRes = await fetch(authorizeUrl.toString()); 42 + assert.strictEqual(getRes.status, 200, 'Direct authorize GET should succeed'); 43 + 44 + const html = await getRes.text(); 45 + assert.ok(html.includes('Authorize'), 'Should show consent page'); 46 + assert.ok(html.includes('request_uri'), 'Should include request_uri in form'); 47 + }); 48 + ``` 49 + 50 + **Step 2: Run test to verify it fails** 51 + 52 + Run: `npm test -- --grep "supports direct authorization"` 53 + 54 + Expected: FAIL with "Direct authorize GET should succeed" - status will be 400 "Missing parameters" 55 + 56 + **Step 3: Add test for full direct auth flow** 57 + 58 + Add after the previous test: 59 + 60 + ```javascript 61 + it('completes full direct authorization flow', async () => { 62 + const clientId = `http://localhost:${mockClientPort}/client-metadata.json`; 63 + const redirectUri = `http://localhost:${mockClientPort}/callback`; 64 + const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 65 + const codeChallenge = await generateCodeChallenge(codeVerifier); 66 + const state = 'test-direct-auth-state'; 67 + 68 + // Step 1: GET authorize with direct parameters 69 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 70 + authorizeUrl.searchParams.set('client_id', clientId); 71 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 72 + authorizeUrl.searchParams.set('response_type', 'code'); 73 + authorizeUrl.searchParams.set('scope', 'atproto'); 74 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 75 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 76 + authorizeUrl.searchParams.set('state', state); 77 + authorizeUrl.searchParams.set('login_hint', DID); 78 + 79 + const getRes = await fetch(authorizeUrl.toString()); 80 + assert.strictEqual(getRes.status, 200); 81 + const html = await getRes.text(); 82 + 83 + // Extract request_uri from the form 84 + const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/); 85 + assert.ok(requestUriMatch, 'Should have request_uri in form'); 86 + const requestUri = requestUriMatch[1]; 87 + 88 + // Step 2: POST to authorize (user approval) 89 + const authRes = await fetch(`${BASE}/oauth/authorize`, { 90 + method: 'POST', 91 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 92 + body: new URLSearchParams({ 93 + request_uri: requestUri, 94 + client_id: clientId, 95 + password: PASSWORD, 96 + }).toString(), 97 + redirect: 'manual', 98 + }); 99 + 100 + assert.strictEqual(authRes.status, 302, 'Should redirect after approval'); 101 + const location = authRes.headers.get('location'); 102 + assert.ok(location, 'Should have Location header'); 103 + const locationUrl = new URL(location); 104 + const code = locationUrl.searchParams.get('code'); 105 + assert.ok(code, 'Should have authorization code'); 106 + assert.strictEqual(locationUrl.searchParams.get('state'), state); 107 + 108 + // Step 3: Exchange code for tokens 109 + const { privateKey: dpopPrivateKey, publicJwk: dpopPublicJwk } = 110 + await generateDpopKeyPair(); 111 + const dpopProof = await createDpopProof( 112 + dpopPrivateKey, 113 + dpopPublicJwk, 114 + 'POST', 115 + `${BASE}/oauth/token`, 116 + ); 117 + 118 + const tokenRes = await fetch(`${BASE}/oauth/token`, { 119 + method: 'POST', 120 + headers: { 121 + 'Content-Type': 'application/x-www-form-urlencoded', 122 + DPoP: dpopProof, 123 + }, 124 + body: new URLSearchParams({ 125 + grant_type: 'authorization_code', 126 + code, 127 + redirect_uri: redirectUri, 128 + client_id: clientId, 129 + code_verifier: codeVerifier, 130 + }).toString(), 131 + }); 132 + 133 + assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); 134 + const tokenData = await tokenRes.json(); 135 + assert.ok(tokenData.access_token, 'Should have access_token'); 136 + assert.strictEqual(tokenData.token_type, 'DPoP'); 137 + }); 138 + ``` 139 + 140 + **Step 4: Run tests to verify they fail** 141 + 142 + Run: `npm test -- --grep "direct authorization"` 143 + 144 + Expected: Both tests FAIL 145 + 146 + **Step 5: Commit test file** 147 + 148 + ```bash 149 + git add test/e2e.test.js 150 + git commit -m "test: add failing tests for direct OAuth authorization flow" 151 + ``` 152 + 153 + --- 154 + 155 + ## Task 2: Extract Shared Validation Logic 156 + 157 + **Files:** 158 + - Modify: `src/pds.js:3737-3845` (handleOAuthPar method) 159 + 160 + **Step 1: Create validateAuthorizationParameters helper** 161 + 162 + Add this new method to the PersonalDataServer class, before `handleOAuthPar` (around line 3730): 163 + 164 + ```javascript 165 + /** 166 + * Validate OAuth authorization request parameters. 167 + * Shared between PAR and direct authorization flows. 168 + * @param {Object} params - The authorization parameters 169 + * @param {string} params.clientId - The client_id 170 + * @param {string} params.redirectUri - The redirect_uri 171 + * @param {string} params.responseType - The response_type 172 + * @param {string} [params.responseMode] - The response_mode 173 + * @param {string} [params.scope] - The scope 174 + * @param {string} [params.state] - The state 175 + * @param {string} params.codeChallenge - The code_challenge 176 + * @param {string} params.codeChallengeMethod - The code_challenge_method 177 + * @param {string} [params.loginHint] - The login_hint 178 + * @returns {Promise<{error: Response} | {clientMetadata: ClientMetadata}>} 179 + */ 180 + async validateAuthorizationParameters({ 181 + clientId, 182 + redirectUri, 183 + responseType, 184 + codeChallenge, 185 + codeChallengeMethod, 186 + }) { 187 + if (!clientId) { 188 + return { error: errorResponse('invalid_request', 'client_id required', 400) }; 189 + } 190 + if (!redirectUri) { 191 + return { error: errorResponse('invalid_request', 'redirect_uri required', 400) }; 192 + } 193 + if (responseType !== 'code') { 194 + return { 195 + error: errorResponse( 196 + 'unsupported_response_type', 197 + 'response_type must be code', 198 + 400, 199 + ), 200 + }; 201 + } 202 + if (!codeChallenge || codeChallengeMethod !== 'S256') { 203 + return { error: errorResponse('invalid_request', 'PKCE with S256 required', 400) }; 204 + } 205 + 206 + let clientMetadata; 207 + try { 208 + clientMetadata = await getClientMetadata(clientId); 209 + } catch (err) { 210 + return { error: errorResponse('invalid_client', err.message, 400) }; 211 + } 212 + 213 + // Validate redirect_uri against registered URIs 214 + const isLoopback = 215 + clientId.startsWith('http://localhost') || 216 + clientId.startsWith('http://127.0.0.1'); 217 + const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { 218 + if (isLoopback) { 219 + try { 220 + const registered = new URL(uri); 221 + const requested = new URL(redirectUri); 222 + return registered.origin === requested.origin; 223 + } catch { 224 + return false; 225 + } 226 + } 227 + return uri === redirectUri; 228 + }); 229 + if (!redirectUriValid) { 230 + return { 231 + error: errorResponse( 232 + 'invalid_request', 233 + 'redirect_uri not registered for this client', 234 + 400, 235 + ), 236 + }; 237 + } 238 + 239 + return { clientMetadata }; 240 + } 241 + ``` 242 + 243 + **Step 2: Run existing tests to verify nothing broke** 244 + 245 + Run: `npm test` 246 + 247 + Expected: All existing tests PASS (new method not called yet) 248 + 249 + **Step 3: Commit** 250 + 251 + ```bash 252 + git add src/pds.js 253 + git commit -m "refactor: extract validateAuthorizationParameters helper" 254 + ``` 255 + 256 + --- 257 + 258 + ## Task 3: Refactor handleOAuthPar to Use Shared Validation 259 + 260 + **Files:** 261 + - Modify: `src/pds.js:3737-3845` (handleOAuthPar method) 262 + 263 + **Step 1: Update handleOAuthPar to use the new helper** 264 + 265 + Replace the validation section in `handleOAuthPar` (lines ~3760-3815) with: 266 + 267 + ```javascript 268 + async handleOAuthPar(request, url) { 269 + // Opportunistically clean up expired authorization requests 270 + this.cleanupExpiredAuthorizationRequests(); 271 + 272 + const issuer = `${url.protocol}//${url.host}`; 273 + 274 + const dpopResult = await this.validateRequiredDpop( 275 + request, 276 + 'POST', 277 + `${issuer}/oauth/par`, 278 + ); 279 + if ('error' in dpopResult) return dpopResult.error; 280 + const { dpop } = dpopResult; 281 + 282 + // Parse body - support both JSON and form-encoded 283 + /** @type {Record<string, string|undefined>} */ 284 + let data; 285 + try { 286 + data = await parseRequestBody(request); 287 + } catch { 288 + return errorResponse('invalid_request', 'Invalid JSON body', 400); 289 + } 290 + 291 + const clientId = data.client_id; 292 + const redirectUri = data.redirect_uri; 293 + const responseType = data.response_type; 294 + const responseMode = data.response_mode; 295 + const scope = data.scope; 296 + const state = data.state; 297 + const codeChallenge = data.code_challenge; 298 + const codeChallengeMethod = data.code_challenge_method; 299 + const loginHint = data.login_hint; 300 + 301 + // Use shared validation 302 + const validationResult = await this.validateAuthorizationParameters({ 303 + clientId, 304 + redirectUri, 305 + responseType, 306 + codeChallenge, 307 + codeChallengeMethod, 308 + }); 309 + if ('error' in validationResult) return validationResult.error; 310 + const { clientMetadata } = validationResult; 311 + 312 + const requestId = crypto.randomUUID(); 313 + const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 314 + const expiresIn = 600; 315 + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 316 + 317 + this.sql.exec( 318 + `INSERT INTO authorization_requests ( 319 + id, client_id, client_metadata, parameters, 320 + code_challenge, code_challenge_method, dpop_jkt, 321 + expires_at, created_at 322 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 323 + requestId, 324 + clientId, 325 + JSON.stringify(clientMetadata), 326 + JSON.stringify({ 327 + redirect_uri: redirectUri, 328 + scope, 329 + state, 330 + response_mode: responseMode, 331 + login_hint: loginHint, 332 + }), 333 + codeChallenge, 334 + codeChallengeMethod, 335 + dpop.jkt, 336 + expiresAt, 337 + new Date().toISOString(), 338 + ); 339 + 340 + return Response.json({ request_uri: requestUri, expires_in: expiresIn }); 341 + } 342 + ``` 343 + 344 + **Step 2: Run all OAuth tests to verify PAR still works** 345 + 346 + Run: `npm test -- --grep OAuth` 347 + 348 + Expected: All existing OAuth tests PASS 349 + 350 + **Step 3: Commit** 351 + 352 + ```bash 353 + git add src/pds.js 354 + git commit -m "refactor: use validateAuthorizationParameters in handleOAuthPar" 355 + ``` 356 + 357 + --- 358 + 359 + ## Task 4: Implement Direct Authorization in handleOAuthAuthorizeGet 360 + 361 + **Files:** 362 + - Modify: `src/pds.js:3869-3911` (handleOAuthAuthorizeGet method) 363 + 364 + **Step 1: Update handleOAuthAuthorizeGet to handle direct parameters** 365 + 366 + Replace the entire `handleOAuthAuthorizeGet` method: 367 + 368 + ```javascript 369 + /** 370 + * Handle GET /oauth/authorize - displays the consent UI. 371 + * Supports both PAR (request_uri) and direct authorization parameters. 372 + * @param {URL} url - Parsed request URL 373 + * @returns {Promise<Response>} HTML consent page 374 + */ 375 + async handleOAuthAuthorizeGet(url) { 376 + // Opportunistically clean up expired authorization requests 377 + this.cleanupExpiredAuthorizationRequests(); 378 + 379 + const requestUri = url.searchParams.get('request_uri'); 380 + const clientId = url.searchParams.get('client_id'); 381 + 382 + // If request_uri is present, use PAR flow 383 + if (requestUri) { 384 + if (!clientId) { 385 + return new Response('Missing client_id parameter', { status: 400 }); 386 + } 387 + 388 + const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 389 + if (!match) return new Response('Invalid request_uri', { status: 400 }); 390 + 391 + const rows = this.sql 392 + .exec( 393 + `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 394 + match[1], 395 + clientId, 396 + ) 397 + .toArray(); 398 + const authRequest = rows[0]; 399 + 400 + if (!authRequest) return new Response('Request not found', { status: 400 }); 401 + if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 402 + return new Response('Request expired', { status: 400 }); 403 + if (authRequest.code) 404 + return new Response('Request already used', { status: 400 }); 405 + 406 + const clientMetadata = JSON.parse( 407 + /** @type {string} */ (authRequest.client_metadata), 408 + ); 409 + const parameters = JSON.parse( 410 + /** @type {string} */ (authRequest.parameters), 411 + ); 412 + 413 + return new Response( 414 + renderConsentPage({ 415 + clientName: clientMetadata.client_name || clientId, 416 + clientId: clientId || '', 417 + scope: parameters.scope || 'atproto', 418 + requestUri: requestUri || '', 419 + }), 420 + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 421 + ); 422 + } 423 + 424 + // Direct authorization flow - create request on-the-fly 425 + if (!clientId) { 426 + return new Response('Missing client_id parameter', { status: 400 }); 427 + } 428 + 429 + const redirectUri = url.searchParams.get('redirect_uri'); 430 + const responseType = url.searchParams.get('response_type'); 431 + const responseMode = url.searchParams.get('response_mode'); 432 + const scope = url.searchParams.get('scope'); 433 + const state = url.searchParams.get('state'); 434 + const codeChallenge = url.searchParams.get('code_challenge'); 435 + const codeChallengeMethod = url.searchParams.get('code_challenge_method'); 436 + const loginHint = url.searchParams.get('login_hint'); 437 + 438 + // Validate parameters using shared helper 439 + const validationResult = await this.validateAuthorizationParameters({ 440 + clientId, 441 + redirectUri, 442 + responseType, 443 + codeChallenge, 444 + codeChallengeMethod, 445 + }); 446 + if ('error' in validationResult) return validationResult.error; 447 + const { clientMetadata } = validationResult; 448 + 449 + // Create authorization request record (same as PAR but without DPoP) 450 + const requestId = crypto.randomUUID(); 451 + const newRequestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; 452 + const expiresIn = 600; 453 + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); 454 + 455 + this.sql.exec( 456 + `INSERT INTO authorization_requests ( 457 + id, client_id, client_metadata, parameters, 458 + code_challenge, code_challenge_method, dpop_jkt, 459 + expires_at, created_at 460 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 461 + requestId, 462 + clientId, 463 + JSON.stringify(clientMetadata), 464 + JSON.stringify({ 465 + redirect_uri: redirectUri, 466 + scope, 467 + state, 468 + response_mode: responseMode, 469 + login_hint: loginHint, 470 + }), 471 + codeChallenge, 472 + codeChallengeMethod, 473 + null, // No DPoP for direct authorization - will be bound at token exchange 474 + expiresAt, 475 + new Date().toISOString(), 476 + ); 477 + 478 + return new Response( 479 + renderConsentPage({ 480 + clientName: clientMetadata.client_name || clientId, 481 + clientId: clientId, 482 + scope: scope || 'atproto', 483 + requestUri: newRequestUri, 484 + }), 485 + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 486 + ); 487 + } 488 + ``` 489 + 490 + **Step 2: Run the first direct auth test** 491 + 492 + Run: `npm test -- --grep "supports direct authorization without PAR"` 493 + 494 + Expected: PASS 495 + 496 + **Step 3: Commit** 497 + 498 + ```bash 499 + git add src/pds.js 500 + git commit -m "feat: support direct authorization in handleOAuthAuthorizeGet" 501 + ``` 502 + 503 + --- 504 + 505 + ## Task 5: Update Token Endpoint for Null DPoP Binding 506 + 507 + **Files:** 508 + - Modify: `src/pds.js:4097-4098` (handleAuthCodeGrant method) 509 + 510 + **Step 1: Update DPoP validation to handle null dpop_jkt** 511 + 512 + Find the DPoP check in `handleAuthCodeGrant` (around line 4097) and replace: 513 + 514 + ```javascript 515 + if (authRequest.dpop_jkt !== dpop.jkt) 516 + return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 517 + ``` 518 + 519 + With: 520 + 521 + ```javascript 522 + // For PAR flow, dpop_jkt is set at PAR time and must match 523 + // For direct authorization, dpop_jkt is null and we bind to the token request's DPoP 524 + if (authRequest.dpop_jkt !== null && authRequest.dpop_jkt !== dpop.jkt) { 525 + return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 526 + } 527 + ``` 528 + 529 + **Step 2: Run full direct auth flow test** 530 + 531 + Run: `npm test -- --grep "completes full direct authorization flow"` 532 + 533 + Expected: PASS 534 + 535 + **Step 3: Run all OAuth tests to verify nothing broke** 536 + 537 + Run: `npm test -- --grep OAuth` 538 + 539 + Expected: All OAuth tests PASS 540 + 541 + **Step 4: Commit** 542 + 543 + ```bash 544 + git add src/pds.js 545 + git commit -m "feat: allow null dpop_jkt binding for direct authorization" 546 + ``` 547 + 548 + --- 549 + 550 + ## Task 6: Update AS Metadata 551 + 552 + **Files:** 553 + - Modify: `src/pds.js:3695` (handleOAuthAuthServerMetadata method) 554 + 555 + **Step 1: Change require_pushed_authorization_requests to false** 556 + 557 + Find line 3695 and change: 558 + 559 + ```javascript 560 + require_pushed_authorization_requests: true, 561 + ``` 562 + 563 + To: 564 + 565 + ```javascript 566 + require_pushed_authorization_requests: false, 567 + ``` 568 + 569 + **Step 2: Update the e2e test expectation** 570 + 571 + Find the AS metadata test in `test/e2e.test.js` (around line 541) and change: 572 + 573 + ```javascript 574 + assert.strictEqual(data.require_pushed_authorization_requests, true); 575 + ``` 576 + 577 + To: 578 + 579 + ```javascript 580 + assert.strictEqual(data.require_pushed_authorization_requests, false); 581 + ``` 582 + 583 + **Step 3: Run tests** 584 + 585 + Run: `npm test` 586 + 587 + Expected: All tests PASS 588 + 589 + **Step 4: Commit** 590 + 591 + ```bash 592 + git add src/pds.js test/e2e.test.js 593 + git commit -m "feat: set require_pushed_authorization_requests to false" 594 + ``` 595 + 596 + --- 597 + 598 + ## Task 7: Final Verification 599 + 600 + **Step 1: Run all tests** 601 + 602 + Run: `npm test` 603 + 604 + Expected: All tests PASS 605 + 606 + **Step 2: Manual verification with the original URL** 607 + 608 + Test that the original failing URL now works by deploying to your worker and visiting: 609 + 610 + ``` 611 + https://chad-pds.chad-53c.workers.dev/oauth/authorize?client_id=https%3A%2F%2Fquickslice-production-9cf4.up.railway.app%2Foauth-client-metadata.json&redirect_uri=https%3A%2F%2Fquickslice-production-9cf4.up.railway.app%2Foauth%2Fatp%2Fcallback&response_type=code&code_challenge=v9w-ACgE-QauiZkLpSDeZTjgGDmGdVHbegFe18dkQSw&code_challenge_method=S256&state=QkxYNYrf73X0rLaU6XBUyg&scope=atproto%20...&login_hint=did%3Aplc%3Ac6vxslynzebnlk5kw2orx37o 612 + ``` 613 + 614 + Expected: Should show consent page instead of "Missing parameters" error 615 + 616 + **Step 3: Final commit (if any cleanup needed)** 617 + 618 + ```bash 619 + git add -A 620 + git commit -m "chore: cleanup after direct authorization implementation" 621 + ``` 622 + 623 + --- 624 + 625 + ## Summary 626 + 627 + This implementation: 628 + 629 + 1. **Extracts shared validation** - `validateAuthorizationParameters()` is used by both PAR and direct auth 630 + 2. **Creates request records on-the-fly** - Direct auth creates the same DB record as PAR, just without DPoP binding 631 + 3. **Defers DPoP binding** - For direct auth, DPoP is bound at token exchange time instead of request time 632 + 4. **Updates metadata** - Sets `require_pushed_authorization_requests: false` to signal clients that PAR is optional 633 + 5. **Maintains backwards compatibility** - PAR flow continues to work exactly as before
+480
docs/plans/2026-01-08-foreign-did-proxying.md
··· 1 + # Foreign DID Proxying Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Handle foreign DID requests by either (1) respecting `atproto-proxy` header, or (2) detecting foreign `repo` param and proxying to AppView. 6 + 7 + **Architecture:** (matches official PDS) 8 + 1. Check if `repo` is a local DID โ†’ handle locally (ignore atproto-proxy) 9 + 2. If foreign DID with `atproto-proxy` header โ†’ proxy to specified service 10 + 3. If foreign DID without header โ†’ proxy to AppView (default) 11 + 12 + **Tech Stack:** Cloudflare Workers, Durable Objects, ATProto 13 + 14 + --- 15 + 16 + ## Background 17 + 18 + When a client needs data from a foreign DID, it may: 19 + 1. Send `atproto-proxy: did:web:api.bsky.app#bsky_appview` header (explicit) 20 + 2. Just send `repo=did:plc:foreign...` without header (implicit) 21 + 22 + Our PDS should handle both cases. Currently it ignores the header and always tries to find records locally. 23 + 24 + --- 25 + 26 + ### Task 1: Add parseAtprotoProxyHeader Utility 27 + 28 + **Files:** 29 + - Modify: `src/pds.js` (after errorResponse function, around line 178) 30 + 31 + **Step 1: Add the utility function** 32 + 33 + ```javascript 34 + /** 35 + * Parse atproto-proxy header to get service DID and service ID 36 + * Format: "did:web:api.bsky.app#bsky_appview" 37 + * @param {string} header 38 + * @returns {{ did: string, serviceId: string } | null} 39 + */ 40 + function parseAtprotoProxyHeader(header) { 41 + if (!header) return null; 42 + const hashIndex = header.indexOf('#'); 43 + if (hashIndex === -1 || hashIndex === 0 || hashIndex === header.length - 1) { 44 + return null; 45 + } 46 + return { 47 + did: header.slice(0, hashIndex), 48 + serviceId: header.slice(hashIndex + 1), 49 + }; 50 + } 51 + ``` 52 + 53 + **Step 2: Commit** 54 + 55 + ```bash 56 + git add src/pds.js 57 + git commit -m "feat: add parseAtprotoProxyHeader utility" 58 + ``` 59 + 60 + --- 61 + 62 + ### Task 2: Add getKnownServiceUrl Utility 63 + 64 + **Files:** 65 + - Modify: `src/pds.js` (after parseAtprotoProxyHeader) 66 + 67 + **Step 1: Add utility to resolve service URLs** 68 + 69 + ```javascript 70 + /** 71 + * Get URL for a known service DID 72 + * @param {string} did - Service DID (e.g., "did:web:api.bsky.app") 73 + * @param {string} serviceId - Service ID (e.g., "bsky_appview") 74 + * @returns {string | null} 75 + */ 76 + function getKnownServiceUrl(did, serviceId) { 77 + // Known Bluesky services 78 + if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') { 79 + return 'https://api.bsky.app'; 80 + } 81 + // Add more known services as needed 82 + return null; 83 + } 84 + ``` 85 + 86 + **Step 2: Commit** 87 + 88 + ```bash 89 + git add src/pds.js 90 + git commit -m "feat: add getKnownServiceUrl utility" 91 + ``` 92 + 93 + --- 94 + 95 + ### Task 3: Add proxyToService Utility 96 + 97 + **Files:** 98 + - Modify: `src/pds.js` (after getKnownServiceUrl) 99 + 100 + **Step 1: Add the proxy utility function** 101 + 102 + ```javascript 103 + /** 104 + * Proxy a request to a service 105 + * @param {Request} request - Original request 106 + * @param {string} serviceUrl - Target service URL (e.g., "https://api.bsky.app") 107 + * @param {string} [authHeader] - Optional Authorization header 108 + * @returns {Promise<Response>} 109 + */ 110 + async function proxyToService(request, serviceUrl, authHeader) { 111 + const url = new URL(request.url); 112 + const targetUrl = new URL(url.pathname + url.search, serviceUrl); 113 + 114 + const headers = new Headers(); 115 + if (authHeader) { 116 + headers.set('Authorization', authHeader); 117 + } 118 + headers.set( 119 + 'Content-Type', 120 + request.headers.get('Content-Type') || 'application/json', 121 + ); 122 + const acceptHeader = request.headers.get('Accept'); 123 + if (acceptHeader) { 124 + headers.set('Accept', acceptHeader); 125 + } 126 + const acceptLangHeader = request.headers.get('Accept-Language'); 127 + if (acceptLangHeader) { 128 + headers.set('Accept-Language', acceptLangHeader); 129 + } 130 + // Forward atproto-specific headers 131 + const labelersHeader = request.headers.get('atproto-accept-labelers'); 132 + if (labelersHeader) { 133 + headers.set('atproto-accept-labelers', labelersHeader); 134 + } 135 + const topicsHeader = request.headers.get('x-bsky-topics'); 136 + if (topicsHeader) { 137 + headers.set('x-bsky-topics', topicsHeader); 138 + } 139 + 140 + try { 141 + const response = await fetch(targetUrl.toString(), { 142 + method: request.method, 143 + headers, 144 + body: 145 + request.method !== 'GET' && request.method !== 'HEAD' 146 + ? request.body 147 + : undefined, 148 + }); 149 + const responseHeaders = new Headers(response.headers); 150 + responseHeaders.set('Access-Control-Allow-Origin', '*'); 151 + return new Response(response.body, { 152 + status: response.status, 153 + statusText: response.statusText, 154 + headers: responseHeaders, 155 + }); 156 + } catch (err) { 157 + const message = err instanceof Error ? err.message : String(err); 158 + return errorResponse('UpstreamFailure', `Failed to reach service: ${message}`, 502); 159 + } 160 + } 161 + ``` 162 + 163 + **Step 2: Commit** 164 + 165 + ```bash 166 + git add src/pds.js 167 + git commit -m "feat: add proxyToService utility" 168 + ``` 169 + 170 + --- 171 + 172 + ### Task 4: Add isLocalDid Helper 173 + 174 + **Files:** 175 + - Modify: `src/pds.js` (after proxyToService) 176 + 177 + **Step 1: Add helper to check if DID is registered locally** 178 + 179 + ```javascript 180 + /** 181 + * Check if a DID is registered on this PDS 182 + * @param {Env} env 183 + * @param {string} did 184 + * @returns {Promise<boolean>} 185 + */ 186 + async function isLocalDid(env, did) { 187 + const defaultPds = getDefaultPds(env); 188 + const res = await defaultPds.fetch( 189 + new Request('http://internal/get-registered-dids'), 190 + ); 191 + if (!res.ok) return false; 192 + const { dids } = await res.json(); 193 + return dids.includes(did); 194 + } 195 + ``` 196 + 197 + **Step 2: Commit** 198 + 199 + ```bash 200 + git add src/pds.js 201 + git commit -m "feat: add isLocalDid helper" 202 + ``` 203 + 204 + --- 205 + 206 + ### Task 5: Refactor handleAppViewProxy to Use proxyToService 207 + 208 + **Files:** 209 + - Modify: `src/pds.js:2725-2782` (handleAppViewProxy in PersonalDataServer class) 210 + 211 + **Step 1: Refactor the method** 212 + 213 + Replace with: 214 + 215 + ```javascript 216 + /** 217 + * @param {Request} request 218 + * @param {string} userDid 219 + */ 220 + async handleAppViewProxy(request, userDid) { 221 + const url = new URL(request.url); 222 + const lxm = url.pathname.replace('/xrpc/', ''); 223 + const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm); 224 + return proxyToService(request, 'https://api.bsky.app', `Bearer ${serviceJwt}`); 225 + } 226 + ``` 227 + 228 + **Step 2: Run existing tests** 229 + 230 + ```bash 231 + npm test 232 + ``` 233 + 234 + Expected: All tests pass 235 + 236 + **Step 3: Commit** 237 + 238 + ```bash 239 + git add src/pds.js 240 + git commit -m "refactor: simplify handleAppViewProxy using proxyToService" 241 + ``` 242 + 243 + --- 244 + 245 + ### Task 6: Handle Foreign Repo with atproto-proxy Support in Worker Routing 246 + 247 + **Files:** 248 + - Modify: `src/pds.js` in `handleRequest` function (around line 5199) 249 + 250 + **Step 1: Update repo endpoints routing to match official PDS behavior** 251 + 252 + Find the repo endpoints routing block and REPLACE the entire block. 253 + 254 + Order of operations (matches official PDS): 255 + 1. Check if repo is local โ†’ return local data 256 + 2. If foreign โ†’ check atproto-proxy header for specific service 257 + 3. If no header โ†’ default to AppView 258 + 259 + ```javascript 260 + // Repo endpoints use ?repo= param instead of ?did= 261 + if ( 262 + url.pathname === '/xrpc/com.atproto.repo.describeRepo' || 263 + url.pathname === '/xrpc/com.atproto.repo.listRecords' || 264 + url.pathname === '/xrpc/com.atproto.repo.getRecord' 265 + ) { 266 + const repo = url.searchParams.get('repo'); 267 + if (!repo) { 268 + return errorResponse('InvalidRequest', 'missing repo param', 400); 269 + } 270 + 271 + // Check if this is a local DID - if so, handle locally 272 + const isLocal = await isLocalDid(env, repo); 273 + if (isLocal) { 274 + const id = env.PDS.idFromName(repo); 275 + const pds = env.PDS.get(id); 276 + return pds.fetch(request); 277 + } 278 + 279 + // Foreign DID - check for atproto-proxy header 280 + const proxyHeader = request.headers.get('atproto-proxy'); 281 + if (proxyHeader) { 282 + const parsed = parseAtprotoProxyHeader(proxyHeader); 283 + if (parsed) { 284 + const serviceUrl = getKnownServiceUrl(parsed.did, parsed.serviceId); 285 + if (serviceUrl) { 286 + return proxyToService(request, serviceUrl); 287 + } 288 + // Unknown service - could add DID resolution here in the future 289 + return errorResponse('InvalidRequest', `Unknown proxy service: ${proxyHeader}`, 400); 290 + } 291 + } 292 + 293 + // No header - default to AppView 294 + return proxyToService(request, 'https://api.bsky.app'); 295 + } 296 + ``` 297 + 298 + **Step 2: Run existing tests** 299 + 300 + ```bash 301 + npm test 302 + ``` 303 + 304 + Expected: All tests pass 305 + 306 + **Step 3: Commit** 307 + 308 + ```bash 309 + git add src/pds.js 310 + git commit -m "feat: handle atproto-proxy header and foreign repo proxying" 311 + ``` 312 + 313 + --- 314 + 315 + ### Task 7: Add E2E Tests 316 + 317 + **Files:** 318 + - Modify: `test/e2e.test.js` 319 + 320 + **Step 1: Add tests for proxy functionality** 321 + 322 + Add a new describe block: 323 + 324 + ```javascript 325 + describe('Foreign DID proxying', () => { 326 + it('proxies to AppView when atproto-proxy header present', async () => { 327 + // Use a known public post from Bluesky (bsky.app official account) 328 + const res = await fetch( 329 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 330 + { 331 + headers: { 332 + 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview', 333 + }, 334 + }, 335 + ); 336 + // Should get response from AppView, not local 404 337 + assert.ok( 338 + res.status === 200 || res.status === 400, 339 + `Expected 200 or 400 from AppView, got ${res.status}`, 340 + ); 341 + }); 342 + 343 + it('proxies to AppView for foreign repo without header', async () => { 344 + // Foreign DID without atproto-proxy header - should still proxy 345 + const res = await fetch( 346 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 347 + ); 348 + // Should get response from AppView, not local 404 349 + assert.ok( 350 + res.status === 200 || res.status === 400, 351 + `Expected 200 or 400 from AppView, got ${res.status}`, 352 + ); 353 + }); 354 + 355 + it('returns error for unknown proxy service', async () => { 356 + const res = await fetch( 357 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 358 + { 359 + headers: { 360 + 'atproto-proxy': 'did:web:unknown.service#unknown', 361 + }, 362 + }, 363 + ); 364 + assert.strictEqual(res.status, 400); 365 + const data = await res.json(); 366 + assert.ok(data.message.includes('Unknown proxy service')); 367 + }); 368 + 369 + it('returns local record for local DID without proxy header', async () => { 370 + // Create a record first 371 + const { data: created } = await jsonPost( 372 + '/xrpc/com.atproto.repo.createRecord', 373 + { 374 + repo: DID, 375 + collection: 'app.bsky.feed.post', 376 + record: { 377 + $type: 'app.bsky.feed.post', 378 + text: 'Test post for local DID test', 379 + createdAt: new Date().toISOString(), 380 + }, 381 + }, 382 + { Authorization: `Bearer ${token}` }, 383 + ); 384 + 385 + // Fetch without proxy header - should get local record 386 + const rkey = created.uri.split('/').pop(); 387 + const res = await fetch( 388 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`, 389 + ); 390 + assert.strictEqual(res.status, 200); 391 + const data = await res.json(); 392 + assert.ok(data.value.text.includes('Test post for local DID test')); 393 + }); 394 + 395 + it('describeRepo proxies for foreign DID', async () => { 396 + const res = await fetch( 397 + `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`, 398 + ); 399 + // Should get response from AppView 400 + assert.ok(res.status === 200 || res.status === 400); 401 + }); 402 + 403 + it('listRecords proxies for foreign DID', async () => { 404 + const res = await fetch( 405 + `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`, 406 + ); 407 + // Should get response from AppView 408 + assert.ok(res.status === 200 || res.status === 400); 409 + }); 410 + }); 411 + ``` 412 + 413 + **Step 2: Run the tests** 414 + 415 + ```bash 416 + npm test 417 + ``` 418 + 419 + Expected: All tests pass 420 + 421 + **Step 3: Commit** 422 + 423 + ```bash 424 + git add test/e2e.test.js 425 + git commit -m "test: add e2e tests for foreign DID proxying" 426 + ``` 427 + 428 + --- 429 + 430 + ### Task 8: Manual Verification 431 + 432 + **Step 1: Deploy to dev** 433 + 434 + ```bash 435 + npx wrangler deploy 436 + ``` 437 + 438 + **Step 2: Test with the original failing curl (with header)** 439 + 440 + ```bash 441 + curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=did%3Aplc%3Abcgltzqazw5tb6k2g3ttenbj&rkey=3mbx6iyfqps2c' \ 442 + -H 'atproto-proxy: did:web:api.bsky.app#bsky_appview' 443 + ``` 444 + 445 + Expected: Returns post data from AppView 446 + 447 + **Step 3: Test without header (foreign repo detection)** 448 + 449 + ```bash 450 + curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=did%3Aplc%3Abcgltzqazw5tb6k2g3ttenbj&rkey=3mbx6iyfqps2c' 451 + ``` 452 + 453 + Expected: Also returns post data from AppView (detected as foreign DID) 454 + 455 + **Step 4: Test replying to a post in Bluesky client** 456 + 457 + Verify the original issue is fixed. 458 + 459 + --- 460 + 461 + ## Future Enhancements 462 + 463 + 1. **Service auth for proxied requests** - Add service JWT when proxying authenticated requests 464 + 2. **DID resolution** - Resolve unknown DIDs to find their service endpoints dynamically 465 + 3. **Caching** - Cache registered DIDs list to avoid repeated lookups 466 + 467 + --- 468 + 469 + ## Summary 470 + 471 + | Task | Description | 472 + |------|-------------| 473 + | 1 | Add `parseAtprotoProxyHeader` utility | 474 + | 2 | Add `getKnownServiceUrl` utility | 475 + | 3 | Add `proxyToService` utility | 476 + | 4 | Add `isLocalDid` helper | 477 + | 5 | Refactor `handleAppViewProxy` to use shared utility | 478 + | 6 | Handle `atproto-proxy` header AND foreign `repo` param | 479 + | 7 | Add e2e tests | 480 + | 8 | Manual verification |
+146
docs/scope-comparison.md
··· 1 + # Scope Validation Comparison: pds.js vs atproto PDS 2 + 3 + Comparison of OAuth scope validation between this implementation and the official AT Protocol PDS. 4 + 5 + --- 6 + 7 + ## Scope Types Supported 8 + 9 + | Scope Type | Format | pds.js | atproto PDS | 10 + |------------|--------|--------|-------------| 11 + | `atproto` | Static | Full access | Required for all OAuth | 12 + | `transition:generic` | Static | Full access | Full repo/blob bypass | 13 + | `transition:email` | Static | N/A | Read account email | 14 + | `transition:chat.bsky` | Static | N/A | Chat RPC access | 15 + | `repo:<collection>?action=<action>` | Granular | Full parsing + enforcement | Full parsing + enforcement | 16 + | `blob:<mime>` | Granular | Full parsing + enforcement | Full parsing + enforcement | 17 + | `rpc:<aud>:<lxm>` | Granular | Not implemented | Full parsing + enforcement | 18 + 19 + --- 20 + 21 + ## Scope Enforcement by Endpoint 22 + 23 + ### com.atproto.repo.createRecord 24 + 25 + | Aspect | pds.js | atproto PDS | 26 + |--------|--------|-------------| 27 + | Scope check | `ScopePermissions.allowsRepo(collection, 'create')` | `permissions.assertRepo({ action: 'create', collection })` | 28 + | Required scope | `repo:<collection>?action=create` or `transition:generic` or `atproto` | `repo:<collection>?action=create` or `transition:generic` or `atproto` | 29 + | OAuth-only check | Yes (legacy tokens without scope bypass) | Yes (legacy Bearer bypasses) | 30 + | Error response | 403 "Missing required scope \"repo:...?action=...\"" | 403 "Missing required scope \"repo:...?action=...\"" | 31 + 32 + ### com.atproto.repo.putRecord 33 + 34 + | Aspect | pds.js | atproto PDS | 35 + |--------|--------|-------------| 36 + | Scope check | `allowsRepo(collection, 'create')` AND `allowsRepo(collection, 'update')` | `assertRepo({ action: 'create' })` AND `assertRepo({ action: 'update' })` | 37 + | Required scope | `repo:<collection>?action=create&action=update` | `repo:<collection>?action=create&action=update` | 38 + | Notes | Requires both since putRecord can create or update | Requires both since putRecord can create or update | 39 + 40 + ### com.atproto.repo.deleteRecord 41 + 42 + | Aspect | pds.js | atproto PDS | 43 + |--------|--------|-------------| 44 + | Scope check | `ScopePermissions.allowsRepo(collection, 'delete')` | `permissions.assertRepo({ action: 'delete', collection })` | 45 + | Required scope | `repo:<collection>?action=delete` | `repo:<collection>?action=delete` | 46 + 47 + ### com.atproto.repo.applyWrites 48 + 49 + | Aspect | pds.js | atproto PDS | 50 + |--------|--------|-------------| 51 + | Scope check | Iterates all writes, checks each unique action/collection pair | Iterates all writes, asserts each unique action/collection pair | 52 + | Required scope | All `repo:<collection>?action=<action>` for each write | All `repo:<collection>?action=<action>` for each write | 53 + | Per-write validation | Yes | Yes | 54 + 55 + ### com.atproto.repo.uploadBlob 56 + 57 + | Aspect | pds.js | atproto PDS | 58 + |--------|--------|-------------| 59 + | Scope check | `ScopePermissions.allowsBlob(contentType)` | `permissions.assertBlob({ mime: encoding })` | 60 + | Required scope | `blob:<mime-type>` (e.g., `blob:image/*`) | `blob:<mime-type>` (e.g., `blob:image/*`) | 61 + | MIME type awareness | Yes (validates against Content-Type) | Yes (validates against Content-Type) | 62 + 63 + ### app.bsky.actor.getPreferences 64 + 65 + | Aspect | pds.js | atproto PDS | 66 + |--------|--------|-------------| 67 + | Scope check | Requires auth only | `permissions.assertRpc({ aud, lxm })` | 68 + | Required scope | Any valid auth | `rpc:app.bsky.actor.getPreferences` | 69 + 70 + ### app.bsky.actor.putPreferences 71 + 72 + | Aspect | pds.js | atproto PDS | 73 + |--------|--------|-------------| 74 + | Scope check | Requires auth only | `permissions.assertRpc({ aud, lxm })` | 75 + | Required scope | Any valid auth | `rpc:app.bsky.actor.putPreferences` | 76 + 77 + --- 78 + 79 + ## Scope Parsing 80 + 81 + | Feature | pds.js | atproto PDS | 82 + |---------|--------|-------------| 83 + | Scope string splitting | `scope.split(' ')` | `ScopesSet` class | 84 + | Repo scope parsing | `parseRepoScope()` | `RepoPermission.fromString()` | 85 + | Repo scope format | `repo:collection?action=create&action=update` | `repo:collection?action=create&action=update` | 86 + | Blob scope parsing | `parseBlobScope()` | `BlobPermission.fromString()` | 87 + | RPC scope parsing | None | `RpcPermission.fromString()` | 88 + | Scope validation | Returns null for invalid | Validates syntax, ignores invalid | 89 + | Action deduplication | Yes (via Set) | Yes | 90 + | Default actions | All (create, update, delete) when no `?action=` | All (create, update, delete) when no `?action=` | 91 + 92 + --- 93 + 94 + ## Permission Checking 95 + 96 + | Feature | pds.js | atproto PDS | 97 + |---------|--------|-------------| 98 + | Permission class | `ScopePermissions` | `ScopePermissions` / `ScopePermissionsTransition` | 99 + | `allowsRepo(collection, action)` | Yes | Yes | 100 + | `allowsBlob(mime)` | Yes (with MIME wildcard matching) | Yes (with MIME wildcard matching) | 101 + | `allowsRpc(aud, lxm)` | N/A | Yes | 102 + | Transition scope handling | `transition:generic` bypasses repo/blob checks | `transition:generic` bypasses repo/blob checks | 103 + | Error messages | Specific missing scope in error | Specific missing scope in error | 104 + 105 + --- 106 + 107 + ## OAuth Flow 108 + 109 + | Feature | pds.js | atproto PDS | 110 + |---------|--------|-------------| 111 + | `scopes_supported` in metadata | `['atproto']` | `['atproto']` (but accepts granular) | 112 + | Scope validation at PAR | None | Validates syntax | 113 + | Scope stored in token | Yes | Yes | 114 + | Scope returned in token response | Yes | Yes | 115 + | `atproto` scope required | Checked at endpoints | Required at token verification | 116 + 117 + --- 118 + 119 + ## Transition Scope Behavior 120 + 121 + | Scope | pds.js | atproto PDS | 122 + |-------|--------|-------------| 123 + | `transition:generic` | Bypasses all repo/blob permission checks | Bypasses ALL repo/blob permission checks | 124 + | `transition:chat.bsky` | Not implemented | Allows `chat.bsky.*` RPC methods | 125 + | `transition:email` | Not implemented | Allows `account:email:read` | 126 + 127 + --- 128 + 129 + ## Summary 130 + 131 + | Category | pds.js | atproto PDS | 132 + |----------|--------|-------------| 133 + | Scope parsing | Full parser for repo/blob | Full parser per scope type | 134 + | Enforcement granularity | Per-collection, per-action | Per-collection, per-action | 135 + | Transition scope support | `transition:generic` only | Full | 136 + | MIME-aware blob scopes | Yes | Yes | 137 + | RPC scopes | No | Yes | 138 + | Error specificity | Names missing scope | Names missing scope | 139 + 140 + --- 141 + 142 + ## Remaining Gaps 143 + 144 + 1. **RPC scopes** โ€” `rpc:<aud>:<lxm>` parsing and enforcement not implemented 145 + 2. **Additional transition scopes** โ€” `transition:chat.bsky` and `transition:email` not implemented 146 + 3. **Scope validation at PAR** โ€” Could validate scope syntax during authorization request
+1 -1
package.json
··· 1 1 { 2 2 "name": "pds.js", 3 - "version": "0.2.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}`);
+883 -225
src/pds.js
··· 31 31 // โ•‘ Environment bindings, SQL row types, protocol constants โ•‘ 32 32 // โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 33 33 34 + // PDS version (keep in sync with package.json) 35 + const VERSION = '0.5.0'; 36 + 34 37 // CBOR primitive markers (RFC 8949) 35 38 const CBOR_FALSE = 0xf4; 36 39 const CBOR_TRUE = 0xf5; ··· 57 60 // Crawler notification throttle 58 61 const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000; // 20 minutes (matches official PDS) 59 62 let lastCrawlNotify = 0; 63 + 64 + // Default Bluesky AppView URL 65 + const BSKY_APPVIEW_URL = 'https://api.bsky.app'; 60 66 61 67 /** 62 68 * Cloudflare Workers environment bindings ··· 172 178 */ 173 179 function errorResponse(error, message, status) { 174 180 return Response.json({ error, message }, { status }); 181 + } 182 + 183 + /** 184 + * Parse atproto-proxy header to get service DID and service ID 185 + * Format: "did:web:api.bsky.app#bsky_appview" 186 + * @param {string} header 187 + * @returns {{ did: string, serviceId: string } | null} 188 + */ 189 + export function parseAtprotoProxyHeader(header) { 190 + if (!header) return null; 191 + const hashIndex = header.indexOf('#'); 192 + if (hashIndex === -1 || hashIndex === 0 || hashIndex === header.length - 1) { 193 + return null; 194 + } 195 + return { 196 + did: header.slice(0, hashIndex), 197 + serviceId: header.slice(hashIndex + 1), 198 + }; 199 + } 200 + 201 + /** 202 + * Get URL for a known service DID 203 + * @param {string} did - Service DID (e.g., "did:web:api.bsky.app") 204 + * @param {string} serviceId - Service ID (e.g., "bsky_appview") 205 + * @returns {string | null} 206 + */ 207 + export function getKnownServiceUrl(did, serviceId) { 208 + // Known Bluesky services 209 + if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') { 210 + return BSKY_APPVIEW_URL; 211 + } 212 + // Add more known services as needed 213 + return null; 214 + } 215 + 216 + /** 217 + * Proxy a request to a service 218 + * @param {Request} request - Original request 219 + * @param {string} serviceUrl - Target service URL (e.g., "https://api.bsky.app") 220 + * @param {string} [authHeader] - Optional Authorization header 221 + * @returns {Promise<Response>} 222 + */ 223 + async function proxyToService(request, serviceUrl, authHeader) { 224 + const url = new URL(request.url); 225 + const targetUrl = new URL(url.pathname + url.search, serviceUrl); 226 + 227 + const headers = new Headers(); 228 + if (authHeader) { 229 + headers.set('Authorization', authHeader); 230 + } 231 + headers.set( 232 + 'Content-Type', 233 + request.headers.get('Content-Type') || 'application/json', 234 + ); 235 + const acceptHeader = request.headers.get('Accept'); 236 + if (acceptHeader) { 237 + headers.set('Accept', acceptHeader); 238 + } 239 + const acceptLangHeader = request.headers.get('Accept-Language'); 240 + if (acceptLangHeader) { 241 + headers.set('Accept-Language', acceptLangHeader); 242 + } 243 + // Forward atproto-specific headers 244 + const labelersHeader = request.headers.get('atproto-accept-labelers'); 245 + if (labelersHeader) { 246 + headers.set('atproto-accept-labelers', labelersHeader); 247 + } 248 + const topicsHeader = request.headers.get('x-bsky-topics'); 249 + if (topicsHeader) { 250 + headers.set('x-bsky-topics', topicsHeader); 251 + } 252 + 253 + try { 254 + const response = await fetch(targetUrl.toString(), { 255 + method: request.method, 256 + headers, 257 + body: 258 + request.method !== 'GET' && request.method !== 'HEAD' 259 + ? request.body 260 + : undefined, 261 + }); 262 + const responseHeaders = new Headers(response.headers); 263 + responseHeaders.set('Access-Control-Allow-Origin', '*'); 264 + return new Response(response.body, { 265 + status: response.status, 266 + statusText: response.statusText, 267 + headers: responseHeaders, 268 + }); 269 + } catch (err) { 270 + const message = err instanceof Error ? err.message : String(err); 271 + return errorResponse( 272 + 'UpstreamFailure', 273 + `Failed to reach service: ${message}`, 274 + 502, 275 + ); 276 + } 175 277 } 176 278 177 279 /** ··· 575 677 576 678 return { jkt, jti: payload.jti, iat: payload.iat, jwk: header.jwk }; 577 679 } 578 - /** 579 - * Render the OAuth consent page HTML. 580 - * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params 581 - * @returns {string} HTML page content 582 - */ 583 - function renderConsentPage({ 584 - clientName, 585 - clientId, 586 - scope, 587 - requestUri, 588 - error = '', 589 - }) { 590 - /** @param {string} s */ 591 - const escHtml = (s) => 592 - s 593 - .replace(/&/g, '&amp;') 594 - .replace(/</g, '&lt;') 595 - .replace(/>/g, '&gt;') 596 - .replace(/"/g, '&quot;'); 597 - return `<!DOCTYPE html> 598 - <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 599 - <title>Authorize</title> 600 - <style> 601 - *{box-sizing:border-box} 602 - body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0} 603 - h2{color:#fff;margin-bottom:24px} 604 - p{color:#b0b0b0;line-height:1.5} 605 - b{color:#fff} 606 - .error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020} 607 - label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px} 608 - input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px} 609 - input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)} 610 - .actions{display:flex;gap:12px;margin-top:24px} 611 - button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s} 612 - .deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040} 613 - .deny:hover{background:#333} 614 - .approve{background:#2563eb;color:#fff;border:none} 615 - .approve:hover{background:#1d4ed8} 616 - </style></head> 617 - <body><h2>Sign in to authorize</h2> 618 - <p><b>${escHtml(clientName)}</b> wants to access your account.</p> 619 - <p>Scope: ${escHtml(scope)}</p> 620 - ${error ? `<p class="error">${escHtml(error)}</p>` : ''} 621 - <form method="POST" action="/oauth/authorize"> 622 - <input type="hidden" name="request_uri" value="${escHtml(requestUri)}"> 623 - <input type="hidden" name="client_id" value="${escHtml(clientId)}"> 624 - <label>Password</label><input type="password" name="password" required autofocus> 625 - <div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 626 - <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 627 - </form></body></html>`; 628 - } 629 680 630 681 /** 631 682 * Encode integer as unsigned varint ··· 744 795 * @param {*} value 745 796 * @returns {Uint8Array} 746 797 */ 747 - function cborEncodeDagCbor(value) { 798 + export function cborEncodeDagCbor(value) { 748 799 /** @type {number[]} */ 749 800 const parts = []; 750 801 ··· 2772 2823 */ 2773 2824 async handleAppViewProxy(request, userDid) { 2774 2825 const url = new URL(request.url); 2775 - // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences 2776 2826 const lxm = url.pathname.replace('/xrpc/', ''); 2777 - 2778 - // Create service auth JWT 2779 2827 const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm); 2780 - 2781 - // Build AppView URL 2782 - const appViewUrl = new URL( 2783 - url.pathname + url.search, 2784 - 'https://api.bsky.app', 2785 - ); 2786 - 2787 - // Forward request with service auth 2788 - const headers = new Headers(); 2789 - headers.set('Authorization', `Bearer ${serviceJwt}`); 2790 - headers.set( 2791 - 'Content-Type', 2792 - request.headers.get('Content-Type') || 'application/json', 2793 - ); 2794 - const acceptHeader = request.headers.get('Accept'); 2795 - if (acceptHeader) { 2796 - headers.set('Accept', acceptHeader); 2797 - } 2798 - const acceptLangHeader = request.headers.get('Accept-Language'); 2799 - if (acceptLangHeader) { 2800 - headers.set('Accept-Language', acceptLangHeader); 2801 - } 2802 - 2803 - const proxyReq = new Request(appViewUrl.toString(), { 2804 - method: request.method, 2805 - headers, 2806 - body: 2807 - request.method !== 'GET' && request.method !== 'HEAD' 2808 - ? request.body 2809 - : undefined, 2810 - }); 2811 - 2812 - try { 2813 - const response = await fetch(proxyReq); 2814 - // Return the response with CORS headers 2815 - const responseHeaders = new Headers(response.headers); 2816 - responseHeaders.set('Access-Control-Allow-Origin', '*'); 2817 - return new Response(response.body, { 2818 - status: response.status, 2819 - statusText: response.statusText, 2820 - headers: responseHeaders, 2821 - }); 2822 - } catch (err) { 2823 - const message = err instanceof Error ? err.message : String(err); 2824 - return errorResponse( 2825 - 'UpstreamFailure', 2826 - `Failed to reach AppView: ${message}`, 2827 - 502, 2828 - ); 2829 - } 2828 + return proxyToService(request, BSKY_APPVIEW_URL, `Bearer ${serviceJwt}`); 2830 2829 } 2831 2830 2832 2831 async handleListRepos() { ··· 3241 3240 3242 3241 /** @param {Request} request */ 3243 3242 async handleUploadBlob(request) { 3244 - // Require auth 3245 - const authHeader = request.headers.get('Authorization'); 3246 - if (!authHeader || !authHeader.startsWith('Bearer ')) { 3247 - return errorResponse( 3248 - 'AuthRequired', 3249 - 'Missing or invalid authorization header', 3250 - 401, 3251 - ); 3252 - } 3243 + // Check if auth was already done by outer handler (OAuth/DPoP flow) 3244 + const authedDid = request.headers.get('x-authed-did'); 3245 + if (!authedDid) { 3246 + // Fallback to legacy Bearer token auth 3247 + const authHeader = request.headers.get('Authorization'); 3248 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 3249 + return errorResponse( 3250 + 'AuthRequired', 3251 + 'Missing or invalid authorization header', 3252 + 401, 3253 + ); 3254 + } 3253 3255 3254 - const token = authHeader.slice(7); 3255 - const jwtSecret = this.env?.JWT_SECRET; 3256 - if (!jwtSecret) { 3257 - return errorResponse( 3258 - 'InternalServerError', 3259 - 'Server not configured for authentication', 3260 - 500, 3261 - ); 3262 - } 3256 + const token = authHeader.slice(7); 3257 + const jwtSecret = this.env?.JWT_SECRET; 3258 + if (!jwtSecret) { 3259 + return errorResponse( 3260 + 'InternalServerError', 3261 + 'Server not configured for authentication', 3262 + 500, 3263 + ); 3264 + } 3263 3265 3264 - try { 3265 - await verifyAccessJwt(token, jwtSecret); 3266 - } catch (err) { 3267 - const message = err instanceof Error ? err.message : String(err); 3268 - return errorResponse('InvalidToken', message, 401); 3266 + try { 3267 + await verifyAccessJwt(token, jwtSecret); 3268 + } catch (err) { 3269 + const message = err instanceof Error ? err.message : String(err); 3270 + return errorResponse('InvalidToken', message, 401); 3271 + } 3269 3272 } 3270 3273 3271 3274 const did = await this.getDid(); ··· 3689 3692 code_challenge_methods_supported: ['S256'], 3690 3693 token_endpoint_auth_methods_supported: ['none'], 3691 3694 dpop_signing_alg_values_supported: ['ES256'], 3692 - require_pushed_authorization_requests: true, 3695 + require_pushed_authorization_requests: false, 3693 3696 authorization_response_iss_parameter_supported: true, 3694 3697 client_id_metadata_document_supported: true, 3695 3698 protected_resources: [issuer], ··· 3725 3728 } 3726 3729 3727 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 + /** 3728 3810 * Handle Pushed Authorization Request (PAR) endpoint. 3729 3811 * Validates DPoP proof, client metadata, PKCE parameters, and stores the authorization request. 3730 3812 * @param {Request} request - The incoming request ··· 3764 3846 const codeChallengeMethod = data.code_challenge_method; 3765 3847 const loginHint = data.login_hint; 3766 3848 3767 - if (!clientId) 3768 - return errorResponse('invalid_request', 'client_id required', 400); 3769 - if (!redirectUri) 3770 - return errorResponse('invalid_request', 'redirect_uri required', 400); 3771 - if (responseType !== 'code') 3772 - return errorResponse( 3773 - 'unsupported_response_type', 3774 - 'response_type must be code', 3775 - 400, 3776 - ); 3777 - if (!codeChallenge || codeChallengeMethod !== 'S256') { 3778 - return errorResponse('invalid_request', 'PKCE with S256 required', 400); 3779 - } 3780 - 3781 - let clientMetadata; 3782 - try { 3783 - clientMetadata = await getClientMetadata(clientId); 3784 - } catch (err) { 3785 - return errorResponse('invalid_client', err.message, 400); 3786 - } 3787 - 3788 - // Validate redirect_uri against registered URIs 3789 - // For loopback clients (RFC 8252), allow any path on the same origin 3790 - const isLoopback = 3791 - clientId.startsWith('http://localhost') || 3792 - clientId.startsWith('http://127.0.0.1'); 3793 - const redirectUriValid = clientMetadata.redirect_uris.some((uri) => { 3794 - if (isLoopback) { 3795 - // For loopback, check origin match (any path allowed) 3796 - try { 3797 - const registered = new URL(uri); 3798 - const requested = new URL(redirectUri); 3799 - return registered.origin === requested.origin; 3800 - } catch { 3801 - return false; 3802 - } 3803 - } 3804 - return uri === redirectUri; 3849 + // Use shared validation 3850 + const validationResult = await this.validateAuthorizationParameters({ 3851 + clientId, 3852 + redirectUri, 3853 + responseType, 3854 + codeChallenge, 3855 + codeChallengeMethod, 3805 3856 }); 3806 - if (!redirectUriValid) { 3807 - return errorResponse( 3808 - 'invalid_request', 3809 - 'redirect_uri not registered for this client', 3810 - 400, 3811 - ); 3812 - } 3857 + if ('error' in validationResult) return validationResult.error; 3858 + const { clientMetadata } = validationResult; 3813 3859 3814 3860 const requestId = crypto.randomUUID(); 3815 3861 const requestUri = `urn:ietf:params:oauth:request_uri:${requestId}`; ··· 3859 3905 3860 3906 /** 3861 3907 * Handle GET /oauth/authorize - displays the consent UI. 3862 - * Validates the request_uri from PAR and renders a login/consent form. 3908 + * Supports both PAR (request_uri) and direct authorization parameters. 3863 3909 * @param {URL} url - Parsed request URL 3864 3910 * @returns {Promise<Response>} HTML consent page 3865 3911 */ 3866 3912 async handleOAuthAuthorizeGet(url) { 3913 + // Opportunistically clean up expired authorization requests 3914 + this.cleanupExpiredAuthorizationRequests(); 3915 + 3867 3916 const requestUri = url.searchParams.get('request_uri'); 3868 3917 const clientId = url.searchParams.get('client_id'); 3869 3918 3870 - if (!requestUri || !clientId) { 3871 - 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 + ); 3872 3966 } 3873 3967 3874 - const match = requestUri.match(/^urn:ietf:params:oauth:request_uri:(.+)$/); 3875 - 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 + } 3876 3972 3877 - const rows = this.sql 3878 - .exec( 3879 - `SELECT * FROM authorization_requests WHERE id = ? AND client_id = ?`, 3880 - match[1], 3881 - clientId, 3882 - ) 3883 - .toArray(); 3884 - 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'); 3885 3981 3886 - if (!authRequest) return new Response('Request not found', { status: 400 }); 3887 - if (new Date(/** @type {string} */ (authRequest.expires_at)) < new Date()) 3888 - return new Response('Request expired', { status: 400 }); 3889 - if (authRequest.code) 3890 - return new Response('Request already used', { status: 400 }); 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; 3992 + 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(); 3891 3998 3892 - const clientMetadata = JSON.parse( 3893 - /** @type {string} */ (authRequest.client_metadata), 3894 - ); 3895 - const parameters = JSON.parse( 3896 - /** @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(), 3897 4020 ); 3898 4021 3899 4022 return new Response( 3900 4023 renderConsentPage({ 3901 4024 clientName: clientMetadata.client_name || clientId, 3902 - clientId: clientId || '', 3903 - scope: parameters.scope || 'atproto', 3904 - requestUri: requestUri || '', 4025 + clientId: clientId, 4026 + scope: scope || 'atproto', 4027 + requestUri: newRequestUri, 4028 + loginHint: loginHint || '', 3905 4029 }), 3906 4030 { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, 3907 4031 ); ··· 4091 4215 return errorResponse('invalid_grant', 'Invalid code', 400); 4092 4216 if (authRequest.client_id !== clientId) 4093 4217 return errorResponse('invalid_grant', 'Client mismatch', 400); 4094 - 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) { 4095 4221 return errorResponse('invalid_dpop_proof', 'DPoP key mismatch', 400); 4222 + } 4096 4223 4097 4224 const parameters = JSON.parse( 4098 4225 /** @type {string} */ (authRequest.parameters), ··· 4549 4676 return { did: payload.sub, scope: payload.scope }; 4550 4677 } 4551 4678 4679 + // โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 4680 + // โ•‘ SCOPES โ•‘ 4681 + // โ•‘ OAuth scope parsing and permission checking โ•‘ 4682 + // โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 4683 + 4552 4684 /** 4553 - * Check if the token scope allows the requested operation. 4554 - * Legacy tokens (no scope) are always allowed; OAuth tokens must have 'atproto' scope. 4555 - * @param {string | undefined} scope - The token scope 4556 - * @param {string} requiredScope - The required scope (e.g., 'atproto') 4557 - * @returns {boolean} Whether the scope is sufficient 4685 + * Parse a repo scope string into collection and actions. 4686 + * Official format: repo:collection?action=create&action=update 4687 + * Or: repo?collection=foo&action=create 4688 + * Without actions defaults to all: create, update, delete 4689 + * @param {string} scope - The scope string to parse 4690 + * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid 4558 4691 */ 4559 - function hasRequiredScope(scope, requiredScope) { 4560 - // Legacy tokens without scope are trusted for all operations 4561 - if (!scope) return true; 4562 - // Check if the scope includes the required scope 4563 - const scopes = scope.split(' '); 4564 - return scopes.includes(requiredScope); 4692 + export function parseRepoScope(scope) { 4693 + if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; 4694 + 4695 + const ALL_ACTIONS = ['create', 'update', 'delete']; 4696 + let collection; 4697 + let actions; 4698 + 4699 + const questionIdx = scope.indexOf('?'); 4700 + if (questionIdx === -1) { 4701 + // repo:collection (no query params = all actions) 4702 + collection = scope.slice(5); 4703 + actions = ALL_ACTIONS; 4704 + } else { 4705 + // Parse query parameters 4706 + const queryString = scope.slice(questionIdx + 1); 4707 + const params = new URLSearchParams(queryString); 4708 + const pathPart = scope.startsWith('repo:') 4709 + ? scope.slice(5, questionIdx) 4710 + : ''; 4711 + 4712 + collection = pathPart || params.get('collection'); 4713 + actions = params.getAll('action'); 4714 + if (actions.length === 0) actions = ALL_ACTIONS; 4715 + } 4716 + 4717 + if (!collection) return null; 4718 + 4719 + // Validate actions 4720 + const validActions = [ 4721 + ...new Set(actions.filter((a) => ALL_ACTIONS.includes(a))), 4722 + ]; 4723 + if (validActions.length === 0) return null; 4724 + 4725 + return { collection, actions: validActions }; 4726 + } 4727 + 4728 + /** 4729 + * Parse a blob scope string into its components. 4730 + * Format: blob:<mime>[,<mime>...] 4731 + * @param {string} scope - The scope string to parse 4732 + * @returns {{ accept: string[] } | null} Parsed scope or null if invalid 4733 + */ 4734 + export function parseBlobScope(scope) { 4735 + if (!scope.startsWith('blob:')) return null; 4736 + 4737 + const mimeStr = scope.slice(5); // Remove 'blob:' 4738 + if (!mimeStr) return null; 4739 + 4740 + const accept = mimeStr.split(',').filter((m) => m); 4741 + if (accept.length === 0) return null; 4742 + 4743 + return { accept }; 4744 + } 4745 + 4746 + /** 4747 + * Check if a MIME pattern matches an actual MIME type. 4748 + * @param {string} pattern - MIME pattern (e.g., 'image/\*', '\*\/\*', 'image/png') 4749 + * @param {string} mime - Actual MIME type to check 4750 + * @returns {boolean} Whether the pattern matches 4751 + */ 4752 + export function matchesMime(pattern, mime) { 4753 + const p = pattern.toLowerCase(); 4754 + const m = mime.toLowerCase(); 4755 + 4756 + if (p === '*/*') return true; 4757 + 4758 + if (p.endsWith('/*')) { 4759 + const pType = p.slice(0, -2); 4760 + const mType = m.split('/')[0]; 4761 + return pType === mType; 4762 + } 4763 + 4764 + return p === m; 4765 + } 4766 + 4767 + /** 4768 + * Error thrown when a required scope is missing. 4769 + */ 4770 + class ScopeMissingError extends Error { 4771 + /** 4772 + * @param {string} scope - The missing scope 4773 + */ 4774 + constructor(scope) { 4775 + super(`Missing required scope "${scope}"`); 4776 + this.name = 'ScopeMissingError'; 4777 + this.scope = scope; 4778 + this.status = 403; 4779 + } 4780 + } 4781 + 4782 + /** 4783 + * Parses and checks OAuth scope permissions. 4784 + */ 4785 + export class ScopePermissions { 4786 + /** 4787 + * @param {string | undefined} scopeString - Space-separated scope string 4788 + */ 4789 + constructor(scopeString) { 4790 + /** @type {Set<string>} */ 4791 + this.scopes = new Set( 4792 + scopeString ? scopeString.split(' ').filter((s) => s) : [], 4793 + ); 4794 + 4795 + /** @type {Array<{ collection: string, actions: string[] }>} */ 4796 + this.repoPermissions = []; 4797 + 4798 + /** @type {Array<{ accept: string[] }>} */ 4799 + this.blobPermissions = []; 4800 + 4801 + for (const scope of this.scopes) { 4802 + const repo = parseRepoScope(scope); 4803 + if (repo) this.repoPermissions.push(repo); 4804 + 4805 + const blob = parseBlobScope(scope); 4806 + if (blob) this.blobPermissions.push(blob); 4807 + } 4808 + } 4809 + 4810 + /** 4811 + * Check if full access is granted (atproto or transition:generic). 4812 + * @returns {boolean} 4813 + */ 4814 + hasFullAccess() { 4815 + return this.scopes.has('atproto') || this.scopes.has('transition:generic'); 4816 + } 4817 + 4818 + /** 4819 + * Check if a repo operation is allowed. 4820 + * @param {string} collection - The collection NSID 4821 + * @param {string} action - The action (create, update, delete) 4822 + * @returns {boolean} 4823 + */ 4824 + allowsRepo(collection, action) { 4825 + if (this.hasFullAccess()) return true; 4826 + 4827 + for (const perm of this.repoPermissions) { 4828 + const collectionMatch = 4829 + perm.collection === '*' || perm.collection === collection; 4830 + const actionMatch = perm.actions.includes(action); 4831 + if (collectionMatch && actionMatch) return true; 4832 + } 4833 + 4834 + return false; 4835 + } 4836 + 4837 + /** 4838 + * Assert that a repo operation is allowed, throwing if not. 4839 + * @param {string} collection - The collection NSID 4840 + * @param {string} action - The action (create, update, delete) 4841 + * @throws {ScopeMissingError} 4842 + */ 4843 + assertRepo(collection, action) { 4844 + if (!this.allowsRepo(collection, action)) { 4845 + throw new ScopeMissingError(`repo:${collection}?action=${action}`); 4846 + } 4847 + } 4848 + 4849 + /** 4850 + * Check if a blob operation is allowed. 4851 + * @param {string} mime - The MIME type of the blob 4852 + * @returns {boolean} 4853 + */ 4854 + allowsBlob(mime) { 4855 + if (this.hasFullAccess()) return true; 4856 + 4857 + for (const perm of this.blobPermissions) { 4858 + for (const pattern of perm.accept) { 4859 + if (matchesMime(pattern, mime)) return true; 4860 + } 4861 + } 4862 + 4863 + return false; 4864 + } 4865 + 4866 + /** 4867 + * Assert that a blob operation is allowed, throwing if not. 4868 + * @param {string} mime - The MIME type of the blob 4869 + * @throws {ScopeMissingError} 4870 + */ 4871 + assertBlob(mime) { 4872 + if (!this.allowsBlob(mime)) { 4873 + throw new ScopeMissingError(`blob:${mime}`); 4874 + } 4875 + } 4876 + } 4877 + 4878 + // โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— 4879 + // โ•‘ CONSENT PAGE DISPLAY โ•‘ 4880 + // โ•‘ OAuth consent page rendering with scope visualization โ•‘ 4881 + // โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• 4882 + 4883 + /** 4884 + * Parse scope string into display-friendly structure. 4885 + * @param {string} scope - Space-separated scope string 4886 + * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} 4887 + */ 4888 + export function parseScopesForDisplay(scope) { 4889 + const scopes = scope.split(' ').filter((s) => s); 4890 + 4891 + const repoPermissions = new Map(); 4892 + 4893 + for (const s of scopes) { 4894 + const repo = parseRepoScope(s); 4895 + if (repo) { 4896 + const existing = repoPermissions.get(repo.collection) || { 4897 + create: false, 4898 + update: false, 4899 + delete: false, 4900 + }; 4901 + for (const action of repo.actions) { 4902 + existing[action] = true; 4903 + } 4904 + repoPermissions.set(repo.collection, existing); 4905 + } 4906 + } 4907 + 4908 + const blobPermissions = []; 4909 + for (const s of scopes) { 4910 + const blob = parseBlobScope(s); 4911 + if (blob) blobPermissions.push(...blob.accept); 4912 + } 4913 + 4914 + return { 4915 + hasAtproto: scopes.includes('atproto'), 4916 + hasTransitionGeneric: scopes.includes('transition:generic'), 4917 + repoPermissions, 4918 + blobPermissions, 4919 + }; 4920 + } 4921 + 4922 + /** 4923 + * Escape HTML special characters. 4924 + * @param {string} s 4925 + * @returns {string} 4926 + */ 4927 + function escapeHtml(s) { 4928 + return s 4929 + .replace(/&/g, '&amp;') 4930 + .replace(/</g, '&lt;') 4931 + .replace(/>/g, '&gt;') 4932 + .replace(/"/g, '&quot;'); 4933 + } 4934 + 4935 + /** 4936 + * Render repo permissions as HTML table. 4937 + * @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions 4938 + * @returns {string} HTML string 4939 + */ 4940 + function renderRepoTable(repoPermissions) { 4941 + if (repoPermissions.size === 0) return ''; 4942 + 4943 + let rows = ''; 4944 + for (const [collection, actions] of repoPermissions) { 4945 + const displayCollection = collection === '*' ? '* (any)' : collection; 4946 + rows += `<tr> 4947 + <td>${escapeHtml(displayCollection)}</td> 4948 + <td class="check">${actions.create ? 'โœ“' : ''}</td> 4949 + <td class="check">${actions.update ? 'โœ“' : ''}</td> 4950 + <td class="check">${actions.delete ? 'โœ“' : ''}</td> 4951 + </tr>`; 4952 + } 4953 + 4954 + return `<div class="permissions-section"> 4955 + <div class="section-label">Repository permissions:</div> 4956 + <table class="permissions-table"> 4957 + <thead><tr><th>Collection</th><th title="Create">C</th><th title="Update">U</th><th title="Delete">D</th></tr></thead> 4958 + <tbody>${rows}</tbody> 4959 + </table> 4960 + </div>`; 4961 + } 4962 + 4963 + /** 4964 + * Render blob permissions as HTML list. 4965 + * @param {string[]} blobPermissions 4966 + * @returns {string} HTML string 4967 + */ 4968 + function renderBlobList(blobPermissions) { 4969 + if (blobPermissions.length === 0) return ''; 4970 + 4971 + const items = blobPermissions 4972 + .map( 4973 + (mime) => 4974 + `<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`, 4975 + ) 4976 + .join(''); 4977 + 4978 + return `<div class="permissions-section"> 4979 + <div class="section-label">Upload permissions:</div> 4980 + <ul class="blob-list">${items}</ul> 4981 + </div>`; 4982 + } 4983 + 4984 + /** 4985 + * Render full permissions display based on parsed scopes. 4986 + * @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} parsed 4987 + * @returns {string} HTML string 4988 + */ 4989 + function renderPermissionsHtml(parsed) { 4990 + if (parsed.hasTransitionGeneric) { 4991 + return `<div class="warning">โš ๏ธ Full repository access requested<br> 4992 + <small>This app can create, update, and delete any data in your repository.</small></div>`; 4993 + } 4994 + 4995 + if ( 4996 + parsed.repoPermissions.size === 0 && 4997 + parsed.blobPermissions.length === 0 4998 + ) { 4999 + return ''; 5000 + } 5001 + 5002 + return ( 5003 + renderRepoTable(parsed.repoPermissions) + 5004 + renderBlobList(parsed.blobPermissions) 5005 + ); 5006 + } 5007 + 5008 + /** 5009 + * Render the OAuth consent page HTML. 5010 + * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, loginHint?: string, error?: string }} params 5011 + * @returns {string} HTML page content 5012 + */ 5013 + function renderConsentPage({ 5014 + clientName, 5015 + clientId, 5016 + scope, 5017 + requestUri, 5018 + loginHint = '', 5019 + error = '', 5020 + }) { 5021 + const parsed = parseScopesForDisplay(scope); 5022 + const isIdentityOnly = 5023 + parsed.repoPermissions.size === 0 && 5024 + parsed.blobPermissions.length === 0 && 5025 + !parsed.hasTransitionGeneric; 5026 + 5027 + return `<!DOCTYPE html> 5028 + <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> 5029 + <title>Authorize</title> 5030 + <style> 5031 + *{box-sizing:border-box} 5032 + body{font-family:system-ui,sans-serif;max-width:400px;margin:40px auto;padding:20px;background:#1a1a1a;color:#e0e0e0} 5033 + h2{color:#fff;margin-bottom:24px} 5034 + p{color:#b0b0b0;line-height:1.5} 5035 + b{color:#fff} 5036 + .error{color:#ff6b6b;background:#2d1f1f;padding:12px;margin:12px 0;border-radius:6px;border:1px solid #4a2020} 5037 + label{display:block;margin:16px 0 6px;color:#b0b0b0;font-size:14px} 5038 + input[type="password"]{width:100%;padding:12px;background:#2a2a2a;border:1px solid #404040;border-radius:6px;color:#fff;font-size:16px} 5039 + input[type="password"]:focus{outline:none;border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.2)} 5040 + .actions{display:flex;gap:12px;margin-top:24px} 5041 + button{flex:1;padding:12px 20px;border-radius:6px;font-size:16px;font-weight:500;cursor:pointer;transition:background 0.15s} 5042 + .deny{background:#2a2a2a;color:#e0e0e0;border:1px solid #404040} 5043 + .deny:hover{background:#333} 5044 + .approve{background:#2563eb;color:#fff;border:none} 5045 + .approve:hover{background:#1d4ed8} 5046 + .permissions-section{margin:16px 0} 5047 + .section-label{color:#b0b0b0;font-size:13px;margin-bottom:8px} 5048 + .permissions-table{width:100%;border-collapse:collapse;font-size:13px} 5049 + .permissions-table th{color:#808080;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #333} 5050 + .permissions-table th:not(:first-child){text-align:center;width:32px} 5051 + .permissions-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a} 5052 + .permissions-table td:not(:first-child){text-align:center} 5053 + .permissions-table .check{color:#4ade80} 5054 + .blob-list{margin:0;padding-left:20px;color:#e0e0e0;font-size:13px} 5055 + .blob-list li{margin:4px 0} 5056 + .warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0} 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}} 5066 + </style></head> 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> 5078 + <p><b>${escapeHtml(clientName)}</b> ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}</p> 5079 + ${renderPermissionsHtml(parsed)} 5080 + ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''} 5081 + <form method="POST" action="/oauth/authorize"> 5082 + <input type="hidden" name="request_uri" value="${escapeHtml(requestUri)}"> 5083 + <input type="hidden" name="client_id" value="${escapeHtml(clientId)}"> 5084 + <label>Password</label><input type="password" name="password" required autofocus> 5085 + <div class="actions"><button type="submit" name="action" value="deny" class="deny" formnovalidate>Deny</button> 5086 + <button type="submit" name="action" value="approve" class="approve">Authorize</button></div> 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>`; 4565 5108 } 4566 5109 4567 5110 /** ··· 4575 5118 if ('error' in auth) return auth.error; 4576 5119 4577 5120 // Validate scope for blob upload 4578 - if (!hasRequiredScope(auth.scope, 'atproto')) { 4579 - return errorResponse( 4580 - 'Forbidden', 4581 - 'Insufficient scope for blob upload', 4582 - 403, 4583 - ); 5121 + if (auth.scope !== undefined) { 5122 + const contentType = 5123 + request.headers.get('content-type') || 'application/octet-stream'; 5124 + const permissions = new ScopePermissions(auth.scope); 5125 + if (!permissions.allowsBlob(contentType)) { 5126 + return errorResponse( 5127 + 'Forbidden', 5128 + `Missing required scope "blob:${contentType}"`, 5129 + 403, 5130 + ); 5131 + } 4584 5132 } 5133 + // Legacy tokens without scope are trusted (backward compat) 4585 5134 4586 5135 // Route to the user's DO based on their DID from the token 4587 5136 const id = env.PDS.idFromName(auth.did); 4588 5137 const pds = env.PDS.get(id); 4589 - return pds.fetch(request); 5138 + // Pass x-authed-did so DO knows auth was already done (avoids DPoP replay detection) 5139 + return pds.fetch( 5140 + new Request(request.url, { 5141 + method: request.method, 5142 + headers: { 5143 + ...Object.fromEntries(request.headers), 5144 + 'x-authed-did': auth.did, 5145 + }, 5146 + body: request.body, 5147 + }), 5148 + ); 4590 5149 } 4591 5150 4592 5151 /** ··· 4599 5158 const auth = await requireAuth(request, env, defaultPds); 4600 5159 if ('error' in auth) return auth.error; 4601 5160 4602 - // Validate scope for repo write 4603 - if (!hasRequiredScope(auth.scope, 'atproto')) { 4604 - return errorResponse('Forbidden', 'Insufficient scope for repo write', 403); 4605 - } 4606 - 4607 5161 const body = await request.json(); 4608 5162 const repo = body.repo; 4609 5163 if (!repo) { ··· 4614 5168 return errorResponse('Forbidden', "Cannot modify another user's repo", 403); 4615 5169 } 4616 5170 5171 + // Granular scope validation for OAuth tokens 5172 + if (auth.scope !== undefined) { 5173 + const permissions = new ScopePermissions(auth.scope); 5174 + const url = new URL(request.url); 5175 + const endpoint = url.pathname; 5176 + 5177 + if (endpoint === '/xrpc/com.atproto.repo.createRecord') { 5178 + const collection = body.collection; 5179 + if (!collection) { 5180 + return errorResponse('InvalidRequest', 'missing collection param', 400); 5181 + } 5182 + if (!permissions.allowsRepo(collection, 'create')) { 5183 + return errorResponse( 5184 + 'Forbidden', 5185 + `Missing required scope "repo:${collection}:create"`, 5186 + 403, 5187 + ); 5188 + } 5189 + } else if (endpoint === '/xrpc/com.atproto.repo.putRecord') { 5190 + const collection = body.collection; 5191 + if (!collection) { 5192 + return errorResponse('InvalidRequest', 'missing collection param', 400); 5193 + } 5194 + // putRecord requires both create and update permissions 5195 + if ( 5196 + !permissions.allowsRepo(collection, 'create') || 5197 + !permissions.allowsRepo(collection, 'update') 5198 + ) { 5199 + const missing = !permissions.allowsRepo(collection, 'create') 5200 + ? 'create' 5201 + : 'update'; 5202 + return errorResponse( 5203 + 'Forbidden', 5204 + `Missing required scope "repo:${collection}:${missing}"`, 5205 + 403, 5206 + ); 5207 + } 5208 + } else if (endpoint === '/xrpc/com.atproto.repo.deleteRecord') { 5209 + const collection = body.collection; 5210 + if (!collection) { 5211 + return errorResponse('InvalidRequest', 'missing collection param', 400); 5212 + } 5213 + if (!permissions.allowsRepo(collection, 'delete')) { 5214 + return errorResponse( 5215 + 'Forbidden', 5216 + `Missing required scope "repo:${collection}:delete"`, 5217 + 403, 5218 + ); 5219 + } 5220 + } else if (endpoint === '/xrpc/com.atproto.repo.applyWrites') { 5221 + const writes = body.writes || []; 5222 + for (const write of writes) { 5223 + const collection = write.collection; 5224 + if (!collection) continue; 5225 + 5226 + let action; 5227 + if (write.$type === 'com.atproto.repo.applyWrites#create') { 5228 + action = 'create'; 5229 + } else if (write.$type === 'com.atproto.repo.applyWrites#update') { 5230 + action = 'update'; 5231 + } else if (write.$type === 'com.atproto.repo.applyWrites#delete') { 5232 + action = 'delete'; 5233 + } else { 5234 + continue; 5235 + } 5236 + 5237 + if (!permissions.allowsRepo(collection, action)) { 5238 + return errorResponse( 5239 + 'Forbidden', 5240 + `Missing required scope "repo:${collection}:${action}"`, 5241 + 403, 5242 + ); 5243 + } 5244 + } 5245 + } 5246 + } 5247 + // Legacy tokens without scope are trusted (backward compat) 5248 + 4617 5249 const id = env.PDS.idFromName(repo); 4618 5250 const pds = env.PDS.get(id); 4619 5251 const response = await pds.fetch( ··· 4774 5406 if (!repo) { 4775 5407 return errorResponse('InvalidRequest', 'missing repo param', 400); 4776 5408 } 5409 + 5410 + // Check for atproto-proxy header - if present, proxy to specified service 5411 + const proxyHeader = request.headers.get('atproto-proxy'); 5412 + if (proxyHeader) { 5413 + const parsed = parseAtprotoProxyHeader(proxyHeader); 5414 + if (!parsed) { 5415 + // Header present but malformed 5416 + return errorResponse( 5417 + 'InvalidRequest', 5418 + `Malformed atproto-proxy header: ${proxyHeader}`, 5419 + 400, 5420 + ); 5421 + } 5422 + const serviceUrl = getKnownServiceUrl(parsed.did, parsed.serviceId); 5423 + if (serviceUrl) { 5424 + return proxyToService(request, serviceUrl); 5425 + } 5426 + // Unknown service - could add DID resolution here in the future 5427 + return errorResponse( 5428 + 'InvalidRequest', 5429 + `Unknown proxy service: ${proxyHeader}`, 5430 + 400, 5431 + ); 5432 + } 5433 + 5434 + // No proxy header - handle locally (returns appropriate error if DID not found) 4777 5435 const id = env.PDS.idFromName(repo); 4778 5436 const pds = env.PDS.get(id); 4779 5437 return pds.fetch(request); ··· 4815 5473 4816 5474 // Health check endpoint 4817 5475 if (url.pathname === '/xrpc/_health') { 4818 - return Response.json({ version: '0.1.0' }); 5476 + return Response.json({ version: VERSION }); 4819 5477 } 4820 5478 4821 5479 // Root path - ASCII art
+824 -25
test/e2e.test.js
··· 3 3 * Uses Node's built-in test runner and fetch 4 4 */ 5 5 6 - import { describe, it, before, after } from 'node:test'; 7 6 import assert from 'node:assert'; 8 7 import { spawn } from 'node:child_process'; 9 8 import { randomBytes } from 'node:crypto'; 9 + import { after, before, describe, it } from 'node:test'; 10 10 import { DpopClient } from './helpers/dpop.js'; 11 + import { getOAuthTokenWithScope } from './helpers/oauth.js'; 11 12 12 13 const BASE = 'http://localhost:8787'; 13 14 const DID = `did:plc:test${randomBytes(8).toString('hex')}`; ··· 39 40 } 40 41 41 42 /** 42 - * Make JSON request helper 43 + * Make JSON request helper (with retry for flaky wrangler dev 5xx errors) 43 44 */ 44 45 async function jsonPost(path, body, headers = {}) { 45 - const res = await fetch(`${BASE}${path}`, { 46 - method: 'POST', 47 - headers: { 'Content-Type': 'application/json', ...headers }, 48 - body: JSON.stringify(body), 49 - }); 50 - 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 + } 51 59 } 52 60 53 61 /** 54 - * Make form-encoded POST 62 + * Make form-encoded POST (with retry for flaky wrangler dev 5xx errors) 55 63 */ 56 64 async function formPost(path, params, headers = {}) { 57 - const res = await fetch(`${BASE}${path}`, { 58 - method: 'POST', 59 - headers: { 60 - 'Content-Type': 'application/x-www-form-urlencoded', 61 - ...headers, 62 - }, 63 - body: new URLSearchParams(params).toString(), 64 - }); 65 - const text = await res.text(); 66 - let data = null; 67 - try { 68 - data = JSON.parse(text); 69 - } catch { 70 - 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 }; 71 87 } 72 - return { status: res.status, data }; 73 88 } 74 89 75 90 describe('E2E Tests', () => { ··· 537 552 assert.strictEqual(data.jwks_uri, `${BASE}/oauth/jwks`); 538 553 assert.deepStrictEqual(data.scopes_supported, ['atproto']); 539 554 assert.deepStrictEqual(data.dpop_signing_alg_values_supported, ['ES256']); 540 - assert.strictEqual(data.require_pushed_authorization_requests, true); 555 + assert.strictEqual(data.require_pushed_authorization_requests, false); 541 556 assert.strictEqual(data.client_id_metadata_document_supported, true); 542 557 assert.deepStrictEqual(data.protected_resources, [BASE]); 543 558 }); ··· 1022 1037 const data = await parRes2.json(); 1023 1038 assert.strictEqual(data.error, 'invalid_dpop_proof'); 1024 1039 assert.ok(data.message?.includes('replay')); 1040 + }); 1041 + }); 1042 + 1043 + describe('Scope Enforcement', () => { 1044 + it('createRecord denied with insufficient scope', async () => { 1045 + // Get token that only allows creating likes, not posts 1046 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1047 + 'repo:app.bsky.feed.like?action=create', 1048 + DID, 1049 + PASSWORD, 1050 + ); 1051 + 1052 + const proof = await dpop.createProof( 1053 + 'POST', 1054 + `${BASE}/xrpc/com.atproto.repo.createRecord`, 1055 + accessToken, 1056 + ); 1057 + 1058 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1059 + method: 'POST', 1060 + headers: { 1061 + 'Content-Type': 'application/json', 1062 + Authorization: `DPoP ${accessToken}`, 1063 + DPoP: proof, 1064 + }, 1065 + body: JSON.stringify({ 1066 + repo: DID, 1067 + collection: 'app.bsky.feed.post', // Not allowed by scope 1068 + record: { text: 'test', createdAt: new Date().toISOString() }, 1069 + }), 1070 + }); 1071 + 1072 + assert.strictEqual(res.status, 403, 'Should reject with 403'); 1073 + const body = await res.json(); 1074 + assert.ok( 1075 + body.message?.includes('Missing required scope'), 1076 + 'Error should mention missing scope', 1077 + ); 1078 + }); 1079 + 1080 + it('createRecord allowed with matching scope', async () => { 1081 + // Get token that allows creating posts 1082 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1083 + 'repo:app.bsky.feed.post?action=create', 1084 + DID, 1085 + PASSWORD, 1086 + ); 1087 + 1088 + const proof = await dpop.createProof( 1089 + 'POST', 1090 + `${BASE}/xrpc/com.atproto.repo.createRecord`, 1091 + accessToken, 1092 + ); 1093 + 1094 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1095 + method: 'POST', 1096 + headers: { 1097 + 'Content-Type': 'application/json', 1098 + Authorization: `DPoP ${accessToken}`, 1099 + DPoP: proof, 1100 + }, 1101 + body: JSON.stringify({ 1102 + repo: DID, 1103 + collection: 'app.bsky.feed.post', 1104 + record: { text: 'scope test', createdAt: new Date().toISOString() }, 1105 + }), 1106 + }); 1107 + 1108 + assert.strictEqual(res.status, 200, 'Should allow with correct scope'); 1109 + const body = await res.json(); 1110 + assert.ok(body.uri, 'Should return uri'); 1111 + 1112 + // Note: We don't clean up here because our token only has create scope 1113 + // The record will be cleaned up by subsequent tests with full-access tokens 1114 + }); 1115 + 1116 + it('createRecord allowed with wildcard collection scope', async () => { 1117 + // Get token that allows creating any record type 1118 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1119 + 'repo:*?action=create', 1120 + DID, 1121 + PASSWORD, 1122 + ); 1123 + 1124 + const proof = await dpop.createProof( 1125 + 'POST', 1126 + `${BASE}/xrpc/com.atproto.repo.createRecord`, 1127 + accessToken, 1128 + ); 1129 + 1130 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1131 + method: 'POST', 1132 + headers: { 1133 + 'Content-Type': 'application/json', 1134 + Authorization: `DPoP ${accessToken}`, 1135 + DPoP: proof, 1136 + }, 1137 + body: JSON.stringify({ 1138 + repo: DID, 1139 + collection: 'app.bsky.feed.post', 1140 + record: { 1141 + text: 'wildcard scope test', 1142 + createdAt: new Date().toISOString(), 1143 + }, 1144 + }), 1145 + }); 1146 + 1147 + assert.strictEqual( 1148 + res.status, 1149 + 200, 1150 + 'Wildcard scope should allow any collection', 1151 + ); 1152 + }); 1153 + 1154 + it('deleteRecord denied without delete scope', async () => { 1155 + // Get token that only has create scope 1156 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1157 + 'repo:app.bsky.feed.post?action=create', 1158 + DID, 1159 + PASSWORD, 1160 + ); 1161 + 1162 + const proof = await dpop.createProof( 1163 + 'POST', 1164 + `${BASE}/xrpc/com.atproto.repo.deleteRecord`, 1165 + accessToken, 1166 + ); 1167 + 1168 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.deleteRecord`, { 1169 + method: 'POST', 1170 + headers: { 1171 + 'Content-Type': 'application/json', 1172 + Authorization: `DPoP ${accessToken}`, 1173 + DPoP: proof, 1174 + }, 1175 + body: JSON.stringify({ 1176 + repo: DID, 1177 + collection: 'app.bsky.feed.post', 1178 + rkey: 'nonexistent', // Doesn't matter, should fail on scope first 1179 + }), 1180 + }); 1181 + 1182 + assert.strictEqual( 1183 + res.status, 1184 + 403, 1185 + 'Should reject delete without delete scope', 1186 + ); 1187 + }); 1188 + 1189 + it('uploadBlob denied with mismatched MIME scope', async () => { 1190 + // Get token that only allows image uploads 1191 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1192 + 'blob:image/*', 1193 + DID, 1194 + PASSWORD, 1195 + ); 1196 + 1197 + const proof = await dpop.createProof( 1198 + 'POST', 1199 + `${BASE}/xrpc/com.atproto.repo.uploadBlob`, 1200 + accessToken, 1201 + ); 1202 + 1203 + // Try to upload a video (not allowed by scope) 1204 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 1205 + method: 'POST', 1206 + headers: { 1207 + 'Content-Type': 'video/mp4', 1208 + Authorization: `DPoP ${accessToken}`, 1209 + DPoP: proof, 1210 + }, 1211 + body: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), // Fake MP4 header 1212 + }); 1213 + 1214 + assert.strictEqual( 1215 + res.status, 1216 + 403, 1217 + 'Should reject video upload with image-only scope', 1218 + ); 1219 + const body = await res.json(); 1220 + assert.ok( 1221 + body.message?.includes('Missing required scope'), 1222 + 'Error should mention missing scope', 1223 + ); 1224 + }); 1225 + 1226 + it('uploadBlob allowed with matching MIME scope', async () => { 1227 + // Get token that allows image uploads 1228 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1229 + 'blob:image/*', 1230 + DID, 1231 + PASSWORD, 1232 + ); 1233 + 1234 + const proof = await dpop.createProof( 1235 + 'POST', 1236 + `${BASE}/xrpc/com.atproto.repo.uploadBlob`, 1237 + accessToken, 1238 + ); 1239 + 1240 + // Minimal PNG 1241 + const pngBytes = new Uint8Array([ 1242 + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 1243 + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 1244 + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 1245 + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 1246 + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 1247 + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, 1248 + ]); 1249 + 1250 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { 1251 + method: 'POST', 1252 + headers: { 1253 + 'Content-Type': 'image/png', 1254 + Authorization: `DPoP ${accessToken}`, 1255 + DPoP: proof, 1256 + }, 1257 + body: pngBytes, 1258 + }); 1259 + 1260 + assert.strictEqual( 1261 + res.status, 1262 + 200, 1263 + 'Should allow image upload with image scope', 1264 + ); 1265 + }); 1266 + 1267 + it('transition:generic grants full access', async () => { 1268 + // Get token with transition:generic scope (full access) 1269 + const { accessToken, dpop } = await getOAuthTokenWithScope( 1270 + 'transition:generic', 1271 + DID, 1272 + PASSWORD, 1273 + ); 1274 + 1275 + const proof = await dpop.createProof( 1276 + 'POST', 1277 + `${BASE}/xrpc/com.atproto.repo.createRecord`, 1278 + accessToken, 1279 + ); 1280 + 1281 + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { 1282 + method: 'POST', 1283 + headers: { 1284 + 'Content-Type': 'application/json', 1285 + Authorization: `DPoP ${accessToken}`, 1286 + DPoP: proof, 1287 + }, 1288 + body: JSON.stringify({ 1289 + repo: DID, 1290 + collection: 'app.bsky.feed.post', 1291 + record: { 1292 + text: 'transition scope test', 1293 + createdAt: new Date().toISOString(), 1294 + }, 1295 + }), 1296 + }); 1297 + 1298 + assert.strictEqual( 1299 + res.status, 1300 + 200, 1301 + 'transition:generic should grant full access', 1302 + ); 1303 + }); 1304 + }); 1305 + 1306 + describe('Consent page display', () => { 1307 + it('consent page shows permissions table for granular scopes', async () => { 1308 + const dpop = await DpopClient.create(); 1309 + const clientId = 'http://localhost:3000'; 1310 + const redirectUri = 'http://localhost:3000/callback'; 1311 + const codeVerifier = randomBytes(32).toString('base64url'); 1312 + 1313 + const challengeBuffer = await crypto.subtle.digest( 1314 + 'SHA-256', 1315 + new TextEncoder().encode(codeVerifier), 1316 + ); 1317 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1318 + 1319 + // PAR request with granular scopes 1320 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1321 + const parRes = await fetch(`${BASE}/oauth/par`, { 1322 + method: 'POST', 1323 + headers: { 1324 + 'Content-Type': 'application/x-www-form-urlencoded', 1325 + DPoP: parProof, 1326 + }, 1327 + body: new URLSearchParams({ 1328 + client_id: clientId, 1329 + redirect_uri: redirectUri, 1330 + response_type: 'code', 1331 + scope: 1332 + 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*', 1333 + code_challenge: codeChallenge, 1334 + code_challenge_method: 'S256', 1335 + state: 'test-state', 1336 + login_hint: DID, 1337 + }).toString(), 1338 + }); 1339 + 1340 + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1341 + const { request_uri } = await parRes.json(); 1342 + 1343 + // GET the authorize page 1344 + const authorizeRes = await fetch( 1345 + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1346 + ); 1347 + 1348 + const html = await authorizeRes.text(); 1349 + 1350 + // Verify permissions table is rendered 1351 + assert.ok( 1352 + html.includes('Repository permissions:'), 1353 + 'Should show repo permissions section', 1354 + ); 1355 + assert.ok( 1356 + html.includes('app.bsky.feed.post'), 1357 + 'Should show collection name', 1358 + ); 1359 + assert.ok( 1360 + html.includes('Upload permissions:'), 1361 + 'Should show upload permissions section', 1362 + ); 1363 + assert.ok(html.includes('image/*'), 'Should show blob MIME type'); 1364 + }); 1365 + 1366 + it('consent page shows identity message for atproto-only scope', async () => { 1367 + const dpop = await DpopClient.create(); 1368 + const clientId = 'http://localhost:3000'; 1369 + const redirectUri = 'http://localhost:3000/callback'; 1370 + const codeVerifier = randomBytes(32).toString('base64url'); 1371 + 1372 + const challengeBuffer = await crypto.subtle.digest( 1373 + 'SHA-256', 1374 + new TextEncoder().encode(codeVerifier), 1375 + ); 1376 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1377 + 1378 + // PAR request with atproto only (identity-only) 1379 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1380 + const parRes = await fetch(`${BASE}/oauth/par`, { 1381 + method: 'POST', 1382 + headers: { 1383 + 'Content-Type': 'application/x-www-form-urlencoded', 1384 + DPoP: parProof, 1385 + }, 1386 + body: new URLSearchParams({ 1387 + client_id: clientId, 1388 + redirect_uri: redirectUri, 1389 + response_type: 'code', 1390 + scope: 'atproto', 1391 + code_challenge: codeChallenge, 1392 + code_challenge_method: 'S256', 1393 + state: 'test-state', 1394 + login_hint: DID, 1395 + }).toString(), 1396 + }); 1397 + 1398 + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1399 + const { request_uri } = await parRes.json(); 1400 + 1401 + // GET the authorize page 1402 + const authorizeRes = await fetch( 1403 + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1404 + ); 1405 + 1406 + const html = await authorizeRes.text(); 1407 + 1408 + // Verify identity-only message 1409 + assert.ok( 1410 + html.includes('wants to uniquely identify you'), 1411 + 'Should show identity-only message', 1412 + ); 1413 + assert.ok( 1414 + !html.includes('Repository permissions:'), 1415 + 'Should NOT show permissions table', 1416 + ); 1417 + }); 1418 + 1419 + it('consent page shows warning for transition:generic scope', async () => { 1420 + const dpop = await DpopClient.create(); 1421 + const clientId = 'http://localhost:3000'; 1422 + const redirectUri = 'http://localhost:3000/callback'; 1423 + const codeVerifier = randomBytes(32).toString('base64url'); 1424 + 1425 + const challengeBuffer = await crypto.subtle.digest( 1426 + 'SHA-256', 1427 + new TextEncoder().encode(codeVerifier), 1428 + ); 1429 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1430 + 1431 + // PAR request with transition:generic (full access) 1432 + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); 1433 + const parRes = await fetch(`${BASE}/oauth/par`, { 1434 + method: 'POST', 1435 + headers: { 1436 + 'Content-Type': 'application/x-www-form-urlencoded', 1437 + DPoP: parProof, 1438 + }, 1439 + body: new URLSearchParams({ 1440 + client_id: clientId, 1441 + redirect_uri: redirectUri, 1442 + response_type: 'code', 1443 + scope: 'atproto transition:generic', 1444 + code_challenge: codeChallenge, 1445 + code_challenge_method: 'S256', 1446 + state: 'test-state', 1447 + login_hint: DID, 1448 + }).toString(), 1449 + }); 1450 + 1451 + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); 1452 + const { request_uri } = await parRes.json(); 1453 + 1454 + // GET the authorize page 1455 + const authorizeRes = await fetch( 1456 + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, 1457 + ); 1458 + 1459 + const html = await authorizeRes.text(); 1460 + 1461 + // Verify warning banner 1462 + assert.ok( 1463 + html.includes('Full repository access requested'), 1464 + 'Should show full access warning', 1465 + ); 1466 + }); 1467 + 1468 + it('supports direct authorization without PAR', async () => { 1469 + const clientId = 'http://localhost:3000'; 1470 + const redirectUri = 'http://localhost:3000/callback'; 1471 + const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 1472 + const challengeBuffer = await crypto.subtle.digest( 1473 + 'SHA-256', 1474 + new TextEncoder().encode(codeVerifier), 1475 + ); 1476 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1477 + const state = 'test-direct-auth-state'; 1478 + 1479 + // Step 1: GET authorize with direct parameters (no PAR) 1480 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1481 + authorizeUrl.searchParams.set('client_id', clientId); 1482 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1483 + authorizeUrl.searchParams.set('response_type', 'code'); 1484 + authorizeUrl.searchParams.set('scope', 'atproto'); 1485 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1486 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1487 + authorizeUrl.searchParams.set('state', state); 1488 + authorizeUrl.searchParams.set('login_hint', DID); 1489 + 1490 + const getRes = await fetch(authorizeUrl.toString()); 1491 + assert.strictEqual( 1492 + getRes.status, 1493 + 200, 1494 + 'Direct authorize GET should succeed', 1495 + ); 1496 + 1497 + const html = await getRes.text(); 1498 + assert.ok(html.includes('Authorize'), 'Should show consent page'); 1499 + assert.ok( 1500 + html.includes('request_uri'), 1501 + 'Should include request_uri in form', 1502 + ); 1503 + }); 1504 + 1505 + it('completes full direct authorization flow', async () => { 1506 + const clientId = 'http://localhost:3000'; 1507 + const redirectUri = 'http://localhost:3000/callback'; 1508 + const codeVerifier = 'test-verifier-for-direct-auth-flow-min-43-chars!!'; 1509 + const challengeBuffer = await crypto.subtle.digest( 1510 + 'SHA-256', 1511 + new TextEncoder().encode(codeVerifier), 1512 + ); 1513 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 1514 + const state = 'test-direct-auth-state'; 1515 + 1516 + // Step 1: GET authorize with direct parameters 1517 + const authorizeUrl = new URL(`${BASE}/oauth/authorize`); 1518 + authorizeUrl.searchParams.set('client_id', clientId); 1519 + authorizeUrl.searchParams.set('redirect_uri', redirectUri); 1520 + authorizeUrl.searchParams.set('response_type', 'code'); 1521 + authorizeUrl.searchParams.set('scope', 'atproto'); 1522 + authorizeUrl.searchParams.set('code_challenge', codeChallenge); 1523 + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); 1524 + authorizeUrl.searchParams.set('state', state); 1525 + authorizeUrl.searchParams.set('login_hint', DID); 1526 + 1527 + const getRes = await fetch(authorizeUrl.toString()); 1528 + assert.strictEqual(getRes.status, 200); 1529 + const html = await getRes.text(); 1530 + 1531 + // Extract request_uri from the form 1532 + const requestUriMatch = html.match(/name="request_uri" value="([^"]+)"/); 1533 + assert.ok(requestUriMatch, 'Should have request_uri in form'); 1534 + const requestUri = requestUriMatch[1]; 1535 + 1536 + // Step 2: POST to authorize (user approval) 1537 + const authRes = await fetch(`${BASE}/oauth/authorize`, { 1538 + method: 'POST', 1539 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 1540 + body: new URLSearchParams({ 1541 + request_uri: requestUri, 1542 + client_id: clientId, 1543 + password: PASSWORD, 1544 + }).toString(), 1545 + redirect: 'manual', 1546 + }); 1547 + 1548 + assert.strictEqual(authRes.status, 302, 'Should redirect after approval'); 1549 + const location = authRes.headers.get('location'); 1550 + assert.ok(location, 'Should have Location header'); 1551 + const locationUrl = new URL(location); 1552 + const code = locationUrl.searchParams.get('code'); 1553 + assert.ok(code, 'Should have authorization code'); 1554 + assert.strictEqual(locationUrl.searchParams.get('state'), state); 1555 + 1556 + // Step 3: Exchange code for tokens 1557 + const dpop = await DpopClient.create(); 1558 + const dpopProof = await dpop.createProof('POST', `${BASE}/oauth/token`); 1559 + 1560 + const tokenRes = await fetch(`${BASE}/oauth/token`, { 1561 + method: 'POST', 1562 + headers: { 1563 + 'Content-Type': 'application/x-www-form-urlencoded', 1564 + DPoP: dpopProof, 1565 + }, 1566 + body: new URLSearchParams({ 1567 + grant_type: 'authorization_code', 1568 + code, 1569 + redirect_uri: redirectUri, 1570 + client_id: clientId, 1571 + code_verifier: codeVerifier, 1572 + }).toString(), 1573 + }); 1574 + 1575 + assert.strictEqual(tokenRes.status, 200, 'Token exchange should succeed'); 1576 + const tokenData = await tokenRes.json(); 1577 + assert.ok(tokenData.access_token, 'Should have access_token'); 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 + ); 1689 + }); 1690 + }); 1691 + 1692 + describe('Foreign DID proxying', () => { 1693 + it('proxies to AppView when atproto-proxy header present', async () => { 1694 + // Use a known public DID (bsky.app official account) 1695 + // We expect 200 (record exists) or 400 (record deleted/not found) from AppView 1696 + // A 502 would indicate proxy failure, 404 would indicate local handling 1697 + const res = await fetch( 1698 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 1699 + { 1700 + headers: { 1701 + 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview', 1702 + }, 1703 + }, 1704 + ); 1705 + // AppView returns 200 (found) or 400 (RecordNotFound), not 404 or 502 1706 + assert.ok( 1707 + res.status === 200 || res.status === 400, 1708 + `Expected 200 or 400 from AppView, got ${res.status}`, 1709 + ); 1710 + // Verify we got a JSON response (not an error page) 1711 + const contentType = res.headers.get('content-type'); 1712 + assert.ok( 1713 + contentType?.includes('application/json'), 1714 + 'Should return JSON', 1715 + ); 1716 + }); 1717 + 1718 + it('handles foreign repo locally without header (returns not found)', async () => { 1719 + // Foreign DID without atproto-proxy header is handled locally 1720 + // This returns an error since the foreign DID doesn't exist on this PDS 1721 + const res = await fetch( 1722 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 1723 + ); 1724 + // Local PDS returns 404 for non-existent record/DID 1725 + assert.strictEqual(res.status, 404); 1726 + }); 1727 + 1728 + it('returns error for unknown proxy service', async () => { 1729 + const res = await fetch( 1730 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1731 + { 1732 + headers: { 1733 + 'atproto-proxy': 'did:web:unknown.service#unknown', 1734 + }, 1735 + }, 1736 + ); 1737 + assert.strictEqual(res.status, 400); 1738 + const data = await res.json(); 1739 + assert.ok(data.message.includes('Unknown proxy service')); 1740 + }); 1741 + 1742 + it('returns error for malformed atproto-proxy header', async () => { 1743 + // Header without fragment separator 1744 + const res1 = await fetch( 1745 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1746 + { 1747 + headers: { 1748 + 'atproto-proxy': 'did:web:api.bsky.app', // missing #serviceId 1749 + }, 1750 + }, 1751 + ); 1752 + assert.strictEqual(res1.status, 400); 1753 + const data1 = await res1.json(); 1754 + assert.ok(data1.message.includes('Malformed atproto-proxy header')); 1755 + 1756 + // Header with only fragment 1757 + const res2 = await fetch( 1758 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1759 + { 1760 + headers: { 1761 + 'atproto-proxy': '#bsky_appview', // missing DID 1762 + }, 1763 + }, 1764 + ); 1765 + assert.strictEqual(res2.status, 400); 1766 + const data2 = await res2.json(); 1767 + assert.ok(data2.message.includes('Malformed atproto-proxy header')); 1768 + }); 1769 + 1770 + it('returns local record for local DID without proxy header', async () => { 1771 + // Create a record first 1772 + const { data: created } = await jsonPost( 1773 + '/xrpc/com.atproto.repo.createRecord', 1774 + { 1775 + repo: DID, 1776 + collection: 'app.bsky.feed.post', 1777 + record: { 1778 + $type: 'app.bsky.feed.post', 1779 + text: 'Test post for local DID test', 1780 + createdAt: new Date().toISOString(), 1781 + }, 1782 + }, 1783 + { Authorization: `Bearer ${token}` }, 1784 + ); 1785 + 1786 + // Fetch without proxy header - should get local record 1787 + const rkey = created.uri.split('/').pop(); 1788 + const res = await fetch( 1789 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`, 1790 + ); 1791 + assert.strictEqual(res.status, 200); 1792 + const data = await res.json(); 1793 + assert.ok(data.value.text.includes('Test post for local DID test')); 1794 + 1795 + // Cleanup - verify success to ensure test isolation 1796 + const { status: cleanupStatus } = await jsonPost( 1797 + '/xrpc/com.atproto.repo.deleteRecord', 1798 + { repo: DID, collection: 'app.bsky.feed.post', rkey }, 1799 + { Authorization: `Bearer ${token}` }, 1800 + ); 1801 + assert.strictEqual(cleanupStatus, 200, 'Cleanup should succeed'); 1802 + }); 1803 + 1804 + it('describeRepo handles foreign DID locally', async () => { 1805 + // Without proxy header, foreign DID is handled locally (returns error) 1806 + const res = await fetch( 1807 + `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`, 1808 + ); 1809 + // Local PDS returns 404 for non-existent DID 1810 + assert.strictEqual(res.status, 404); 1811 + }); 1812 + 1813 + it('listRecords handles foreign DID locally', async () => { 1814 + // Without proxy header, foreign DID is handled locally 1815 + // listRecords returns 200 with empty records for non-existent collection 1816 + const res = await fetch( 1817 + `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`, 1818 + ); 1819 + // Local PDS returns 200 with empty records (or 404 for completely unknown DID) 1820 + assert.ok( 1821 + res.status === 200 || res.status === 404, 1822 + `Expected 200 or 404, got ${res.status}`, 1823 + ); 1025 1824 }); 1026 1825 }); 1027 1826
+157
test/helpers/oauth.js
··· 1 + /** 2 + * OAuth flow helpers for e2e tests 3 + */ 4 + 5 + import { randomBytes } from 'node:crypto'; 6 + import { DpopClient } from './dpop.js'; 7 + 8 + const BASE = 'http://localhost:8787'; 9 + 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 + /** 43 + * Get an OAuth token with a specific scope via full PAR -> authorize -> token flow 44 + * @param {string} scope - The scope to request 45 + * @param {string} did - The DID to authenticate as 46 + * @param {string} password - The password for authentication 47 + * @returns {Promise<{accessToken: string, refreshToken: string, dpop: DpopClient}>} 48 + */ 49 + export async function getOAuthTokenWithScope(scope, did, password) { 50 + const dpop = await DpopClient.create(); 51 + const clientId = 'http://localhost:3000'; 52 + const redirectUri = 'http://localhost:3000/callback'; 53 + const codeVerifier = randomBytes(32).toString('base64url'); 54 + const challengeBuffer = await crypto.subtle.digest( 55 + 'SHA-256', 56 + new TextEncoder().encode(codeVerifier), 57 + ); 58 + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); 59 + 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 + } 94 + 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 + } 119 + 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 + } 151 + 152 + return { 153 + accessToken: tokenData.access_token, 154 + refreshToken: tokenData.refresh_token, 155 + dpop, 156 + }; 157 + }
+420 -1
test/pds.test.js
··· 12 12 cidToString, 13 13 computeJwkThumbprint, 14 14 createAccessJwt, 15 - createCid, 16 15 createBlobCid, 16 + createCid, 17 17 createRefreshJwt, 18 18 createTid, 19 19 findBlobRefs, 20 20 generateKeyPair, 21 21 getKeyDepth, 22 + getKnownServiceUrl, 22 23 getLoopbackClientMetadata, 23 24 hexToBytes, 24 25 importPrivateKey, 25 26 isLoopbackClient, 27 + matchesMime, 28 + parseAtprotoProxyHeader, 29 + parseBlobScope, 30 + parseRepoScope, 31 + parseScopesForDisplay, 32 + ScopePermissions, 26 33 sign, 27 34 sniffMimeType, 28 35 validateClientMetadata, ··· 30 37 verifyAccessJwt, 31 38 verifyRefreshJwt, 32 39 } from '../src/pds.js'; 40 + 41 + // Internal constant - not exported from pds.js due to Cloudflare Workers limitation 42 + const BSKY_APPVIEW_URL = 'https://api.bsky.app'; 33 43 34 44 describe('CBOR Encoding', () => { 35 45 test('encodes simple map', () => { ··· 827 837 ); 828 838 }); 829 839 }); 840 + 841 + describe('Proxy Utilities', () => { 842 + describe('parseAtprotoProxyHeader', () => { 843 + test('parses valid header', () => { 844 + const result = parseAtprotoProxyHeader( 845 + 'did:web:api.bsky.app#bsky_appview', 846 + ); 847 + assert.deepStrictEqual(result, { 848 + did: 'did:web:api.bsky.app', 849 + serviceId: 'bsky_appview', 850 + }); 851 + }); 852 + 853 + test('parses header with did:plc', () => { 854 + const result = parseAtprotoProxyHeader( 855 + 'did:plc:z72i7hdynmk6r22z27h6tvur#atproto_labeler', 856 + ); 857 + assert.deepStrictEqual(result, { 858 + did: 'did:plc:z72i7hdynmk6r22z27h6tvur', 859 + serviceId: 'atproto_labeler', 860 + }); 861 + }); 862 + 863 + test('returns null for null/undefined', () => { 864 + assert.strictEqual(parseAtprotoProxyHeader(null), null); 865 + assert.strictEqual(parseAtprotoProxyHeader(undefined), null); 866 + assert.strictEqual(parseAtprotoProxyHeader(''), null); 867 + }); 868 + 869 + test('returns null for header without fragment', () => { 870 + assert.strictEqual(parseAtprotoProxyHeader('did:web:api.bsky.app'), null); 871 + }); 872 + 873 + test('returns null for header with only fragment', () => { 874 + assert.strictEqual(parseAtprotoProxyHeader('#bsky_appview'), null); 875 + }); 876 + 877 + test('returns null for header with trailing fragment', () => { 878 + assert.strictEqual( 879 + parseAtprotoProxyHeader('did:web:api.bsky.app#'), 880 + null, 881 + ); 882 + }); 883 + }); 884 + 885 + describe('getKnownServiceUrl', () => { 886 + test('returns URL for known Bluesky AppView', () => { 887 + const result = getKnownServiceUrl('did:web:api.bsky.app', 'bsky_appview'); 888 + assert.strictEqual(result, BSKY_APPVIEW_URL); 889 + }); 890 + 891 + test('returns null for unknown service DID', () => { 892 + const result = getKnownServiceUrl( 893 + 'did:web:unknown.service', 894 + 'bsky_appview', 895 + ); 896 + assert.strictEqual(result, null); 897 + }); 898 + 899 + test('returns null for unknown service ID', () => { 900 + const result = getKnownServiceUrl( 901 + 'did:web:api.bsky.app', 902 + 'unknown_service', 903 + ); 904 + assert.strictEqual(result, null); 905 + }); 906 + 907 + test('returns null for both unknown', () => { 908 + const result = getKnownServiceUrl('did:web:unknown', 'unknown'); 909 + assert.strictEqual(result, null); 910 + }); 911 + }); 912 + }); 913 + 914 + describe('Scope Parsing', () => { 915 + describe('parseRepoScope', () => { 916 + test('parses repo scope with query parameter action', () => { 917 + const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); 918 + assert.deepStrictEqual(result, { 919 + collection: 'app.bsky.feed.post', 920 + actions: ['create'], 921 + }); 922 + }); 923 + 924 + test('parses repo scope with multiple query parameter actions', () => { 925 + const result = parseRepoScope( 926 + 'repo:app.bsky.feed.post?action=create&action=update', 927 + ); 928 + assert.deepStrictEqual(result, { 929 + collection: 'app.bsky.feed.post', 930 + actions: ['create', 'update'], 931 + }); 932 + }); 933 + 934 + test('parses repo scope without actions as all actions', () => { 935 + const result = parseRepoScope('repo:app.bsky.feed.post'); 936 + assert.deepStrictEqual(result, { 937 + collection: 'app.bsky.feed.post', 938 + actions: ['create', 'update', 'delete'], 939 + }); 940 + }); 941 + 942 + test('parses wildcard collection with action', () => { 943 + const result = parseRepoScope('repo:*?action=create'); 944 + assert.deepStrictEqual(result, { 945 + collection: '*', 946 + actions: ['create'], 947 + }); 948 + }); 949 + 950 + test('parses query-only format', () => { 951 + const result = parseRepoScope( 952 + 'repo?collection=app.bsky.feed.post&action=create', 953 + ); 954 + assert.deepStrictEqual(result, { 955 + collection: 'app.bsky.feed.post', 956 + actions: ['create'], 957 + }); 958 + }); 959 + 960 + test('deduplicates repeated actions', () => { 961 + const result = parseRepoScope( 962 + 'repo:app.bsky.feed.post?action=create&action=create&action=update', 963 + ); 964 + assert.deepStrictEqual(result, { 965 + collection: 'app.bsky.feed.post', 966 + actions: ['create', 'update'], 967 + }); 968 + }); 969 + 970 + test('returns null for non-repo scope', () => { 971 + assert.strictEqual(parseRepoScope('atproto'), null); 972 + assert.strictEqual(parseRepoScope('blob:image/*'), null); 973 + assert.strictEqual(parseRepoScope('transition:generic'), null); 974 + }); 975 + 976 + test('returns null for invalid repo scope', () => { 977 + assert.strictEqual(parseRepoScope('repo:'), null); 978 + assert.strictEqual(parseRepoScope('repo?'), null); 979 + }); 980 + }); 981 + 982 + describe('parseBlobScope', () => { 983 + test('parses wildcard MIME', () => { 984 + const result = parseBlobScope('blob:*/*'); 985 + assert.deepStrictEqual(result, { accept: ['*/*'] }); 986 + }); 987 + 988 + test('parses type wildcard', () => { 989 + const result = parseBlobScope('blob:image/*'); 990 + assert.deepStrictEqual(result, { accept: ['image/*'] }); 991 + }); 992 + 993 + test('parses specific MIME', () => { 994 + const result = parseBlobScope('blob:image/png'); 995 + assert.deepStrictEqual(result, { accept: ['image/png'] }); 996 + }); 997 + 998 + test('parses multiple MIMEs', () => { 999 + const result = parseBlobScope('blob:image/png,image/jpeg'); 1000 + assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] }); 1001 + }); 1002 + 1003 + test('returns null for non-blob scope', () => { 1004 + assert.strictEqual(parseBlobScope('atproto'), null); 1005 + assert.strictEqual(parseBlobScope('repo:*:create'), null); 1006 + }); 1007 + }); 1008 + 1009 + describe('matchesMime', () => { 1010 + test('wildcard matches everything', () => { 1011 + assert.strictEqual(matchesMime('*/*', 'image/png'), true); 1012 + assert.strictEqual(matchesMime('*/*', 'video/mp4'), true); 1013 + }); 1014 + 1015 + test('type wildcard matches same type', () => { 1016 + assert.strictEqual(matchesMime('image/*', 'image/png'), true); 1017 + assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true); 1018 + assert.strictEqual(matchesMime('image/*', 'video/mp4'), false); 1019 + }); 1020 + 1021 + test('exact match', () => { 1022 + assert.strictEqual(matchesMime('image/png', 'image/png'), true); 1023 + assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false); 1024 + }); 1025 + 1026 + test('case insensitive', () => { 1027 + assert.strictEqual(matchesMime('image/PNG', 'image/png'), true); 1028 + assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true); 1029 + }); 1030 + }); 1031 + }); 1032 + 1033 + describe('ScopePermissions', () => { 1034 + describe('static scopes', () => { 1035 + test('atproto grants full access', () => { 1036 + const perms = new ScopePermissions('atproto'); 1037 + assert.strictEqual( 1038 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1039 + true, 1040 + ); 1041 + assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 1042 + assert.strictEqual(perms.allowsBlob('image/png'), true); 1043 + assert.strictEqual(perms.allowsBlob('video/mp4'), true); 1044 + }); 1045 + 1046 + test('transition:generic grants full repo/blob access', () => { 1047 + const perms = new ScopePermissions('transition:generic'); 1048 + assert.strictEqual( 1049 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1050 + true, 1051 + ); 1052 + assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); 1053 + assert.strictEqual(perms.allowsBlob('image/png'), true); 1054 + }); 1055 + }); 1056 + 1057 + describe('repo scopes', () => { 1058 + test('wildcard collection allows any collection', () => { 1059 + const perms = new ScopePermissions('repo:*?action=create'); 1060 + assert.strictEqual( 1061 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1062 + true, 1063 + ); 1064 + assert.strictEqual( 1065 + perms.allowsRepo('app.bsky.feed.like', 'create'), 1066 + true, 1067 + ); 1068 + assert.strictEqual( 1069 + perms.allowsRepo('app.bsky.feed.post', 'delete'), 1070 + false, 1071 + ); 1072 + }); 1073 + 1074 + test('specific collection restricts to that collection', () => { 1075 + const perms = new ScopePermissions( 1076 + 'repo:app.bsky.feed.post?action=create', 1077 + ); 1078 + assert.strictEqual( 1079 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1080 + true, 1081 + ); 1082 + assert.strictEqual( 1083 + perms.allowsRepo('app.bsky.feed.like', 'create'), 1084 + false, 1085 + ); 1086 + }); 1087 + 1088 + test('multiple actions', () => { 1089 + const perms = new ScopePermissions('repo:*?action=create&action=update'); 1090 + assert.strictEqual(perms.allowsRepo('x', 'create'), true); 1091 + assert.strictEqual(perms.allowsRepo('x', 'update'), true); 1092 + assert.strictEqual(perms.allowsRepo('x', 'delete'), false); 1093 + }); 1094 + 1095 + test('multiple scopes combine', () => { 1096 + const perms = new ScopePermissions( 1097 + 'repo:app.bsky.feed.post?action=create repo:app.bsky.feed.like?action=delete', 1098 + ); 1099 + assert.strictEqual( 1100 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1101 + true, 1102 + ); 1103 + assert.strictEqual( 1104 + perms.allowsRepo('app.bsky.feed.like', 'delete'), 1105 + true, 1106 + ); 1107 + assert.strictEqual( 1108 + perms.allowsRepo('app.bsky.feed.post', 'delete'), 1109 + false, 1110 + ); 1111 + }); 1112 + 1113 + test('allowsRepo with query param format scopes', () => { 1114 + const perms = new ScopePermissions( 1115 + 'atproto repo:app.bsky.feed.post?action=create', 1116 + ); 1117 + assert.strictEqual( 1118 + perms.allowsRepo('app.bsky.feed.post', 'create'), 1119 + true, 1120 + ); 1121 + assert.strictEqual( 1122 + perms.allowsRepo('app.bsky.feed.post', 'delete'), 1123 + true, 1124 + ); // atproto grants full access 1125 + }); 1126 + }); 1127 + 1128 + describe('blob scopes', () => { 1129 + test('wildcard allows any MIME', () => { 1130 + const perms = new ScopePermissions('blob:*/*'); 1131 + assert.strictEqual(perms.allowsBlob('image/png'), true); 1132 + assert.strictEqual(perms.allowsBlob('video/mp4'), true); 1133 + }); 1134 + 1135 + test('type wildcard restricts to type', () => { 1136 + const perms = new ScopePermissions('blob:image/*'); 1137 + assert.strictEqual(perms.allowsBlob('image/png'), true); 1138 + assert.strictEqual(perms.allowsBlob('image/jpeg'), true); 1139 + assert.strictEqual(perms.allowsBlob('video/mp4'), false); 1140 + }); 1141 + 1142 + test('specific MIME restricts exactly', () => { 1143 + const perms = new ScopePermissions('blob:image/png'); 1144 + assert.strictEqual(perms.allowsBlob('image/png'), true); 1145 + assert.strictEqual(perms.allowsBlob('image/jpeg'), false); 1146 + }); 1147 + }); 1148 + 1149 + describe('empty/no scope', () => { 1150 + test('no scope denies everything', () => { 1151 + const perms = new ScopePermissions(''); 1152 + assert.strictEqual(perms.allowsRepo('x', 'create'), false); 1153 + assert.strictEqual(perms.allowsBlob('image/png'), false); 1154 + }); 1155 + 1156 + test('undefined scope denies everything', () => { 1157 + const perms = new ScopePermissions(undefined); 1158 + assert.strictEqual(perms.allowsRepo('x', 'create'), false); 1159 + }); 1160 + }); 1161 + 1162 + describe('assertRepo', () => { 1163 + test('throws ScopeMissingError when denied', () => { 1164 + const perms = new ScopePermissions( 1165 + 'repo:app.bsky.feed.post?action=create', 1166 + ); 1167 + assert.throws(() => perms.assertRepo('app.bsky.feed.like', 'create'), { 1168 + message: /Missing required scope/, 1169 + }); 1170 + }); 1171 + 1172 + test('does not throw when allowed', () => { 1173 + const perms = new ScopePermissions( 1174 + 'repo:app.bsky.feed.post?action=create', 1175 + ); 1176 + assert.doesNotThrow(() => 1177 + perms.assertRepo('app.bsky.feed.post', 'create'), 1178 + ); 1179 + }); 1180 + }); 1181 + 1182 + describe('assertBlob', () => { 1183 + test('throws ScopeMissingError when denied', () => { 1184 + const perms = new ScopePermissions('blob:image/*'); 1185 + assert.throws(() => perms.assertBlob('video/mp4'), { 1186 + message: /Missing required scope/, 1187 + }); 1188 + }); 1189 + 1190 + test('does not throw when allowed', () => { 1191 + const perms = new ScopePermissions('blob:image/*'); 1192 + assert.doesNotThrow(() => perms.assertBlob('image/png')); 1193 + }); 1194 + }); 1195 + }); 1196 + 1197 + describe('parseScopesForDisplay', () => { 1198 + test('parses identity-only scope', () => { 1199 + const result = parseScopesForDisplay('atproto'); 1200 + assert.strictEqual(result.hasAtproto, true); 1201 + assert.strictEqual(result.hasTransitionGeneric, false); 1202 + assert.strictEqual(result.repoPermissions.size, 0); 1203 + assert.deepStrictEqual(result.blobPermissions, []); 1204 + }); 1205 + 1206 + test('parses granular repo scopes', () => { 1207 + const result = parseScopesForDisplay( 1208 + 'atproto repo:app.bsky.feed.post?action=create&action=update', 1209 + ); 1210 + assert.strictEqual(result.repoPermissions.size, 1); 1211 + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 1212 + assert.deepStrictEqual(postPerms, { 1213 + create: true, 1214 + update: true, 1215 + delete: false, 1216 + }); 1217 + }); 1218 + 1219 + test('merges multiple scopes for same collection', () => { 1220 + const result = parseScopesForDisplay( 1221 + 'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete', 1222 + ); 1223 + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 1224 + assert.deepStrictEqual(postPerms, { 1225 + create: true, 1226 + update: false, 1227 + delete: true, 1228 + }); 1229 + }); 1230 + 1231 + test('parses blob scopes', () => { 1232 + const result = parseScopesForDisplay('atproto blob:image/*'); 1233 + assert.deepStrictEqual(result.blobPermissions, ['image/*']); 1234 + }); 1235 + 1236 + test('detects transition:generic', () => { 1237 + const result = parseScopesForDisplay('atproto transition:generic'); 1238 + assert.strictEqual(result.hasTransitionGeneric, true); 1239 + }); 1240 + 1241 + test('handles empty scope string', () => { 1242 + const result = parseScopesForDisplay(''); 1243 + assert.strictEqual(result.hasAtproto, false); 1244 + assert.strictEqual(result.hasTransitionGeneric, false); 1245 + assert.strictEqual(result.repoPermissions.size, 0); 1246 + assert.deepStrictEqual(result.blobPermissions, []); 1247 + }); 1248 + });