@atproto/oauth-scopes 0.0.1

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 (104) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE.txt +7 -0
  3. package/dist/index.d.ts +16 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +32 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib/did.d.ts +3 -0
  8. package/dist/lib/did.d.ts.map +1 -0
  9. package/dist/lib/did.js +6 -0
  10. package/dist/lib/did.js.map +1 -0
  11. package/dist/lib/mime.d.ts +7 -0
  12. package/dist/lib/mime.d.ts.map +1 -0
  13. package/dist/lib/mime.js +65 -0
  14. package/dist/lib/mime.js.map +1 -0
  15. package/dist/lib/nsid.d.ts +3 -0
  16. package/dist/lib/nsid.d.ts.map +1 -0
  17. package/dist/lib/nsid.js +9 -0
  18. package/dist/lib/nsid.js.map +1 -0
  19. package/dist/lib/util.d.ts +3 -0
  20. package/dist/lib/util.d.ts.map +1 -0
  21. package/dist/lib/util.js +24 -0
  22. package/dist/lib/util.js.map +1 -0
  23. package/dist/parser.d.ts +31 -0
  24. package/dist/parser.d.ts.map +1 -0
  25. package/dist/parser.js +118 -0
  26. package/dist/parser.js.map +1 -0
  27. package/dist/permission-set-transition.d.ts +15 -0
  28. package/dist/permission-set-transition.d.ts.map +1 -0
  29. package/dist/permission-set-transition.js +52 -0
  30. package/dist/permission-set-transition.js.map +1 -0
  31. package/dist/permission-set.d.ts +22 -0
  32. package/dist/permission-set.d.ts.map +1 -0
  33. package/dist/permission-set.js +68 -0
  34. package/dist/permission-set.js.map +1 -0
  35. package/dist/resources/account-scope.d.ts +35 -0
  36. package/dist/resources/account-scope.d.ts.map +1 -0
  37. package/dist/resources/account-scope.js +60 -0
  38. package/dist/resources/account-scope.js.map +1 -0
  39. package/dist/resources/blob-scope.d.ts +25 -0
  40. package/dist/resources/blob-scope.d.ts.map +1 -0
  41. package/dist/resources/blob-scope.js +74 -0
  42. package/dist/resources/blob-scope.js.map +1 -0
  43. package/dist/resources/identity-scope.d.ts +25 -0
  44. package/dist/resources/identity-scope.d.ts.map +1 -0
  45. package/dist/resources/identity-scope.js +46 -0
  46. package/dist/resources/identity-scope.js.map +1 -0
  47. package/dist/resources/repo-scope.d.ts +37 -0
  48. package/dist/resources/repo-scope.d.ts.map +1 -0
  49. package/dist/resources/repo-scope.js +92 -0
  50. package/dist/resources/repo-scope.js.map +1 -0
  51. package/dist/resources/rpc-scope.d.ts +31 -0
  52. package/dist/resources/rpc-scope.d.ts.map +1 -0
  53. package/dist/resources/rpc-scope.js +74 -0
  54. package/dist/resources/rpc-scope.js.map +1 -0
  55. package/dist/scope-missing-error.d.ts +9 -0
  56. package/dist/scope-missing-error.d.ts.map +1 -0
  57. package/dist/scope-missing-error.js +39 -0
  58. package/dist/scope-missing-error.js.map +1 -0
  59. package/dist/scopes-set.d.ts +21 -0
  60. package/dist/scopes-set.d.ts.map +1 -0
  61. package/dist/scopes-set.js +55 -0
  62. package/dist/scopes-set.js.map +1 -0
  63. package/dist/syntax.d.ts +76 -0
  64. package/dist/syntax.d.ts.map +1 -0
  65. package/dist/syntax.js +249 -0
  66. package/dist/syntax.js.map +1 -0
  67. package/dist/utilities.d.ts +17 -0
  68. package/dist/utilities.d.ts.map +1 -0
  69. package/dist/utilities.js +108 -0
  70. package/dist/utilities.js.map +1 -0
  71. package/jest.config.js +5 -0
  72. package/package.json +36 -0
  73. package/src/index.ts +17 -0
  74. package/src/lib/did.ts +3 -0
  75. package/src/lib/mime.test.ts +98 -0
  76. package/src/lib/mime.ts +70 -0
  77. package/src/lib/nsid.ts +6 -0
  78. package/src/lib/util.ts +19 -0
  79. package/src/parser.ts +150 -0
  80. package/src/permission-set-transition.test.ts +109 -0
  81. package/src/permission-set-transition.ts +67 -0
  82. package/src/permission-set.test.ts +225 -0
  83. package/src/permission-set.ts +78 -0
  84. package/src/resources/account-scope.test.ts +175 -0
  85. package/src/resources/account-scope.ts +66 -0
  86. package/src/resources/blob-scope.test.ts +118 -0
  87. package/src/resources/blob-scope.ts +86 -0
  88. package/src/resources/identity-scope.test.ts +80 -0
  89. package/src/resources/identity-scope.ts +49 -0
  90. package/src/resources/repo-scope.test.ts +255 -0
  91. package/src/resources/repo-scope.ts +101 -0
  92. package/src/resources/rpc-scope.test.ts +280 -0
  93. package/src/resources/rpc-scope.ts +77 -0
  94. package/src/scope-missing-error.ts +15 -0
  95. package/src/scopes-set.test.ts +47 -0
  96. package/src/scopes-set.ts +60 -0
  97. package/src/syntax.test.ts +203 -0
  98. package/src/syntax.ts +325 -0
  99. package/src/utilities.ts +109 -0
  100. package/tsconfig.build.json +9 -0
  101. package/tsconfig.build.tsbuildinfo +1 -0
  102. package/tsconfig.json +7 -0
  103. package/tsconfig.tests.json +7 -0
  104. package/tsconfig.tests.tsbuildinfo +1 -0
@@ -0,0 +1,77 @@
1
+ import { DIDLike, isDIDLike } from '../lib/did.js'
2
+ import { NSID, isNSID } from '../lib/nsid.js'
3
+ import { Parser } from '../parser.js'
4
+ import { NeRoArray, ResourceSyntax, isScopeForResource } from '../syntax.js'
5
+
6
+ const validateLxmParam = (value: string) => value === '*' || isNSID(value)
7
+ const validateAudParam = (value: string) => value === '*' || isDIDLike(value)
8
+
9
+ export const rpcParser = new Parser(
10
+ 'rpc',
11
+ {
12
+ lxm: {
13
+ multiple: true,
14
+ required: true,
15
+ validate: validateLxmParam,
16
+ },
17
+ aud: {
18
+ multiple: false,
19
+ required: true,
20
+ validate: validateAudParam,
21
+ },
22
+ },
23
+ 'lxm',
24
+ )
25
+
26
+ export type RpcScopeMatch = {
27
+ lxm: string
28
+ aud: string
29
+ }
30
+
31
+ export class RpcScope {
32
+ constructor(
33
+ public readonly aud: '*' | DIDLike,
34
+ public readonly lxm: NeRoArray<'*' | NSID>,
35
+ ) {}
36
+
37
+ matches(options: RpcScopeMatch): boolean {
38
+ const { aud, lxm } = this
39
+ return (
40
+ (aud === '*' || aud === options.aud) &&
41
+ (lxm.includes('*') || (lxm as readonly string[]).includes(options.lxm))
42
+ )
43
+ }
44
+
45
+ toString(): string {
46
+ const { lxm, aud } = this
47
+ return rpcParser.format({
48
+ aud,
49
+ lxm: lxm.includes('*')
50
+ ? ['*']
51
+ : ([...new Set(lxm)].sort() as [NSID, ...NSID[]]),
52
+ })
53
+ }
54
+
55
+ static fromString(scope: string): RpcScope | null {
56
+ if (!isScopeForResource(scope, 'rpc')) return null
57
+ const syntax = ResourceSyntax.fromString(scope)
58
+ return this.fromSyntax(syntax)
59
+ }
60
+
61
+ static fromSyntax(syntax: ResourceSyntax): RpcScope | null {
62
+ const result = rpcParser.parse(syntax)
63
+ if (!result) return null
64
+
65
+ // rpc:*?aud=* is forbidden
66
+ if (result.aud === '*' && result.lxm.includes('*')) return null
67
+
68
+ return new RpcScope(result.aud, result.lxm)
69
+ }
70
+
71
+ static scopeNeededFor(options: RpcScopeMatch): string {
72
+ return rpcParser.format({
73
+ aud: options.aud as DIDLike,
74
+ lxm: [options.lxm as NSID],
75
+ })
76
+ }
77
+ }
@@ -0,0 +1,15 @@
1
+ export class ScopeMissingError extends Error {
2
+ name = 'ScopeMissingError'
3
+
4
+ // compatibility layer with http-errors package. The goal if to make
5
+ // isHttpError(new ScopeMissingError) return true.
6
+ status = 403
7
+ expose = true
8
+ get statusCode() {
9
+ return this.status
10
+ }
11
+
12
+ constructor(public readonly scope: string) {
13
+ super(`Missing required scope "${scope}"`)
14
+ }
15
+ }
@@ -0,0 +1,47 @@
1
+ import { ScopesSet } from './scopes-set.js'
2
+
3
+ describe('ScopesSet', () => {
4
+ it('should initialize with an empty set', () => {
5
+ const set = new ScopesSet()
6
+ expect(set.size).toBe(0)
7
+ })
8
+
9
+ it('should add scopes correctly', () => {
10
+ const set = new ScopesSet()
11
+ set.add('repo:read')
12
+ expect(set.size).toBe(1)
13
+ expect(set.has('repo:read')).toBe(true)
14
+ expect(set.has('repo:write')).toBe(false)
15
+ })
16
+
17
+ it('should remove scopes correctly', () => {
18
+ const set = new ScopesSet(['repo:read'])
19
+ set.delete('repo:read')
20
+ expect(set.size).toBe(0)
21
+ expect(set.has('repo:read')).toBe(false)
22
+ })
23
+
24
+ it('should match included scopes', () => {
25
+ const set = new ScopesSet(['repo:foo.bar'])
26
+ expect(
27
+ set.matches('repo', { action: 'create', collection: 'foo.bar' }),
28
+ ).toBe(true)
29
+ expect(
30
+ set.matches('repo', { action: 'create', collection: 'baz.qux' }),
31
+ ).toBe(false)
32
+ })
33
+
34
+ it('should not match missing scopes', () => {
35
+ const set = new ScopesSet(['repo:foo.bar?action=create'])
36
+ expect(
37
+ set.matches('repo', { action: 'delete', collection: 'foo.bar' }),
38
+ ).toBe(false)
39
+ })
40
+
41
+ it('should not match invalid scopes', () => {
42
+ const set = new ScopesSet(['repo:not-a-valid-nsid'])
43
+ expect(
44
+ set.matches('repo', { action: 'create', collection: 'not-a-valid-nsid' }),
45
+ ).toBe(false)
46
+ })
47
+ })
@@ -0,0 +1,60 @@
1
+ import { ScopeMissingError } from './scope-missing-error.js'
2
+ import {
3
+ ScopeMatchingOptionsByResource,
4
+ scopeMatches,
5
+ scopeNeededFor,
6
+ } from './utilities.js'
7
+
8
+ export { ScopeMissingError }
9
+
10
+ /**
11
+ * Utility class to manage a set of scopes and check if they match specific
12
+ * options for a given resource.
13
+ */
14
+ export class ScopesSet extends Set<string> {
15
+ /**
16
+ * Check if the container has a scope that matches the given options for a
17
+ * specific resource.
18
+ */
19
+ public matches<R extends keyof ScopeMatchingOptionsByResource>(
20
+ resource: R,
21
+ options: ScopeMatchingOptionsByResource[R],
22
+ ): boolean {
23
+ for (const scope of this) {
24
+ if (scopeMatches(scope, resource, options)) return true
25
+ }
26
+ return false
27
+ }
28
+
29
+ public assert<R extends keyof ScopeMatchingOptionsByResource>(
30
+ resource: R,
31
+ options: ScopeMatchingOptionsByResource[R],
32
+ ) {
33
+ if (!this.matches(resource, options)) {
34
+ const scope = scopeNeededFor(resource, options)
35
+ throw new ScopeMissingError(scope)
36
+ }
37
+ }
38
+
39
+ public some(fn: (scope: string) => boolean): boolean {
40
+ for (const scope of this) if (fn(scope)) return true
41
+ return false
42
+ }
43
+
44
+ public every(fn: (scope: string) => boolean): boolean {
45
+ for (const scope of this) if (!fn(scope)) return false
46
+ return true
47
+ }
48
+
49
+ public *filter(fn: (scope: string) => boolean) {
50
+ for (const scope of this) if (fn(scope)) yield scope
51
+ }
52
+
53
+ public *map<O>(fn: (scope: string) => O) {
54
+ for (const scope of this) yield fn(scope)
55
+ }
56
+
57
+ static fromString(string?: string): ScopesSet {
58
+ return new ScopesSet(string?.split(' '))
59
+ }
60
+ }
@@ -0,0 +1,203 @@
1
+ import { ResourceSyntax, isScopeForResource } from './syntax.js'
2
+
3
+ describe('isScopeForResource', () => {
4
+ describe('exact match', () => {
5
+ it('should return true for exact match', () => {
6
+ expect(isScopeForResource('resource', 'resource')).toBe(true)
7
+ })
8
+
9
+ it('should return false for different resource', () => {
10
+ expect(isScopeForResource('resource', 'differentResource')).toBe(false)
11
+ })
12
+ })
13
+
14
+ describe('with positional parameter', () => {
15
+ it('should return true for exact match with positional parameter', () => {
16
+ expect(isScopeForResource('resource:positional', 'resource')).toBe(true)
17
+ })
18
+
19
+ it('should return false for different resource with positional parameter', () => {
20
+ expect(
21
+ isScopeForResource('differentResource:positional', 'resource'),
22
+ ).toBe(false)
23
+ })
24
+ })
25
+
26
+ describe('with named parameters', () => {
27
+ it('should return true for exact match with named parameters', () => {
28
+ expect(isScopeForResource('resource?param=value', 'resource')).toBe(true)
29
+ })
30
+
31
+ it('should return false for different resource with named parameters', () => {
32
+ expect(
33
+ isScopeForResource('differentResource?param=value', 'resource'),
34
+ ).toBe(false)
35
+ })
36
+ })
37
+ })
38
+
39
+ for (const { scope, normalized = scope, content } of [
40
+ {
41
+ scope: 'my-res',
42
+ content: { resource: 'my-res' },
43
+ },
44
+ {
45
+ scope: 'my-res:my-pos',
46
+ content: { resource: 'my-res', positional: 'my-pos' },
47
+ },
48
+ {
49
+ scope: 'my-res:',
50
+ content: { resource: 'my-res', positional: '' },
51
+ },
52
+ {
53
+ scope: 'my-res:foo?x=value&y=value-y',
54
+ content: {
55
+ resource: 'my-res',
56
+ positional: 'foo',
57
+ params: { x: ['value'], y: ['value-y'] },
58
+ },
59
+ },
60
+ {
61
+ scope: 'my-res?x=value&y=value-y',
62
+ content: { resource: 'my-res', params: { x: ['value'], y: ['value-y'] } },
63
+ },
64
+ {
65
+ scope: 'my-res?x=foo&x=bar&x=baz',
66
+ content: { resource: 'my-res', params: { x: ['foo', 'bar', 'baz'] } },
67
+ },
68
+ {
69
+ scope: 'rpc:foo.bar?aud=did:foo:bar?lxm=bar.baz',
70
+ normalized: 'rpc:foo.bar?aud=did:foo:bar%3Flxm%3Dbar.baz',
71
+ content: {
72
+ resource: 'rpc',
73
+ positional: 'foo.bar',
74
+ params: { aud: ['did:foo:bar?lxm=bar.baz'] },
75
+ },
76
+ },
77
+ ] as Array<{
78
+ scope: string
79
+ normalized?: string
80
+ content: {
81
+ resource: string
82
+ positional?: string
83
+ params?: Record<string, string[]>
84
+ }
85
+ }>) {
86
+ describe(`Valid "${scope}"`, () => {
87
+ const syntax = ResourceSyntax.fromString(scope)
88
+
89
+ it('should match the expected syntax', () => {
90
+ expect(syntax).toEqual({
91
+ resource: content.resource,
92
+ positional: content.positional,
93
+ params: content.params
94
+ ? new URLSearchParams(
95
+ Object.entries(content.params).flatMap(([k, v]) =>
96
+ v.map((val) => [k, val]),
97
+ ),
98
+ )
99
+ : undefined,
100
+ })
101
+ })
102
+
103
+ it(`should stringify ${scope} correctly`, () => {
104
+ expect(syntax.toString()).toBe(normalized)
105
+ })
106
+
107
+ it(`should parse ${scope} correctly`, () => {
108
+ expect(syntax.toJSON()).toMatchObject(content)
109
+ })
110
+
111
+ it(`should match ${scope} resource`, () => {
112
+ expect(syntax.is(content.resource)).toBe(true)
113
+ })
114
+
115
+ it(`should return undefined for nonexistent single-value param`, () => {
116
+ expect(syntax.getSingle('nonexistent')).toBeUndefined()
117
+ })
118
+
119
+ it(`should return undefined for nonexistent multi-value param`, () => {
120
+ expect(syntax.getMulti('nonexistent')).toBeUndefined()
121
+ })
122
+
123
+ const { params } = content
124
+ if (params) {
125
+ it(`should allow detecting unknown params`, () => {
126
+ const allowedParams = Object.keys(params) as [string, ...string[]]
127
+ expect(syntax.containsParamsOtherThan(allowedParams)).toBe(false)
128
+
129
+ if (allowedParams.length > 1) {
130
+ const woFirst = allowedParams.slice(1) as [string, ...string[]]
131
+ expect(syntax.containsParamsOtherThan(woFirst)).toBe(true)
132
+
133
+ const woLast = allowedParams.slice(0, -1) as [string, ...string[]]
134
+ expect(syntax.containsParamsOtherThan(woLast)).toBe(true)
135
+ }
136
+ })
137
+
138
+ for (const [key, values] of Object.entries(params)) {
139
+ it(`should get an array when reading "${key}"`, () => {
140
+ expect(syntax.getMulti(key)).toEqual(values)
141
+ })
142
+
143
+ if (values.length === 1) {
144
+ it(`should allow retrieving single-value params`, () => {
145
+ expect(syntax.getSingle(key)).toEqual(values[0])
146
+ })
147
+ } else {
148
+ it(`should return null for multi-value params`, () => {
149
+ expect(syntax.getSingle(key)).toBeNull()
150
+ expect(syntax.getSingle(key, true)).toBeNull()
151
+ })
152
+ }
153
+ }
154
+ }
155
+
156
+ const { positional } = content
157
+ if (positional !== undefined) {
158
+ it(`should return positional parameter`, () => {
159
+ expect(syntax.positional).toBe(positional)
160
+ })
161
+
162
+ it(`should return positional parameter when reading as single-value`, () => {
163
+ expect(syntax.getSingle('nonexistent', true)).toBe(positional)
164
+ })
165
+
166
+ it(`should return positional parameter when reading as multi-value`, () => {
167
+ expect(syntax.getMulti('nonexistent', true)).toEqual([positional])
168
+ })
169
+ }
170
+ })
171
+ }
172
+
173
+ describe('invalid positional parameters', () => {
174
+ it('should return null for positional parameters used together with named parameters', () => {
175
+ const syntax = ResourceSyntax.fromString('my-res:pos?x=value')
176
+ expect(syntax.getSingle('x', true)).toBeNull()
177
+ expect(syntax.getMulti('x', true)).toBeNull()
178
+ })
179
+ })
180
+
181
+ describe('containsParamsOtherThan', () => {
182
+ it('should return true if there are additional params', () => {
183
+ const syntax = ResourceSyntax.fromString('my-res?x=value&y=value-y')
184
+ expect(syntax.containsParamsOtherThan(['x'])).toBe(true)
185
+ })
186
+ })
187
+
188
+ describe('url encoding', () => {
189
+ it('should handle URL encoding in positional parameters', () => {
190
+ const syntax = ResourceSyntax.fromString('my-res:my%20pos')
191
+ expect(syntax.positional).toBe('my pos')
192
+ })
193
+
194
+ it('should handle URL encoding in named parameters', () => {
195
+ const syntax = ResourceSyntax.fromString('my-res?x=my%20value')
196
+ expect(syntax.getSingle('x')).toBe('my value')
197
+ })
198
+
199
+ it(`should allow colon (:) in positional parameters`, () => {
200
+ const syntax = ResourceSyntax.fromString('my-res:my:pos')
201
+ expect(syntax.positional).toBe('my:pos')
202
+ })
203
+ })