@atproto/oauth-provider 0.8.0 → 0.9.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 +49 -0
- package/dist/client/client-auth.d.ts +48 -3
- package/dist/client/client-auth.d.ts.map +1 -1
- package/dist/client/client-auth.js +0 -31
- package/dist/client/client-auth.js.map +1 -1
- package/dist/client/client-manager.d.ts.map +1 -1
- package/dist/client/client-manager.js +19 -19
- package/dist/client/client-manager.js.map +1 -1
- package/dist/client/client.d.ts +14 -17
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +115 -73
- package/dist/client/client.js.map +1 -1
- package/dist/constants.d.ts +7 -6
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +8 -7
- package/dist/constants.js.map +1 -1
- package/dist/metadata/build-metadata.js +1 -1
- package/dist/metadata/build-metadata.js.map +1 -1
- package/dist/oauth-provider.d.ts +20 -16
- package/dist/oauth-provider.d.ts.map +1 -1
- package/dist/oauth-provider.js +268 -122
- package/dist/oauth-provider.js.map +1 -1
- package/dist/replay/replay-manager.d.ts +1 -1
- package/dist/replay/replay-manager.d.ts.map +1 -1
- package/dist/replay/replay-manager.js +5 -2
- package/dist/replay/replay-manager.js.map +1 -1
- package/dist/request/request-data.d.ts +3 -2
- package/dist/request/request-data.d.ts.map +1 -1
- package/dist/request/request-data.js.map +1 -1
- package/dist/request/request-info.d.ts +1 -1
- package/dist/request/request-info.d.ts.map +1 -1
- package/dist/request/request-manager.d.ts +73 -9
- package/dist/request/request-manager.d.ts.map +1 -1
- package/dist/request/request-manager.js +34 -61
- package/dist/request/request-manager.js.map +1 -1
- package/dist/request/request-store.d.ts +6 -2
- package/dist/request/request-store.d.ts.map +1 -1
- package/dist/request/request-store.js +6 -6
- package/dist/request/request-store.js.map +1 -1
- package/dist/router/create-api-middleware.js +1 -1
- package/dist/router/create-api-middleware.js.map +1 -1
- package/dist/router/create-oauth-middleware.d.ts.map +1 -1
- package/dist/router/create-oauth-middleware.js +2 -1
- package/dist/router/create-oauth-middleware.js.map +1 -1
- package/dist/token/token-data.d.ts +2 -2
- package/dist/token/token-data.d.ts.map +1 -1
- package/dist/token/token-manager.d.ts +10 -10
- package/dist/token/token-manager.d.ts.map +1 -1
- package/dist/token/token-manager.js +64 -201
- package/dist/token/token-manager.js.map +1 -1
- package/package.json +8 -7
- package/src/client/client-auth.ts +52 -33
- package/src/client/client-manager.ts +26 -27
- package/src/client/client.ts +153 -89
- package/src/constants.ts +9 -7
- package/src/metadata/build-metadata.ts +2 -2
- package/src/oauth-provider.ts +391 -191
- package/src/replay/replay-manager.ts +10 -6
- package/src/request/request-data.ts +12 -2
- package/src/request/request-info.ts +1 -1
- package/src/request/request-manager.ts +45 -85
- package/src/request/request-store.ts +11 -8
- package/src/router/create-api-middleware.ts +1 -1
- package/src/router/create-oauth-middleware.ts +7 -1
- package/src/token/token-data.ts +2 -2
- package/src/token/token-manager.ts +112 -312
- package/tsconfig.build.tsbuildinfo +1 -1
- package/dist/request/request-store-memory.d.ts +0 -16
- package/dist/request/request-store-memory.d.ts.map +0 -1
- package/dist/request/request-store-memory.js +0 -31
- package/dist/request/request-store-memory.js.map +0 -1
- package/dist/request/request-store-redis.d.ts +0 -24
- package/dist/request/request-store-redis.d.ts.map +0 -1
- package/dist/request/request-store-redis.js +0 -58
- package/dist/request/request-store-redis.js.map +0 -1
- package/src/request/request-store-memory.ts +0 -39
- package/src/request/request-store-redis.ts +0 -71
package/src/oauth-provider.ts
CHANGED
@@ -1,4 +1,6 @@
|
|
1
|
+
import { createHash } from 'node:crypto'
|
1
2
|
import type { Redis, RedisOptions } from 'ioredis'
|
3
|
+
import { ZodError } from 'zod'
|
2
4
|
import { Jwks, Keyset } from '@atproto/jwk'
|
3
5
|
import type { Account } from '@atproto/oauth-provider-api'
|
4
6
|
import {
|
@@ -33,7 +35,7 @@ import {
|
|
33
35
|
DeviceAccount,
|
34
36
|
asAccountStore,
|
35
37
|
} from './account/account-store.js'
|
36
|
-
import { ClientAuth,
|
38
|
+
import { ClientAuth, ClientAuthLegacy } from './client/client-auth.js'
|
37
39
|
import { ClientId } from './client/client-id.js'
|
38
40
|
import {
|
39
41
|
ClientManager,
|
@@ -41,7 +43,14 @@ import {
|
|
41
43
|
} from './client/client-manager.js'
|
42
44
|
import { ClientStore, ifClientStore } from './client/client-store.js'
|
43
45
|
import { Client } from './client/client.js'
|
44
|
-
import {
|
46
|
+
import {
|
47
|
+
AUTHENTICATION_MAX_AGE,
|
48
|
+
CONFIDENTIAL_CLIENT_REFRESH_LIFETIME,
|
49
|
+
CONFIDENTIAL_CLIENT_SESSION_LIFETIME,
|
50
|
+
PUBLIC_CLIENT_REFRESH_LIFETIME,
|
51
|
+
PUBLIC_CLIENT_SESSION_LIFETIME,
|
52
|
+
TOKEN_MAX_AGE,
|
53
|
+
} from './constants.js'
|
45
54
|
import { Branding, BrandingInput } from './customization/branding.js'
|
46
55
|
import {
|
47
56
|
Customization,
|
@@ -58,8 +67,9 @@ import { DeviceStore, asDeviceStore } from './device/device-store.js'
|
|
58
67
|
import { AccessDeniedError } from './errors/access-denied-error.js'
|
59
68
|
import { AccountSelectionRequiredError } from './errors/account-selection-required-error.js'
|
60
69
|
import { ConsentRequiredError } from './errors/consent-required-error.js'
|
70
|
+
import { InvalidDpopKeyBindingError } from './errors/invalid-dpop-key-binding-error.js'
|
71
|
+
import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js'
|
61
72
|
import { InvalidGrantError } from './errors/invalid-grant-error.js'
|
62
|
-
import { InvalidParametersError } from './errors/invalid-parameters-error.js'
|
63
73
|
import { InvalidRequestError } from './errors/invalid-request-error.js'
|
64
74
|
import { LoginRequiredError } from './errors/login-required-error.js'
|
65
75
|
import { HcaptchaConfig } from './lib/hcaptcha.js'
|
@@ -76,18 +86,20 @@ import {
|
|
76
86
|
} from './oauth-verifier.js'
|
77
87
|
import { ReplayStore, ifReplayStore } from './replay/replay-store.js'
|
78
88
|
import { codeSchema } from './request/code.js'
|
79
|
-
import { RequestInfo } from './request/request-info.js'
|
80
89
|
import { RequestManager } from './request/request-manager.js'
|
81
|
-
import {
|
82
|
-
import { RequestStoreRedis } from './request/request-store-redis.js'
|
83
|
-
import { RequestStore, ifRequestStore } from './request/request-store.js'
|
90
|
+
import { RequestStore, asRequestStore } from './request/request-store.js'
|
84
91
|
import { requestUriSchema } from './request/request-uri.js'
|
85
92
|
import { AuthorizationRedirectParameters } from './result/authorization-redirect-parameters.js'
|
86
93
|
import { AuthorizationResultAuthorizePage } from './result/authorization-result-authorize-page.js'
|
87
94
|
import { AuthorizationResultRedirect } from './result/authorization-result-redirect.js'
|
88
95
|
import { ErrorHandler } from './router/error-handler.js'
|
96
|
+
import { TokenData } from './token/token-data.js'
|
89
97
|
import { TokenManager } from './token/token-manager.js'
|
90
|
-
import {
|
98
|
+
import {
|
99
|
+
TokenStore,
|
100
|
+
asTokenStore,
|
101
|
+
refreshTokenSchema,
|
102
|
+
} from './token/token-store.js'
|
91
103
|
import {
|
92
104
|
VerifyTokenClaimsOptions,
|
93
105
|
VerifyTokenClaimsResult,
|
@@ -242,18 +254,17 @@ export class OAuthProvider extends OAuthVerifier {
|
|
242
254
|
metadata,
|
243
255
|
|
244
256
|
safeFetch = safeFetchWrap(),
|
245
|
-
redis,
|
246
257
|
store, // compound store implementation
|
247
258
|
|
248
259
|
// Requires stores
|
249
260
|
accountStore = asAccountStore(store),
|
250
261
|
deviceStore = asDeviceStore(store),
|
251
262
|
tokenStore = asTokenStore(store),
|
263
|
+
requestStore = asRequestStore(store),
|
252
264
|
|
253
265
|
// These are optional
|
254
266
|
clientStore = ifClientStore(store),
|
255
267
|
replayStore = ifReplayStore(store),
|
256
|
-
requestStore = ifRequestStore(store),
|
257
268
|
|
258
269
|
clientJwksCache = new SimpleStoreMemory({
|
259
270
|
maxSize: 50_000_000,
|
@@ -288,11 +299,7 @@ export class OAuthProvider extends OAuthVerifier {
|
|
288
299
|
// be the responsibility of the super class.
|
289
300
|
const superOptions: OAuthVerifierOptions = rest
|
290
301
|
|
291
|
-
super({ replayStore,
|
292
|
-
|
293
|
-
requestStore ??= redis
|
294
|
-
? new RequestStoreRedis({ redis })
|
295
|
-
: new RequestStoreMemory()
|
302
|
+
super({ replayStore, ...superOptions })
|
296
303
|
|
297
304
|
this.accessTokenMode = accessTokenMode
|
298
305
|
this.authenticationMaxAge = authenticationMaxAge
|
@@ -363,97 +370,92 @@ export class OAuthProvider extends OAuthVerifier {
|
|
363
370
|
}
|
364
371
|
|
365
372
|
protected async authenticateClient(
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
373
|
+
clientCredentials: OAuthClientCredentials,
|
374
|
+
dpopProof: null | DpopProof,
|
375
|
+
options?: {
|
376
|
+
allowMissingDpopProof?: boolean
|
377
|
+
},
|
378
|
+
): Promise<{
|
379
|
+
client: Client
|
380
|
+
clientAuth: ClientAuth
|
381
|
+
}> {
|
382
|
+
const client = await this.clientManager.getClient(
|
383
|
+
clientCredentials.client_id,
|
384
|
+
)
|
372
385
|
|
373
386
|
if (
|
374
|
-
client.metadata.
|
375
|
-
|
387
|
+
client.metadata.dpop_bound_access_tokens &&
|
388
|
+
!dpopProof &&
|
389
|
+
!options?.allowMissingDpopProof
|
376
390
|
) {
|
377
|
-
|
378
|
-
|
379
|
-
// > Except when using a mechanism like Dynamic Client Registration
|
380
|
-
// > [RFC7591] to provision per-instance secrets, native apps are
|
381
|
-
// > classified as public clients, as defined by Section 2.1 of OAuth 2.0
|
382
|
-
// > [RFC6749]; they MUST be registered with the authorization server as
|
383
|
-
// > such. Authorization servers MUST record the client type in the client
|
384
|
-
// > registration details in order to identify and process requests
|
385
|
-
// > accordingly.
|
391
|
+
throw new InvalidDpopProofError('DPoP proof required')
|
392
|
+
}
|
386
393
|
|
387
|
-
|
388
|
-
|
389
|
-
)
|
394
|
+
if (dpopProof && !client.metadata.dpop_bound_access_tokens) {
|
395
|
+
throw new InvalidDpopProofError('DPoP proof not allowed for this client')
|
390
396
|
}
|
391
397
|
|
392
|
-
|
393
|
-
|
398
|
+
const clientAuth = await client.authenticate(clientCredentials, {
|
399
|
+
authorizationServerIdentifier: this.issuer,
|
400
|
+
})
|
401
|
+
|
402
|
+
if (clientAuth.method === 'private_key_jwt') {
|
403
|
+
// Clients MUST NOT use their client assertion key to sign DPoP proofs
|
404
|
+
if (dpopProof && clientAuth.jkt === dpopProof.jkt) {
|
405
|
+
throw new InvalidRequestError(
|
406
|
+
'The DPoP proof must be signed with a different key than the client assertion',
|
407
|
+
)
|
408
|
+
}
|
409
|
+
|
410
|
+
// https://www.rfc-editor.org/rfc/rfc7523.html#section-3
|
411
|
+
// > 7. [...] The authorization server MAY ensure that JWTs are not
|
412
|
+
// > replayed by maintaining the set of used "jti" values for the
|
413
|
+
// > length of time for which the JWT would be considered valid based
|
414
|
+
// > on the applicable "exp" instant.
|
415
|
+
|
416
|
+
const unique = await this.replayManager.uniqueAuth(
|
417
|
+
clientAuth.jti,
|
418
|
+
client.id,
|
419
|
+
clientAuth.exp,
|
420
|
+
)
|
394
421
|
if (!unique) {
|
395
422
|
throw new InvalidGrantError(`${clientAuth.method} jti reused`)
|
396
423
|
}
|
397
424
|
}
|
398
425
|
|
399
|
-
return
|
426
|
+
return { client, clientAuth }
|
400
427
|
}
|
401
428
|
|
402
429
|
protected async decodeJAR(
|
403
430
|
client: Client,
|
404
431
|
input: OAuthAuthorizationRequestJar,
|
405
|
-
): Promise<
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
| {
|
410
|
-
payload: OAuthAuthorizationRequestParameters
|
411
|
-
protectedHeader: { kid: string; alg: string }
|
412
|
-
jkt: string
|
413
|
-
}
|
414
|
-
> {
|
415
|
-
const result = await client.decodeRequestObject(input.request)
|
416
|
-
const payload = oauthAuthorizationRequestParametersSchema.parse(
|
417
|
-
result.payload,
|
432
|
+
): Promise<OAuthAuthorizationRequestParameters> {
|
433
|
+
const { payload } = await client.decodeRequestObject(
|
434
|
+
input.request,
|
435
|
+
this.issuer,
|
418
436
|
)
|
419
437
|
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
'Request object must contain a jti claim',
|
438
|
+
const { jti } = payload
|
439
|
+
if (!jti) {
|
440
|
+
throw new InvalidRequestError(
|
441
|
+
'Request object payload must contain a "jti" claim',
|
424
442
|
)
|
425
443
|
}
|
426
|
-
|
427
|
-
|
428
|
-
throw new InvalidParametersError(
|
429
|
-
payload,
|
430
|
-
'Request object jti is not unique',
|
431
|
-
)
|
444
|
+
if (!(await this.replayManager.uniqueJar(jti, client.id))) {
|
445
|
+
throw new InvalidRequestError('Request object was replayed')
|
432
446
|
}
|
433
447
|
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
},
|
446
|
-
}
|
447
|
-
}
|
448
|
-
|
449
|
-
if ('header' in result) {
|
450
|
-
return {
|
451
|
-
payload,
|
452
|
-
}
|
453
|
-
}
|
454
|
-
|
455
|
-
// Should never happen
|
456
|
-
throw new Error('Invalid request object')
|
448
|
+
const parameters = await oauthAuthorizationRequestParametersSchema
|
449
|
+
.parseAsync(payload)
|
450
|
+
.catch((err) => {
|
451
|
+
const message =
|
452
|
+
err instanceof ZodError
|
453
|
+
? `Invalid request parameters: ${err.message}`
|
454
|
+
: `Invalid "request" object`
|
455
|
+
throw InvalidRequestError.from(err, message)
|
456
|
+
})
|
457
|
+
|
458
|
+
return parameters
|
457
459
|
}
|
458
460
|
|
459
461
|
/**
|
@@ -465,12 +467,43 @@ export class OAuthProvider extends OAuthVerifier {
|
|
465
467
|
dpopProof: null | DpopProof,
|
466
468
|
): Promise<OAuthParResponse> {
|
467
469
|
try {
|
468
|
-
const
|
470
|
+
const { client, clientAuth } = await this.authenticateClient(
|
471
|
+
credentials,
|
472
|
+
dpopProof,
|
473
|
+
// Allow missing DPoP header for PAR requests as rfc9449 allows it
|
474
|
+
// (though the dpop_jkt parameter must be present in that case, see
|
475
|
+
// check bellow).
|
476
|
+
{ allowMissingDpopProof: true },
|
477
|
+
)
|
469
478
|
|
470
|
-
const
|
479
|
+
const parameters =
|
471
480
|
'request' in authorizationRequest // Handle JAR
|
472
481
|
? await this.decodeJAR(client, authorizationRequest)
|
473
|
-
:
|
482
|
+
: authorizationRequest
|
483
|
+
|
484
|
+
if (!parameters.dpop_jkt) {
|
485
|
+
if (client.metadata.dpop_bound_access_tokens) {
|
486
|
+
if (dpopProof) parameters.dpop_jkt = dpopProof.jkt
|
487
|
+
else {
|
488
|
+
// @NOTE When both PAR and DPoP are used, either the DPoP header, or
|
489
|
+
// the dpop_jkt parameter must be present. We do not enforce this
|
490
|
+
// for legacy reasons.
|
491
|
+
// https://datatracker.ietf.org/doc/html/rfc9449#section-10.1
|
492
|
+
}
|
493
|
+
}
|
494
|
+
} else {
|
495
|
+
if (!client.metadata.dpop_bound_access_tokens) {
|
496
|
+
throw new InvalidRequestError(
|
497
|
+
'DPoP bound access tokens are not enabled for this client',
|
498
|
+
)
|
499
|
+
}
|
500
|
+
|
501
|
+
// Proof is optional if the dpop_jkt is provided, but if it is provided,
|
502
|
+
// it must match the DPoP proof JKT.
|
503
|
+
if (dpopProof && dpopProof.jkt !== parameters.dpop_jkt) {
|
504
|
+
throw new InvalidDpopKeyBindingError()
|
505
|
+
}
|
506
|
+
}
|
474
507
|
|
475
508
|
const { uri, expiresAt } =
|
476
509
|
await this.requestManager.createAuthorizationRequest(
|
@@ -478,7 +511,6 @@ export class OAuthProvider extends OAuthVerifier {
|
|
478
511
|
clientAuth,
|
479
512
|
parameters,
|
480
513
|
null,
|
481
|
-
dpopProof,
|
482
514
|
)
|
483
515
|
|
484
516
|
return {
|
@@ -501,7 +533,8 @@ export class OAuthProvider extends OAuthVerifier {
|
|
501
533
|
client: Client,
|
502
534
|
deviceId: DeviceId,
|
503
535
|
query: OAuthAuthorizationRequestQuery,
|
504
|
-
)
|
536
|
+
) {
|
537
|
+
// PAR
|
505
538
|
if ('request_uri' in query) {
|
506
539
|
const requestUri = await requestUriSchema
|
507
540
|
.parseAsync(query.request_uri, { path: ['query', 'request_uri'] })
|
@@ -515,43 +548,35 @@ export class OAuthProvider extends OAuthVerifier {
|
|
515
548
|
return this.requestManager.get(requestUri, deviceId, client.id)
|
516
549
|
}
|
517
550
|
|
551
|
+
// JAR
|
518
552
|
if ('request' in query) {
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
return this.requestManager.createAuthorizationRequest(
|
532
|
-
client,
|
533
|
-
clientAuth,
|
534
|
-
requestObject.payload,
|
535
|
-
deviceId,
|
536
|
-
null,
|
537
|
-
)
|
538
|
-
}
|
553
|
+
// @NOTE Since JAR are signed with the client's private key, a JAR *could*
|
554
|
+
// technically be used to authenticate the client when requests are
|
555
|
+
// created without PAR (i.e. created on the fly by the authorize
|
556
|
+
// endpoint). This implementation actually used to support this
|
557
|
+
// (un-spec'd) behavior. That support was removed:
|
558
|
+
// - Because it was not actually used
|
559
|
+
// - Because it was not part of any standard
|
560
|
+
// - Because it makes extending the client authentication mechanism more
|
561
|
+
// complex since any extension would not only need to affect the
|
562
|
+
// "private_key_jwt" auth method but also the JAR "request" object.
|
563
|
+
const parameters = await this.decodeJAR(client, query)
|
539
564
|
|
540
565
|
return this.requestManager.createAuthorizationRequest(
|
541
566
|
client,
|
542
|
-
{ method: 'none' },
|
543
|
-
requestObject.payload,
|
544
|
-
deviceId,
|
545
567
|
null,
|
568
|
+
parameters,
|
569
|
+
deviceId,
|
546
570
|
)
|
547
571
|
}
|
548
572
|
|
573
|
+
// "Regular" authorization request (created on the fly by directing the user
|
574
|
+
// to the authorization endpoint with all the parameters in the url).
|
549
575
|
return this.requestManager.createAuthorizationRequest(
|
550
576
|
client,
|
551
|
-
|
577
|
+
null,
|
552
578
|
query,
|
553
579
|
deviceId,
|
554
|
-
null,
|
555
580
|
)
|
556
581
|
}
|
557
582
|
|
@@ -723,8 +748,10 @@ export class OAuthProvider extends OAuthVerifier {
|
|
723
748
|
request: OAuthTokenRequest,
|
724
749
|
dpopProof: null | DpopProof,
|
725
750
|
): Promise<OAuthTokenResponse> {
|
726
|
-
const
|
727
|
-
|
751
|
+
const { client, clientAuth } = await this.authenticateClient(
|
752
|
+
clientCredentials,
|
753
|
+
dpopProof,
|
754
|
+
)
|
728
755
|
|
729
756
|
if (!this.metadata.grant_types_supported?.includes(request.grant_type)) {
|
730
757
|
throw new InvalidGrantError(
|
@@ -739,7 +766,7 @@ export class OAuthProvider extends OAuthVerifier {
|
|
739
766
|
}
|
740
767
|
|
741
768
|
if (request.grant_type === 'authorization_code') {
|
742
|
-
return this.
|
769
|
+
return this.authorizationCodeGrant(
|
743
770
|
client,
|
744
771
|
clientAuth,
|
745
772
|
clientMetadata,
|
@@ -763,116 +790,289 @@ export class OAuthProvider extends OAuthVerifier {
|
|
763
790
|
)
|
764
791
|
}
|
765
792
|
|
766
|
-
protected async
|
793
|
+
protected async compareClientAuth(
|
794
|
+
client: Client,
|
795
|
+
clientAuth: ClientAuth,
|
796
|
+
dpopProof: null | DpopProof,
|
797
|
+
initial: {
|
798
|
+
parameters: OAuthAuthorizationRequestParameters
|
799
|
+
clientId: ClientId
|
800
|
+
clientAuth: null | ClientAuth | ClientAuthLegacy
|
801
|
+
},
|
802
|
+
): Promise<void> {
|
803
|
+
// Fool proofing, ensure that the client is authenticating using the right method
|
804
|
+
if (clientAuth.method !== client.metadata.token_endpoint_auth_method) {
|
805
|
+
throw new InvalidGrantError(
|
806
|
+
`Client authentication method mismatch (expected ${client.metadata.token_endpoint_auth_method}, got ${clientAuth.method})`,
|
807
|
+
)
|
808
|
+
}
|
809
|
+
|
810
|
+
if (initial.clientId !== client.id) {
|
811
|
+
throw new InvalidGrantError(`Token was not issued to this client`)
|
812
|
+
}
|
813
|
+
|
814
|
+
const { parameters } = initial
|
815
|
+
if (parameters.dpop_jkt) {
|
816
|
+
if (!dpopProof) {
|
817
|
+
throw new InvalidGrantError(`DPoP proof is required for this request`)
|
818
|
+
} else if (parameters.dpop_jkt !== dpopProof.jkt) {
|
819
|
+
throw new InvalidGrantError(
|
820
|
+
`DPoP proof does not match the expected JKT`,
|
821
|
+
)
|
822
|
+
}
|
823
|
+
}
|
824
|
+
|
825
|
+
if (!initial.clientAuth) {
|
826
|
+
// If the client did not use PAR, it was not authenticated when the request
|
827
|
+
// was initially created (see authorize() method in OAuthProvider). Since
|
828
|
+
// PAR is not mandatory, and since the token exchange currently taking place
|
829
|
+
// *is* authenticated (`clientAuth`), we allow "upgrading" the
|
830
|
+
// authentication method (the token created will be bound to the current
|
831
|
+
// clientAuth).
|
832
|
+
return
|
833
|
+
}
|
834
|
+
|
835
|
+
switch (initial.clientAuth.method) {
|
836
|
+
case CLIENT_ASSERTION_TYPE_JWT_BEARER: // LEGACY
|
837
|
+
case 'private_key_jwt':
|
838
|
+
if (clientAuth.method !== 'private_key_jwt') {
|
839
|
+
throw new InvalidGrantError(
|
840
|
+
`Client authentication method mismatch (expected ${initial.clientAuth.method})`,
|
841
|
+
)
|
842
|
+
}
|
843
|
+
if (
|
844
|
+
clientAuth.kid !== initial.clientAuth.kid ||
|
845
|
+
clientAuth.alg !== initial.clientAuth.alg ||
|
846
|
+
clientAuth.jkt !== initial.clientAuth.jkt
|
847
|
+
) {
|
848
|
+
throw new InvalidGrantError(
|
849
|
+
`The session was initiated with a different key than the client assertion currently used`,
|
850
|
+
)
|
851
|
+
}
|
852
|
+
break
|
853
|
+
case 'none':
|
854
|
+
// @NOTE We allow the client to "upgrade" to a confidential client if
|
855
|
+
// the session was initially created without client authentication.
|
856
|
+
break
|
857
|
+
default:
|
858
|
+
throw new InvalidGrantError(
|
859
|
+
// @ts-expect-error (future proof, backwards compatibility)
|
860
|
+
`Invalid method "${initial.clientAuth.method}"`,
|
861
|
+
)
|
862
|
+
}
|
863
|
+
}
|
864
|
+
|
865
|
+
protected async authorizationCodeGrant(
|
767
866
|
client: Client,
|
768
867
|
clientAuth: ClientAuth,
|
769
868
|
clientMetadata: RequestMetadata,
|
770
869
|
input: OAuthAuthorizationCodeGrantTokenRequest,
|
771
870
|
dpopProof: null | DpopProof,
|
772
871
|
): Promise<OAuthTokenResponse> {
|
773
|
-
const code = codeSchema
|
774
|
-
|
775
|
-
|
776
|
-
|
777
|
-
|
778
|
-
|
872
|
+
const code = await codeSchema
|
873
|
+
.parseAsync(input.code, { path: ['code'] })
|
874
|
+
.catch((err) => {
|
875
|
+
throw InvalidGrantError.from(
|
876
|
+
err,
|
877
|
+
err instanceof ZodError
|
878
|
+
? `Invalid code: ${err.message}`
|
879
|
+
: `Invalid code`,
|
880
|
+
)
|
881
|
+
})
|
882
|
+
|
883
|
+
const data = await this.requestManager
|
884
|
+
.consumeCode(code)
|
885
|
+
.catch(async (err) => {
|
886
|
+
// Code not found in request manager: check for replays
|
887
|
+
const tokenInfo = await this.tokenManager.findByCode(code)
|
888
|
+
if (tokenInfo) {
|
889
|
+
// try/finally to ensure that both code path get executed (sequentially)
|
890
|
+
try {
|
891
|
+
// "code" was replayed, delete existing session
|
892
|
+
await this.tokenManager.deleteToken(tokenInfo.id)
|
893
|
+
} finally {
|
894
|
+
// As an additional security measure, we also sign the device out,
|
895
|
+
// so that the device cannot be used to access the account anymore
|
896
|
+
// without a new authentication.
|
897
|
+
const { deviceId, sub } = tokenInfo.data
|
898
|
+
if (deviceId) {
|
899
|
+
await this.accountManager.removeDeviceAccount(deviceId, sub)
|
900
|
+
}
|
901
|
+
}
|
902
|
+
}
|
903
|
+
|
904
|
+
throw InvalidGrantError.from(err, `Invalid code`)
|
905
|
+
})
|
906
|
+
|
907
|
+
// @NOTE at this point, the request data was removed from the store and only
|
908
|
+
// exists in memory here (in the "data" variable). Because of this, any
|
909
|
+
// error thrown after this point will permanently cause the request data to
|
910
|
+
// be lost.
|
911
|
+
|
912
|
+
await this.compareClientAuth(client, clientAuth, dpopProof, data)
|
913
|
+
|
914
|
+
// If the DPoP proof was not provided earlier (PAR / authorize), let's add
|
915
|
+
// it now.
|
916
|
+
const parameters =
|
917
|
+
dpopProof &&
|
918
|
+
client.metadata.dpop_bound_access_tokens &&
|
919
|
+
!data.parameters.dpop_jkt
|
920
|
+
? { ...data.parameters, dpop_jkt: dpopProof.jkt }
|
921
|
+
: data.parameters
|
922
|
+
|
923
|
+
await this.validateCodeGrant(parameters, input)
|
924
|
+
|
925
|
+
const { account } = await this.accountManager.getAccount(data.sub)
|
926
|
+
|
927
|
+
return this.tokenManager.createToken(
|
928
|
+
client,
|
929
|
+
clientAuth,
|
930
|
+
clientMetadata,
|
931
|
+
account,
|
932
|
+
data.deviceId,
|
933
|
+
parameters,
|
934
|
+
code,
|
935
|
+
)
|
936
|
+
}
|
937
|
+
|
938
|
+
protected async validateCodeGrant(
|
939
|
+
parameters: OAuthAuthorizationRequestParameters,
|
940
|
+
input: OAuthAuthorizationCodeGrantTokenRequest,
|
941
|
+
): Promise<void> {
|
942
|
+
if (parameters.redirect_uri !== input.redirect_uri) {
|
943
|
+
throw new InvalidGrantError(
|
944
|
+
'The redirect_uri parameter must match the one used in the authorization request',
|
779
945
|
)
|
946
|
+
}
|
780
947
|
|
781
|
-
|
782
|
-
|
783
|
-
|
784
|
-
|
785
|
-
|
786
|
-
|
787
|
-
|
788
|
-
|
789
|
-
|
790
|
-
|
791
|
-
|
792
|
-
|
793
|
-
|
794
|
-
|
795
|
-
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
800
|
-
|
948
|
+
if (parameters.code_challenge) {
|
949
|
+
if (!input.code_verifier) {
|
950
|
+
throw new InvalidGrantError('code_verifier is required')
|
951
|
+
}
|
952
|
+
if (input.code_verifier.length < 43) {
|
953
|
+
throw new InvalidGrantError('code_verifier too short')
|
954
|
+
}
|
955
|
+
switch (parameters.code_challenge_method) {
|
956
|
+
case undefined: // default is "plain"
|
957
|
+
case 'plain':
|
958
|
+
if (parameters.code_challenge !== input.code_verifier) {
|
959
|
+
throw new InvalidGrantError('Invalid code_verifier')
|
960
|
+
}
|
961
|
+
break
|
962
|
+
|
963
|
+
case 'S256': {
|
964
|
+
const inputChallenge = Buffer.from(
|
965
|
+
parameters.code_challenge,
|
966
|
+
'base64',
|
967
|
+
)
|
968
|
+
const computedChallenge = createHash('sha256')
|
969
|
+
.update(input.code_verifier)
|
970
|
+
.digest()
|
971
|
+
if (inputChallenge.compare(computedChallenge) !== 0) {
|
972
|
+
throw new InvalidGrantError('Invalid code_verifier')
|
973
|
+
}
|
974
|
+
break
|
801
975
|
}
|
976
|
+
|
977
|
+
default:
|
978
|
+
// Should never happen (because request validation should catch this)
|
979
|
+
throw new Error(`Unsupported code_challenge_method`)
|
802
980
|
}
|
981
|
+
const unique = await this.replayManager.uniqueCodeChallenge(
|
982
|
+
parameters.code_challenge,
|
983
|
+
)
|
984
|
+
if (!unique) {
|
985
|
+
throw new InvalidGrantError('Code challenge already used')
|
986
|
+
}
|
987
|
+
} else if (input.code_verifier !== undefined) {
|
988
|
+
throw new InvalidRequestError("code_challenge parameter wasn't provided")
|
989
|
+
}
|
990
|
+
}
|
803
991
|
|
804
|
-
|
992
|
+
protected async refreshTokenGrant(
|
993
|
+
client: Client,
|
994
|
+
clientAuth: ClientAuth,
|
995
|
+
clientMetadata: RequestMetadata,
|
996
|
+
input: OAuthRefreshTokenGrantTokenRequest,
|
997
|
+
dpopProof: null | DpopProof,
|
998
|
+
): Promise<OAuthTokenResponse> {
|
999
|
+
const refreshToken = await refreshTokenSchema
|
1000
|
+
.parseAsync(input.refresh_token, { path: ['refresh_token'] })
|
1001
|
+
.catch((err) => {
|
1002
|
+
throw InvalidGrantError.from(err, `Invalid refresh token`)
|
1003
|
+
})
|
805
1004
|
|
806
|
-
|
1005
|
+
const tokenInfo = await this.tokenManager.consumeRefreshToken(refreshToken)
|
1006
|
+
|
1007
|
+
try {
|
1008
|
+
const { data } = tokenInfo
|
1009
|
+
await this.compareClientAuth(client, clientAuth, dpopProof, data)
|
1010
|
+
await this.validateRefreshGrant(client, clientAuth, data)
|
1011
|
+
|
1012
|
+
return await this.tokenManager.rotateToken(
|
807
1013
|
client,
|
808
1014
|
clientAuth,
|
809
1015
|
clientMetadata,
|
810
|
-
|
811
|
-
deviceId,
|
812
|
-
parameters,
|
813
|
-
input,
|
814
|
-
dpopProof,
|
1016
|
+
tokenInfo,
|
815
1017
|
)
|
816
1018
|
} catch (err) {
|
817
|
-
|
818
|
-
// case, we need to revoke any token that was issued for this code.
|
819
|
-
|
820
|
-
const tokenInfo = await this.tokenManager.findByCode(code)
|
821
|
-
if (tokenInfo) {
|
822
|
-
await this.tokenManager.deleteToken(tokenInfo.id)
|
823
|
-
|
824
|
-
// As an additional security measure, we also sign the device out, so
|
825
|
-
// that the device cannot be used to access the account anymore without
|
826
|
-
// a new authentication.
|
827
|
-
const { deviceId, sub } = tokenInfo.data
|
828
|
-
if (deviceId) {
|
829
|
-
await this.accountManager.removeDeviceAccount(deviceId, sub)
|
830
|
-
}
|
831
|
-
}
|
1019
|
+
await this.tokenManager.deleteToken(tokenInfo.id)
|
832
1020
|
|
833
1021
|
throw err
|
834
1022
|
}
|
835
1023
|
}
|
836
1024
|
|
837
|
-
async
|
1025
|
+
protected async validateRefreshGrant(
|
838
1026
|
client: Client,
|
839
1027
|
clientAuth: ClientAuth,
|
840
|
-
|
841
|
-
|
842
|
-
|
843
|
-
|
844
|
-
|
845
|
-
|
846
|
-
|
847
|
-
|
848
|
-
|
849
|
-
|
850
|
-
)
|
1028
|
+
data: TokenData,
|
1029
|
+
): Promise<void> {
|
1030
|
+
const [sessionLifetime, refreshLifetime] =
|
1031
|
+
clientAuth.method !== 'none' || client.info.isFirstParty
|
1032
|
+
? [
|
1033
|
+
CONFIDENTIAL_CLIENT_SESSION_LIFETIME,
|
1034
|
+
CONFIDENTIAL_CLIENT_REFRESH_LIFETIME,
|
1035
|
+
]
|
1036
|
+
: [PUBLIC_CLIENT_SESSION_LIFETIME, PUBLIC_CLIENT_REFRESH_LIFETIME]
|
1037
|
+
|
1038
|
+
const sessionAge = Date.now() - data.createdAt.getTime()
|
1039
|
+
if (sessionAge > sessionLifetime) {
|
1040
|
+
throw new InvalidGrantError(`Session expired`)
|
1041
|
+
}
|
1042
|
+
|
1043
|
+
const refreshAge = Date.now() - data.updatedAt.getTime()
|
1044
|
+
if (refreshAge > refreshLifetime) {
|
1045
|
+
throw new InvalidGrantError(`Refresh token expired`)
|
1046
|
+
}
|
851
1047
|
}
|
852
1048
|
|
853
1049
|
/**
|
854
1050
|
* @see {@link https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 rfc7009}
|
855
1051
|
*/
|
856
1052
|
public async revoke(
|
857
|
-
|
1053
|
+
clientCredentials: OAuthClientCredentials,
|
858
1054
|
{ token }: OAuthTokenIdentification,
|
1055
|
+
dpopProof: null | DpopProof,
|
859
1056
|
) {
|
860
1057
|
// > The authorization server first validates the client credentials (in
|
861
1058
|
// > case of a confidential client)
|
862
|
-
const
|
1059
|
+
const { client, clientAuth } = await this.authenticateClient(
|
1060
|
+
clientCredentials,
|
1061
|
+
dpopProof,
|
1062
|
+
)
|
863
1063
|
|
864
1064
|
const tokenInfo = await this.tokenManager.findToken(token)
|
865
|
-
|
866
|
-
|
867
|
-
|
868
|
-
|
869
|
-
|
870
|
-
|
871
|
-
|
872
|
-
|
873
|
-
|
874
|
-
|
875
|
-
|
1065
|
+
if (tokenInfo) {
|
1066
|
+
// > [...] and then verifies whether the token was issued to the client
|
1067
|
+
// > making the revocation request.
|
1068
|
+
const { data } = tokenInfo
|
1069
|
+
await this.compareClientAuth(client, clientAuth, dpopProof, data)
|
1070
|
+
|
1071
|
+
// > In the next step, the authorization server invalidates the token. The
|
1072
|
+
// > invalidation takes place immediately, and the token cannot be used
|
1073
|
+
// > again after the revocation.
|
1074
|
+
await this.tokenManager.deleteToken(tokenInfo.id)
|
1075
|
+
}
|
876
1076
|
}
|
877
1077
|
|
878
1078
|
protected override async verifyToken(
|