@atproto/oauth-provider 0.1.3 → 0.2.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 +29 -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/components/url-viewer.tsx +3 -3
- 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
|
}
|