diff --git a/CHANGELOG.md b/CHANGELOG.md index e03ad35..6683b14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Granular OAuth scope enforcement** on repo and blob endpoints + - `parseRepoScope()` parses `repo:collection?action=create&action=update` format + - `parseBlobScope()` parses `blob:image/*` format with MIME wildcards + - `ScopePermissions` class for checking repo/blob permissions + - Enforced on createRecord, putRecord, deleteRecord, applyWrites, uploadBlob +- **Consent page permissions table** displaying scopes in human-readable format + - Identity-only: "wants to uniquely identify you" message + - Granular scopes: Table with Collection + Create/Update/Delete columns + - Full access: Warning banner for `transition:generic` +- `parseScopesForDisplay()` helper for consent page rendering +- E2E tests for scope enforcement and consent page display + ## [0.2.0] - 2026-01-07 ### Added diff --git a/docs/plans/2026-01-08-consent-permissions-table.md b/docs/plans/2026-01-08-consent-permissions-table.md new file mode 100644 index 0000000..b9eb2ec --- /dev/null +++ b/docs/plans/2026-01-08-consent-permissions-table.md @@ -0,0 +1,563 @@ +# Consent Page Permissions Table Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Display OAuth scopes as a human-readable permissions table on the consent page, matching official atproto PDS behavior. + +**Architecture:** Update `parseRepoScope()` to handle official query parameter format, add display helpers to parse scopes into a permissions map, render as HTML table with Create/Update/Delete columns. Three display modes: identity-only (no table), granular scopes (table), full access (warning banner). + +**Tech Stack:** Vanilla JavaScript, HTML/CSS (inline in template string) + +--- + +### Task 1: Update parseRepoScope to Handle Query Parameters + +**Files:** +- Modify: `src/pds.js:4558-4580` (parseRepoScope function) +- Test: `test/pds.test.js` (parseRepoScope tests) + +**Step 1: Write failing tests for new format** + +Add to existing parseRepoScope test block in `test/pds.test.js`: + +```javascript +test('parses repo scope with query parameter action', () => { + const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create'], + }); +}); + +test('parses repo scope with multiple query parameter actions', () => { + const result = parseRepoScope('repo:app.bsky.feed.post?action=create&action=update'); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create', 'update'], + }); +}); + +test('parses repo scope without actions as all actions', () => { + const result = parseRepoScope('repo:app.bsky.feed.post'); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create', 'update', 'delete'], + }); +}); + +test('parses wildcard collection with action', () => { + const result = parseRepoScope('repo:*?action=create'); + assert.deepStrictEqual(result, { + collection: '*', + actions: ['create'], + }); +}); + +test('parses query-only format', () => { + const result = parseRepoScope('repo?collection=app.bsky.feed.post&action=create'); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create'], + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test 2>&1 | grep -A2 'parses repo scope with query'` +Expected: FAIL - current parser doesn't handle query params + +**Step 3: Rewrite parseRepoScope implementation** + +Replace the existing `parseRepoScope` function in `src/pds.js`: + +```javascript +/** + * Parse a repo scope string into collection and actions. + * Official format: repo:collection?action=create&action=update + * Or: repo?collection=foo&action=create + * Without actions defaults to all: create, update, delete + * @param {string} scope - The scope string to parse + * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid + */ +export function parseRepoScope(scope) { + if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; + + const ALL_ACTIONS = ['create', 'update', 'delete']; + let collection; + let actions; + + const questionIdx = scope.indexOf('?'); + if (questionIdx === -1) { + // repo:collection (no query params = all actions) + collection = scope.slice(5); + actions = ALL_ACTIONS; + } else { + // Parse query parameters + const queryString = scope.slice(questionIdx + 1); + const params = new URLSearchParams(queryString); + const pathPart = scope.startsWith('repo:') ? scope.slice(5, questionIdx) : ''; + + collection = pathPart || params.get('collection'); + actions = params.getAll('action'); + if (actions.length === 0) actions = ALL_ACTIONS; + } + + if (!collection) return null; + + // Validate actions + const validActions = actions.filter((a) => ALL_ACTIONS.includes(a)); + if (validActions.length === 0) return null; + + return { collection, actions: validActions }; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm test` +Expected: All parseRepoScope tests pass + +**Step 5: Remove old format tests that no longer apply** + +Remove tests for colon-delimited action format (e.g., `repo:collection:create,update`) from test file. + +**Step 6: Run tests to verify still passing** + +Run: `npm test` +Expected: PASS + +**Step 7: Commit** + +```bash +git add src/pds.js test/pds.test.js +git commit -m "refactor(scope): update parseRepoScope to official query param format" +``` + +--- + +### Task 2: Update ScopePermissions to Use New Parser + +**Files:** +- Modify: `src/pds.js:4700-4710` (assertRepo method) +- Test: `test/pds.test.js` (ScopePermissions tests) + +**Step 1: Update ScopePermissions.allowsRepo to handle new format** + +The `allowsRepo` method should still work since it iterates `repoPermissions` which now have new structure. Verify with test. + +**Step 2: Write test for new format compatibility** + +```javascript +test('allowsRepo with query param format scopes', () => { + const perms = new ScopePermissions('atproto repo:app.bsky.feed.post?action=create'); + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); + assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); +}); +``` + +**Step 3: Run test** + +Run: `npm test` +Expected: PASS (existing logic should work) + +**Step 4: Update assertRepo error message format** + +In `assertRepo` method, update the error message to use official format: + +```javascript +assertRepo(collection, action) { + if (!this.allowsRepo(collection, action)) { + throw new ScopeMissingError(`repo:${collection}?action=${action}`); + } +} +``` + +**Step 5: Run tests** + +Run: `npm test` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/pds.js test/pds.test.js +git commit -m "refactor(scope): update ScopePermissions for query param format" +``` + +--- + +### Task 3: Add parseScopesForDisplay Helper + +**Files:** +- Modify: `src/pds.js` (add new function near renderConsentPage) +- Test: `test/pds.test.js` + +**Step 1: Write failing test** + +```javascript +describe('parseScopesForDisplay', () => { + test('parses identity-only scope', () => { + const result = parseScopesForDisplay('atproto'); + assert.strictEqual(result.hasAtproto, true); + assert.strictEqual(result.hasTransitionGeneric, false); + assert.strictEqual(result.repoPermissions.size, 0); + assert.deepStrictEqual(result.blobPermissions, []); + }); + + test('parses granular repo scopes', () => { + const result = parseScopesForDisplay('atproto repo:app.bsky.feed.post?action=create&action=update'); + assert.strictEqual(result.repoPermissions.size, 1); + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); + assert.deepStrictEqual(postPerms, { create: true, update: true, delete: false }); + }); + + test('merges multiple scopes for same collection', () => { + const result = parseScopesForDisplay('atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete'); + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); + assert.deepStrictEqual(postPerms, { create: true, update: false, delete: true }); + }); + + test('parses blob scopes', () => { + const result = parseScopesForDisplay('atproto blob:image/*'); + assert.deepStrictEqual(result.blobPermissions, ['image/*']); + }); + + test('detects transition:generic', () => { + const result = parseScopesForDisplay('atproto transition:generic'); + assert.strictEqual(result.hasTransitionGeneric, true); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm test 2>&1 | grep -A2 'parseScopesForDisplay'` +Expected: FAIL - function doesn't exist + +**Step 3: Add export to pds.js and implement** + +```javascript +/** + * Parse scope string into display-friendly structure. + * @param {string} scope - Space-separated scope string + * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map, blobPermissions: string[] }} + */ +export function parseScopesForDisplay(scope) { + const scopes = scope.split(' ').filter((s) => s); + + const repoPermissions = new Map(); + + for (const s of scopes) { + const repo = parseRepoScope(s); + if (repo) { + const existing = repoPermissions.get(repo.collection) || { + create: false, + update: false, + delete: false, + }; + for (const action of repo.actions) { + existing[action] = true; + } + repoPermissions.set(repo.collection, existing); + } + } + + const blobPermissions = []; + for (const s of scopes) { + const blob = parseBlobScope(s); + if (blob) blobPermissions.push(...blob.accept); + } + + return { + hasAtproto: scopes.includes('atproto'), + hasTransitionGeneric: scopes.includes('transition:generic'), + repoPermissions, + blobPermissions, + }; +} +``` + +**Step 4: Run tests** + +Run: `npm test` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/pds.js test/pds.test.js +git commit -m "feat(consent): add parseScopesForDisplay helper" +``` + +--- + +### Task 4: Add Permission Rendering Helpers + +**Files:** +- Modify: `src/pds.js` (add functions near renderConsentPage) + +**Step 1: Add renderRepoTable helper** + +```javascript +/** + * Render repo permissions as HTML table. + * @param {Map} repoPermissions + * @returns {string} HTML string + */ +function renderRepoTable(repoPermissions) { + if (repoPermissions.size === 0) return ''; + + let rows = ''; + for (const [collection, actions] of repoPermissions) { + const displayCollection = collection === '*' ? '* (any)' : collection; + rows += ` + ${escapeHtml(displayCollection)} + ${actions.create ? '✓' : ''} + ${actions.update ? '✓' : ''} + ${actions.delete ? '✓' : ''} + `; + } + + return `
+ + + + ${rows} +
CollectionCUD
+
`; +} +``` + +**Step 2: Add renderBlobList helper** + +```javascript +/** + * Render blob permissions as HTML list. + * @param {string[]} blobPermissions + * @returns {string} HTML string + */ +function renderBlobList(blobPermissions) { + if (blobPermissions.length === 0) return ''; + + const items = blobPermissions + .map((mime) => `
  • ${escapeHtml(mime === '*/*' ? 'All file types' : mime)}
  • `) + .join(''); + + return `
    + +
      ${items}
    +
    `; +} +``` + +**Step 3: Add renderPermissionsHtml helper** + +```javascript +/** + * Render full permissions display based on parsed scopes. + * @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map, blobPermissions: string[] }} parsed + * @returns {string} HTML string + */ +function renderPermissionsHtml(parsed) { + if (parsed.hasTransitionGeneric) { + return `
    ⚠️ Full repository access requested
    + This app can create, update, and delete any data in your repository.
    `; + } + + if (parsed.repoPermissions.size === 0 && parsed.blobPermissions.length === 0) { + return ''; + } + + return renderRepoTable(parsed.repoPermissions) + renderBlobList(parsed.blobPermissions); +} +``` + +**Step 4: Add escapeHtml helper (if not exists)** + +Check if `escHtml` exists in renderConsentPage - rename to `escapeHtml` and move outside function for reuse, or create new one: + +```javascript +/** + * Escape HTML special characters. + * @param {string} s + * @returns {string} + */ +function escapeHtml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} +``` + +**Step 5: Run lint/format** + +Run: `npm run format && npm run lint` +Expected: PASS + +**Step 6: Commit** + +```bash +git add src/pds.js +git commit -m "feat(consent): add permission rendering helpers" +``` + +--- + +### Task 5: Update renderConsentPage + +**Files:** +- Modify: `src/pds.js:583-628` (renderConsentPage function) + +**Step 1: Add new CSS to renderConsentPage** + +Add to the ` -

    Sign in to authorize

    -

    ${escHtml(clientName)} wants to access your account.

    -

    Scope: ${escHtml(scope)}

    -${error ? `

    ${escHtml(error)}

    ` : ''} -
    - - - -
    -
    -
    `; -} /** * Encode integer as unsigned varint @@ -3241,31 +3190,35 @@ export class PersonalDataServer { /** @param {Request} request */ async handleUploadBlob(request) { - // Require auth - const authHeader = request.headers.get('Authorization'); - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return errorResponse( - 'AuthRequired', - 'Missing or invalid authorization header', - 401, - ); - } + // Check if auth was already done by outer handler (OAuth/DPoP flow) + const authedDid = request.headers.get('x-authed-did'); + if (!authedDid) { + // Fallback to legacy Bearer token auth + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return errorResponse( + 'AuthRequired', + 'Missing or invalid authorization header', + 401, + ); + } - const token = authHeader.slice(7); - const jwtSecret = this.env?.JWT_SECRET; - if (!jwtSecret) { - return errorResponse( - 'InternalServerError', - 'Server not configured for authentication', - 500, - ); - } + const token = authHeader.slice(7); + const jwtSecret = this.env?.JWT_SECRET; + if (!jwtSecret) { + return errorResponse( + 'InternalServerError', + 'Server not configured for authentication', + 500, + ); + } - try { - await verifyAccessJwt(token, jwtSecret); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return errorResponse('InvalidToken', message, 401); + try { + await verifyAccessJwt(token, jwtSecret); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return errorResponse('InvalidToken', message, 401); + } } const did = await this.getDid(); @@ -4549,19 +4502,396 @@ async function verifyOAuthAccessToken(request, token, pdsStub) { return { did: payload.sub, scope: payload.scope }; } +// ╔══════════════════════════════════════════════════════════════════════════════╗ +// ║ SCOPES ║ +// ║ OAuth scope parsing and permission checking ║ +// ╚══════════════════════════════════════════════════════════════════════════════╝ + +/** + * Parse a repo scope string into collection and actions. + * Official format: repo:collection?action=create&action=update + * Or: repo?collection=foo&action=create + * Without actions defaults to all: create, update, delete + * @param {string} scope - The scope string to parse + * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid + */ +export function parseRepoScope(scope) { + if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; + + const ALL_ACTIONS = ['create', 'update', 'delete']; + let collection; + let actions; + + const questionIdx = scope.indexOf('?'); + if (questionIdx === -1) { + // repo:collection (no query params = all actions) + collection = scope.slice(5); + actions = ALL_ACTIONS; + } else { + // Parse query parameters + const queryString = scope.slice(questionIdx + 1); + const params = new URLSearchParams(queryString); + const pathPart = scope.startsWith('repo:') + ? scope.slice(5, questionIdx) + : ''; + + collection = pathPart || params.get('collection'); + actions = params.getAll('action'); + if (actions.length === 0) actions = ALL_ACTIONS; + } + + if (!collection) return null; + + // Validate actions + const validActions = [ + ...new Set(actions.filter((a) => ALL_ACTIONS.includes(a))), + ]; + if (validActions.length === 0) return null; + + return { collection, actions: validActions }; +} + +/** + * 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 + */ +export 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 + */ +export 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; +} + /** - * Check if the token scope allows the requested operation. - * Legacy tokens (no scope) are always allowed; OAuth tokens must have 'atproto' scope. - * @param {string | undefined} scope - The token scope - * @param {string} requiredScope - The required scope (e.g., 'atproto') - * @returns {boolean} Whether the scope is sufficient + * Error thrown when a required scope is missing. */ -function hasRequiredScope(scope, requiredScope) { - // Legacy tokens without scope are trusted for all operations - if (!scope) return true; - // Check if the scope includes the required scope - const scopes = scope.split(' '); - return scopes.includes(requiredScope); +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. + */ +export 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<{ collection: 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.collection === '*' || perm.collection === 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=${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}`); + } + } +} + +// ╔══════════════════════════════════════════════════════════════════════════════╗ +// ║ CONSENT PAGE DISPLAY ║ +// ║ OAuth consent page rendering with scope visualization ║ +// ╚══════════════════════════════════════════════════════════════════════════════╝ + +/** + * Parse scope string into display-friendly structure. + * @param {string} scope - Space-separated scope string + * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map, blobPermissions: string[] }} + */ +export function parseScopesForDisplay(scope) { + const scopes = scope.split(' ').filter((s) => s); + + const repoPermissions = new Map(); + + for (const s of scopes) { + const repo = parseRepoScope(s); + if (repo) { + const existing = repoPermissions.get(repo.collection) || { + create: false, + update: false, + delete: false, + }; + for (const action of repo.actions) { + existing[action] = true; + } + repoPermissions.set(repo.collection, existing); + } + } + + const blobPermissions = []; + for (const s of scopes) { + const blob = parseBlobScope(s); + if (blob) blobPermissions.push(...blob.accept); + } + + return { + hasAtproto: scopes.includes('atproto'), + hasTransitionGeneric: scopes.includes('transition:generic'), + repoPermissions, + blobPermissions, + }; +} + +/** + * Escape HTML special characters. + * @param {string} s + * @returns {string} + */ +function escapeHtml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Render repo permissions as HTML table. + * @param {Map} repoPermissions + * @returns {string} HTML string + */ +function renderRepoTable(repoPermissions) { + if (repoPermissions.size === 0) return ''; + + let rows = ''; + for (const [collection, actions] of repoPermissions) { + const displayCollection = collection === '*' ? '* (any)' : collection; + rows += ` + ${escapeHtml(displayCollection)} + ${actions.create ? '✓' : ''} + ${actions.update ? '✓' : ''} + ${actions.delete ? '✓' : ''} + `; + } + + return `
    + + + + ${rows} +
    CollectionCUD
    +
    `; +} + +/** + * Render blob permissions as HTML list. + * @param {string[]} blobPermissions + * @returns {string} HTML string + */ +function renderBlobList(blobPermissions) { + if (blobPermissions.length === 0) return ''; + + const items = blobPermissions + .map( + (mime) => + `
  • ${escapeHtml(mime === '*/*' ? 'All file types' : mime)}
  • `, + ) + .join(''); + + return `
    + +
      ${items}
    +
    `; +} + +/** + * Render full permissions display based on parsed scopes. + * @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map, blobPermissions: string[] }} parsed + * @returns {string} HTML string + */ +function renderPermissionsHtml(parsed) { + if (parsed.hasTransitionGeneric) { + return `
    ⚠️ Full repository access requested
    + This app can create, update, and delete any data in your repository.
    `; + } + + if ( + parsed.repoPermissions.size === 0 && + parsed.blobPermissions.length === 0 + ) { + return ''; + } + + return ( + renderRepoTable(parsed.repoPermissions) + + renderBlobList(parsed.blobPermissions) + ); +} + +/** + * Render the OAuth consent page HTML. + * @param {{ clientName: string, clientId: string, scope: string, requestUri: string, error?: string }} params + * @returns {string} HTML page content + */ +function renderConsentPage({ + clientName, + clientId, + scope, + requestUri, + error = '', +}) { + const parsed = parseScopesForDisplay(scope); + const isIdentityOnly = + parsed.repoPermissions.size === 0 && + parsed.blobPermissions.length === 0 && + !parsed.hasTransitionGeneric; + + return ` + +Authorize + +

    Sign in to authorize

    +

    ${escapeHtml(clientName)} ${isIdentityOnly ? 'wants to uniquely identify you through your account.' : 'wants to access your account.'}

    +${renderPermissionsHtml(parsed)} +${error ? `

    ${escapeHtml(error)}

    ` : ''} +
    + + + +
    +
    +
    `; } /** @@ -4575,18 +4905,34 @@ async function handleAuthenticatedBlobUpload(request, env) { if ('error' in auth) return auth.error; // Validate scope for blob upload - if (!hasRequiredScope(auth.scope, 'atproto')) { - return errorResponse( - 'Forbidden', - 'Insufficient scope for blob upload', - 403, - ); + if (auth.scope !== undefined) { + const contentType = + request.headers.get('content-type') || 'application/octet-stream'; + const permissions = new ScopePermissions(auth.scope); + if (!permissions.allowsBlob(contentType)) { + return errorResponse( + 'Forbidden', + `Missing required scope "blob:${contentType}"`, + 403, + ); + } } + // Legacy tokens without scope are trusted (backward compat) // Route to the user's DO based on their DID from the token const id = env.PDS.idFromName(auth.did); const pds = env.PDS.get(id); - return pds.fetch(request); + // Pass x-authed-did so DO knows auth was already done (avoids DPoP replay detection) + return pds.fetch( + new Request(request.url, { + method: request.method, + headers: { + ...Object.fromEntries(request.headers), + 'x-authed-did': auth.did, + }, + body: request.body, + }), + ); } /** @@ -4599,11 +4945,6 @@ async function handleAuthenticatedRepoWrite(request, env) { const auth = await requireAuth(request, env, defaultPds); if ('error' in auth) return auth.error; - // Validate scope for repo write - if (!hasRequiredScope(auth.scope, 'atproto')) { - return errorResponse('Forbidden', 'Insufficient scope for repo write', 403); - } - const body = await request.json(); const repo = body.repo; if (!repo) { @@ -4614,6 +4955,84 @@ async function handleAuthenticatedRepoWrite(request, env) { return errorResponse('Forbidden', "Cannot modify another user's repo", 403); } + // Granular scope validation for OAuth tokens + if (auth.scope !== undefined) { + const permissions = new ScopePermissions(auth.scope); + const url = new URL(request.url); + const endpoint = url.pathname; + + if (endpoint === '/xrpc/com.atproto.repo.createRecord') { + const collection = body.collection; + if (!collection) { + return errorResponse('InvalidRequest', 'missing collection param', 400); + } + if (!permissions.allowsRepo(collection, 'create')) { + return errorResponse( + 'Forbidden', + `Missing required scope "repo:${collection}:create"`, + 403, + ); + } + } else if (endpoint === '/xrpc/com.atproto.repo.putRecord') { + const collection = body.collection; + if (!collection) { + return errorResponse('InvalidRequest', 'missing collection param', 400); + } + // putRecord requires both create and update permissions + 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, + ); + } + } else if (endpoint === '/xrpc/com.atproto.repo.deleteRecord') { + const collection = body.collection; + if (!collection) { + return errorResponse('InvalidRequest', 'missing collection param', 400); + } + if (!permissions.allowsRepo(collection, 'delete')) { + return errorResponse( + 'Forbidden', + `Missing required scope "repo:${collection}:delete"`, + 403, + ); + } + } else if (endpoint === '/xrpc/com.atproto.repo.applyWrites') { + const writes = body.writes || []; + for (const write of writes) { + const collection = write.collection; + if (!collection) continue; + + 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, + ); + } + } + } + } + // Legacy tokens without scope are trusted (backward compat) + const id = env.PDS.idFromName(repo); const pds = env.PDS.get(id); const response = await pds.fetch( diff --git a/test/e2e.test.js b/test/e2e.test.js index 1a1ddf5..e263b0b 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -3,11 +3,12 @@ * Uses Node's built-in test runner and fetch */ -import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; +import { after, before, describe, it } from 'node:test'; import { DpopClient } from './helpers/dpop.js'; +import { getOAuthTokenWithScope } from './helpers/oauth.js'; const BASE = 'http://localhost:8787'; const DID = `did:plc:test${randomBytes(8).toString('hex')}`; @@ -1025,6 +1026,432 @@ describe('E2E Tests', () => { }); }); + describe('Scope Enforcement', () => { + it('createRecord denied with insufficient scope', async () => { + // Get token that only allows creating likes, not posts + const { accessToken, dpop } = await getOAuthTokenWithScope( + 'repo:app.bsky.feed.like?action=create', + DID, + PASSWORD, + ); + + const proof = await dpop.createProof( + 'POST', + `${BASE}/xrpc/com.atproto.repo.createRecord`, + accessToken, + ); + + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `DPoP ${accessToken}`, + DPoP: proof, + }, + body: JSON.stringify({ + repo: DID, + collection: 'app.bsky.feed.post', // Not allowed by scope + record: { text: 'test', createdAt: new Date().toISOString() }, + }), + }); + + assert.strictEqual(res.status, 403, 'Should reject with 403'); + const body = await res.json(); + assert.ok( + body.message?.includes('Missing required scope'), + 'Error should mention missing scope', + ); + }); + + it('createRecord allowed with matching scope', async () => { + // Get token that allows creating posts + const { accessToken, dpop } = await getOAuthTokenWithScope( + 'repo:app.bsky.feed.post?action=create', + DID, + PASSWORD, + ); + + const proof = await dpop.createProof( + 'POST', + `${BASE}/xrpc/com.atproto.repo.createRecord`, + accessToken, + ); + + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `DPoP ${accessToken}`, + DPoP: proof, + }, + body: JSON.stringify({ + repo: DID, + collection: 'app.bsky.feed.post', + record: { text: 'scope test', createdAt: new Date().toISOString() }, + }), + }); + + assert.strictEqual(res.status, 200, 'Should allow with correct scope'); + const body = await res.json(); + assert.ok(body.uri, 'Should return uri'); + + // Note: We don't clean up here because our token only has create scope + // The record will be cleaned up by subsequent tests with full-access tokens + }); + + it('createRecord allowed with wildcard collection scope', async () => { + // Get token that allows creating any record type + const { accessToken, dpop } = await getOAuthTokenWithScope( + 'repo:*?action=create', + DID, + PASSWORD, + ); + + const proof = await dpop.createProof( + 'POST', + `${BASE}/xrpc/com.atproto.repo.createRecord`, + accessToken, + ); + + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `DPoP ${accessToken}`, + DPoP: proof, + }, + body: JSON.stringify({ + repo: DID, + collection: 'app.bsky.feed.post', + record: { + text: 'wildcard scope test', + createdAt: new Date().toISOString(), + }, + }), + }); + + assert.strictEqual( + res.status, + 200, + 'Wildcard scope should allow any collection', + ); + }); + + it('deleteRecord denied without delete scope', async () => { + // Get token that only has create scope + const { accessToken, dpop } = await getOAuthTokenWithScope( + 'repo:app.bsky.feed.post?action=create', + DID, + PASSWORD, + ); + + const proof = await dpop.createProof( + 'POST', + `${BASE}/xrpc/com.atproto.repo.deleteRecord`, + accessToken, + ); + + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.deleteRecord`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `DPoP ${accessToken}`, + DPoP: proof, + }, + body: JSON.stringify({ + repo: DID, + collection: 'app.bsky.feed.post', + rkey: 'nonexistent', // Doesn't matter, should fail on scope first + }), + }); + + assert.strictEqual( + res.status, + 403, + 'Should reject delete without delete scope', + ); + }); + + it('uploadBlob denied with mismatched MIME scope', async () => { + // Get token that only allows image uploads + const { accessToken, dpop } = await getOAuthTokenWithScope( + 'blob:image/*', + DID, + PASSWORD, + ); + + const proof = await dpop.createProof( + 'POST', + `${BASE}/xrpc/com.atproto.repo.uploadBlob`, + accessToken, + ); + + // Try to upload a video (not allowed by scope) + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: { + 'Content-Type': 'video/mp4', + Authorization: `DPoP ${accessToken}`, + DPoP: proof, + }, + body: new Uint8Array([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70]), // Fake MP4 header + }); + + assert.strictEqual( + res.status, + 403, + 'Should reject video upload with image-only scope', + ); + const body = await res.json(); + assert.ok( + body.message?.includes('Missing required scope'), + 'Error should mention missing scope', + ); + }); + + it('uploadBlob allowed with matching MIME scope', async () => { + // Get token that allows image uploads + const { accessToken, dpop } = await getOAuthTokenWithScope( + 'blob:image/*', + DID, + PASSWORD, + ); + + const proof = await dpop.createProof( + 'POST', + `${BASE}/xrpc/com.atproto.repo.uploadBlob`, + accessToken, + ); + + // Minimal PNG + const pngBytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, { + method: 'POST', + headers: { + 'Content-Type': 'image/png', + Authorization: `DPoP ${accessToken}`, + DPoP: proof, + }, + body: pngBytes, + }); + + assert.strictEqual( + res.status, + 200, + 'Should allow image upload with image scope', + ); + }); + + it('transition:generic grants full access', async () => { + // Get token with transition:generic scope (full access) + const { accessToken, dpop } = await getOAuthTokenWithScope( + 'transition:generic', + DID, + PASSWORD, + ); + + const proof = await dpop.createProof( + 'POST', + `${BASE}/xrpc/com.atproto.repo.createRecord`, + accessToken, + ); + + const res = await fetch(`${BASE}/xrpc/com.atproto.repo.createRecord`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `DPoP ${accessToken}`, + DPoP: proof, + }, + body: JSON.stringify({ + repo: DID, + collection: 'app.bsky.feed.post', + record: { + text: 'transition scope test', + createdAt: new Date().toISOString(), + }, + }), + }); + + assert.strictEqual( + res.status, + 200, + 'transition:generic should grant full access', + ); + }); + }); + + describe('Consent page display', () => { + it('consent page shows permissions table for granular scopes', async () => { + const dpop = await DpopClient.create(); + const clientId = 'http://localhost:3000'; + const redirectUri = 'http://localhost:3000/callback'; + const codeVerifier = randomBytes(32).toString('base64url'); + + const challengeBuffer = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(codeVerifier), + ); + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); + + // PAR request with granular scopes + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); + const parRes = await fetch(`${BASE}/oauth/par`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: parProof, + }, + body: new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: + 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state: 'test-state', + login_hint: DID, + }).toString(), + }); + + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); + const { request_uri } = await parRes.json(); + + // GET the authorize page + const authorizeRes = await fetch( + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, + ); + + const html = await authorizeRes.text(); + + // Verify permissions table is rendered + assert.ok( + html.includes('Repository permissions:'), + 'Should show repo permissions section', + ); + assert.ok( + html.includes('app.bsky.feed.post'), + 'Should show collection name', + ); + assert.ok( + html.includes('Upload permissions:'), + 'Should show upload permissions section', + ); + assert.ok(html.includes('image/*'), 'Should show blob MIME type'); + }); + + it('consent page shows identity message for atproto-only scope', async () => { + const dpop = await DpopClient.create(); + const clientId = 'http://localhost:3000'; + const redirectUri = 'http://localhost:3000/callback'; + const codeVerifier = randomBytes(32).toString('base64url'); + + const challengeBuffer = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(codeVerifier), + ); + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); + + // PAR request with atproto only (identity-only) + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); + const parRes = await fetch(`${BASE}/oauth/par`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: parProof, + }, + body: new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'atproto', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state: 'test-state', + login_hint: DID, + }).toString(), + }); + + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); + const { request_uri } = await parRes.json(); + + // GET the authorize page + const authorizeRes = await fetch( + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, + ); + + const html = await authorizeRes.text(); + + // Verify identity-only message + assert.ok( + html.includes('wants to uniquely identify you'), + 'Should show identity-only message', + ); + assert.ok( + !html.includes('Repository permissions:'), + 'Should NOT show permissions table', + ); + }); + + it('consent page shows warning for transition:generic scope', async () => { + const dpop = await DpopClient.create(); + const clientId = 'http://localhost:3000'; + const redirectUri = 'http://localhost:3000/callback'; + const codeVerifier = randomBytes(32).toString('base64url'); + + const challengeBuffer = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(codeVerifier), + ); + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); + + // PAR request with transition:generic (full access) + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); + const parRes = await fetch(`${BASE}/oauth/par`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: parProof, + }, + body: new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'atproto transition:generic', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state: 'test-state', + login_hint: DID, + }).toString(), + }); + + assert.strictEqual(parRes.status, 200, 'PAR should succeed'); + const { request_uri } = await parRes.json(); + + // GET the authorize page + const authorizeRes = await fetch( + `${BASE}/oauth/authorize?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(request_uri)}`, + ); + + const html = await authorizeRes.text(); + + // Verify warning banner + assert.ok( + html.includes('Full repository access requested'), + 'Should show full access warning', + ); + }); + }); + describe('Cleanup', () => { it('deleteRecord (cleanup)', async () => { const { status } = await jsonPost( diff --git a/test/helpers/oauth.js b/test/helpers/oauth.js new file mode 100644 index 0000000..19671f2 --- /dev/null +++ b/test/helpers/oauth.js @@ -0,0 +1,85 @@ +/** + * OAuth flow helpers for e2e tests + */ + +import { randomBytes } from 'node:crypto'; +import { DpopClient } from './dpop.js'; + +const BASE = 'http://localhost:8787'; + +/** + * Get an OAuth token with a specific scope via full PAR -> authorize -> token flow + * @param {string} scope - The scope to request + * @param {string} did - The DID to authenticate as + * @param {string} password - The password for authentication + * @returns {Promise<{accessToken: string, refreshToken: string, dpop: DpopClient}>} + */ +export async function getOAuthTokenWithScope(scope, did, password) { + const dpop = await DpopClient.create(); + const clientId = 'http://localhost:3000'; + const redirectUri = 'http://localhost:3000/callback'; + const codeVerifier = randomBytes(32).toString('base64url'); + const challengeBuffer = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(codeVerifier), + ); + const codeChallenge = Buffer.from(challengeBuffer).toString('base64url'); + + // PAR request + const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`); + const parRes = await fetch(`${BASE}/oauth/par`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: parProof, + }, + body: new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: scope, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + login_hint: did, + }).toString(), + }); + const parData = await parRes.json(); + + // Authorize + const authRes = await fetch(`${BASE}/oauth/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + request_uri: parData.request_uri, + client_id: clientId, + password: password, + }).toString(), + redirect: 'manual', + }); + const location = authRes.headers.get('location'); + const authCode = new URL(location).searchParams.get('code'); + + // Token exchange + const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`); + const tokenRes = await fetch(`${BASE}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: tokenProof, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: authCode, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }).toString(), + }); + const tokenData = await tokenRes.json(); + + return { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + dpop, + }; +} diff --git a/test/pds.test.js b/test/pds.test.js index cff98ae..c95b6fe 100644 --- a/test/pds.test.js +++ b/test/pds.test.js @@ -12,8 +12,8 @@ import { cidToString, computeJwkThumbprint, createAccessJwt, - createCid, createBlobCid, + createCid, createRefreshJwt, createTid, findBlobRefs, @@ -23,6 +23,11 @@ import { hexToBytes, importPrivateKey, isLoopbackClient, + matchesMime, + parseBlobScope, + parseRepoScope, + parseScopesForDisplay, + ScopePermissions, sign, sniffMimeType, validateClientMetadata, @@ -827,3 +832,339 @@ describe('Client Metadata', () => { ); }); }); + +describe('Scope Parsing', () => { + describe('parseRepoScope', () => { + test('parses repo scope with query parameter action', () => { + const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create'], + }); + }); + + test('parses repo scope with multiple query parameter actions', () => { + const result = parseRepoScope( + 'repo:app.bsky.feed.post?action=create&action=update', + ); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create', 'update'], + }); + }); + + test('parses repo scope without actions as all actions', () => { + const result = parseRepoScope('repo:app.bsky.feed.post'); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create', 'update', 'delete'], + }); + }); + + test('parses wildcard collection with action', () => { + const result = parseRepoScope('repo:*?action=create'); + assert.deepStrictEqual(result, { + collection: '*', + actions: ['create'], + }); + }); + + test('parses query-only format', () => { + const result = parseRepoScope( + 'repo?collection=app.bsky.feed.post&action=create', + ); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create'], + }); + }); + + test('deduplicates repeated actions', () => { + const result = parseRepoScope( + 'repo:app.bsky.feed.post?action=create&action=create&action=update', + ); + assert.deepStrictEqual(result, { + collection: 'app.bsky.feed.post', + actions: ['create', 'update'], + }); + }); + + 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?'), null); + }); + }); + + 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); + }); + }); +}); + +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:*?action=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?action=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:*?action=create&action=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?action=create repo:app.bsky.feed.like?action=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, + ); + }); + + test('allowsRepo with query param format scopes', () => { + const perms = new ScopePermissions( + 'atproto repo:app.bsky.feed.post?action=create', + ); + assert.strictEqual( + perms.allowsRepo('app.bsky.feed.post', 'create'), + true, + ); + assert.strictEqual( + perms.allowsRepo('app.bsky.feed.post', 'delete'), + true, + ); // atproto grants full access + }); + }); + + 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?action=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?action=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')); + }); + }); +}); + +describe('parseScopesForDisplay', () => { + test('parses identity-only scope', () => { + const result = parseScopesForDisplay('atproto'); + assert.strictEqual(result.hasAtproto, true); + assert.strictEqual(result.hasTransitionGeneric, false); + assert.strictEqual(result.repoPermissions.size, 0); + assert.deepStrictEqual(result.blobPermissions, []); + }); + + test('parses granular repo scopes', () => { + const result = parseScopesForDisplay( + 'atproto repo:app.bsky.feed.post?action=create&action=update', + ); + assert.strictEqual(result.repoPermissions.size, 1); + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); + assert.deepStrictEqual(postPerms, { + create: true, + update: true, + delete: false, + }); + }); + + test('merges multiple scopes for same collection', () => { + const result = parseScopesForDisplay( + 'atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete', + ); + const postPerms = result.repoPermissions.get('app.bsky.feed.post'); + assert.deepStrictEqual(postPerms, { + create: true, + update: false, + delete: true, + }); + }); + + test('parses blob scopes', () => { + const result = parseScopesForDisplay('atproto blob:image/*'); + assert.deepStrictEqual(result.blobPermissions, ['image/*']); + }); + + test('detects transition:generic', () => { + const result = parseScopesForDisplay('atproto transition:generic'); + assert.strictEqual(result.hasTransitionGeneric, true); + }); + + test('handles empty scope string', () => { + const result = parseScopesForDisplay(''); + assert.strictEqual(result.hasAtproto, false); + assert.strictEqual(result.hasTransitionGeneric, false); + assert.strictEqual(result.repoPermissions.size, 0); + assert.deepStrictEqual(result.blobPermissions, []); + }); +});