// @pdsjs/core/scope - OAuth scope parsing and enforcement // Handles repo and blob scope permissions for AT Protocol OAuth /** * 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 an rpc scope string into lexicon methods and audience. * Official format: rpc:?aud= * Or: rpc?lxm=&lxm=&aud= * Both lxm and aud are required; '*' wildcards both. * @param {string} scope - The scope string to parse * @returns {{ lxms: string[]|'*', aud: string } | null} Parsed scope or null if invalid */ export function parseRpcScope(scope) { if (!scope.startsWith('rpc:') && !scope.startsWith('rpc?')) return null; const questionIdx = scope.indexOf('?'); const pathPart = questionIdx === -1 ? scope.slice(4) : scope.startsWith('rpc:') ? scope.slice(4, questionIdx) : ''; const params = new URLSearchParams( questionIdx === -1 ? '' : scope.slice(questionIdx + 1), ); const aud = params.get('aud'); if (!aud) return null; const lxms = pathPart ? [decodeURIComponent(pathPart)] : params.getAll('lxm'); if (lxms.length === 0) return null; if (lxms.includes('*')) return { lxms: '*', aud }; return { lxms, aud }; } /** * Parse an include scope string referencing a permission-set lexicon. * Official format: include:[?aud=] * The NSID names a lexicon whose main def is a permission-set; the optional * aud is injected into the set's rpc permissions that declare inheritAud. * @param {string} scope - The scope string to parse * @returns {{ nsid: string, aud: string|null } | null} Parsed scope or null if invalid */ export function parseIncludeScope(scope) { if (!scope.startsWith('include:')) return null; const questionIdx = scope.indexOf('?'); const nsid = questionIdx === -1 ? scope.slice(8) : scope.slice(8, questionIdx); // An NSID has at least three segments (authority + name), no wildcards. if (nsid.split('.').length < 3 || nsid.includes('*')) return null; const params = new URLSearchParams( questionIdx === -1 ? '' : scope.slice(questionIdx + 1), ); const aud = params.get('aud'); if (aud !== null && !aud.startsWith('did:')) return null; return { nsid, aud }; } /** * One entry of a permission set's `permissions` array, as loosely as a * resolved lexicon document may carry it. * @typedef {Object} LexPermission * @property {unknown} [type] * @property {unknown} [resource] * @property {unknown} [collection] * @property {unknown} [action] * @property {unknown} [lxm] * @property {unknown} [aud] * @property {unknown} [inheritAud] * @property {unknown} [spaceType] - `type` names the permission, so a space * permission spells its own type this way * @property {unknown} [authority] * @property {unknown} [skey] * @property {unknown} [manage] */ /** * @typedef {{ type: string, permissions: LexPermission[] } & Record} PermissionSetDef */ /** * The permission-set definition inside a resolved lexicon document, or null * when the document does not define one. * @param {object|null|undefined} doc - A lexicon document (defs.main expected) * @returns {PermissionSetDef | null} */ export function permissionSetDef(doc) { const container = /** @type {{ defs?: Record }} */ ( doc ?? {} ); const main = /** @type {{ type?: unknown, permissions?: unknown } | undefined} */ ( container.defs?.main ); if (main?.type !== 'permission-set') return null; if (!Array.isArray(main.permissions)) return null; return /** @type {PermissionSetDef} */ (main); } /** @param {unknown} value @returns {string[]} */ function toStringArray(value) { if (typeof value === 'string') return [value]; if (Array.isArray(value)) return value.filter((v) => typeof v === 'string'); return []; } /** * Expand a permission set into concrete repo:/rpc:/space: scope strings. * * A set may only grant NSIDs under its own authority: for a set named * `app.bsky.authFullApp` every collection and lxm must start with `app.bsky.`. * A space permission is the exception, and only for its collections: the space * type is authority-checked, while the collections belong to that type's * declaration, which another authority may own. * * Wildcards are never grantable from a set, rpc permissions must inherit * their audience from the include scope (a concrete aud baked into the set is * ignored), and resources other than repo/rpc/space are not grantable. Entries * that violate these rules are dropped rather than granted. * * @param {string} setNsid - The permission set's own NSID * @param {object|null} doc - The resolved lexicon document for that NSID * @param {string|null} [aud] - Audience from the include scope, for inheritAud * @returns {string[] | null} Concrete scope strings, or null when the document * is not a permission set */ export function expandPermissionSet(setNsid, doc, aud = null) { const def = permissionSetDef(doc); if (!def) return null; const authorityPrefix = `${setNsid.split('.').slice(0, -1).join('.')}.`; /** @param {string} nsid */ const contained = (nsid) => nsid.startsWith(authorityPrefix) && !nsid.includes('*'); const ALL_ACTIONS = ['create', 'update', 'delete']; const scopes = []; for (const perm of def.permissions) { if (perm?.type !== 'permission') continue; if (perm.resource === 'repo') { const actions = [ ...new Set( toStringArray(perm.action).filter((a) => ALL_ACTIONS.includes(a)), ), ]; const granted = actions.length === 0 ? ALL_ACTIONS : actions; for (const collection of toStringArray(perm.collection)) { if (!contained(collection)) continue; scopes.push( granted.length === ALL_ACTIONS.length ? `repo:${collection}` : `repo:${collection}?${granted.map((a) => `action=${a}`).join('&')}`, ); } } else if (perm.resource === 'rpc') { if (perm.inheritAud !== true || perm.aud !== undefined || !aud) continue; for (const lxm of toStringArray(perm.lxm)) { if (!contained(lxm)) continue; scopes.push(`rpc:${lxm}?aud=${encodeURIComponent(aud)}`); } } else if (perm.resource === 'space') { // JSON spells the space type `spaceType`, because `type` names the // permission itself. The scope string calls it back. const spaceType = typeof perm.spaceType === 'string' ? perm.spaceType : ''; if (!contained(spaceType)) continue; const params = new URLSearchParams(); // `authority=*` is what reaches a space somebody else hosts. Absent, the // scope means authority=self, which no shared space matches. const authority = typeof perm.authority === 'string' ? perm.authority : null; if (authority && authority !== 'self') params.set('authority', authority); const skey = typeof perm.skey === 'string' ? perm.skey : null; if (skey && skey !== '*') params.set('skey', skey); for (const collection of toStringArray(perm.collection)) { params.append('collection', collection); } const declared = toStringArray(perm.action); const actions = [ ...new Set(declared.filter((a) => SPACE_ACTIONS.includes(a))), ]; // An action list naming nothing this server knows would otherwise read // as an absent list, which grants the full default four. if (declared.length > 0 && actions.length === 0) continue; const isDefault = actions.length === SPACE_DEFAULT_ACTIONS.length && SPACE_DEFAULT_ACTIONS.every((a) => actions.includes(a)); if (!isDefault) { for (const action of actions) params.append('action', action); } // An absent `manage` param grants no verb at all, so a `manage` list this // server does not know drops itself and leaves the record actions. const manage = new Set( toStringArray(perm.manage).filter((m) => SPACE_MANAGE_OPS.includes(m)), ); for (const op of manage) params.append('manage', op); const query = params.toString(); scopes.push(query ? `space:${spaceType}?${query}` : `space:${spaceType}`); } } return scopes; } /** * Replace include: tokens in a scope string with the concrete scopes their * resolved permission sets grant. Tokens whose set is missing from * `permissionSets` (or resolves to something that is not a permission set) * expand to nothing, so an unresolved include never widens access. * @param {string} scope - Space-separated scope string * @param {Record} [permissionSets] - Resolved lexicon * documents keyed by NSID * @returns {string} The expanded scope string */ export function expandScopeString(scope, permissionSets = {}) { const out = []; for (const s of scope.split(' ').filter(Boolean)) { const include = parseIncludeScope(s); if (!include) { out.push(s); continue; } const expanded = expandPermissionSet( include.nsid, permissionSets[include.nsid] ?? null, include.aud, ); if (expanded) out.push(...expanded); } return [...new Set(out)].join(' ') || 'atproto'; } /** * 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; } /** * Error thrown when a required scope is missing. */ export 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. */ // ── space: scopes (permissioned data, proposal 0016) ──────────────────────── /** Record-level actions. `read` is whole-space; `read_self` is own-repo only. */ export const SPACE_ACTIONS = [ 'read_self', 'read', 'create', 'update', 'delete', ]; /** * Omitting `action` grants everything except `read_self`, which `read` already * implies. */ export const SPACE_DEFAULT_ACTIONS = ['read', 'create', 'update', 'delete']; /** Space-level management verbs, governed by the separate `manage=` param. */ export const SPACE_MANAGE_OPS = ['create', 'update', 'delete']; const SKEY_MAX_LENGTH = 512; /** * Parse a `space:` scope. * * space:[?authority=][&skey=] * [&collection=...][&action=...][&manage=...] * * `authority` defaults to `self` — the granting user's own DID — and `skey` * defaults to `*`. Omitting `collection` leaves it null, meaning "the space * type's declared collections", which only the space type can resolve; callers * that need a concrete list supply it. * * @param {string} scope * @returns {{spaceType: string, authority: string, skey: string, collections: string[]|null, actions: string[], manage: string[]} | null} */ export function parseSpaceScope(scope) { if (!scope.startsWith('space:') && !scope.startsWith('space?')) return null; const questionIdx = scope.indexOf('?'); const pathPart = scope.startsWith('space:') ? scope.slice(6, questionIdx === -1 ? undefined : questionIdx) : ''; const params = new URLSearchParams( questionIdx === -1 ? '' : scope.slice(questionIdx + 1), ); const spaceType = pathPart || params.get('type'); // A space type is an NSID or `*`; NSIDs always contain a dot, which is what // keeps them distinguishable from the `space` marker in a URI. if (!spaceType) return null; if (spaceType !== '*' && !spaceType.includes('.')) return null; const authority = params.get('authority') ?? 'self'; if ( authority !== '*' && authority !== 'self' && !authority.startsWith('did:') ) { return null; } const skey = params.get('skey') ?? '*'; if (!skey || skey.length > SKEY_MAX_LENGTH) return null; const rawCollections = params.getAll('collection'); const collections = rawCollections.length === 0 ? null : rawCollections; if (collections?.some((c) => c !== '*' && !c.includes('.'))) return null; const rawActions = params.getAll('action'); const actions = rawActions.length === 0 ? [...SPACE_DEFAULT_ACTIONS] : [...new Set(rawActions.filter((a) => SPACE_ACTIONS.includes(a)))]; if (actions.length === 0 && rawActions.length > 0) return null; const manage = [ ...new Set( params.getAll('manage').filter((m) => SPACE_MANAGE_OPS.includes(m)), ), ]; return { spaceType, authority, skey, collections, actions, manage }; } /** * Whether a grant covers a request against a space. * * A request is authorized when its target matches the grant's * `(authority, spaceType, skey)` — each component equal or covered by `*` — * and the action is granted. Write actions additionally require the target * collection to be covered. * * @param {ReturnType} grant * @param {Object} req * @param {string} req.spaceType * @param {string} req.authority - the space authority's DID * @param {string} req.skey * @param {string} req.action * @param {string} [req.collection] - required for create/update/delete * @param {string} [req.userDid] - resolves a grant's `self` authority * @param {string[]} [req.declaredCollections] - the space type's declared * collections, used when the grant omitted `collection` * @returns {boolean} */ export function spaceScopeMatches(grant, req) { if (!grant) return false; if (grant.spaceType !== '*' && grant.spaceType !== req.spaceType) return false; const authority = grant.authority === 'self' ? (req.userDid ?? null) : grant.authority; if (authority !== '*' && authority !== req.authority) return false; if (grant.skey !== '*' && grant.skey !== req.skey) return false; // `read` implies `read_self`, but not the reverse. const granted = grant.actions.includes(req.action) || (req.action === 'read_self' && grant.actions.includes('read')); if (!granted) return false; // read and read_self are collection-independent; writes are not. if (req.action === 'read' || req.action === 'read_self') return true; const collections = grant.collections ?? req.declaredCollections ?? []; if (!req.collection) return false; return collections.includes('*') || collections.includes(req.collection); } /** * Whether a grant permits a space-level management verb. Not implied by any * record action. * * @param {ReturnType} grant * @param {Object} req * @param {string} req.spaceType * @param {string} req.authority * @param {string} req.skey * @param {string} req.op - one of SPACE_MANAGE_OPS * @param {string} [req.userDid] * @returns {boolean} */ export function spaceScopeAllowsManage(grant, req) { if (!grant) return false; if (grant.spaceType !== '*' && grant.spaceType !== req.spaceType) return false; const authority = grant.authority === 'self' ? (req.userDid ?? null) : grant.authority; if (authority !== '*' && authority !== req.authority) return false; if (grant.skey !== '*' && grant.skey !== req.skey) return false; return grant.manage.includes(req.op); } 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 = []; /** @type {Array<{ lxms: string[]|'*', aud: string }>} */ this.rpcPermissions = []; /** @type {Array>>} */ this.spacePermissions = []; 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); const rpc = parseRpcScope(scope); if (rpc) this.rpcPermissions.push(rpc); const space = parseSpaceScope(scope); if (space) this.spacePermissions.push(space); } } /** * Check if a proxied RPC call to a service method is allowed. * The audience is compared in combined `did#serviceId` form; lexicon * method comparison is case-insensitive. * @param {string} lxm - Lexicon method NSID being called * @param {string} aud - Target service as `did#serviceId` * @returns {boolean} */ allowsRpc(lxm, aud) { if (this.skipsGranularChecks()) return true; const lxmLower = lxm.toLowerCase(); return this.rpcPermissions.some( (perm) => (perm.aud === '*' || perm.aud === aud) && (perm.lxms === '*' || perm.lxms.some((l) => l.toLowerCase() === lxmLower)), ); } /** * Check whether any granted space scope covers this request. * @param {Parameters[1]} req * @returns {boolean} */ allowsSpace(req) { if (this.isPasswordSession()) return true; return this.spacePermissions.some((g) => spaceScopeMatches(g, req)); } /** * Check whether any granted space scope permits a management verb. * @param {Parameters[1]} req * @returns {boolean} */ allowsSpaceManage(req) { if (this.isPasswordSession()) return true; return this.spacePermissions.some((g) => spaceScopeAllowsManage(g, req)); } /** * Whether this session holds the account itself rather than a grant over * part of it: the password login, which is the account holder acting * directly on a server that hosts one account. * * It is the only session a space is reachable from without a `space:` scope * naming it. `atproto` is in every OAuth authorization request, so reading * full access as space access would give every application every space the * account can reach, at every authority, without asking. The reference * refuses a space even to `transition:generic`, a token that predates * permissioned data. * @returns {boolean} */ isPasswordSession() { return this.scopes.has('com.atproto.access'); } /** * Check if full access is granted (atproto, transition:generic, or legacy com.atproto.access). * @returns {boolean} */ hasFullAccess() { return ( this.scopes.has('atproto') || this.scopes.has('transition:generic') || this.scopes.has('com.atproto.access') ); } /** * Whether this is an app-password session, whose scope string names no * granular permissions at all. * * The reference PDS asserts repo, blob and rpc permissions only for OAuth * credentials, and bounds an app password by other means: it cannot mint or * list app passwords, and it never sees the account email. Held apart from * hasFullAccess so those bounds stay in force, and so space scopes -- which * the reference has no equivalent of -- are not granted by this route. * @returns {boolean} */ isAppPasswordSession() { return ( this.scopes.has('com.atproto.appPass') || this.scopes.has('com.atproto.appPassPrivileged') ); } /** * Whether granular repo, blob and rpc permissions apply to this session at * all. They do not for the legacy session scopes. * @returns {boolean} */ skipsGranularChecks() { return this.hasFullAccess() || this.isAppPasswordSession(); } /** * Check if the session may read the account's own email address. Granted by * atproto's `transition:email` scope or the granular `account:email` scope. * Deliberately NOT implied by full access: `transition:generic` and `atproto` * cover the repository but not the email, matching the reference PDS. * @returns {boolean} */ hasEmailAccess() { for (const scope of this.scopes) { if ( scope === 'transition:email' || scope === 'account:email' || scope.startsWith('account:email?') ) { return true; } } return false; } /** * 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.skipsGranularChecks()) 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.skipsGranularChecks()) 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}`); } } } /** * Parse scope string into display-friendly structure. * @param {string} scope - Space-separated scope string * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, hasEmail: boolean, repoPermissions: Map, rpcPermissions: Array<{aud: string, lxms: string[]|'*'}>, includeScopes: Array<{nsid: string, aud: string|null}>, blobPermissions: string[], spacePermissions: Array>> }} */ 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); } } // Merge rpc grants by audience so one card can say "call X, Y towards Z". /** @type {Map|'*'>} */ const rpcByAud = new Map(); for (const s of scopes) { const rpc = parseRpcScope(s); if (!rpc) continue; const existing = rpcByAud.get(rpc.aud); if (rpc.lxms === '*' || existing === '*') { rpcByAud.set(rpc.aud, '*'); } else { const set = existing ?? new Set(); for (const lxm of rpc.lxms) set.add(lxm); rpcByAud.set(rpc.aud, set); } } const rpcPermissions = [...rpcByAud.entries()].map(([aud, lxms]) => ({ aud, lxms: lxms === '*' ? /** @type {'*'} */ ('*') : [...lxms].sort(), })); const includeScopes = []; for (const s of scopes) { const include = parseIncludeScope(s); if (include) includeScopes.push(include); } const blobPermissions = []; for (const s of scopes) { const blob = parseBlobScope(s); if (blob) blobPermissions.push(...blob.accept); } const spacePermissions = []; for (const s of scopes) { const space = parseSpaceScope(s); if (space) spacePermissions.push(space); } return { hasAtproto: scopes.includes('atproto'), hasTransitionGeneric: scopes.includes('transition:generic'), hasEmail: scopes.some( (s) => s === 'transition:email' || s === 'account:email' || s.startsWith('account:email?'), ), repoPermissions, rpcPermissions, includeScopes, blobPermissions, spacePermissions, }; }