# OAuth Scope Validation Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implement granular OAuth scope validation matching the official atproto PDS behavior for repo, blob, and transition scopes. **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. **Tech Stack:** Pure JavaScript, no dependencies. Node.js test runner for TDD. --- ## Task 1: Parse Repo Scopes **Files:** - Modify: `src/pds.js` (add after `hasRequiredScope` function ~line 4565) - Test: `test/pds.test.js` (add new describe block) **Step 1: Write the failing tests** Add to `test/pds.test.js`: ```javascript import { // ... existing imports ... parseRepoScope, } from '../src/pds.js'; describe('Scope Parsing', () => { describe('parseRepoScope', () => { test('parses wildcard collection with single action', () => { const result = parseRepoScope('repo:*:create'); assert.deepStrictEqual(result, { collections: ['*'], actions: ['create'], }); }); test('parses specific collection with single action', () => { const result = parseRepoScope('repo:app.bsky.feed.post:create'); assert.deepStrictEqual(result, { collections: ['app.bsky.feed.post'], actions: ['create'], }); }); test('parses multiple actions', () => { const result = parseRepoScope('repo:*:create,update,delete'); assert.deepStrictEqual(result, { collections: ['*'], actions: ['create', 'update', 'delete'], }); }); test('returns null for non-repo scope', () => { assert.strictEqual(parseRepoScope('atproto'), null); assert.strictEqual(parseRepoScope('blob:image/*'), null); assert.strictEqual(parseRepoScope('transition:generic'), null); }); test('returns null for invalid repo scope', () => { assert.strictEqual(parseRepoScope('repo:'), null); assert.strictEqual(parseRepoScope('repo:foo'), null); assert.strictEqual(parseRepoScope('repo::create'), null); }); }); }); ``` **Step 2: Run tests to verify they fail** Run: `npm test` Expected: FAIL with "parseRepoScope is not exported" **Step 3: Write minimal implementation** Add to `src/pds.js` after the `hasRequiredScope` function (~line 4565): ```javascript /** * Parse a repo scope string into its components. * Format: repo::[,...] * @param {string} scope - The scope string to parse * @returns {{ collections: string[], actions: string[] } | null} Parsed scope or null if invalid */ function parseRepoScope(scope) { if (!scope.startsWith('repo:')) return null; const rest = scope.slice(5); // Remove 'repo:' const colonIdx = rest.lastIndexOf(':'); if (colonIdx === -1 || colonIdx === 0 || colonIdx === rest.length - 1) { return null; } const collection = rest.slice(0, colonIdx); const actionsStr = rest.slice(colonIdx + 1); if (!collection || !actionsStr) return null; const actions = actionsStr.split(',').filter(a => a); if (actions.length === 0) return null; return { collections: [collection], actions, }; } ``` Add `parseRepoScope` to the exports at the end of the file. **Step 4: Run tests to verify they pass** Run: `npm test` Expected: PASS **Step 5: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "feat(scope): add parseRepoScope function" ``` --- ## Task 2: Parse Blob Scopes with MIME Matching **Files:** - Modify: `src/pds.js` - Test: `test/pds.test.js` **Step 1: Write the failing tests** Add to test file: ```javascript import { // ... existing imports ... parseBlobScope, matchesMime, } from '../src/pds.js'; describe('parseBlobScope', () => { test('parses wildcard MIME', () => { const result = parseBlobScope('blob:*/*'); assert.deepStrictEqual(result, { accept: ['*/*'] }); }); test('parses type wildcard', () => { const result = parseBlobScope('blob:image/*'); assert.deepStrictEqual(result, { accept: ['image/*'] }); }); test('parses specific MIME', () => { const result = parseBlobScope('blob:image/png'); assert.deepStrictEqual(result, { accept: ['image/png'] }); }); test('parses multiple MIMEs', () => { const result = parseBlobScope('blob:image/png,image/jpeg'); assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] }); }); test('returns null for non-blob scope', () => { assert.strictEqual(parseBlobScope('atproto'), null); assert.strictEqual(parseBlobScope('repo:*:create'), null); }); }); describe('matchesMime', () => { test('wildcard matches everything', () => { assert.strictEqual(matchesMime('*/*', 'image/png'), true); assert.strictEqual(matchesMime('*/*', 'video/mp4'), true); }); test('type wildcard matches same type', () => { assert.strictEqual(matchesMime('image/*', 'image/png'), true); assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true); assert.strictEqual(matchesMime('image/*', 'video/mp4'), false); }); test('exact match', () => { assert.strictEqual(matchesMime('image/png', 'image/png'), true); assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false); }); test('case insensitive', () => { assert.strictEqual(matchesMime('image/PNG', 'image/png'), true); assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true); }); }); ``` **Step 2: Run tests to verify they fail** Run: `npm test` Expected: FAIL **Step 3: Write minimal implementation** ```javascript /** * Parse a blob scope string into its components. * Format: blob:[,...] * @param {string} scope - The scope string to parse * @returns {{ accept: string[] } | null} Parsed scope or null if invalid */ function parseBlobScope(scope) { if (!scope.startsWith('blob:')) return null; const mimeStr = scope.slice(5); // Remove 'blob:' if (!mimeStr) return null; const accept = mimeStr.split(',').filter(m => m); if (accept.length === 0) return null; return { accept }; } /** * Check if a MIME pattern matches an actual MIME type. * @param {string} pattern - MIME pattern (e.g., 'image/*', '*/*', 'image/png') * @param {string} mime - Actual MIME type to check * @returns {boolean} Whether the pattern matches */ function matchesMime(pattern, mime) { const p = pattern.toLowerCase(); const m = mime.toLowerCase(); if (p === '*/*') return true; if (p.endsWith('/*')) { const pType = p.slice(0, -2); const mType = m.split('/')[0]; return pType === mType; } return p === m; } ``` Add exports. **Step 4: Run tests to verify they pass** Run: `npm test` Expected: PASS **Step 5: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "feat(scope): add parseBlobScope and matchesMime functions" ``` --- ## Task 3: Create ScopePermissions Class **Files:** - Modify: `src/pds.js` - Test: `test/pds.test.js` **Step 1: Write the failing tests** ```javascript import { // ... existing imports ... ScopePermissions, } from '../src/pds.js'; describe('ScopePermissions', () => { describe('static scopes', () => { test('atproto grants full access', () => { const perms = new ScopePermissions('atproto'); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); assert.strictEqual(perms.allowsBlob('image/png'), true); assert.strictEqual(perms.allowsBlob('video/mp4'), true); }); test('transition:generic grants full repo/blob access', () => { const perms = new ScopePermissions('transition:generic'); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true); assert.strictEqual(perms.allowsBlob('image/png'), true); }); }); describe('repo scopes', () => { test('wildcard collection allows any collection', () => { const perms = new ScopePermissions('repo:*:create'); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'create'), true); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); }); test('specific collection restricts to that collection', () => { const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'create'), false); }); test('multiple actions', () => { const perms = new ScopePermissions('repo:*:create,update'); assert.strictEqual(perms.allowsRepo('x', 'create'), true); assert.strictEqual(perms.allowsRepo('x', 'update'), true); assert.strictEqual(perms.allowsRepo('x', 'delete'), false); }); test('multiple scopes combine', () => { const perms = new ScopePermissions('repo:app.bsky.feed.post:create repo:app.bsky.feed.like:delete'); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'delete'), true); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); }); }); describe('blob scopes', () => { test('wildcard allows any MIME', () => { const perms = new ScopePermissions('blob:*/*'); assert.strictEqual(perms.allowsBlob('image/png'), true); assert.strictEqual(perms.allowsBlob('video/mp4'), true); }); test('type wildcard restricts to type', () => { const perms = new ScopePermissions('blob:image/*'); assert.strictEqual(perms.allowsBlob('image/png'), true); assert.strictEqual(perms.allowsBlob('image/jpeg'), true); assert.strictEqual(perms.allowsBlob('video/mp4'), false); }); test('specific MIME restricts exactly', () => { const perms = new ScopePermissions('blob:image/png'); assert.strictEqual(perms.allowsBlob('image/png'), true); assert.strictEqual(perms.allowsBlob('image/jpeg'), false); }); }); describe('empty/no scope', () => { test('no scope denies everything', () => { const perms = new ScopePermissions(''); assert.strictEqual(perms.allowsRepo('x', 'create'), false); assert.strictEqual(perms.allowsBlob('image/png'), false); }); test('undefined scope denies everything', () => { const perms = new ScopePermissions(undefined); assert.strictEqual(perms.allowsRepo('x', 'create'), false); }); }); describe('assertRepo', () => { test('throws ScopeMissingError when denied', () => { const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); assert.throws( () => perms.assertRepo('app.bsky.feed.like', 'create'), { message: /Missing required scope/ } ); }); test('does not throw when allowed', () => { const perms = new ScopePermissions('repo:app.bsky.feed.post:create'); assert.doesNotThrow(() => perms.assertRepo('app.bsky.feed.post', 'create')); }); }); describe('assertBlob', () => { test('throws ScopeMissingError when denied', () => { const perms = new ScopePermissions('blob:image/*'); assert.throws( () => perms.assertBlob('video/mp4'), { message: /Missing required scope/ } ); }); test('does not throw when allowed', () => { const perms = new ScopePermissions('blob:image/*'); assert.doesNotThrow(() => perms.assertBlob('image/png')); }); }); }); ``` **Step 2: Run tests to verify they fail** Run: `npm test` Expected: FAIL **Step 3: Write minimal implementation** ```javascript /** * Error thrown when a required scope is missing. */ class ScopeMissingError extends Error { /** * @param {string} scope - The missing scope */ constructor(scope) { super(`Missing required scope "${scope}"`); this.name = 'ScopeMissingError'; this.scope = scope; this.status = 403; } } /** * Parses and checks OAuth scope permissions. */ class ScopePermissions { /** * @param {string | undefined} scopeString - Space-separated scope string */ constructor(scopeString) { /** @type {Set} */ this.scopes = new Set(scopeString ? scopeString.split(' ').filter(s => s) : []); /** @type {Array<{ collections: string[], actions: string[] }>} */ this.repoPermissions = []; /** @type {Array<{ accept: string[] }>} */ this.blobPermissions = []; for (const scope of this.scopes) { const repo = parseRepoScope(scope); if (repo) this.repoPermissions.push(repo); const blob = parseBlobScope(scope); if (blob) this.blobPermissions.push(blob); } } /** * Check if full access is granted (atproto or transition:generic). * @returns {boolean} */ hasFullAccess() { return this.scopes.has('atproto') || this.scopes.has('transition:generic'); } /** * Check if a repo operation is allowed. * @param {string} collection - The collection NSID * @param {string} action - The action (create, update, delete) * @returns {boolean} */ allowsRepo(collection, action) { if (this.hasFullAccess()) return true; for (const perm of this.repoPermissions) { const collectionMatch = perm.collections.includes('*') || perm.collections.includes(collection); const actionMatch = perm.actions.includes(action); if (collectionMatch && actionMatch) return true; } return false; } /** * Assert that a repo operation is allowed, throwing if not. * @param {string} collection - The collection NSID * @param {string} action - The action (create, update, delete) * @throws {ScopeMissingError} */ assertRepo(collection, action) { if (!this.allowsRepo(collection, action)) { throw new ScopeMissingError(`repo:${collection}:${action}`); } } /** * Check if a blob operation is allowed. * @param {string} mime - The MIME type of the blob * @returns {boolean} */ allowsBlob(mime) { if (this.hasFullAccess()) return true; for (const perm of this.blobPermissions) { for (const pattern of perm.accept) { if (matchesMime(pattern, mime)) return true; } } return false; } /** * Assert that a blob operation is allowed, throwing if not. * @param {string} mime - The MIME type of the blob * @throws {ScopeMissingError} */ assertBlob(mime) { if (!this.allowsBlob(mime)) { throw new ScopeMissingError(`blob:${mime}`); } } } ``` Add exports. **Step 4: Run tests to verify they pass** Run: `npm test` Expected: PASS **Step 5: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "feat(scope): add ScopePermissions class with repo/blob checking" ``` --- ## Task 4: Integrate Scope Checking into createRecord **Files:** - Modify: `src/pds.js` (handleRepoWrite function and createRecord handler) - Test: `test/e2e.test.js` (add scope enforcement tests) **Step 1: Understand the current flow** The `handleRepoWrite` function at line ~4597 currently does: ```javascript if (!hasRequiredScope(auth.scope, 'atproto')) { return errorResponse('Forbidden', 'Insufficient scope for repo write', 403); } ``` This needs to be replaced with per-endpoint scope checking. The collection is in `body.collection`. **Step 2: Modify handleRepoWrite to accept collection and action** Update `handleRepoWrite` in `src/pds.js`: ```javascript /** * @param {Request} request * @param {Env} env * @param {string} collection - The collection being written to * @param {string} action - The action being performed (create, update, delete) */ async function handleRepoWrite(request, env, collection, action) { const auth = await requireAuth(request, env); if ('error' in auth) return auth.error; // Validate scope for repo write using granular permissions if (auth.scope !== undefined) { const permissions = new ScopePermissions(auth.scope); if (!permissions.allowsRepo(collection, action)) { return errorResponse( 'Forbidden', `Missing required scope "repo:${collection}:${action}"`, 403, ); } } // Legacy tokens without scope are trusted (backward compat) // ... rest of function } ``` **Step 3: Update createRecord to pass collection and action** Find the createRecord handler in the routes object and update it to extract collection before calling handleRepoWrite. Since createRecord is POST, the collection comes from the body. We need to restructure slightly: ```javascript // In the route handler for com.atproto.repo.createRecord async (request, env) => { const auth = await requireAuth(request, env); if ('error' in auth) return auth.error; const body = await request.json(); const collection = body.collection; if (!collection) { return errorResponse('InvalidRequest', 'missing collection param', 400); } // Validate scope if (auth.scope !== undefined) { const permissions = new ScopePermissions(auth.scope); if (!permissions.allowsRepo(collection, 'create')) { return errorResponse( 'Forbidden', `Missing required scope "repo:${collection}:create"`, 403, ); } } // Continue with existing logic... } ``` **Step 4: Write E2E test for scope enforcement** Add to `test/e2e.test.js`: ```javascript describe('Scope Enforcement', () => { test('createRecord denied with insufficient scope', async () => { // Create OAuth token with limited scope const limitedToken = await getOAuthToken('repo:app.bsky.feed.like:create'); const response = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `DPoP ${limitedToken}`, 'DPoP': dpopProof, }, body: JSON.stringify({ repo: TEST_DID, collection: 'app.bsky.feed.post', // Not allowed by scope record: { text: 'test', createdAt: new Date().toISOString() }, }), }); assert.strictEqual(response.status, 403); const body = await response.json(); assert.ok(body.message.includes('Missing required scope')); }); test('createRecord allowed with matching scope', async () => { const validToken = await getOAuthToken('repo:app.bsky.feed.post:create'); const response = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `DPoP ${validToken}`, 'DPoP': dpopProof, }, body: JSON.stringify({ repo: TEST_DID, collection: 'app.bsky.feed.post', record: { text: 'test', createdAt: new Date().toISOString() }, }), }); assert.strictEqual(response.status, 200); }); }); ``` **Step 5: Run E2E tests** Run: `npm run test:e2e` Expected: PASS **Step 6: Commit** ```bash git add src/pds.js test/e2e.test.js git commit -m "feat(scope): enforce granular scopes on createRecord" ``` --- ## Task 5: Integrate Scope Checking into putRecord **Files:** - Modify: `src/pds.js` **Step 1: Update putRecord handler** putRecord requires BOTH create AND update permissions (since it can do either): ```javascript // In putRecord handler if (auth.scope !== undefined) { const permissions = new ScopePermissions(auth.scope); if (!permissions.allowsRepo(collection, 'create') || !permissions.allowsRepo(collection, 'update')) { const missing = !permissions.allowsRepo(collection, 'create') ? 'create' : 'update'; return errorResponse( 'Forbidden', `Missing required scope "repo:${collection}:${missing}"`, 403, ); } } ``` **Step 2: Run tests** Run: `npm test && npm run test:e2e` Expected: PASS **Step 3: Commit** ```bash git add src/pds.js git commit -m "feat(scope): enforce granular scopes on putRecord" ``` --- ## Task 6: Integrate Scope Checking into deleteRecord **Files:** - Modify: `src/pds.js` **Step 1: Update deleteRecord handler** ```javascript // In deleteRecord handler if (auth.scope !== undefined) { const permissions = new ScopePermissions(auth.scope); if (!permissions.allowsRepo(collection, 'delete')) { return errorResponse( 'Forbidden', `Missing required scope "repo:${collection}:delete"`, 403, ); } } ``` **Step 2: Run tests** Run: `npm test && npm run test:e2e` Expected: PASS **Step 3: Commit** ```bash git add src/pds.js git commit -m "feat(scope): enforce granular scopes on deleteRecord" ``` --- ## Task 7: Integrate Scope Checking into applyWrites **Files:** - Modify: `src/pds.js` **Step 1: Update applyWrites handler** applyWrites must check each write operation individually: ```javascript // In applyWrites handler if (auth.scope !== undefined) { const permissions = new ScopePermissions(auth.scope); for (const write of writes) { const collection = write.collection; let action; if (write.$type === 'com.atproto.repo.applyWrites#create') { action = 'create'; } else if (write.$type === 'com.atproto.repo.applyWrites#update') { action = 'update'; } else if (write.$type === 'com.atproto.repo.applyWrites#delete') { action = 'delete'; } else { continue; } if (!permissions.allowsRepo(collection, action)) { return errorResponse( 'Forbidden', `Missing required scope "repo:${collection}:${action}"`, 403, ); } } } ``` **Step 2: Run tests** Run: `npm test && npm run test:e2e` Expected: PASS **Step 3: Commit** ```bash git add src/pds.js git commit -m "feat(scope): enforce granular scopes on applyWrites" ``` --- ## Task 8: Integrate Scope Checking into uploadBlob **Files:** - Modify: `src/pds.js` (handleBlobUpload function) **Step 1: Update handleBlobUpload** The MIME type comes from the Content-Type header: ```javascript async function handleBlobUpload(request, env) { const auth = await requireAuth(request, env); if ('error' in auth) return auth.error; const contentType = request.headers.get('content-type') || 'application/octet-stream'; // Validate scope for blob upload if (auth.scope !== undefined) { const permissions = new ScopePermissions(auth.scope); if (!permissions.allowsBlob(contentType)) { return errorResponse( 'Forbidden', `Missing required scope "blob:${contentType}"`, 403, ); } } // ... rest of function } ``` **Step 2: Run tests** Run: `npm test && npm run test:e2e` Expected: PASS **Step 3: Commit** ```bash git add src/pds.js git commit -m "feat(scope): enforce granular scopes on uploadBlob with MIME matching" ``` --- ## Task 9: Remove Old hasRequiredScope Calls **Files:** - Modify: `src/pds.js` **Step 1: Search and remove old calls** Find all remaining uses of `hasRequiredScope` and either: - Remove them (if replaced by ScopePermissions) - Keep for legacy non-OAuth paths if needed **Step 2: Run all tests** Run: `npm test && npm run test:e2e` Expected: PASS **Step 3: Commit** ```bash git add src/pds.js git commit -m "refactor(scope): remove deprecated hasRequiredScope function" ``` --- ## Task 10: Update scope-comparison.md **Files:** - Modify: `docs/scope-comparison.md` **Step 1: Update status in comparison doc** Change the pds.js column entries to reflect new implementation: - `atproto`: "Full access" - `transition:generic`: "Full access" - `repo::`: "Full parsing + enforcement" - `blob:`: "Full parsing + enforcement" **Step 2: Commit** ```bash git add docs/scope-comparison.md git commit -m "docs: update scope comparison with implementation status" ``` --- ## Summary | Task | Description | Est. Time | |------|-------------|-----------| | 1 | Parse repo scopes | 5 min | | 2 | Parse blob scopes + MIME matching | 5 min | | 3 | ScopePermissions class | 10 min | | 4 | Integrate into createRecord | 10 min | | 5 | Integrate into putRecord | 5 min | | 6 | Integrate into deleteRecord | 5 min | | 7 | Integrate into applyWrites | 10 min | | 8 | Integrate into uploadBlob | 5 min | | 9 | Remove old hasRequiredScope | 5 min | | 10 | Update docs | 5 min | **Total: ~65 minutes**