@atproto/oauth-provider 0.10.1 → 0.11.0
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.
- package/CHANGELOG.md +32 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/customization/branding.d.ts +5 -19
- package/dist/customization/branding.d.ts.map +1 -1
- package/dist/customization/customization.d.ts +7 -25
- package/dist/customization/customization.d.ts.map +1 -1
- package/dist/customization/links.d.ts +3 -13
- package/dist/customization/links.d.ts.map +1 -1
- package/dist/customization/links.js +1 -1
- package/dist/customization/links.js.map +1 -1
- package/dist/lexicon/lexicon-data.d.ts +9 -0
- package/dist/lexicon/lexicon-data.d.ts.map +1 -0
- package/dist/lexicon/lexicon-data.js +3 -0
- package/dist/lexicon/lexicon-data.js.map +1 -0
- package/dist/lexicon/lexicon-getter.d.ts +15 -0
- package/dist/lexicon/lexicon-getter.d.ts.map +1 -0
- package/dist/lexicon/lexicon-getter.js +55 -0
- package/dist/lexicon/lexicon-getter.js.map +1 -0
- package/dist/lexicon/lexicon-manager.d.ts +60 -0
- package/dist/lexicon/lexicon-manager.d.ts.map +1 -0
- package/dist/lexicon/lexicon-manager.js +105 -0
- package/dist/lexicon/lexicon-manager.js.map +1 -0
- package/dist/lexicon/lexicon-store.d.ts +13 -0
- package/dist/lexicon/lexicon-store.d.ts.map +1 -0
- package/dist/lexicon/lexicon-store.js +24 -0
- package/dist/lexicon/lexicon-store.js.map +1 -0
- package/dist/lib/html/hydration-data.d.ts.map +1 -1
- package/dist/lib/html/hydration-data.js +1 -2
- package/dist/lib/html/hydration-data.js.map +1 -1
- package/dist/lib/nsid.d.ts +4 -0
- package/dist/lib/nsid.d.ts.map +1 -0
- package/dist/lib/nsid.js +15 -0
- package/dist/lib/nsid.js.map +1 -0
- package/dist/lib/util/locale.d.ts +1 -15
- package/dist/lib/util/locale.d.ts.map +1 -1
- package/dist/lib/util/locale.js +2 -7
- package/dist/lib/util/locale.js.map +1 -1
- package/dist/oauth-provider.d.ts +14 -9
- package/dist/oauth-provider.d.ts.map +1 -1
- package/dist/oauth-provider.js +15 -6
- package/dist/oauth-provider.js.map +1 -1
- package/dist/oauth-store.d.ts +1 -0
- package/dist/oauth-store.d.ts.map +1 -1
- package/dist/oauth-store.js +1 -0
- package/dist/oauth-store.js.map +1 -1
- package/dist/request/request-manager.d.ts +3 -1
- package/dist/request/request-manager.d.ts.map +1 -1
- package/dist/request/request-manager.js +19 -4
- package/dist/request/request-manager.js.map +1 -1
- package/dist/result/authorization-result-authorize-page.d.ts +5 -3
- package/dist/result/authorization-result-authorize-page.d.ts.map +1 -1
- package/dist/router/assets/send-authorization-page.d.ts.map +1 -1
- package/dist/router/assets/send-authorization-page.js +1 -0
- package/dist/router/assets/send-authorization-page.js.map +1 -1
- package/dist/signer/api-token-payload.d.ts +60 -630
- package/dist/signer/api-token-payload.d.ts.map +1 -1
- package/dist/signer/signed-token-payload.d.ts +60 -630
- package/dist/signer/signed-token-payload.d.ts.map +1 -1
- package/dist/signer/signer.d.ts +11 -11
- package/dist/signer/signer.d.ts.map +1 -1
- package/dist/token/token-data.d.ts +7 -0
- package/dist/token/token-data.d.ts.map +1 -1
- package/dist/token/token-manager.d.ts +5 -6
- package/dist/token/token-manager.d.ts.map +1 -1
- package/dist/token/token-manager.js +37 -13
- package/dist/token/token-manager.js.map +1 -1
- package/dist/token/token-store.d.ts +16 -2
- package/dist/token/token-store.d.ts.map +1 -1
- package/dist/token/token-store.js.map +1 -1
- package/package.json +15 -13
- package/src/constants.ts +3 -0
- package/src/customization/links.ts +2 -2
- package/src/lexicon/lexicon-data.ts +9 -0
- package/src/lexicon/lexicon-getter.ts +62 -0
- package/src/lexicon/lexicon-manager.ts +116 -0
- package/src/lexicon/lexicon-store.ts +36 -0
- package/src/lib/html/hydration-data.ts +1 -2
- package/src/lib/nsid.ts +10 -0
- package/src/lib/util/locale.ts +3 -9
- package/src/oauth-provider.ts +30 -9
- package/src/oauth-store.ts +1 -0
- package/src/request/request-manager.ts +26 -7
- package/src/result/authorization-result-authorize-page.ts +5 -3
- package/src/router/assets/send-authorization-page.ts +1 -0
- package/src/token/token-data.ts +8 -0
- package/src/token/token-manager.ts +68 -34
- package/src/token/token-store.ts +17 -5
- package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,116 @@
|
|
1
|
+
import { LexPermissionSet } from '@atproto/lexicon'
|
2
|
+
import {
|
3
|
+
LexiconResolutionError,
|
4
|
+
LexiconResolver,
|
5
|
+
} from '@atproto/lexicon-resolver'
|
6
|
+
import { IncludeScope, Nsid } from '@atproto/oauth-scopes'
|
7
|
+
import { LexiconGetter } from './lexicon-getter.js'
|
8
|
+
import { LexiconStore } from './lexicon-store.js'
|
9
|
+
|
10
|
+
export * from './lexicon-store.js'
|
11
|
+
|
12
|
+
export class LexiconManager {
|
13
|
+
protected readonly lexiconGetter: LexiconGetter
|
14
|
+
|
15
|
+
constructor(store: LexiconStore, resolveLexicon?: LexiconResolver) {
|
16
|
+
this.lexiconGetter = new LexiconGetter(store, resolveLexicon)
|
17
|
+
}
|
18
|
+
|
19
|
+
public async getPermissionSetsFromScope(scope?: string) {
|
20
|
+
const { includeScopes } = parseScope(scope)
|
21
|
+
return this.extractPermissionSets(includeScopes)
|
22
|
+
}
|
23
|
+
|
24
|
+
/**
|
25
|
+
* Transforms a scope string from an authorization request into a scope
|
26
|
+
* composed solely of granular permission scopes, transforming any NSID
|
27
|
+
* into its corresponding permission scopes.
|
28
|
+
*/
|
29
|
+
public async buildTokenScope(scope: string): Promise<string> {
|
30
|
+
const { includeScopes, otherScopes } = parseScope(scope)
|
31
|
+
|
32
|
+
// If the scope does not contain any "include:<nsid>" scopes, return it as-is.
|
33
|
+
if (!includeScopes.length) return scope
|
34
|
+
|
35
|
+
const permissionSets = await this.extractPermissionSets(includeScopes)
|
36
|
+
|
37
|
+
return Array.from(includeScopes)
|
38
|
+
.flatMap(nsidToPermissionScopes, permissionSets)
|
39
|
+
.concat(otherScopes)
|
40
|
+
.join(' ')
|
41
|
+
}
|
42
|
+
|
43
|
+
/**
|
44
|
+
* Given a list of scope values, extract those that are NSIDs and return their
|
45
|
+
* corresponding permission sets.
|
46
|
+
*/
|
47
|
+
protected async extractPermissionSets(includeScopes: IncludeScope[]) {
|
48
|
+
const nsids = extractNsids(includeScopes)
|
49
|
+
return this.getPermissionSets(nsids)
|
50
|
+
}
|
51
|
+
|
52
|
+
protected async getPermissionSets(nsids: Set<Nsid>) {
|
53
|
+
return new Map<string, LexPermissionSet>(
|
54
|
+
await Promise.all(Array.from(nsids, this.getPermissionSetEntry, this)),
|
55
|
+
)
|
56
|
+
}
|
57
|
+
|
58
|
+
protected async getPermissionSetEntry(
|
59
|
+
nsid: Nsid,
|
60
|
+
): Promise<[nsid: Nsid, permissionSet: LexPermissionSet]> {
|
61
|
+
const permissionSet = await this.getPermissionSet(nsid)
|
62
|
+
return [nsid, permissionSet]
|
63
|
+
}
|
64
|
+
|
65
|
+
protected async getPermissionSet(nsid: Nsid): Promise<LexPermissionSet> {
|
66
|
+
const { lexicon } = await this.lexiconGetter.get(nsid)
|
67
|
+
|
68
|
+
if (!lexicon) {
|
69
|
+
throw LexiconResolutionError.from(nsid)
|
70
|
+
}
|
71
|
+
|
72
|
+
if (lexicon.defs.main?.type !== 'permission-set') {
|
73
|
+
const description = 'Lexicon document is not a permission set'
|
74
|
+
throw LexiconResolutionError.from(nsid, description)
|
75
|
+
}
|
76
|
+
|
77
|
+
return lexicon.defs.main
|
78
|
+
}
|
79
|
+
}
|
80
|
+
|
81
|
+
function parseScope(scope?: string) {
|
82
|
+
const includeScopes: IncludeScope[] = []
|
83
|
+
const otherScopes: string[] = []
|
84
|
+
|
85
|
+
if (scope) {
|
86
|
+
for (const scopeValue of scope.split(' ')) {
|
87
|
+
const parsed = IncludeScope.fromString(scopeValue)
|
88
|
+
if (parsed) {
|
89
|
+
includeScopes.push(parsed)
|
90
|
+
} else {
|
91
|
+
otherScopes.push(scopeValue)
|
92
|
+
}
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
return {
|
97
|
+
includeScopes,
|
98
|
+
otherScopes,
|
99
|
+
}
|
100
|
+
}
|
101
|
+
|
102
|
+
function extractNsids(includeScopes: IncludeScope[]): Set<Nsid> {
|
103
|
+
return new Set(Array.from(includeScopes, extractNsid))
|
104
|
+
}
|
105
|
+
|
106
|
+
function extractNsid(nsidScope: IncludeScope): Nsid {
|
107
|
+
return nsidScope.nsid
|
108
|
+
}
|
109
|
+
|
110
|
+
export function nsidToPermissionScopes(
|
111
|
+
this: Map<string, LexPermissionSet>,
|
112
|
+
includeScope: IncludeScope,
|
113
|
+
): string[] {
|
114
|
+
const permissionSet = this.get(includeScope.nsid)!
|
115
|
+
return includeScope.toPermissions(permissionSet).map(String)
|
116
|
+
}
|
@@ -0,0 +1,36 @@
|
|
1
|
+
import { LexiconDoc } from '@atproto/lexicon'
|
2
|
+
import { Awaitable, buildInterfaceChecker } from '../lib/util/type.js'
|
3
|
+
import { LexiconData } from './lexicon-data.js'
|
4
|
+
|
5
|
+
export type { Awaitable, LexiconData, LexiconDoc }
|
6
|
+
|
7
|
+
export interface LexiconStore {
|
8
|
+
findLexicon(nsid: string): Awaitable<LexiconData | null>
|
9
|
+
storeLexicon(nsid: string, data: LexiconData): Awaitable<void>
|
10
|
+
deleteLexicon(nsid: string): Awaitable<void>
|
11
|
+
}
|
12
|
+
|
13
|
+
export const isLexiconStore = buildInterfaceChecker<LexiconStore>([
|
14
|
+
'findLexicon',
|
15
|
+
'storeLexicon',
|
16
|
+
'deleteLexicon',
|
17
|
+
])
|
18
|
+
|
19
|
+
export function ifLexiconStore<V extends Partial<LexiconStore>>(
|
20
|
+
implementation?: V,
|
21
|
+
): (V & LexiconStore) | undefined {
|
22
|
+
if (implementation && isLexiconStore(implementation)) {
|
23
|
+
return implementation
|
24
|
+
}
|
25
|
+
|
26
|
+
return undefined
|
27
|
+
}
|
28
|
+
|
29
|
+
export function asLexiconStore<V extends Partial<LexiconStore>>(
|
30
|
+
implementation?: V,
|
31
|
+
): V & LexiconStore {
|
32
|
+
const store = ifLexiconStore(implementation)
|
33
|
+
if (store) return store
|
34
|
+
|
35
|
+
throw new Error('Invalid LexiconStore implementation')
|
36
|
+
}
|
@@ -14,7 +14,6 @@ export function* hydrationDataGenerator(
|
|
14
14
|
}
|
15
15
|
// The script tag is removed after the data is assigned to the global
|
16
16
|
// variables to prevent other scripts from reading the values. The "app"
|
17
|
-
// script will read the global variable and then unset it.
|
18
|
-
// `readBackendData()` in "src/assets/app/backend-data.ts".
|
17
|
+
// script will read the global variable and then unset it.
|
19
18
|
yield js`document.currentScript.remove();`
|
20
19
|
}
|
package/src/lib/nsid.ts
ADDED
package/src/lib/util/locale.ts
CHANGED
@@ -5,14 +5,8 @@ export const localeSchema = z
|
|
5
5
|
.regex(/^[a-z]{2,3}(-[A-Z]{2})?$/, 'Invalid locale')
|
6
6
|
export type Locale = z.infer<typeof localeSchema>
|
7
7
|
|
8
|
-
export const multiLangStringSchema = z.
|
9
|
-
|
10
|
-
z.
|
8
|
+
export const multiLangStringSchema = z.record(
|
9
|
+
localeSchema,
|
10
|
+
z.string().optional(),
|
11
11
|
)
|
12
12
|
export type MultiLangString = z.infer<typeof multiLangStringSchema>
|
13
|
-
|
14
|
-
export const localizedStringSchema = z.union([
|
15
|
-
z.string(),
|
16
|
-
multiLangStringSchema,
|
17
|
-
])
|
18
|
-
export type LocalizedString = z.infer<typeof localizedStringSchema>
|
package/src/oauth-provider.ts
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
import { createHash } from 'node:crypto'
|
2
2
|
import type { Redis, RedisOptions } from 'ioredis'
|
3
3
|
import { Jwks, Keyset } from '@atproto/jwk'
|
4
|
+
import { LexiconResolver } from '@atproto/lexicon-resolver'
|
4
5
|
import type { Account } from '@atproto/oauth-provider-api'
|
5
6
|
import {
|
6
7
|
CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
@@ -71,11 +72,13 @@ import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js'
|
|
71
72
|
import { InvalidGrantError } from './errors/invalid-grant-error.js'
|
72
73
|
import { InvalidRequestError } from './errors/invalid-request-error.js'
|
73
74
|
import { LoginRequiredError } from './errors/login-required-error.js'
|
75
|
+
import { LexiconManager } from './lexicon/lexicon-manager.js'
|
76
|
+
import { LexiconStore, asLexiconStore } from './lexicon/lexicon-store.js'
|
74
77
|
import { HcaptchaConfig } from './lib/hcaptcha.js'
|
75
78
|
import { RequestMetadata } from './lib/http/request.js'
|
76
79
|
import { dateToRelativeSeconds } from './lib/util/date.js'
|
77
80
|
import { formatError } from './lib/util/error.js'
|
78
|
-
import {
|
81
|
+
import { MultiLangString } from './lib/util/locale.js'
|
79
82
|
import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js'
|
80
83
|
import { OAuthHooks } from './oauth-hooks.js'
|
81
84
|
import {
|
@@ -117,7 +120,7 @@ export type {
|
|
117
120
|
CustomizationInput,
|
118
121
|
ErrorHandler,
|
119
122
|
HcaptchaConfig,
|
120
|
-
|
123
|
+
LexiconResolver,
|
121
124
|
MultiLangString,
|
122
125
|
OAuthAuthorizationServerMetadata,
|
123
126
|
}
|
@@ -129,11 +132,6 @@ type OAuthProviderConfig = {
|
|
129
132
|
*/
|
130
133
|
authenticationMaxAge?: number
|
131
134
|
|
132
|
-
/**
|
133
|
-
* Maximum age an ephemeral session (one where "remember me" was not
|
134
|
-
* checked) can be before requiring re-authentication.
|
135
|
-
*/
|
136
|
-
|
137
135
|
/**
|
138
136
|
* Maximum age access & id tokens can be before requiring a refresh.
|
139
137
|
*/
|
@@ -169,6 +167,11 @@ type OAuthProviderConfig = {
|
|
169
167
|
*/
|
170
168
|
safeFetch?: typeof globalThis.fetch
|
171
169
|
|
170
|
+
/**
|
171
|
+
* A custom ATProto lexicon resolver
|
172
|
+
*/
|
173
|
+
lexiconResolver?: LexiconResolver
|
174
|
+
|
172
175
|
/**
|
173
176
|
* A redis instance to use for replay protection. If not provided, replay
|
174
177
|
* protection will use memory storage.
|
@@ -186,6 +189,7 @@ type OAuthProviderConfig = {
|
|
186
189
|
AccountStore &
|
187
190
|
ClientStore &
|
188
191
|
DeviceStore &
|
192
|
+
LexiconStore &
|
189
193
|
ReplayStore &
|
190
194
|
RequestStore &
|
191
195
|
TokenStore
|
@@ -194,6 +198,7 @@ type OAuthProviderConfig = {
|
|
194
198
|
accountStore?: AccountStore
|
195
199
|
clientStore?: ClientStore
|
196
200
|
deviceStore?: DeviceStore
|
201
|
+
lexiconStore?: LexiconStore
|
197
202
|
replayStore?: ReplayStore
|
198
203
|
requestStore?: RequestStore
|
199
204
|
tokenStore?: TokenStore
|
@@ -243,6 +248,7 @@ export class OAuthProvider extends OAuthVerifier {
|
|
243
248
|
public readonly accountManager: AccountManager
|
244
249
|
public readonly deviceManager: DeviceManager
|
245
250
|
public readonly clientManager: ClientManager
|
251
|
+
public readonly lexiconManager: LexiconManager
|
246
252
|
public readonly requestManager: RequestManager
|
247
253
|
public readonly tokenManager: TokenManager
|
248
254
|
|
@@ -254,16 +260,18 @@ export class OAuthProvider extends OAuthVerifier {
|
|
254
260
|
|
255
261
|
metadata,
|
256
262
|
|
263
|
+
lexiconResolver,
|
257
264
|
safeFetch = safeFetchWrap(),
|
258
265
|
store, // compound store implementation
|
259
266
|
|
260
|
-
//
|
267
|
+
// Required stores
|
261
268
|
accountStore = asAccountStore(store),
|
262
269
|
deviceStore = asDeviceStore(store),
|
270
|
+
lexiconStore = asLexiconStore(store),
|
263
271
|
tokenStore = asTokenStore(store),
|
264
272
|
requestStore = asRequestStore(store),
|
265
273
|
|
266
|
-
//
|
274
|
+
// Optional stores
|
267
275
|
clientStore = ifClientStore(store),
|
268
276
|
replayStore = ifReplayStore(store),
|
269
277
|
|
@@ -322,14 +330,17 @@ export class OAuthProvider extends OAuthVerifier {
|
|
322
330
|
clientJwksCache,
|
323
331
|
clientMetadataCache,
|
324
332
|
)
|
333
|
+
this.lexiconManager = new LexiconManager(lexiconStore, lexiconResolver)
|
325
334
|
this.requestManager = new RequestManager(
|
326
335
|
requestStore,
|
336
|
+
this.lexiconManager,
|
327
337
|
this.signer,
|
328
338
|
this.metadata,
|
329
339
|
this.hooks,
|
330
340
|
)
|
331
341
|
this.tokenManager = new TokenManager(
|
332
342
|
tokenStore,
|
343
|
+
this.lexiconManager,
|
333
344
|
this.signer,
|
334
345
|
this.hooks,
|
335
346
|
this.accessTokenMode,
|
@@ -667,6 +678,16 @@ export class OAuthProvider extends OAuthVerifier {
|
|
667
678
|
loginRequired: session.loginRequired,
|
668
679
|
consentRequired: session.consentRequired,
|
669
680
|
})),
|
681
|
+
permissionSets: await this.lexiconManager
|
682
|
+
.getPermissionSetsFromScope(parameters.scope)
|
683
|
+
.catch((cause) => {
|
684
|
+
throw new AuthorizationError(
|
685
|
+
parameters,
|
686
|
+
'Unable to retrieve permission sets',
|
687
|
+
'invalid_scope',
|
688
|
+
cause,
|
689
|
+
)
|
690
|
+
}),
|
670
691
|
}
|
671
692
|
} catch (err) {
|
672
693
|
try {
|
package/src/oauth-store.ts
CHANGED
@@ -6,6 +6,7 @@
|
|
6
6
|
export * from './account/account-store.js'
|
7
7
|
export * from './client/client-store.js'
|
8
8
|
export * from './device/device-store.js'
|
9
|
+
export * from './lexicon/lexicon-store.js'
|
9
10
|
export * from './replay/replay-store.js'
|
10
11
|
export * from './request/request-store.js'
|
11
12
|
export * from './token/token-store.js'
|
@@ -1,6 +1,7 @@
|
|
1
1
|
import { isAtprotoDid } from '@atproto/did'
|
2
|
+
import { LexiconResolutionError } from '@atproto/lexicon-resolver'
|
2
3
|
import type { Account } from '@atproto/oauth-provider-api'
|
3
|
-
import {
|
4
|
+
import { isAtprotoOauthScope } from '@atproto/oauth-scopes'
|
4
5
|
import {
|
5
6
|
OAuthAuthorizationRequestParameters,
|
6
7
|
OAuthAuthorizationServerMetadata,
|
@@ -23,6 +24,7 @@ import { InvalidAuthorizationDetailsError } from '../errors/invalid-authorizatio
|
|
23
24
|
import { InvalidGrantError } from '../errors/invalid-grant-error.js'
|
24
25
|
import { InvalidRequestError } from '../errors/invalid-request-error.js'
|
25
26
|
import { InvalidScopeError } from '../errors/invalid-scope-error.js'
|
27
|
+
import { LexiconManager } from '../lexicon/lexicon-manager.js'
|
26
28
|
import { RequestMetadata } from '../lib/http/request.js'
|
27
29
|
import { callAsync } from '../lib/util/function.js'
|
28
30
|
import { OAuthHooks } from '../oauth-hooks.js'
|
@@ -43,6 +45,7 @@ import {
|
|
43
45
|
export class RequestManager {
|
44
46
|
constructor(
|
45
47
|
protected readonly store: RequestStore,
|
48
|
+
protected readonly lexiconManager: LexiconManager,
|
46
49
|
protected readonly signer: Signer,
|
47
50
|
protected readonly metadata: OAuthAuthorizationServerMetadata,
|
48
51
|
protected readonly hooks: OAuthHooks,
|
@@ -171,17 +174,16 @@ export class RequestManager {
|
|
171
174
|
// > (Section 3.2.3) to inform the client of the actual scope granted.
|
172
175
|
|
173
176
|
// Let's make sure the scopes are unique (to reduce the token & storage
|
174
|
-
// size)
|
177
|
+
// size).
|
178
|
+
const scopes = new Set(parameters.scope?.split(' '))
|
175
179
|
|
176
180
|
// @NOTE An app requesting a not yet supported list of scopes will need to
|
177
181
|
// re-authenticate the user once the scopes are supported. This is due to
|
178
182
|
// the fact that the AS does not know how to properly display those scopes
|
179
183
|
// to the user, so it cannot properly ask for consent.
|
180
|
-
const
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
parameters = { ...parameters, scope: [...scopes].join(' ') || undefined }
|
184
|
+
const scope =
|
185
|
+
Array.from(scopes).filter(isAtprotoOauthScope).join(' ') || undefined
|
186
|
+
parameters = { ...parameters, scope }
|
185
187
|
|
186
188
|
if (parameters.code_challenge) {
|
187
189
|
switch (parameters.code_challenge_method) {
|
@@ -288,6 +290,23 @@ export class RequestManager {
|
|
288
290
|
parameters = { ...parameters, login_hint: hint }
|
289
291
|
}
|
290
292
|
|
293
|
+
// Make sure that every nsid in the scope resolves to a valid permission set
|
294
|
+
// lexicon
|
295
|
+
if (parameters.scope) {
|
296
|
+
await this.lexiconManager
|
297
|
+
.getPermissionSetsFromScope(parameters.scope)
|
298
|
+
.catch((cause) => {
|
299
|
+
throw new AuthorizationError(
|
300
|
+
parameters,
|
301
|
+
cause instanceof LexiconResolutionError
|
302
|
+
? cause.message
|
303
|
+
: 'Unable to retrieve included permission sets',
|
304
|
+
'invalid_scope',
|
305
|
+
cause,
|
306
|
+
)
|
307
|
+
})
|
308
|
+
}
|
309
|
+
|
291
310
|
return parameters
|
292
311
|
}
|
293
312
|
|
@@ -1,12 +1,14 @@
|
|
1
|
+
import type { LexPermissionSet } from '@atproto/lexicon'
|
1
2
|
import type { Session } from '@atproto/oauth-provider-api'
|
2
|
-
import { OAuthAuthorizationRequestParameters } from '@atproto/oauth-types'
|
3
|
-
import { Client } from '../client/client.js'
|
4
|
-
import { RequestUri } from '../request/request-uri.js'
|
3
|
+
import type { OAuthAuthorizationRequestParameters } from '@atproto/oauth-types'
|
4
|
+
import type { Client } from '../client/client.js'
|
5
|
+
import type { RequestUri } from '../request/request-uri.js'
|
5
6
|
|
6
7
|
export type AuthorizationResultAuthorizePage = {
|
7
8
|
issuer: string
|
8
9
|
client: Client
|
9
10
|
parameters: OAuthAuthorizationRequestParameters
|
11
|
+
permissionSets: Map<string, LexPermissionSet>
|
10
12
|
|
11
13
|
requestUri: RequestUri
|
12
14
|
sessions: readonly Session[]
|
@@ -46,6 +46,7 @@ export function sendAuthorizePageFactory(customization: Customization) {
|
|
46
46
|
scope: data.parameters.scope,
|
47
47
|
uiLocales: data.parameters.ui_locales,
|
48
48
|
loginHint: data.parameters.login_hint,
|
49
|
+
permissionSets: Object.fromEntries(data.permissionSets),
|
49
50
|
},
|
50
51
|
__sessions: data.sessions,
|
51
52
|
})
|
package/src/token/token-data.ts
CHANGED
@@ -29,4 +29,12 @@ export type TokenData = {
|
|
29
29
|
parameters: OAuthAuthorizationRequestParameters
|
30
30
|
details?: null // Legacy field, not used
|
31
31
|
code: Code | null
|
32
|
+
|
33
|
+
/**
|
34
|
+
* This will contain the parameter scope, translated into permissions
|
35
|
+
*
|
36
|
+
* @note null because this didn't use to exist. New tokens should always
|
37
|
+
* include a scope.
|
38
|
+
*/
|
39
|
+
scope: string | null
|
32
40
|
}
|
@@ -1,4 +1,5 @@
|
|
1
1
|
import { SignedJwt, isSignedJwt } from '@atproto/jwk'
|
2
|
+
import { LexiconResolutionError } from '@atproto/lexicon-resolver'
|
2
3
|
import type { Account } from '@atproto/oauth-provider-api'
|
3
4
|
import {
|
4
5
|
OAuthAccessToken,
|
@@ -14,6 +15,7 @@ import { DeviceId } from '../device/device-id.js'
|
|
14
15
|
import { InvalidGrantError } from '../errors/invalid-grant-error.js'
|
15
16
|
import { InvalidRequestError } from '../errors/invalid-request-error.js'
|
16
17
|
import { InvalidTokenError } from '../errors/invalid-token-error.js'
|
18
|
+
import { LexiconManager } from '../lexicon/lexicon-manager.js'
|
17
19
|
import { RequestMetadata } from '../lib/http/request.js'
|
18
20
|
import { dateToEpoch, dateToRelativeSeconds } from '../lib/util/date.js'
|
19
21
|
import { callAsync } from '../lib/util/function.js'
|
@@ -28,9 +30,8 @@ import {
|
|
28
30
|
generateRefreshToken,
|
29
31
|
isRefreshToken,
|
30
32
|
} from './refresh-token.js'
|
31
|
-
import { TokenData } from './token-data.js'
|
32
33
|
import { TokenId, generateTokenId, isTokenId } from './token-id.js'
|
33
|
-
import { TokenInfo, TokenStore } from './token-store.js'
|
34
|
+
import { CreateTokenData, TokenInfo, TokenStore } from './token-store.js'
|
34
35
|
import {
|
35
36
|
VerifyTokenClaimsOptions,
|
36
37
|
VerifyTokenClaimsResult,
|
@@ -43,6 +44,7 @@ export type { OAuthHooks, TokenStore, VerifyTokenClaimsResult }
|
|
43
44
|
export class TokenManager {
|
44
45
|
constructor(
|
45
46
|
protected readonly store: TokenStore,
|
47
|
+
protected readonly lexiconManager: LexiconManager,
|
46
48
|
protected readonly signer: Signer,
|
47
49
|
protected readonly hooks: OAuthHooks,
|
48
50
|
protected readonly accessTokenMode: AccessTokenMode,
|
@@ -58,21 +60,20 @@ export class TokenManager {
|
|
58
60
|
account: Account,
|
59
61
|
client: Client,
|
60
62
|
parameters: OAuthAuthorizationRequestParameters,
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
},
|
63
|
+
createdAt: Date,
|
64
|
+
expiresAt: Date,
|
65
|
+
scope: string,
|
65
66
|
): Promise<OAuthAccessToken> {
|
66
67
|
return this.signer.createAccessToken({
|
67
68
|
jti: tokenId,
|
68
69
|
sub: account.sub,
|
69
|
-
exp: dateToEpoch(
|
70
|
-
iat: dateToEpoch(
|
70
|
+
exp: dateToEpoch(expiresAt),
|
71
|
+
iat: dateToEpoch(createdAt),
|
71
72
|
cnf: parameters.dpop_jkt ? { jkt: parameters.dpop_jkt } : undefined,
|
72
73
|
|
73
74
|
...(this.accessTokenMode === AccessTokenMode.stateless && {
|
74
75
|
aud: account.aud,
|
75
|
-
scope
|
76
|
+
scope,
|
76
77
|
// https://datatracker.ietf.org/doc/html/rfc8693#section-4.3
|
77
78
|
client_id: client.id,
|
78
79
|
}),
|
@@ -98,36 +99,50 @@ export class TokenManager {
|
|
98
99
|
const now = new Date()
|
99
100
|
const expiresAt = this.createTokenExpiry(now)
|
100
101
|
|
101
|
-
const
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
code,
|
112
|
-
}
|
102
|
+
const scope = await this.lexiconManager
|
103
|
+
.buildTokenScope(parameters.scope!)
|
104
|
+
.catch((cause) => {
|
105
|
+
throw new InvalidRequestError(
|
106
|
+
cause instanceof LexiconResolutionError
|
107
|
+
? cause.message
|
108
|
+
: 'Unable to retrieve included permission sets',
|
109
|
+
cause,
|
110
|
+
)
|
111
|
+
})
|
113
112
|
|
114
113
|
const accessToken = await this.buildAccessToken(
|
115
114
|
tokenId,
|
116
115
|
account,
|
117
116
|
client,
|
118
117
|
parameters,
|
119
|
-
|
118
|
+
now,
|
119
|
+
expiresAt,
|
120
|
+
scope,
|
120
121
|
)
|
121
122
|
|
122
|
-
const response =
|
123
|
-
|
123
|
+
const response = this.buildTokenResponse(
|
124
|
+
inferTokenType(parameters),
|
124
125
|
accessToken,
|
125
126
|
refreshToken,
|
126
127
|
expiresAt,
|
127
|
-
parameters,
|
128
128
|
account.sub,
|
129
|
+
scope,
|
129
130
|
)
|
130
131
|
|
132
|
+
const tokenData: CreateTokenData = {
|
133
|
+
createdAt: now,
|
134
|
+
updatedAt: now,
|
135
|
+
expiresAt,
|
136
|
+
clientId: client.id,
|
137
|
+
clientAuth,
|
138
|
+
deviceId,
|
139
|
+
sub: account.sub,
|
140
|
+
parameters,
|
141
|
+
details: null,
|
142
|
+
scope,
|
143
|
+
code,
|
144
|
+
}
|
145
|
+
|
131
146
|
await this.store.createToken(tokenId, tokenData, refreshToken)
|
132
147
|
|
133
148
|
try {
|
@@ -161,18 +176,18 @@ export class TokenManager {
|
|
161
176
|
}
|
162
177
|
|
163
178
|
protected buildTokenResponse(
|
164
|
-
|
179
|
+
tokenType: OAuthTokenType,
|
165
180
|
accessToken: OAuthAccessToken,
|
166
181
|
refreshToken: string | undefined,
|
167
182
|
expiresAt: Date,
|
168
|
-
parameters: OAuthAuthorizationRequestParameters,
|
169
183
|
sub: Sub,
|
184
|
+
scope: string,
|
170
185
|
): OAuthTokenResponse {
|
171
186
|
return {
|
172
187
|
access_token: accessToken,
|
173
|
-
token_type:
|
188
|
+
token_type: tokenType,
|
174
189
|
refresh_token: refreshToken,
|
175
|
-
scope
|
190
|
+
scope,
|
176
191
|
|
177
192
|
// @NOTE using a getter so that the value gets computed when the JSON
|
178
193
|
// response is generated, allowing to value to be as accurate as possible.
|
@@ -204,6 +219,11 @@ export class TokenManager {
|
|
204
219
|
const now = new Date()
|
205
220
|
const expiresAt = this.createTokenExpiry(now)
|
206
221
|
|
222
|
+
// @NOTE since the permission sets are stored in a persistent store,
|
223
|
+
// it's fine to propagate a 500 (server_error) here as the values should
|
224
|
+
// be retrievable from the store.
|
225
|
+
const scope = await this.lexiconManager.buildTokenScope(parameters.scope!)
|
226
|
+
|
207
227
|
await this.store.rotateToken(tokenInfo.id, nextTokenId, nextRefreshToken, {
|
208
228
|
updatedAt: now,
|
209
229
|
expiresAt,
|
@@ -214,6 +234,7 @@ export class TokenManager {
|
|
214
234
|
// - Allow clients to become "confidential" if they were previously
|
215
235
|
// "public"
|
216
236
|
clientAuth,
|
237
|
+
scope,
|
217
238
|
})
|
218
239
|
|
219
240
|
const accessToken = await this.buildAccessToken(
|
@@ -221,16 +242,18 @@ export class TokenManager {
|
|
221
242
|
account,
|
222
243
|
client,
|
223
244
|
parameters,
|
224
|
-
|
245
|
+
now,
|
246
|
+
expiresAt,
|
247
|
+
scope,
|
225
248
|
)
|
226
249
|
|
227
|
-
const response =
|
228
|
-
|
250
|
+
const response = this.buildTokenResponse(
|
251
|
+
inferTokenType(parameters),
|
229
252
|
accessToken,
|
230
253
|
nextRefreshToken,
|
231
254
|
expiresAt,
|
232
|
-
parameters,
|
233
255
|
account.sub,
|
256
|
+
scope,
|
234
257
|
)
|
235
258
|
|
236
259
|
await callAsync(this.hooks.onTokenRefreshed, {
|
@@ -361,7 +384,9 @@ export class TokenManager {
|
|
361
384
|
// These are not stored in the JWT access token in "light" access token
|
362
385
|
// mode. See `buildAccessToken`.
|
363
386
|
aud: account.aud,
|
364
|
-
|
387
|
+
// Note we fallback to parameters.scope for sessions created before
|
388
|
+
// TokenData.scope was introduced.
|
389
|
+
scope: data.scope ?? parameters.scope,
|
365
390
|
client_id: data.clientId,
|
366
391
|
}
|
367
392
|
|
@@ -386,3 +411,12 @@ export class TokenManager {
|
|
386
411
|
function isCurrentTokenExpired(tokenInfo: TokenInfo): boolean {
|
387
412
|
return tokenInfo.data.expiresAt.getTime() < Date.now()
|
388
413
|
}
|
414
|
+
|
415
|
+
function inferTokenType(
|
416
|
+
parameters: OAuthAuthorizationRequestParameters,
|
417
|
+
): OAuthTokenType {
|
418
|
+
if (parameters.dpop_jkt) {
|
419
|
+
return 'DPoP'
|
420
|
+
}
|
421
|
+
return 'Bearer'
|
422
|
+
}
|