A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
at main 239 lines 6.7 kB view raw
1// @pds/core/scope - OAuth scope parsing and enforcement 2// Handles repo and blob scope permissions for AT Protocol OAuth 3 4/** 5 * Parse a repo scope string into collection and actions. 6 * Official format: repo:collection?action=create&action=update 7 * Or: repo?collection=foo&action=create 8 * Without actions defaults to all: create, update, delete 9 * @param {string} scope - The scope string to parse 10 * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid 11 */ 12export function parseRepoScope(scope) { 13 if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; 14 15 const ALL_ACTIONS = ['create', 'update', 'delete']; 16 let collection; 17 let actions; 18 19 const questionIdx = scope.indexOf('?'); 20 if (questionIdx === -1) { 21 // repo:collection (no query params = all actions) 22 collection = scope.slice(5); 23 actions = ALL_ACTIONS; 24 } else { 25 // Parse query parameters 26 const queryString = scope.slice(questionIdx + 1); 27 const params = new URLSearchParams(queryString); 28 const pathPart = scope.startsWith('repo:') 29 ? scope.slice(5, questionIdx) 30 : ''; 31 32 collection = pathPart || params.get('collection'); 33 actions = params.getAll('action'); 34 if (actions.length === 0) actions = ALL_ACTIONS; 35 } 36 37 if (!collection) return null; 38 39 // Validate actions 40 const validActions = [ 41 ...new Set(actions.filter((a) => ALL_ACTIONS.includes(a))), 42 ]; 43 if (validActions.length === 0) return null; 44 45 return { collection, actions: validActions }; 46} 47 48/** 49 * Parse a blob scope string into its components. 50 * Format: blob:<mime>[,<mime>...] 51 * @param {string} scope - The scope string to parse 52 * @returns {{ accept: string[] } | null} Parsed scope or null if invalid 53 */ 54export function parseBlobScope(scope) { 55 if (!scope.startsWith('blob:')) return null; 56 57 const mimeStr = scope.slice(5); // Remove 'blob:' 58 if (!mimeStr) return null; 59 60 const accept = mimeStr.split(',').filter((m) => m); 61 if (accept.length === 0) return null; 62 63 return { accept }; 64} 65 66/** 67 * Check if a MIME pattern matches an actual MIME type. 68 * @param {string} pattern - MIME pattern (e.g., 'image/*', '*\/*', 'image/png') 69 * @param {string} mime - Actual MIME type to check 70 * @returns {boolean} Whether the pattern matches 71 */ 72export function matchesMime(pattern, mime) { 73 const p = pattern.toLowerCase(); 74 const m = mime.toLowerCase(); 75 76 if (p === '*/*') return true; 77 78 if (p.endsWith('/*')) { 79 const pType = p.slice(0, -2); 80 const mType = m.split('/')[0]; 81 return pType === mType; 82 } 83 84 return p === m; 85} 86 87/** 88 * Error thrown when a required scope is missing. 89 */ 90export class ScopeMissingError extends Error { 91 /** 92 * @param {string} scope - The missing scope 93 */ 94 constructor(scope) { 95 super(`Missing required scope "${scope}"`); 96 this.name = 'ScopeMissingError'; 97 this.scope = scope; 98 this.status = 403; 99 } 100} 101 102/** 103 * Parses and checks OAuth scope permissions. 104 */ 105export class ScopePermissions { 106 /** 107 * @param {string | undefined} scopeString - Space-separated scope string 108 */ 109 constructor(scopeString) { 110 /** @type {Set<string>} */ 111 this.scopes = new Set( 112 scopeString ? scopeString.split(' ').filter((s) => s) : [], 113 ); 114 115 /** @type {Array<{ collection: string, actions: string[] }>} */ 116 this.repoPermissions = []; 117 118 /** @type {Array<{ accept: string[] }>} */ 119 this.blobPermissions = []; 120 121 for (const scope of this.scopes) { 122 const repo = parseRepoScope(scope); 123 if (repo) this.repoPermissions.push(repo); 124 125 const blob = parseBlobScope(scope); 126 if (blob) this.blobPermissions.push(blob); 127 } 128 } 129 130 /** 131 * Check if full access is granted (atproto, transition:generic, or legacy com.atproto.access). 132 * @returns {boolean} 133 */ 134 hasFullAccess() { 135 return ( 136 this.scopes.has('atproto') || 137 this.scopes.has('transition:generic') || 138 this.scopes.has('com.atproto.access') 139 ); 140 } 141 142 /** 143 * Check if a repo operation is allowed. 144 * @param {string} collection - The collection NSID 145 * @param {string} action - The action (create, update, delete) 146 * @returns {boolean} 147 */ 148 allowsRepo(collection, action) { 149 if (this.hasFullAccess()) return true; 150 151 for (const perm of this.repoPermissions) { 152 const collectionMatch = 153 perm.collection === '*' || perm.collection === collection; 154 const actionMatch = perm.actions.includes(action); 155 if (collectionMatch && actionMatch) return true; 156 } 157 158 return false; 159 } 160 161 /** 162 * Assert that a repo operation is allowed, throwing if not. 163 * @param {string} collection - The collection NSID 164 * @param {string} action - The action (create, update, delete) 165 * @throws {ScopeMissingError} 166 */ 167 assertRepo(collection, action) { 168 if (!this.allowsRepo(collection, action)) { 169 throw new ScopeMissingError(`repo:${collection}?action=${action}`); 170 } 171 } 172 173 /** 174 * Check if a blob operation is allowed. 175 * @param {string} mime - The MIME type of the blob 176 * @returns {boolean} 177 */ 178 allowsBlob(mime) { 179 if (this.hasFullAccess()) return true; 180 181 for (const perm of this.blobPermissions) { 182 for (const pattern of perm.accept) { 183 if (matchesMime(pattern, mime)) return true; 184 } 185 } 186 187 return false; 188 } 189 190 /** 191 * Assert that a blob operation is allowed, throwing if not. 192 * @param {string} mime - The MIME type of the blob 193 * @throws {ScopeMissingError} 194 */ 195 assertBlob(mime) { 196 if (!this.allowsBlob(mime)) { 197 throw new ScopeMissingError(`blob:${mime}`); 198 } 199 } 200} 201 202/** 203 * Parse scope string into display-friendly structure. 204 * @param {string} scope - Space-separated scope string 205 * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} 206 */ 207export function parseScopesForDisplay(scope) { 208 const scopes = scope.split(' ').filter((s) => s); 209 210 const repoPermissions = new Map(); 211 212 for (const s of scopes) { 213 const repo = parseRepoScope(s); 214 if (repo) { 215 const existing = repoPermissions.get(repo.collection) || { 216 create: false, 217 update: false, 218 delete: false, 219 }; 220 for (const action of repo.actions) { 221 existing[action] = true; 222 } 223 repoPermissions.set(repo.collection, existing); 224 } 225 } 226 227 const blobPermissions = []; 228 for (const s of scopes) { 229 const blob = parseBlobScope(s); 230 if (blob) blobPermissions.push(...blob.accept); 231 } 232 233 return { 234 hasAtproto: scopes.includes('atproto'), 235 hasTransitionGeneric: scopes.includes('transition:generic'), 236 repoPermissions, 237 blobPermissions, 238 }; 239}