@atproto/oauth-client 0.3.22 → 0.4.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 +14 -0
- package/dist/errors/auth-method-unsatisfiable-error.d.ts +3 -0
- package/dist/errors/auth-method-unsatisfiable-error.d.ts.map +1 -0
- package/dist/errors/auth-method-unsatisfiable-error.js +7 -0
- package/dist/errors/auth-method-unsatisfiable-error.js.map +1 -0
- package/dist/oauth-client-auth.d.ts +23 -0
- package/dist/oauth-client-auth.d.ts.map +1 -0
- package/dist/oauth-client-auth.js +131 -0
- package/dist/oauth-client-auth.js.map +1 -0
- package/dist/oauth-client.d.ts +4 -4
- package/dist/oauth-client.d.ts.map +1 -1
- package/dist/oauth-client.js +25 -10
- package/dist/oauth-client.js.map +1 -1
- package/dist/oauth-resolver.d.ts +1 -1
- package/dist/oauth-server-agent.d.ts +8 -6
- package/dist/oauth-server-agent.d.ts.map +1 -1
- package/dist/oauth-server-agent.js +19 -50
- package/dist/oauth-server-agent.js.map +1 -1
- package/dist/oauth-server-factory.d.ts +15 -2
- package/dist/oauth-server-factory.d.ts.map +1 -1
- package/dist/oauth-server-factory.js +23 -4
- package/dist/oauth-server-factory.js.map +1 -1
- package/dist/session-getter.d.ts +5 -0
- package/dist/session-getter.d.ts.map +1 -1
- package/dist/session-getter.js +24 -11
- package/dist/session-getter.js.map +1 -1
- package/dist/state-store.d.ts +3 -0
- package/dist/state-store.d.ts.map +1 -1
- package/dist/types.d.ts +8 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/validate-client-metadata.d.ts.map +1 -1
- package/dist/validate-client-metadata.js +32 -26
- package/dist/validate-client-metadata.js.map +1 -1
- package/package.json +3 -3
- package/src/errors/auth-method-unsatisfiable-error.ts +1 -0
- package/src/oauth-client-auth.ts +182 -0
- package/src/oauth-client.ts +49 -9
- package/src/oauth-server-agent.ts +19 -71
- package/src/oauth-server-factory.ts +37 -2
- package/src/session-getter.ts +43 -10
- package/src/state-store.ts +3 -0
- package/src/validate-client-metadata.ts +40 -27
- package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,182 @@
|
|
1
|
+
import { Keyset } from '@atproto/jwk'
|
2
|
+
import {
|
3
|
+
CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
4
|
+
OAuthAuthorizationServerMetadata,
|
5
|
+
OAuthClientCredentials,
|
6
|
+
} from '@atproto/oauth-types'
|
7
|
+
import { FALLBACK_ALG } from './constants.js'
|
8
|
+
import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
|
9
|
+
import { Runtime } from './runtime.js'
|
10
|
+
import { ClientMetadata } from './types.js'
|
11
|
+
import { Awaitable } from './util.js'
|
12
|
+
|
13
|
+
export type ClientAuthMethod =
|
14
|
+
| { method: 'none' }
|
15
|
+
| { method: 'private_key_jwt'; kid: string }
|
16
|
+
|
17
|
+
export function negotiateClientAuthMethod(
|
18
|
+
serverMetadata: OAuthAuthorizationServerMetadata,
|
19
|
+
clientMetadata: ClientMetadata,
|
20
|
+
keyset?: Keyset,
|
21
|
+
): ClientAuthMethod {
|
22
|
+
const method = clientMetadata.token_endpoint_auth_method
|
23
|
+
|
24
|
+
// @NOTE ATproto spec requires that AS support both "none" and
|
25
|
+
// "private_key_jwt", and that clients use one of the other. The following
|
26
|
+
// check ensures that the AS is indeed compliant with this client's
|
27
|
+
// configuration.
|
28
|
+
const methods = supportedMethods(serverMetadata)
|
29
|
+
if (!methods.includes(method)) {
|
30
|
+
throw new Error(
|
31
|
+
`The server does not support "${method}" authentication. Supported methods are: ${methods.join(
|
32
|
+
', ',
|
33
|
+
)}.`,
|
34
|
+
)
|
35
|
+
}
|
36
|
+
|
37
|
+
if (method === 'private_key_jwt') {
|
38
|
+
// Invalid client configuration. This should not happen as
|
39
|
+
// "validateClientMetadata" already check this.
|
40
|
+
if (!keyset) throw new Error('A keyset is required for private_key_jwt')
|
41
|
+
|
42
|
+
const alg = supportedAlgs(serverMetadata)
|
43
|
+
|
44
|
+
// @NOTE we can't use `keyset.findPrivateKey` here because we can't enforce
|
45
|
+
// that the returned key contains a "kid". The following implementation is
|
46
|
+
// more robust against keysets containing keys without a "kid" property.
|
47
|
+
for (const key of keyset.list({ use: 'sig', alg })) {
|
48
|
+
// Return the first key from the key set that matches the server's
|
49
|
+
// supported algorithms.
|
50
|
+
if (key.isPrivate && key.kid) {
|
51
|
+
return { method: 'private_key_jwt', kid: key.kid }
|
52
|
+
}
|
53
|
+
}
|
54
|
+
|
55
|
+
throw new Error(
|
56
|
+
alg.includes(FALLBACK_ALG)
|
57
|
+
? `Client authentication method "${method}" requires at least one "${FALLBACK_ALG}" signing key with a "kid" property`
|
58
|
+
: // AS is not compliant with the ATproto OAuth spec.
|
59
|
+
`Authorization server requires "${method}" authentication method, but does not support "${FALLBACK_ALG}" algorithm.`,
|
60
|
+
)
|
61
|
+
}
|
62
|
+
|
63
|
+
if (method === 'none') {
|
64
|
+
return { method: 'none' }
|
65
|
+
}
|
66
|
+
|
67
|
+
throw new Error(
|
68
|
+
`The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.` +
|
69
|
+
(method === 'client_secret_basic'
|
70
|
+
? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.'
|
71
|
+
: ` You set "${method}" which is not allowed.`),
|
72
|
+
)
|
73
|
+
}
|
74
|
+
|
75
|
+
export type ClientCredentialsFactory = () => Awaitable<{
|
76
|
+
headers?: Record<string, string>
|
77
|
+
payload?: OAuthClientCredentials
|
78
|
+
}>
|
79
|
+
|
80
|
+
/**
|
81
|
+
* @throws {AuthMethodUnsatisfiableError} if the authentication method is no
|
82
|
+
* long usable (either because the AS changed, of because the key is no longer
|
83
|
+
* available in the keyset).
|
84
|
+
*/
|
85
|
+
export function createClientCredentialsFactory(
|
86
|
+
authMethod: ClientAuthMethod,
|
87
|
+
serverMetadata: OAuthAuthorizationServerMetadata,
|
88
|
+
clientMetadata: ClientMetadata,
|
89
|
+
runtime: Runtime,
|
90
|
+
keyset?: Keyset,
|
91
|
+
): ClientCredentialsFactory {
|
92
|
+
// Ensure the AS still supports the auth method.
|
93
|
+
if (!supportedMethods(serverMetadata).includes(authMethod.method)) {
|
94
|
+
throw new AuthMethodUnsatisfiableError(
|
95
|
+
`Client authentication method "${authMethod.method}" no longer supported`,
|
96
|
+
)
|
97
|
+
}
|
98
|
+
|
99
|
+
if (authMethod.method === 'none') {
|
100
|
+
return () => ({
|
101
|
+
payload: {
|
102
|
+
client_id: clientMetadata.client_id,
|
103
|
+
},
|
104
|
+
})
|
105
|
+
}
|
106
|
+
|
107
|
+
if (authMethod.method === 'private_key_jwt') {
|
108
|
+
try {
|
109
|
+
// The client used to be a confidential client but no longer has a keyset.
|
110
|
+
if (!keyset) throw new Error('A keyset is required for private_key_jwt')
|
111
|
+
|
112
|
+
// @NOTE throws if no matching key can be found
|
113
|
+
const [key, alg] = keyset.findPrivateKey({
|
114
|
+
use: 'sig',
|
115
|
+
kid: authMethod.kid,
|
116
|
+
alg: supportedAlgs(serverMetadata),
|
117
|
+
})
|
118
|
+
|
119
|
+
// https://www.rfc-editor.org/rfc/rfc7523.html#section-3
|
120
|
+
return async () => ({
|
121
|
+
payload: {
|
122
|
+
client_id: clientMetadata.client_id,
|
123
|
+
client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
124
|
+
client_assertion: await key.createJwt(
|
125
|
+
{ alg },
|
126
|
+
{
|
127
|
+
// > The JWT MUST contain an "iss" (issuer) claim that contains a
|
128
|
+
// > unique identifier for the entity that issued the JWT.
|
129
|
+
iss: clientMetadata.client_id,
|
130
|
+
// > For client authentication, the subject MUST be the
|
131
|
+
// > "client_id" of the OAuth client.
|
132
|
+
sub: clientMetadata.client_id,
|
133
|
+
// > The JWT MUST contain an "aud" (audience) claim containing a value
|
134
|
+
// > that identifies the authorization server as an intended audience.
|
135
|
+
// > The token endpoint URL of the authorization server MAY be used as a
|
136
|
+
// > value for an "aud" element to identify the authorization server as an
|
137
|
+
// > intended audience of the JWT.
|
138
|
+
aud: serverMetadata.issuer,
|
139
|
+
// > The JWT MAY contain a "jti" (JWT ID) claim that provides a
|
140
|
+
// > unique identifier for the token.
|
141
|
+
jti: await runtime.generateNonce(),
|
142
|
+
// > The JWT MAY contain an "iat" (issued at) claim that
|
143
|
+
// > identifies the time at which the JWT was issued.
|
144
|
+
iat: Math.floor(Date.now() / 1000),
|
145
|
+
// > The JWT MUST contain an "exp" (expiration time) claim that
|
146
|
+
// > limits the time window during which the JWT can be used.
|
147
|
+
exp: Math.floor(Date.now() / 1000) + 60, // 1 minute
|
148
|
+
},
|
149
|
+
),
|
150
|
+
},
|
151
|
+
})
|
152
|
+
} catch (cause) {
|
153
|
+
throw new AuthMethodUnsatisfiableError('Failed to load private key', {
|
154
|
+
cause,
|
155
|
+
})
|
156
|
+
}
|
157
|
+
}
|
158
|
+
|
159
|
+
throw new AuthMethodUnsatisfiableError(
|
160
|
+
// @ts-expect-error
|
161
|
+
`Unsupported auth method ${authMethod.method}`,
|
162
|
+
)
|
163
|
+
}
|
164
|
+
|
165
|
+
function supportedMethods(serverMetadata: OAuthAuthorizationServerMetadata) {
|
166
|
+
return serverMetadata['token_endpoint_auth_methods_supported']
|
167
|
+
}
|
168
|
+
|
169
|
+
function supportedAlgs(serverMetadata: OAuthAuthorizationServerMetadata) {
|
170
|
+
return (
|
171
|
+
serverMetadata['token_endpoint_auth_signing_alg_values_supported'] ?? [
|
172
|
+
// @NOTE If not specified, assume that the server supports the ES256
|
173
|
+
// algorithm, as prescribed by the spec:
|
174
|
+
//
|
175
|
+
// > Clients and Authorization Servers currently must support the ES256
|
176
|
+
// > cryptographic system [for client authentication].
|
177
|
+
//
|
178
|
+
// https://atproto.com/specs/oauth#confidential-client-authentication
|
179
|
+
FALLBACK_ALG,
|
180
|
+
]
|
181
|
+
)
|
182
|
+
}
|
package/src/oauth-client.ts
CHANGED
@@ -26,12 +26,14 @@ import {
|
|
26
26
|
import { IdentityResolver } from '@atproto-labs/identity-resolver'
|
27
27
|
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
|
28
28
|
import { FALLBACK_ALG } from './constants.js'
|
29
|
+
import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
|
29
30
|
import { TokenRevokedError } from './errors/token-revoked-error.js'
|
30
31
|
import {
|
31
32
|
AuthorizationServerMetadataCache,
|
32
33
|
OAuthAuthorizationServerMetadataResolver,
|
33
34
|
} from './oauth-authorization-server-metadata-resolver.js'
|
34
35
|
import { OAuthCallbackError } from './oauth-callback-error.js'
|
36
|
+
import { negotiateClientAuthMethod } from './oauth-client-auth.js'
|
35
37
|
import {
|
36
38
|
OAuthProtectedResourceMetadataResolver,
|
37
39
|
ProtectedResourceMetadataCache,
|
@@ -290,11 +292,17 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
290
292
|
metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
|
291
293
|
)
|
292
294
|
|
295
|
+
const authMethod = negotiateClientAuthMethod(
|
296
|
+
metadata,
|
297
|
+
this.clientMetadata,
|
298
|
+
this.keyset,
|
299
|
+
)
|
293
300
|
const state = await this.runtime.generateNonce()
|
294
301
|
|
295
302
|
await this.stateStore.set(state, {
|
296
303
|
iss: metadata.issuer,
|
297
304
|
dpopKey,
|
305
|
+
authMethod,
|
298
306
|
verifier: pkce.verifier,
|
299
307
|
appState: options?.state,
|
300
308
|
})
|
@@ -327,7 +335,11 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
327
335
|
}
|
328
336
|
|
329
337
|
if (metadata.pushed_authorization_request_endpoint) {
|
330
|
-
const server = await this.serverFactory.fromMetadata(
|
338
|
+
const server = await this.serverFactory.fromMetadata(
|
339
|
+
metadata,
|
340
|
+
authMethod,
|
341
|
+
dpopKey,
|
342
|
+
)
|
331
343
|
const parResponse = await server.request(
|
332
344
|
'pushed_authorization_request',
|
333
345
|
parameters,
|
@@ -423,6 +435,8 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
423
435
|
|
424
436
|
const server = await this.serverFactory.fromIssuer(
|
425
437
|
stateData.iss,
|
438
|
+
// Using the literal 'legacy' if the authMethod is not defined (because stateData was created through an old version of this lib)
|
439
|
+
stateData.authMethod ?? 'legacy',
|
426
440
|
stateData.dpopKey,
|
427
441
|
)
|
428
442
|
|
@@ -455,6 +469,7 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
455
469
|
try {
|
456
470
|
await this.sessionGetter.setStored(tokenSet.sub, {
|
457
471
|
dpopKey: stateData.dpopKey,
|
472
|
+
authMethod: server.authMethod,
|
458
473
|
tokenSet,
|
459
474
|
})
|
460
475
|
|
@@ -486,24 +501,45 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
486
501
|
// sub arg is lightly typed for convenience of library user
|
487
502
|
assertAtprotoDid(sub)
|
488
503
|
|
489
|
-
const {
|
504
|
+
const {
|
505
|
+
dpopKey,
|
506
|
+
authMethod = 'legacy',
|
507
|
+
tokenSet,
|
508
|
+
} = await this.sessionGetter.get(sub, {
|
490
509
|
noCache: refresh === true,
|
491
510
|
allowStale: refresh === false,
|
492
511
|
})
|
493
512
|
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
513
|
+
try {
|
514
|
+
const server = await this.serverFactory.fromIssuer(
|
515
|
+
tokenSet.iss,
|
516
|
+
authMethod,
|
517
|
+
dpopKey,
|
518
|
+
{
|
519
|
+
noCache: refresh === true,
|
520
|
+
allowStale: refresh === false,
|
521
|
+
},
|
522
|
+
)
|
523
|
+
|
524
|
+
return this.createSession(server, sub)
|
525
|
+
} catch (err) {
|
526
|
+
if (err instanceof AuthMethodUnsatisfiableError) {
|
527
|
+
await this.sessionGetter.delStored(sub, err)
|
528
|
+
}
|
498
529
|
|
499
|
-
|
530
|
+
throw err
|
531
|
+
}
|
500
532
|
}
|
501
533
|
|
502
534
|
async revoke(sub: string) {
|
503
535
|
// sub arg is lightly typed for convenience of library user
|
504
536
|
assertAtprotoDid(sub)
|
505
537
|
|
506
|
-
const {
|
538
|
+
const {
|
539
|
+
dpopKey,
|
540
|
+
authMethod = 'legacy',
|
541
|
+
tokenSet,
|
542
|
+
} = await this.sessionGetter.get(sub, {
|
507
543
|
allowStale: true,
|
508
544
|
})
|
509
545
|
|
@@ -511,7 +547,11 @@ export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
|
|
511
547
|
// the tokens to be deleted even if it was not possible to fetch the issuer
|
512
548
|
// data.
|
513
549
|
try {
|
514
|
-
const server = await this.serverFactory.fromIssuer(
|
550
|
+
const server = await this.serverFactory.fromIssuer(
|
551
|
+
tokenSet.iss,
|
552
|
+
authMethod,
|
553
|
+
dpopKey,
|
554
|
+
)
|
515
555
|
await server.revoke(tokenSet.access_token)
|
516
556
|
} finally {
|
517
557
|
await this.sessionGetter.delStored(sub, new TokenRevokedError(sub))
|
@@ -1,10 +1,8 @@
|
|
1
1
|
import { AtprotoDid } from '@atproto/did'
|
2
2
|
import { Key, Keyset } from '@atproto/jwk'
|
3
3
|
import {
|
4
|
-
CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
5
4
|
OAuthAuthorizationRequestPar,
|
6
5
|
OAuthAuthorizationServerMetadata,
|
7
|
-
OAuthClientCredentials,
|
8
6
|
OAuthEndpointName,
|
9
7
|
OAuthParResponse,
|
10
8
|
OAuthTokenRequest,
|
@@ -17,9 +15,13 @@ import {
|
|
17
15
|
AtprotoTokenResponse,
|
18
16
|
atprotoTokenResponseSchema,
|
19
17
|
} from './atproto-token-response.js'
|
20
|
-
import { FALLBACK_ALG } from './constants.js'
|
21
18
|
import { TokenRefreshError } from './errors/token-refresh-error.js'
|
22
19
|
import { dpopFetchWrapper } from './fetch-dpop.js'
|
20
|
+
import {
|
21
|
+
ClientAuthMethod,
|
22
|
+
ClientCredentialsFactory,
|
23
|
+
createClientCredentialsFactory,
|
24
|
+
} from './oauth-client-auth.js'
|
23
25
|
import { OAuthResolver } from './oauth-resolver.js'
|
24
26
|
import { OAuthResponseError } from './oauth-response-error.js'
|
25
27
|
import { Runtime } from './runtime.js'
|
@@ -43,8 +45,13 @@ export type DpopNonceCache = SimpleStore<string, string>
|
|
43
45
|
|
44
46
|
export class OAuthServerAgent {
|
45
47
|
protected dpopFetch: Fetch<unknown>
|
48
|
+
protected clientCredentialsFactory: ClientCredentialsFactory
|
46
49
|
|
50
|
+
/**
|
51
|
+
* @throws see {@link createClientCredentialsFactory}
|
52
|
+
*/
|
47
53
|
constructor(
|
54
|
+
readonly authMethod: ClientAuthMethod,
|
48
55
|
readonly dpopKey: Key,
|
49
56
|
readonly serverMetadata: OAuthAuthorizationServerMetadata,
|
50
57
|
readonly clientMetadata: ClientMetadata,
|
@@ -54,6 +61,14 @@ export class OAuthServerAgent {
|
|
54
61
|
readonly keyset?: Keyset,
|
55
62
|
fetch?: Fetch,
|
56
63
|
) {
|
64
|
+
this.clientCredentialsFactory = createClientCredentialsFactory(
|
65
|
+
authMethod,
|
66
|
+
serverMetadata,
|
67
|
+
clientMetadata,
|
68
|
+
runtime,
|
69
|
+
keyset,
|
70
|
+
)
|
71
|
+
|
57
72
|
this.dpopFetch = dpopFetchWrapper<void>({
|
58
73
|
fetch: bindFetch(fetch),
|
59
74
|
key: dpopKey,
|
@@ -204,7 +219,7 @@ export class OAuthServerAgent {
|
|
204
219
|
const url = this.serverMetadata[`${endpoint}_endpoint`]
|
205
220
|
if (!url) throw new Error(`No ${endpoint} endpoint available`)
|
206
221
|
|
207
|
-
const auth = await this.
|
222
|
+
const auth = await this.clientCredentialsFactory()
|
208
223
|
|
209
224
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-3.2.2
|
210
225
|
// https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
|
@@ -232,73 +247,6 @@ export class OAuthServerAgent {
|
|
232
247
|
throw new OAuthResponseError(response, json)
|
233
248
|
}
|
234
249
|
}
|
235
|
-
|
236
|
-
async buildClientAuth(endpoint: OAuthEndpointName): Promise<{
|
237
|
-
headers?: Record<string, string>
|
238
|
-
payload: OAuthClientCredentials
|
239
|
-
}> {
|
240
|
-
const methodSupported =
|
241
|
-
this.serverMetadata[`token_endpoint_auth_methods_supported`]
|
242
|
-
|
243
|
-
const method = this.clientMetadata[`token_endpoint_auth_method`]
|
244
|
-
|
245
|
-
if (
|
246
|
-
method === 'private_key_jwt' ||
|
247
|
-
(this.keyset &&
|
248
|
-
!method &&
|
249
|
-
(methodSupported?.includes('private_key_jwt') ?? false))
|
250
|
-
) {
|
251
|
-
if (!this.keyset) throw new Error('No keyset available')
|
252
|
-
|
253
|
-
try {
|
254
|
-
const alg =
|
255
|
-
this.serverMetadata[
|
256
|
-
`token_endpoint_auth_signing_alg_values_supported`
|
257
|
-
] ?? FALLBACK_ALG
|
258
|
-
|
259
|
-
// If jwks is defined, make sure to only sign using a key that exists in
|
260
|
-
// the jwks. If jwks_uri is defined, we can't be sure that the key we're
|
261
|
-
// looking for is in there so we will just assume it is.
|
262
|
-
const kid = this.clientMetadata.jwks?.keys
|
263
|
-
.map(({ kid }) => kid)
|
264
|
-
.filter((v): v is string => typeof v === 'string')
|
265
|
-
|
266
|
-
return {
|
267
|
-
payload: {
|
268
|
-
client_id: this.clientMetadata.client_id,
|
269
|
-
client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
270
|
-
client_assertion: await this.keyset.createJwt(
|
271
|
-
{ alg, kid },
|
272
|
-
{
|
273
|
-
iss: this.clientMetadata.client_id,
|
274
|
-
sub: this.clientMetadata.client_id,
|
275
|
-
aud: this.serverMetadata.issuer,
|
276
|
-
jti: await this.runtime.generateNonce(),
|
277
|
-
iat: Math.floor(Date.now() / 1000),
|
278
|
-
},
|
279
|
-
),
|
280
|
-
},
|
281
|
-
}
|
282
|
-
} catch (err) {
|
283
|
-
if (method === 'private_key_jwt') throw err
|
284
|
-
|
285
|
-
// Else try next method
|
286
|
-
}
|
287
|
-
}
|
288
|
-
|
289
|
-
if (
|
290
|
-
method === 'none' ||
|
291
|
-
(!method && (methodSupported?.includes('none') ?? true))
|
292
|
-
) {
|
293
|
-
return {
|
294
|
-
payload: {
|
295
|
-
client_id: this.clientMetadata.client_id,
|
296
|
-
},
|
297
|
-
}
|
298
|
-
}
|
299
|
-
|
300
|
-
throw new Error(`Unsupported ${endpoint} authentication method`)
|
301
|
-
}
|
302
250
|
}
|
303
251
|
|
304
252
|
function wwwFormUrlEncode(payload: Record<string, undefined | unknown>) {
|
@@ -2,6 +2,10 @@ import { Key, Keyset } from '@atproto/jwk'
|
|
2
2
|
import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types'
|
3
3
|
import { Fetch } from '@atproto-labs/fetch'
|
4
4
|
import { GetCachedOptions } from './oauth-authorization-server-metadata-resolver.js'
|
5
|
+
import {
|
6
|
+
ClientAuthMethod,
|
7
|
+
negotiateClientAuthMethod,
|
8
|
+
} from './oauth-client-auth.js'
|
5
9
|
import { OAuthResolver } from './oauth-resolver.js'
|
6
10
|
import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
|
7
11
|
import { Runtime } from './runtime.js'
|
@@ -17,19 +21,50 @@ export class OAuthServerFactory {
|
|
17
21
|
readonly dpopNonceCache: DpopNonceCache,
|
18
22
|
) {}
|
19
23
|
|
20
|
-
|
24
|
+
/**
|
25
|
+
* @param authMethod `undefined` means that we are restoring a session that
|
26
|
+
* was created before we started storing the `authMethod` in the session. In
|
27
|
+
* that case, we will use the first key from the keyset.
|
28
|
+
*
|
29
|
+
* Support for this might be removed in the future.
|
30
|
+
*
|
31
|
+
* @throws see {@link OAuthServerFactory.fromMetadata}
|
32
|
+
*/
|
33
|
+
async fromIssuer(
|
34
|
+
issuer: string,
|
35
|
+
authMethod: 'legacy' | ClientAuthMethod,
|
36
|
+
dpopKey: Key,
|
37
|
+
options?: GetCachedOptions,
|
38
|
+
) {
|
21
39
|
const serverMetadata = await this.resolver.getAuthorizationServerMetadata(
|
22
40
|
issuer,
|
23
41
|
options,
|
24
42
|
)
|
25
|
-
|
43
|
+
|
44
|
+
if (authMethod === 'legacy') {
|
45
|
+
// @NOTE Because we were previously not storing the authMethod in the
|
46
|
+
// session data, we provide a backwards compatible implementation by
|
47
|
+
// computing it here.
|
48
|
+
authMethod = negotiateClientAuthMethod(
|
49
|
+
serverMetadata,
|
50
|
+
this.clientMetadata,
|
51
|
+
this.keyset,
|
52
|
+
)
|
53
|
+
}
|
54
|
+
|
55
|
+
return this.fromMetadata(serverMetadata, authMethod, dpopKey)
|
26
56
|
}
|
27
57
|
|
58
|
+
/**
|
59
|
+
* @throws see {@link OAuthServerAgent}
|
60
|
+
*/
|
28
61
|
async fromMetadata(
|
29
62
|
serverMetadata: OAuthAuthorizationServerMetadata,
|
63
|
+
authMethod: ClientAuthMethod,
|
30
64
|
dpopKey: Key,
|
31
65
|
) {
|
32
66
|
return new OAuthServerAgent(
|
67
|
+
authMethod,
|
33
68
|
dpopKey,
|
34
69
|
serverMetadata,
|
35
70
|
this.clientMetadata,
|
package/src/session-getter.ts
CHANGED
@@ -5,9 +5,11 @@ import {
|
|
5
5
|
GetCachedOptions,
|
6
6
|
SimpleStore,
|
7
7
|
} from '@atproto-labs/simple-store'
|
8
|
+
import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'
|
8
9
|
import { TokenInvalidError } from './errors/token-invalid-error.js'
|
9
10
|
import { TokenRefreshError } from './errors/token-refresh-error.js'
|
10
11
|
import { TokenRevokedError } from './errors/token-revoked-error.js'
|
12
|
+
import { ClientAuthMethod } from './oauth-client-auth.js'
|
11
13
|
import { OAuthResponseError } from './oauth-response-error.js'
|
12
14
|
import { TokenSet } from './oauth-server-agent.js'
|
13
15
|
import { OAuthServerFactory } from './oauth-server-factory.js'
|
@@ -16,6 +18,10 @@ import { CustomEventTarget, combineSignals, timeoutSignal } from './util.js'
|
|
16
18
|
|
17
19
|
export type Session = {
|
18
20
|
dpopKey: Key
|
21
|
+
/**
|
22
|
+
* Previous implementation of this lib did not define an `authMethod`
|
23
|
+
*/
|
24
|
+
authMethod?: ClientAuthMethod
|
19
25
|
tokenSet: TokenSet
|
20
26
|
}
|
21
27
|
|
@@ -51,7 +57,7 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
51
57
|
private readonly runtime: Runtime,
|
52
58
|
) {
|
53
59
|
super(
|
54
|
-
async (sub, options, storedSession)
|
60
|
+
async (sub, options, storedSession) => {
|
55
61
|
// There needs to be a previous session to be able to refresh. If
|
56
62
|
// storedSession is undefined, it means that the store does not contain
|
57
63
|
// a session for the given sub.
|
@@ -73,7 +79,7 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
73
79
|
// concurrent access (which, normally, should not happen if a proper
|
74
80
|
// runtime lock was provided).
|
75
81
|
|
76
|
-
const { dpopKey, tokenSet } = storedSession
|
82
|
+
const { dpopKey, authMethod = 'legacy', tokenSet } = storedSession
|
77
83
|
|
78
84
|
if (sub !== tokenSet.sub) {
|
79
85
|
// Fool-proofing (e.g. against invalid session storage)
|
@@ -94,7 +100,11 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
94
100
|
// always possible. If no lock implementation is provided, we will use
|
95
101
|
// the store to check if a concurrent refresh occurred.
|
96
102
|
|
97
|
-
const server = await serverFactory.fromIssuer(
|
103
|
+
const server = await serverFactory.fromIssuer(
|
104
|
+
tokenSet.iss,
|
105
|
+
authMethod,
|
106
|
+
dpopKey,
|
107
|
+
)
|
98
108
|
|
99
109
|
// Because refresh tokens can only be used once, we must not use the
|
100
110
|
// "signal" to abort the refresh, or throw any abort error beyond this
|
@@ -111,7 +121,11 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
111
121
|
throw new TokenRefreshError(sub, 'Token set sub mismatch')
|
112
122
|
}
|
113
123
|
|
114
|
-
return {
|
124
|
+
return {
|
125
|
+
dpopKey,
|
126
|
+
tokenSet: newTokenSet,
|
127
|
+
authMethod: server.authMethod,
|
128
|
+
}
|
115
129
|
} catch (cause) {
|
116
130
|
// If the refresh token is invalid, let's try to recover from
|
117
131
|
// concurrency issues, or make sure the session is deleted by throwing
|
@@ -173,17 +187,36 @@ export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
|
|
173
187
|
30e3 * Math.random()
|
174
188
|
)
|
175
189
|
},
|
176
|
-
onStoreError: async (
|
177
|
-
|
178
|
-
|
179
|
-
|
190
|
+
onStoreError: async (
|
191
|
+
err,
|
192
|
+
sub,
|
193
|
+
{ tokenSet, dpopKey, authMethod = 'legacy' as const },
|
194
|
+
) => {
|
195
|
+
if (!(err instanceof AuthMethodUnsatisfiableError)) {
|
196
|
+
// If the error was an AuthMethodUnsatisfiableError, there is no
|
197
|
+
// point in trying to call `fromIssuer`.
|
198
|
+
try {
|
199
|
+
// If the token data cannot be stored, let's revoke it
|
200
|
+
const server = await serverFactory.fromIssuer(
|
201
|
+
tokenSet.iss,
|
202
|
+
authMethod,
|
203
|
+
dpopKey,
|
204
|
+
)
|
205
|
+
await server.revoke(
|
206
|
+
tokenSet.refresh_token ?? tokenSet.access_token,
|
207
|
+
)
|
208
|
+
} catch {
|
209
|
+
// Let the original error propagate
|
210
|
+
}
|
211
|
+
}
|
212
|
+
|
180
213
|
throw err
|
181
214
|
},
|
182
215
|
deleteOnError: async (err) =>
|
183
|
-
// Optimization: More likely to happen first
|
184
216
|
err instanceof TokenRefreshError ||
|
185
217
|
err instanceof TokenRevokedError ||
|
186
|
-
err instanceof TokenInvalidError
|
218
|
+
err instanceof TokenInvalidError ||
|
219
|
+
err instanceof AuthMethodUnsatisfiableError,
|
187
220
|
},
|
188
221
|
)
|
189
222
|
}
|
package/src/state-store.ts
CHANGED
@@ -1,9 +1,12 @@
|
|
1
1
|
import { Key } from '@atproto/jwk'
|
2
2
|
import { SimpleStore } from '@atproto-labs/simple-store'
|
3
|
+
import { ClientAuthMethod } from './oauth-client-auth.js'
|
3
4
|
|
4
5
|
export type InternalStateData = {
|
5
6
|
iss: string
|
6
7
|
dpopKey: Key
|
8
|
+
/** @note optional for legacy reasons */
|
9
|
+
authMethod?: ClientAuthMethod
|
7
10
|
verifier?: string
|
8
11
|
appState?: string
|
9
12
|
}
|