@atproto/oauth-scopes 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +10 -6
  3. package/jest.config.cjs +0 -14
  4. package/src/atproto-oauth-scope.ts +0 -79
  5. package/src/index.ts +0 -13
  6. package/src/lib/lexicon.ts +0 -21
  7. package/src/lib/mime.test.ts +0 -98
  8. package/src/lib/mime.ts +0 -71
  9. package/src/lib/nsid.ts +0 -5
  10. package/src/lib/parser.ts +0 -176
  11. package/src/lib/resource-permission.ts +0 -10
  12. package/src/lib/syntax-lexicon.ts +0 -55
  13. package/src/lib/syntax-string.test.ts +0 -130
  14. package/src/lib/syntax-string.ts +0 -132
  15. package/src/lib/syntax.test.ts +0 -43
  16. package/src/lib/syntax.ts +0 -54
  17. package/src/lib/util.ts +0 -18
  18. package/src/scope-missing-error.ts +0 -15
  19. package/src/scope-permissions-transition.test.ts +0 -122
  20. package/src/scope-permissions-transition.ts +0 -71
  21. package/src/scope-permissions.test.ts +0 -303
  22. package/src/scope-permissions.ts +0 -91
  23. package/src/scopes/account-permission.test.ts +0 -187
  24. package/src/scopes/account-permission.ts +0 -78
  25. package/src/scopes/blob-permission.test.ts +0 -126
  26. package/src/scopes/blob-permission.ts +0 -105
  27. package/src/scopes/identity-permission.test.ts +0 -80
  28. package/src/scopes/identity-permission.ts +0 -54
  29. package/src/scopes/include-scope.test.ts +0 -637
  30. package/src/scopes/include-scope.ts +0 -208
  31. package/src/scopes/repo-permission.test.ts +0 -267
  32. package/src/scopes/repo-permission.ts +0 -111
  33. package/src/scopes/rpc-permission.test.ts +0 -323
  34. package/src/scopes/rpc-permission.ts +0 -90
  35. package/src/scopes-set.test.ts +0 -47
  36. package/src/scopes-set.ts +0 -134
  37. package/tsconfig.build.json +0 -9
  38. package/tsconfig.build.tsbuildinfo +0 -1
  39. package/tsconfig.json +0 -7
  40. package/tsconfig.tests.json +0 -7
@@ -1,208 +0,0 @@
1
- import { LexiconPermission, LexiconPermissionSet } from '../lib/lexicon.js'
2
- import { Nsid, isNsid } from '../lib/nsid.js'
3
- import { Parser } from '../lib/parser.js'
4
- import { LexPermissionSyntax } from '../lib/syntax-lexicon.js'
5
- import { ScopeStringSyntax } from '../lib/syntax-string.js'
6
- import {
7
- ScopeStringFor,
8
- ScopeSyntax,
9
- isScopeStringFor,
10
- isScopeSyntaxFor,
11
- } from '../lib/syntax.js'
12
- import { RepoPermission } from './repo-permission.js'
13
- import {
14
- AtprotoDidRefAbsolute,
15
- RpcPermission,
16
- isAtprotoDidRefAbsolute,
17
- } from './rpc-permission.js'
18
-
19
- export { type LexiconPermission, type LexiconPermissionSet, type Nsid, isNsid }
20
-
21
- /**
22
- * This is used to handle "include:" oauth scope values, used to include
23
- * permissions from a lexicon defined permission set. Not being a resource
24
- * permission, it does not implement `Matchable`.
25
- */
26
- export class IncludeScope {
27
- constructor(
28
- public readonly nsid: Nsid,
29
- public readonly aud: undefined | AtprotoDidRefAbsolute = undefined,
30
- ) {}
31
-
32
- toString() {
33
- return IncludeScope.parser.format(this)
34
- }
35
-
36
- toPermissions(
37
- permissionSet: LexiconPermissionSet,
38
- ): Array<RepoPermission | RpcPermission> {
39
- return Array.from(this.buildPermissions(permissionSet))
40
- }
41
-
42
- toScopes(
43
- permissionSet: LexiconPermissionSet,
44
- ): Array<ScopeStringFor<'repo' | 'rpc'>> {
45
- return Array.from(this.buildPermissions(permissionSet), (p) => p.toString())
46
- }
47
-
48
- /**
49
- * Converts an "include:" to the list of permissions it includes, based on the
50
- * lexicon defined permission set.
51
- */
52
- *buildPermissions(
53
- permissionSet: LexiconPermissionSet,
54
- ): Generator<RepoPermission | RpcPermission, void, unknown> {
55
- for (const lexPermission of permissionSet.permissions) {
56
- const syntax = this.parseLexPermission(lexPermission)
57
- if (!syntax) continue
58
-
59
- const resourcePermission = toResourcePermission(syntax)
60
- if (!resourcePermission) continue
61
-
62
- if (this.isAllowedPermission(resourcePermission)) {
63
- yield resourcePermission
64
- }
65
- }
66
- }
67
-
68
- protected parseLexPermission(
69
- permission: LexiconPermission,
70
- ): ScopeSyntax<'repo' | 'rpc'> | null {
71
- // This function converts permissions listed in the permission set into
72
- // their respective ScopeSyntax representations, handling special cases as
73
- // needed.
74
-
75
- if (isLexPermissionForResource(permission, 'repo')) {
76
- return new LexPermissionSyntax(permission)
77
- }
78
-
79
- if (isLexPermissionForResource(permission, 'rpc')) {
80
- // "rpc" permissions with a defined audience are not allowed in permission
81
- // sets
82
- if (permission.aud !== undefined && permission.aud !== '*') {
83
- return null
84
- }
85
-
86
- // "rpc" permissions can "inherit" their audience from "aud" param defined
87
- // in the "include:<nsid>?aud=<audience>" scope the permission set was
88
- // loaded from.
89
- if (
90
- permission.inheritAud === true &&
91
- permission.aud === undefined &&
92
- this.aud !== undefined
93
- ) {
94
- const { inheritAud, ...rest } = permission
95
- return new LexPermissionSyntax({ aud: this.aud, ...rest })
96
- }
97
-
98
- return new LexPermissionSyntax(permission)
99
- }
100
-
101
- return null
102
- }
103
-
104
- /**
105
- * Verifies that a permission included through a lexicon permission set is
106
- * allowed in the context of the `include:` scope. This basically checks that
107
- * the permission is "under" the namespace authority of the `include:` scope,
108
- * and that it only contains "repo:", "rpc:", or "blob:" permissions.
109
- */
110
- protected isAllowedPermission(
111
- permission: RpcPermission | RepoPermission,
112
- ): boolean {
113
- if (permission instanceof RpcPermission) {
114
- return permission.lxm.every(this.isParentAuthorityOf, this)
115
- }
116
-
117
- if (permission instanceof RepoPermission) {
118
- return permission.collection.every(this.isParentAuthorityOf, this)
119
- }
120
-
121
- throw new TypeError(`Unexpected permission ${permission}`)
122
- }
123
-
124
- /**
125
- * Verifies that a permission item's nsid is under the same authority as the
126
- * nsid of the lexicon itself (which is the same as the nsid of the `include:`
127
- * scope).
128
- */
129
- public isParentAuthorityOf(otherNsid: '*' | Nsid) {
130
- if (otherNsid === '*') {
131
- return false
132
- }
133
-
134
- const lexiconNsid = this.nsid
135
-
136
- const groupPrefixEnd = lexiconNsid.lastIndexOf('.')
137
-
138
- // There should always be a dot, but since this is a security feature, let's
139
- // be strict about it.
140
- if (groupPrefixEnd === -1) {
141
- throw new TypeError('Dot character (".") missing from lexicon NSID')
142
- }
143
-
144
- // Make sure that otherNsid is at least as long as the "group prefix"
145
- if (groupPrefixEnd >= otherNsid.length - 1) {
146
- return false
147
- }
148
-
149
- // Make sure that the "otherNsid" starts with the group of the lexiconNsid,
150
- // up to the dot itself. We check in reverse order as nsids tend to have
151
- // long common prefixes.
152
- for (let i = groupPrefixEnd; i >= 0; i--) {
153
- if (lexiconNsid.charCodeAt(i) !== otherNsid.charCodeAt(i)) {
154
- return false
155
- }
156
- }
157
-
158
- return true
159
- }
160
-
161
- protected static readonly parser = new Parser(
162
- 'include',
163
- {
164
- nsid: {
165
- multiple: false,
166
- required: true,
167
- validate: isNsid,
168
- },
169
- aud: {
170
- multiple: false,
171
- required: false,
172
- validate: isAtprotoDidRefAbsolute,
173
- },
174
- },
175
- 'nsid',
176
- )
177
-
178
- static fromString(scope: string) {
179
- if (!isScopeStringFor(scope, 'include')) return null
180
- const syntax = ScopeStringSyntax.fromString(scope)
181
- return IncludeScope.fromSyntax(syntax)
182
- }
183
-
184
- static fromSyntax(syntax: ScopeSyntax<'include'>) {
185
- const result = IncludeScope.parser.parse(syntax)
186
- if (!result) return null
187
- return new IncludeScope(result.nsid, result.aud)
188
- }
189
- }
190
-
191
- function toResourcePermission(
192
- syntax: ScopeSyntax<'repo' | 'rpc'>,
193
- ): RepoPermission | RpcPermission | null {
194
- if (isScopeSyntaxFor(syntax, 'repo')) {
195
- return RepoPermission.fromSyntax(syntax)
196
- }
197
- if (isScopeSyntaxFor(syntax, 'rpc')) {
198
- return RpcPermission.fromSyntax(syntax)
199
- }
200
- return null
201
- }
202
-
203
- function isLexPermissionForResource<
204
- P extends { resource: unknown },
205
- T extends string,
206
- >(permission: P, type: T): permission is P & { resource: T } {
207
- return permission.resource === type
208
- }
@@ -1,267 +0,0 @@
1
- import { RepoPermission } from './repo-permission.js'
2
-
3
- describe('RepoPermission', () => {
4
- describe('static', () => {
5
- describe('fromString', () => {
6
- it('should parse positional scope', () => {
7
- const scope = RepoPermission.fromString('repo:com.example.foo')
8
- expect(scope).not.toBeNull()
9
- expect(scope!.collection).toEqual(['com.example.foo'])
10
- expect(scope!.action).toEqual(['create', 'update', 'delete'])
11
- })
12
-
13
- it('should parse valid repo scope with multiple actions', () => {
14
- const scope = RepoPermission.fromString(
15
- 'repo:com.example.foo?action=create&action=update',
16
- )
17
- expect(scope).not.toBeNull()
18
- expect(scope!.collection).toEqual(['com.example.foo'])
19
- expect(scope!.action).toEqual(['create', 'update'])
20
- })
21
-
22
- it('should parse valid repo scope without actions (defaults to create, update, delete)', () => {
23
- const scope = RepoPermission.fromString('repo:com.example.foo')
24
- expect(scope).not.toBeNull()
25
- expect(scope!.collection).toEqual(['com.example.foo'])
26
- expect(scope!.action).toEqual(['create', 'update', 'delete'])
27
- })
28
-
29
- it('should allow wildcard collection with specific action', () => {
30
- const scope = RepoPermission.fromString('repo:*?action=create')
31
- expect(scope).not.toBeNull()
32
- expect(scope!.collection).toEqual(['*'])
33
- expect(scope!.action).toEqual(['create'])
34
- expect(
35
- scope!.matches({ action: 'create', collection: 'any.collection' }),
36
- ).toBe(true)
37
- expect(
38
- scope!.matches({ action: 'update', collection: 'any.collection' }),
39
- ).toBe(false)
40
- })
41
-
42
- it('should allow wildcard collection without actions', () => {
43
- const scope = RepoPermission.fromString('repo:*')
44
- expect(scope).not.toBeNull()
45
- expect(scope!.collection).toEqual(['*'])
46
- expect(scope!.action).toEqual(['create', 'update', 'delete'])
47
- expect(
48
- scope!.matches({ action: 'create', collection: 'any.collection' }),
49
- ).toBe(true)
50
- expect(
51
- scope!.matches({ action: 'update', collection: 'any.collection' }),
52
- ).toBe(true)
53
- expect(
54
- scope!.matches({ action: 'delete', collection: 'any.collection' }),
55
- ).toBe(true)
56
- })
57
-
58
- it('should ignore scopes with invalid collection names', () => {
59
- expect(RepoPermission.fromString('repo:foo bar')).toBeNull()
60
- expect(RepoPermission.fromString('repo:.foo')).toBeNull()
61
- expect(RepoPermission.fromString('repo:bar.')).toBeNull()
62
- })
63
-
64
- it('should reject invalid action names', () => {
65
- const scope = RepoPermission.fromString(
66
- 'repo:com.example.foo?action=invalid',
67
- )
68
- expect(scope).toBeNull()
69
- })
70
-
71
- it('should return null for invalid repo scope', () => {
72
- expect(RepoPermission.fromString('invalid')).toBeNull()
73
- expect(RepoPermission.fromString('scope')).toBeNull()
74
- })
75
-
76
- for (const invalid of [
77
- 'repo:*?action=*',
78
- 'invalid',
79
- 'repo:invalid',
80
- 'repo:com.example.foo?action=invalid',
81
- 'repo?collection=invalid&action=invalid',
82
- ]) {
83
- it(`should return null for invalid rpc scope: ${invalid}`, () => {
84
- expect(RepoPermission.fromString(invalid)).toBeNull()
85
- })
86
- }
87
- })
88
-
89
- describe('scopeNeededFor', () => {
90
- it('should return correct scope string for specific collection and action', () => {
91
- const scope = RepoPermission.scopeNeededFor({
92
- collection: 'com.example.foo',
93
- action: 'create',
94
- })
95
- expect(scope).toBe('repo:com.example.foo?action=create')
96
- })
97
-
98
- it('should return scope that accepts all collections with specific action', () => {
99
- const scope = RepoPermission.scopeNeededFor({
100
- collection: '*',
101
- action: 'create',
102
- })
103
- expect(scope).toBe('repo:*?action=create')
104
- })
105
-
106
- it('ignores invalid options', () => {
107
- // @NOTE the scopeNeededFor assumes valid input, so it does not validate
108
- // collection or action.
109
-
110
- expect(
111
- RepoPermission.scopeNeededFor({
112
- collection: 'invalid',
113
- action: 'create',
114
- }),
115
- ).toBe('repo:invalid?action=create')
116
- })
117
- })
118
- })
119
-
120
- describe('instance', () => {
121
- describe('matches', () => {
122
- it('should match create action', () => {
123
- const scope = RepoPermission.fromString(
124
- 'repo:com.example.foo?action=create',
125
- )
126
- expect(scope).not.toBeNull()
127
- expect(
128
- scope!.matches({ action: 'create', collection: 'com.example.foo' }),
129
- ).toBe(true)
130
- })
131
-
132
- it('should not match unspecified action', () => {
133
- const scope = RepoPermission.fromString(
134
- 'repo:com.example.foo?action=create',
135
- )
136
- expect(scope).not.toBeNull()
137
- expect(
138
- scope!.matches({ action: 'update', collection: 'com.example.foo' }),
139
- ).toBe(false)
140
- })
141
-
142
- it('should match wildcard collection', () => {
143
- const scope = RepoPermission.fromString('repo:*?action=create')
144
- expect(scope).not.toBeNull()
145
- expect(
146
- scope!.matches({ action: 'create', collection: 'com.example.bar' }),
147
- ).toBe(true)
148
- })
149
-
150
- it('should not match different action with wildcard collection', () => {
151
- const scope = RepoPermission.fromString('repo:*?action=create')
152
- expect(scope).not.toBeNull()
153
- expect(
154
- scope!.matches({ action: 'delete', collection: 'com.example.bar' }),
155
- ).toBe(false)
156
- })
157
-
158
- it('should match multiple actions', () => {
159
- const scope = RepoPermission.fromString(
160
- 'repo:com.example.foo?action=create&action=update',
161
- )
162
- expect(scope).not.toBeNull()
163
- expect(
164
- scope!.matches({ action: 'create', collection: 'com.example.foo' }),
165
- ).toBe(true)
166
- expect(
167
- scope!.matches({ action: 'update', collection: 'com.example.foo' }),
168
- ).toBe(true)
169
- expect(
170
- scope!.matches({ action: 'delete', collection: 'com.example.foo' }),
171
- ).toBe(false)
172
- })
173
-
174
- it('should default to "create", "update", and "delete" actions', () => {
175
- const scope = RepoPermission.fromString('repo:com.example.foo')
176
- expect(scope).not.toBeNull()
177
- expect(
178
- scope!.matches({ action: 'create', collection: 'com.example.foo' }),
179
- ).toBe(true)
180
- expect(
181
- scope!.matches({ action: 'update', collection: 'com.example.foo' }),
182
- ).toBe(true)
183
- expect(
184
- scope!.matches({ action: 'delete', collection: 'com.example.foo' }),
185
- ).toBe(true)
186
- })
187
- })
188
-
189
- describe('toString', () => {
190
- it('should format repo scope correctly', () => {
191
- const scope = new RepoPermission(
192
- ['com.example.foo'],
193
- ['create', 'update'],
194
- )
195
- expect(scope).not.toBeNull()
196
- expect(scope!.toString()).toBe(
197
- 'repo:com.example.foo?action=create&action=update',
198
- )
199
- })
200
- })
201
- })
202
-
203
- describe('consistency', () => {
204
- const testCases: { input: string; expected: string }[] = [
205
- { input: 'repo:com.example.foo', expected: 'repo:com.example.foo' },
206
- {
207
- input: 'repo:com.example.foo?action=create',
208
- expected: 'repo:com.example.foo?action=create',
209
- },
210
- {
211
- input: 'repo:com.example.foo?action=create&action=update',
212
- expected: 'repo:com.example.foo?action=create&action=update',
213
- },
214
- {
215
- input: 'repo:*?action=create&action=update&action=delete',
216
- expected: 'repo:*',
217
- },
218
- {
219
- input: 'repo:com.example.foo?action=create&action=update&action=delete',
220
- expected: 'repo:com.example.foo',
221
- },
222
- { input: 'repo:*?action=create', expected: 'repo:*?action=create' },
223
- { input: 'repo:*?action=update', expected: 'repo:*?action=update' },
224
- {
225
- input: 'repo?collection=*&action=update',
226
- expected: 'repo:*?action=update',
227
- },
228
- {
229
- input: 'repo?collection=*&collection=com.example.foo&action=update',
230
- expected: 'repo:*?action=update',
231
- },
232
- {
233
- input: 'repo?collection=*',
234
- expected: 'repo:*',
235
- },
236
- {
237
- input: 'repo?collection=*&action=create&action=update&action=delete',
238
- expected: 'repo:*',
239
- },
240
- {
241
- input: 'repo?collection=*&collection=com.example.foo',
242
- expected: 'repo:*',
243
- },
244
- {
245
- input: 'repo?action=create&collection=com.example.foo',
246
- expected: 'repo:com.example.foo?action=create',
247
- },
248
- {
249
- input:
250
- 'repo?collection=com.example.foo&action=create&action=update&action=delete',
251
- expected: 'repo:com.example.foo',
252
- },
253
- {
254
- input:
255
- 'repo?action=create&collection=com.example.foo&collection=com.example.bar',
256
- expected:
257
- 'repo?collection=com.example.bar&collection=com.example.foo&action=create',
258
- },
259
- ]
260
-
261
- for (const { input, expected } of testCases) {
262
- it(`should properly re-format ${input}`, () => {
263
- expect(RepoPermission.fromString(input)?.toString()).toBe(expected)
264
- })
265
- }
266
- })
267
- })
@@ -1,111 +0,0 @@
1
- import { Nsid, isNsid } from '../lib/nsid.js'
2
- import { Parser } from '../lib/parser.js'
3
- import { ResourcePermission } from '../lib/resource-permission.js'
4
- import { ScopeStringSyntax } from '../lib/syntax-string.js'
5
- import {
6
- NeArray,
7
- NeRoArray,
8
- ScopeSyntax,
9
- isScopeStringFor,
10
- } from '../lib/syntax.js'
11
- import { knownValuesValidator } from '../lib/util.js'
12
-
13
- export { type Nsid, isNsid }
14
-
15
- export const REPO_ACTIONS = Object.freeze([
16
- 'create',
17
- 'update',
18
- 'delete',
19
- ] as const)
20
- export type RepoAction = (typeof REPO_ACTIONS)[number]
21
- export const isRepoAction = knownValuesValidator(REPO_ACTIONS)
22
-
23
- export type CollectionParam = '*' | Nsid
24
- export const isCollectionParam = (value: unknown): value is CollectionParam =>
25
- value === '*' || isNsid(value)
26
-
27
- export type RepoPermissionMatch = {
28
- collection: string
29
- action: RepoAction
30
- }
31
-
32
- export class RepoPermission
33
- implements ResourcePermission<'repo', RepoPermissionMatch>
34
- {
35
- constructor(
36
- public readonly collection: NeRoArray<'*' | Nsid>,
37
- public readonly action: NeRoArray<RepoAction>,
38
- ) {}
39
-
40
- matches({ action, collection }: RepoPermissionMatch) {
41
- return (
42
- this.action.includes(action) &&
43
- (this.collection.includes('*') ||
44
- (this.collection as readonly string[]).includes(collection))
45
- )
46
- }
47
-
48
- toString() {
49
- return RepoPermission.parser.format(this)
50
- }
51
-
52
- protected static readonly parser = new Parser(
53
- 'repo',
54
- {
55
- collection: {
56
- multiple: true,
57
- required: true,
58
- validate: isCollectionParam,
59
- normalize: (value) => {
60
- if (value.length > 1) {
61
- if (value.includes('*')) return ['*'] as const
62
- return [...new Set(value)].sort() as NeArray<Nsid>
63
- }
64
- return value as ['*' | Nsid]
65
- },
66
- },
67
- action: {
68
- multiple: true,
69
- required: false,
70
- validate: isRepoAction,
71
- default: REPO_ACTIONS,
72
- normalize: (value) => {
73
- return value === REPO_ACTIONS
74
- ? REPO_ACTIONS // No need to filter if the default was used
75
- : (REPO_ACTIONS.filter(includedIn, value) as NeArray<RepoAction>)
76
- },
77
- },
78
- },
79
- 'collection',
80
- )
81
-
82
- static fromString(scope: string): RepoPermission | null {
83
- if (!isScopeStringFor(scope, 'repo')) return null
84
- const syntax = ScopeStringSyntax.fromString(scope)
85
- return RepoPermission.fromSyntax(syntax)
86
- }
87
-
88
- static fromSyntax(syntax: ScopeSyntax<'repo'>): RepoPermission | null {
89
- const result = RepoPermission.parser.parse(syntax)
90
- if (!result) return null
91
-
92
- return new RepoPermission(result.collection, result.action)
93
- }
94
-
95
- static scopeNeededFor(options: RepoPermissionMatch): string {
96
- return RepoPermission.parser.format({
97
- collection: [options.collection as '*' | Nsid],
98
- action: [options.action],
99
- })
100
- }
101
- }
102
-
103
- /**
104
- * Special utility function to be used as predicate for array methods like
105
- * `Array.prototype.includes`, etc. When used as predicate, it expects that
106
- * the array method is called with a `thisArg` that is a readonly array of
107
- * the same type as the `value` parameter.
108
- */
109
- function includedIn<T>(this: readonly T[], value: T): boolean {
110
- return this.includes(value)
111
- }