@atproto/oauth-provider 0.19.7 → 0.19.8
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 +26 -0
- package/dist/errors/error-parser.js +5 -5
- package/dist/errors/error-parser.js.map +1 -1
- package/package.json +22 -18
- package/src/access-token/access-token-mode.ts +0 -4
- package/src/account/account-manager.ts +0 -591
- package/src/account/account-store.ts +0 -305
- package/src/account/sign-in-data.ts +0 -15
- package/src/account/sign-up-input.ts +0 -20
- package/src/client/client-auth.ts +0 -64
- package/src/client/client-data.ts +0 -9
- package/src/client/client-id.ts +0 -4
- package/src/client/client-info.ts +0 -13
- package/src/client/client-manager.ts +0 -772
- package/src/client/client-store.ts +0 -35
- package/src/client/client-utils.ts +0 -37
- package/src/client/client.ts +0 -394
- package/src/constants.ts +0 -80
- package/src/customization/branding.ts +0 -12
- package/src/customization/build-customization-css.ts +0 -55
- package/src/customization/build-customization-data.ts +0 -24
- package/src/customization/colors.ts +0 -48
- package/src/customization/customization.ts +0 -29
- package/src/customization/links.ts +0 -10
- package/src/device/device-data.ts +0 -11
- package/src/device/device-id.ts +0 -27
- package/src/device/device-manager.ts +0 -292
- package/src/device/device-store.ts +0 -31
- package/src/device/session-id.ts +0 -21
- package/src/dpop/dpop-manager.ts +0 -214
- package/src/dpop/dpop-nonce.ts +0 -121
- package/src/dpop/dpop-proof.ts +0 -6
- package/src/errors/access-denied-error.ts +0 -12
- package/src/errors/account-selection-required-error.ts +0 -12
- package/src/errors/authorization-error.ts +0 -45
- package/src/errors/consent-required-error.ts +0 -12
- package/src/errors/error-parser.ts +0 -147
- package/src/errors/handle-unavailable-error.ts +0 -23
- package/src/errors/invalid-authorization-details-error.ts +0 -27
- package/src/errors/invalid-client-error.ts +0 -20
- package/src/errors/invalid-client-id-error.ts +0 -31
- package/src/errors/invalid-client-metadata-error.ts +0 -68
- package/src/errors/invalid-credentials-error.ts +0 -29
- package/src/errors/invalid-dpop-key-binding-error.ts +0 -21
- package/src/errors/invalid-dpop-proof-error.ts +0 -13
- package/src/errors/invalid-grant-error.ts +0 -21
- package/src/errors/invalid-invite-code-error.ts +0 -10
- package/src/errors/invalid-redirect-uri-error.ts +0 -17
- package/src/errors/invalid-request-error.ts +0 -32
- package/src/errors/invalid-scope-error.ts +0 -15
- package/src/errors/invalid-token-error.ts +0 -60
- package/src/errors/login-required-error.ts +0 -12
- package/src/errors/oauth-error.ts +0 -28
- package/src/errors/second-authentication-factor-required-error.ts +0 -25
- package/src/errors/unauthorized-client-error.ts +0 -20
- package/src/errors/use-dpop-nonce-error.ts +0 -32
- package/src/errors/www-authenticate-error.ts +0 -64
- package/src/index.ts +0 -16
- package/src/lexicon/lexicon-data.ts +0 -11
- package/src/lexicon/lexicon-getter.ts +0 -55
- package/src/lexicon/lexicon-manager.ts +0 -116
- package/src/lexicon/lexicon-store.ts +0 -35
- package/src/lib/csp/index.ts +0 -101
- package/src/lib/hcaptcha.ts +0 -228
- package/src/lib/html/README.md +0 -9
- package/src/lib/html/build-document.ts +0 -151
- package/src/lib/html/escapers.ts +0 -66
- package/src/lib/html/html.ts +0 -56
- package/src/lib/html/hydration-data.ts +0 -19
- package/src/lib/html/index.ts +0 -5
- package/src/lib/html/tags.ts +0 -67
- package/src/lib/html/util.ts +0 -17
- package/src/lib/http/README.md +0 -11
- package/src/lib/http/accept.ts +0 -90
- package/src/lib/http/context.ts +0 -42
- package/src/lib/http/headers.ts +0 -15
- package/src/lib/http/index.ts +0 -10
- package/src/lib/http/method.ts +0 -18
- package/src/lib/http/middleware.ts +0 -169
- package/src/lib/http/parser.ts +0 -101
- package/src/lib/http/path.ts +0 -82
- package/src/lib/http/request.ts +0 -256
- package/src/lib/http/response.ts +0 -122
- package/src/lib/http/route.ts +0 -60
- package/src/lib/http/router.ts +0 -117
- package/src/lib/http/security-headers.ts +0 -100
- package/src/lib/http/stream.ts +0 -57
- package/src/lib/http/types.ts +0 -16
- package/src/lib/http/url.ts +0 -23
- package/src/lib/nsid.ts +0 -10
- package/src/lib/redis.ts +0 -25
- package/src/lib/util/authorization-header.ts +0 -28
- package/src/lib/util/cast.ts +0 -18
- package/src/lib/util/color.ts +0 -168
- package/src/lib/util/crypto.ts +0 -32
- package/src/lib/util/date.ts +0 -7
- package/src/lib/util/error.ts +0 -7
- package/src/lib/util/function.ts +0 -39
- package/src/lib/util/locale.ts +0 -12
- package/src/lib/util/object.ts +0 -23
- package/src/lib/util/redirect-uri.ts +0 -46
- package/src/lib/util/time.ts +0 -49
- package/src/lib/util/type.ts +0 -182
- package/src/lib/util/ui8.ts +0 -14
- package/src/lib/util/well-known.ts +0 -8
- package/src/lib/util/zod-error.ts +0 -26
- package/src/lib/write-form-redirect.ts +0 -58
- package/src/lib/write-html.ts +0 -70
- package/src/metadata/build-metadata.ts +0 -141
- package/src/oauth-client.ts +0 -3
- package/src/oauth-dpop.ts +0 -2
- package/src/oauth-errors.ts +0 -26
- package/src/oauth-hooks.ts +0 -519
- package/src/oauth-middleware.ts +0 -53
- package/src/oauth-provider.ts +0 -1110
- package/src/oauth-store.ts +0 -12
- package/src/oauth-verifier.ts +0 -260
- package/src/replay/replay-manager.ts +0 -51
- package/src/replay/replay-store-memory.ts +0 -36
- package/src/replay/replay-store-redis.ts +0 -30
- package/src/replay/replay-store.ts +0 -44
- package/src/request/code.ts +0 -23
- package/src/request/request-data.ts +0 -36
- package/src/request/request-id.ts +0 -22
- package/src/request/request-manager.ts +0 -521
- package/src/request/request-store.ts +0 -60
- package/src/request/request-uri.ts +0 -42
- package/src/result/authorization-redirect-parameters.ts +0 -24
- package/src/result/authorization-result-authorize-page.ts +0 -16
- package/src/result/authorization-result-redirect.ts +0 -8
- package/src/router/assets/assets-manifest.ts +0 -117
- package/src/router/assets/assets.ts +0 -124
- package/src/router/assets/csrf.ts +0 -79
- package/src/router/assets/send-account-page.ts +0 -23
- package/src/router/assets/send-authorization-page.ts +0 -42
- package/src/router/assets/send-cookie-error-page.ts +0 -21
- package/src/router/assets/send-error-page.ts +0 -25
- package/src/router/assets/send-redirect.ts +0 -140
- package/src/router/create-account-page-middleware.ts +0 -88
- package/src/router/create-api-middleware.ts +0 -1051
- package/src/router/create-authorization-page-middleware.ts +0 -281
- package/src/router/create-oauth-middleware.ts +0 -257
- package/src/router/error-handler.ts +0 -6
- package/src/router/middleware-options.ts +0 -9
- package/src/signer/access-token-payload.ts +0 -25
- package/src/signer/api-token-payload.ts +0 -18
- package/src/signer/signer.ts +0 -121
- package/src/token/refresh-token.ts +0 -30
- package/src/token/token-claims.ts +0 -22
- package/src/token/token-data.ts +0 -40
- package/src/token/token-id.ts +0 -25
- package/src/token/token-manager.ts +0 -427
- package/src/token/token-store.ts +0 -88
- package/src/types/authorization-response-error.ts +0 -27
- package/src/types/color-hue.ts +0 -3
- package/src/types/email-otp.ts +0 -3
- package/src/types/email.ts +0 -26
- package/src/types/handle.ts +0 -23
- package/src/types/invite-code.ts +0 -4
- package/src/types/par-response-error.ts +0 -25
- package/src/types/password.ts +0 -4
- package/src/types/rgb-color.ts +0 -21
- package/tsconfig.build.json +0 -8
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -4
package/src/signer/signer.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
JwtHeader,
|
|
3
|
-
JwtPayload,
|
|
4
|
-
JwtPayloadGetter,
|
|
5
|
-
JwtSignHeader,
|
|
6
|
-
Key,
|
|
7
|
-
Keyset,
|
|
8
|
-
SignedJwt,
|
|
9
|
-
VerifyOptions,
|
|
10
|
-
VerifyResult,
|
|
11
|
-
} from '@atproto/jwk'
|
|
12
|
-
import { EPHEMERAL_SESSION_MAX_AGE } from '../constants.js'
|
|
13
|
-
import { dateToEpoch } from '../lib/util/date.js'
|
|
14
|
-
import { OmitKey, RequiredKey } from '../lib/util/type.js'
|
|
15
|
-
import {
|
|
16
|
-
AccessTokenPayload,
|
|
17
|
-
accessTokenPayloadSchema,
|
|
18
|
-
} from './access-token-payload.js'
|
|
19
|
-
import { ApiTokenPayload, apiTokenPayloadSchema } from './api-token-payload.js'
|
|
20
|
-
|
|
21
|
-
export type SignPayload = JwtPayload & { iss?: never }
|
|
22
|
-
|
|
23
|
-
export { Keyset }
|
|
24
|
-
export type { JwtPayloadGetter, JwtSignHeader, SignedJwt, VerifyOptions }
|
|
25
|
-
|
|
26
|
-
export class Signer {
|
|
27
|
-
constructor(
|
|
28
|
-
public readonly issuer: string,
|
|
29
|
-
public readonly keyset: Keyset,
|
|
30
|
-
) {}
|
|
31
|
-
|
|
32
|
-
async verify<C extends string = never>(
|
|
33
|
-
token: SignedJwt,
|
|
34
|
-
options?: Omit<VerifyOptions<C>, 'issuer'>,
|
|
35
|
-
): Promise<VerifyResult<C> & { key: Key }> {
|
|
36
|
-
return this.keyset.verifyJwt<C>(token, {
|
|
37
|
-
...options,
|
|
38
|
-
issuer: [this.issuer],
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
public async sign(
|
|
43
|
-
signHeader: JwtSignHeader,
|
|
44
|
-
payload: SignPayload | JwtPayloadGetter<SignPayload>,
|
|
45
|
-
): Promise<SignedJwt> {
|
|
46
|
-
return this.keyset.createJwt(signHeader, async (protectedHeader, key) => ({
|
|
47
|
-
...(typeof payload === 'function'
|
|
48
|
-
? await payload(protectedHeader, key)
|
|
49
|
-
: payload),
|
|
50
|
-
iss: this.issuer,
|
|
51
|
-
}))
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async createAccessToken(
|
|
55
|
-
payload: OmitKey<AccessTokenPayload, 'iss'>,
|
|
56
|
-
): Promise<SignedJwt> {
|
|
57
|
-
return this.sign(
|
|
58
|
-
{
|
|
59
|
-
// https://datatracker.ietf.org/doc/html/rfc9068#section-2.1
|
|
60
|
-
alg: undefined,
|
|
61
|
-
typ: 'at+jwt',
|
|
62
|
-
},
|
|
63
|
-
payload,
|
|
64
|
-
)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async verifyAccessToken<C extends string = never>(
|
|
68
|
-
token: SignedJwt,
|
|
69
|
-
options?: Omit<VerifyOptions<C>, 'issuer' | 'typ'>,
|
|
70
|
-
): Promise<{
|
|
71
|
-
protectedHeader: JwtHeader
|
|
72
|
-
payload: RequiredKey<AccessTokenPayload, C>
|
|
73
|
-
}> {
|
|
74
|
-
const result = await this.verify<C>(token, { ...options, typ: 'at+jwt' })
|
|
75
|
-
return {
|
|
76
|
-
protectedHeader: result.protectedHeader,
|
|
77
|
-
payload: accessTokenPayloadSchema.parse(result.payload) as RequiredKey<
|
|
78
|
-
AccessTokenPayload,
|
|
79
|
-
C
|
|
80
|
-
>,
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async createEphemeralToken(
|
|
85
|
-
payload: OmitKey<ApiTokenPayload, 'iss' | 'aud' | 'iat'>,
|
|
86
|
-
): Promise<SignedJwt> {
|
|
87
|
-
return this.sign(
|
|
88
|
-
{
|
|
89
|
-
alg: undefined,
|
|
90
|
-
typ: 'at+jwt',
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
...payload,
|
|
94
|
-
aud: `oauth-provider-api@${this.issuer}`,
|
|
95
|
-
iat: dateToEpoch(),
|
|
96
|
-
},
|
|
97
|
-
)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async verifyEphemeralToken<C extends string = never>(
|
|
101
|
-
token: SignedJwt,
|
|
102
|
-
options?: Omit<VerifyOptions<C>, 'issuer' | 'audience' | 'typ'>,
|
|
103
|
-
): Promise<{
|
|
104
|
-
protectedHeader: JwtHeader
|
|
105
|
-
payload: RequiredKey<ApiTokenPayload, C>
|
|
106
|
-
}> {
|
|
107
|
-
const result = await this.verify<C>(token, {
|
|
108
|
-
...options,
|
|
109
|
-
maxTokenAge: options?.maxTokenAge ?? EPHEMERAL_SESSION_MAX_AGE / 1e3,
|
|
110
|
-
audience: `oauth-provider-api@${this.issuer}`,
|
|
111
|
-
typ: 'at+jwt',
|
|
112
|
-
})
|
|
113
|
-
return {
|
|
114
|
-
protectedHeader: result.protectedHeader,
|
|
115
|
-
payload: apiTokenPayloadSchema.parse(result.payload) as RequiredKey<
|
|
116
|
-
ApiTokenPayload,
|
|
117
|
-
C
|
|
118
|
-
>,
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import {
|
|
3
|
-
REFRESH_TOKEN_BYTES_LENGTH,
|
|
4
|
-
REFRESH_TOKEN_PREFIX,
|
|
5
|
-
} from '../constants.js'
|
|
6
|
-
import { randomHexId } from '../lib/util/crypto.js'
|
|
7
|
-
|
|
8
|
-
export const REFRESH_TOKEN_LENGTH =
|
|
9
|
-
REFRESH_TOKEN_PREFIX.length + REFRESH_TOKEN_BYTES_LENGTH * 2 // hex encoding
|
|
10
|
-
|
|
11
|
-
export const refreshTokenSchema = z
|
|
12
|
-
.string()
|
|
13
|
-
.length(REFRESH_TOKEN_LENGTH)
|
|
14
|
-
.refine(
|
|
15
|
-
(v): v is `${typeof REFRESH_TOKEN_PREFIX}${string}` =>
|
|
16
|
-
v.startsWith(REFRESH_TOKEN_PREFIX),
|
|
17
|
-
{
|
|
18
|
-
message: `Invalid refresh token format`,
|
|
19
|
-
},
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
export const isRefreshToken = (data: unknown): data is RefreshToken =>
|
|
23
|
-
refreshTokenSchema.safeParse(data).success
|
|
24
|
-
|
|
25
|
-
export type RefreshToken = z.infer<typeof refreshTokenSchema>
|
|
26
|
-
export const generateRefreshToken = async (): Promise<RefreshToken> => {
|
|
27
|
-
return `${REFRESH_TOKEN_PREFIX}${await randomHexId(
|
|
28
|
-
REFRESH_TOKEN_BYTES_LENGTH,
|
|
29
|
-
)}`
|
|
30
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { Did } from '@atproto/did'
|
|
2
|
-
import { OAuthScope } from '@atproto/oauth-types'
|
|
3
|
-
import { ClientId } from '../client/client-id.js'
|
|
4
|
-
import { TokenId } from './token-id.js'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The access token claims that will be set by the {@link TokenManager} and that
|
|
8
|
-
* will be passed to the "onCreateToken" hook.
|
|
9
|
-
*
|
|
10
|
-
* @note "iss" is missing here because it cannot be altered and will always be
|
|
11
|
-
* set to the Authorization Server's identifier.
|
|
12
|
-
*/
|
|
13
|
-
export type TokenClaims = {
|
|
14
|
-
jti: TokenId
|
|
15
|
-
sub: Did
|
|
16
|
-
iat: number
|
|
17
|
-
exp: number
|
|
18
|
-
aud: string | [string, ...string[]]
|
|
19
|
-
cnf?: { jkt: string }
|
|
20
|
-
scope?: OAuthScope
|
|
21
|
-
client_id: ClientId
|
|
22
|
-
}
|
package/src/token/token-data.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { Did } from '@atproto/did'
|
|
2
|
-
import {
|
|
3
|
-
OAuthAuthorizationDetails,
|
|
4
|
-
OAuthAuthorizationRequestParameters,
|
|
5
|
-
} from '@atproto/oauth-types'
|
|
6
|
-
import { ClientAuth, ClientAuthLegacy } from '../client/client-auth.js'
|
|
7
|
-
import { ClientId } from '../client/client-id.js'
|
|
8
|
-
import { DeviceId } from '../device/device-id.js'
|
|
9
|
-
import { Code } from '../request/code.js'
|
|
10
|
-
|
|
11
|
-
export type {
|
|
12
|
-
ClientAuth,
|
|
13
|
-
ClientId,
|
|
14
|
-
Code,
|
|
15
|
-
DeviceId,
|
|
16
|
-
Did,
|
|
17
|
-
OAuthAuthorizationDetails,
|
|
18
|
-
OAuthAuthorizationRequestParameters,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type TokenData = {
|
|
22
|
-
createdAt: Date
|
|
23
|
-
updatedAt: Date
|
|
24
|
-
expiresAt: Date
|
|
25
|
-
clientId: ClientId
|
|
26
|
-
clientAuth: ClientAuth | ClientAuthLegacy
|
|
27
|
-
deviceId: DeviceId | null
|
|
28
|
-
did: Did
|
|
29
|
-
parameters: OAuthAuthorizationRequestParameters
|
|
30
|
-
details?: null // Legacy field, not used
|
|
31
|
-
code: Code | null
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* This will contain the parameter scope, translated into permissions
|
|
35
|
-
*
|
|
36
|
-
* @note null because this didn't use to exist. New tokens should always
|
|
37
|
-
* include a scope.
|
|
38
|
-
*/
|
|
39
|
-
scope: string | null
|
|
40
|
-
}
|
package/src/token/token-id.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import { TOKEN_ID_BYTES_LENGTH, TOKEN_ID_PREFIX } from '../constants.js'
|
|
3
|
-
import { randomHexId } from '../lib/util/crypto.js'
|
|
4
|
-
|
|
5
|
-
export const TOKEN_ID_LENGTH =
|
|
6
|
-
TOKEN_ID_PREFIX.length + TOKEN_ID_BYTES_LENGTH * 2 // hex encoding
|
|
7
|
-
|
|
8
|
-
export const tokenIdSchema = z
|
|
9
|
-
.string()
|
|
10
|
-
.length(TOKEN_ID_LENGTH)
|
|
11
|
-
.refine(
|
|
12
|
-
(v): v is `${typeof TOKEN_ID_PREFIX}${string}` =>
|
|
13
|
-
v.startsWith(TOKEN_ID_PREFIX),
|
|
14
|
-
{
|
|
15
|
-
message: `Invalid token ID format`,
|
|
16
|
-
},
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
export type TokenId = z.infer<typeof tokenIdSchema>
|
|
20
|
-
export const generateTokenId = async (): Promise<TokenId> => {
|
|
21
|
-
return `${TOKEN_ID_PREFIX}${await randomHexId(TOKEN_ID_BYTES_LENGTH)}`
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export const isTokenId = (data: unknown): data is TokenId =>
|
|
25
|
-
tokenIdSchema.safeParse(data).success
|
|
@@ -1,427 +0,0 @@
|
|
|
1
|
-
import type { Did } from '@atproto/did'
|
|
2
|
-
import { SignedJwt, isSignedJwt } from '@atproto/jwk'
|
|
3
|
-
import { LexResolverError } from '@atproto/lex-resolver'
|
|
4
|
-
import type { Account } from '@atproto/oauth-provider-api'
|
|
5
|
-
import {
|
|
6
|
-
OAuthAccessToken,
|
|
7
|
-
OAuthAuthorizationRequestParameters,
|
|
8
|
-
OAuthScope,
|
|
9
|
-
OAuthTokenResponse,
|
|
10
|
-
OAuthTokenType,
|
|
11
|
-
} from '@atproto/oauth-types'
|
|
12
|
-
import { AccessTokenMode } from '../access-token/access-token-mode.js'
|
|
13
|
-
import { ClientAuth } from '../client/client-auth.js'
|
|
14
|
-
import { Client } from '../client/client.js'
|
|
15
|
-
import { TOKEN_MAX_AGE } from '../constants.js'
|
|
16
|
-
import { DeviceId } from '../device/device-id.js'
|
|
17
|
-
import { InvalidGrantError } from '../errors/invalid-grant-error.js'
|
|
18
|
-
import { InvalidRequestError } from '../errors/invalid-request-error.js'
|
|
19
|
-
import { InvalidTokenError } from '../errors/invalid-token-error.js'
|
|
20
|
-
import { LexiconManager } from '../lexicon/lexicon-manager.js'
|
|
21
|
-
import { RequestMetadata } from '../lib/http/request.js'
|
|
22
|
-
import { dateToEpoch, dateToRelativeSeconds } from '../lib/util/date.js'
|
|
23
|
-
import { OAuthHooks } from '../oauth-hooks.js'
|
|
24
|
-
import { Code, isCode } from '../request/code.js'
|
|
25
|
-
import { AccessTokenPayload } from '../signer/access-token-payload.js'
|
|
26
|
-
import { Signer } from '../signer/signer.js'
|
|
27
|
-
import {
|
|
28
|
-
RefreshToken,
|
|
29
|
-
generateRefreshToken,
|
|
30
|
-
isRefreshToken,
|
|
31
|
-
} from './refresh-token.js'
|
|
32
|
-
import { TokenClaims } from './token-claims.js'
|
|
33
|
-
import { TokenId, generateTokenId, isTokenId } from './token-id.js'
|
|
34
|
-
import { CreateTokenData, TokenInfo, TokenStore } from './token-store.js'
|
|
35
|
-
|
|
36
|
-
export { AccessTokenMode, Signer }
|
|
37
|
-
export type { OAuthHooks, TokenStore }
|
|
38
|
-
|
|
39
|
-
export class TokenManager {
|
|
40
|
-
constructor(
|
|
41
|
-
protected readonly store: TokenStore,
|
|
42
|
-
protected readonly lexiconManager: LexiconManager,
|
|
43
|
-
protected readonly signer: Signer,
|
|
44
|
-
protected readonly hooks: OAuthHooks,
|
|
45
|
-
protected readonly accessTokenMode: AccessTokenMode,
|
|
46
|
-
protected readonly tokenMaxAge = TOKEN_MAX_AGE,
|
|
47
|
-
) {}
|
|
48
|
-
|
|
49
|
-
protected createTokenExpiry(now = new Date()) {
|
|
50
|
-
return new Date(now.getTime() + this.tokenMaxAge)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
protected async createAccessToken(
|
|
54
|
-
tokenId: TokenId,
|
|
55
|
-
client: Client,
|
|
56
|
-
account: Account,
|
|
57
|
-
parameters: OAuthAuthorizationRequestParameters,
|
|
58
|
-
issuedAt: Date,
|
|
59
|
-
expiresAt: Date,
|
|
60
|
-
scope: OAuthScope,
|
|
61
|
-
): Promise<OAuthAccessToken> {
|
|
62
|
-
const claims: TokenClaims = {
|
|
63
|
-
jti: tokenId,
|
|
64
|
-
sub: account.did,
|
|
65
|
-
iat: dateToEpoch(issuedAt),
|
|
66
|
-
exp: dateToEpoch(expiresAt),
|
|
67
|
-
aud: account.pds,
|
|
68
|
-
|
|
69
|
-
...(parameters.dpop_jkt && {
|
|
70
|
-
cnf: { jkt: parameters.dpop_jkt },
|
|
71
|
-
}),
|
|
72
|
-
|
|
73
|
-
// Because tokens can end-up being quite big, we only include the scope in
|
|
74
|
-
// stateless mode.
|
|
75
|
-
...(this.accessTokenMode === AccessTokenMode.stateless && {
|
|
76
|
-
scope,
|
|
77
|
-
}),
|
|
78
|
-
|
|
79
|
-
// https://datatracker.ietf.org/doc/html/rfc8693#section-4.3
|
|
80
|
-
client_id: client.id,
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const claimsOverride = await this.hooks.onCreateToken?.call(null, {
|
|
84
|
-
client,
|
|
85
|
-
account,
|
|
86
|
-
parameters,
|
|
87
|
-
claims,
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
return this.signer.createAccessToken(claimsOverride ?? claims)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async createToken(
|
|
94
|
-
client: Client,
|
|
95
|
-
clientAuth: ClientAuth,
|
|
96
|
-
clientMetadata: RequestMetadata,
|
|
97
|
-
account: Account,
|
|
98
|
-
deviceId: null | DeviceId,
|
|
99
|
-
parameters: OAuthAuthorizationRequestParameters,
|
|
100
|
-
code: Code,
|
|
101
|
-
): Promise<OAuthTokenResponse> {
|
|
102
|
-
await this.validateTokenParams(client, clientAuth, parameters)
|
|
103
|
-
|
|
104
|
-
const tokenId = await generateTokenId()
|
|
105
|
-
const refreshToken = client.metadata.grant_types.includes('refresh_token')
|
|
106
|
-
? await generateRefreshToken()
|
|
107
|
-
: undefined
|
|
108
|
-
|
|
109
|
-
const now = new Date()
|
|
110
|
-
const expiresAt = this.createTokenExpiry(now)
|
|
111
|
-
|
|
112
|
-
const scope = await this.lexiconManager
|
|
113
|
-
.buildTokenScope(parameters.scope!)
|
|
114
|
-
.catch((err) => {
|
|
115
|
-
// Parse expected errors
|
|
116
|
-
if (err instanceof LexResolverError) {
|
|
117
|
-
throw new InvalidRequestError(err.message, err)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Unexpected error
|
|
121
|
-
throw err
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
const accessToken = await this.createAccessToken(
|
|
125
|
-
tokenId,
|
|
126
|
-
client,
|
|
127
|
-
account,
|
|
128
|
-
parameters,
|
|
129
|
-
now,
|
|
130
|
-
expiresAt,
|
|
131
|
-
scope,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
const response = this.buildTokenResponse(
|
|
135
|
-
inferTokenType(parameters),
|
|
136
|
-
accessToken,
|
|
137
|
-
refreshToken,
|
|
138
|
-
expiresAt,
|
|
139
|
-
account.did,
|
|
140
|
-
scope,
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
const tokenData: CreateTokenData = {
|
|
144
|
-
createdAt: now,
|
|
145
|
-
updatedAt: now,
|
|
146
|
-
expiresAt,
|
|
147
|
-
clientId: client.id,
|
|
148
|
-
clientAuth,
|
|
149
|
-
deviceId,
|
|
150
|
-
did: account.did,
|
|
151
|
-
parameters,
|
|
152
|
-
details: null,
|
|
153
|
-
scope,
|
|
154
|
-
code,
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
await this.store.createToken(tokenId, tokenData, refreshToken)
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
await this.hooks.onTokenCreated?.call(null, {
|
|
161
|
-
client,
|
|
162
|
-
clientAuth,
|
|
163
|
-
clientMetadata,
|
|
164
|
-
account,
|
|
165
|
-
parameters,
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
return response
|
|
169
|
-
} catch (err) {
|
|
170
|
-
// If the hook fails, we delete the token to avoid leaving a dangling
|
|
171
|
-
// token in the store.
|
|
172
|
-
await this.deleteToken(tokenId)
|
|
173
|
-
throw err
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
protected async validateTokenParams(
|
|
178
|
-
client: Client,
|
|
179
|
-
clientAuth: ClientAuth,
|
|
180
|
-
parameters: OAuthAuthorizationRequestParameters,
|
|
181
|
-
): Promise<void> {
|
|
182
|
-
if (client.metadata.dpop_bound_access_tokens && !parameters.dpop_jkt) {
|
|
183
|
-
throw new InvalidGrantError(
|
|
184
|
-
`DPoP JKT is required for DPoP bound access tokens`,
|
|
185
|
-
)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
protected buildTokenResponse(
|
|
190
|
-
tokenType: OAuthTokenType,
|
|
191
|
-
accessToken: OAuthAccessToken,
|
|
192
|
-
refreshToken: string | undefined,
|
|
193
|
-
expiresAt: Date,
|
|
194
|
-
did: Did,
|
|
195
|
-
scope: string,
|
|
196
|
-
): OAuthTokenResponse {
|
|
197
|
-
return {
|
|
198
|
-
access_token: accessToken,
|
|
199
|
-
token_type: tokenType,
|
|
200
|
-
refresh_token: refreshToken,
|
|
201
|
-
scope,
|
|
202
|
-
|
|
203
|
-
// @NOTE using a getter so that the value gets computed when the JSON
|
|
204
|
-
// response is generated, allowing to value to be as accurate as possible.
|
|
205
|
-
get expires_in() {
|
|
206
|
-
return dateToRelativeSeconds(expiresAt)
|
|
207
|
-
},
|
|
208
|
-
|
|
209
|
-
// ATPROTO extension: add the sub claim to the token response to allow
|
|
210
|
-
// clients to resolve the PDS url (audience) using the did resolution
|
|
211
|
-
// mechanism.
|
|
212
|
-
sub: did,
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async rotateToken(
|
|
217
|
-
client: Client,
|
|
218
|
-
clientAuth: ClientAuth,
|
|
219
|
-
clientMetadata: RequestMetadata,
|
|
220
|
-
tokenInfo: TokenInfo,
|
|
221
|
-
): Promise<OAuthTokenResponse> {
|
|
222
|
-
const { account, data } = tokenInfo
|
|
223
|
-
const { parameters } = data
|
|
224
|
-
|
|
225
|
-
await this.validateTokenParams(client, clientAuth, parameters)
|
|
226
|
-
|
|
227
|
-
const nextTokenId = await generateTokenId()
|
|
228
|
-
const nextRefreshToken = await generateRefreshToken()
|
|
229
|
-
|
|
230
|
-
const now = new Date()
|
|
231
|
-
const expiresAt = this.createTokenExpiry(now)
|
|
232
|
-
|
|
233
|
-
// @NOTE since the permission sets are stored in a persistent store,
|
|
234
|
-
// it's fine to propagate a 500 (server_error) here as the values should
|
|
235
|
-
// be retrievable from the store.
|
|
236
|
-
const scope = await this.lexiconManager.buildTokenScope(parameters.scope!)
|
|
237
|
-
|
|
238
|
-
await this.store.rotateToken(tokenInfo.id, nextTokenId, nextRefreshToken, {
|
|
239
|
-
updatedAt: now,
|
|
240
|
-
expiresAt,
|
|
241
|
-
// @NOTE Normally, the clientAuth not change over time. There are two
|
|
242
|
-
// exceptions:
|
|
243
|
-
// - Upgrade from a legacy representation of client authentication to
|
|
244
|
-
// a modern one.
|
|
245
|
-
// - Allow clients to become "confidential" if they were previously
|
|
246
|
-
// "public"
|
|
247
|
-
clientAuth,
|
|
248
|
-
scope,
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
const accessToken = await this.createAccessToken(
|
|
252
|
-
nextTokenId,
|
|
253
|
-
client,
|
|
254
|
-
account,
|
|
255
|
-
parameters,
|
|
256
|
-
now,
|
|
257
|
-
expiresAt,
|
|
258
|
-
scope,
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
const response = this.buildTokenResponse(
|
|
262
|
-
inferTokenType(parameters),
|
|
263
|
-
accessToken,
|
|
264
|
-
nextRefreshToken,
|
|
265
|
-
expiresAt,
|
|
266
|
-
account.did,
|
|
267
|
-
scope,
|
|
268
|
-
)
|
|
269
|
-
|
|
270
|
-
await this.hooks.onTokenRefreshed?.call(null, {
|
|
271
|
-
client,
|
|
272
|
-
clientAuth,
|
|
273
|
-
clientMetadata,
|
|
274
|
-
account,
|
|
275
|
-
parameters,
|
|
276
|
-
})
|
|
277
|
-
|
|
278
|
-
return response
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* @note The token validity is not guaranteed. The caller must ensure that the
|
|
283
|
-
* token is valid before using the returned token info.
|
|
284
|
-
*/
|
|
285
|
-
public async findToken(token: string): Promise<null | TokenInfo> {
|
|
286
|
-
if (isTokenId(token)) {
|
|
287
|
-
return this.getTokenInfo(token)
|
|
288
|
-
} else if (isCode(token)) {
|
|
289
|
-
return this.findByCode(token)
|
|
290
|
-
} else if (isRefreshToken(token)) {
|
|
291
|
-
return this.findByRefreshToken(token)
|
|
292
|
-
} else if (isSignedJwt(token)) {
|
|
293
|
-
return this.findByAccessToken(token)
|
|
294
|
-
} else {
|
|
295
|
-
throw new InvalidRequestError(`Invalid token`)
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
public async findByAccessToken(token: SignedJwt): Promise<null | TokenInfo> {
|
|
300
|
-
const { payload } = await this.signer.verifyAccessToken(token, {
|
|
301
|
-
clockTolerance: Infinity,
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
const tokenInfo = await this.getTokenInfo(payload.jti)
|
|
305
|
-
if (!tokenInfo) return null
|
|
306
|
-
|
|
307
|
-
// Fool-proof: Invalid store implementation ?
|
|
308
|
-
if (payload.sub !== tokenInfo.account.did) {
|
|
309
|
-
await this.deleteToken(tokenInfo.id)
|
|
310
|
-
throw new Error(
|
|
311
|
-
`Account sub (${tokenInfo.account.did}) does not match token sub (${payload.sub})`,
|
|
312
|
-
)
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
return tokenInfo
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
protected async findByRefreshToken(
|
|
319
|
-
token: RefreshToken,
|
|
320
|
-
): Promise<null | TokenInfo> {
|
|
321
|
-
return this.store.findTokenByRefreshToken(token)
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
public async consumeRefreshToken(token: RefreshToken): Promise<TokenInfo> {
|
|
325
|
-
// @NOTE concurrent refreshes of the same refresh token could theoretically
|
|
326
|
-
// lead to two new tokens (access & refresh) being created. This is deemed
|
|
327
|
-
// acceptable for now (as the mechanism can only be used once since only one
|
|
328
|
-
// of the two refresh token created will be valid, and any future refresh
|
|
329
|
-
// attempts from outdated tokens will cause the entire session to be
|
|
330
|
-
// invalidated). Ideally, the store should be able to handle this case by
|
|
331
|
-
// atomically consuming the refresh token and returning the token info.
|
|
332
|
-
|
|
333
|
-
// @TODO Add another store method that atomically consumes the refresh token
|
|
334
|
-
// with a lock.
|
|
335
|
-
const tokenInfo = await this.findByRefreshToken(token).catch((err) => {
|
|
336
|
-
throw InvalidGrantError.from(err, `Invalid refresh token`)
|
|
337
|
-
})
|
|
338
|
-
|
|
339
|
-
if (!tokenInfo) {
|
|
340
|
-
throw new InvalidGrantError(`Invalid refresh token`)
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (tokenInfo.currentRefreshToken !== token) {
|
|
344
|
-
await this.deleteToken(tokenInfo.id)
|
|
345
|
-
throw new InvalidGrantError(`Refresh token replayed`)
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
return tokenInfo
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
public async findByCode(code: Code): Promise<null | TokenInfo> {
|
|
352
|
-
return this.store.findTokenByCode(code)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
public async deleteToken(tokenId: TokenId): Promise<void> {
|
|
356
|
-
return this.store.deleteToken(tokenId)
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
async getTokenInfo(tokenId: TokenId): Promise<null | TokenInfo> {
|
|
360
|
-
return this.store.readToken(tokenId)
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* This method is called to when decoding a token that was encoded in
|
|
365
|
-
* {@link AccessTokenMode.light} mode, using data from the store to fill the
|
|
366
|
-
* data that was omitted in the token itself.
|
|
367
|
-
*/
|
|
368
|
-
async loadTokenClaims(
|
|
369
|
-
tokenType: OAuthTokenType,
|
|
370
|
-
tokenPayload: AccessTokenPayload,
|
|
371
|
-
): Promise<TokenClaims> {
|
|
372
|
-
const tokenId = tokenPayload.jti
|
|
373
|
-
const tokenInfo = await this.getTokenInfo(tokenId).catch((err) => {
|
|
374
|
-
throw InvalidTokenError.from(err, tokenType)
|
|
375
|
-
})
|
|
376
|
-
|
|
377
|
-
if (!tokenInfo) {
|
|
378
|
-
throw new InvalidTokenError(tokenType, `Invalid token`)
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const { account, data } = tokenInfo
|
|
382
|
-
|
|
383
|
-
// Fool proof, make sure that the database & token payload are consistent.
|
|
384
|
-
// These should both be either undefined or a string so it's safe to compare
|
|
385
|
-
// the values directly.
|
|
386
|
-
if (tokenPayload.cnf?.jkt !== data.parameters.dpop_jkt) {
|
|
387
|
-
await this.deleteToken(tokenId)
|
|
388
|
-
throw new InvalidTokenError(tokenType, `Invalid token`)
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (isCurrentTokenExpired(tokenInfo)) {
|
|
392
|
-
await this.deleteToken(tokenId)
|
|
393
|
-
throw new InvalidTokenError(tokenType, `Token expired`)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
return {
|
|
397
|
-
jti: tokenId,
|
|
398
|
-
sub: account.did,
|
|
399
|
-
iat: dateToEpoch(data.updatedAt),
|
|
400
|
-
exp: dateToEpoch(data.expiresAt),
|
|
401
|
-
aud: account.pds,
|
|
402
|
-
scope: data.scope ?? data.parameters.scope,
|
|
403
|
-
// https://datatracker.ietf.org/doc/html/rfc8693#section-4.3
|
|
404
|
-
client_id: data.clientId,
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
async listAccountTokens(did: Did): Promise<TokenInfo[]> {
|
|
409
|
-
const results = await this.store.listAccountTokens(did)
|
|
410
|
-
return results
|
|
411
|
-
.filter((tokenInfo) => tokenInfo.account.did === did) // Fool proof
|
|
412
|
-
.filter((tokenInfo) => !isCurrentTokenExpired(tokenInfo))
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function isCurrentTokenExpired(tokenInfo: TokenInfo): boolean {
|
|
417
|
-
return tokenInfo.data.expiresAt.getTime() < Date.now()
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function inferTokenType(
|
|
421
|
-
parameters: OAuthAuthorizationRequestParameters,
|
|
422
|
-
): OAuthTokenType {
|
|
423
|
-
if (parameters.dpop_jkt) {
|
|
424
|
-
return 'DPoP'
|
|
425
|
-
}
|
|
426
|
-
return 'Bearer'
|
|
427
|
-
}
|