@atproto/oauth-provider 0.1.3 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- package/CHANGELOG.md +35 -0
- package/dist/account/account.d.ts +6 -2
- package/dist/account/account.d.ts.map +1 -1
- package/dist/assets/app/bundle-manifest.json +3 -3
- package/dist/assets/app/main.css +1 -1
- package/dist/assets/app/main.js +1 -1
- package/dist/assets/app/main.js.map +1 -1
- package/dist/assets/assets-middleware.d.ts +2 -1
- package/dist/assets/assets-middleware.d.ts.map +1 -1
- package/dist/assets/assets-middleware.js +7 -0
- package/dist/assets/assets-middleware.js.map +1 -1
- package/dist/client/client-manager.d.ts +4 -3
- package/dist/client/client-manager.d.ts.map +1 -1
- package/dist/client/client-manager.js +60 -37
- package/dist/client/client-manager.js.map +1 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +1 -3
- package/dist/client/client.js.map +1 -1
- 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/device/device-manager.d.ts +1 -1
- package/dist/device/device-manager.d.ts.map +1 -1
- package/dist/device/device-manager.js +2 -2
- package/dist/device/device-manager.js.map +1 -1
- package/dist/errors/invalid-authorization-details-error.d.ts +4 -3
- package/dist/errors/invalid-authorization-details-error.d.ts.map +1 -1
- package/dist/errors/invalid-authorization-details-error.js +4 -4
- package/dist/errors/invalid-authorization-details-error.js.map +1 -1
- package/dist/lib/http/request.d.ts +3 -0
- package/dist/lib/http/request.d.ts.map +1 -1
- package/dist/lib/http/request.js +24 -12
- package/dist/lib/http/request.js.map +1 -1
- package/dist/metadata/build-metadata.d.ts +0 -1
- package/dist/metadata/build-metadata.d.ts.map +1 -1
- package/dist/metadata/build-metadata.js +9 -35
- package/dist/metadata/build-metadata.js.map +1 -1
- package/dist/oauth-hooks.d.ts +3 -10
- package/dist/oauth-hooks.d.ts.map +1 -1
- package/dist/oauth-provider.d.ts +8 -13
- package/dist/oauth-provider.d.ts.map +1 -1
- package/dist/oauth-provider.js +169 -109
- package/dist/oauth-provider.js.map +1 -1
- package/dist/oauth-verifier.d.ts +1 -2
- package/dist/oauth-verifier.d.ts.map +1 -1
- package/dist/oauth-verifier.js.map +1 -1
- package/dist/output/build-authorize-data.d.ts +6 -0
- package/dist/output/build-authorize-data.d.ts.map +1 -1
- package/dist/output/build-authorize-data.js +1 -0
- package/dist/output/build-authorize-data.js.map +1 -1
- package/dist/replay/replay-manager.d.ts +1 -0
- package/dist/replay/replay-manager.d.ts.map +1 -1
- package/dist/replay/replay-manager.js +3 -0
- package/dist/replay/replay-manager.js.map +1 -1
- package/dist/replay/replay-store.d.ts +1 -1
- package/dist/request/request-info.d.ts +2 -0
- package/dist/request/request-info.d.ts.map +1 -1
- package/dist/request/request-manager.d.ts +3 -9
- package/dist/request/request-manager.d.ts.map +1 -1
- package/dist/request/request-manager.js +52 -77
- package/dist/request/request-manager.js.map +1 -1
- package/dist/request/types.d.ts +10 -10
- package/dist/signer/signed-token-payload.d.ts +85 -85
- package/dist/signer/signer.d.ts +23 -30
- package/dist/signer/signer.d.ts.map +1 -1
- package/dist/signer/signer.js +0 -40
- package/dist/signer/signer.js.map +1 -1
- package/dist/token/token-claims.d.ts +81 -81
- package/dist/token/token-manager.d.ts +1 -2
- package/dist/token/token-manager.d.ts.map +1 -1
- package/dist/token/token-manager.js +10 -37
- package/dist/token/token-manager.js.map +1 -1
- package/dist/token/types.d.ts +10 -10
- package/package.json +2 -3
- package/src/account/account.ts +11 -7
- package/src/assets/app/backend-data.ts +9 -2
- package/src/assets/app/components/accept-form.tsx +65 -51
- package/src/assets/app/components/client-name.tsx +24 -16
- package/src/assets/app/views/accept-view.tsx +7 -4
- package/src/assets/app/views/authorize-view.tsx +2 -1
- package/src/assets/assets-middleware.ts +14 -2
- package/src/client/client-manager.ts +78 -60
- package/src/client/client.ts +1 -4
- package/src/constants.ts +3 -0
- package/src/device/device-manager.ts +7 -1
- package/src/errors/invalid-authorization-details-error.ts +9 -4
- package/src/lib/http/request.ts +61 -15
- package/src/metadata/build-metadata.ts +9 -42
- package/src/oauth-hooks.ts +3 -13
- package/src/oauth-provider.ts +181 -159
- package/src/oauth-verifier.ts +1 -2
- package/src/output/build-authorize-data.ts +8 -0
- package/src/replay/replay-manager.ts +9 -0
- package/src/replay/replay-store.ts +1 -1
- package/src/request/request-info.ts +2 -0
- package/src/request/request-manager.ts +81 -107
- package/src/signer/signer.ts +0 -63
- package/src/token/token-manager.ts +8 -41
- package/dist/oidc/claims.d.ts +0 -16
- package/dist/oidc/claims.d.ts.map +0 -1
- package/dist/oidc/claims.js +0 -29
- package/dist/oidc/claims.js.map +0 -1
- package/dist/oidc/userinfo.d.ts +0 -7
- package/dist/oidc/userinfo.d.ts.map +0 -1
- package/dist/oidc/userinfo.js +0 -3
- package/dist/oidc/userinfo.js.map +0 -1
- package/dist/parameters/claims-requested.d.ts +0 -3
- package/dist/parameters/claims-requested.d.ts.map +0 -1
- package/dist/parameters/claims-requested.js +0 -77
- package/dist/parameters/claims-requested.js.map +0 -1
- package/dist/parameters/oidc-payload.d.ts +0 -31
- package/dist/parameters/oidc-payload.d.ts.map +0 -1
- package/dist/parameters/oidc-payload.js +0 -25
- package/dist/parameters/oidc-payload.js.map +0 -1
- package/src/assets/app/components/client-identifier.tsx +0 -31
- package/src/oidc/claims.ts +0 -35
- package/src/oidc/userinfo.ts +0 -11
- package/src/parameters/claims-requested.ts +0 -106
- package/src/parameters/oidc-payload.ts +0 -28
package/src/oauth-provider.ts
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
import { safeFetchWrap } from '@atproto-labs/fetch-node'
|
2
2
|
import { SimpleStore } from '@atproto-labs/simple-store'
|
3
3
|
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
|
4
|
-
import { Jwks, Keyset
|
4
|
+
import { Jwks, Keyset } from '@atproto/jwk'
|
5
5
|
import {
|
6
6
|
AccessToken,
|
7
7
|
CLIENT_ASSERTION_TYPE_JWT_BEARER,
|
@@ -15,12 +15,11 @@ import {
|
|
15
15
|
oauthAuthenticationRequestParametersSchema,
|
16
16
|
} from '@atproto/oauth-types'
|
17
17
|
import { Redis, type RedisOptions } from 'ioredis'
|
18
|
-
import {
|
18
|
+
import z, { ZodError } from 'zod'
|
19
19
|
|
20
20
|
import { AccessTokenType } from './access-token/access-token-type.js'
|
21
21
|
import { AccountManager } from './account/account-manager.js'
|
22
22
|
import {
|
23
|
-
AccountInfo,
|
24
23
|
AccountStore,
|
25
24
|
DeviceAccountInfo,
|
26
25
|
SignInCredentials,
|
@@ -58,12 +57,13 @@ import {
|
|
58
57
|
Middleware,
|
59
58
|
Router,
|
60
59
|
ServerResponse,
|
61
|
-
acceptMiddleware,
|
62
60
|
combineMiddlewares,
|
63
61
|
setupCsrfToken,
|
64
62
|
staticJsonHandler,
|
65
63
|
validateCsrfToken,
|
64
|
+
validateFetchDest,
|
66
65
|
validateFetchMode,
|
66
|
+
validateFetchSite,
|
67
67
|
validateReferer,
|
68
68
|
validateRequestPayload,
|
69
69
|
validateSameOrigin,
|
@@ -74,7 +74,6 @@ import { Override } from './lib/util/type.js'
|
|
74
74
|
import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js'
|
75
75
|
import { OAuthHooks } from './oauth-hooks.js'
|
76
76
|
import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js'
|
77
|
-
import { Userinfo } from './oidc/userinfo.js'
|
78
77
|
import { AuthorizationResultAuthorize } from './output/build-authorize-data.js'
|
79
78
|
import {
|
80
79
|
buildErrorPayload,
|
@@ -86,7 +85,6 @@ import {
|
|
86
85
|
AuthorizationResultRedirect,
|
87
86
|
sendAuthorizeRedirect,
|
88
87
|
} from './output/send-authorize-redirect.js'
|
89
|
-
import { oidcPayload } from './parameters/oidc-payload.js'
|
90
88
|
import { ReplayStore, ifReplayStore } from './replay/replay-store.js'
|
91
89
|
import { RequestInfo } from './request/request-info.js'
|
92
90
|
import { RequestManager } from './request/request-manager.js'
|
@@ -103,7 +101,7 @@ import {
|
|
103
101
|
} from './request/types.js'
|
104
102
|
import { isTokenId } from './token/token-id.js'
|
105
103
|
import { TokenManager } from './token/token-manager.js'
|
106
|
-
import {
|
104
|
+
import { TokenStore, asTokenStore } from './token/token-store.js'
|
107
105
|
import {
|
108
106
|
CodeGrantRequest,
|
109
107
|
Introspect,
|
@@ -146,9 +144,7 @@ export type OAuthProviderOptions = Override<
|
|
146
144
|
{
|
147
145
|
/**
|
148
146
|
* Maximum age a device/account session can be before requiring
|
149
|
-
* re-authentication.
|
150
|
-
* using the `max_age` parameter and on a client basis using the
|
151
|
-
* `default_max_age` client metadata.
|
147
|
+
* re-authentication.
|
152
148
|
*/
|
153
149
|
authenticationMaxAge?: number
|
154
150
|
|
@@ -286,6 +282,7 @@ export class OAuthProvider extends OAuthVerifier {
|
|
286
282
|
|
287
283
|
this.accountManager = new AccountManager(accountStore)
|
288
284
|
this.clientManager = new ClientManager(
|
285
|
+
this.metadata,
|
289
286
|
this.keyset,
|
290
287
|
rest,
|
291
288
|
clientStore || null,
|
@@ -326,14 +323,7 @@ export class OAuthProvider extends OAuthVerifier {
|
|
326
323
|
return true
|
327
324
|
}
|
328
325
|
|
329
|
-
|
330
|
-
const maxAge = parameters.max_age ?? client.metadata.default_max_age
|
331
|
-
|
332
|
-
if (maxAge != null && maxAge < this.authenticationMaxAge) {
|
333
|
-
return authAge >= maxAge
|
334
|
-
} else {
|
335
|
-
return authAge >= this.authenticationMaxAge
|
336
|
-
}
|
326
|
+
return authAge >= this.authenticationMaxAge
|
337
327
|
}
|
338
328
|
|
339
329
|
protected async authenticateClient(
|
@@ -551,15 +541,14 @@ export class OAuthProvider extends OAuthVerifier {
|
|
551
541
|
throw new ConsentRequiredError(parameters)
|
552
542
|
}
|
553
543
|
|
554
|
-
const
|
544
|
+
const code = await this.requestManager.setAuthorized(
|
555
545
|
client,
|
556
546
|
uri,
|
557
547
|
deviceId,
|
558
548
|
ssoSession.account,
|
559
|
-
ssoSession.info,
|
560
549
|
)
|
561
550
|
|
562
|
-
return { issuer, client, parameters, redirect }
|
551
|
+
return { issuer, client, parameters, redirect: { code } }
|
563
552
|
}
|
564
553
|
|
565
554
|
// Automatic SSO when a did was provided
|
@@ -568,15 +557,14 @@ export class OAuthProvider extends OAuthVerifier {
|
|
568
557
|
if (ssoSessions.length === 1) {
|
569
558
|
const ssoSession = ssoSessions[0]!
|
570
559
|
if (!ssoSession.loginRequired && !ssoSession.consentRequired) {
|
571
|
-
const
|
560
|
+
const code = await this.requestManager.setAuthorized(
|
572
561
|
client,
|
573
562
|
uri,
|
574
563
|
deviceId,
|
575
564
|
ssoSession.account,
|
576
|
-
ssoSession.info,
|
577
565
|
)
|
578
566
|
|
579
|
-
return { issuer, client, parameters, redirect }
|
567
|
+
return { issuer, client, parameters, redirect: { code } }
|
580
568
|
}
|
581
569
|
}
|
582
570
|
}
|
@@ -585,7 +573,20 @@ export class OAuthProvider extends OAuthVerifier {
|
|
585
573
|
issuer,
|
586
574
|
client,
|
587
575
|
parameters,
|
588
|
-
authorize: {
|
576
|
+
authorize: {
|
577
|
+
uri,
|
578
|
+
sessions,
|
579
|
+
scopeDetails: parameters.scope
|
580
|
+
?.split(/\s+/)
|
581
|
+
.filter(Boolean)
|
582
|
+
.sort((a, b) => a.localeCompare(b))
|
583
|
+
.map((scope) => ({
|
584
|
+
scope,
|
585
|
+
// @TODO Allow to customize the scope descriptions (e.g.
|
586
|
+
// using a hook)
|
587
|
+
description: undefined,
|
588
|
+
})),
|
589
|
+
},
|
589
590
|
}
|
590
591
|
} catch (err) {
|
591
592
|
await this.deleteRequest(uri, parameters)
|
@@ -652,6 +653,9 @@ export class OAuthProvider extends OAuthVerifier {
|
|
652
653
|
this.loginRequired(client, parameters, info),
|
653
654
|
consentRequired:
|
654
655
|
parameters.prompt === 'consent' ||
|
656
|
+
// @TODO the "authorizedClients" should also include the scopes that
|
657
|
+
// were already authorized for the client. Otherwise a client could
|
658
|
+
// use silent authentication to get additional scopes without consent.
|
655
659
|
!info.authorizedClients.includes(client.id),
|
656
660
|
|
657
661
|
matchesHint: hint == null || matchesHint(account),
|
@@ -660,9 +664,33 @@ export class OAuthProvider extends OAuthVerifier {
|
|
660
664
|
|
661
665
|
protected async signIn(
|
662
666
|
deviceId: DeviceId,
|
667
|
+
uri: RequestUri,
|
668
|
+
clientId: ClientId,
|
663
669
|
credentials: SignInCredentials,
|
664
|
-
): Promise<
|
665
|
-
|
670
|
+
): Promise<{
|
671
|
+
account: Account
|
672
|
+
consentRequired: boolean
|
673
|
+
}> {
|
674
|
+
const client = await this.clientManager.getClient(clientId)
|
675
|
+
|
676
|
+
// Ensure the request is still valid (and update the request expiration)
|
677
|
+
// @TODO use the returned scopes to determine if consent is required
|
678
|
+
await this.requestManager.get(uri, clientId, deviceId)
|
679
|
+
|
680
|
+
const { account, info } = await this.accountManager.signIn(
|
681
|
+
credentials,
|
682
|
+
deviceId,
|
683
|
+
)
|
684
|
+
|
685
|
+
return {
|
686
|
+
account,
|
687
|
+
consentRequired: client.info.isFirstParty
|
688
|
+
? false
|
689
|
+
: // @TODO: the "authorizedClients" should also include the scopes that
|
690
|
+
// were already authorized for the client. Otherwise a client could
|
691
|
+
// use silent authentication to get additional scopes without consent.
|
692
|
+
!info.authorizedClients.includes(client.id),
|
693
|
+
}
|
666
694
|
}
|
667
695
|
|
668
696
|
protected async acceptRequest(
|
@@ -692,12 +720,11 @@ export class OAuthProvider extends OAuthVerifier {
|
|
692
720
|
)
|
693
721
|
}
|
694
722
|
|
695
|
-
const
|
723
|
+
const code = await this.requestManager.setAuthorized(
|
696
724
|
client,
|
697
725
|
uri,
|
698
726
|
deviceId,
|
699
727
|
account,
|
700
|
-
info,
|
701
728
|
)
|
702
729
|
|
703
730
|
await this.accountManager.addAuthorizedClient(
|
@@ -707,7 +734,7 @@ export class OAuthProvider extends OAuthVerifier {
|
|
707
734
|
clientAuth,
|
708
735
|
)
|
709
736
|
|
710
|
-
return { issuer, client, parameters, redirect }
|
737
|
+
return { issuer, client, parameters, redirect: { code } }
|
711
738
|
} catch (err) {
|
712
739
|
await this.deleteRequest(uri, parameters)
|
713
740
|
|
@@ -794,6 +821,32 @@ export class OAuthProvider extends OAuthVerifier {
|
|
794
821
|
input.code,
|
795
822
|
)
|
796
823
|
|
824
|
+
// the following check prevents re-use of PKCE challenges, enforcing the
|
825
|
+
// clients to generate a new challenge for each authorization request. The
|
826
|
+
// replay manager typically prevents replay over a certain time frame,
|
827
|
+
// which might not cover the entire lifetime of the token (depending on
|
828
|
+
// the implementation of the replay store). For this reason, we should
|
829
|
+
// ideally ensure that the code_challenge was not already used by any
|
830
|
+
// existing token or any other pending request.
|
831
|
+
//
|
832
|
+
// The current implementation will cause client devs not issuing a new
|
833
|
+
// code challenge for each authorization request to fail, which should be
|
834
|
+
// a good enough incentive to follow the best practices, until we have a
|
835
|
+
// better implementation.
|
836
|
+
//
|
837
|
+
// @TODO: Use tokenManager to ensure uniqueness of code_challenge
|
838
|
+
if (parameters.code_challenge) {
|
839
|
+
const unique = await this.replayManager.uniqueCodeChallenge(
|
840
|
+
parameters.code_challenge,
|
841
|
+
)
|
842
|
+
if (!unique) {
|
843
|
+
throw new InvalidGrantError(
|
844
|
+
'code_challenge',
|
845
|
+
'Code challenge already used',
|
846
|
+
)
|
847
|
+
}
|
848
|
+
}
|
849
|
+
|
797
850
|
const { account, info } = await this.accountManager.get(deviceId, sub)
|
798
851
|
|
799
852
|
return await this.tokenManager.create(
|
@@ -891,31 +944,6 @@ export class OAuthProvider extends OAuthVerifier {
|
|
891
944
|
}
|
892
945
|
}
|
893
946
|
|
894
|
-
/**
|
895
|
-
* @see {@link https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.3.2 Successful UserInfo Response}
|
896
|
-
*/
|
897
|
-
protected async userinfo({ data, account }: TokenInfo): Promise<Userinfo> {
|
898
|
-
return {
|
899
|
-
...oidcPayload(data.parameters, account),
|
900
|
-
|
901
|
-
sub: account.sub,
|
902
|
-
|
903
|
-
client_id: data.clientId,
|
904
|
-
username: account.preferred_username,
|
905
|
-
}
|
906
|
-
}
|
907
|
-
|
908
|
-
protected async signUserinfo(userinfo: Userinfo): Promise<SignedJwt> {
|
909
|
-
const client = await this.clientManager.getClient(userinfo.client_id)
|
910
|
-
return this.signer.sign(
|
911
|
-
{
|
912
|
-
alg: client.metadata.userinfo_signed_response_alg,
|
913
|
-
typ: 'JWT',
|
914
|
-
},
|
915
|
-
userinfo,
|
916
|
-
)
|
917
|
-
}
|
918
|
-
|
919
947
|
protected override async authenticateToken(
|
920
948
|
tokenType: OAuthTokenType,
|
921
949
|
token: AccessToken,
|
@@ -979,6 +1007,8 @@ export class OAuthProvider extends OAuthVerifier {
|
|
979
1007
|
combineMiddlewares([
|
980
1008
|
function (req, res, next) {
|
981
1009
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
1010
|
+
res.setHeader('Access-Control-Allow-Headers', '*')
|
1011
|
+
|
982
1012
|
res.setHeader('Cache-Control', 'max-age=300')
|
983
1013
|
next()
|
984
1014
|
},
|
@@ -995,6 +1025,7 @@ export class OAuthProvider extends OAuthVerifier {
|
|
995
1025
|
): Handler<T, TReq, TRes> =>
|
996
1026
|
async function (req, res) {
|
997
1027
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
1028
|
+
res.setHeader('Access-Control-Allow-Headers', '*')
|
998
1029
|
|
999
1030
|
// https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1
|
1000
1031
|
res.setHeader('Cache-Control', 'no-store')
|
@@ -1037,11 +1068,15 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1037
1068
|
handler: (this: T, req: TReq, res: TRes) => void | Promise<void>,
|
1038
1069
|
): Handler<T, TReq, TRes> =>
|
1039
1070
|
async function (req, res) {
|
1071
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
1072
|
+
res.setHeader('Access-Control-Allow-Headers', '*')
|
1073
|
+
|
1040
1074
|
res.setHeader('Cache-Control', 'no-store')
|
1041
1075
|
res.setHeader('Pragma', 'no-cache')
|
1042
1076
|
|
1043
1077
|
try {
|
1044
1078
|
validateFetchMode(req, res, ['navigate'])
|
1079
|
+
validateFetchDest(req, res, ['document'])
|
1045
1080
|
validateSameOrigin(req, res, issuerOrigin)
|
1046
1081
|
|
1047
1082
|
await handler.call(this, req, res)
|
@@ -1066,49 +1101,45 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1066
1101
|
|
1067
1102
|
//- Public OAuth endpoints
|
1068
1103
|
|
1069
|
-
/*
|
1070
|
-
* Although OpenID compatibility is not required to implement the Atproto
|
1071
|
-
* OAuth2 specification, we do support OIDC discovery in this
|
1072
|
-
* implementation as we believe this may:
|
1073
|
-
* 1) Make the implementation of Atproto clients easier (since lots of
|
1074
|
-
* libraries support OIDC discovery)
|
1075
|
-
* 2) Allow self hosted PDS' to not implement authentication themselves
|
1076
|
-
* but rely on a trusted Atproto actor to act as their OIDC providers.
|
1077
|
-
* By supporting OIDC in the current implementation, Bluesky's
|
1078
|
-
* Authorization Server server can be used as an OIDC provider for
|
1079
|
-
* these users.
|
1080
|
-
*/
|
1081
|
-
router.get('/.well-known/openid-configuration', staticJson(server.metadata))
|
1082
|
-
|
1083
1104
|
router.get(
|
1084
1105
|
'/.well-known/oauth-authorization-server',
|
1085
1106
|
staticJson(server.metadata),
|
1086
1107
|
)
|
1087
1108
|
|
1088
1109
|
// CORS preflight
|
1089
|
-
|
1090
|
-
|
1091
|
-
|
1092
|
-
|
1093
|
-
|
1094
|
-
|
1095
|
-
|
1096
|
-
|
1097
|
-
|
1098
|
-
|
1099
|
-
|
1100
|
-
|
1101
|
-
|
1102
|
-
.
|
1103
|
-
|
1104
|
-
|
1110
|
+
const corsPreflight: Middleware = function (req, res, _next) {
|
1111
|
+
res
|
1112
|
+
.writeHead(204, {
|
1113
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
|
1114
|
+
//
|
1115
|
+
// > For requests without credentials, the literal value "*" can be
|
1116
|
+
// > specified as a wildcard; the value tells browsers to allow
|
1117
|
+
// > requesting code from any origin to access the resource.
|
1118
|
+
// > Attempting to use the wildcard with credentials results in an
|
1119
|
+
// > error.
|
1120
|
+
//
|
1121
|
+
// A "*" is safer to use than reflecting the request origin.
|
1122
|
+
'Access-Control-Allow-Origin': '*',
|
1123
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods
|
1124
|
+
// > The value "*" only counts as a special wildcard value for
|
1125
|
+
// > requests without credentials (requests without HTTP cookies or
|
1126
|
+
// > HTTP authentication information). In requests with credentials,
|
1127
|
+
// > it is treated as the literal method name "*" without special
|
1128
|
+
// > semantics.
|
1129
|
+
'Access-Control-Allow-Methods': '*',
|
1130
|
+
'Access-Control-Allow-Headers': 'Content-Type,Authorization,DPoP',
|
1131
|
+
'Access-Control-Max-Age': '86400', // 1 day
|
1132
|
+
})
|
1133
|
+
.end()
|
1134
|
+
}
|
1105
1135
|
|
1106
1136
|
router.get('/oauth/jwks', staticJson(server.jwks))
|
1107
1137
|
|
1138
|
+
router.options('/oauth/par', corsPreflight)
|
1108
1139
|
router.post(
|
1109
1140
|
'/oauth/par',
|
1110
1141
|
jsonHandler(async function (req, _res) {
|
1111
|
-
const input = await
|
1142
|
+
const input = await validateRequest(
|
1112
1143
|
req,
|
1113
1144
|
pushedAuthorizationRequestSchema,
|
1114
1145
|
)
|
@@ -1124,14 +1155,18 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1124
1155
|
)
|
1125
1156
|
|
1126
1157
|
// https://datatracker.ietf.org/doc/html/rfc9126#section-2.3
|
1127
|
-
|
1158
|
+
// > If the request did not use the POST method, the authorization server
|
1159
|
+
// > responds with an HTTP 405 (Method Not Allowed) status code.
|
1160
|
+
router.options('/oauth/par', corsPreflight)
|
1161
|
+
router.all('/oauth/par', (req, res) => {
|
1128
1162
|
res.writeHead(405).end()
|
1129
1163
|
})
|
1130
1164
|
|
1165
|
+
router.options('/oauth/token', corsPreflight)
|
1131
1166
|
router.post(
|
1132
1167
|
'/oauth/token',
|
1133
1168
|
jsonHandler(async function (req, _res) {
|
1134
|
-
const input = await
|
1169
|
+
const input = await validateRequest(req, tokenRequestSchema)
|
1135
1170
|
|
1136
1171
|
const dpopJkt = await server.checkDpopProof(
|
1137
1172
|
req.headers['dpop'],
|
@@ -1143,10 +1178,11 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1143
1178
|
}),
|
1144
1179
|
)
|
1145
1180
|
|
1181
|
+
router.options('/oauth/revoke', corsPreflight)
|
1146
1182
|
router.post(
|
1147
1183
|
'/oauth/revoke',
|
1148
1184
|
jsonHandler(async function (req, res) {
|
1149
|
-
const input = await
|
1185
|
+
const input = await validateRequest(req, revokeSchema)
|
1150
1186
|
|
1151
1187
|
try {
|
1152
1188
|
await server.revoke(input)
|
@@ -1156,6 +1192,7 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1156
1192
|
}),
|
1157
1193
|
)
|
1158
1194
|
|
1195
|
+
router.options('/oauth/revoke', corsPreflight)
|
1159
1196
|
router.get(
|
1160
1197
|
'/oauth/revoke',
|
1161
1198
|
navigationHandler(async function (req, res) {
|
@@ -1180,69 +1217,11 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1180
1217
|
router.post(
|
1181
1218
|
'/oauth/introspect',
|
1182
1219
|
jsonHandler(async function (req, _res) {
|
1183
|
-
const input = await
|
1220
|
+
const input = await validateRequest(req, introspectSchema)
|
1184
1221
|
return server.introspect(input)
|
1185
1222
|
}),
|
1186
1223
|
)
|
1187
1224
|
|
1188
|
-
const userinfoBodySchema = z.object({
|
1189
|
-
access_token: signedJwtSchema.optional(),
|
1190
|
-
})
|
1191
|
-
|
1192
|
-
router.addRoute(
|
1193
|
-
['GET', 'POST'],
|
1194
|
-
'/oauth/userinfo',
|
1195
|
-
acceptMiddleware(
|
1196
|
-
async function (req, _res) {
|
1197
|
-
const body =
|
1198
|
-
req.method === 'POST'
|
1199
|
-
? await validateRequestPayload(req, userinfoBodySchema)
|
1200
|
-
: null
|
1201
|
-
|
1202
|
-
if (body?.access_token && req.headers['authorization']) {
|
1203
|
-
throw new InvalidRequestError(
|
1204
|
-
'access token must be provided in either the authorization header or the request body',
|
1205
|
-
)
|
1206
|
-
}
|
1207
|
-
|
1208
|
-
const auth = await server.authenticateRequest(
|
1209
|
-
req.method!,
|
1210
|
-
this.url,
|
1211
|
-
body?.access_token // Allow credentials to be parsed from body.
|
1212
|
-
? {
|
1213
|
-
authorization: `Bearer ${body.access_token}`,
|
1214
|
-
dpop: undefined, // DPoP can only be used with headers
|
1215
|
-
}
|
1216
|
-
: req.headers,
|
1217
|
-
{
|
1218
|
-
scope: ['profile'],
|
1219
|
-
},
|
1220
|
-
)
|
1221
|
-
|
1222
|
-
const tokenInfo: TokenInfo =
|
1223
|
-
'tokenInfo' in auth
|
1224
|
-
? (auth.tokenInfo as TokenInfo)
|
1225
|
-
: await server.tokenManager.getTokenInfo(
|
1226
|
-
auth.tokenType,
|
1227
|
-
auth.tokenId,
|
1228
|
-
)
|
1229
|
-
|
1230
|
-
return server.userinfo(tokenInfo)
|
1231
|
-
},
|
1232
|
-
{
|
1233
|
-
'': 'application/json',
|
1234
|
-
'application/json': jsonHandler(async function (_req, _res) {
|
1235
|
-
return this.data
|
1236
|
-
}),
|
1237
|
-
'application/jwt': jsonHandler(async function (_req, res) {
|
1238
|
-
const jwt = await server.signUserinfo(this.data)
|
1239
|
-
res.writeHead(200, { 'Content-Type': 'application/jwt' }).end(jwt)
|
1240
|
-
return undefined
|
1241
|
-
}),
|
1242
|
-
},
|
1243
|
-
),
|
1244
|
-
)
|
1245
|
-
|
1246
1225
|
//- Private authorization endpoints
|
1247
1226
|
|
1248
1227
|
router.use(authorizeAssetsMiddleware())
|
@@ -1250,6 +1229,8 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1250
1229
|
router.get(
|
1251
1230
|
'/oauth/authorize',
|
1252
1231
|
navigationHandler(async function (req, res) {
|
1232
|
+
validateFetchSite(req, res, ['cross-site', 'none'])
|
1233
|
+
|
1253
1234
|
const query = Object.fromEntries(this.url.searchParams)
|
1254
1235
|
const input = await authorizationRequestQuerySchema.parseAsync(query, {
|
1255
1236
|
path: ['query'],
|
@@ -1281,13 +1262,15 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1281
1262
|
credentials: signInCredentialsSchema,
|
1282
1263
|
})
|
1283
1264
|
|
1265
|
+
router.options('/oauth/authorize/sign-in', corsPreflight)
|
1284
1266
|
router.post(
|
1285
1267
|
'/oauth/authorize/sign-in',
|
1286
1268
|
jsonHandler(async function (req, res) {
|
1287
1269
|
validateFetchMode(req, res, ['same-origin'])
|
1270
|
+
validateFetchSite(req, res, ['same-origin'])
|
1288
1271
|
validateSameOrigin(req, res, issuerOrigin)
|
1289
1272
|
|
1290
|
-
const input = await
|
1273
|
+
const input = await validateRequest(req, signInPayloadSchema)
|
1291
1274
|
|
1292
1275
|
validateReferer(req, res, {
|
1293
1276
|
origin: issuerOrigin,
|
@@ -1300,20 +1283,14 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1300
1283
|
csrfCookie(input.request_uri),
|
1301
1284
|
)
|
1302
1285
|
|
1303
|
-
const { deviceId } = await deviceManager.load(req, res)
|
1286
|
+
const { deviceId } = await deviceManager.load(req, res, true)
|
1304
1287
|
|
1305
|
-
|
1288
|
+
return server.signIn(
|
1306
1289
|
deviceId,
|
1290
|
+
input.request_uri,
|
1291
|
+
input.client_id,
|
1307
1292
|
input.credentials,
|
1308
1293
|
)
|
1309
|
-
|
1310
|
-
// Prevent fixation attacks
|
1311
|
-
await deviceManager.rotate(req, res, deviceId)
|
1312
|
-
|
1313
|
-
return {
|
1314
|
-
account,
|
1315
|
-
consentRequired: !info.authorizedClients.includes(input.client_id),
|
1316
|
-
}
|
1317
1294
|
}),
|
1318
1295
|
)
|
1319
1296
|
|
@@ -1324,9 +1301,20 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1324
1301
|
account_sub: z.string(),
|
1325
1302
|
})
|
1326
1303
|
|
1304
|
+
// Though this is a "no-cors" request, meaning that the browser will allow
|
1305
|
+
// any cross-origin request, with credentials, to be sent, the handler will
|
1306
|
+
// 1) validate the request origin,
|
1307
|
+
// 2) validate the CSRF token,
|
1308
|
+
// 3) validate the referer,
|
1309
|
+
// 4) validate the sec-fetch-site header,
|
1310
|
+
// 4) validate the sec-fetch-mode header,
|
1311
|
+
// 5) validate the sec-fetch-dest header (see navigationHandler).
|
1312
|
+
// And will error if any of these checks fail.
|
1327
1313
|
router.get(
|
1328
1314
|
'/oauth/authorize/accept',
|
1329
1315
|
navigationHandler(async function (req, res) {
|
1316
|
+
validateFetchSite(req, res, ['same-origin'])
|
1317
|
+
|
1330
1318
|
const query = Object.fromEntries(this.url.searchParams)
|
1331
1319
|
const input = await acceptQuerySchema.parseAsync(query, {
|
1332
1320
|
path: ['query'],
|
@@ -1367,9 +1355,20 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1367
1355
|
client_id: clientIdSchema,
|
1368
1356
|
})
|
1369
1357
|
|
1358
|
+
// Though this is a "no-cors" request, meaning that the browser will allow
|
1359
|
+
// any cross-origin request, with credentials, to be sent, the handler will
|
1360
|
+
// 1) validate the request origin,
|
1361
|
+
// 2) validate the CSRF token,
|
1362
|
+
// 3) validate the referer,
|
1363
|
+
// 4) validate the sec-fetch-site header,
|
1364
|
+
// 4) validate the sec-fetch-mode header,
|
1365
|
+
// 5) validate the sec-fetch-dest header (see navigationHandler).
|
1366
|
+
// And will error if any of these checks fail.
|
1370
1367
|
router.get(
|
1371
1368
|
'/oauth/authorize/reject',
|
1372
1369
|
navigationHandler(async function (req, res) {
|
1370
|
+
validateFetchSite(req, res, ['same-origin'])
|
1371
|
+
|
1373
1372
|
const query = Object.fromEntries(this.url.searchParams)
|
1374
1373
|
const input = await rejectQuerySchema.parseAsync(query, {
|
1375
1374
|
path: ['query'],
|
@@ -1406,3 +1405,26 @@ export class OAuthProvider extends OAuthVerifier {
|
|
1406
1405
|
return router
|
1407
1406
|
}
|
1408
1407
|
}
|
1408
|
+
|
1409
|
+
async function validateRequest<S extends z.ZodTypeAny>(
|
1410
|
+
req: IncomingMessage,
|
1411
|
+
schema: S,
|
1412
|
+
): Promise<z.TypeOf<S>> {
|
1413
|
+
try {
|
1414
|
+
return await validateRequestPayload(req, schema)
|
1415
|
+
} catch (err) {
|
1416
|
+
if (err instanceof ZodError) {
|
1417
|
+
const issue = err.issues[0]
|
1418
|
+
if (issue?.path.length) {
|
1419
|
+
// "part" will typically be
|
1420
|
+
const [part, ...path] = issue.path
|
1421
|
+
throw new InvalidRequestError(
|
1422
|
+
`Validation of ${part}'s "${path.join('.')}" with error: ${issue.message}`,
|
1423
|
+
err,
|
1424
|
+
)
|
1425
|
+
}
|
1426
|
+
}
|
1427
|
+
|
1428
|
+
throw new InvalidRequestError('Input validation error', err)
|
1429
|
+
}
|
1430
|
+
}
|
package/src/oauth-verifier.ts
CHANGED
@@ -36,8 +36,7 @@ export type OAuthVerifierOptions = Override<
|
|
36
36
|
issuer: URL | string
|
37
37
|
|
38
38
|
/**
|
39
|
-
* The keyset used to sign tokens.
|
40
|
-
* RS256 key is present in the keyset. ATPROTO requires ES256.
|
39
|
+
* The keyset used to sign access tokens.
|
41
40
|
*/
|
42
41
|
keyset: Keyset | Iterable<Key | undefined | null | false>
|
43
42
|
|
@@ -8,12 +8,18 @@ import { Account } from '../account/account.js'
|
|
8
8
|
import { Client } from '../client/client.js'
|
9
9
|
import { RequestUri } from '../request/request-uri.js'
|
10
10
|
|
11
|
+
export type ScopeDetail = {
|
12
|
+
scope: string
|
13
|
+
description?: string
|
14
|
+
}
|
15
|
+
|
11
16
|
export type AuthorizationResultAuthorize = {
|
12
17
|
issuer: string
|
13
18
|
client: Client
|
14
19
|
parameters: OAuthAuthenticationRequestParameters
|
15
20
|
authorize: {
|
16
21
|
uri: RequestUri
|
22
|
+
scopeDetails?: ScopeDetail[]
|
17
23
|
sessions: readonly {
|
18
24
|
account: Account
|
19
25
|
info: DeviceAccountInfo
|
@@ -44,6 +50,7 @@ export type AuthorizeData = {
|
|
44
50
|
requestUri: string
|
45
51
|
csrfCookie: string
|
46
52
|
loginHint?: string
|
53
|
+
scopeDetails?: ScopeDetail[]
|
47
54
|
newSessionsRequireConsent: boolean
|
48
55
|
sessions: Session[]
|
49
56
|
}
|
@@ -59,6 +66,7 @@ export function buildAuthorizeData(
|
|
59
66
|
csrfCookie: `csrf-${data.authorize.uri}`,
|
60
67
|
loginHint: data.parameters.login_hint,
|
61
68
|
newSessionsRequireConsent: data.parameters.prompt === 'consent',
|
69
|
+
scopeDetails: data.authorize.scopeDetails,
|
62
70
|
sessions: data.authorize.sessions.map(
|
63
71
|
(session): Session => ({
|
64
72
|
account: session.account,
|
@@ -2,6 +2,7 @@ import { ClientId } from '../client/client-id.js'
|
|
2
2
|
import {
|
3
3
|
CLIENT_ASSERTION_MAX_AGE,
|
4
4
|
DPOP_NONCE_MAX_AGE,
|
5
|
+
CODE_CHALLENGE_REPLAY_TIMEFRAME,
|
5
6
|
JAR_MAX_AGE,
|
6
7
|
} from '../constants.js'
|
7
8
|
import { ReplayStore } from './replay-store.js'
|
@@ -35,4 +36,12 @@ export class ReplayManager {
|
|
35
36
|
asTimeFrame(DPOP_NONCE_MAX_AGE),
|
36
37
|
)
|
37
38
|
}
|
39
|
+
|
40
|
+
async uniqueCodeChallenge(challenge: string): Promise<boolean> {
|
41
|
+
return this.replayStore.unique(
|
42
|
+
'CodeChallenge',
|
43
|
+
challenge,
|
44
|
+
asTimeFrame(CODE_CHALLENGE_REPLAY_TIMEFRAME),
|
45
|
+
)
|
46
|
+
}
|
38
47
|
}
|