A minimal AT Protocol Personal Data Server written in JavaScript.
atproto
pds
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}