From cc296157a8a62eb2fefe73a43640e612bd6b0bea Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 4 Mar 2024 10:06:22 +0100 Subject: [PATCH 001/140] fix(loggerMiddleware): avoid 500 when invalid jwt used as bearer & add support for DPoP auth scheme --- packages/bsky/src/logger.ts | 65 +++++++++++++++++++++++++------------ packages/pds/src/logger.ts | 65 +++++++++++++++++++++++++------------ 2 files changed, 90 insertions(+), 40 deletions(-) diff --git a/packages/bsky/src/logger.ts b/packages/bsky/src/logger.ts index 935b929d6f3..9302043cfde 100644 --- a/packages/bsky/src/logger.ts +++ b/packages/bsky/src/logger.ts @@ -26,32 +26,57 @@ export const loggerMiddleware = pinoHttp({ }, req: (req) => { const serialized = pino.stdSerializers.req(req) - const authHeader = serialized.headers.authorization || '' - let auth: string | undefined = undefined - if (authHeader.startsWith('Bearer ')) { - const token = authHeader.slice('Bearer '.length) - const { iss } = jose.decodeJwt(token) - if (iss) { - auth = 'Bearer ' + iss - } else { - auth = 'Bearer Invalid' - } - } - if (authHeader.startsWith('Basic ')) { - const parsed = parseBasicAuth(authHeader) - if (!parsed) { - auth = 'Basic Invalid' - } else { - auth = 'Basic ' + parsed.username - } - } + const authHeader = serialized.headers.authorization + + if (authHeader == null) return serialized + return { ...serialized, headers: { ...serialized.headers, - authorization: auth, + authorization: obfuscateAuthHeader(authHeader), }, } }, }, }) + +function obfuscateAuthHeader(authHeader: string): string { + const [type, token] = authHeader.split(' ', 2) + switch (type) { + case 'Basic': + return `${type} ${obfuscateBasic(authHeader!)}` + case 'Bearer': + case 'DPoP': + return `${type} ${obfuscateBearer(token)}` + default: + return `Invalid` + } +} + +function obfuscateBasic(authHeader: string): string { + const parsed = parseBasicAuth(authHeader) + if (parsed) return parsed.username + return 'Invalid' +} + +function obfuscateBearer(token?: string): string { + if (token) { + if (token.includes('.')) { + try { + const { sub } = jose.decodeJwt(token) + if (sub) return sub + } catch { + // Not a JWT + } + } + + if (token.length > 10) { + // Log no more than half the token, up to 10 characters. tokens should be + // long enough to be secure even when half of them are exposed. + return `${token.slice(0, Math.min(10, token.length / 2))}...` + } + } + + return 'Invalid' +} diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index 717e554d00b..737e05a5e67 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -25,32 +25,57 @@ export const loggerMiddleware = pinoHttp({ }, req: (req) => { const serialized = pino.stdSerializers.req(req) - const authHeader = serialized.headers.authorization || '' - let auth: string | undefined = undefined - if (authHeader.startsWith('Bearer ')) { - const token = authHeader.slice('Bearer '.length) - const { sub } = jose.decodeJwt(token) - if (sub) { - auth = 'Bearer ' + sub - } else { - auth = 'Bearer Invalid' - } - } - if (authHeader.startsWith('Basic ')) { - const parsed = parseBasicAuth(authHeader) - if (!parsed) { - auth = 'Basic Invalid' - } else { - auth = 'Basic ' + parsed.username - } - } + const authHeader = serialized.headers.authorization + + if (authHeader == null) return serialized + return { ...serialized, headers: { ...serialized.headers, - authorization: auth, + authorization: obfuscateAuthHeader(authHeader), }, } }, }, }) + +function obfuscateAuthHeader(authHeader: string): string { + const [type, token] = authHeader.split(' ', 2) + switch (type) { + case 'Basic': + return `${type} ${obfuscateBasic(authHeader!)}` + case 'Bearer': + case 'DPoP': + return `${type} ${obfuscateBearer(token)}` + default: + return `Invalid` + } +} + +function obfuscateBasic(authHeader: string): string { + const parsed = parseBasicAuth(authHeader) + if (parsed) return parsed.username + return 'Invalid' +} + +function obfuscateBearer(token?: string): string { + if (token) { + if (token.includes('.')) { + try { + const { sub } = jose.decodeJwt(token) + if (sub) return sub + } catch { + // Not a JWT + } + } + + if (token.length > 10) { + // Log no more than half the token, up to 10 characters. tokens should be + // long enough to be secure even when half of them are exposed. + return `${token.slice(0, Math.min(10, token.length / 2))}...` + } + } + + return 'Invalid' +} From 604dce40f62c1b345ea2bf23ac8e72511b0b0e2f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 28 Feb 2024 12:36:05 +0100 Subject: [PATCH 002/140] refactor(pds): add refreshExpired auth verifier --- .../api/com/atproto/server/deleteSession.ts | 34 ++--- packages/pds/src/auth-verifier.ts | 128 ++++++++++++++---- 2 files changed, 112 insertions(+), 50 deletions(-) diff --git a/packages/pds/src/api/com/atproto/server/deleteSession.ts b/packages/pds/src/api/com/atproto/server/deleteSession.ts index 22a2055048f..bd3778a0b12 100644 --- a/packages/pds/src/api/com/atproto/server/deleteSession.ts +++ b/packages/pds/src/api/com/atproto/server/deleteSession.ts @@ -1,28 +1,22 @@ -import { AuthScope } from '../../../../auth-verifier' import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import { authPassthru } from '../../../proxy' export default function (server: Server, ctx: AppContext) { - server.com.atproto.server.deleteSession(async ({ req }) => { - if (ctx.entrywayAgent) { - await ctx.entrywayAgent.com.atproto.server.deleteSession( + const { entrywayAgent } = ctx + if (entrywayAgent) { + server.com.atproto.server.deleteSession(async (reqCtx) => { + await entrywayAgent.com.atproto.server.deleteSession( undefined, - authPassthru(req, true), + authPassthru(reqCtx.req, true), ) - return - } - - const result = await ctx.authVerifier.validateBearerToken( - req, - [AuthScope.Refresh], - { clockTolerance: Infinity }, // ignore expiration - ) - const id = result.payload.jti - if (!id) { - throw new Error('Unexpected missing refresh token id') - } - - await ctx.accountManager.revokeRefreshToken(id) - }) + }) + } else { + server.com.atproto.server.deleteSession({ + auth: ctx.authVerifier.refreshExpired, + handler: async ({ auth }) => { + await ctx.accountManager.revokeRefreshToken(auth.credentials.tokenId) + }, + }) + } } diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 826ee99456b..a288c55c105 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -87,6 +87,10 @@ type ValidatedBearer = { audience: string | undefined } +type ValidatedRefreshBearer = ValidatedBearer & { + tokenId: string +} + export type AuthVerifierOpts = { jwtKey: KeyObject adminPass: string @@ -155,24 +159,32 @@ export class AuthVerifier { } refresh = async (ctx: ReqCtx): Promise => { - const { did, scope, token, audience, payload } = - await this.validateBearerToken(ctx.req, [AuthScope.Refresh], { - // when using entryway, proxying refresh credentials - audience: this.dids.entryway ? this.dids.entryway : this.dids.pds, - }) - if (!payload.jti) { - throw new AuthRequiredError( - 'Unexpected missing refresh token id', - 'MissingTokenId', - ) + const { did, scope, token, tokenId, audience } = + await this.validateRefreshToken(ctx.req) + + return { + credentials: { + type: 'refresh', + did, + scope, + audience, + tokenId, + }, + artifacts: token, } + } + + refreshExpired = async (ctx: ReqCtx): Promise => { + const { did, scope, token, tokenId, audience } = + await this.validateRefreshToken(ctx.req, { clockTolerance: Infinity }) + return { credentials: { type: 'refresh', did, scope, audience, - tokenId: payload.jti, + tokenId, }, artifacts: token, } @@ -193,7 +205,7 @@ export class AuthVerifier { optionalAccessOrAdminToken = async ( ctx: ReqCtx, ): Promise => { - if (isBearerToken(ctx.req)) { + if (isAccessToken(ctx.req)) { return await this.access(ctx) } else if (isBasicToken(ctx.req)) { return await this.adminToken(ctx) @@ -262,7 +274,26 @@ export class AuthVerifier { } } - async validateBearerToken( + protected async validateRefreshToken( + req: express.Request, + verifyOptions?: Omit, + ): Promise { + const result = await this.validateBearerToken(req, [AuthScope.Refresh], { + ...verifyOptions, + // when using entryway, proxying refresh credentials + audience: this.dids.entryway ? this.dids.entryway : this.dids.pds, + }) + const tokenId = result.payload.jti + if (!tokenId) { + throw new AuthRequiredError( + 'Unexpected missing refresh token id', + 'MissingTokenId', + ) + } + return { ...result, tokenId } + } + + protected async validateBearerToken( req: express.Request, scopes: AuthScope[], verifyOptions?: jose.JWTVerifyOptions, @@ -271,7 +302,11 @@ export class AuthVerifier { if (!token) { throw new AuthRequiredError(undefined, 'AuthMissing') } - const payload = await verifyJwt({ key: this._jwtKey, token, verifyOptions }) + const { payload } = await verifyJwt({ + key: this._jwtKey, + token, + verifyOptions, + }) const { sub, aud, scope } = payload if (typeof sub !== 'string' || !sub.startsWith('did:')) { throw new InvalidRequestError('Malformed token', 'InvalidToken') @@ -282,6 +317,10 @@ export class AuthVerifier { ) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } + if (payload.cnf) { + // DPoP bound tokens must not be usable as regular Bearer tokens + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } if (!isAuthScope(scope) || (scopes.length > 0 && !scopes.includes(scope))) { throw new InvalidRequestError('Bad token scope', 'InvalidToken') } @@ -297,6 +336,24 @@ export class AuthVerifier { async validateAccessToken( req: express.Request, scopes: AuthScope[], + ): Promise { + const [type] = parseAuthorizationHeader(req.headers.authorization) + switch (type) { + case BEARER: + return this.validateBearerAccessToken(req, scopes) + case null: + throw new AuthRequiredError(undefined, 'AuthMissing') + default: + throw new InvalidRequestError( + 'Unexpected authorization type', + 'InvalidToken', + ) + } + } + + async validateBearerAccessToken( + req: express.Request, + scopes: AuthScope[], ): Promise { const { did, scope, token, audience } = await this.validateBearerToken( req, @@ -374,11 +431,23 @@ export class AuthVerifier { // HELPERS // --------- -const BEARER = 'Bearer ' -const BASIC = 'Basic ' +const BASIC = 'Basic' +const BEARER = 'Bearer' + +export const parseAuthorizationHeader = (authorization?: string) => { + const result = authorization?.split(' ', 2) + if (result?.length === 2) return result as [type: string, token: string] + return [null] as [type: null] +} + +const isAccessToken = (req: express.Request): boolean => { + const [type] = parseAuthorizationHeader(req.headers.authorization) + return type === BEARER +} const isBearerToken = (req: express.Request): boolean => { - return req.headers.authorization?.startsWith(BEARER) ?? false + const [type] = parseAuthorizationHeader(req.headers.authorization) + return type === BEARER } const isBasicToken = (req: express.Request): boolean => { @@ -386,20 +455,18 @@ const isBasicToken = (req: express.Request): boolean => { } const bearerTokenFromReq = (req: express.Request) => { - const header = req.headers.authorization || '' - if (!header.startsWith(BEARER)) return null - return header.slice(BEARER.length) + const [type, token] = parseAuthorizationHeader(req.headers.authorization) + return type === BEARER ? token : null } const verifyJwt = async (params: { key: KeyObject token: string verifyOptions?: jose.JWTVerifyOptions -}): Promise => { +}) => { const { key, token, verifyOptions } = params try { - const result = await jose.jwtVerify(token, key, verifyOptions) - return result.payload + return await jose.jwtVerify(token, key, verifyOptions) } catch (err) { if (err?.['code'] === 'ERR_JWT_EXPIRED') { throw new InvalidRequestError('Token has expired', 'ExpiredToken') @@ -411,17 +478,18 @@ const verifyJwt = async (params: { export const parseBasicAuth = ( token: string, ): { username: string; password: string } | null => { - if (!token.startsWith(BASIC)) return null - const b64 = token.slice(BASIC.length) - let parsed: string[] try { - parsed = ui8.toString(ui8.fromString(b64, 'base64pad'), 'utf8').split(':') + const [type, b64] = parseAuthorizationHeader(token) + if (type !== BASIC) return null + const parsed = ui8 + .toString(ui8.fromString(b64, 'base64pad'), 'utf8') + .split(':', 2) + const [username, password] = parsed + if (!username || !password) return null + return { username, password } } catch (err) { return null } - const [username, password] = parsed - if (!username || !password) return null - return { username, password } } const authScopes = new Set(Object.values(AuthScope)) From 6386333cacf9977c160dc798fef0ba82a33428ec Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 28 Feb 2024 12:41:46 +0100 Subject: [PATCH 003/140] refactor(pds:auth-verifier): add jwtVerify instance method --- packages/pds/src/auth-verifier.ts | 41 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index a288c55c105..b6c7ebbc2b2 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -302,11 +302,9 @@ export class AuthVerifier { if (!token) { throw new AuthRequiredError(undefined, 'AuthMissing') } - const { payload } = await verifyJwt({ - key: this._jwtKey, - token, - verifyOptions, - }) + + const { payload } = await this.jwtVerify(token, verifyOptions) + const { sub, aud, scope } = payload if (typeof sub !== 'string' || !sub.startsWith('did:')) { throw new InvalidRequestError('Malformed token', 'InvalidToken') @@ -426,6 +424,23 @@ export class AuthVerifier { return auth.credentials.did === did } } + + protected async jwtVerify( + token: string, + verifyOptions?: jose.JWTVerifyOptions, + ) { + try { + return await jose.jwtVerify(token, this._jwtKey, verifyOptions) + } catch (err) { + if (err?.['code'] === 'ERR_JWT_EXPIRED') { + throw new InvalidRequestError('Token has expired', 'ExpiredToken') + } + throw new InvalidRequestError( + 'Token could not be verified', + 'InvalidToken', + ) + } + } } // HELPERS @@ -459,22 +474,6 @@ const bearerTokenFromReq = (req: express.Request) => { return type === BEARER ? token : null } -const verifyJwt = async (params: { - key: KeyObject - token: string - verifyOptions?: jose.JWTVerifyOptions -}) => { - const { key, token, verifyOptions } = params - try { - return await jose.jwtVerify(token, key, verifyOptions) - } catch (err) { - if (err?.['code'] === 'ERR_JWT_EXPIRED') { - throw new InvalidRequestError('Token has expired', 'ExpiredToken') - } - throw new InvalidRequestError('Token could not be verified', 'InvalidToken') - } -} - export const parseBasicAuth = ( token: string, ): { username: string; password: string } | null => { From 4af72bcd89358faa811672534ad302b2bd943b6a Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 14 Feb 2024 15:59:27 +0100 Subject: [PATCH 004/140] eat(oauth-provider): initial implementation --- packages/fetch-node/package.json | 40 + packages/fetch-node/src/fetch-wrap.ts | 84 + packages/fetch-node/src/index.ts | 1 + packages/fetch-node/src/ssrf.ts | 49 + packages/fetch-node/tsconfig.json | 8 + packages/fetch/package.json | 37 + packages/fetch/src/fetch-error.ts | 73 + packages/fetch/src/fetch-request.ts | 48 + packages/fetch/src/fetch-response.ts | 150 ++ packages/fetch/src/fetch-wrap.ts | 43 + packages/fetch/src/fetch.ts | 4 + packages/fetch/src/index.ts | 6 + packages/fetch/src/utils.ts | 28 + packages/fetch/tsconfig.json | 8 + packages/html/package.json | 34 + packages/html/src/index.ts | 65 + packages/html/tsconfig.json | 8 + packages/http-util/package.json | 44 + packages/http-util/src/accept.ts | 94 + packages/http-util/src/context.ts | 11 + packages/http-util/src/index.ts | 9 + packages/http-util/src/method.ts | 18 + packages/http-util/src/middleware.ts | 183 ++ packages/http-util/src/parser.ts | 71 + packages/http-util/src/path.ts | 82 + packages/http-util/src/request.ts | 143 ++ packages/http-util/src/response.ts | 133 + packages/http-util/src/route.ts | 42 + packages/http-util/src/router.ts | 113 + packages/http-util/src/server.ts | 194 ++ packages/http-util/src/stream.ts | 76 + packages/http-util/src/types.ts | 22 + packages/http-util/src/url.ts | 23 + packages/http-util/tsconfig.json | 8 + packages/jwk-node/package.json | 38 + packages/jwk-node/src/index.ts | 2 + packages/jwk-node/src/node-key.ts | 127 + packages/jwk-node/src/node-keyset.ts | 16 + packages/jwk-node/tsconfig.json | 8 + packages/jwk/package.json | 39 + packages/jwk/src/alg.ts | 97 + packages/jwk/src/index.ts | 8 + packages/jwk/src/jwk.ts | 153 ++ packages/jwk/src/jwks.ts | 19 + packages/jwk/src/jwt.ts | 172 ++ packages/jwk/src/key.ts | 122 + packages/jwk/src/keyset.ts | 200 ++ packages/jwk/src/types.ts | 22 + packages/jwk/src/util.ts | 59 + packages/jwk/tsconfig.json | 8 + .../oauth-provider-client-fqdn/package.json | 39 + .../oauth-provider-client-fqdn/src/index.ts | 4 + .../src/oauth-client-fqdn-store.ts | 36 + .../oauth-provider-client-fqdn/tsconfig.json | 8 + .../oauth-provider-client-uri/package.json | 44 + .../oauth-provider-client-uri/src/index.ts | 4 + .../src/oauth-client-uri-store.ts | 320 +++ .../oauth-provider-client-uri/src/util.ts | 19 + .../oauth-provider-client-uri/tsconfig.json | 8 + .../oauth-provider-replay-memory/package.json | 36 + .../oauth-provider-replay-memory/src/index.ts | 1 + .../src/oauth-replay-store-memory.ts | 36 + .../tsconfig.json | 8 + .../oauth-provider-replay-redis/package.json | 37 + .../oauth-provider-replay-redis/src/index.ts | 2 + .../src/oauth-replay-store-redis.ts | 41 + .../oauth-provider-replay-redis/tsconfig.json | 8 + packages/oauth-provider/.postcssrc.yml | 3 + packages/oauth-provider/package.json | 75 + packages/oauth-provider/rollup.config.js | 40 + .../src/access-token/access-token-type.ts | 5 + .../src/access-token/access-token.ts | 4 + .../src/account/account-manager.ts | 60 + .../src/account/account-store.ts | 74 + .../oauth-provider/src/account/account.ts | 10 + packages/oauth-provider/src/app/app.tsx | 165 ++ .../oauth-provider/src/app/backend-data.ts | 16 + .../src/app/components/account-list.tsx | 72 + .../src/app/components/authorize.tsx | 92 + .../src/app/components/layout.tsx | 30 + .../src/app/components/login-form.tsx | 114 + .../src/app/components/session-selector.tsx | 46 + .../oauth-provider/src/app/csrf-cookie.ts | 13 + packages/oauth-provider/src/app/main.css | 3 + packages/oauth-provider/src/app/main.tsx | 16 + packages/oauth-provider/src/app/types.ts | 54 + packages/oauth-provider/src/assets/index.ts | 107 + .../oauth-provider/src/client/client-auth.ts | 32 + .../src/client/client-credentials.ts | 43 + .../oauth-provider/src/client/client-data.ts | 8 + .../oauth-provider/src/client/client-id.ts | 4 + .../src/client/client-manager.ts | 329 +++ .../src/client/client-metadata.ts | 101 + .../oauth-provider/src/client/client-store.ts | 26 + .../oauth-provider/src/client/client-utils.ts | 9 + packages/oauth-provider/src/client/client.ts | 220 ++ packages/oauth-provider/src/constants.ts | 59 + .../src/device/device-details.ts | 43 + .../oauth-provider/src/device/device-id.ts | 22 + .../oauth-provider/src/dpop/dpop-manager.ts | 130 + .../oauth-provider/src/dpop/dpop-nonce.ts | 86 + .../src/errors/access-denied-error.ts | 7 + .../account-selection-required-error.ts | 10 + .../src/errors/consent-required-error.ts | 7 + .../invalid-authorization-details-error.ts | 10 + .../src/errors/invalid-client-error.ts | 10 + .../errors/invalid-client-metadata-error.ts | 10 + .../src/errors/invalid-dpop-key-binding.ts | 7 + .../src/errors/invalid-dpop-proof-error.ts | 14 + .../src/errors/invalid-redirect-uri-error.ts | 10 + .../src/errors/invalid-request-error.ts | 7 + .../src/errors/invalid-token-error.ts | 30 + .../src/errors/login-required-error.ts | 7 + .../oauth-provider/src/errors/oauth-error.ts | 28 + .../src/errors/unauthorized-client-error.ts | 7 + .../src/errors/unauthorized-dpop-error.ts | 7 + .../src/errors/unauthorized-error.ts | 21 + .../src/errors/use-dpop-nonce-error.ts | 13 + .../src/errors/www-authenticate-error.ts | 66 + packages/oauth-provider/src/index.ts | 8 + .../src/metadata/build-metadata.ts | 155 ++ packages/oauth-provider/src/oauth-client.ts | 2 + packages/oauth-provider/src/oauth-dpop.ts | 2 + packages/oauth-provider/src/oauth-errors.ts | 19 + packages/oauth-provider/src/oauth-hooks.ts | 9 + packages/oauth-provider/src/oauth-provider.ts | 1281 +++++++++ packages/oauth-provider/src/oauth-store.ts | 11 + packages/oauth-provider/src/oauth-verifier.ts | 184 ++ packages/oauth-provider/src/oidc/claims.ts | 35 + packages/oauth-provider/src/oidc/sub.ts | 4 + packages/oauth-provider/src/oidc/userinfo.ts | 11 + .../src/output/build-error-payload.ts | 140 + .../src/output/send-authorize-page.ts | 140 + .../src/output/send-authorize-redirect.ts | 130 + .../src/output/send-error-page.ts | 58 + .../src/parameters/authorization-details.ts | 17 + .../parameters/authorization-parameters.ts | 147 ++ .../src/parameters/claims-requested.ts | 30 + .../src/parameters/oidc-payload.ts | 25 + .../src/replay/replay-manager.ts | 38 + .../oauth-provider/src/replay/replay-store.ts | 34 + packages/oauth-provider/src/request/code.ts | 22 + .../src/request/request-data.ts | 16 + .../oauth-provider/src/request/request-id.ts | 22 + .../src/request/request-manager.ts | 441 ++++ .../src/request/request-store-memory.ts | 39 + .../src/request/request-store.ts | 36 + .../oauth-provider/src/request/request-uri.ts | 29 + packages/oauth-provider/src/request/types.ts | 47 + .../src/session/session-data.ts | 29 + .../src/session/session-manager.ts | 293 +++ .../src/session/session-store.ts | 36 + .../src/signer/signed-token-payload.ts | 35 + packages/oauth-provider/src/signer/signer.ts | 164 ++ .../oauth-provider/src/token/refresh-token.ts | 30 + .../oauth-provider/src/token/token-claims.ts | 30 + .../oauth-provider/src/token/token-data.ts | 30 + packages/oauth-provider/src/token/token-id.ts | 25 + .../oauth-provider/src/token/token-manager.ts | 617 +++++ .../oauth-provider/src/token/token-store.ts | 76 + .../oauth-provider/src/token/token-type.ts | 15 + packages/oauth-provider/src/token/types.ts | 97 + .../src/token/verify-token-claims.ts | 66 + .../src/util/authorization-header.ts | 21 + packages/oauth-provider/src/util/awaitable.ts | 1 + packages/oauth-provider/src/util/cast.ts | 4 + packages/oauth-provider/src/util/crypto.ts | 27 + packages/oauth-provider/src/util/date.ts | 7 + .../oauth-provider/src/util/redirect-uri.ts | 41 + packages/oauth-provider/src/util/type.ts | 3 + packages/oauth-provider/tailwind.config.js | 8 + packages/oauth-provider/tsconfig.backend.json | 9 + .../oauth-provider/tsconfig.frontend.json | 8 + packages/oauth-provider/tsconfig.json | 8 + packages/oauth-provider/tsconfig.tools.json | 8 + .../package.json | 26 + .../src/index.ts | 76 + .../tsconfig.json | 8 + packages/transformer/package.json | 22 + packages/transformer/src/compose.ts | 60 + packages/transformer/src/index.ts | 2 + packages/transformer/src/transformer.ts | 1 + packages/transformer/tsconfig.json | 8 + pnpm-lock.yaml | 2288 ++++++++++++++--- tsconfig.json | 13 + tsconfig/browser.json | 8 + tsconfig/bundler.json | 11 + tsconfig/nodenext.json | 11 + 188 files changed, 13061 insertions(+), 368 deletions(-) create mode 100644 packages/fetch-node/package.json create mode 100644 packages/fetch-node/src/fetch-wrap.ts create mode 100644 packages/fetch-node/src/index.ts create mode 100644 packages/fetch-node/src/ssrf.ts create mode 100644 packages/fetch-node/tsconfig.json create mode 100644 packages/fetch/package.json create mode 100644 packages/fetch/src/fetch-error.ts create mode 100644 packages/fetch/src/fetch-request.ts create mode 100644 packages/fetch/src/fetch-response.ts create mode 100644 packages/fetch/src/fetch-wrap.ts create mode 100644 packages/fetch/src/fetch.ts create mode 100644 packages/fetch/src/index.ts create mode 100644 packages/fetch/src/utils.ts create mode 100644 packages/fetch/tsconfig.json create mode 100644 packages/html/package.json create mode 100644 packages/html/src/index.ts create mode 100644 packages/html/tsconfig.json create mode 100644 packages/http-util/package.json create mode 100644 packages/http-util/src/accept.ts create mode 100644 packages/http-util/src/context.ts create mode 100644 packages/http-util/src/index.ts create mode 100644 packages/http-util/src/method.ts create mode 100644 packages/http-util/src/middleware.ts create mode 100644 packages/http-util/src/parser.ts create mode 100644 packages/http-util/src/path.ts create mode 100644 packages/http-util/src/request.ts create mode 100644 packages/http-util/src/response.ts create mode 100644 packages/http-util/src/route.ts create mode 100644 packages/http-util/src/router.ts create mode 100644 packages/http-util/src/server.ts create mode 100644 packages/http-util/src/stream.ts create mode 100644 packages/http-util/src/types.ts create mode 100644 packages/http-util/src/url.ts create mode 100644 packages/http-util/tsconfig.json create mode 100644 packages/jwk-node/package.json create mode 100644 packages/jwk-node/src/index.ts create mode 100644 packages/jwk-node/src/node-key.ts create mode 100644 packages/jwk-node/src/node-keyset.ts create mode 100644 packages/jwk-node/tsconfig.json create mode 100644 packages/jwk/package.json create mode 100644 packages/jwk/src/alg.ts create mode 100644 packages/jwk/src/index.ts create mode 100644 packages/jwk/src/jwk.ts create mode 100644 packages/jwk/src/jwks.ts create mode 100644 packages/jwk/src/jwt.ts create mode 100644 packages/jwk/src/key.ts create mode 100644 packages/jwk/src/keyset.ts create mode 100644 packages/jwk/src/types.ts create mode 100644 packages/jwk/src/util.ts create mode 100644 packages/jwk/tsconfig.json create mode 100644 packages/oauth-provider-client-fqdn/package.json create mode 100644 packages/oauth-provider-client-fqdn/src/index.ts create mode 100644 packages/oauth-provider-client-fqdn/src/oauth-client-fqdn-store.ts create mode 100644 packages/oauth-provider-client-fqdn/tsconfig.json create mode 100644 packages/oauth-provider-client-uri/package.json create mode 100644 packages/oauth-provider-client-uri/src/index.ts create mode 100644 packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts create mode 100644 packages/oauth-provider-client-uri/src/util.ts create mode 100644 packages/oauth-provider-client-uri/tsconfig.json create mode 100644 packages/oauth-provider-replay-memory/package.json create mode 100644 packages/oauth-provider-replay-memory/src/index.ts create mode 100644 packages/oauth-provider-replay-memory/src/oauth-replay-store-memory.ts create mode 100644 packages/oauth-provider-replay-memory/tsconfig.json create mode 100644 packages/oauth-provider-replay-redis/package.json create mode 100644 packages/oauth-provider-replay-redis/src/index.ts create mode 100644 packages/oauth-provider-replay-redis/src/oauth-replay-store-redis.ts create mode 100644 packages/oauth-provider-replay-redis/tsconfig.json create mode 100644 packages/oauth-provider/.postcssrc.yml create mode 100644 packages/oauth-provider/package.json create mode 100644 packages/oauth-provider/rollup.config.js create mode 100644 packages/oauth-provider/src/access-token/access-token-type.ts create mode 100644 packages/oauth-provider/src/access-token/access-token.ts create mode 100644 packages/oauth-provider/src/account/account-manager.ts create mode 100644 packages/oauth-provider/src/account/account-store.ts create mode 100644 packages/oauth-provider/src/account/account.ts create mode 100644 packages/oauth-provider/src/app/app.tsx create mode 100644 packages/oauth-provider/src/app/backend-data.ts create mode 100644 packages/oauth-provider/src/app/components/account-list.tsx create mode 100644 packages/oauth-provider/src/app/components/authorize.tsx create mode 100644 packages/oauth-provider/src/app/components/layout.tsx create mode 100644 packages/oauth-provider/src/app/components/login-form.tsx create mode 100644 packages/oauth-provider/src/app/components/session-selector.tsx create mode 100644 packages/oauth-provider/src/app/csrf-cookie.ts create mode 100644 packages/oauth-provider/src/app/main.css create mode 100644 packages/oauth-provider/src/app/main.tsx create mode 100644 packages/oauth-provider/src/app/types.ts create mode 100644 packages/oauth-provider/src/assets/index.ts create mode 100644 packages/oauth-provider/src/client/client-auth.ts create mode 100644 packages/oauth-provider/src/client/client-credentials.ts create mode 100644 packages/oauth-provider/src/client/client-data.ts create mode 100644 packages/oauth-provider/src/client/client-id.ts create mode 100644 packages/oauth-provider/src/client/client-manager.ts create mode 100644 packages/oauth-provider/src/client/client-metadata.ts create mode 100644 packages/oauth-provider/src/client/client-store.ts create mode 100644 packages/oauth-provider/src/client/client-utils.ts create mode 100644 packages/oauth-provider/src/client/client.ts create mode 100644 packages/oauth-provider/src/constants.ts create mode 100644 packages/oauth-provider/src/device/device-details.ts create mode 100644 packages/oauth-provider/src/device/device-id.ts create mode 100644 packages/oauth-provider/src/dpop/dpop-manager.ts create mode 100644 packages/oauth-provider/src/dpop/dpop-nonce.ts create mode 100644 packages/oauth-provider/src/errors/access-denied-error.ts create mode 100644 packages/oauth-provider/src/errors/account-selection-required-error.ts create mode 100644 packages/oauth-provider/src/errors/consent-required-error.ts create mode 100644 packages/oauth-provider/src/errors/invalid-authorization-details-error.ts create mode 100644 packages/oauth-provider/src/errors/invalid-client-error.ts create mode 100644 packages/oauth-provider/src/errors/invalid-client-metadata-error.ts create mode 100644 packages/oauth-provider/src/errors/invalid-dpop-key-binding.ts create mode 100644 packages/oauth-provider/src/errors/invalid-dpop-proof-error.ts create mode 100644 packages/oauth-provider/src/errors/invalid-redirect-uri-error.ts create mode 100644 packages/oauth-provider/src/errors/invalid-request-error.ts create mode 100644 packages/oauth-provider/src/errors/invalid-token-error.ts create mode 100644 packages/oauth-provider/src/errors/login-required-error.ts create mode 100644 packages/oauth-provider/src/errors/oauth-error.ts create mode 100644 packages/oauth-provider/src/errors/unauthorized-client-error.ts create mode 100644 packages/oauth-provider/src/errors/unauthorized-dpop-error.ts create mode 100644 packages/oauth-provider/src/errors/unauthorized-error.ts create mode 100644 packages/oauth-provider/src/errors/use-dpop-nonce-error.ts create mode 100644 packages/oauth-provider/src/errors/www-authenticate-error.ts create mode 100644 packages/oauth-provider/src/index.ts create mode 100644 packages/oauth-provider/src/metadata/build-metadata.ts create mode 100644 packages/oauth-provider/src/oauth-client.ts create mode 100644 packages/oauth-provider/src/oauth-dpop.ts create mode 100644 packages/oauth-provider/src/oauth-errors.ts create mode 100644 packages/oauth-provider/src/oauth-hooks.ts create mode 100644 packages/oauth-provider/src/oauth-provider.ts create mode 100644 packages/oauth-provider/src/oauth-store.ts create mode 100644 packages/oauth-provider/src/oauth-verifier.ts create mode 100644 packages/oauth-provider/src/oidc/claims.ts create mode 100644 packages/oauth-provider/src/oidc/sub.ts create mode 100644 packages/oauth-provider/src/oidc/userinfo.ts create mode 100644 packages/oauth-provider/src/output/build-error-payload.ts create mode 100644 packages/oauth-provider/src/output/send-authorize-page.ts create mode 100644 packages/oauth-provider/src/output/send-authorize-redirect.ts create mode 100644 packages/oauth-provider/src/output/send-error-page.ts create mode 100644 packages/oauth-provider/src/parameters/authorization-details.ts create mode 100644 packages/oauth-provider/src/parameters/authorization-parameters.ts create mode 100644 packages/oauth-provider/src/parameters/claims-requested.ts create mode 100644 packages/oauth-provider/src/parameters/oidc-payload.ts create mode 100644 packages/oauth-provider/src/replay/replay-manager.ts create mode 100644 packages/oauth-provider/src/replay/replay-store.ts create mode 100644 packages/oauth-provider/src/request/code.ts create mode 100644 packages/oauth-provider/src/request/request-data.ts create mode 100644 packages/oauth-provider/src/request/request-id.ts create mode 100644 packages/oauth-provider/src/request/request-manager.ts create mode 100644 packages/oauth-provider/src/request/request-store-memory.ts create mode 100644 packages/oauth-provider/src/request/request-store.ts create mode 100644 packages/oauth-provider/src/request/request-uri.ts create mode 100644 packages/oauth-provider/src/request/types.ts create mode 100644 packages/oauth-provider/src/session/session-data.ts create mode 100644 packages/oauth-provider/src/session/session-manager.ts create mode 100644 packages/oauth-provider/src/session/session-store.ts create mode 100644 packages/oauth-provider/src/signer/signed-token-payload.ts create mode 100644 packages/oauth-provider/src/signer/signer.ts create mode 100644 packages/oauth-provider/src/token/refresh-token.ts create mode 100644 packages/oauth-provider/src/token/token-claims.ts create mode 100644 packages/oauth-provider/src/token/token-data.ts create mode 100644 packages/oauth-provider/src/token/token-id.ts create mode 100644 packages/oauth-provider/src/token/token-manager.ts create mode 100644 packages/oauth-provider/src/token/token-store.ts create mode 100644 packages/oauth-provider/src/token/token-type.ts create mode 100644 packages/oauth-provider/src/token/types.ts create mode 100644 packages/oauth-provider/src/token/verify-token-claims.ts create mode 100644 packages/oauth-provider/src/util/authorization-header.ts create mode 100644 packages/oauth-provider/src/util/awaitable.ts create mode 100644 packages/oauth-provider/src/util/cast.ts create mode 100644 packages/oauth-provider/src/util/crypto.ts create mode 100644 packages/oauth-provider/src/util/date.ts create mode 100644 packages/oauth-provider/src/util/redirect-uri.ts create mode 100644 packages/oauth-provider/src/util/type.ts create mode 100644 packages/oauth-provider/tailwind.config.js create mode 100644 packages/oauth-provider/tsconfig.backend.json create mode 100644 packages/oauth-provider/tsconfig.frontend.json create mode 100644 packages/oauth-provider/tsconfig.json create mode 100644 packages/oauth-provider/tsconfig.tools.json create mode 100644 packages/rollup-plugin-bundle-manifest/package.json create mode 100644 packages/rollup-plugin-bundle-manifest/src/index.ts create mode 100644 packages/rollup-plugin-bundle-manifest/tsconfig.json create mode 100644 packages/transformer/package.json create mode 100644 packages/transformer/src/compose.ts create mode 100644 packages/transformer/src/index.ts create mode 100644 packages/transformer/src/transformer.ts create mode 100644 packages/transformer/tsconfig.json create mode 100644 tsconfig/browser.json create mode 100644 tsconfig/bundler.json create mode 100644 tsconfig/nodenext.json diff --git a/packages/fetch-node/package.json b/packages/fetch-node/package.json new file mode 100644 index 00000000000..89c77cf7f8f --- /dev/null +++ b/packages/fetch-node/package.json @@ -0,0 +1,40 @@ +{ + "name": "@atproto/fetch-node", + "version": "0.0.1", + "license": "MIT", + "description": "SSRF protection for fetch() in Node.js", + "keywords": [ + "atproto", + "fetch", + "node" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/fetch-node" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/fetch": "workspace:*", + "@atproto/transformer": "workspace:*", + "http-errors": "^2.0.0", + "ipaddr.js": "^2.1.0", + "tslib": "^2.6.2" + }, + "devDependencies": { + "@types/http-errors": "^2.0.4", + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/fetch-node/src/fetch-wrap.ts b/packages/fetch-node/src/fetch-wrap.ts new file mode 100644 index 00000000000..5b74296a562 --- /dev/null +++ b/packages/fetch-node/src/fetch-wrap.ts @@ -0,0 +1,84 @@ +import { + Fetch, + fetchMaxSizeProcessor, + forbiddenDomainNameRequestTransform, + protocolCheckRequestTransform, +} from '@atproto/fetch' +import { compose } from '@atproto/transformer' + +import { ssrfSafeHostname } from './ssrf.js' + +export type SafeFetchWrapOptions = NonNullable< + Parameters[0] +> +export const safeFetchWrap = ({ + fetch = globalThis.fetch as Fetch, + responseMaxSize = 512 * 1024, // 512kB + allowHttp = false, + ssrfProtection = true, + forbiddenDomainNames = [ + 'example.com', + 'example.org', + 'example.net', + 'bsky.social', + 'bsky.network', + 'googleusercontent.com', + ] as Iterable, +} = {}): Fetch => + compose( + /** + * Prevent using http:, file: or data: protocols. + */ + protocolCheckRequestTransform(allowHttp ? ['http:', 'https:'] : ['https:']), + + /** + * Disallow fetching from domains we know are not atproto/OIDC client + * implementation. Note that other domains can be blocked by providing a + * custom fetch function combined with another + * forbiddenDomainNameRequestTransform. + */ + forbiddenDomainNameRequestTransform(forbiddenDomainNames), + + /** + * Since we will be fetching from the network based on user provided + * input, we need to make sure that the request is not vulnerable to SSRF + * attacks. + */ + ssrfProtection ? ssrfSafeFetchWrap({ fetch }) : fetch, + + /** + * Since we will be fetching user owned data, we need to make sure that an + * attacker cannot force us to download a large amounts of data. + */ + fetchMaxSizeProcessor(responseMaxSize), + ) + +export type SsrfSafeFetchWrapOptions = NonNullable< + Parameters[0] +> +export const ssrfSafeFetchWrap = ({ + fetch = globalThis.fetch as Fetch, +} = {}): Fetch => { + const ssrfSafeFetch: Fetch = async (request) => { + const { hostname } = new URL(request.url) + + // Make sure the hostname is a valid IP address + const ip = await ssrfSafeHostname(hostname) + if (ip) { + // Normally we would replace the hostname with the IP address and set the + // Host header to the original hostname. However, since we are using + // fetch() we can't set the Host header. + } + + if (request.redirect === 'follow') { + // TODO: actually implement by calling ssrfSafeFetch recursively + throw new Error( + 'Request redirect must be "error" or "manual" when SSRF is enabled', + ) + } + + return fetch(request) + } + + return ssrfSafeFetch +} diff --git a/packages/fetch-node/src/index.ts b/packages/fetch-node/src/index.ts new file mode 100644 index 00000000000..3d1acba67f7 --- /dev/null +++ b/packages/fetch-node/src/index.ts @@ -0,0 +1 @@ +export * from './fetch-wrap.js' diff --git a/packages/fetch-node/src/ssrf.ts b/packages/fetch-node/src/ssrf.ts new file mode 100644 index 00000000000..f9202223296 --- /dev/null +++ b/packages/fetch-node/src/ssrf.ts @@ -0,0 +1,49 @@ +import dns, { promises as dnsPromises } from 'node:dns' + +import createError from 'http-errors' +import ipaddr from 'ipaddr.js' + +const { IPv4, IPv6 } = ipaddr + +export async function ssrfSafeHostname(hostname: string): Promise { + const ip = await hostnameLookup(hostname).catch((cause) => { + throw cause?.code === 'ENOTFOUND' + ? createError(400, `Invalid hostname ${hostname}`, { cause }) + : createError(500, `Unable resolve DNS for ${hostname}`, { cause }) + }) + if (ip.range() !== 'unicast') { + throw createError(400, `Invalid hostname IP address ${ip}`) + } + return ip.toString() +} + +export async function hostnameLookup( + hostname: string, +): Promise { + if (IPv4.isIPv4(hostname)) { + return IPv4.parse(hostname) + } + + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return IPv6.parse(hostname.slice(1, -1)) + } + + return domainLookup(hostname) +} + +export async function domainLookup( + domain: string, +): Promise { + const addr = await dnsPromises.lookup(domain, { + hints: dns.ADDRCONFIG | dns.V4MAPPED, + }) + + const ip = + addr.family === 4 ? IPv4.parse(addr.address) : IPv6.parse(addr.address) + + if (ip instanceof IPv6 && ip.isIPv4MappedAddress()) { + return ip.toIPv4Address() + } + + return ip +} diff --git a/packages/fetch-node/tsconfig.json b/packages/fetch-node/tsconfig.json new file mode 100644 index 00000000000..5c47f3f86ef --- /dev/null +++ b/packages/fetch-node/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/fetch/package.json b/packages/fetch/package.json new file mode 100644 index 00000000000..fbf78930830 --- /dev/null +++ b/packages/fetch/package.json @@ -0,0 +1,37 @@ +{ + "name": "@atproto/fetch", + "version": "0.0.1", + "license": "MIT", + "description": "Isomorphic wrapper utilities for fetch API", + "keywords": [ + "atproto", + "fetch" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/fetch" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/transformer": "workspace:*", + "tslib": "^2.6.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/http-errors": "^2.0.4", + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/fetch/src/fetch-error.ts b/packages/fetch/src/fetch-error.ts new file mode 100644 index 00000000000..e97f8560511 --- /dev/null +++ b/packages/fetch/src/fetch-error.ts @@ -0,0 +1,73 @@ +import { Transformer } from '@atproto/transformer' + +export class FetchError extends Error { + public readonly request?: Request + public readonly response?: Response + + constructor( + public readonly statusCode: number, + message?: string, + { + cause = undefined as unknown, + request = undefined as undefined | Request, + response = undefined as undefined | Response, + } = {}, + ) { + super(message, { cause }) + this.request = request + this.response = response + } + + static from(err: unknown) { + const cause = extractCause(err) + return new FetchError(...extractInfo(cause), { cause }) + } +} + +export const fetchFailureHandler: Transformer = ( + err: unknown, +) => Promise.reject(FetchError.from(err)) + +function extractCause(err: unknown): unknown { + // Unwrap the Network error from undici (i.e. Node's internal fetch() implementation) + // https://github.com/nodejs/undici/blob/3274c975947ce11a08508743df026f73598bfead/lib/web/fetch/index.js#L223-L228 + if ( + err instanceof TypeError && + err.message === 'fetch failed' && + err.cause instanceof Error + ) { + return err.cause + } + + return err +} + +export function extractInfo( + err: unknown, +): [statusCode: number, message: string] { + if (typeof err === 'string' && err.length > 0) { + return [502, err] + } + + if (!(err instanceof Error)) { + return [502, 'Unable to fetch'] + } + + if ('code' in err && typeof err.code === 'string') { + switch (true) { + case err.code === 'ENOTFOUND': + return [404, 'Invalid hostname'] + case err.code === 'ECONNREFUSED': + return [502, 'Connection refused'] + case err.code === 'DEPTH_ZERO_SELF_SIGNED_CERT': + return [502, 'Self-signed certificate'] + case err.code.startsWith('ERR_TLS'): + return [502, 'TLS error'] + case err.code.startsWith('ECONN'): + return [502, 'Connection error'] + } + } + + // Let's assume that other errors are "bad gateway" errors + return [502, err.message] +} diff --git a/packages/fetch/src/fetch-request.ts b/packages/fetch/src/fetch-request.ts new file mode 100644 index 00000000000..0f25ab925ba --- /dev/null +++ b/packages/fetch/src/fetch-request.ts @@ -0,0 +1,48 @@ +import { Transformer } from '@atproto/transformer' + +import { FetchError } from './fetch-error.js' + +export type RequestTranformer = Transformer + +export function protocolCheckRequestTransform( + protocols: Iterable, +): RequestTranformer { + const allowedProtocols = new Set(protocols) + + return async (request) => { + const { protocol } = new URL(request.url) + + if (!allowedProtocols.has(protocol)) { + throw new FetchError(400, `${protocol} is not allowed`, { request }) + } + + return request + } +} + +export function forbiddenDomainNameRequestTransform( + forbiddenDomainNames: Iterable, +): RequestTranformer { + const forbiddenDomainNameSet = new Set(forbiddenDomainNames) + if (forbiddenDomainNameSet.size === 0) return (request) => request + + return async (request) => { + const { hostname } = new URL(request.url) + + // IPv4 + if (hostname.match(/^\d+\.\d+\.\d+\.\d+$/)) { + throw new FetchError(400, 'Invalid hostname', { request }) + } + + // IPv6 + if (hostname.startsWith('[') || hostname.endsWith(']')) { + throw new FetchError(400, 'Invalid hostname', { request }) + } + + if (forbiddenDomainNameSet.has(hostname)) { + throw new FetchError(403, 'Forbidden hostname', { request }) + } + + return request + } +} diff --git a/packages/fetch/src/fetch-response.ts b/packages/fetch/src/fetch-response.ts new file mode 100644 index 00000000000..3264d113cb7 --- /dev/null +++ b/packages/fetch/src/fetch-response.ts @@ -0,0 +1,150 @@ +import { Transformer, compose } from '@atproto/transformer' +import { z } from 'zod' + +import { FetchError } from './fetch-error.js' +import { overrideResponseBody } from './utils.js' + +export type ResponseTranformer = Transformer + +export function fetchOkProcessor(): ResponseTranformer { + return async (response) => { + if (!response.ok) { + throw new FetchError(response.status, response.statusText, { response }) + } + + return response + } +} + +export function fetchMaxSizeProcessor(maxBytes: number): ResponseTranformer { + if (!(maxBytes >= 0)) throw new TypeError('maxBytes must be >= 0') + if (maxBytes === Infinity) return (response) => response + + return async (response) => { + if (!response.body) return response + + const contentLength = response.headers.get('content-length') + if (contentLength) { + const length = Number(contentLength) + if (!(length < maxBytes)) { + const err = new FetchError(502, 'Response too large', { response }) + await response.body.cancel(err) + throw err + } + } + + let bytesRead = 0 + + // @ts-ignore - @types/node does not have ReadableStream as global + const newBody: ReadableStream = response.body.pipeThrough( + // @ts-ignore - @types/node does not have TransformStream as global + new TransformStream({ + transform: ( + chunk: Uint8Array, + // @ts-ignore - @types/node does not have TransformStreamDefaultController as global + ctrl: TransformStreamDefaultController, + ) => { + if ((bytesRead += chunk.length) <= maxBytes) { + ctrl.enqueue(chunk) + } else { + ctrl.error(new FetchError(502, 'Response too large', { response })) + } + }, + }), + ) + + return overrideResponseBody(response, newBody) + } +} + +export type ContentTypeCheckFn = (contentType: string) => boolean +export type ContentTypeCheck = string | RegExp | ContentTypeCheckFn + +export function fetchTypeProcessor( + expectedType: ContentTypeCheck, + contentTypeRequired = true, +): ResponseTranformer { + const isExpected: ContentTypeCheckFn = + typeof expectedType === 'string' + ? (ct) => ct === expectedType + : expectedType instanceof RegExp + ? (ct) => expectedType.test(ct) + : expectedType + + return async (response) => { + const contentType = response.headers + .get('content-type') + ?.split(';')[0]! + .trim() + + if (contentType) { + if (!isExpected(contentType)) { + throw new FetchError( + 502, + `Unexpected response Content-Type (${contentType})`, + { + response, + }, + ) + } + } else if (contentTypeRequired) { + throw new FetchError(502, 'Missing response Content-Type header', { + response, + }) + } + + return response + } +} + +type ParsedJsonResponse = { + status: number + headers: Headers + body: Body +} + +export async function jsonTranformer( + response: Response, +): Promise> { + return response + .json() + .then((body) => ({ + status: response.status, + headers: response.headers, + body: body as Body, + })) + .catch((err) => { + throw new FetchError(502, err, { response }) + }) +} + +export function fetchJsonProcessor( + contentType: ContentTypeCheck = /^application\/(?:[^+]+\+)?json$/, + contentTypeRequired = true, +): Transformer> { + return compose( + fetchTypeProcessor(contentType, contentTypeRequired), + jsonTranformer, + ) +} + +export function fetchZodProcessor( + schema: S, + params?: Partial, +): Transformer> { + return async ({ + body, + ...rest + }: ParsedJsonResponse): Promise>> => ({ + body: schema.parseAsync(body, params), + ...rest, + }) +} + +export function fetchZodBodyProcessor( + schema: S, + params?: Partial, +): Transformer> { + return async (jsonResponse: ParsedJsonResponse): Promise> => + schema.parseAsync(jsonResponse.body, params) +} diff --git a/packages/fetch/src/fetch-wrap.ts b/packages/fetch/src/fetch-wrap.ts new file mode 100644 index 00000000000..bb077986292 --- /dev/null +++ b/packages/fetch/src/fetch-wrap.ts @@ -0,0 +1,43 @@ +import { Fetch } from './fetch.js' + +export const loggedFetchWrap = + ({ fetch = globalThis.fetch as Fetch, prefix = '' } = {}): Fetch => + async (request) => { + await logRequest(request, prefix) + try { + const response = await fetch(request) + await logResponse(response, prefix) + return response + } catch (error) { + await logError(error, prefix) + throw error + } + } + +const logRequest = async (request: Request, prefix = '') => + console.info( + `${prefix}> ${request.method} ${request.url}\n` + + stringifyPayload(request.headers, await request.clone().text()), + ) + +const logResponse = async (response: Response, prefix = '') => + console.info( + `${prefix}< HTTP/1.1 ${response.status} ${response.statusText}\n` + + stringifyPayload(response.headers, await response.clone().text()), + ) + +const logError = async (error: unknown, prefix = '') => + console.error(`${prefix} error:`, error) + +const stringifyPayload = (headers: Headers, body: string) => + [stringifyHeaders(headers), stringifyBody(body)] + .filter(Boolean) + .join('\n ') + '\n ' + +const stringifyHeaders = (headers: Headers) => + Array.from(headers) + .map(([name, value]) => ` ${name}: ${value}\n`) + .join('') + +const stringifyBody = (body: string) => + body ? `\n ${body.replace(/\r?\n/g, '\\n')}` : '' diff --git a/packages/fetch/src/fetch.ts b/packages/fetch/src/fetch.ts new file mode 100644 index 00000000000..204a20ccdda --- /dev/null +++ b/packages/fetch/src/fetch.ts @@ -0,0 +1,4 @@ +export type Fetch = ( + this: void | null | typeof globalThis, + input: Request, +) => Promise diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts new file mode 100644 index 00000000000..5d6dc2b2f6e --- /dev/null +++ b/packages/fetch/src/index.ts @@ -0,0 +1,6 @@ +export * from './fetch-error.js' +export * from './fetch-request.js' +export * from './fetch-response.js' +export * from './fetch-wrap.js' +export * from './fetch.js' +export * from './utils.js' diff --git a/packages/fetch/src/utils.ts b/packages/fetch/src/utils.ts new file mode 100644 index 00000000000..1e074a96c13 --- /dev/null +++ b/packages/fetch/src/utils.ts @@ -0,0 +1,28 @@ +// BodyInit not made available by @types/node +export type BodyInit = Exclude< + ConstructorParameters[0], + undefined +> + +export function overrideResponseBody( + response: Response, + body: BodyInit, +): Response { + const newResponse = new Response(body, response) + + /** + * Some props do not get copied by the Response constructor (e.g. url) + */ + for (const key of ['url', 'redirected', 'type', 'statusText'] as const) { + const value = response[key] + if (value !== newResponse[key]) { + Object.defineProperty(newResponse, key, { + get: () => value, + enumerable: true, + configurable: true, + }) + } + } + + return newResponse +} diff --git a/packages/fetch/tsconfig.json b/packages/fetch/tsconfig.json new file mode 100644 index 00000000000..74b7e3462ca --- /dev/null +++ b/packages/fetch/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/html/package.json b/packages/html/package.json new file mode 100644 index 00000000000..9d5d6edac12 --- /dev/null +++ b/packages/html/package.json @@ -0,0 +1,34 @@ +{ + "name": "@atproto/html", + "version": "0.0.1", + "license": "MIT", + "description": "Safe HTML encoding", + "keywords": [ + "atproto", + "html" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/html" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "tslib": "^2.6.2" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts new file mode 100644 index 00000000000..69bffb8341c --- /dev/null +++ b/packages/html/src/index.ts @@ -0,0 +1,65 @@ +type NestedArray = V | readonly NestedArray[] + +export function html( + htmlFragment: TemplateStringsArray, + ...values: readonly NestedArray[] +): TrustedHtml { + const fragments: string[] = [] + for (let i = 0; i < htmlFragment.length; i++) { + fragments.push(htmlFragment[i]!) + if (i < values.length) { + fragments.push(...valueToFragment(values[i]!)) + } + } + + return new TrustedHtml(fragments) +} + +html.dangerouslyCreate = (fragment: string) => new TrustedHtml([fragment]) + +html.scriptTag = (fragment: string) => + // "" can only appear in javascript strings, so we can safely escape + // the "<" without breaking the javascript. + new TrustedHtml([fragment.replace(/<\/script>/g, '\\u003c/script>')]) + +/** + * @see {@link https://redux.js.org/usage/server-rendering#security-considerations} + */ +html.jsonForScriptTag = (value: unknown) => + new TrustedHtml([JSON.stringify(value).replace(/, +): Generator { + if (typeof value === 'string') { + yield encode(value) + } else if (value instanceof TrustedHtml) { + yield* value.fragments + } else { + for (const v of value) { + yield* valueToFragment(v) + } + } +} + +const specialCharRegExp = /[<>"'&]/g +const specialCharMap = new Map([ + ['<', '<'], + ['>', '>'], + ['"', '"'], + ["'", '''], + ['&', '&'], +]) +function encode(value: string): string { + return value.replace(specialCharRegExp, (c) => specialCharMap.get(c)!) +} + +class TrustedHtml { + constructor(readonly fragments: readonly string[]) {} + toString() { + return this.fragments.join('') + } + toBuffer() { + return Buffer.concat(this.fragments.map((f) => Buffer.from(f))) + } +} diff --git a/packages/html/tsconfig.json b/packages/html/tsconfig.json new file mode 100644 index 00000000000..5c47f3f86ef --- /dev/null +++ b/packages/html/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/http-util/package.json b/packages/http-util/package.json new file mode 100644 index 00000000000..aa247f80daa --- /dev/null +++ b/packages/http-util/package.json @@ -0,0 +1,44 @@ +{ + "name": "@atproto/http-util", + "version": "0.0.1", + "license": "MIT", + "description": "Lightweight utilities to create request handlers to use with the standard library's `node:http` module and that can be used as middleware with `express`/`connect` like frameworks.", + "keywords": [ + "atproto", + "middleware", + "connect", + "express", + "http" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/http" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@hapi/accept": "^6.0.3", + "@hapi/bourne": "^3.0.0", + "cookie": "^0.6.0", + "http-errors": "^2.0.0", + "tslib": "^2.6.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/cookie": "^0.6.0", + "@types/http-errors": "^2.0.4", + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/http-util/src/accept.ts b/packages/http-util/src/accept.ts new file mode 100644 index 00000000000..4374c2dbd5c --- /dev/null +++ b/packages/http-util/src/accept.ts @@ -0,0 +1,94 @@ +import { mediaType } from '@hapi/accept' + +import { SubCtx, subCtx } from './context.js' +import { + IncomingMessage, + Middleware, + NextFunction, + ServerResponse, +} from './types.js' + +type View< + T, + D, + Req extends IncomingMessage = IncomingMessage, + Res extends ServerResponse = ServerResponse, +> = ( + this: SubCtx, + req: Req, + res: Res, + next: NextFunction, +) => void | PromiseLike + +/** + * + * @example + * ```ts + * import { acceptMiddleware } from '@' + * + * app.use( + * acceptMiddleware( + * async function (req, res) { + * return { hello: 'world' } + * }, + * { + * '': 'application/json', // Fallback to JSON + * 'text/plain': function (req, res) { + * res.writeHead(200).end(this.data.hello) + * }, + * 'application/json': function (req, res) { + * res.writeHead(200).end(JSON.stringify(this.data)) + * } + * } + * ) + * ) + */ +export function acceptMiddleware< + D, + T = void, + Req extends IncomingMessage = IncomingMessage, + Res extends ServerResponse = ServerResponse, +>( + controller: (this: T, req: Req, res: Res) => D | PromiseLike, + views: Record>, + fallback: Middleware = (req, res, _next) => void res.writeHead(406).end(), +): (this: T, req: Req, res: Res, next: NextFunction) => Promise { + const viewsMap = new Map(Object.entries(views)) + const preferences = Array.from(viewsMap.keys()).filter(Boolean) + + // Make sure that every view is either a function or a string that points to a + // function. + for (const type of viewsMap.keys()) { + const view = viewsMap.get(type) + if (typeof view === 'string' && typeof viewsMap.get(view) !== 'function') { + throw new Error(`Invalid view "${view}" for media type "${type}"`) + } + } + + return async function (req, res, next) { + try { + const type = req.headers['accept'] + ? mediaType(req.headers['accept'], preferences) || undefined + : '' // indicate that the client accepts anything + + let view = type != null ? viewsMap.get(type) : undefined + + if (typeof view === 'string') view = viewsMap.get(view) + if (typeof view === 'string') throw new Error('Invalid view') // should not happen + + if (view) { + const data = await controller.call(this, req, res) + const ctx = subCtx(this, 'data', data) + if (type) res.setHeader('Content-Type', type) + + await view.call(ctx, req, res, next) + } else { + // media negotiation failed + await fallback.call(this, req, res, next) + } + } catch (err) { + if (!res.headersSent) res.removeHeader('Content-Type') + next(err) + } + } +} diff --git a/packages/http-util/src/context.ts b/packages/http-util/src/context.ts new file mode 100644 index 00000000000..c66df147aad --- /dev/null +++ b/packages/http-util/src/context.ts @@ -0,0 +1,11 @@ +export type SubCtx = Child & Omit + +export function subCtx( + ctx: T, + key: K, + value: V, +): SubCtx { + return Object.create(typeof ctx === 'object' ? ctx : null, { + [key]: { value, enumerable: true, writable: false }, + }) +} diff --git a/packages/http-util/src/index.ts b/packages/http-util/src/index.ts new file mode 100644 index 00000000000..51d5ec56a84 --- /dev/null +++ b/packages/http-util/src/index.ts @@ -0,0 +1,9 @@ +export * from './accept.js' +export * from './middleware.js' +export * from './parser.js' +export * from './request.js' +export * from './response.js' +export * from './router.js' +export * from './server.js' +export * from './stream.js' +export * from './types.js' diff --git a/packages/http-util/src/method.ts b/packages/http-util/src/method.ts new file mode 100644 index 00000000000..8459bbc77ac --- /dev/null +++ b/packages/http-util/src/method.ts @@ -0,0 +1,18 @@ +import { IncomingMessage } from './types.js' + +export type MethodMatcherInput = string | Iterable | MethodMatcher +export type MethodMatcher = (req: IncomingMessage) => boolean + +export function createMethodMatcher(method: MethodMatcherInput): MethodMatcher { + if (method === '*') return () => true + if (typeof method === 'function') return method + + if (typeof method === 'string') { + method = method.toUpperCase() + return (req) => req.method === method + } + + const set = new Set(Array.from(method, (m) => m.toUpperCase())) + if (set.size === 0) return () => false + return (req) => req.method != null && set.has(req.method) +} diff --git a/packages/http-util/src/middleware.ts b/packages/http-util/src/middleware.ts new file mode 100644 index 00000000000..cb3c0f540a4 --- /dev/null +++ b/packages/http-util/src/middleware.ts @@ -0,0 +1,183 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { writeJson } from './response.js' +import { Middleware, Handler, NextFunction } from './types.js' + +export function combine>( + middlewares: Iterable, + options?: { skipKeyword?: string }, +): M + +/** + * Combine express/connect like middlewares (that can be async) into a single + * middleware. + */ +export function combine( + middlewares: Iterable>, + { skipKeyword }: { skipKeyword?: string } = {}, +): Middleware { + const middlewaresArray = Array.from(middlewares).filter( + (x): x is NonNullable => x != null, + ) + + // Optimization: if there are no middlewares, return a noop middleware. + if (middlewaresArray.length === 0) return (req, res, next) => void next() + + return function (req, res, next) { + let i = 0 + const nextMiddleware = (err?: unknown) => { + if (err) { + if (skipKeyword && err === skipKeyword) next() + else next(err) + } else if (i >= middlewaresArray.length) { + next() + } else { + const currentMiddleware = middlewaresArray[i++]! + const currentNext = once(nextMiddleware) + try { + const result = currentMiddleware.call(this, req, res, currentNext) + Promise.resolve(result).catch(currentNext) + } catch (err) { + currentNext(err) + } + } + } + nextMiddleware() + } +} + +export type AsHandler> = + M extends Middleware + ? Handler + : never + +/** + * Convert a middleware in a function that can be used as both a middleware and + * and handler. + */ +export function asHandler>( + middleware: M, + options?: FinalHandlerOptions, +) { + return function ( + this, + req, + res, + next = once(createFinalHandler(req, res, options)), + ) { + return middleware.call(this, req, res, next) + } as AsHandler +} + +export type FinalHandlerOptions = { + debug?: boolean +} + +export function createFinalHandler( + req: IncomingMessage, + res: ServerResponse, + options?: FinalHandlerOptions, +): NextFunction { + return (err) => { + if (err && (options?.debug ?? process.env['NODE_ENV'] === 'development')) { + console.error(err) + } + + if (res.headersSent) { + // If an error occurred, and headers were sent, we can't know that the + // whole response body was sent. So we can't safely reuse the socket. + if (err) req.socket.destroy() + + return + } + + const { status, ...payload } = buildFallbackPayload(req, err) + + res.setHeader('Content-Security-Policy', "default-src 'none'") + res.setHeader('X-Content-Type-Options', 'nosniff') + + writeJson(res, payload, status) + } +} + +function buildFallbackPayload( + req: IncomingMessage, + err: unknown, +): { + status: number + error: string + error_description: string + stack?: undefined | string +} { + const status = err ? getErrorStatusCode(err) : 404 + const expose = getProp(err, 'expose', 'boolean') ?? status < 500 + + return { + status, + error: err + ? expose + ? getProp(err, 'code', 'string') ?? + getProp(err, 'error', 'string') ?? + 'unknown_error' + : 'system_error' + : 'not_found', + error_description: + err instanceof Error + ? expose + ? getProp(err, 'error_description', 'string') || + String(err.message) || + 'Unknown error' + : 'System error' + : `Cannot ${req.method} ${req.url}`, + stack: + err instanceof Error && process.env['NODE_ENV'] === 'development' + ? err.stack + : undefined, + } +} + +function getErrorStatusCode(err: NonNullable): number { + const status = + getProp(err, 'status', 'number') ?? getProp(err, 'statusCode', 'number') + return status != null && status >= 400 && status < 600 ? status : 500 +} + +export function once(next: T): T { + let nextNullable: T | null = next + return function (err) { + if (!nextNullable) throw new Error('next() called multiple times') + const next = nextNullable + nextNullable = null + return next(err) + } as T +} + +// eslint-disable-next-line +function getProp(obj: unknown, key: string, t: 'function'): Function | undefined +function getProp(obj: unknown, key: string, t: 'string'): string | undefined +function getProp(obj: unknown, key: string, t: 'number'): number | undefined +function getProp(obj: unknown, key: string, t: 'boolean'): boolean | undefined +function getProp(obj: unknown, key: string, t: 'object'): object | undefined +function getProp(obj: unknown, key: string, t: 'symbol'): symbol | undefined +function getProp(obj: unknown, key: string, t: 'bigint'): bigint | undefined +function getProp(obj: unknown, key: string, t: 'undefined'): undefined +function getProp( + obj: unknown, + key: string, + type: + | 'string' + | 'number' + | 'boolean' + | 'object' + | 'function' + | 'symbol' + | 'bigint' + | 'undefined', +): unknown + +function getProp(obj: unknown, key: string, type: string): unknown { + if (obj != null && typeof obj === 'object' && key in obj) { + const value = (obj as Record)[key] + if (typeof value === type) return value + } + return undefined +} diff --git a/packages/http-util/src/parser.ts b/packages/http-util/src/parser.ts new file mode 100644 index 00000000000..60d0bcdb9d4 --- /dev/null +++ b/packages/http-util/src/parser.ts @@ -0,0 +1,71 @@ +import { parse as parseJson } from '@hapi/bourne' +import createHttpError from 'http-errors' + +export type JsonScalar = string | number | boolean | null +export type Json = JsonScalar | Json[] | { [_ in string]?: Json } + +export type Parser = { + readonly name: string + readonly test: (type: unknown) => type is T + readonly parse: (buffer: Buffer) => R +} + +export type ParserName

= P extends { readonly name: infer N } + ? N + : never +export type ParserType

= P extends Parser ? T : never +export type ParserResult

= + P extends Parser ? R : never + +export type ParserForType

= + P extends Parser ? (U extends T ? P : never) : never + +export const parsers = [ + { + name: 'json', + test: ( + type: unknown, + ): type is `application/json` | `application/${string}+json` => { + return ( + typeof type === 'string' && /^application\/(?:.+\+)?json$/.test(type) + ) + }, + parse: (buffer: Buffer): Json => { + try { + return parseJson(buffer.toString()) + } catch (err) { + throw createHttpError(400, 'Invalid JSON', { cause: err }) + } + }, + }, + { + name: 'urlencoded', + test: (type: unknown): type is 'application/x-www-form-urlencoded' => { + return type === 'application/x-www-form-urlencoded' + }, + parse: (buffer: Buffer): Partial> => { + try { + if (!buffer.length) return {} + return Object.fromEntries(new URLSearchParams(buffer.toString())) + } catch (err) { + throw createHttpError(400, 'Invalid URL-encoded data', { cause: err }) + } + }, + }, + { + name: 'bytes', + test: (type: unknown): type is 'application/octet-stream' => { + return type === 'application/octet-stream' + }, + parse: (buffer: Buffer): Buffer | null => (buffer.length ? buffer : null), + }, +] as const + +export type KnownParser = (typeof parsers)[number] + +export type KnownNames = KnownParser['name'] +export type KnownTypes = ParserType + +export type TypeToParserMap

= { + [T in ParserType

]: ParserResult> +} diff --git a/packages/http-util/src/path.ts b/packages/http-util/src/path.ts new file mode 100644 index 00000000000..e4697422f23 --- /dev/null +++ b/packages/http-util/src/path.ts @@ -0,0 +1,82 @@ +export type PathMatcher

= (pathname: string) => P | undefined + +type StringPath

= string extends keyof P + ? `/${string}` + : keyof P extends never + ? `/${string}` | `` + : { + [K in keyof P]: K extends string + ? + | `${`/:${K}` | `/${string}/:${K}`}${StringPath>}` + | `${StringPath>}${`/:${K}` | `/:${K}/${string}`}` + : never + }[keyof P] + +export type Path

= + | string + | StringPath

+ | RegExp + | PathMatcher

+export type Params = Record + +export function createPathMatcher

( + refPath: Path

, +): PathMatcher

{ + if (typeof refPath === 'string') { + // Create a path matcher for a path with parameters (like /foo/:fooId/bar/:barId). + if (refPath.includes('/:')) { + const refParts = refPath + .slice(1) + .split('/') + .map((part, i) => [part, i] as const) + const refPartsLength = refParts.length + + const staticParts = refParts.filter(([p]) => !p.startsWith(':')) + const paramParts = refParts + // Extract parameters, ignoring those with no name (like /foo/:/bar). + .filter(([p]) => p.startsWith(':') && p.length > 1) + .map(([p, i]) => [p.slice(1), i] as const) + + return (reqPath: string) => { + const reqParts = reqPath.slice(1).split('/') + + if (reqParts.length !== refPartsLength) return undefined + + // Make sure all static parts match. + for (let i = 0; i < staticParts.length; i++) { + const value = staticParts[i]![0] + const idx = staticParts[i]![1] + + if (value !== reqParts[idx]) return undefined + } + + // Then extract the parameters. + const params: Record = {} + for (let i = 0; i < paramParts.length; i++) { + const name = paramParts[i]![0] + const idx = paramParts[i]![1] + + const value = reqParts[idx] + + // Empty parameter values are not allowed. + if (!value) return undefined + + params[name] = value + } + + return params as P + } + } + + return (reqPath: string) => (reqPath === refPath ? ({} as P) : undefined) + } + + if (refPath instanceof RegExp) { + return (reqPath: string) => { + const match = reqPath.match(refPath) + return match ? ((match.groups || {}) as P) : undefined + } + } + + return refPath +} diff --git a/packages/http-util/src/request.ts b/packages/http-util/src/request.ts new file mode 100644 index 00000000000..f9af3a89dd8 --- /dev/null +++ b/packages/http-util/src/request.ts @@ -0,0 +1,143 @@ +import { parse as parseCookie, serialize as serializeCookie } from 'cookie' +import { randomBytes } from 'crypto' +import createHttpError from 'http-errors' +import { z } from 'zod' + +import { KnownNames } from './parser.js' +import { appendHeader } from './response.js' +import { decodeStream, parseStream } from './stream.js' +import { IncomingMessage, ServerResponse } from './types.js' +import { UrlReference, urlMatch } from './url.js' + +export function parseRequestPayload< + A extends readonly KnownNames[] = readonly KnownNames[], +>(req: IncomingMessage, allow?: A) { + return parseStream( + decodeStream(req, req.headers['content-encoding']), + req.headers['content-type'], + allow, + ) +} + +export async function validateRequestPayload< + S extends z.ZodTypeAny, + A extends readonly KnownNames[] = ['json', 'urlencoded'], +>(req: IncomingMessage, schema: S, allow?: A): Promise> { + const payload = await parseRequestPayload( + req, + (allow || ['json', 'urlencoded']) as A, + ) + return schema.parseAsync(payload, { path: ['body'] }) +} + +export function validateFetchMode( + req: IncomingMessage, + res: ServerResponse, + expectedMode: readonly ( + | null + | 'navigate' + | 'same-origin' + | 'no-cors' + | 'cors' + )[], +) { + const reqMode = req.headers['sec-fetch-mode'] ?? null + + if (Array.isArray(reqMode)) { + throw createHttpError(400, `Invalid sec-fetch-mode header`) + } + + if (!(expectedMode as (string | null)[]).includes(reqMode)) { + throw createHttpError( + 403, + reqMode + ? `Forbidden sec-fetch-mode "${reqMode}" (expected ${expectedMode})` + : `Missing sec-fetch-mode (expected ${expectedMode})`, + ) + } +} + +export function validateReferer( + req: IncomingMessage, + res: ServerResponse, + reference: UrlReference, + allowNull = false, +) { + const referer = req.headers['referer'] + const refererUrl = referer ? new URL(referer) : null + if (refererUrl ? !urlMatch(refererUrl, reference) : !allowNull) { + throw createHttpError(403, `Invalid referer ${referer}`) + } +} + +export async function setupCsrfToken( + req: IncomingMessage, + res: ServerResponse, + cookieName = 'csrf_token', +) { + const csrfToken = randomBytes(8).toString('hex') + appendHeader( + res, + 'Set-Cookie', + serializeCookie(cookieName, csrfToken, { + secure: true, + httpOnly: false, + sameSite: 'lax', + path: req.url?.split('?', 1)[0] || '/', + }), + ) +} + +// CORS ensure not cross origin +export function validateSameOrigin( + req: IncomingMessage, + res: ServerResponse, + origin: string, + allowNull = true, +) { + const reqOrigin = req.headers['origin'] + if (reqOrigin ? reqOrigin !== origin : !allowNull) { + throw createHttpError(403, `Invalid origin ${reqOrigin}`) + } +} + +export function validateCsrfToken( + req: IncomingMessage, + res: ServerResponse, + csrfToken: string, + cookieName = 'csrf_token', + clearCookie = false, +) { + const cookies = parseHttpCookies(req) + if ( + !csrfToken || + !cookies || + !cookieName || + cookies[cookieName] !== csrfToken + ) { + throw createHttpError(403, `Invalid CSRF token`) + } + + if (clearCookie) { + appendHeader( + res, + 'Set-Cookie', + serializeCookie(cookieName, '', { + secure: true, + httpOnly: false, + sameSite: 'lax', + maxAge: 0, + }), + ) + } +} + +export function parseHttpCookies( + req: IncomingMessage, +): null | Record { + return 'cookies' in req && req.cookies // Already parsed by another middleware + ? (req.cookies as any) + : req.headers['cookie'] + ? ((req as any).cookies = parseCookie(req.headers['cookie'])) + : null +} diff --git a/packages/http-util/src/response.ts b/packages/http-util/src/response.ts new file mode 100644 index 00000000000..6c9585dfea2 --- /dev/null +++ b/packages/http-util/src/response.ts @@ -0,0 +1,133 @@ +import { PassThrough, Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { constants, createBrotliCompress, createGzip } from 'node:zlib' + +import { Handler, ServerResponse } from './types.js' + +export function appendHeader( + res: ServerResponse, + header: string, + value: string | readonly string[], +): void { + const existing = res.getHeader(header) + if (existing == null) { + res.setHeader(header, value) + } else { + const arr = Array.isArray(existing) ? existing : [String(existing)] + res.setHeader(header, arr.concat(value)) + } +} + +export function writeRedirect( + res: ServerResponse, + url: string, + status = 302, +): void { + res.writeHead(status, { Location: url }).end() +} + +function negotiateEncoding(accept?: string | string[]) { + if (accept?.includes('br')) return 'br' + if (accept?.includes('gzip')) return 'gzip' + return 'identity' +} + +function getEncoder(encoding: string): Transform { + switch (encoding) { + case 'br': + return createBrotliCompress({ + // Default quality is too slow + params: { [constants.BROTLI_PARAM_QUALITY]: 5 }, + }) + case 'gzip': + return createGzip() + case 'identity': + return new PassThrough() + default: + throw new Error(`Unsupported encoding: ${encoding}`) + } +} + +const ifString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined + +export async function writeStream( + res: ServerResponse, + stream: Readable, + contentType = ifString((stream as any).headers?.['content-type']) || + 'application/octet-stream', + status = 200, +): Promise { + res.statusCode = status + res.setHeader('content-type', contentType) + appendHeader(res, 'vary', 'accept-encoding') + + const encoding = negotiateEncoding(res.req.headers['accept-encoding']) + + res.setHeader('content-encoding', encoding) + res.setHeader('transfer-encoding', 'chunked') + + if (res.req.method === 'HEAD') { + res.end() + stream.destroy() + return + } + + try { + await pipeline(stream, getEncoder(encoding), res) + } catch (err) { + // Prevent the socket from being left open in a bad state + res.socket?.destroy() + + if (err != null && typeof err === 'object') { + // If an abort signal is used, we can consider this function's job successful + if ('name' in err && err.name === 'AbortError') return + + // If the client closes the connection, we don't care about the error + if ('code' in err && err.code === 'ERR_STREAM_PREMATURE_CLOSE') return + } + + throw err + } +} + +export async function writeBuffer( + res: ServerResponse, + buffer: Buffer, + contentType?: string, + status = 200, +): Promise { + const stream = Readable.from([buffer]) + return writeStream(res, stream, contentType, status) +} + +export async function writeJson( + res: ServerResponse, + payload: unknown, + status = 200, + contentType = 'application/json', +): Promise { + const buffer = Buffer.from(JSON.stringify(payload)) + return writeBuffer(res, buffer, contentType, status) +} + +export function staticJsonHandler( + value: unknown, + contentType = 'application/json', + status = 200, +): Handler { + const buffer = Buffer.from(JSON.stringify(value)) + return function (req, res, next) { + void writeBuffer(res, buffer, contentType, status).catch(next) + } +} + +export async function writeHtml( + res: ServerResponse, + html: Buffer | string, + status = 200, + contentType = 'text/html', +): Promise { + const buffer = Buffer.isBuffer(html) ? html : Buffer.from(html) + return writeBuffer(res, buffer, contentType, status) +} diff --git a/packages/http-util/src/route.ts b/packages/http-util/src/route.ts new file mode 100644 index 00000000000..4e3eb0cf689 --- /dev/null +++ b/packages/http-util/src/route.ts @@ -0,0 +1,42 @@ +import { SubCtx, subCtx } from './context.js' +import { MethodMatcherInput, createMethodMatcher } from './method.js' +import { combine } from './middleware.js' +import { Params, Path, createPathMatcher } from './path.js' +import { IncomingMessage, Middleware, ServerResponse } from './types.js' + +export type RouteCtx = SubCtx }> +export type RouteMiddleware< + T, + P extends Params, + Req = IncomingMessage, + Res = ServerResponse, +> = Middleware, Req, Res> + +export function createRoute< + P extends Params, + T = void, + Req extends IncomingMessage = IncomingMessage, + Res extends ServerResponse = ServerResponse, +>( + method: MethodMatcherInput, + path: Path

, + ...mw: RouteMiddleware[] +): Middleware { + const paramsMatcher = createPathMatcher

(path) + const methodMatcher = createMethodMatcher(method) + + const middleware = combine(mw, { skipKeyword: 'route' }) + + return function (req, res, next) { + if (methodMatcher(req)) { + const pathname = req.url?.split('?')[0] ?? '/' + const params = paramsMatcher(pathname) + if (params) { + const context = subCtx(this, 'params', params) + return middleware.call(context, req, res, next) + } + } + + return next() + } +} diff --git a/packages/http-util/src/router.ts b/packages/http-util/src/router.ts new file mode 100644 index 00000000000..a6bfa71ef7c --- /dev/null +++ b/packages/http-util/src/router.ts @@ -0,0 +1,113 @@ +import { SubCtx, subCtx } from './context.js' +import { MethodMatcherInput } from './method.js' +import { asHandler, combine } from './middleware.js' +import { Params, Path } from './path.js' +import { RouteMiddleware, createRoute } from './route.js' +import { IncomingMessage, Middleware, ServerResponse } from './types.js' + +export type RouterCtx = SubCtx }> +export type RouterMiddleware< + T = void, + Req = IncomingMessage, + Res = ServerResponse, +> = Middleware, Req, Res> + +export class Router< + T = void, + Req extends IncomingMessage = IncomingMessage, + Res extends ServerResponse = ServerResponse, +> { + private readonly middlewares: RouterMiddleware[] = [] + + constructor( + private readonly url?: { + /** Used to build the origin of the {@link RouterCtx['url']} context property */ + protocol?: string + /** Used to build the origin of the {@link RouterCtx['url']} context property */ + host?: string + }, + ) {} + + use(...middlewares: RouterMiddleware[]) { + this.middlewares.push(...middlewares) + return this + } + + all

( + path: Path

, + ...mw: RouteMiddleware, P, Req, Res>[] + ) { + return this.addRoute

('*', path, ...mw) + } + + get

( + path: Path

, + ...mw: RouteMiddleware, P, Req, Res>[] + ) { + return this.addRoute

('GET', path, ...mw) + } + + post

( + path: Path

, + ...mw: RouteMiddleware, P, Req, Res>[] + ) { + return this.addRoute

('POST', path, ...mw) + } + + options

( + path: Path

, + ...mw: RouteMiddleware, P, Req, Res>[] + ) { + return this.addRoute

('OPTIONS', path, ...mw) + } + + addRoute

( + method: MethodMatcherInput, + path: Path

, + ...mw: RouteMiddleware, P, Req, Res>[] + ) { + return this.use(createRoute(method, path, ...mw)) + } + + get handler() { + const routerUrl = this.url + + // Calling next('router') from a middleware will skip all the remaining + // middlewares in the stack. + const middleware = combine(this.middlewares, { skipKeyword: 'router' }) + + return asHandler>(function (this, req, res, next) { + // Make sure that the context contains a "url". This will allow the add() + // method to match routes based on the pathname and will allow routes to + // access the query params (through this.url.searchParams). + let url: URL + + if ( + !routerUrl && + this != null && + typeof this === 'object' && + 'url' in this && + this.url instanceof URL + ) { + // If the context already contains a "url" (router inside router), let's + // use it. + url = this.url + } else { + // Parse the URL using node's URL parser. + try { + const protocol = routerUrl?.protocol || 'https:' + const host = req.headers.host || routerUrl?.host || 'localhost' + const pathname = req.url || '/' + url = new URL(pathname, `${protocol}//${host}`) + } catch (err) { + return next( + Object.assign(err as Error, { status: 400, statusCode: 400 }), + ) + } + } + + const context = subCtx(this, 'url', url) + middleware.call(context, req, res, next) + }) + } +} diff --git a/packages/http-util/src/server.ts b/packages/http-util/src/server.ts new file mode 100644 index 00000000000..a4dd6355c99 --- /dev/null +++ b/packages/http-util/src/server.ts @@ -0,0 +1,194 @@ +import { + IncomingMessage, + Server as HttpServer, + ServerResponse, +} from 'node:http' +import { Server as HttpsServer } from 'node:https' +import { Socket } from 'node:net' + +/** + * Inspired by {@link https://github.com/thedillonb/http-shutdown/blob/master/index.js} + */ +export function createShutdown( + server: HttpServer | HttpsServer, + gracefulTerminationTimeout = 30_000, +) { + const sockets = new Set() + const idleSockets = new WeakSet() + + let terminated = false + + server.on('connection', onConnection) + server.on('secureConnection', onConnection) + server.on('request', onRequest) + + /** + * This function can only be used once. If the server is re-started (using + * listen()) then a new shutdown function must be created. + */ + return async () => { + if (terminated) throw new Error('Server is already terminated') + + terminated = true + + return new Promise((resolve, reject) => { + // Stop accepting new connections + server.close((err) => { + // This callback is called when all existing connections are closed. + + clearTimeout(timer) + + server.off('connection', onConnection) + server.off('secureConnection', onConnection) + server.off('request', onRequest) + server.off('request', onRequestWhenTerminated) + + if (err && (err as any).code !== 'ERR_SERVER_NOT_RUNNING') { + reject(err) + } else { + resolve() + } + }) + + // If any new request is made on an existing connection, make sure the + // connection is closed once the request is done. + server.on('request', onRequestWhenTerminated) + + for (const socket of sockets) { + // Mark requests that are being processed (but not yet sent) to close + // the connection once they are done. + const res = (socket as { _httpMessage?: ServerResponse })._httpMessage + if (res) setConnectionClose(res) + + // Actively close all idle sockets + if (idleSockets.has(socket)) { + destroy(socket) + } + } + + // At this point what remains are sockets linked to requests that are + // being processed. They should be closed once the 'finish' event is + // emitted, or when the timeout is reached. + + const timer = setTimeout(() => { + for (const socket of sockets) { + destroy(socket) + } + }, gracefulTerminationTimeout).unref() + }) + } + + function destroy(socket: Socket) { + socket.destroy() + sockets.delete(socket) + idleSockets.delete(socket) + } + + function onConnection(socket: Socket) { + idleSockets.add(socket) + sockets.add(socket) + + socket.once('close', onSocketClose) + } + + function onSocketClose(this: Socket) { + sockets.delete(this) + idleSockets.delete(this) + } + + function onRequest(req: IncomingMessage, res: ServerResponse) { + idleSockets.delete(req.socket) + + res.once('finish', onResponseFinished) + } + + function onResponseFinished(this: ServerResponse) { + const { req } = this + idleSockets.add(req.socket) + + // If the server is terminated, but the user agent sent several requests + // on the same connection, we want to allow these requests to be processed + // before closing the connection. + if (terminated) { + // We allow a full event loop cycle to run before we check if the + // connection contained any other requests. "setTimeout" brings us to the + // beginning of the next event loop cycle. + setTimeout(() => { + // "setImmediate" brings us after I/O callbacks are processed. + setImmediate(() => { + if (idleSockets.has(req.socket)) destroy(req.socket) + }).unref() + }).unref() + } + } + + function onRequestWhenTerminated(req: IncomingMessage, res: ServerResponse) { + setConnectionClose(res) + } + + function setConnectionClose(res: ServerResponse) { + if (!res.headersSent) { + res.setHeader('connection', 'close') + } + } +} + +export async function startServer( + signal: AbortSignal, + server: S, + ...args: Parameters +) { + if (signal.aborted) return Promise.resolve() + + return new Promise((resolve, reject) => { + server.listen(...args) + + const shutdown = createShutdown(server) + + server.on('listening', onListening) + server.on('error', onError) + + signal.addEventListener('abort', shutdownAndResolve) + + function onListening(this: S) { + server.off('listening', onListening) + server.off('error', onError) + logServerAddress.call(this) + } + + function onError(this: S, err: Error) { + signal.removeEventListener('abort', shutdownAndResolve) + + server.off('listening', onListening) + server.off('error', onError) + + // We call shutdown() even though the server was never listening because + // we want to cleanup any resources that were allocated. + shutdown().then( + () => reject(err), + () => reject(err), // ignore shutdown error if there was a listen error + ) + } + + function shutdownAndResolve() { + signal.removeEventListener('abort', shutdownAndResolve) + server.off('listening', onListening) + server.off('error', onError) + + shutdown().then(resolve, reject) + } + }) +} + +export function logServerAddress(this: HttpServer | HttpsServer) { + const info = this.address() + if (info) { + const protocol = this instanceof HttpServer ? 'http' : 'https' + if (typeof info === 'string') { + console.log(`${protocol} server listening on ${info}`) + } else { + const host = info.family === 'IPv4' ? info.address : `[${info.address}]` + console.log(`server listening on ${protocol}://${host}:${info.port}`) + } + } +} diff --git a/packages/http-util/src/stream.ts b/packages/http-util/src/stream.ts new file mode 100644 index 00000000000..650a745cae7 --- /dev/null +++ b/packages/http-util/src/stream.ts @@ -0,0 +1,76 @@ +import { PassThrough, Readable } from 'node:stream' +import { createGunzip, createInflate } from 'node:zlib' + +import createHttpError from 'http-errors' + +import { + KnownNames, + KnownParser, + KnownTypes, + ParserForType, + ParserResult, + parsers, +} from './parser.js' + +export async function readStream(req: Readable): Promise { + const chunks: Buffer[] = [] + let totalLength = 0 + for await (const chunk of req) { + chunks.push(chunk) + totalLength += chunk.length + } + return Buffer.concat(chunks, totalLength) +} + +export function decodeStream( + req: Readable, + encoding: string = 'identity', +): Readable { + switch (encoding) { + case 'deflate': + return req.compose(createInflate()) + case 'gzip': + return req.compose(createGunzip()) + case 'identity': + return req.compose(new PassThrough()) + default: + throw createHttpError(415, 'Unsupported content-encoding') + } +} + +export async function parseStream< + T extends KnownTypes, + A extends readonly KnownNames[] = readonly KnownNames[], +>( + req: Readable, + contentType: T, + allow?: A, +): Promise>> +export async function parseStream< + A extends readonly KnownNames[] = readonly KnownNames[], +>( + req: Readable, + contentType: unknown, + allow?: A, +): Promise> +export async function parseStream( + req: Readable, + contentType: unknown = 'application/octet-stream', + allow?: string[], +): Promise> { + if (typeof contentType !== 'string') { + throw createHttpError(400, 'Invalid content-type') + } + + const parser = parsers.find( + (parser) => + allow?.includes(parser.name) !== false && parser.test(contentType), + ) + + if (!parser) { + throw createHttpError(400, 'Unsupported content-type') + } + + const buffer = await readStream(req) + return parser.parse(buffer) +} diff --git a/packages/http-util/src/types.ts b/packages/http-util/src/types.ts new file mode 100644 index 00000000000..d3f7cbf1244 --- /dev/null +++ b/packages/http-util/src/types.ts @@ -0,0 +1,22 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +export { IncomingMessage, ServerResponse } + +export type NextFunction = (err?: unknown) => void + +export type Middleware< + T = void, + Req = IncomingMessage, + Res = ServerResponse, +> = ( + this: T, + req: Req, + res: Res, + next: NextFunction, +) => void | PromiseLike + +export type Handler = ( + this: T, + req: Req, + res: Res, + next?: NextFunction, +) => void diff --git a/packages/http-util/src/url.ts b/packages/http-util/src/url.ts new file mode 100644 index 00000000000..d4219a791e4 --- /dev/null +++ b/packages/http-util/src/url.ts @@ -0,0 +1,23 @@ +export type UrlReference = { + origin?: string + pathname?: string + searchParams?: Iterable // compatible with URLSearchParams +} + +export function urlMatch(url: URL, reference: UrlReference) { + if (reference.origin !== undefined) { + if (url.origin !== reference.origin) return false + } + + if (reference.pathname !== undefined) { + if (url.pathname !== reference.pathname) return false + } + + if (reference.searchParams !== undefined) { + for (const [key, value] of reference.searchParams) { + if (url.searchParams.get(key) !== value) return false + } + } + + return true +} diff --git a/packages/http-util/tsconfig.json b/packages/http-util/tsconfig.json new file mode 100644 index 00000000000..5c47f3f86ef --- /dev/null +++ b/packages/http-util/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/jwk-node/package.json b/packages/jwk-node/package.json new file mode 100644 index 00000000000..f6e130c6737 --- /dev/null +++ b/packages/jwk-node/package.json @@ -0,0 +1,38 @@ +{ + "name": "@atproto/jwk-node", + "version": "0.0.1", + "license": "MIT", + "description": "NodeJS implementation of Keypair from @atproto/jwk", + "keywords": [ + "atproto", + "jwk", + "node", + "keypair" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/jwk-node" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/jwk": "workspace:*", + "jose": "^5.2.0", + "tslib": "^2.6.2" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/jwk-node/src/index.ts b/packages/jwk-node/src/index.ts new file mode 100644 index 00000000000..229b29b1078 --- /dev/null +++ b/packages/jwk-node/src/index.ts @@ -0,0 +1,2 @@ +export * from './node-key.js' +export * from './node-keyset.js' diff --git a/packages/jwk-node/src/node-key.ts b/packages/jwk-node/src/node-key.ts new file mode 100644 index 00000000000..96dd314b2f3 --- /dev/null +++ b/packages/jwk-node/src/node-key.ts @@ -0,0 +1,127 @@ +import { KeyObject, createPublicKey } from 'node:crypto' + +import { + Jwk, + Key, + KeyLike, + either, + jwkPubSchema, + jwkSchema, +} from '@atproto/jwk' +import { exportJWK, importJWK, importPKCS8 } from 'jose' + +export type Importable = string | KeyObject | KeyLike | Jwk + +function asPublicJwk( + { kid, use, alg }: { kid?: string; use?: 'sig' | 'enc'; alg?: string }, + publicKey: KeyObject, +) { + const jwk = publicKey.export({ format: 'jwk' }) + + if (use) jwk['use'] = use + if (kid) jwk['kid'] = kid + if (alg) jwk['alg'] = alg + + return jwkPubSchema.parse(jwk) +} + +export class NodeKey extends Key { + static async fromImportable( + input: Importable, + kid: string, + ): Promise { + if (typeof input === 'string') { + // PKCS8 (string) + if (input.startsWith('-----')) { + return this.fromPKCS8(kid, input) + } + + // Jwk (string) + if (input.startsWith('{')) { + return this.fromJWK(input, kid) + } + + throw new TypeError('Invalid input') + } + + if (typeof input === 'object') { + // KeyObject + if (input instanceof KeyObject) { + return this.fromKeyObject(kid, input) + } + + // Jwk + if ( + !(input instanceof Uint8Array) && + ('kty' in input || 'alg' in input) + ) { + return this.fromJWK(input, kid) + } + + // KeyLike + return this.fromJWK(await exportJWK(input), kid) + } + + throw new TypeError('Invalid input') + } + + static async fromPKCS8( + kid: string, + pem: string, + use?: 'sig' | 'enc', + alg?: string, + ): Promise { + const privateKey = await importPKCS8(pem, '', { + extractable: true, + }) + + return this.fromKeyObject(kid, privateKey, use, alg) + } + + static async fromKeyObject( + kid: string, + privateKey: KeyObject, + inputUse?: 'sig' | 'enc', + inputAlg?: string, + ): Promise { + const jwk = jwkSchema.parse(privateKey.export({ format: 'jwk' })) + + const alg = either(jwk.alg, inputAlg) + const use = either(jwk.use, inputUse) || 'sig' + + const privateJwk = { ...jwk, use, kid, alg } + + if (privateKey.asymmetricKeyType) { + const publicKey = createPublicKey(privateKey) + const publicJwk = asPublicJwk(privateJwk, publicKey) + return new NodeKey({ privateJwk, privateKey, publicJwk, publicKey }) + } else { + return new NodeKey({ privateJwk, privateKey }) + } + } + + static async fromJWK( + input: string | Record, + inputKid?: string, + ): Promise { + const jwk = jwkSchema.parse( + typeof input === 'string' ? JSON.parse(input) : input, + ) + + const kid = either(jwk.kid, inputKid) + const alg = jwk.alg + const use = jwk.use || 'sig' + + // @ts-expect-error https://github.com/panva/jose/issues/634 + const privateKey = await importJWK(jwk) + if (!(privateKey instanceof KeyObject)) { + throw new TypeError('Expected an asymmetric key') + } + const privateJwk = { ...jwk, kid, alg, use } + + const publicKey = createPublicKey(privateKey) + const publicJwk = asPublicJwk(privateJwk, publicKey) + + return new NodeKey({ privateJwk, privateKey, publicJwk, publicKey }) + } +} diff --git a/packages/jwk-node/src/node-keyset.ts b/packages/jwk-node/src/node-keyset.ts new file mode 100644 index 00000000000..2704a4735c9 --- /dev/null +++ b/packages/jwk-node/src/node-keyset.ts @@ -0,0 +1,16 @@ +import { Key, Keyset } from '@atproto/jwk' +import { Importable, NodeKey } from './node-key.js' + +export class NodeKeyset extends Keyset { + static async fromImportables( + input: Record, + ) { + return new NodeKeyset( + await Promise.all( + Object.entries(input).map(([kid, secret]) => + secret instanceof Key ? secret : NodeKey.fromImportable(secret, kid), + ), + ), + ) + } +} diff --git a/packages/jwk-node/tsconfig.json b/packages/jwk-node/tsconfig.json new file mode 100644 index 00000000000..6922d4d1578 --- /dev/null +++ b/packages/jwk-node/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/nodenext.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/jwk/package.json b/packages/jwk/package.json new file mode 100644 index 00000000000..49d221a3bbf --- /dev/null +++ b/packages/jwk/package.json @@ -0,0 +1,39 @@ +{ + "name": "@atproto/jwk", + "version": "0.0.1", + "license": "MIT", + "description": "A library for working with JSON Web Keys (JWKs) in TypeScript", + "keywords": [ + "atproto", + "jwk", + "jwks", + "jwt", + "json web key" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/jwk" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "jose": "^5.2.2", + "tslib": "^2.6.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/jwk/src/alg.ts b/packages/jwk/src/alg.ts new file mode 100644 index 00000000000..226a1bb6624 --- /dev/null +++ b/packages/jwk/src/alg.ts @@ -0,0 +1,97 @@ +import { Jwk } from './jwk.js' + +declare const process: undefined | { versions?: { node?: string } } +const IS_NODE_RUNTIME = + typeof process !== 'undefined' && typeof process?.versions?.node === 'string' + +export function* jwkAlgorithms(jwk: Jwk): Generator { + // Ed25519, Ed448, and secp256k1 always have "alg" + // OKP always has "use" + if (jwk.alg) { + yield jwk.alg + return + } + + switch (jwk.kty) { + case 'EC': { + if (jwk.use === 'enc' || jwk.use === undefined) { + yield 'ECDH-ES' + yield 'ECDH-ES+A128KW' + yield 'ECDH-ES+A192KW' + yield 'ECDH-ES+A256KW' + } + + if (jwk.use === 'sig' || jwk.use === undefined) { + const crv = 'crv' in jwk ? jwk.crv : undefined + switch (crv) { + case 'P-256': + case 'P-384': + yield `ES${crv.slice(-3)}`.replace('21', '12') + break + case 'P-521': + yield 'ES512' + break + case 'secp256k1': + if (IS_NODE_RUNTIME) yield 'ES256K' + break + default: + throw new TypeError(`Unsupported crv "${crv}"`) + } + } + + return + } + + case 'OKP': { + if (!jwk.use) throw new TypeError('Missing "use" Parameter value') + yield 'ECDH-ES' + yield 'ECDH-ES+A128KW' + yield 'ECDH-ES+A192KW' + yield 'ECDH-ES+A256KW' + return + } + + case 'RSA': { + if (jwk.use === 'enc' || jwk.use === undefined) { + yield 'RSA-OAEP' + yield 'RSA-OAEP-256' + yield 'RSA-OAEP-384' + yield 'RSA-OAEP-512' + if (IS_NODE_RUNTIME) yield 'RSA1_5' + } + + if (jwk.use === 'sig' || jwk.use === undefined) { + yield 'PS256' + yield 'PS384' + yield 'PS512' + yield 'RS256' + yield 'RS384' + yield 'RS512' + } + + return + } + + case 'oct': { + if (jwk.use === 'enc' || jwk.use === undefined) { + yield 'A128GCMKW' + yield 'A192GCMKW' + yield 'A256GCMKW' + yield 'A128KW' + yield 'A192KW' + yield 'A256KW' + } + + if (jwk.use === 'sig' || jwk.use === undefined) { + yield 'HS256' + yield 'HS384' + yield 'HS512' + } + + return + } + + default: + throw new Error(`Unsupported kty "${jwk.kty}"`) + } +} diff --git a/packages/jwk/src/index.ts b/packages/jwk/src/index.ts new file mode 100644 index 00000000000..1e86d1ad21f --- /dev/null +++ b/packages/jwk/src/index.ts @@ -0,0 +1,8 @@ +export * from './alg.js' +export * from './jwk.js' +export * from './jwks.js' +export * from './jwt.js' +export * from './key.js' +export * from './keyset.js' +export * from './types.js' +export * from './util.js' diff --git a/packages/jwk/src/jwk.ts b/packages/jwk/src/jwk.ts new file mode 100644 index 00000000000..f74a2ef3775 --- /dev/null +++ b/packages/jwk/src/jwk.ts @@ -0,0 +1,153 @@ +import { z } from 'zod' + +export const keyUsageSchema = z.enum([ + 'sign', + 'verify', + 'encrypt', + 'decrypt', + 'wrapKey', + 'unwrapKey', + 'deriveKey', + 'deriveBits', +]) + +export type KeyUsage = z.infer + +/** + * The "use" and "key_ops" JWK members SHOULD NOT be used together; + * however, if both are used, the information they convey MUST be + * consistent. Applications should specify which of these members they + * use, if either is to be used by the application. + * + * @todo Actually check that "use" and "key_ops" are consistent when both are present. + * @see {@link https://datatracker.ietf.org/doc/html/rfc7517#section-4.3} + */ +export const jwkBaseSchema = z.object({ + kty: z.string().min(1), + alg: z.string().min(1).optional(), + kid: z.string().min(1).optional(), + ext: z.boolean().optional(), + use: z.enum(['sig', 'enc']).optional(), + key_ops: z.array(keyUsageSchema).readonly().optional(), + + x5c: z.array(z.string()).readonly().optional(), // X.509 Certificate Chain + x5t: z.string().min(1).optional(), // X.509 Certificate SHA-1 Thumbprint + 'x5t#S256': z.string().min(1).optional(), // X.509 Certificate SHA-256 Thumbprint + x5u: z.string().url().optional(), // X.509 URL +}) + +/** + * @todo: properly implement this + */ +export const jwkRsaKeySchema = jwkBaseSchema + .extend({ + kty: z.literal('RSA'), + alg: z + .enum(['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512']) + .optional(), + + n: z.string().min(1), // Modulus + e: z.string().min(1), // Exponent + + d: z.string().min(1).optional(), // Private Exponent + p: z.string().min(1).optional(), // First Prime Factor + q: z.string().min(1).optional(), // Second Prime Factor + dp: z.string().min(1).optional(), // First Factor CRT Exponent + dq: z.string().min(1).optional(), // Second Factor CRT Exponent + qi: z.string().min(1).optional(), // First CRT Coefficient + oth: z + .array( + z + .object({ + r: z.string().optional(), + d: z.string().optional(), + t: z.string().optional(), + }) + .readonly(), + ) + .nonempty() + .readonly() + .optional(), // Other Primes Info + }) + .readonly() + +export const jwkEcKeySchema = jwkBaseSchema + .extend({ + kty: z.literal('EC'), + alg: z.enum(['ES256', 'ES384', 'ES512']).optional(), + crv: z.enum(['P-256', 'P-384', 'P-521']), + + x: z.string().min(1), + y: z.string().min(1), + + d: z.string().min(1).optional(), // ECC Private Key + }) + .readonly() + +export const jwkEcSecp256k1KeySchema = jwkBaseSchema + .extend({ + kty: z.literal('EC'), + alg: z.enum(['ES256K']).optional(), + crv: z.enum(['secp256k1']), + + x: z.string().min(1), + y: z.string().min(1), + + d: z.string().min(1).optional(), // ECC Private Key + }) + .readonly() + +export const jwkOkpKeySchema = jwkBaseSchema + .extend({ + kty: z.literal('OKP'), + alg: z.enum(['EdDSA']).optional(), + crv: z.enum(['Ed25519', 'Ed448']), + + x: z.string().min(1), + d: z.string().min(1).optional(), // ECC Private Key + }) + .readonly() + +export const jwkSymKeySchema = jwkBaseSchema + .extend({ + kty: z.literal('oct'), // Octet Sequence (used to represent symmetric keys) + alg: z.enum(['HS256', 'HS384', 'HS512']).optional(), + + k: z.string(), // Key Value (base64url encoded) + }) + .readonly() + +export const jwkUnknownKeySchema = jwkBaseSchema + .extend({ + kty: z + .string() + .refine((v) => v !== 'RSA' && v !== 'EC' && v !== 'OKP' && v !== 'oct'), + }) + .readonly() + +export const jwkSchema = z.union([ + jwkUnknownKeySchema, + jwkRsaKeySchema, + jwkEcKeySchema, + jwkEcSecp256k1KeySchema, + jwkOkpKeySchema, + jwkSymKeySchema, +]) + +export type Jwk = z.infer + +export const jwkPubSchema = jwkSchema + .refine((k) => k.kid != null, 'kid is required') + .refine((k) => k.use != null || k.key_ops != null, 'use or key_ops required') + .refine( + (k) => + !k.use || + !k.key_ops || + k.key_ops.every((o) => + k.use === 'sig' + ? o === 'sign' || o === 'verify' + : o === 'encrypt' || o === 'decrypt', + ), + 'use and key_ops must be consistent', + ) + .refine((k) => !('k' in k) && !('d' in k), 'private key not allowed') diff --git a/packages/jwk/src/jwks.ts b/packages/jwk/src/jwks.ts new file mode 100644 index 00000000000..1ec8d382ba8 --- /dev/null +++ b/packages/jwk/src/jwks.ts @@ -0,0 +1,19 @@ +import { z } from 'zod' + +import { jwkPubSchema, jwkSchema } from './jwk.js' + +export const jwksSchema = z + .object({ + keys: z.array(jwkSchema).readonly(), + }) + .readonly() + +export type Jwks = z.infer + +export const jwksPubSchema = z + .object({ + keys: z.array(jwkPubSchema).readonly(), + }) + .readonly() + +export type JwksPub = z.infer diff --git a/packages/jwk/src/jwt.ts b/packages/jwk/src/jwt.ts new file mode 100644 index 00000000000..70073eeea9d --- /dev/null +++ b/packages/jwk/src/jwt.ts @@ -0,0 +1,172 @@ +import { z } from 'zod' + +import { jwkPubSchema } from './jwk.js' + +export const JWT_REGEXP = /^[A-Za-z0-9_-]{2,}(?:\.[A-Za-z0-9_-]{2,}){1,2}$/ +export const jwtSchema = z + .string() + .min(5) + .refinement( + (data: string): data is `${string}.${string}.${string}` => + JWT_REGEXP.test(data), + { + code: z.ZodIssueCode.custom, + message: 'Must be a JWT', + }, + ) + +export const isJwt = (data: unknown): data is Jwt => + jwtSchema.safeParse(data).success + +export type Jwt = z.infer + +/** + * @see {@link https://www.rfc-editor.org/rfc/rfc7515.html#section-4} + */ +export const jwtHeaderSchema = z.object({ + /** "alg" (Algorithm) Header Parameter */ + alg: z.string(), + /** "jku" (JWK Set URL) Header Parameter */ + jku: z.string().url().optional(), + /** "jwk" (JSON Web Key) Header Parameter */ + jwk: z + .object({ + kty: z.string(), + crv: z.string().optional(), + x: z.string().optional(), + y: z.string().optional(), + e: z.string().optional(), + n: z.string().optional(), + }) + .optional(), + /** "kid" (Key ID) Header Parameter */ + kid: z.string().optional(), + /** "x5u" (X.509 URL) Header Parameter */ + x5u: z.string().optional(), + /** "x5c" (X.509 Certificate Chain) Header Parameter */ + x5c: z.array(z.string()).optional(), + /** "x5t" (X.509 Certificate SHA-1 Thumbprint) Header Parameter */ + x5t: z.string().optional(), + /** "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header Parameter */ + 'x5t#S256': z.string().optional(), + /** "typ" (Type) Header Parameter */ + typ: z.string().optional(), + /** "cty" (Content Type) Header Parameter */ + cty: z.string().optional(), + /** "crit" (Critical) Header Parameter */ + crit: z.array(z.string()).optional(), +}) + +export type JwtHeader = z.infer + +// https://www.iana.org/assignments/jwt/jwt.xhtml +export const jwtPayloadSchema = z.object({ + iss: z.string(), + aud: z.union([z.string(), z.array(z.string()).nonempty()]).optional(), + sub: z.string().optional(), + exp: z.number().int().optional(), + nbf: z.number().int().optional(), + iat: z.number().int().optional(), + jti: z.string().optional(), + htm: z.string().optional(), + htu: z.string().optional(), + ath: z.string().optional(), + acr: z.string().optional(), + azp: z.string().optional(), + amr: z.array(z.string()).optional(), + // https://datatracker.ietf.org/doc/html/rfc7800 + cnf: z + .object({ + kid: z.string().optional(), // Key ID + jwk: jwkPubSchema.optional(), // JWK + jwe: z.string().optional(), // Encrypted key + jku: z.string().url().optional(), // JWK Set URI ("kid" should also be provided) + + // https://datatracker.ietf.org/doc/html/rfc9449#section-6.1 + jkt: z.string().optional(), + + // https://datatracker.ietf.org/doc/html/rfc8705 + 'x5t#S256': z.string().optional(), // X.509 Certificate SHA-256 Thumbprint + + // https://datatracker.ietf.org/doc/html/rfc9203 + osc: z.string().optional(), // OSCORE_Input_Material carrying the parameters for using OSCORE per-message security with implicit key confirmation + }) + .optional(), + + client_id: z.string().optional(), + + scope: z.string().optional(), + nonce: z.string().optional(), + + at_hash: z.string().optional(), + c_hash: z.string().optional(), + s_hash: z.string().optional(), + auth_time: z.number().int().optional(), + + // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + + // OpenID: "profile" scope + name: z.string().optional(), + family_name: z.string().optional(), + given_name: z.string().optional(), + middle_name: z.string().optional(), + nickname: z.string().optional(), + preferred_username: z.string().optional(), + gender: z.string().optional(), // OpenID only defines "male" and "female" without forbidding other values + picture: z.string().url().optional(), + profile: z.string().url().optional(), + website: z.string().url().optional(), + birthdate: z + .string() + .regex(/\d{4}-\d{2}-\d{2}/) // YYYY-MM-DD + .optional(), + zoneinfo: z + .string() + .regex(/^[A-Za-z0-9_/]+$/) + .optional(), + locale: z + .string() + .regex(/^[a-z]{2}(-[A-Z]{2})?$/) + .optional(), + updated_at: z.number().int().optional(), + + // OpenID: "email" scope + email: z.string().optional(), + email_verified: z.boolean().optional(), + + // OpenID: "phone" scope + phone_number: z.string().optional(), + phone_number_verified: z.boolean().optional(), + + // OpenID: "address" scope + // https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim + address: z + .object({ + formatted: z.string().optional(), + street_address: z.string().optional(), + locality: z.string().optional(), + region: z.string().optional(), + postal_code: z.string().optional(), + country: z.string().optional(), + }) + .optional(), + + // https://datatracker.ietf.org/doc/html/rfc9396#section-14.2 + authorization_details: z + .array( + z + .object({ + type: z.string(), + // https://datatracker.ietf.org/doc/html/rfc9396#section-2.2 + locations: z.array(z.string()).optional(), + actions: z.array(z.string()).optional(), + datatypes: z.array(z.string()).optional(), + identifier: z.string().optional(), + privileges: z.array(z.string()).optional(), + }) + .passthrough(), + ) + .optional(), +}) + +export type JwtPayload = z.infer diff --git a/packages/jwk/src/key.ts b/packages/jwk/src/key.ts new file mode 100644 index 00000000000..d6b58d7faea --- /dev/null +++ b/packages/jwk/src/key.ts @@ -0,0 +1,122 @@ +import { JWK, importJWK } from 'jose' + +import { jwkAlgorithms } from './alg.js' +import { Jwk, jwkSchema } from './jwk.js' +import { KeyLike } from './types.js' +import { cachedGetter, either } from './util.js' + +export class Key { + readonly privateJwk?: Jwk + readonly privateKey?: KeyLike + + readonly publicJwk?: Jwk + readonly publicKey?: KeyLike + + constructor({ + privateJwk, + privateKey, + publicJwk, + publicKey, + }: + | { + privateJwk: Jwk + privateKey?: KeyLike + publicJwk?: Jwk + publicKey?: KeyLike + } + | { + privateJwk?: Jwk + privateKey?: KeyLike + publicJwk: Jwk + publicKey?: KeyLike + }) { + if (!privateJwk && !publicJwk) + throw new TypeError('At least one of privateJwk or publicJwk is required') + if (privateKey && !privateJwk) + throw new TypeError('privateKey must be used with privateJwk') + if (publicKey && !publicJwk) + throw new TypeError('publicKey must be used with publicJwk') + + this.privateJwk = privateJwk + this.privateKey = privateKey + + this.publicJwk = publicJwk + this.publicKey = publicKey + } + + /** + * A key should always be used either for signing or encryption. + */ + get use() { + const use = either(this.privateJwk?.use, this.publicJwk?.use) + if (!use) throw new TypeError('Missing "use" Parameter value') + return use + } + + /** + * The (forced) algorithm to use. If not provided, the key will be usable with + * any of the algorithms in {@link algorithms}. + */ + get alg() { + return either(this.privateJwk?.alg, this.publicJwk?.alg) + } + + /** + * The key ID. + */ + get kid() { + const kid = either(this.privateJwk?.kid, this.publicJwk?.kid) + if (!kid) throw new TypeError('Missing "kid" Parameter value') + return kid + } + + get crv() { + return either( + (this.privateJwk as undefined | Extract)?.crv, + (this.publicJwk as undefined | Extract)?.crv, + ) + } + + get canVerify() { + return this.use === 'sig' + } + + get canSign() { + return this.use === 'sig' && this.privateJwk != null + } + + /** + * The "bare" public jwk (without `kid`, `use` and `alg`), to use inside a + * "cnf" JWT header. + */ + @cachedGetter + get bareJwk(): Jwk { + const { kty, crv, e, n, x, y } = (this.publicJwk || this.privateJwk) as any + return jwkSchema.parse({ crv, e, kty, n, x, y }) + } + + /** + * All the algorithms that this key can be used with. If `alg` is provided, + * this set will only contain that algorithm. + */ + @cachedGetter + get algorithms(): readonly string[] { + const jwk = this.privateJwk || this.publicJwk + return Array.from(jwk ? jwkAlgorithms(jwk) : []) + } + + signKeyObject() { + if (!this.privateJwk) throw new TypeError('Not a private key') + return this.privateKey || importJWK(this.privateJwk as JWK) + } + + verifyKeyObject() { + return ( + // Use the KeyLike object if it's available + this.publicKey || + this.privateKey || + // Fallback to the JWK + importJWK((this.privateJwk || this.publicJwk)! as JWK) + ) + } +} diff --git a/packages/jwk/src/keyset.ts b/packages/jwk/src/keyset.ts new file mode 100644 index 00000000000..4e9611c5504 --- /dev/null +++ b/packages/jwk/src/keyset.ts @@ -0,0 +1,200 @@ +import { JWTVerifyOptions, SignJWT, jwtVerify } from 'jose' + +import { Jwk } from './jwk.js' +import { Jwks } from './jwks.js' +import { Jwt, JwtHeader, JwtPayload } from './jwt.js' +import { Key } from './key.js' +import { + Override, + RequiredKey, + Simplify, + cachedGetter, + isDefined, + matchesAny, + preferredOrderCmp, +} from './util.js' + +export type { JWTVerifyOptions } + +export type JwtProtectedHeader = RequiredKey +export type JwtPayloadGetter

= ( + protectedHeader: JwtProtectedHeader, + key: Key, +) => P | PromiseLike

+ +export type JwtSignHeader = Override> + +export type JwtVerifyResult

= { + payload: Simplify

+ protectedHeader: JwtProtectedHeader +} + +export type KeySearch = { + use?: 'sig' | 'enc' + kid?: string + alg?: string | string[] +} + +const extractPrivateJwk = (key: Key): Jwk | undefined => key.privateJwk +const extractPublicJwk = (key: Key): Jwk | undefined => key.publicJwk + +export class Keyset implements Iterable { + constructor( + private readonly keys: readonly K[], + /** + * The preferred algorithms to use when signing a JWT using this keyset. + */ + readonly preferredSigningAlgorithms: readonly string[] = [ + 'EdDSA', + 'ES256K', + 'ES256', + // https://datatracker.ietf.org/doc/html/rfc7518#section-3.5 + 'PS256', + 'PS384', + 'PS512', + 'HS256', + 'HS384', + 'HS512', + ], + ) { + if (!keys.length) throw new Error('Keyset is empty') + + const kids = new Set() + for (const key of keys) { + if (kids.has(key.kid)) throw new Error(`Duplicate key id: ${key.kid}`) + else kids.add(key.kid) + } + } + + @cachedGetter + get signAlgorithms(): readonly string[] { + const algorithms = new Set() + for (const key of this) { + if (key.use !== 'sig') continue + for (const alg of key.algorithms) { + algorithms.add(alg) + } + } + return Object.freeze( + [...algorithms].sort(preferredOrderCmp(this.preferredSigningAlgorithms)), + ) + } + + @cachedGetter + get publicJwks(): Jwks { + return { + keys: Array.from(this, extractPublicJwk).filter(isDefined), + } + } + + @cachedGetter + get privateJwks(): Jwks { + return { + keys: Array.from(this, extractPrivateJwk).filter(isDefined), + } + } + + has(kid: string): boolean { + return this.keys.some((key) => key.kid === kid) + } + + get(search: KeySearch): K { + for (const key of this.list(search)) { + return key + } + + throw new TypeError( + `Key not found ${search.kid || search.alg || ''}`, + ) + } + + *list(search: KeySearch): Generator { + for (const key of this) { + if (search.kid && key.kid !== search.kid) continue + if (search.use && key.use !== search.use) continue + if (Array.isArray(search.alg)) { + if (!search.alg.some((a) => key.algorithms.includes(a))) continue + } else if (typeof search.alg === 'string') { + if (!key.algorithms.includes(search.alg)) continue + } + + yield key + } + } + + findSigningKey(search: Omit): [key: Key, alg: string] { + const { kid, alg } = search + const matchingKeys: Key[] = [] + + for (const key of this.list({ kid, alg, use: 'sig' })) { + // Not a signing key + if (!key.canSign) continue + + // Skip negotiation if a specific "alg" was provided + if (typeof alg === 'string') return [key, alg] + + matchingKeys.push(key) + } + + const isAllowedAlg = matchesAny(alg) + const candidates = matchingKeys.map( + (key) => [key, key.algorithms.filter(isAllowedAlg)] as const, + ) + + // Return the first candidates that matches the preferred algorithms + for (const prefAlg of this.preferredSigningAlgorithms) { + for (const [matchingKey, matchingAlgs] of candidates) { + if (matchingAlgs.includes(prefAlg)) return [matchingKey, prefAlg] + } + } + + // Return any candidate + for (const [matchingKey, matchingAlgs] of candidates) { + for (const alg of matchingAlgs) { + return [matchingKey, alg] + } + } + + throw new TypeError(`No singing key found for ${kid || alg || ''}`) + } + + [Symbol.iterator](): IterableIterator { + return this.keys.values() + } + + async sign

( + { alg: searchAlg, kid: searchKid, ...header }: JwtSignHeader, + payload: P | JwtPayloadGetter

, + ): Promise { + const [key, alg] = this.findSigningKey({ alg: searchAlg, kid: searchKid }) + + const protectedHeader: JwtProtectedHeader = { ...header, alg, kid: key.kid } + + const keyObj = await key.signKeyObject() + + if (typeof payload === 'function') { + payload = await payload(protectedHeader, key) + } + + return new SignJWT(payload) + .setProtectedHeader(protectedHeader) + .sign(keyObj) as Promise + } + + async verify

( + token: Jwt, + options?: JWTVerifyOptions, + ): Promise> { + return jwtVerify>( + token, + async ({ kid, alg }) => { + // Ensure that the casting to JwtVerifyResult

is actually safe + if (!kid || !alg) throw new TypeError('Missing "kid" or "alg"') + + const key = this.get({ use: 'sig', kid, alg }) + return key.verifyKeyObject() + }, + options, + ) as Promise> + } +} diff --git a/packages/jwk/src/types.ts b/packages/jwk/src/types.ts new file mode 100644 index 00000000000..0adea8a8198 --- /dev/null +++ b/packages/jwk/src/types.ts @@ -0,0 +1,22 @@ +export type JWSAlgorithm = + // HMAC + | 'HS256' + | 'HS384' + | 'HS512' + // RSA + | 'PS256' + | 'PS384' + | 'PS512' + | 'RS256' + | 'RS384' + | 'RS512' + // EC + | 'ES256' + | 'ES256K' + | 'ES384' + | 'ES512' + // OKP + | 'EdDSA' + +// Runtime specific key representation or secret +export type KeyLike = { type: string } | Uint8Array diff --git a/packages/jwk/src/util.ts b/packages/jwk/src/util.ts new file mode 100644 index 00000000000..550c10e883a --- /dev/null +++ b/packages/jwk/src/util.ts @@ -0,0 +1,59 @@ +// eslint-disable-next-line @typescript-eslint/ban-types +export type Simplify = { [K in keyof T]: T[K] } & {} +export type Override = Simplify> + +export type RequiredKey = Override< + T, + Required> +> + +export const isDefined = (i: T | undefined): i is T => i !== undefined + +export const preferredOrderCmp = + (order: readonly T[]) => + (a: T, b: T) => { + const aIdx = order.indexOf(a) + const bIdx = order.indexOf(b) + if (aIdx === bIdx) return 0 + if (aIdx === -1) return 1 + if (bIdx === -1) return -1 + return aIdx - bIdx + } + +export function matchesAny( + value: null | undefined | T | readonly T[], +): (v: unknown) => v is T { + return value == null + ? (v): v is T => true + : Array.isArray(value) + ? (v): v is T => value.includes(v) + : (v): v is T => v === value +} + +/** + * Decorator to cache the result of a getter on a class instance. + */ +export const cachedGetter = ( + target: (this: T) => V, + _context: ClassGetterDecoratorContext, +) => { + return function (this: T) { + const value = target.call(this) + Object.defineProperty(this, target.name, { + get: () => value, + enumerable: true, + configurable: true, + }) + return value + } +} + +export function either( + a?: T, + b?: T, +): T | undefined { + if (a != null && b != null && a !== b) { + throw new TypeError(`Expected "${b}", got "${a}"`) + } + return a ?? b ?? undefined +} diff --git a/packages/jwk/tsconfig.json b/packages/jwk/tsconfig.json new file mode 100644 index 00000000000..74b7e3462ca --- /dev/null +++ b/packages/jwk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/oauth-provider-client-fqdn/package.json b/packages/oauth-provider-client-fqdn/package.json new file mode 100644 index 00000000000..bed76f83bde --- /dev/null +++ b/packages/oauth-provider-client-fqdn/package.json @@ -0,0 +1,39 @@ +{ + "name": "@atproto/oauth-provider-client-fqdn", + "version": "0.0.1", + "license": "MIT", + "description": "Client Store implementation for @atproto/oauth-provider. This implementation interprets client_id as Fully Qualified Domain Name (FQDN) in order to retrieve Client Metadata.", + "keywords": [ + "atproto", + "oauth", + "provider", + "client", + "store", + "fqdn", + "client_id" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-provider-client-fqdn" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/oauth-provider-client-uri": "workspace:*", + "@atproto/oauth-provider": "workspace:*", + "tslib": "^2.6.2" + }, + "devDependencies": {}, + "scripts": { + "build": "tsc" + } +} diff --git a/packages/oauth-provider-client-fqdn/src/index.ts b/packages/oauth-provider-client-fqdn/src/index.ts new file mode 100644 index 00000000000..5f8960dab2c --- /dev/null +++ b/packages/oauth-provider-client-fqdn/src/index.ts @@ -0,0 +1,4 @@ +export { + OAuthClientFQDNStore, + type OAuthClientFQDNStoreConfig, +} from './oauth-client-fqdn-store.js' diff --git a/packages/oauth-provider-client-fqdn/src/oauth-client-fqdn-store.ts b/packages/oauth-provider-client-fqdn/src/oauth-client-fqdn-store.ts new file mode 100644 index 00000000000..74032f28c4d --- /dev/null +++ b/packages/oauth-provider-client-fqdn/src/oauth-client-fqdn-store.ts @@ -0,0 +1,36 @@ +import { + ClientId, + ClientStore, + InvalidClientMetadataError, +} from '@atproto/oauth-provider' +import { + OAuthClientUriStore, + OAuthClientUriStoreConfig, +} from '@atproto/oauth-provider-client-uri' + +/** + * @see {@link https://regexr.com/3g5j0} + */ +const FQDN_REGEXP = + /^(?!:\/\/)(?=.{1,255}$)((.{1,63}\.){1,127}(?![0-9]*$)[a-z0-9-]+\.?)$/ + +export type OAuthClientFQDNStoreConfig = OAuthClientUriStoreConfig + +export class OAuthClientFQDNStore + extends OAuthClientUriStore + implements ClientStore +{ + override async buildClientUrl(clientId: ClientId): Promise { + if (clientId === 'localhost') { + return super.buildClientUrl('http://localhost/') + } + + if (!clientId.endsWith('.') && FQDN_REGEXP.test(clientId)) { + return super.buildClientUrl(`https://${clientId}/`) + } + + throw new InvalidClientMetadataError( + `ClientID must be a fully qualified domain name (FQDN) or "localhost"`, + ) + } +} diff --git a/packages/oauth-provider-client-fqdn/tsconfig.json b/packages/oauth-provider-client-fqdn/tsconfig.json new file mode 100644 index 00000000000..5c47f3f86ef --- /dev/null +++ b/packages/oauth-provider-client-fqdn/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/oauth-provider-client-uri/package.json b/packages/oauth-provider-client-uri/package.json new file mode 100644 index 00000000000..e974964604d --- /dev/null +++ b/packages/oauth-provider-client-uri/package.json @@ -0,0 +1,44 @@ +{ + "name": "@atproto/oauth-provider-client-uri", + "version": "0.0.1", + "license": "MIT", + "description": "Client Store implementation for @atproto/oauth-provider. This implementation interprets client_id as a URI and uses the URI to retrieve Client Metadata.", + "keywords": [ + "atproto", + "oauth", + "provider", + "client", + "store", + "client_uri", + "client_id" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-provider-client-uri" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/fetch": "workspace:*", + "@atproto/jwk": "workspace:*", + "@atproto/oauth-provider": "workspace:*", + "@atproto/transformer": "workspace:*", + "psl": "^1.9.0", + "tslib": "^2.6.2" + }, + "devDependencies": { + "@types/psl": "^1.1.3" + }, + "scripts": { + "build": "tsc" + } +} diff --git a/packages/oauth-provider-client-uri/src/index.ts b/packages/oauth-provider-client-uri/src/index.ts new file mode 100644 index 00000000000..f0ec8afe5f0 --- /dev/null +++ b/packages/oauth-provider-client-uri/src/index.ts @@ -0,0 +1,4 @@ +export { + OAuthClientUriStore, + type OAuthClientUriStoreConfig, +} from './oauth-client-uri-store.js' diff --git a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts new file mode 100644 index 00000000000..3815f48f591 --- /dev/null +++ b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts @@ -0,0 +1,320 @@ +import { + Fetch, + fetchFailureHandler, + fetchJsonProcessor, + fetchOkProcessor, + fetchZodBodyProcessor, +} from '@atproto/fetch' +import { Jwks, jwksSchema } from '@atproto/jwk' +import { + Awaitable, + ClientData, + ClientId, + ClientMetadata, + ClientStore, + InvalidClientMetadataError, + InvalidRedirectUriError, + clientMetadataSchema, + parseRedirectUri, +} from '@atproto/oauth-provider' +import { compose } from '@atproto/transformer' + +import { buildWellknownUrl, isInternetHost, isLoopbackHost } from './util.js' + +const metadataTransformer = compose( + fetchOkProcessor(), + fetchJsonProcessor('application/json', false), + fetchZodBodyProcessor(clientMetadataSchema), +) + +const responseToJwksTransformer = compose( + fetchOkProcessor(), + fetchJsonProcessor('application/json', false), + fetchZodBodyProcessor(jwksSchema), +) + +export type LoopbackMetadataGetter = ( + url: URL, +) => Awaitable> +export type ClientMetadataValidator = ( + clientId: ClientId, + clientUrl: URL, + metadata: ClientMetadata, +) => Awaitable + +export type OAuthClientUriStoreConfig = { + /** + * In prod, it can be useful to enable SSRF & other kinds of protections. + * This can be done by providing a custom fetch function that enforces + * these protections. If you want to disable all protections, you can + * provide `globalThis.fetch` as fetch function. + */ + fetch: Fetch + + /** + * In order to enable loopback clients, you can provide a function that + * returns the client metadata for a given loopback URL. This is useful for + * development and testing purposes. This function is not called for internet + * clients. + */ + loopbackMetadata?: null | false | LoopbackMetadataGetter + + /** + * A custom function to validate the client metadata. This is useful for + * enforcing custom rules on the client metadata. This function is called for + * both loopback and internet clients. + */ + validateMetadata?: null | false | ClientMetadataValidator +} + +/** + * This class is responsible for fetching client data based on it's ID. Since + * clients are not pre-registered, we need to fetch their data from the network. + */ +export class OAuthClientUriStore implements ClientStore { + protected readonly fetch: Fetch + protected readonly loopbackMetadata?: LoopbackMetadataGetter + protected readonly validateMetadataCustom?: ClientMetadataValidator + + constructor({ + fetch, + loopbackMetadata, + validateMetadata, + }: OAuthClientUriStoreConfig) { + this.fetch = fetch + + this.loopbackMetadata = loopbackMetadata || undefined + this.validateMetadataCustom = validateMetadata || undefined + } + + public async findClient(clientId: ClientId): Promise { + const clientUrl = await this.buildClientUrl(clientId) + + if (isLoopbackHost(clientUrl.hostname)) { + // It is not possible to fetch the client metadata for loopback URLs + // because they are not accessible from the outside. We support this as a + // special case by generating a client metadata object ourselves. + return this.loopbackClient(clientId, clientUrl) + } else if (isInternetHost(clientUrl.hostname)) { + return this.internetClient(clientId, clientUrl) + } else { + throw new InvalidClientMetadataError( + 'Client ID hostname must be a valid domain', + ) + } + } + + protected async buildClientUrl(clientId: ClientId): Promise { + const url = (() => { + try { + return new URL(clientId) + } catch (err) { + throw new InvalidClientMetadataError('ClientID must be a URI', err) + } + })() + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new InvalidClientMetadataError( + `ClientID must be an http or https URI`, + ) + } + + if (url.href !== clientId) { + throw new InvalidClientMetadataError(`ClientID must be a normalized URI`) + } + + if (url.port || url.search || url.hash || url.username || url.password) { + throw new InvalidClientMetadataError( + `ClientID URI must not contain any port, username, password, query or fragment`, + ) + } + + if (url.pathname.includes('//')) { + throw new InvalidClientMetadataError( + `ClientID URI must not contain any double slashes in its path`, + ) + } + + return url + } + + protected async loopbackClient( + clientId: ClientId, + clientUrl: URL, + ): Promise { + if (!this.loopbackMetadata) { + throw new InvalidClientMetadataError('Loopback clients are not allowed') + } + + if (clientUrl.protocol !== 'http:') { + throw new InvalidClientMetadataError( + 'Loopback client must use the "http:" protocol', + ) + } + + if (clientUrl.hostname !== 'localhost') { + throw new InvalidClientMetadataError( + 'Loopback client must use the "localhost" hostname', + ) + } + + const metadata = clientMetadataSchema.parse( + await this.loopbackMetadata(clientUrl), + ) + + await this.validateMetadata(clientId, clientUrl, metadata) + + return { metadata, jwks: undefined } + } + + protected async internetClient( + clientId: ClientId, + clientUrl: URL, + ): Promise { + const metadataEndpoint = await this.getMetadataEndpoint(clientId, clientUrl) + const metadata = await this.fetchMetadata(metadataEndpoint) + await this.validateMetadata(clientId, clientUrl, metadata) + + return { + metadata, + jwks: metadata.jwks_uri + ? await this.fetchJwks(metadata.jwks_uri) + : undefined, + } + } + + protected async getMetadataEndpoint( + clientId: ClientId, + clientUrl: URL, + ): Promise { + return buildWellknownUrl(clientUrl, `oauth-client-metadata`) + } + + protected async fetchMetadata( + metadataEndpoint: string | URL, + ): Promise { + const request = new Request(metadataEndpoint, { + redirect: 'error', + headers: { accept: 'application/json' }, + }) + const { fetch } = this + return fetch(request).then(metadataTransformer, fetchFailureHandler) + } + + protected async fetchJwks(jwksUri: string): Promise { + const request = new Request(jwksUri, { + redirect: 'error', + headers: { accept: 'application/json' }, + }) + const { fetch } = this + return fetch(request).then(responseToJwksTransformer, fetchFailureHandler) + } + + /** + * Here we check that the metadata returned by the store is compatible with + * the Atproto OAuth spec. OAuth compliance & validity will be enforced by the + * ClientManager class in the oauth-provider package. + */ + protected async validateMetadata( + clientId: ClientId, + clientUrl: URL, + metadata: ClientMetadata, + ): Promise { + await this.validateMetadataClientId(clientId, clientUrl, metadata) + await this.validateMetadataClientUri(clientId, clientUrl, metadata) + await this.validateMetadataRedirectUris(clientId, clientUrl, metadata) + await this.validateMetadataCustom?.(clientId, clientUrl, metadata) + } + + protected async validateMetadataClientId( + clientId: ClientId, + clientUrl: URL, + metadata: ClientMetadata, + ): Promise { + if (metadata.client_id && metadata.client_id !== clientId) { + throw new InvalidClientMetadataError('client_id must match the client ID') + } + } + + protected async validateMetadataClientUri( + clientId: ClientId, + clientUrl: URL, + metadata: ClientMetadata, + ): Promise { + if (metadata.client_uri && metadata.client_uri !== clientUrl.href) { + throw new InvalidClientMetadataError( + 'client_uri must match the client URI', + ) + } + } + + protected async validateMetadataRedirectUris( + clientId: ClientId, + clientUrl: URL, + metadata: ClientMetadata, + ): Promise { + for (const redirectUri of metadata.redirect_uris) { + const uri = parseRedirectUri(redirectUri) + + switch (true) { + case uri.hostname === 'localhost': + // https://datatracker.ietf.org/doc/html/rfc8252#section-8.3 + // + // > While redirect URIs using localhost (i.e., + // > "http://localhost:{port}/{path}") function similarly to loopback + // > IP redirects described in Section 7.3, the use of localhost is + // > NOT RECOMMENDED. Specifying a redirect URI with the loopback IP + // > literal rather than localhost avoids inadvertently listening on + // > network interfaces other than the loopback interface. It is also + // > less susceptible to client-side firewalls and misconfigured host + // > name resolution on the user's device. + throw new InvalidRedirectUriError( + `Loopback redirect URI ${uri} is not allowed (use explicit IPs instead)`, + ) + + // Loopback redirects + case uri.hostname === '127.0.0.1': + case uri.hostname === '[::1]': + // https://openid.net/specs/openid-connect-registration-1_0.html#rfc.section.2 + // + // > Native Clients MUST only register redirect_uris using custom URI + // > schemes or loopback URLs using the http scheme; loopback URLs use + // > localhost or the IP loopback literals 127.0.0.1 or [::1] as the + // > hostname. + if (metadata.application_type !== 'native') { + throw new InvalidRedirectUriError( + `Loopback redirect URIs are not allowed for non-native clients`, + ) + } + if (uri.protocol !== 'http:') { + throw new InvalidRedirectUriError( + `Loopback redirect URIs must use the "http:" protocol`, + ) + } + continue + + case uri.protocol === 'http:': + case uri.protocol === 'https:': + // https://openid.net/specs/openid-connect-registration-1_0.html#rfc.section.2 + // + // > Native Clients MUST only register redirect_uris using custom URI + // > schemes or loopback URLs using the http scheme; loopback URLs use + // > localhost or the IP loopback literals 127.0.0.1 or [::1] as the + // > hostname. + // + // "http:" case is already handled by the "Loopback redirects" case + // before. + // + if (metadata.application_type === 'native') { + throw new InvalidRedirectUriError( + `Native clients must use loopback redirect URIs or custom URI schemes (got ${uri})`, + ) + } + continue + + default: + continue + } + } + } +} diff --git a/packages/oauth-provider-client-uri/src/util.ts b/packages/oauth-provider-client-uri/src/util.ts new file mode 100644 index 00000000000..1d03f6db8d4 --- /dev/null +++ b/packages/oauth-provider-client-uri/src/util.ts @@ -0,0 +1,19 @@ +import { parse as pslParse } from 'psl' + +export function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '[::1]' +} + +export function isInternetHost(host: string): boolean { + const parsed = pslParse(host) + return 'listed' in parsed && parsed.listed === true +} + +export function buildWellknownUrl(url: URL, name: string): URL { + const path = + url.pathname === '/' + ? `/.well-known/${name}` + : `${url.pathname.replace(/\/+$/, '')}/${name}` + + return new URL(path, url) +} diff --git a/packages/oauth-provider-client-uri/tsconfig.json b/packages/oauth-provider-client-uri/tsconfig.json new file mode 100644 index 00000000000..5c47f3f86ef --- /dev/null +++ b/packages/oauth-provider-client-uri/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/oauth-provider-replay-memory/package.json b/packages/oauth-provider-replay-memory/package.json new file mode 100644 index 00000000000..8b8bcdbe03c --- /dev/null +++ b/packages/oauth-provider-replay-memory/package.json @@ -0,0 +1,36 @@ +{ + "name": "@atproto/oauth-provider-replay-memory", + "version": "0.0.1", + "license": "MIT", + "description": "REplay Store implementation for @atproto/oauth-provider. This implementation uses the app's memory to store seen nonces. This implementation could lead in DoS attacks (e.g. if there is no rate limiting).", + "keywords": [ + "atproto", + "oauth", + "provider", + "replay", + "store", + "memory" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-provider-replay-memory" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/oauth-provider": "workspace:*", + "tslib": "^2.6.2" + }, + "scripts": { + "build": "tsc" + } +} diff --git a/packages/oauth-provider-replay-memory/src/index.ts b/packages/oauth-provider-replay-memory/src/index.ts new file mode 100644 index 00000000000..2d5d23c4105 --- /dev/null +++ b/packages/oauth-provider-replay-memory/src/index.ts @@ -0,0 +1 @@ +export { OAuthReplayStoreMemory } from './oauth-replay-store-memory.js' diff --git a/packages/oauth-provider-replay-memory/src/oauth-replay-store-memory.ts b/packages/oauth-provider-replay-memory/src/oauth-replay-store-memory.ts new file mode 100644 index 00000000000..8fa9b737afe --- /dev/null +++ b/packages/oauth-provider-replay-memory/src/oauth-replay-store-memory.ts @@ -0,0 +1,36 @@ +import type { ReplayStore } from '@atproto/oauth-provider' + +export class OAuthReplayStoreMemory implements ReplayStore { + private lastCleanup = Date.now() + private nonces = new Map() + + /** + * Returns true if the nonce is unique within the given time frame. + */ + async unique( + namespace: string, + nonce: string, + timeFrame: number, + ): Promise { + this.cleanup() + const key = `${namespace}:${nonce}` + + const now = Date.now() + + const exp = this.nonces.get(key) + this.nonces.set(key, now + timeFrame) + + return exp == null || exp < now + } + + private cleanup() { + const now = Date.now() + + if (this.lastCleanup < now - 60_000) { + for (const [key, expires] of this.nonces) { + if (expires < now) this.nonces.delete(key) + } + this.lastCleanup = now + } + } +} diff --git a/packages/oauth-provider-replay-memory/tsconfig.json b/packages/oauth-provider-replay-memory/tsconfig.json new file mode 100644 index 00000000000..5c47f3f86ef --- /dev/null +++ b/packages/oauth-provider-replay-memory/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/oauth-provider-replay-redis/package.json b/packages/oauth-provider-replay-redis/package.json new file mode 100644 index 00000000000..c41bf61e2b2 --- /dev/null +++ b/packages/oauth-provider-replay-redis/package.json @@ -0,0 +1,37 @@ +{ + "name": "@atproto/oauth-provider-replay-redis", + "version": "0.0.1", + "license": "MIT", + "description": "REplay Store implementation for @atproto/oauth-provider. This implementation uses an ioredis Redis connection in order to store nonces.", + "keywords": [ + "atproto", + "oauth", + "provider", + "replay", + "store", + "redis" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-provider-replay-redis" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/oauth-provider": "workspace:*", + "ioredis": "^5.3.2", + "tslib": "^2.6.2" + }, + "scripts": { + "build": "tsc" + } +} diff --git a/packages/oauth-provider-replay-redis/src/index.ts b/packages/oauth-provider-replay-redis/src/index.ts new file mode 100644 index 00000000000..a98b70a13af --- /dev/null +++ b/packages/oauth-provider-replay-redis/src/index.ts @@ -0,0 +1,2 @@ +export type * from './oauth-replay-store-redis.js' +export { OAuthReplayStoreRedis } from './oauth-replay-store-redis.js' diff --git a/packages/oauth-provider-replay-redis/src/oauth-replay-store-redis.ts b/packages/oauth-provider-replay-redis/src/oauth-replay-store-redis.ts new file mode 100644 index 00000000000..47d348fa872 --- /dev/null +++ b/packages/oauth-provider-replay-redis/src/oauth-replay-store-redis.ts @@ -0,0 +1,41 @@ +import type { ReplayStore } from '@atproto/oauth-provider' +import { Redis, type RedisOptions } from 'ioredis' + +export type { RedisOptions, Redis } + +export type OAuthReplayStoreRedisOptions = Redis | string | RedisOptions + +export class OAuthReplayStoreRedis implements ReplayStore { + private readonly redis: Redis + + constructor(options: OAuthReplayStoreRedisOptions) { + if (options instanceof Redis) { + this.redis = options + } else if (typeof options === 'string') { + const url = new URL( + options.startsWith('redis://') ? options : `redis://${options}`, + ) + + this.redis = new Redis({ + host: url.hostname, + port: parseInt(url.port, 10), + password: url.password, + }) + } else { + this.redis = new Redis(options) + } + } + + /** + * Returns true if the nonce is unique within the given time frame. + */ + async unique( + namespace: string, + nonce: string, + timeFrame: number, + ): Promise { + const key = `nonces:${namespace}:${nonce}` + const prev = await this.redis.set(key, '1', 'PX', timeFrame, 'GET') + return prev == null + } +} diff --git a/packages/oauth-provider-replay-redis/tsconfig.json b/packages/oauth-provider-replay-redis/tsconfig.json new file mode 100644 index 00000000000..5c47f3f86ef --- /dev/null +++ b/packages/oauth-provider-replay-redis/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/oauth-provider/.postcssrc.yml b/packages/oauth-provider/.postcssrc.yml new file mode 100644 index 00000000000..0114fbc9e78 --- /dev/null +++ b/packages/oauth-provider/.postcssrc.yml @@ -0,0 +1,3 @@ +plugins: + tailwindcss: {} + autoprefixer: {} diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json new file mode 100644 index 00000000000..e79de4edde3 --- /dev/null +++ b/packages/oauth-provider/package.json @@ -0,0 +1,75 @@ +{ + "name": "@atproto/oauth-provider", + "version": "0.0.0", + "license": "MIT", + "description": "Generic OAuth2 and OpenID Connect provider for Node.js. Currently only supports features needed for Atproto.", + "keywords": [ + "atproto", + "oauth", + "oauth2", + "open id connect", + "oidc", + "provider", + "oidc provider" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-provider" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/fetch": "workspace:*", + "@atproto/fetch-node": "workspace:*", + "@atproto/html": "workspace:*", + "@atproto/http-util": "workspace:*", + "@atproto/jwk": "workspace:*", + "@atproto/jwk-node": "workspace:*", + "cookie": "^0.6.0", + "jose": "^5.2.0", + "keygrip": "^1.1.0", + "lru-cache": "^10.2.0", + "oidc-token-hash": "^5.0.3", + "tslib": "^2.6.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@atproto/rollup-plugin-bundle-manifest": "workspace:*", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-replace": "^5.0.5", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^11.1.6", + "@types/cookie": "^0.6.0", + "@types/keygrip": "^1.0.6", + "@types/react": "^18.2.50", + "@types/react-dom": "^18.2.18", + "@types/send": "^0.17.4", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "rollup": "^4.10.0", + "rollup-plugin-postcss": "^4.0.2", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + }, + "optionalDependencies": { + "keygrip": "^1.1.0" + }, + "scripts": { + "build:frontend": "rollup --config rollup.config.js", + "build:backend": "tsc --build --force tsconfig.backend.json", + "build": "pnpm --parallel --stream '/^build:.+$/'", + "dev": "rollup --config rollup.config.js --watch" + } +} diff --git a/packages/oauth-provider/rollup.config.js b/packages/oauth-provider/rollup.config.js new file mode 100644 index 00000000000..564b72341f5 --- /dev/null +++ b/packages/oauth-provider/rollup.config.js @@ -0,0 +1,40 @@ +/* eslint-env node */ + +const { defineConfig } = require('rollup') + +const { default: manifest } = require('@atproto/rollup-plugin-bundle-manifest') +const { default: commonjs } = require('@rollup/plugin-commonjs') +const { default: nodeResolve } = require('@rollup/plugin-node-resolve') +const { default: replace } = require('@rollup/plugin-replace') +const { default: terser } = require('@rollup/plugin-terser') +const { default: typescript } = require('@rollup/plugin-typescript') +const postcss = ((m) => m.default || m)(require('rollup-plugin-postcss')) + +const NODE_ENV = + process.env['NODE_ENV'] === 'development' ? 'development' : 'production' +const devMode = NODE_ENV === 'development' + +module.exports = defineConfig({ + input: 'src/app/main.tsx', + output: { + manualChunks: undefined, + sourcemap: devMode, + file: 'dist/app/main.js', + format: 'iife', + }, + plugins: [ + nodeResolve({ preferBuiltins: false, browser: true }), + commonjs(), + postcss({ config: true, extract: true, minimize: !devMode }), + typescript({ + tsconfig: './tsconfig.frontend.json', + outputToFilesystem: true, + }), + replace({ + preventAssignment: true, + values: { 'process.env.NODE_ENV': JSON.stringify(NODE_ENV) }, + }), + manifest(), + !devMode && terser({}), + ], +}) diff --git a/packages/oauth-provider/src/access-token/access-token-type.ts b/packages/oauth-provider/src/access-token/access-token-type.ts new file mode 100644 index 00000000000..d75a9711319 --- /dev/null +++ b/packages/oauth-provider/src/access-token/access-token-type.ts @@ -0,0 +1,5 @@ +export enum AccessTokenType { + auto = 'auto', + jwt = 'jwt', + id = 'id', +} diff --git a/packages/oauth-provider/src/access-token/access-token.ts b/packages/oauth-provider/src/access-token/access-token.ts new file mode 100644 index 00000000000..6fffa79f3f0 --- /dev/null +++ b/packages/oauth-provider/src/access-token/access-token.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const accessTokenSchema = z.string().min(1) +export type AccessToken = z.infer diff --git a/packages/oauth-provider/src/account/account-manager.ts b/packages/oauth-provider/src/account/account-manager.ts new file mode 100644 index 00000000000..f5214b7912f --- /dev/null +++ b/packages/oauth-provider/src/account/account-manager.ts @@ -0,0 +1,60 @@ +import { ClientId } from '../client/client-id.js' +import { DeviceId } from '../device/device-id.js' +import { InvalidRequestError } from '../errors/invalid-request-error.js' +import { Sub } from '../oidc/sub.js' +import { LoginCredentials, AccountStore, AccountInfo } from './account-store.js' +import { Account } from './account.js' + +const TIMING_ATTACK_MITIGATION_DELAY = 400 + +export class AccountManager { + constructor(protected readonly store: AccountStore) {} + + public async login( + credentials: LoginCredentials, + deviceId: DeviceId | null, + ): Promise { + const start = Date.now() + try { + const result = await this.store.authenticateAccount(credentials, deviceId) + if (!result) throw new InvalidRequestError('Invalid credentials') + return result + } finally { + // Mitigate timing attacks + const delta = Date.now() - start + if (delta < TIMING_ATTACK_MITIGATION_DELAY) { + await new Promise((resolve) => + setTimeout(resolve, TIMING_ATTACK_MITIGATION_DELAY - delta), + ) + } else { + // Make sure we wait a multiple of TIMING_ATTACK_MITIGATION_DELAY + await new Promise((resolve) => + setTimeout( + resolve, + TIMING_ATTACK_MITIGATION_DELAY * + Math.ceil(delta / TIMING_ATTACK_MITIGATION_DELAY), + ), + ) + } + } + } + + public async get(deviceId: DeviceId, sub: Sub): Promise { + const result = await this.store.getDeviceAccount(deviceId, sub) + if (!result) throw new InvalidRequestError(`Account not found`) + return result + } + + public async addAuthorizedClient( + deviceId: DeviceId, + sub: Sub, + clientId: ClientId, + ): Promise { + await this.store.addAuthorizedClient(deviceId, sub, clientId) + } + + public async list(deviceId: DeviceId): Promise { + const results = await this.store.listDeviceAccounts(deviceId) + return results.filter((result) => result.info.remembered) + } +} diff --git a/packages/oauth-provider/src/account/account-store.ts b/packages/oauth-provider/src/account/account-store.ts new file mode 100644 index 00000000000..50219389a4f --- /dev/null +++ b/packages/oauth-provider/src/account/account-store.ts @@ -0,0 +1,74 @@ +import { ClientId } from '../client/client-id.js' +import { DeviceId } from '../device/device-id.js' +import { Sub } from '../oidc/sub.js' +import { Awaitable } from '../util/awaitable.js' +import { Account } from './account.js' + +export type LoginCredentials = { + username: string + password: string + + /** + * If false, the account must not be returned from + * {@link AccountStore.listDeviceAccounts}. Note that this only makes sense when + * used with a device ID. + */ + remember?: boolean +} + +export type DeviceAccountInfo = { + remembered: boolean + authenticatedAt: Date + authorizedClients: readonly ClientId[] +} + +// Export all types needed to implement the AccountStore interface +export type { Awaitable, Account, DeviceId, Sub } + +export type AccountInfo = { + account: Account + info: DeviceAccountInfo +} + +export interface AccountStore { + authenticateAccount( + credentials: LoginCredentials, + deviceId: DeviceId | null, + ): Awaitable + + addAuthorizedClient( + deviceId: DeviceId, + sub: Sub, + clientId: ClientId, + ): Awaitable + + getDeviceAccount(deviceId: DeviceId, sub: Sub): Awaitable + removeDeviceAccount(deviceId: DeviceId, sub: Sub): Awaitable + + /** + * @note Only the accounts that where logged in with `remember: true` need to + * be returned. The others will be ignored. + */ + listDeviceAccounts(deviceId: DeviceId): Awaitable +} + +export function isAccountStore( + implementation: Record & Partial, +): implementation is Record & AccountStore { + return ( + typeof implementation.authenticateAccount === 'function' && + typeof implementation.getDeviceAccount === 'function' && + typeof implementation.addAuthorizedClient === 'function' && + typeof implementation.listDeviceAccounts === 'function' && + typeof implementation.removeDeviceAccount === 'function' + ) +} + +export function asAccountStore( + implementation?: Record & Partial, +): AccountStore { + if (!implementation || !isAccountStore(implementation)) { + throw new Error('Invalid AccountStore implementation') + } + return implementation +} diff --git a/packages/oauth-provider/src/account/account.ts b/packages/oauth-provider/src/account/account.ts new file mode 100644 index 00000000000..a005553aeb5 --- /dev/null +++ b/packages/oauth-provider/src/account/account.ts @@ -0,0 +1,10 @@ +import { OIDCStandardPayload } from '../oidc/claims.js' +import { Sub } from '../oidc/sub.js' +import { Simplify } from '../util/type.js' + +export type Account = Simplify< + { + sub: Sub // Account id + aud: string | [string, ...string[]] // Resource server URL + } & OIDCStandardPayload +> diff --git a/packages/oauth-provider/src/app/app.tsx b/packages/oauth-provider/src/app/app.tsx new file mode 100644 index 00000000000..61ad2a011d5 --- /dev/null +++ b/packages/oauth-provider/src/app/app.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react' + +import { Authorize } from './components/authorize' +import { Layout } from './components/layout' +import { LoginForm } from './components/login-form' +import { SessionSelector } from './components/session-selector' +import { csrfToken } from './csrf-cookie' +import { Account, Session } from './types' + +import type { BackendData } from './backend-data' + +export function App({ + requestUri, + clientId, + clientMetadata, + consentRequired: initialConsentRequired, + loginHint: initialLoginHint, + sessions: initialSessions, +}: BackendData) { + const [isDone, setIsDone] = useState(false) + const [loginHint, setLoginHint] = useState(initialLoginHint) + const [sessions, setSessions] = useState(initialSessions) + const [sub, setSub] = useState( + initialSessions.find((s) => s.initiallySelected)?.account.sub || null, + ) + + const selectedSession = sub && sessions.find((s) => s.account.sub == sub) + + const setAccount = (account: Account, consentRequired: boolean) => { + setLoginHint(undefined) + setSub(account.sub) + if (consentRequired === false && initialConsentRequired === false) { + authorizeAccept(account) + } + } + + const updateSession = (account: Account, consentRequired: boolean) => { + const sessionIdx = sessions.findIndex((s) => s.account.sub === account.sub) + if (sessionIdx === -1) { + const newSession: Session = { + initiallySelected: false, + account, + loginRequired: false, + consentRequired, + } + setSessions([...sessions, newSession]) + } else { + const curSession = sessions[sessionIdx] + const newSession: Session = { + ...curSession, + initiallySelected: false, + account, + consentRequired, + loginRequired: false, + } + setSessions([ + ...sessions.slice(0, sessionIdx), + newSession, + ...sessions.slice(sessionIdx + 1), + ]) + } + } + + const performLogin = async (credentials: { + username: string + password: string + remember: boolean + }) => { + const r = await fetch('/oauth/authorize/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'same-origin', + body: JSON.stringify({ + csrf_token: csrfToken, + request_uri: requestUri, + client_id: clientId, + credentials, + }), + }) + const json = await r.json() + if (!r.ok) throw new Error(json.error || 'Error', { cause: json }) + + const { account, info } = json + const consentRequired = !info.authorizedClients.includes(clientId) + updateSession(account, consentRequired) + setAccount(account, consentRequired) + } + + const authorizeAccept = async (account: Account) => { + setIsDone(true) + + const url = new URL('/oauth/authorize/accept', window.origin) + url.searchParams.set('request_uri', requestUri) + url.searchParams.set('account_sub', account.sub) + url.searchParams.set('client_id', clientId) + url.searchParams.set('csrf_token', csrfToken) + + window.location.href = url.href + } + + const authorizeReject = () => { + setIsDone(true) + + const url = new URL('/oauth/authorize/reject', window.origin) + url.searchParams.set('request_uri', requestUri) + url.searchParams.set('client_id', clientId) + url.searchParams.set('csrf_token', csrfToken) + + window.location.href = url.href + } + + if (isDone) { + // TODO + return You are being redirected + } + + if (selectedSession) { + if (selectedSession.loginRequired === false) { + return ( + setSub(null)} + onAccept={() => authorizeAccept(selectedSession.account)} + onReject={() => authorizeReject()} + account={selectedSession.account} + clientId={clientId} + clientMetadata={clientMetadata} + /> + ) + } else { + return ( + authorizeReject()} + /> + ) + } + } + + if (loginHint) { + return ( + setLoginHint(undefined)} + /> + ) + } + + return ( + + setAccount( + account, + sessions.find((s) => s.account.sub === account.sub) + ?.consentRequired || false, + ) + } + onBack={() => authorizeReject()} + /> + ) +} diff --git a/packages/oauth-provider/src/app/backend-data.ts b/packages/oauth-provider/src/app/backend-data.ts new file mode 100644 index 00000000000..cfada2c0c45 --- /dev/null +++ b/packages/oauth-provider/src/app/backend-data.ts @@ -0,0 +1,16 @@ +import { ClientMetadata, Session } from './types' + +// This is injected by the backend in the HTML template +declare const __backendData: { + clientId: string + clientMetadata: ClientMetadata + requestUri: string + csrfCookie: string + sessions: readonly Session[] + consentRequired: boolean + loginHint?: string +} + +export const backendData = __backendData + +export type BackendData = typeof __backendData diff --git a/packages/oauth-provider/src/app/components/account-list.tsx b/packages/oauth-provider/src/app/components/account-list.tsx new file mode 100644 index 00000000000..0f74a6c181b --- /dev/null +++ b/packages/oauth-provider/src/app/components/account-list.tsx @@ -0,0 +1,72 @@ +import { Account } from '../types' +import { Layout } from './layout' + +export function AccountList({ + accounts, + onAccount, + another = undefined, + onBack = undefined, + ...props +}: { + accounts: readonly Account[] + onAccount: (account: Account) => void + another?: () => void + onBack?: () => void +}) { + return ( + +

+ + ) +} diff --git a/packages/oauth-provider/src/app/components/authorize.tsx b/packages/oauth-provider/src/app/components/authorize.tsx new file mode 100644 index 00000000000..8ab31fb0b42 --- /dev/null +++ b/packages/oauth-provider/src/app/components/authorize.tsx @@ -0,0 +1,92 @@ +import { Account, ClientMetadata } from '../types' +import { Layout } from './layout' + +export function Authorize({ + account, + clientId, + clientMetadata, + onAccept, + onReject, + onBack = undefined, +}: { + account: Account + clientId: string + clientMetadata: ClientMetadata + onAccept: () => void + onReject: () => void + onBack?: () => void +}) { + const clientUriHost = clientMetadata.client_uri + ? new URL(clientMetadata.client_uri).host + : null + const clientName = clientMetadata.client_name || clientUriHost || clientId + + return ( + + Grant {clientUriHost} access to your{' '} + {account.preferred_username} account + + } + > +
+ {clientMetadata.logo_uri && ( +
+ {clientMetadata.client_name} +
+ )} + +

+ {clientUriHost || clientId} +

+ +

+ {clientName} is asking for permission to access your account. +

+ +

+ By clicking Accept, you allow this application to access your + information in accordance to its{' '} + terms of service. +

+ +
+ {onBack && ( + + )} + +
+ + + + +
+
+ + ) +} diff --git a/packages/oauth-provider/src/app/components/layout.tsx b/packages/oauth-provider/src/app/components/layout.tsx new file mode 100644 index 00000000000..e37dd9406ca --- /dev/null +++ b/packages/oauth-provider/src/app/components/layout.tsx @@ -0,0 +1,30 @@ +import { HTMLAttributes } from 'react' + +export function Layout({ + title, + subTitle, + children, + ...props +}: HTMLAttributes & { + title: string | JSX.Element + subTitle?: string | JSX.Element + children?: string | JSX.Element +}) { + return ( +
+
+

+ {title} +

+

{subTitle}

+
+ +
+ {children} +
+
+ ) +} diff --git a/packages/oauth-provider/src/app/components/login-form.tsx b/packages/oauth-provider/src/app/components/login-form.tsx new file mode 100644 index 00000000000..f135353302d --- /dev/null +++ b/packages/oauth-provider/src/app/components/login-form.tsx @@ -0,0 +1,114 @@ +import { FormHTMLAttributes } from 'react' + +import { Layout } from './layout' + +export function LoginForm({ + onLogin, + onBack = undefined, + username = '', + usernameReadonly = false, + ...props +}: FormHTMLAttributes & { + onLogin: (credentials: { + username: string + password: string + remember: boolean + }) => void + onBack?: () => void + username?: string + usernameReadonly?: boolean +}) { + const onSubmit = ( + event: React.SyntheticEvent< + HTMLFormElement & { + username: HTMLInputElement + password: HTMLInputElement + remember: HTMLInputElement + }, + SubmitEvent + >, + ) => { + event.preventDefault() + onLogin({ + username: event.currentTarget.username.value, + password: event.currentTarget.password.value, + remember: event.currentTarget.remember.checked, + }) + } + + return ( + +
+
+
+ @ + +
+ +
+ +
+ * + +
+ +
+ +
+ + + + + +
+
+ +
+ + + {onBack && ( + + )} +
+
+
+ ) +} diff --git a/packages/oauth-provider/src/app/components/session-selector.tsx b/packages/oauth-provider/src/app/components/session-selector.tsx new file mode 100644 index 00000000000..02db236c75e --- /dev/null +++ b/packages/oauth-provider/src/app/components/session-selector.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' + +import { Session } from '../types' +import { AccountList } from './account-list' +import { LoginForm } from './login-form' + +export function SessionSelector({ + sessions, + onSession, + onLogin, + onBack = undefined, +}: { + sessions: readonly Session[] + onSession: (session: Session) => void + onLogin: (credentials: { + username: string + password: string + remember: boolean + }) => void + onBack?: () => void +}) { + const [showLogin, setShowLogin] = useState(sessions.length === 0) + + return showLogin ? ( + 0 + ? () => { + if (sessions.length > 0) setShowLogin(false) + } + : onBack + } + /> + ) : ( + s.account)} + onAccount={(a) => { + const session = sessions.find((s) => s.account.sub === a.sub) + if (session) onSession(session) + }} + another={() => setShowLogin(true)} + onBack={onBack} + /> + ) +} diff --git a/packages/oauth-provider/src/app/csrf-cookie.ts b/packages/oauth-provider/src/app/csrf-cookie.ts new file mode 100644 index 00000000000..b7641e0284e --- /dev/null +++ b/packages/oauth-provider/src/app/csrf-cookie.ts @@ -0,0 +1,13 @@ +import { backendData } from './backend-data' + +const parseCookieString = (cookie: string) => + Object.fromEntries( + cookie + .split(';') + .filter(Boolean) + .map((str) => str.split('=', 2).map((s) => decodeURIComponent(s.trim()))), + ) + +export const csrfToken = parseCookieString(document.cookie)[ + backendData.csrfCookie +] diff --git a/packages/oauth-provider/src/app/main.css b/packages/oauth-provider/src/app/main.css new file mode 100644 index 00000000000..b5c61c95671 --- /dev/null +++ b/packages/oauth-provider/src/app/main.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/oauth-provider/src/app/main.tsx b/packages/oauth-provider/src/app/main.tsx new file mode 100644 index 00000000000..2e0df5fcdd9 --- /dev/null +++ b/packages/oauth-provider/src/app/main.tsx @@ -0,0 +1,16 @@ +import './main.css' + +import { createRoot } from 'react-dom/client' + +import { App } from './app' +import { backendData } from './backend-data' + +const url = new URL(window.location.href) +url.search = '' +url.searchParams.set('request_uri', backendData.requestUri) +url.searchParams.set('client_id', backendData.clientId) +window.history.replaceState(history.state, '', url.pathname + url.search) + +const container = document.getElementById('root')! +const root = createRoot(container) +root.render() diff --git a/packages/oauth-provider/src/app/types.ts b/packages/oauth-provider/src/app/types.ts new file mode 100644 index 00000000000..124808a9df6 --- /dev/null +++ b/packages/oauth-provider/src/app/types.ts @@ -0,0 +1,54 @@ +// ../client is not a shared module (frontend/backend) and cannot be imported in +// the frontend. +export type ClientMetadata = { + client_id?: string + application_type?: 'native' | 'web' + contacts?: string[] + client_name?: string + logo_uri?: string + client_uri?: string + policy_uri?: string + tos_uri?: string + [key: string]: unknown +} + +export type Address = { + formatted?: string + street_address?: string + locality?: string + region?: string + postal_code?: string + country?: string +} + +export type Account = { + sub: string + aud: string + + email?: string + email_verified?: boolean + phone_number?: string + phone_number_verified?: boolean + address?: Address + name?: string + family_name?: string + given_name?: string + middle_name?: string + nickname?: string + preferred_username?: string + gender?: string + picture?: string + profile?: string + website?: string + birthdate?: `${number}-${number}-${number}` + zoneinfo?: string + locale?: `${string}-${string}` | string + updated_at?: number +} + +export type Session = { + account: Account + loginRequired: boolean + consentRequired: boolean + initiallySelected: boolean +} diff --git a/packages/oauth-provider/src/assets/index.ts b/packages/oauth-provider/src/assets/index.ts new file mode 100644 index 00000000000..a65337bba7b --- /dev/null +++ b/packages/oauth-provider/src/assets/index.ts @@ -0,0 +1,107 @@ +type ManifestItem = + import('@atproto/rollup-plugin-bundle-manifest').ManifestItem + +// If this library is used as a regular dependency (e.g. from node_modules), the +// assets will simply be referenced from the node_modules directory. However, if +// this library is bundled (e.g. via rollup), the assets need to be copied to +// the output directory. Most bundlers support this (webpack, rollup, etc.) by +// re-writing new URL('./path', import.meta.url) calls to point to the correct +// output directory. +// +// https://github.com/evanw/esbuild/issues/795 +// https://www.npmjs.com/package/@web/rollup-plugin-import-meta-assets + +// Note that the bundle-manifest, being a JSON file, can be imported directly +// without any special handling. This is because all bundlers support JSON +// imports out of the box. + +import { createReadStream } from 'node:fs' +import { join } from 'node:path' +import { Readable } from 'node:stream' + +// @ts-expect-error: This file is generated at build time +import manifestData from '../app/bundle-manifest.json' + +const assets: Map = new Map(Object.entries(manifestData)) + +async function getAsset( + filename: string, +): Promise<{ asset: ManifestItem; path: string }> { + // Prevent directory traversal attacks + if ( + filename.includes(':') || + filename.includes('/') || + filename.includes('\\') || + filename.startsWith('.') + ) { + throw new AssetNotFoundError(filename) + } + + const asset = assets.get(filename) + if (!asset) throw new AssetNotFoundError(filename) + + // We make it extra easy on the bundler by providing a list of known assets + // instead of relying on globbing (globbing with + // 'rollup-plugin-import-meta-assets' requires a file extension anyway). + + // return { + // asset, + // url: new URL(`../app/${filename}`, import.meta.url), + // } + + switch (filename) { + case 'main.js': + return { + asset, + path: join(__dirname, '../app/main.js'), + } + case 'main.js.map': + return { + asset, + path: join(__dirname, '../app/main.js.map'), + } + case 'main.css': + return { + asset, + path: join(__dirname, '../app/main.css'), + } + case 'main.css.map': + return { + asset, + path: join(__dirname, '../app/main.css.map'), + } + default: + // Should never happen + throw new AssetNotFoundError(filename) + } +} + +export async function findAsset( + filename: string, +): Promise<{ asset: ManifestItem; getStream: () => Readable }> { + const { asset, path } = await getAsset(filename) + + // When this package is used as a regular "node_modules" dependency, and gets + // bundled by the consumer, the assets should be copied to the output + // directory. In case the bundler does not support copying assets based on the + // "new URL(path, import.meta.url)" pattern, this package's build system can + // be modified to embed the asset data directly into the bundle metadata (see + // rollup.config.mjs). + + const { data } = asset + + return { + asset, + getStream: data + ? () => Readable.from(Buffer.from(data, 'base64')) + : () => createReadStream(path), + } +} + +class AssetNotFoundError extends Error { + public readonly code = 'ENOENT' + public readonly statusCode = 404 + constructor(filename: string) { + super(`Asset not found: ${filename}`) + } +} diff --git a/packages/oauth-provider/src/client/client-auth.ts b/packages/oauth-provider/src/client/client-auth.ts new file mode 100644 index 00000000000..6a1b59fb333 --- /dev/null +++ b/packages/oauth-provider/src/client/client-auth.ts @@ -0,0 +1,32 @@ +import { KeyLike, calculateJwkThumbprint, exportJWK } from 'jose' +import { CLIENT_ASSERTION_TYPE_JWT_BEARER } from './client-credentials.js' + +export type ClientAuth = + | { method: 'none' } + | { + method: typeof CLIENT_ASSERTION_TYPE_JWT_BEARER + alg: string + kid: string + jkt: string + } + +export function compareClientAuth(a: ClientAuth, b: ClientAuth): boolean { + if (a.method === 'none') { + if (b.method !== a.method) return false + + return true + } + + if (a.method === CLIENT_ASSERTION_TYPE_JWT_BEARER) { + if (b.method !== a.method) return false + + return true + } + + // Fool-proof + throw new TypeError('Invalid ClientAuth method') +} + +export async function authJwkThumbprint(key: Uint8Array | KeyLike) { + return calculateJwkThumbprint(await exportJWK(key), 'sha512') +} diff --git a/packages/oauth-provider/src/client/client-credentials.ts b/packages/oauth-provider/src/client/client-credentials.ts new file mode 100644 index 00000000000..ac586790caf --- /dev/null +++ b/packages/oauth-provider/src/client/client-credentials.ts @@ -0,0 +1,43 @@ +import { z } from 'zod' + +import { clientIdSchema } from './client-id.js' + +export const CLIENT_ASSERTION_TYPE_JWT_BEARER = + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + +export const clientJwtBearerAssertionSchema = z.object({ + client_id: clientIdSchema, + client_assertion_type: z.literal(CLIENT_ASSERTION_TYPE_JWT_BEARER), + /** + * - "sub" the subject MUST be the "client_id" of the OAuth client + * - "iat" is required and MUST be less than one minute + * - "aud" must containing a value that identifies the authorization server + * - The JWT MAY contain a "jti" (JWT ID) claim that provides a unique identifier for the token. + * - Note that the authorization server may reject JWTs with an "exp" claim value that is unreasonably far in the future. + * + * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-bearer-11#section-3} + */ + client_assertion: z + .string() + .regex(/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/), +}) + +export const clientSecretPostSchema = z.object({ + client_id: clientIdSchema, + client_secret: z.string(), +}) + +export const clientCredentialsSchema = z.union([ + clientJwtBearerAssertionSchema, + clientSecretPostSchema, +]) + +export type ClientCredentials = z.infer + +export const clientIdentificationSchema = z.union([ + clientCredentialsSchema, + // Must be last since it is less specific + z.object({ client_id: clientIdSchema }), +]) + +export type ClientIdentification = z.infer diff --git a/packages/oauth-provider/src/client/client-data.ts b/packages/oauth-provider/src/client/client-data.ts new file mode 100644 index 00000000000..45e64d20dde --- /dev/null +++ b/packages/oauth-provider/src/client/client-data.ts @@ -0,0 +1,8 @@ +import { Jwks } from '@atproto/jwk' + +import { ClientMetadata } from './client-metadata.js' + +export type ClientData = { + metadata: ClientMetadata + jwks?: Jwks +} diff --git a/packages/oauth-provider/src/client/client-id.ts b/packages/oauth-provider/src/client/client-id.ts new file mode 100644 index 00000000000..a0d4975e9c1 --- /dev/null +++ b/packages/oauth-provider/src/client/client-id.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const clientIdSchema = z.string().min(1) +export type ClientId = z.infer diff --git a/packages/oauth-provider/src/client/client-manager.ts b/packages/oauth-provider/src/client/client-manager.ts new file mode 100644 index 00000000000..af9042b7746 --- /dev/null +++ b/packages/oauth-provider/src/client/client-manager.ts @@ -0,0 +1,329 @@ +import { Jwks, Keyset } from '@atproto/jwk' + +import { InvalidClientMetadataError } from '../errors/invalid-client-metadata-error.js' +import { InvalidRedirectUriError } from '../errors/invalid-redirect-uri-error.js' +import { OAuthError } from '../errors/oauth-error.js' +import { Awaitable } from '../util/awaitable.js' +import { ClientData } from './client-data.js' +import { ClientId } from './client-id.js' +import { ClientMetadata } from './client-metadata.js' +import { ClientStore } from './client-store.js' +import { parseRedirectUri } from './client-utils.js' +import { Client } from './client.js' + +/** + * Use this to alter or override client metadata & jwks before they are used. + */ +export type ClientDataHook = ( + clientId: ClientId, + data: { metadata: ClientMetadata; jwks?: Jwks }, +) => Awaitable + +export class ClientManager { + constructor( + protected readonly store: ClientStore, + protected readonly keyset: Keyset, + protected readonly hooks: { onClientData?: ClientDataHook }, + ) {} + + /** + * This method will ensure that the client metadata is valid w.r.t. the OAuth + * and OIDC specifications. It will also ensure that the metadata is + * compatible with this implementation. + */ + protected async findClient(clientId: ClientId): Promise { + try { + const { metadata, jwks } = await this.store.findClient(clientId) + + if (metadata.jwks && metadata.jwks_uri) { + throw new InvalidClientMetadataError( + 'jwks_uri and jwks are mutually exclusive', + ) + } + + const scopes = metadata.scope?.split(' ') + if ( + metadata.grant_types.includes('refresh_token') !== + (scopes?.includes('offline_access') ?? false) + ) { + throw new InvalidClientMetadataError( + 'Grant type "refresh_token" requires scope "offline_access"', + ) + } + + for (const responseType of metadata.response_types) { + const rt = responseType.split(' ') + + if ( + rt.includes('code') && + !metadata.grant_types.includes('authorization_code') + ) { + throw new InvalidClientMetadataError( + `Response type "${responseType}" requires the "authorization_code" grant type`, + ) + } + + if (rt.includes('id_token') && !scopes?.includes('openid')) { + throw new InvalidClientMetadataError( + 'Response type "token" requires scope "openid"', + ) + } + + for (const t of ['id_token', 'token'] as const) { + if (rt.includes(t) && !metadata.grant_types.includes('implicit')) { + throw new InvalidClientMetadataError( + `Response type "${responseType}" requires the "implicit" grant type`, + ) + } + } + } + + for (const grantType of metadata.grant_types) { + switch (grantType) { + case 'authorization_code': + case 'password': + case 'refresh_token': + case 'implicit': // Required by OIDC (for id_token) + continue + default: + throw new InvalidClientMetadataError( + `Grant type "${grantType}" is not supported`, + ) + } + } + + if (metadata.client_id && metadata.client_id !== clientId) { + throw new InvalidClientMetadataError('client_id does not match') + } + + if (metadata.subject_type && metadata.subject_type !== 'public') { + throw new InvalidClientMetadataError( + 'Only "public" subject_type is supported', + ) + } + + if ( + metadata.userinfo_signed_response_alg && + !this.keyset.signAlgorithms.includes( + metadata.userinfo_signed_response_alg, + ) + ) { + throw new InvalidClientMetadataError( + `Unsupported "userinfo_signed_response_alg" ${metadata.userinfo_signed_response_alg}`, + ) + } + + if ( + metadata.id_token_signed_response_alg && + !this.keyset.signAlgorithms.includes( + metadata.id_token_signed_response_alg, + ) + ) { + throw new InvalidClientMetadataError( + `Unsupported "id_token_signed_response_alg" ${metadata.id_token_signed_response_alg}`, + ) + } + + if (metadata.userinfo_encrypted_response_alg) { + // We only support signature for now. + throw new InvalidClientMetadataError( + 'Encrypted userinfo response is not supported', + ) + } + + for (const endpoint of [ + 'token', + 'introspection', + 'revocation', + ] as const) { + const method = + metadata[`${endpoint}_endpoint_auth_method`] || + metadata[`token_endpoint_auth_method`] + + switch (method || null) { + case 'none': + break + case 'private_key_jwt': + if (!(jwks || metadata.jwks)?.keys.length) { + throw new InvalidClientMetadataError( + `private_key_jwt auth method requires at least one key in jwks`, + ) + } + if (!metadata.token_endpoint_auth_signing_alg) { + throw new InvalidClientMetadataError( + `Missing token_endpoint_auth_signing_alg client metadata`, + ) + } + break + case 'self_signed_tls_client_auth': + case 'tls_client_auth': + // We choose to use the `client_assertion` method instead. + throw new InvalidClientMetadataError( + `${method} is not supported. Use private_key_jwt instead.`, + ) + + case 'client_secret_post': + case 'client_secret_basic': + case 'client_secret_jwt': + // Not supported by the Atproto "lazy client registration" model. + throw new InvalidClientMetadataError(`${method} is not allowed`) + + case null: + throw new InvalidClientMetadataError( + `Missing "${endpoint}_endpoint_auth_method" client metadata`, + ) + default: + throw new InvalidClientMetadataError( + `Unsupported "${endpoint}_endpoint_auth_method" client metadata`, + ) + } + } + + if (metadata.authorization_encrypted_response_enc) { + throw new InvalidClientMetadataError( + 'Encrypted authorization response is not supported', + ) + } + + if ( + metadata.authorization_encrypted_response_enc && + !metadata.authorization_encrypted_response_alg + ) { + throw new InvalidClientMetadataError( + 'authorization_encrypted_response_enc requires authorization_encrypted_response_alg', + ) + } + + // https://openid.net/specs/openid-connect-registration-1_0.html#rfc.section.2 + + // > Web Clients [as defined by "application_type"] using the OAuth + // > Implicit Grant Type MUST only register URLs using the https scheme as + // > redirect_uris; they MUST NOT use localhost as the hostname. Native + // > Clients [as defined by "application_type"] MUST only register + // > redirect_uris using custom URI schemes or loopback URLs using the + // > http scheme; loopback URLs use localhost or the IP loopback literals + // > 127.0.0.1 or [::1] as the hostname. Authorization Servers MAY place + // > additional constraints on Native Clients. Authorization Servers MAY + // > reject Redirection URI values using the http scheme, other than the + // > loopback case for Native Clients. The Authorization Server MUST + // > verify that all the registered redirect_uris conform to these + // > constraints. This prevents sharing a Client ID across different types + // > of Clients. + for (const redirectUri of metadata.redirect_uris) { + const url = parseRedirectUri(redirectUri) + + switch (true) { + // Loopback Interface Redirection + // https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 + case url.hostname === 'localhost': + case url.hostname === '127.0.0.1': + case url.hostname === '[::1]': { + if (metadata.application_type !== 'native') { + throw new InvalidRedirectUriError( + 'Loopback redirect URIs are only allowed for native apps', + ) + } + if (url.port) { + throw new InvalidRedirectUriError( + `Loopback redirect URI ${url} must not contain a port`, + ) + } + if (url.protocol !== 'http:') { + throw new InvalidRedirectUriError( + `Loopback redirect URI ${url} must use HTTP`, + ) + } + continue + } + + // Claimed "https" Scheme URI Redirection + // https://datatracker.ietf.org/doc/html/rfc8252#section-7.2 + case url.protocol === 'https:': + case url.protocol === 'http:': { + continue + } + + // Private-Use URI Scheme (must contain at least one dot) + // https://datatracker.ietf.org/doc/html/rfc8252#section-7.1 + // > When choosing a URI scheme to associate with the app, apps MUST + // > use a URI scheme based on a domain name under their control, + // > expressed in reverse order, as recommended by Section 3.8 of + // > [RFC7595] for private-use URI schemes. + case url.protocol.includes('.'): { + if (metadata.application_type !== 'native') { + throw new InvalidRedirectUriError( + `Private-Use URI Scheme redirect URI are only allowed for native apps`, + ) + } + + if (!metadata.client_uri) { + throw new InvalidRedirectUriError( + `Private-Use URI Scheme redirect URI requires a client_uri`, + ) + } + + const clientUri = new URL(metadata.client_uri) + + if (clientUri.hostname === 'localhost') { + throw new InvalidRedirectUriError( + `Private-Use URI Scheme are not allowed for loopback clients`, + ) + } + + if (!clientUri.hostname.includes('.')) { + throw new InvalidRedirectUriError( + `Private-Use URI Scheme require a fully qualified domain name (FQDN) client_uri`, + ) + } + + if ( + url.protocol !== + `${clientUri.hostname.split('.').reverse().join('.')}:` + ) { + throw new InvalidRedirectUriError( + `Private-Use URI Scheme redirect URI be the reversed client URI domain`, + ) + } + + // > Following the requirements of Section 3.2 of [RFC3986], as there + // > is no naming authority for private-use URI scheme redirects, only + // > a single slash ("/") appears after the scheme component. + if ( + url.href.startsWith(`${url.protocol}//`) || + url.username || + url.password || + url.hostname || + url.port + ) { + throw new InvalidRedirectUriError( + `Private-Use URI Scheme must be in the form ${url.protocol}/`, + ) + } + continue + } + + default: + throw new InvalidRedirectUriError( + `Invalid redirect URI scheme "${url.protocol}"`, + ) + } + } + + await this.hooks.onClientData?.(clientId, { metadata, jwks }) + + return { metadata, jwks } + } catch (err) { + if (err instanceof OAuthError) throw err + throw new InvalidClientMetadataError('Unable to obtain metadata', err) + } + } + + /** + * + * @see {@link https://openid.net/specs/openid-connect-registration-1_0.html#rfc.section.2 OIDC Client Registration} + */ + async getClient(clientId: string) { + const { metadata, jwks } = await this.findClient(clientId) + return new Client(clientId, metadata, jwks) + } +} diff --git a/packages/oauth-provider/src/client/client-metadata.ts b/packages/oauth-provider/src/client/client-metadata.ts new file mode 100644 index 00000000000..4ffb83af8c8 --- /dev/null +++ b/packages/oauth-provider/src/client/client-metadata.ts @@ -0,0 +1,101 @@ +import { jwksPubSchema } from '@atproto/jwk' +import { z } from 'zod' + +import { VERIFY_ALGOS } from '../util/crypto.js' +import { clientIdSchema } from './client-id.js' + +export const endpointAuthMethod = z.enum([ + 'client_secret_basic', + 'client_secret_jwt', + 'client_secret_post', + 'none', + 'private_key_jwt', + 'self_signed_tls_client_auth', + 'tls_client_auth', +]) + +export const algSchema = z.enum(VERIFY_ALGOS) + +// TODO: Move in shared package +// https://openid.net/specs/openid-connect-registration-1_0.html +// https://datatracker.ietf.org/doc/html/rfc7591 +export const clientMetadataSchema = z + .object({ + redirect_uris: z.array(z.string().url()).nonempty().readonly(), + response_types: z + .array( + z.enum([ + // OAuth + 'code', + 'token', + + // OpenID + 'none', + 'id_token', + 'code id_token', + 'id_token token', + 'code token', + 'code id_token token', + ]), + ) + .nonempty() + .default(['code']) + .readonly(), + grant_types: z + .array( + z.enum([ + 'authorization_code', + 'implicit', + 'refresh_token', + 'password', + 'client_credentials', + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'urn:ietf:params:oauth:grant-type:saml2-bearer', + ]), + ) + .nonempty() + // "If omitted, the default behavior is that the client will use only the + // "authorization_code" Grant Type." [RFC7591] + .default(['authorization_code']) + .readonly(), + scope: z.string().optional(), + token_endpoint_auth_method: endpointAuthMethod.default('none'), + token_endpoint_auth_signing_alg: algSchema.optional(), + introspection_endpoint_auth_method: endpointAuthMethod.optional(), + introspection_endpoint_auth_signing_alg: algSchema.optional(), + revocation_endpoint_auth_method: endpointAuthMethod.optional(), + revocation_endpoint_auth_signing_alg: algSchema.optional(), + userinfo_signed_response_alg: algSchema.optional(), + userinfo_encrypted_response_alg: z.string().optional(), + jwks_uri: z.string().url().optional(), + jwks: jwksPubSchema.optional(), + application_type: z.enum(['web', 'native']).default('web'), // default, per spec, is "web" + subject_type: z.enum(['public', 'pairwise']).default('public'), + request_object_signing_alg: z + .union([algSchema, z.literal('none')]) + .optional(), + id_token_signed_response_alg: algSchema.optional(), + authorization_signed_response_alg: algSchema.default('RS256').optional(), + authorization_encrypted_response_enc: z.enum(['A128CBC-HS256']).optional(), + authorization_encrypted_response_alg: algSchema.optional(), + client_id: clientIdSchema.optional(), + client_name: z.string().optional(), + client_uri: z.string().url().optional(), + policy_uri: z.string().url().optional(), + tos_uri: z.string().url().optional(), + logo_uri: z.string().url().optional(), + default_max_age: z.number().optional(), + require_auth_time: z.boolean().optional(), + contacts: z.array(z.string().email()).readonly().optional(), + tls_client_certificate_bound_access_tokens: z.boolean().optional(), + + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 + dpop_bound_access_tokens: z.boolean().optional(), + + // https://datatracker.ietf.org/doc/html/rfc9396#section-14.5 + authorization_details_types: z.array(z.string()).readonly().optional(), + }) + .readonly() + .brand('ClientMetadata') + +export type ClientMetadata = z.infer diff --git a/packages/oauth-provider/src/client/client-store.ts b/packages/oauth-provider/src/client/client-store.ts new file mode 100644 index 00000000000..3984c9912e6 --- /dev/null +++ b/packages/oauth-provider/src/client/client-store.ts @@ -0,0 +1,26 @@ +import { Awaitable } from '../util/awaitable.js' +import { ClientId } from './client-id.js' +import { ClientMetadata } from './client-metadata.js' +import { ClientData } from './client-data.js' + +// Export all types needed to implement the ClientStore interface +export type { ClientId, ClientMetadata, ClientData, Awaitable } + +export interface ClientStore { + findClient(clientId: ClientId): Awaitable +} + +export function isClientStore( + implementation: Record & Partial, +): implementation is Record & ClientStore { + return typeof implementation.findClient === 'function' +} + +export function asClientStore( + implementation?: Record & Partial, +): ClientStore { + if (!implementation || !isClientStore(implementation)) { + throw new Error('Invalid ClientStore implementation') + } + return implementation +} diff --git a/packages/oauth-provider/src/client/client-utils.ts b/packages/oauth-provider/src/client/client-utils.ts new file mode 100644 index 00000000000..f9650c3b665 --- /dev/null +++ b/packages/oauth-provider/src/client/client-utils.ts @@ -0,0 +1,9 @@ +import { InvalidRedirectUriError } from '../errors/invalid-redirect-uri-error.js' + +export function parseRedirectUri(redirectUri: string): URL { + try { + return new URL(redirectUri) + } catch (err) { + throw new InvalidRedirectUriError('Invalid redirect URI', err) + } +} diff --git a/packages/oauth-provider/src/client/client.ts b/packages/oauth-provider/src/client/client.ts new file mode 100644 index 00000000000..8df77541c0f --- /dev/null +++ b/packages/oauth-provider/src/client/client.ts @@ -0,0 +1,220 @@ +import { Jwks } from '@atproto/jwk' +import { + JWTPayload, + JWTVerifyGetKey, + JWTVerifyOptions, + JWTVerifyResult, + KeyLike, + ResolvedKey, + UnsecuredJWT, + UnsecuredResult, + createLocalJWKSet, + createRemoteJWKSet, + jwtVerify, +} from 'jose' +import { JOSEError } from 'jose/errors' + +import { CLIENT_ASSERTION_MAX_AGE, JAR_MAX_AGE } from '../constants.js' +import { InvalidClientMetadataError } from '../errors/invalid-client-metadata-error.js' +import { InvalidRequestError } from '../errors/invalid-request-error.js' +import { ClientAuth, authJwkThumbprint } from './client-auth.js' +import { + CLIENT_ASSERTION_TYPE_JWT_BEARER, + ClientIdentification, +} from './client-credentials.js' +import { ClientId } from './client-id.js' +import { ClientMetadata } from './client-metadata.js' + +type AuthEndpoint = 'token' | 'introspection' | 'revocation' + +export class Client { + /** + * @see {@link https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#token-endpoint-auth-method} + */ + static readonly AUTH_METHODS_SUPPORTED = ['none', 'private_key_jwt'] as const + + private readonly keyGetter: JWTVerifyGetKey + + constructor( + public readonly id: ClientId, + public readonly metadata: ClientMetadata, + jwks: undefined | Jwks = metadata.jwks, + ) { + // If the remote JWKS content is provided, we don't need to fetch it again. + this.keyGetter = + jwks || !metadata.jwks_uri + ? // @ts-expect-error https://github.com/panva/jose/issues/634 + createLocalJWKSet(jwks || { keys: [] }) + : createRemoteJWKSet(new URL(metadata.jwks_uri), {}) + } + + async decodeRequestObject(jar: string) { + switch (this.metadata.request_object_signing_alg) { + case 'none': + return this.jwtVerifyUnsecured(jar, { + maxTokenAge: JAR_MAX_AGE / 1000, + }) + case undefined: + // https://openid.net/specs/openid-connect-registration-1_0.html#rfc.section.2 + // > The default, if omitted, is that any algorithm supported by the OP + // > and the RP MAY be used. + return this.jwtVerify(jar, { + maxTokenAge: JAR_MAX_AGE / 1000, + }) + default: + return this.jwtVerify(jar, { + maxTokenAge: JAR_MAX_AGE / 1000, + algorithms: [this.metadata.request_object_signing_alg], + }) + } + } + + async jwtVerifyUnsecured( + token: string, + options?: Omit, + ): Promise> { + return UnsecuredJWT.decode(token, { + ...options, + issuer: this.id, + }) + } + + async jwtVerify( + token: string, + options?: Omit, + ): Promise & ResolvedKey> { + return jwtVerify(token, this.keyGetter, { + ...options, + issuer: this.id, + }) + } + + getAuthMethod(endpoint: AuthEndpoint) { + return ( + this.metadata[`${endpoint}_endpoint_auth_method`] || + this.metadata[`token_endpoint_auth_method`] + ) + } + + /** + * @see {@link https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1} + * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-jwt-bearer-11#section-3} + * @see {@link https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#token-endpoint-auth-method} + */ + async verifyCredentials( + input: ClientIdentification, + endpoint: AuthEndpoint, + checks: { + audience: string + }, + ): Promise<{ + clientAuth: ClientAuth + // for replay protection + nonce?: string + }> { + const method = this.getAuthMethod(endpoint) + + if (method === 'none') { + const clientAuth: ClientAuth = { method: 'none' } + return { clientAuth } + } + + if (method === 'private_key_jwt') { + if (!('client_assertion_type' in input) || !input.client_assertion_type) { + throw new InvalidRequestError( + `client_assertion_type required for "${method}"`, + ) + } else if (!input.client_assertion) { + throw new InvalidRequestError( + `client_assertion required for "${method}"`, + ) + } + + if (input.client_assertion_type === CLIENT_ASSERTION_TYPE_JWT_BEARER) { + const result = await this.jwtVerify<{ + jti: string + }>(input.client_assertion, { + audience: checks.audience, + subject: this.id, + maxTokenAge: CLIENT_ASSERTION_MAX_AGE / 1000, + }).catch((err) => { + if (err instanceof JOSEError) { + const msg = `Invalid "client_assertion": ${err.message}` + throw new InvalidRequestError(msg, err) + } + + throw err + }) + + if (!result.protectedHeader.kid) { + throw new InvalidRequestError(`"kid" required in client_assertion`) + } + + if (!result.payload.jti) { + throw new InvalidRequestError(`"jti" required in client_assertion`) + } + + const clientAuth: ClientAuth = { + method: CLIENT_ASSERTION_TYPE_JWT_BEARER, + jkt: await authJwkThumbprint(result.key), + alg: result.protectedHeader.alg, + kid: result.protectedHeader.kid, + } + + return { clientAuth, nonce: result.payload.jti } + } + + throw new InvalidRequestError( + `Unsupported client_assertion_type "${input.client_assertion_type}"`, + ) + } + + // @ts-expect-error Ensure to keep Client.AUTH_METHODS_SUPPORTED in sync + if (Client.AUTH_METHODS_SUPPORTED.includes(method)) { + throw new Error( + `verifyCredentials() should implement all of ${[ + Client.AUTH_METHODS_SUPPORTED, + ]}`, + ) + } + + throw new InvalidClientMetadataError( + `Unsupported ${endpoint}_endpoint_auth_method "${method}"`, + ) + } + + /** + * Ensures that a {@link ClientAuth} generated in the past is still valid wrt + * the current client metadata & jwks. This is used to invalidate tokens when + * the client stops advertising the key that it used to authenticate itself + * during the initial token request. + */ + async validateClientAuth(clientAuth: ClientAuth): Promise { + if (clientAuth.method === 'none') { + return this.getAuthMethod('token') === 'none' + } + + if (clientAuth.method === CLIENT_ASSERTION_TYPE_JWT_BEARER) { + if (this.getAuthMethod('token') !== 'private_key_jwt') { + return false + } + try { + const key = await this.keyGetter( + { + kid: clientAuth.kid, + alg: clientAuth.alg, + }, + { payload: '', signature: '' }, + ) + const jtk = await authJwkThumbprint(key) + + return jtk === clientAuth.jkt + } catch (e) { + return false + } + } + + // @ts-expect-error + throw new Error(`Invalid method "${clientAuth.method}"`) + } +} diff --git a/packages/oauth-provider/src/constants.ts b/packages/oauth-provider/src/constants.ts new file mode 100644 index 00000000000..767544c9aac --- /dev/null +++ b/packages/oauth-provider/src/constants.ts @@ -0,0 +1,59 @@ +// The purpose of the prefix is to provide type safety + +export const DEVICE_ID_PREFIX = 'dev-' +export const DEVICE_ID_BYTES_LENGTH = 16 // 128 bits + +export const SESSION_ID_PREFIX = 'ses-' +export const SESSION_ID_BYTES_LENGTH = 16 // 128 bits - only valid if device id is valid + +export const REFRESH_TOKEN_PREFIX = 'ref-' +export const REFRESH_TOKEN_BYTES_LENGTH = 32 // 256 bits + +export const TOKEN_ID_PREFIX = 'tok-' +export const TOKEN_ID_BYTES_LENGTH = 16 // 128 bits - used as `jti` in JWTs (cannot be forged) + +export const REQUEST_ID_PREFIX = 'req-' +export const REQUEST_ID_BYTES_LENGTH = 16 // 128 bits + +export const CODE_PREFIX = 'cod-' +export const CODE_BYTES_LENGTH = 32 + +const SECOND = 1e3 +const MINUTE = 60 * SECOND +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR +const YEAR = 365.25 * DAY +const MONTH = YEAR / 12 + +/** 7 days */ +export const AUTH_MAX_AGE = 7 * DAY + +/** 60 minutes */ +export const TOKEN_MAX_AGE = 60 * MINUTE + +/** 5 minutes */ +export const AUTHORIZATION_INACTIVITY_TIMEOUT = 5 * MINUTE + +/** 1 months */ +export const AUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT = 1 * MONTH + +/** 2 days */ +export const UNAUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT = 2 * DAY + +/** 1 year */ +export const TOTAL_REFRESH_LIFETIME = 1 * YEAR + +/** 5 minutes */ +export const PAR_EXPIRES_IN = 5 * MINUTE + +/** 1 minute */ +export const JAR_MAX_AGE = 1 * MINUTE + +/** 1 minute */ +export const CLIENT_ASSERTION_MAX_AGE = 1 * MINUTE + +/** 3 minutes */ +export const DPOP_NONCE_MAX_AGE = 3 * MINUTE + +/** 5 seconds */ +export const SESSION_FIXATION_MAX_AGE = 5 * SECOND diff --git a/packages/oauth-provider/src/device/device-details.ts b/packages/oauth-provider/src/device/device-details.ts new file mode 100644 index 00000000000..9d0767ccbe9 --- /dev/null +++ b/packages/oauth-provider/src/device/device-details.ts @@ -0,0 +1,43 @@ +import { IncomingMessage } from 'node:http' + +import { z } from 'zod' + +export const devideDetailsSchema = z.object({ + userAgent: z.string().nullable(), + ipAddress: z.string(), +}) +export type DeviceDetails = z.infer + +export function extractDeviceDetails( + req: IncomingMessage, + trustProxy: boolean, +): DeviceDetails { + const userAgent = req.headers['user-agent'] || null + const ipAddress = extractIpAddress(req, trustProxy) || null + + if (!ipAddress) { + throw new Error('Could not determine IP address') + } + + return { userAgent, ipAddress } +} + +export function extractIpAddress( + req: IncomingMessage, + trustProxy: boolean, +): string | undefined { + // Express app compatibility + if ('ip' in req && typeof req.ip === 'string') { + return req.ip + } + + if (trustProxy) { + const forwardedFor = req.headers['x-forwarded-for'] + if (typeof forwardedFor === 'string') { + const firstForward = forwardedFor.split(',')[0]!.trim() + if (firstForward) return firstForward + } + } + + return req.socket.remoteAddress +} diff --git a/packages/oauth-provider/src/device/device-id.ts b/packages/oauth-provider/src/device/device-id.ts new file mode 100644 index 00000000000..36fc03c942f --- /dev/null +++ b/packages/oauth-provider/src/device/device-id.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +import { DEVICE_ID_BYTES_LENGTH, DEVICE_ID_PREFIX } from '../constants.js' +import { randomHexId } from '../util/crypto.js' + +export const deviceIdSchema = z + .string() + .length( + DEVICE_ID_PREFIX.length + DEVICE_ID_BYTES_LENGTH * 2, // hex encoding + ) + .refine( + (v): v is `${typeof DEVICE_ID_PREFIX}${string}` => + v.startsWith(DEVICE_ID_PREFIX), + { + message: `Invalid device ID format`, + }, + ) + +export type DeviceId = z.infer +export const generateDeviceId = async (): Promise => { + return `${DEVICE_ID_PREFIX}${await randomHexId(DEVICE_ID_BYTES_LENGTH)}` +} diff --git a/packages/oauth-provider/src/dpop/dpop-manager.ts b/packages/oauth-provider/src/dpop/dpop-manager.ts new file mode 100644 index 00000000000..e51b1fe3498 --- /dev/null +++ b/packages/oauth-provider/src/dpop/dpop-manager.ts @@ -0,0 +1,130 @@ +import { createHash } from 'node:crypto' + +import { EmbeddedJWK, calculateJwkThumbprint, jwtVerify } from 'jose' + +import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' +import { UseDpopNonceError } from '../errors/use-dpop-nonce-error.js' +import { DpopNonce } from './dpop-nonce.js' + +export { DpopNonce } +export type DpopManagerOptions = { + /** + * Set this to `false` to disable the use of DPoP nonces. Set this to a secret + * Uint8Array to use a predictable seed for all nonces (typically useful when + * multiple instances are running). Leave undefined to generate a random seed + * at startup. + */ + dpopNonce?: false | Uint8Array | DpopNonce +} + +export class DpopManager { + protected readonly dpopNonce?: DpopNonce + + constructor({ dpopNonce = new DpopNonce() }: DpopManagerOptions = {}) { + this.dpopNonce = + dpopNonce instanceof Uint8Array + ? new DpopNonce(dpopNonce) + : dpopNonce || undefined + } + + nextNonce(): string | undefined { + return this.dpopNonce?.next() + } + + /** + * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3} + */ + async checkProof( + proof: unknown, + htm: string, // HTTP Method + htu: string | URL, // HTTP URL + accessToken?: string, // Access Token + ) { + if (Array.isArray(proof) && proof.length === 1) { + proof = proof[0] + } + + if (!proof || typeof proof !== 'string') { + throw new InvalidDpopProofError('DPoP proof required') + } + + const { protectedHeader, payload } = await jwtVerify<{ + iat: number + jti: string + }>(proof, EmbeddedJWK, { + typ: 'dpop+jwt', + maxTokenAge: 300, + requiredClaims: ['iat', 'jti'], + }).catch((err) => { + throw new InvalidDpopProofError('DPoP key mismatch', err) + }) + + if (!payload.jti || typeof payload.jti !== 'string') { + throw new InvalidDpopProofError('Invalid or missing jti property') + } + + // Note rfc9110#section-9.1 states that the method name is case-sensitive + if (!htm || htm !== payload['htm']) { + throw new InvalidDpopProofError('DPoP htm mismatch') + } + + if ( + payload['nonce'] !== undefined && + typeof payload['nonce'] !== 'string' + ) { + throw new InvalidDpopProofError('DPoP nonce must be a string') + } + + if (!payload['nonce'] && this.dpopNonce) { + throw new UseDpopNonceError() + } + + if (payload['nonce'] && !this.dpopNonce?.check(payload['nonce'])) { + throw new UseDpopNonceError() + } + + const htuNorm = normalizeHtu(htu) + if (!htuNorm || htuNorm !== normalizeHtu(payload['htu'])) { + throw new InvalidDpopProofError('DPoP htu mismatch') + } + + if (accessToken) { + const athBuffer = createHash('sha256').update(accessToken).digest() + if (payload['ath'] !== athBuffer.toString('base64url')) { + throw new InvalidDpopProofError('DPoP ath mismatch') + } + } else if (payload['ath']) { + throw new InvalidDpopProofError('DPoP ath not allowed') + } + + return { + protectedHeader, + payload, + jkt: await calculateJwkThumbprint(protectedHeader['jwk']!, 'sha256'), // EmbeddedJWK + } + } +} + +/** + * @note + * > The htu claim matches the HTTP URI value for the HTTP request in which the + * > JWT was received, ignoring any query and fragment parts. + * + * > To reduce the likelihood of false negatives, servers SHOULD employ + * > syntax-based normalization (Section 6.2.2 of [RFC3986]) and scheme-based + * > normalization (Section 6.2.3 of [RFC3986]) before comparing the htu claim. + * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3 | RFC9449 section 4.3. Checking DPoP Proofs} + */ +function normalizeHtu(htu: unknown): string | null { + // Optimization + if (!htu) return null + + try { + const url = new URL(String(htu)) + url.hash = '' + url.search = '' + return url.href + } catch { + return null + } +} diff --git a/packages/oauth-provider/src/dpop/dpop-nonce.ts b/packages/oauth-provider/src/dpop/dpop-nonce.ts new file mode 100644 index 00000000000..851b7c7c8d3 --- /dev/null +++ b/packages/oauth-provider/src/dpop/dpop-nonce.ts @@ -0,0 +1,86 @@ +import { createHmac, randomBytes } from 'node:crypto' + +import { DPOP_NONCE_MAX_AGE } from '../constants.js' + +function numTo64bits(num: number) { + const arr = new Uint8Array(8) + arr[7] = (num = num | 0) & 0xff + arr[6] = (num >>= 8) & 0xff + arr[5] = (num >>= 8) & 0xff + arr[4] = (num >>= 8) & 0xff + arr[3] = (num >>= 8) & 0xff + arr[2] = (num >>= 8) & 0xff + arr[1] = (num >>= 8) & 0xff + arr[0] = (num >>= 8) & 0xff + return arr +} + +export class DpopNonce { + #secret: Uint8Array + #counter: number + + #prev: string + #now: string + #next: string + + constructor( + protected readonly secret: Uint8Array = randomBytes(32), + protected readonly step = DPOP_NONCE_MAX_AGE / 3, + ) { + if (secret.length !== 32) throw new TypeError('Expected 32 bytes') + if (this.step < 0 || this.step > DPOP_NONCE_MAX_AGE / 3) { + throw new TypeError('Invalid step') + } + + this.#secret = Uint8Array.from(secret) + this.#counter = (Date.now() / step) | 0 + + this.#prev = this.compute(this.#counter - 1) + this.#now = this.compute(this.#counter) + this.#next = this.compute(this.#counter + 1) + } + + protected rotate() { + const counter = (Date.now() / this.step) | 0 + switch (counter - this.#counter) { + case 0: + // counter === this.#counter => nothing to do + return + case 1: + // Optimization: avoid recomputing #prev & #now + this.#prev = this.#now + this.#now = this.#next + this.#next = this.compute(counter + 1) + break + case 2: + // Optimization: avoid recomputing #prev + this.#prev = this.#next + this.#now = this.compute(counter) + this.#next = this.compute(counter + 1) + break + default: + // All nonces are outdated, so we recompute all of them + this.#prev = this.compute(counter - 1) + this.#now = this.compute(counter) + this.#next = this.compute(counter + 1) + break + } + this.#counter = counter + } + + protected compute(counter: number) { + return createHmac('sha256', this.#secret) + .update(numTo64bits(counter)) + .digest() + .toString('base64url') + } + + public next() { + this.rotate() + return this.#next + } + + public check(nonce: string) { + return this.#next === nonce || this.#now === nonce || this.#prev === nonce + } +} diff --git a/packages/oauth-provider/src/errors/access-denied-error.ts b/packages/oauth-provider/src/errors/access-denied-error.ts new file mode 100644 index 00000000000..64fd2639b8c --- /dev/null +++ b/packages/oauth-provider/src/errors/access-denied-error.ts @@ -0,0 +1,7 @@ +import { OAuthError } from './oauth-error.js' + +export class AccessDeniedError extends OAuthError { + constructor(error_description: string, cause?: unknown) { + super('access_denied', error_description, 400, cause) + } +} diff --git a/packages/oauth-provider/src/errors/account-selection-required-error.ts b/packages/oauth-provider/src/errors/account-selection-required-error.ts new file mode 100644 index 00000000000..b0df07e87b0 --- /dev/null +++ b/packages/oauth-provider/src/errors/account-selection-required-error.ts @@ -0,0 +1,10 @@ +import { OAuthError } from './oauth-error.js' + +export class AccountSelectionRequiredError extends OAuthError { + constructor( + error_description = 'Account selection required', + cause?: unknown, + ) { + super('account_selection_required', error_description, 401, cause) + } +} diff --git a/packages/oauth-provider/src/errors/consent-required-error.ts b/packages/oauth-provider/src/errors/consent-required-error.ts new file mode 100644 index 00000000000..50c55b88742 --- /dev/null +++ b/packages/oauth-provider/src/errors/consent-required-error.ts @@ -0,0 +1,7 @@ +import { OAuthError } from './oauth-error.js' + +export class ConsentRequiredError extends OAuthError { + constructor(error_description = 'User consent required', cause?: unknown) { + super('consent_required', error_description, 401, cause) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-authorization-details-error.ts b/packages/oauth-provider/src/errors/invalid-authorization-details-error.ts new file mode 100644 index 00000000000..9a9d04765be --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-authorization-details-error.ts @@ -0,0 +1,10 @@ +import { OAuthError } from './oauth-error.js' + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-14.6 | RFC 9396, Section 14.6} + */ +export class InvalidAuthorizationDetailsError extends OAuthError { + constructor(error_description: string, cause?: unknown) { + super('invalid_authorization_details', error_description, 400, cause) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-client-error.ts b/packages/oauth-provider/src/errors/invalid-client-error.ts new file mode 100644 index 00000000000..2dd191c5b53 --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-client-error.ts @@ -0,0 +1,10 @@ +import { OAuthError } from './oauth-error.js' + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2 | RFC7591} + */ +export abstract class InvalidClientError extends OAuthError { + constructor(error: string, error_description: string, cause?: unknown) { + super(error, error_description, 400, cause) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-client-metadata-error.ts b/packages/oauth-provider/src/errors/invalid-client-metadata-error.ts new file mode 100644 index 00000000000..65d841a01b9 --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-client-metadata-error.ts @@ -0,0 +1,10 @@ +import { InvalidClientError } from './invalid-client-error.js' + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2 | RFC7591} + */ +export class InvalidClientMetadataError extends InvalidClientError { + constructor(error_description: string, cause?: unknown) { + super('invalid_client_metadata', error_description, cause) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-dpop-key-binding.ts b/packages/oauth-provider/src/errors/invalid-dpop-key-binding.ts new file mode 100644 index 00000000000..8104b0bbd3d --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-dpop-key-binding.ts @@ -0,0 +1,7 @@ +import { InvalidTokenError } from './invalid-token-error.js' + +export class InvalidDpopKeyBindingError extends InvalidTokenError { + constructor(cause?: unknown) { + super('Invalid DPoP key binding', { DPoP: {} }, cause) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-dpop-proof-error.ts b/packages/oauth-provider/src/errors/invalid-dpop-proof-error.ts new file mode 100644 index 00000000000..47309b5ecfb --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-dpop-proof-error.ts @@ -0,0 +1,14 @@ +import { WWWAuthenticateError } from './www-authenticate-error.js' + +export class InvalidDpopProofError extends WWWAuthenticateError { + constructor(error_description: string, cause?: unknown) { + const error = 'invalid_dpop_proof' + super( + error, + error_description, + 400, + { DPoP: { error, error_description } }, + cause, + ) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-redirect-uri-error.ts b/packages/oauth-provider/src/errors/invalid-redirect-uri-error.ts new file mode 100644 index 00000000000..ca666cbdf72 --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-redirect-uri-error.ts @@ -0,0 +1,10 @@ +import { InvalidClientError } from './invalid-client-error.js' + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2 | RFC7591} + */ +export class InvalidRedirectUriError extends InvalidClientError { + constructor(error_description: string, cause?: unknown) { + super('invalid_redirect_uri', error_description, cause) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-request-error.ts b/packages/oauth-provider/src/errors/invalid-request-error.ts new file mode 100644 index 00000000000..1508835edf3 --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-request-error.ts @@ -0,0 +1,7 @@ +import { OAuthError } from './oauth-error.js' + +export class InvalidRequestError extends OAuthError { + constructor(error_description: string, cause?: unknown) { + super('invalid_request', error_description, 400, cause) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-token-error.ts b/packages/oauth-provider/src/errors/invalid-token-error.ts new file mode 100644 index 00000000000..d201cbc040e --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-token-error.ts @@ -0,0 +1,30 @@ +import { JOSEError } from 'jose/errors' +import { ZodError } from 'zod' + +import { UnauthorizedError, WWWAuthenticate } from './unauthorized-error.js' + +export class InvalidTokenError extends UnauthorizedError { + static from( + err: unknown, + wwwAuthenticate: WWWAuthenticate, + fallbackMessage = 'Invalid token', + ): InvalidTokenError { + if (err instanceof JOSEError) { + throw new InvalidTokenError(err.message, wwwAuthenticate, err) + } + + if (err instanceof ZodError) { + throw new InvalidTokenError(err.message, wwwAuthenticate, err) + } + + throw new InvalidTokenError(fallbackMessage, wwwAuthenticate, err) + } + + constructor( + error_description: string, + wwwAuthenticate: WWWAuthenticate, + cause?: unknown, + ) { + super(error_description, wwwAuthenticate, cause) + } +} diff --git a/packages/oauth-provider/src/errors/login-required-error.ts b/packages/oauth-provider/src/errors/login-required-error.ts new file mode 100644 index 00000000000..8436c849840 --- /dev/null +++ b/packages/oauth-provider/src/errors/login-required-error.ts @@ -0,0 +1,7 @@ +import { OAuthError } from './oauth-error.js' + +export class LoginRequiredError extends OAuthError { + constructor(error_description = 'Login is required', cause?: unknown) { + super('login_required', error_description, 401, cause) + } +} diff --git a/packages/oauth-provider/src/errors/oauth-error.ts b/packages/oauth-provider/src/errors/oauth-error.ts new file mode 100644 index 00000000000..9b14de44a5b --- /dev/null +++ b/packages/oauth-provider/src/errors/oauth-error.ts @@ -0,0 +1,28 @@ +export class OAuthError extends Error { + public expose: boolean + + constructor( + public readonly error: string, + public readonly error_description: string, + public readonly status = 400, + cause?: unknown, + ) { + super(error_description, { cause }) + + Error.captureStackTrace?.(this, this.constructor) + + this.name = this.constructor.name + this.expose = status < 500 + } + + get statusCode() { + return this.status + } + + toJSON() { + return { + error: this.error, + error_description: this.error_description, + } as const + } +} diff --git a/packages/oauth-provider/src/errors/unauthorized-client-error.ts b/packages/oauth-provider/src/errors/unauthorized-client-error.ts new file mode 100644 index 00000000000..29f40a87424 --- /dev/null +++ b/packages/oauth-provider/src/errors/unauthorized-client-error.ts @@ -0,0 +1,7 @@ +import { OAuthError } from './oauth-error.js' + +export class UnauthorizedClientError extends OAuthError { + constructor(error_description: string, cause?: unknown) { + super('unauthorized_client', error_description, 401, cause) + } +} diff --git a/packages/oauth-provider/src/errors/unauthorized-dpop-error.ts b/packages/oauth-provider/src/errors/unauthorized-dpop-error.ts new file mode 100644 index 00000000000..a8f05fccfbe --- /dev/null +++ b/packages/oauth-provider/src/errors/unauthorized-dpop-error.ts @@ -0,0 +1,7 @@ +import { UnauthorizedError } from './unauthorized-error.js' + +export class UnauthorizedDpopError extends UnauthorizedError { + constructor(cause?: unknown) { + super('DPoP proof required', { DPoP: {} }, cause) + } +} diff --git a/packages/oauth-provider/src/errors/unauthorized-error.ts b/packages/oauth-provider/src/errors/unauthorized-error.ts new file mode 100644 index 00000000000..86034d6e3ed --- /dev/null +++ b/packages/oauth-provider/src/errors/unauthorized-error.ts @@ -0,0 +1,21 @@ +import { + WWWAuthenticate, + WWWAuthenticateParams, + WWWAuthenticateError, +} from './www-authenticate-error.js' + +export type { WWWAuthenticate, WWWAuthenticateParams } + +/** + * @see {@link https://www.rfc-editor.org/rfc/rfc6750.html} + * @see {@link https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1} + */ +export class UnauthorizedError extends WWWAuthenticateError { + constructor( + error_description: string, + wwwAuthenticate: WWWAuthenticate, + cause?: unknown, + ) { + super('unauthorized', error_description, 401, wwwAuthenticate, cause) + } +} diff --git a/packages/oauth-provider/src/errors/use-dpop-nonce-error.ts b/packages/oauth-provider/src/errors/use-dpop-nonce-error.ts new file mode 100644 index 00000000000..57f9547f37a --- /dev/null +++ b/packages/oauth-provider/src/errors/use-dpop-nonce-error.ts @@ -0,0 +1,13 @@ +import { OAuthError } from './oauth-error.js' + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-8} + */ +export class UseDpopNonceError extends OAuthError { + constructor( + error_description = 'Authorization server requires nonce in DPoP proof', + cause?: unknown, + ) { + super('use_dpop_nonce', error_description, 400, cause) + } +} diff --git a/packages/oauth-provider/src/errors/www-authenticate-error.ts b/packages/oauth-provider/src/errors/www-authenticate-error.ts new file mode 100644 index 00000000000..950aae9b9fa --- /dev/null +++ b/packages/oauth-provider/src/errors/www-authenticate-error.ts @@ -0,0 +1,66 @@ +import { VERIFY_ALGOS } from '../util/crypto.js' + +import { OAuthError } from './oauth-error.js' + +export type WWWAuthenticateParams = Record +export type WWWAuthenticate = Record + +export class WWWAuthenticateError extends OAuthError { + public readonly wwwAuthenticate: WWWAuthenticate + + constructor( + error: string, + error_description: string, + status: number, + wwwAuthenticate: WWWAuthenticate, + cause?: unknown, + ) { + super(error, error_description, status, cause) + + this.wwwAuthenticate = + wwwAuthenticate['DPoP'] != null + ? { + ...wwwAuthenticate, + DPoP: { algs: VERIFY_ALGOS.join(' '), ...wwwAuthenticate['DPoP'] }, + } + : wwwAuthenticate + } + + get wwwAuthenticateHeader() { + return formatWWWAuthenticateHeader(this.wwwAuthenticate) + } +} + +function formatWWWAuthenticateHeader(wwwAuthenticate: WWWAuthenticate): string { + return Object.entries(wwwAuthenticate) + .filter(isWWWAuthenticateEntry) + .map(wwwAuthenticateEntryToString) + .join(', ') +} + +type WWWAuthenticateEntry = [type: string, params: WWWAuthenticateParams] +function isWWWAuthenticateEntry( + entry: [string, unknown], +): entry is WWWAuthenticateEntry { + const [, value] = entry + return value != null && typeof value === 'object' +} + +function wwwAuthenticateEntryToString([type, params]: WWWAuthenticateEntry) { + const paramsEnc = Object.entries(params) + .filter(isParamEntry) + .map(paramEntryToString) + + return paramsEnc.length ? `${type} ${paramsEnc.join(', ')}` : type +} + +type ParamEntry = [name: string, value: string] + +function isParamEntry(entry: [string, unknown]): entry is ParamEntry { + const [, value] = entry + return typeof value === 'string' && value !== '' && !value.includes('"') +} + +function paramEntryToString([name, value]: ParamEntry): string { + return `${name}="${value}"` +} diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts new file mode 100644 index 00000000000..b42d6e5c16e --- /dev/null +++ b/packages/oauth-provider/src/index.ts @@ -0,0 +1,8 @@ +export * from './constants.js' +export * from './oauth-client.js' +export * from './oauth-dpop.js' +export * from './oauth-errors.js' +export * from './oauth-hooks.js' +export * from './oauth-provider.js' +export * from './oauth-store.js' +export * from './oauth-verifier.js' diff --git a/packages/oauth-provider/src/metadata/build-metadata.ts b/packages/oauth-provider/src/metadata/build-metadata.ts new file mode 100644 index 00000000000..bdf2f063d2a --- /dev/null +++ b/packages/oauth-provider/src/metadata/build-metadata.ts @@ -0,0 +1,155 @@ +import { Keyset } from '@atproto/jwk' + +import { Client } from '../client/client.js' +import { OIDC_STANDARD_CLAIMS } from '../oidc/claims.js' +import { VERIFY_ALGOS } from '../util/crypto.js' + +export type CustomMetadata = { + claims_supported?: string[] + scopes_supported?: string[] + authorization_details_types_supported?: string[] +} + +// TODO: Load from shared package (with client class) +export type Metadata = ReturnType + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc8414#section-2} + * @see {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata} + */ +export function buildMetadata( + issuer: string, + keyset: Keyset, + customMetadata?: CustomMetadata, +) { + return { + issuer: issuer, + + scopes_supported: [ + 'openid', + 'email', + 'phone', + 'profile', + + ...(customMetadata?.scopes_supported ?? []), + ], + claims_supported: [ + /* IESG (Always provided) */ + + 'sub', // did + 'iss', // Authorization Server Origin + 'aud', + 'exp', + 'iat', + 'jti', + 'client_id', + + /* OpenID */ + + // 'acr', // "0" + // 'amr', + // 'azp', + 'auth_time', // number - seconds since epoch + 'nonce', // always required in "id_token", why would it not be supported? + + ...(customMetadata?.claims_supported ?? OIDC_STANDARD_CLAIMS), + ], + subject_types_supported: [ + // + 'public', // The same "sub" is returned for all clients + // 'pairwise', // A different "sub" is returned for each client + ], + response_types_supported: [ + // + 'none', + 'code', + 'token', + 'id_token', + 'id_token token', + 'code id_token', + 'code token', + 'code id_token token', + ], + response_modes_supported: [ + // https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes + 'query', + 'fragment', + // https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html#FormPostResponseMode + 'form_post', + ], + grant_types_supported: [ + // + 'authorization_code', + 'refresh_token', + 'password', + ], + code_challenge_methods_supported: [ + // https://www.iana.org/assignments/oauth-parameters/oauth-parameters.xhtml#pkce-code-challenge-method + 'S256', + 'plain', + ], + ui_locales_supported: [ + // + 'en-US', + ], + id_token_signing_alg_values_supported: [...keyset.signAlgorithms], + display_values_supported: [ + // + 'page', + 'popup', + 'touch', + // 'wap', LoL + ], + + // https://datatracker.ietf.org/doc/html/rfc9207 + authorization_response_iss_parameter_supported: true, + + // https://datatracker.ietf.org/doc/html/rfc9101#section-4 + request_object_signing_alg_values_supported: [...VERIFY_ALGOS, 'none'], + request_object_encryption_alg_values_supported: [], // None + request_object_encryption_enc_values_supported: [], // None + + // No claim makes sense to be translated + claims_locales_supported: [], + + claims_parameter_supported: true, + request_parameter_supported: true, + request_uri_parameter_supported: true, + require_request_uri_registration: true, + + jwks_uri: new URL('/oauth/jwks', issuer), + + authorization_endpoint: new URL('/oauth/authorize', issuer), + + token_endpoint: new URL('/oauth/token', issuer), + token_endpoint_auth_methods_supported: [...Client.AUTH_METHODS_SUPPORTED], + token_endpoint_auth_signing_alg_values_supported: [...VERIFY_ALGOS], + + revocation_endpoint: new URL('/oauth/revoke', issuer), + revocation_endpoint_auth_methods_supported: [ + ...Client.AUTH_METHODS_SUPPORTED, + ], + revocation_endpoint_auth_signing_alg_values_supported: [...VERIFY_ALGOS], + + introspection_endpoint: new URL('/oauth/introspect', issuer), + introspection_endpoint_auth_methods_supported: [ + ...Client.AUTH_METHODS_SUPPORTED, + ], + introspection_endpoint_auth_signing_alg_values_supported: [...VERIFY_ALGOS], + + userinfo_endpoint: new URL('/oauth/userinfo', issuer), + // end_session_endpoint: new URL('/oauth/logout', issuer), + + // https://datatracker.ietf.org/doc/html/rfc9126#section-5 + pushed_authorization_request_endpoint: new URL('/oauth/par', issuer), + + require_pushed_authorization_requests: true, + + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.1 + dpop_signing_alg_values_supported: [...VERIFY_ALGOS], + + // https://datatracker.ietf.org/doc/html/rfc9396#section-14.4 + authorization_details_types_supported: + customMetadata?.authorization_details_types_supported, + } +} diff --git a/packages/oauth-provider/src/oauth-client.ts b/packages/oauth-provider/src/oauth-client.ts new file mode 100644 index 00000000000..73be8e6ca85 --- /dev/null +++ b/packages/oauth-provider/src/oauth-client.ts @@ -0,0 +1,2 @@ +export * from './client/client-metadata.js' +export * from './client/client-utils.js' diff --git a/packages/oauth-provider/src/oauth-dpop.ts b/packages/oauth-provider/src/oauth-dpop.ts new file mode 100644 index 00000000000..8ba4e7aad84 --- /dev/null +++ b/packages/oauth-provider/src/oauth-dpop.ts @@ -0,0 +1,2 @@ +export * from './dpop/dpop-nonce.js' +export * from './dpop/dpop-manager.js' diff --git a/packages/oauth-provider/src/oauth-errors.ts b/packages/oauth-provider/src/oauth-errors.ts new file mode 100644 index 00000000000..90bcf8af0a3 --- /dev/null +++ b/packages/oauth-provider/src/oauth-errors.ts @@ -0,0 +1,19 @@ +// Root Error class +export { OAuthError } from './errors/oauth-error.js' + +export { AccessDeniedError } from './errors/access-denied-error.js' +export { AccountSelectionRequiredError } from './errors/account-selection-required-error.js' +export { ConsentRequiredError } from './errors/consent-required-error.js' +export { InvalidAuthorizationDetailsError } from './errors/invalid-authorization-details-error.js' +export { InvalidClientError } from './errors/invalid-client-error.js' +export { InvalidClientMetadataError } from './errors/invalid-client-metadata-error.js' +export { InvalidDpopKeyBindingError } from './errors/invalid-dpop-key-binding.js' +export { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js' +export { InvalidRedirectUriError } from './errors/invalid-redirect-uri-error.js' +export { InvalidRequestError } from './errors/invalid-request-error.js' +export { InvalidTokenError } from './errors/invalid-token-error.js' +export { LoginRequiredError } from './errors/login-required-error.js' +export { UnauthorizedClientError } from './errors/unauthorized-client-error.js' +export { UnauthorizedDpopError } from './errors/unauthorized-dpop-error.js' +export { UnauthorizedError } from './errors/unauthorized-error.js' +export { UseDpopNonceError } from './errors/use-dpop-nonce-error.js' diff --git a/packages/oauth-provider/src/oauth-hooks.ts b/packages/oauth-provider/src/oauth-hooks.ts new file mode 100644 index 00000000000..31bb8eef23f --- /dev/null +++ b/packages/oauth-provider/src/oauth-hooks.ts @@ -0,0 +1,9 @@ +/** + * This file exposes all the hooks that can be used when instantiating the + * OAuthProvider. + */ + +export { type AuthorizationDetailsHook } from './token/token-manager.js' +export { type ClientDataHook } from './client/client-manager.js' +export { type TokenResponseHook } from './token/token-manager.js' +export { type AuthorizationRequestHook } from './request/request-manager.js' diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts new file mode 100644 index 00000000000..d53c7d5d34a --- /dev/null +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -0,0 +1,1281 @@ +import { + Handler, + IncomingMessage, + Middleware, + Router, + ServerResponse, + acceptMiddleware, + combine as combineMiddlewares, + setupCsrfToken, + staticJsonHandler, + validateCsrfToken, + validateFetchMode, + validateReferer, + validateRequestPayload, + validateSameOrigin, + writeJson, +} from '@atproto/http-util' +import { Jwks, Jwt, Keyset, jwtSchema } from '@atproto/jwk' +import { JWTHeaderParameters, ResolvedKey, decodeJwt } from 'jose' +import { z } from 'zod' + +import { AccessTokenType } from './access-token/access-token-type.js' +import { AccessToken } from './access-token/access-token.js' +import { AccountManager } from './account/account-manager.js' +import { + AccountInfo, + AccountStore, + DeviceAccountInfo, + LoginCredentials, + asAccountStore, +} from './account/account-store.js' +import { Account } from './account/account.js' +import { ClientAuth, authJwkThumbprint } from './client/client-auth.js' +import { + CLIENT_ASSERTION_TYPE_JWT_BEARER, + ClientIdentification, +} from './client/client-credentials.js' +import { ClientId, clientIdSchema } from './client/client-id.js' +import { ClientDataHook, ClientManager } from './client/client-manager.js' +import { ClientStore, asClientStore } from './client/client-store.js' +import { Client } from './client/client.js' +import { AUTH_MAX_AGE, TOKEN_MAX_AGE } from './constants.js' +import { DeviceId } from './device/device-id.js' +import { AccessDeniedError } from './errors/access-denied-error.js' +import { AccountSelectionRequiredError } from './errors/account-selection-required-error.js' +import { ConsentRequiredError } from './errors/consent-required-error.js' +import { InvalidRequestError } from './errors/invalid-request-error.js' +import { LoginRequiredError } from './errors/login-required-error.js' +import { OAuthError } from './errors/oauth-error.js' +import { UnauthorizedClientError } from './errors/unauthorized-client-error.js' +import { WWWAuthenticateError } from './errors/www-authenticate-error.js' +import { + CustomMetadata, + Metadata, + buildMetadata, +} from './metadata/build-metadata.js' +import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js' +import { Userinfo } from './oidc/userinfo.js' +import { + buildErrorPayload, + buildErrorStatus, +} from './output/build-error-payload.js' +import { + AuthorizationResultAuthorize, + authorizeAssetsMiddleware, + sendAuthorizePage, +} from './output/send-authorize-page.js' +import { + AuthorizationResultRedirect, + sendAuthorizeRedirect, +} from './output/send-authorize-redirect.js' +import { sendErrorPage } from './output/send-error-page.js' +import { + AuthorizationParameters, + authorizationParametersSchema, +} from './parameters/authorization-parameters.js' +import { oidcPayload } from './parameters/oidc-payload.js' +import { ReplayStore, asReplayStore } from './replay/replay-store.js' +import { + AuthorizationRequestHook, + RequestManager, +} from './request/request-manager.js' +import { RequestStoreMemory } from './request/request-store-memory.js' +import { RequestStore, isRequestStore } from './request/request-store.js' +import { RequestUri, requestUriSchema } from './request/request-uri.js' +import { + AuthorizationRequestJar, + AuthorizationRequestQuery, + PushedAuthorizationRequest, + authorizationRequestQuerySchema, + pushedAuthorizationRequestSchema, +} from './request/types.js' +import { SessionManager } from './session/session-manager.js' +import { SessionStore, asSessionStore } from './session/session-store.js' +import { isTokenId } from './token/token-id.js' +import { + AuthorizationDetailsHook, + TokenManager, + TokenResponse, + TokenResponseHook, +} from './token/token-manager.js' +import { TokenInfo, TokenStore, asTokenStore } from './token/token-store.js' +import { TokenType } from './token/token-type.js' +import { + CodeGrantRequest, + Introspect, + IntrospectionResponse, + PasswordGrantRequest, + RefreshGrantRequest, + Revoke, + TokenRequest, + introspectSchema, + revokeSchema, + tokenRequestSchema, +} from './token/types.js' +import { VerifyTokenClaimsOptions } from './token/verify-token-claims.js' +import { dateToEpoch, dateToRelativeSeconds } from './util/date.js' + +export type OAuthProviderStore = Partial< + ClientStore & + AccountStore & + SessionStore & + TokenStore & + RequestStore & + ReplayStore +> + +export { Keyset, type CustomMetadata, type Handler } +export type OAuthProviderOptions = OAuthVerifierOptions & { + /** + * Maximum age a device/account session can be before requiring + * re-authentication. This can be overridden on a authorization request basis + * using the `max_age` parameter and on a client basis using the + * `default_max_age` client metadata. + */ + defaultMaxAge?: number + + /** + * Maximum age access & id tokens can be before requiring a refresh. + */ + tokenMaxAge?: number + + /** + * Additional metadata to be included in the discovery document. + */ + metadata?: CustomMetadata + + onAuthorizationRequest?: AuthorizationRequestHook + onAuthorizationDetails?: AuthorizationDetailsHook + onClientData?: ClientDataHook + onTokenResponse?: TokenResponseHook + + accountStore?: AccountStore + clientStore?: ClientStore + replayStore?: ReplayStore + requestStore?: RequestStore + sessionStore?: SessionStore + tokenStore?: TokenStore + + /** + * This will be used as the default store for all the stores. If a store is + * not provided, this store will be used instead. If the `store` does not + * implement a specific store, a runtime error will be thrown. Make sure that + * this store implements all the interfaces not provided in the other + * `Store` options. + */ + store?: OAuthProviderStore +} + +export class OAuthProvider extends OAuthVerifier { + public readonly metadata: Metadata + + public readonly defaultMaxAge: number + + public readonly sessionStore: SessionStore + + public readonly accountManager: AccountManager + public readonly clientManager: ClientManager + public readonly requestManager: RequestManager + public readonly tokenManager: TokenManager + + public constructor({ + defaultMaxAge = AUTH_MAX_AGE, + tokenMaxAge = TOKEN_MAX_AGE, + + store, + metadata, + + onAuthorizationRequest, + onAuthorizationDetails, + onClientData, + onTokenResponse, + + accountStore = asAccountStore(store), + clientStore = asClientStore(store), + replayStore = asReplayStore(store), + requestStore = store && isRequestStore(store) + ? store + : new RequestStoreMemory(), + sessionStore = asSessionStore(store), + tokenStore = asTokenStore(store), + + ...superOptions + }: OAuthProviderOptions) { + super({ replayStore, ...superOptions }) + + const hooks = { + onAuthorizationRequest, + onAuthorizationDetails, + onClientData, + onTokenResponse, + } + + this.defaultMaxAge = defaultMaxAge + this.metadata = buildMetadata(this.issuer, this.keyset, metadata) + + this.sessionStore = sessionStore + + this.accountManager = new AccountManager(accountStore) + this.clientManager = new ClientManager(clientStore, this.keyset, hooks) + this.requestManager = new RequestManager(requestStore, this.signer, hooks) + this.tokenManager = new TokenManager( + tokenStore, + this.signer, + hooks, + this.accessTokenType, + tokenMaxAge, + ) + } + + get jwks(): Jwks { + return this.keyset.publicJwks + } + + protected loginRequired( + client: Client, + parameters: AuthorizationParameters, + info: DeviceAccountInfo, + ) { + const authAge = Math.max( + 0, // Prevent negative values (fool proof) + (Date.now() - info.authenticatedAt.getTime()) / 1e3, + ) + const maxAge = Math.max( + 0, // Prevent negative values (fool proof) + parameters.max_age ?? + client.metadata.default_max_age ?? + this.defaultMaxAge, + ) + + return Math.floor(authAge) > Math.floor(maxAge) + } + + protected consentRequired( + client: Client, + clientAuth: ClientAuth, + parameters: AuthorizationParameters, + info: DeviceAccountInfo, + ) { + // Every client must have been granted consent at least once + if (!info.authorizedClients.includes(client.id)) return true + + // Allow listed clients can skip consent event without credentials + // TODO: make this configurable + if (client.id === 'bsky.app') return false + + // Unauthenticated clients must always go through consent + if (clientAuth.method === 'none') return true + + return false + } + + protected async authenticateClient( + client: Client, + endpoint: 'token' | 'introspection' | 'revocation', + credentials: ClientIdentification, + ): Promise { + const { clientAuth, nonce } = await client.verifyCredentials( + credentials, + endpoint, + { audience: this.issuer }, + ) + + if (nonce != null) { + const unique = await this.replayManager.uniqueAuth(nonce, client.id) + if (!unique) { + throw new InvalidRequestError(`${clientAuth.method} jti reused`) + } + } + + return clientAuth + } + + protected async decodeJAR( + client: Client, + input: AuthorizationRequestJar, + ): Promise< + | { + payload: AuthorizationParameters + protectedHeader?: undefined + key?: undefined + } + | { + payload: AuthorizationParameters + protectedHeader: JWTHeaderParameters & { kid: string } + key: ResolvedKey['key'] + } + > { + const result = await client.decodeRequestObject(input.request) + + if (!result.payload.jti) { + throw new InvalidRequestError('Request object must contain a jti claim') + } + + if (!(await this.replayManager.uniqueJar(result.payload.jti, client.id))) { + throw new InvalidRequestError('Request object jti is not unique') + } + + const payload = authorizationParametersSchema.parse(result.payload) + + if ('protectedHeader' in result) { + if (!result.protectedHeader.kid) { + throw new InvalidRequestError('Missing "kid" in header') + } + + return { + key: result.key, + payload, + protectedHeader: result.protectedHeader as JWTHeaderParameters & { + kid: string + }, + } + } + + if ('header' in result) { + return { + payload, + } + } + + // Should never happen + throw new Error('Invalid request object') + } + + /** + * @see {@link https://datatracker.ietf.org/doc/html/rfc9126} + */ + protected async pushedAuthorizationRequest( + input: PushedAuthorizationRequest, + dpopJkt: null | string, + ) { + const client = await this.clientManager.getClient(input.client_id) + const clientAuth = await this.authenticateClient(client, 'token', input) + + // TODO (?) should we allow using signed JAR for client authentication? + const { payload: parameters } = + 'request' in input // Handle JAR + ? await this.decodeJAR(client, input) + : { payload: input } + + const { uri, expiresAt } = + await this.requestManager.pushedAuthorizationRequest( + client, + clientAuth, + parameters, + dpopJkt, + ) + + return { + request_uri: uri, + expires_in: dateToRelativeSeconds(expiresAt), + } + } + + private async setupAuthorizationRequest( + client: Client, + deviceId: DeviceId, + input: AuthorizationRequestQuery, + ) { + // Load PAR + if ('request_uri' in input) { + return this.requestManager.get(input.request_uri, client.id, deviceId) + } + + // Handle JAR + if ('request' in input) { + const requestObject = await this.decodeJAR(client, input) + + if (requestObject.protectedHeader) { + // Allow using signed JAR during "/authorize" as client authentication. + // This allows clients to skip PAR to initiate trusted sessions. + const clientAuth: ClientAuth = { + method: CLIENT_ASSERTION_TYPE_JWT_BEARER, + kid: requestObject.protectedHeader.kid, + alg: requestObject.protectedHeader.alg, + jkt: await authJwkThumbprint(requestObject.key), + } + + return this.requestManager.authorizationRequest( + client, + clientAuth, + requestObject.payload, + deviceId, + ) + } + + return this.requestManager.authorizationRequest( + client, + { method: 'none' }, + requestObject.payload, + deviceId, + ) + } + + return this.requestManager.authorizationRequest( + client, + { method: 'none' }, + input, + deviceId, + ) + } + + protected async authorize( + deviceId: DeviceId, + input: AuthorizationRequestQuery, + ): Promise { + const { issuer } = this + const client = await this.clientManager.getClient(input.client_id) + + try { + const { uri, parameters, clientAuth } = + await this.setupAuthorizationRequest(client, deviceId, input) + + try { + const sessions = await this.getSessions( + client, + clientAuth, + deviceId, + parameters, + ) + + if (parameters.prompt === 'none') { + const ssoSessions = sessions.filter((s) => s.ssoAllowed) + if (ssoSessions.length > 1) throw new AccountSelectionRequiredError() + if (ssoSessions.length < 1) throw new LoginRequiredError() + + const ssoSession = ssoSessions[0]! + if (ssoSession.consentRequired) throw new ConsentRequiredError() + if (ssoSession.loginRequired) throw new LoginRequiredError() + + const redirect = await this.requestManager.setAuthorized( + client, + uri, + deviceId, + ssoSession.account, + ssoSession.info, + ) + + return { issuer, client, parameters, redirect } + } + + return { issuer, client, parameters, authorize: { uri, sessions } } + } catch (err) { + await this.requestManager.delete(uri) + + if (err instanceof OAuthError) { + return { issuer, client, parameters, redirect: err.toJSON() } + } + + throw err + } + } catch (err) { + if (err instanceof OAuthError && 'redirect_uri' in input) { + return { issuer, client, parameters: input, redirect: err.toJSON() } + } + + throw err + } + } + + protected async getSessions( + client: Client, + clientAuth: ClientAuth, + deviceId: DeviceId, + parameters: AuthorizationParameters, + ): Promise< + { + account: Account + info: DeviceAccountInfo + + ssoAllowed: boolean + initiallySelected: boolean + loginRequired: boolean + consentRequired: boolean + }[] + > { + const accounts = await this.accountManager.list(deviceId) + + const idTokenSub = + parameters.id_token_hint != null + ? // token already validated by RequestManager.validate() + decodeJwt(parameters.id_token_hint).sub || null + : null + + const hasHint = Boolean(parameters.id_token_hint || parameters.login_hint) + const hintSub = // Only take the hint into account if they match each other + parameters.login_hint && idTokenSub + ? parameters.login_hint === idTokenSub + ? idTokenSub + : null + : parameters.login_hint || idTokenSub || null + + return accounts.map(({ account, info }) => { + const consentRequired = this.consentRequired( + client, + clientAuth, + parameters, + info, + ) + const loginRequired = this.loginRequired(client, parameters, info) + const matchesHint = hintSub != null && hintSub === account.sub + + return { + account, + info, + + ssoAllowed: parameters.prompt === 'none' && (!hasHint || matchesHint), + loginRequired: parameters.prompt === 'login' || loginRequired, + consentRequired: parameters.prompt === 'consent' || consentRequired, + initiallySelected: + parameters.prompt !== 'select_account' && matchesHint, + } + }) + } + + protected async login( + deviceId: DeviceId, + uri: RequestUri, + credentials: LoginCredentials, + ): Promise { + const account = await this.accountManager.login(credentials, deviceId) + return this.accountManager.get(deviceId, account.sub) + } + + protected async acceptRequest( + deviceId: DeviceId, + uri: RequestUri, + clientId: ClientId, + sub: string, + ): Promise { + const { account, info } = await this.accountManager.get(deviceId, sub) + + const { issuer } = this + const { parameters } = await this.requestManager.get( + uri, + clientId, + deviceId, + ) + + const client = await this.clientManager.getClient(clientId) + + // The user is trying to authorize without a fresh login + if (this.loginRequired(client, parameters, info)) { + throw new LoginRequiredError('Account authentication required.') + } + + try { + const redirect = await this.requestManager.setAuthorized( + client, + uri, + deviceId, + account, + info, + ) + + try { + await this.accountManager.addAuthorizedClient( + deviceId, + account.sub, + client.id, + ) + } catch (err) { + await this.requestManager.delete(uri) + throw err + } + + return { issuer, client, parameters, redirect } + } catch (err) { + if (err instanceof OAuthError) { + const redirect = buildErrorPayload(err) + return { issuer, client, parameters, redirect } + } + + throw err + } + } + + protected async rejectRequest( + deviceId: DeviceId, + uri: RequestUri, + clientId: ClientId, + ): Promise { + const { parameters } = await this.requestManager.get( + uri, + clientId, + deviceId, + ) + + await this.requestManager.delete(uri) + + return { + issuer: this.issuer, + client: await this.clientManager.getClient(clientId), + parameters, + redirect: { + error: 'access_denied', + error_description: 'Access denied', + }, + } + } + + protected async token( + input: TokenRequest, + dpopJkt: null | string, + ): Promise { + const client = await this.clientManager.getClient(input.client_id) + const clientAuth = await this.authenticateClient(client, 'token', input) + + if (!client.metadata.grant_types.includes(input.grant_type)) { + throw new InvalidRequestError( + `"${input.grant_type}" grant type is not allowed for this client`, + ) + } + + if (input.grant_type === 'authorization_code') { + return this.codeGrant(client, clientAuth, input, dpopJkt) + } + + if (input.grant_type === 'refresh_token') { + return this.refreshTokenGrant(client, clientAuth, input, dpopJkt) + } + + if (input.grant_type === 'password') { + // @ts-expect-error: THIS REQUIRES RATE LIMITING BEFORE IT CAN BE ENABLED + if (!(this.allow_password_grant !== true)) { + throw new InvalidRequestError('Password grant not allowed') + } + + return this.passwordGrant(client, clientAuth, input, dpopJkt) + } + + throw new InvalidRequestError( + // @ts-expect-error: fool proof + `Grant type "${input.grant_type}" not supported`, + ) + } + + protected async codeGrant( + client: Client, + clientAuth: ClientAuth, + input: CodeGrantRequest, + dpopJkt: null | string, + ): Promise { + try { + const { sub, deviceId, parameters } = await this.requestManager.findCode( + client, + clientAuth, + input.code, + ) + + const { account, info } = await this.accountManager.get(deviceId, sub) + + // User revoked consent while client was asking for a token + if (!info.authorizedClients.includes(client.id)) { + throw new AccessDeniedError('Client not trusted anymore') + } + + return await this.tokenManager.create( + client, + clientAuth, + account, + { id: deviceId, info }, + parameters, + input, + dpopJkt, + ) + } catch (err) { + // If a token is replayed, requestManager.findCode will throw. In that + // case, we need to revoke any token that was issued for this code. + await this.tokenManager.revoke(input.code) + + throw err + } + } + + async refreshTokenGrant( + client: Client, + clientAuth: ClientAuth, + input: RefreshGrantRequest, + dpopJkt: null | string, + ): Promise { + return this.tokenManager.refresh(client, clientAuth, input, dpopJkt) + } + + async passwordGrant( + client: Client, + clientAuth: ClientAuth, + input: PasswordGrantRequest, + dpopJkt: null | string, + ): Promise { + const parameters = await this.requestManager.validate( + client, + clientAuth, + { + scope: input.scope, + response_type: input.scope?.includes('openid') + ? 'id_token token' + : 'token', + }, + dpopJkt, + false, + ) + + const account = await this.accountManager.login(input, null) + + return this.tokenManager.create( + client, + clientAuth, + account, + null, + parameters, + input, + dpopJkt, + ) + } + + /** + * @see {@link https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 rfc7009} + */ + protected async revoke(input: Revoke) { + await this.tokenManager.revoke(input.token) + } + + /** + * @see {@link https://datatracker.ietf.org/doc/html/rfc7662#section-2.1 rfc7662} + */ + protected async introspect( + input: Introspect, + ): Promise { + const client = await this.clientManager.getClient(input.client_id) + const clientAuth = await this.authenticateClient( + client, + 'introspection', + input, + ) + + // RFC7662 states the following: + // + // > To prevent token scanning attacks, the endpoint MUST also require some + // > form of authorization to access this endpoint, such as client + // > authentication as described in OAuth 2.0 [RFC6749] or a separate OAuth + // > 2.0 access token such as the bearer token described in OAuth 2.0 Bearer + // > Token Usage [RFC6750]. The methods of managing and validating these + // > authentication credentials are out of scope of this specification. + if (clientAuth.method === 'none') { + throw new UnauthorizedClientError('Client authentication required') + } + + const start = Date.now() + try { + const tokenInfo = await this.tokenManager.clientTokenInfo( + client, + clientAuth, + input.token, + ) + + return { + active: true, + + scope: tokenInfo.data.parameters.scope, + client_id: tokenInfo.data.clientId, + username: tokenInfo.account.preferred_username, + token_type: tokenInfo.data.parameters.dpop_jkt ? 'DPoP' : 'Bearer', + authorization_details: tokenInfo.data.details ?? undefined, + + aud: tokenInfo.account.aud, + exp: dateToEpoch(tokenInfo.data.expiresAt), + iat: dateToEpoch(tokenInfo.data.updatedAt), + iss: this.signer.issuer, + jti: tokenInfo.id, + sub: tokenInfo.account.sub, + } + } catch (err) { + // Prevent brute force & timing attack (only for inactive tokens) + await new Promise((r) => setTimeout(r, 750 - (Date.now() - start))) + + return { + active: false, + } + } + } + + /** + * @see {@link https://openid.net/specs/openid-connect-core-1_0.html#rfc.section.5.3.2 Successful UserInfo Response} + */ + protected async userinfo({ data, account }: TokenInfo): Promise { + return { + ...oidcPayload(data.parameters, account), + + sub: account.sub, + + client_id: data.clientId, + username: account.preferred_username, + } + } + + protected async signUserinfo(userinfo: Userinfo): Promise { + const client = await this.clientManager.getClient(userinfo.client_id) + return this.signer.sign( + { + alg: client.metadata.userinfo_signed_response_alg, + typ: 'JWT', + }, + userinfo, + ) + } + + override async authenticateToken( + tokenType: TokenType, + token: AccessToken, + dpopJkt: string | null, + verifyOptions?: VerifyTokenClaimsOptions, + ) { + if (isTokenId(token)) { + this.assertTokenTypeAllowed(tokenType, AccessTokenType.id) + + return this.tokenManager.authenticateTokenId( + tokenType, + token, + dpopJkt, + verifyOptions, + ) + } + + return super.authenticateToken(tokenType, token, dpopJkt, verifyOptions) + } + + /** + * @returns An http request handler that can be used with node's http server + * or as a middleware with express / connect. + */ + public httpHandler< + T = void, + Req extends IncomingMessage = IncomingMessage, + Res extends ServerResponse = ServerResponse, + >({ + onError = process.env['NODE_ENV'] === 'development' + ? (req, res, err): void => console.error('OAuthProvider error:', err) + : undefined, + }: { + onError?: (req: Req, res: Res, err: unknown) => void + }): Handler { + const sessionManager = new SessionManager(this.sessionStore) + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const server = this + const issuerUrl = new URL(server.issuer) + const issuerOrigin = issuerUrl.origin + const router = new Router(issuerUrl) + + // Utils + + const csrfCookie = (uri: RequestUri) => `csrf-${uri}` + + /** + * Creates a middleware that will serve static JSON content. + */ + const staticJson = (json: unknown): Middleware => + combineMiddlewares([ + function (req, res, next) { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Cache-Control', 'max-age=300') + next() + }, + staticJsonHandler(json), + ]) + + /** + * Wrap an OAuth endpoint in a middleware that will set the appropriate + * response headers and format the response as JSON. + */ + const dynamicJson = ( + buildJson: (this: T, req: TReq, res: TRes) => Json | Promise, + status?: number, + ): Handler => + async function (req, res) { + res.setHeader('Access-Control-Allow-Origin', '*') + + // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Pragma', 'no-cache') + + // https://datatracker.ietf.org/doc/html/rfc9449#section-8.2 + const dpopNonce = server.nextDpopNonce() + if (dpopNonce) { + const name = 'DPoP-Nonce' + res.setHeader(name, dpopNonce) + res.appendHeader('Access-Control-Expose-Headers', name) + } + + try { + const result = await buildJson.call(this, req, res) + if (result !== undefined) writeJson(res, result, status) + else if (!res.headersSent) res.writeHead(status ?? 204).end() + } catch (err) { + if (!res.headersSent) { + if (err instanceof WWWAuthenticateError) { + const name = 'WWW-Authenticate' + res.setHeader(name, err.wwwAuthenticateHeader) + res.appendHeader('Access-Control-Expose-Headers', name) + } + + writeJson(res, buildErrorPayload(err), buildErrorStatus(err)) + } else { + res.destroy() + } + + await onError?.(req, res, err) + } + } + + //- Public OAuth endpoints + + /* + * Although OpenID compatibility is not required to implement the Atproto + * OAuth2 specification, we do support OIDC discovery in this + * implementation as we believe this may: + * 1) Make the implementation of Atproto clients easier (since lots of + * libraries support OIDC discovery) + * 2) Allow self hosted PDS' to not implement authentication themselves + * but rely on a trusted Atproto actor to act as their OIDC providers. + * By supporting OIDC in the current implementation, Bluesky's + * Authorization Server server can be used as an OIDC provider for + * these users. + */ + router.get('/.well-known/openid-configuration', staticJson(server.metadata)) + + router.get( + '/.well-known/oauth-authorization-server', + staticJson(server.metadata), + ) + + // CORS preflight + router.options<{ + endpoint: 'jwks' | 'par' | 'token' | 'revoke' | 'introspect' | 'userinfo' + }>( + /^\/oauth\/(?jwks|par|token|revoke|introspect|userinfo)$/, + function (req, res, _next) { + res + .writeHead(204, { + 'Access-Control-Allow-Origin': req.headers['origin'] || '*', + 'Access-Control-Allow-Methods': + this.params.endpoint === 'jwks' ? 'GET' : 'POST', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,DPoP', + 'Access-Control-Max-Age': '86400', // 1 day + }) + .end() + }, + ) + + router.get('/oauth/jwks', staticJson(server.jwks)) + + router.post( + '/oauth/par', + dynamicJson(async function (req, _res) { + const input = await validateRequestPayload( + req, + pushedAuthorizationRequestSchema, + ) + + const dpopJkt = await server.checkDpopProof( + req.headers['dpop'], + req.method!, + this.url, + ) + + return server.pushedAuthorizationRequest(input, dpopJkt) + }, 201), + ) + + // https://datatracker.ietf.org/doc/html/rfc9126#section-2.3 + router.addRoute('*', '/oauth/par', (req, res) => { + res.writeHead(405).end() + }) + + router.post( + '/oauth/token', + dynamicJson(async function (req, _res) { + const input = await validateRequestPayload(req, tokenRequestSchema) + + const dpopJkt = await server.checkDpopProof( + req.headers['dpop'], + req.method!, + this.url, + ) + + return server.token(input, dpopJkt) + }), + ) + + router.post( + '/oauth/revoke', + dynamicJson(async function (req, _res) { + const input = await validateRequestPayload(req, revokeSchema) + await server.revoke(input) + }), + ) + + router.get( + '/oauth/revoke', + dynamicJson(async function (req, res) { + validateFetchMode(req, res, ['navigate']) + validateSameOrigin(req, res, issuerOrigin) + + const query = Object.fromEntries(this.url.searchParams) + const input = await revokeSchema.parseAsync(query, { path: ['query'] }) + await server.revoke(input) + + // Same as POST + redirect to callback URL + // todo: generate JSONP response (if "callback" is provided) + + throw new Error( + 'You are successfully logged out. Redirect not implemented', + ) + }), + ) + + router.post( + '/oauth/introspect', + dynamicJson(async function (req, _res) { + const input = await validateRequestPayload(req, introspectSchema) + return server.introspect(input) + }), + ) + + const userinfoBodySchema = z.object({ + access_token: jwtSchema.optional(), + }) + + router.addRoute( + ['GET', 'POST'], + '/oauth/userinfo', + acceptMiddleware( + async function (req, _res) { + const body = + req.method === 'POST' + ? await validateRequestPayload(req, userinfoBodySchema) + : null + + if (body?.access_token && req.headers['authorization']) { + throw new InvalidRequestError( + 'access token must be provided in either the authorization header or the request body', + ) + } + + const auth = await server.authenticateHttpRequest( + req.method!, + this.url, + body?.access_token // Allow credentials to be parsed from body. + ? { + authorization: `Bearer ${body.access_token}`, + dpop: undefined, // DPoP can only be used with headers + } + : req.headers, + { + // TODO? Add the URL as an audience of the token ? + // audience: [this.url.href], + scope: ['profile'], + }, + ) + + const tokenInfo: TokenInfo = + 'tokenInfo' in auth + ? (auth.tokenInfo as TokenInfo) + : await server.tokenManager.getTokenInfo( + auth.tokenType, + auth.tokenId, + ) + + return server.userinfo(tokenInfo) + }, + { + '': 'application/json', + 'application/json': dynamicJson(async function (_req, _res) { + return this.data + }), + 'application/jwt': dynamicJson(async function (_req, res) { + const jwt = await server.signUserinfo(this.data) + res.writeHead(200, { 'Content-Type': 'application/jwt' }).end(jwt) + return undefined + }), + }, + ), + ) + + //- Private authorization endpoints + + router.use(authorizeAssetsMiddleware('/oauth/')) + + router.get('/oauth/authorize', async function (req, res) { + try { + res.setHeader('Cache-Control', 'no-store') + + validateFetchMode(req, res, ['navigate']) + validateSameOrigin(req, res, issuerOrigin) + + const query = Object.fromEntries(this.url.searchParams) + const input = await authorizationRequestQuerySchema.parseAsync(query, { + path: ['query'], + }) + + const { deviceId } = await sessionManager.load(req, res) + const data = await server.authorize(deviceId, input) + + switch (true) { + case 'redirect' in data: { + return await sendAuthorizeRedirect(req, res, data) + } + case 'authorize' in data: { + await setupCsrfToken(req, res, csrfCookie(data.authorize.uri)) + return await sendAuthorizePage(req, res, data) + } + default: { + // Should never happen + throw new Error('Unexpected authorization result') + } + } + } catch (err) { + await onError?.(req, res, err) + + if (!res.headersSent) { + await sendErrorPage(req, res, err) + } + } + }) + + const loginPayloadSchema = z.object({ + csrf_token: z.string(), + request_uri: requestUriSchema, + // client_id: clientIdSchema, + credentials: z.object({ + username: z.string(), + password: z.string(), + remember: z.boolean().optional().default(false), + }), + }) + + router.post('/oauth/authorize/login', async function (req, res) { + validateFetchMode(req, res, ['same-origin']) + validateSameOrigin(req, res, issuerOrigin) + + const input = await validateRequestPayload(req, loginPayloadSchema) + + validateReferer(req, res, { + origin: issuerOrigin, + pathname: '/oauth/authorize', + }) + validateCsrfToken( + req, + res, + input.csrf_token, + csrfCookie(input.request_uri), + ) + + const { deviceId } = await sessionManager.load(req, res) + + const { account, info } = await server.login( + deviceId, + input.request_uri, + input.credentials, + ) + + // Prevent fixation attacks + await sessionManager.rotate(req, res, deviceId) + + return writeJson(res, { account, info }) + }) + + const acceptQuerySchema = z.object({ + csrf_token: z.string(), + request_uri: requestUriSchema, + client_id: clientIdSchema, + account_sub: z.string(), + }) + + router.get('/oauth/authorize/accept', async function (req, res) { + res.setHeader('Cache-Control', 'no-store') + + validateFetchMode(req, res, ['navigate']) + validateSameOrigin(req, res, issuerOrigin) + + const query = Object.fromEntries(this.url.searchParams) + const input = await acceptQuerySchema.parseAsync(query, { + path: ['query'], + }) + + validateReferer(req, res, { + origin: issuerOrigin, + pathname: '/oauth/authorize', + searchParams: [ + ['request_uri', input.request_uri], + ['client_id', input.client_id], + ], + }) + validateCsrfToken( + req, + res, + input.csrf_token, + csrfCookie(input.request_uri), + true, + ) + + const { deviceId } = await sessionManager.load(req, res) + + const data = await server.acceptRequest( + deviceId, + input.request_uri, + input.client_id, + input.account_sub, + ) + + return await sendAuthorizeRedirect(req, res, data) + }) + + const rejectQuerySchema = z.object({ + csrf_token: z.string(), + request_uri: requestUriSchema, + client_id: clientIdSchema, + }) + + router.get('/oauth/authorize/reject', async function (req, res) { + res.setHeader('Cache-Control', 'no-store') + + validateFetchMode(req, res, ['navigate']) + validateSameOrigin(req, res, issuerOrigin) + + const query = Object.fromEntries(this.url.searchParams) + const input = await rejectQuerySchema.parseAsync(query, { + path: ['query'], + }) + + validateReferer(req, res, { + origin: issuerOrigin, + pathname: '/oauth/authorize', + searchParams: [ + ['request_uri', input.request_uri], + ['client_id', input.client_id], + ], + }) + validateCsrfToken( + req, + res, + input.csrf_token, + csrfCookie(input.request_uri), + true, + ) + + const { deviceId } = await sessionManager.load(req, res) + + const data = await server.rejectRequest( + deviceId, + input.request_uri, + input.client_id, + ) + + return await sendAuthorizeRedirect(req, res, data) + }) + + return router.handler + } +} diff --git a/packages/oauth-provider/src/oauth-store.ts b/packages/oauth-provider/src/oauth-store.ts new file mode 100644 index 00000000000..b0e1caa8de8 --- /dev/null +++ b/packages/oauth-provider/src/oauth-store.ts @@ -0,0 +1,11 @@ +/** + * Every store file exports all the types needed to implement that store. This + * files re-exports all the types from the x-store files. + */ + +export type * from './account/account-store.js' +export type * from './client/client-store.js' +export type * from './replay/replay-store.js' +export type * from './request/request-store.js' +export type * from './session/session-store.js' +export type * from './token/token-store.js' diff --git a/packages/oauth-provider/src/oauth-verifier.ts b/packages/oauth-provider/src/oauth-verifier.ts new file mode 100644 index 00000000000..f2b62145a0d --- /dev/null +++ b/packages/oauth-provider/src/oauth-verifier.ts @@ -0,0 +1,184 @@ +import { Keyset, jwtSchema } from '@atproto/jwk' +import { AccessTokenType } from './access-token/access-token-type.js' +import { AccessToken } from './access-token/access-token.js' +import { DpopManager, DpopManagerOptions } from './dpop/dpop-manager.js' +import { DpopNonce } from './dpop/dpop-nonce.js' +import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js' +import { InvalidTokenError } from './errors/invalid-token-error.js' +import { ReplayManager } from './replay/replay-manager.js' +import { ReplayStore } from './replay/replay-store.js' +import { Signer } from './signer/signer.js' +import { TokenType } from './token/token-type.js' +import { + VerifyTokenClaimsOptions, + VerifyTokenClaimsResult, + verifyTokenClaims, +} from './token/verify-token-claims.js' +import { parseAuthorizationHeader } from './util/authorization-header.js' +import { Override } from './util/type.js' + +export type OAuthVerifierOptions = Override< + DpopManagerOptions, + { + /** + * The "issuer" identifier of the OAuth provider, this is the base URL of the + * OAuth provider. + */ + issuer: URL | string + + /** + * The keyset used to sign tokens. Note that OIDC requires that at least one + * RS256 key is present in the keyset. ATPROTO requires ES256. + */ + keyset: Keyset + + /** + * If set to {@link AccessTokenType.jwt}, the provider will use JWTs for + * access tokens. If set to {@link AccessTokenType.id}, the provider will + * use tokenId as access tokens. If set to {@link AccessTokenType.auto}, + * JWTs will only be used if the audience is different from the issuer. + * Defaults to {@link AccessTokenType.jwt}. + * + * Here is a comparison of the two types: + * + * - pro id: less CPU intensive (no crypto operations) + * - pro id: less bandwidth (shorter tokens than jwt) + * - pro id: token data is in sync with database (e.g. revocation) + * - pro jwt: stateless: no I/O needed (no db lookups through token store) + * - pro jwt: stateless: allows Resource Server to be on a different + * host/server + */ + accessTokenType?: AccessTokenType + + replayStore: ReplayStore + } +> + +export { + AccessTokenType, + DpopNonce, + Keyset, + type ReplayStore, + type VerifyTokenClaimsOptions, +} + +export class OAuthVerifier { + public readonly issuer: string + public readonly keyset: Keyset + + protected readonly accessTokenType: AccessTokenType + protected readonly dpopManager: DpopManager + protected readonly replayManager: ReplayManager + protected readonly signer: Signer + + constructor({ + issuer, + keyset, + replayStore, + accessTokenType = AccessTokenType.jwt, + + ...dpopMgrOptions + }: OAuthVerifierOptions) { + const issuerUrl = new URL(String(issuer)) + if (issuerUrl.pathname !== '/' || issuerUrl.search || issuerUrl.hash) { + throw new TypeError( + '"issuer" must be an URL with no path, search or hash', + ) + } + + this.issuer = issuerUrl.href + this.keyset = keyset + + this.accessTokenType = accessTokenType + this.dpopManager = new DpopManager(dpopMgrOptions) + this.replayManager = new ReplayManager(replayStore) + this.signer = new Signer(this.issuer, this.keyset) + } + + public nextDpopNonce() { + return this.dpopManager.nextNonce() + } + + public async checkDpopProof( + proof: unknown, + htm: string, + htu: string | URL, + accessToken?: string, + ): Promise { + if (proof === undefined) return null + + const { payload, jkt } = await this.dpopManager.checkProof( + proof, + htm, + htu, + accessToken, + ) + + const unique = await this.replayManager.uniqueDpop(payload.jti) + if (!unique) throw new InvalidDpopProofError('DPoP proof jti is not unique') + + return jkt + } + + protected assertTokenTypeAllowed( + tokenType: TokenType, + accessTokenType: AccessTokenType, + ) { + if ( + this.accessTokenType !== AccessTokenType.auto && + this.accessTokenType !== accessTokenType + ) { + throw new InvalidTokenError(`Invalid token`, { [tokenType]: {} }) + } + } + + public async authenticateToken( + tokenType: TokenType, + token: AccessToken, + dpopJkt: string | null, + verifyOptions?: VerifyTokenClaimsOptions, + ): Promise { + const jwt = jwtSchema.safeParse(token) + if (!jwt.success) { + throw new InvalidTokenError(`Invalid token`, { [tokenType]: {} }) + } + + this.assertTokenTypeAllowed(tokenType, AccessTokenType.jwt) + + const { payload } = await this.signer + .verifyAccessToken(jwt.data) + .catch((err) => { + throw InvalidTokenError.from( + err, + { [tokenType]: {} }, + 'Unable to verify token', + ) + }) + + return verifyTokenClaims( + jwt.data, + payload.jti, + tokenType, + dpopJkt, + payload, + verifyOptions, + ) + } + + public async authenticateHttpRequest( + method: string, + url: URL, + headers: { + authorization?: string + dpop?: unknown + }, + verifyOptions?: VerifyTokenClaimsOptions, + ): Promise { + const [tokenType, token] = parseAuthorizationHeader(headers.authorization) + const dpopJkt = await this.checkDpopProof(headers.dpop, method, url, token) + if (tokenType === 'DPoP' && !dpopJkt) { + throw new InvalidDpopProofError(`DPoP proof required`) + } + return this.authenticateToken(tokenType, token, dpopJkt, verifyOptions) + } +} diff --git a/packages/oauth-provider/src/oidc/claims.ts b/packages/oauth-provider/src/oidc/claims.ts new file mode 100644 index 00000000000..3b31a9bf048 --- /dev/null +++ b/packages/oauth-provider/src/oidc/claims.ts @@ -0,0 +1,35 @@ +import { JwtPayload } from '@atproto/jwk' + +/** + * @see {@link https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims | OpenID Connect Core 1.0, 5.4. Requesting Claims using Scope Values} + */ +export const OIDC_SCOPE_CLAIMS = Object.freeze({ + email: Object.freeze(['email', 'email_verified'] as const), + phone: Object.freeze(['phone_number', 'phone_number_verified'] as const), + address: Object.freeze(['address'] as const), + profile: Object.freeze([ + 'name', + 'family_name', + 'given_name', + 'middle_name', + 'nickname', + 'preferred_username', + 'gender', + 'picture', + 'profile', + 'website', + 'birthdate', + 'zoneinfo', + 'locale', + 'updated_at', + ] as const), +}) + +export const OIDC_STANDARD_CLAIMS = Object.freeze( + Object.values(OIDC_SCOPE_CLAIMS).flat(), +) + +export type OIDCStandardClaim = typeof OIDC_STANDARD_CLAIMS[number] +export type OIDCStandardPayload = Partial<{ + [K in OIDCStandardClaim]?: JwtPayload[K] +}> diff --git a/packages/oauth-provider/src/oidc/sub.ts b/packages/oauth-provider/src/oidc/sub.ts new file mode 100644 index 00000000000..be25110436b --- /dev/null +++ b/packages/oauth-provider/src/oidc/sub.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const subSchema = z.string().min(1) +export type Sub = z.infer diff --git a/packages/oauth-provider/src/oidc/userinfo.ts b/packages/oauth-provider/src/oidc/userinfo.ts new file mode 100644 index 00000000000..3810c6e3748 --- /dev/null +++ b/packages/oauth-provider/src/oidc/userinfo.ts @@ -0,0 +1,11 @@ +import { OIDCStandardPayload } from './claims.js' + +export type Userinfo = OIDCStandardPayload & { + // "The sub (subject) Claim MUST always be returned in the UserInfo Response." + sub: string + + // client_id is not mandatory per spec, but we require it here for convenience + client_id: string + + username?: string +} diff --git a/packages/oauth-provider/src/output/build-error-payload.ts b/packages/oauth-provider/src/output/build-error-payload.ts new file mode 100644 index 00000000000..7ab5acabc24 --- /dev/null +++ b/packages/oauth-provider/src/output/build-error-payload.ts @@ -0,0 +1,140 @@ +import { errors } from 'jose' +import { ZodError } from 'zod' + +import { OAuthError } from '../errors/oauth-error.js' + +const { JOSEError } = errors + +const INVALID_REQUEST = 'invalid_request' +const SERVER_ERROR = 'server_error' + +export function buildErrorStatus(error: unknown): number { + if (error instanceof OAuthError) { + return error.statusCode + } + + if (error instanceof ZodError) { + return 400 + } + + if (error instanceof JOSEError) { + return 400 + } + + if (error instanceof TypeError) { + return 400 + } + + if (isBoom(error)) { + return error.output.statusCode + } + + if (isXrpcError(error)) { + return error.type + } + + const status = (error as any)?.status + if ( + typeof status === 'number' && + status === (status | 0) && + status >= 400 && + status < 600 + ) { + return status + } + + return 500 +} + +export function buildErrorPayload(error: unknown): { + error: string + error_description: string +} { + if (error instanceof OAuthError) { + return error.toJSON() + } + + if (error instanceof ZodError) { + return { + error: INVALID_REQUEST, + error_description: error.issues[0]?.message || 'Invalid request', + } + } + + if (error instanceof JOSEError) { + return { + error: INVALID_REQUEST, + error_description: error.message, + } + } + + if (error instanceof TypeError) { + return { + error: INVALID_REQUEST, + error_description: error.message, + } + } + + if (isBoom(error)) { + return { + error: error.output.statusCode <= 500 ? INVALID_REQUEST : SERVER_ERROR, + error_description: + error.output.statusCode <= 500 + ? isPayloadLike(error.output?.payload) + ? error.output.payload.message + : error.message + : 'Server error', + } + } + + if (isXrpcError(error)) { + return { + error: error.type <= 500 ? INVALID_REQUEST : SERVER_ERROR, + error_description: error.payload.message, + } + } + + const status = buildErrorStatus(error) + return { + error: status < 500 ? INVALID_REQUEST : SERVER_ERROR, + error_description: + error instanceof Error && (error as any)?.expose === true + ? error.message + : 'Server error', + } +} + +function isBoom(v: unknown): v is Error & { + isBoom: true + output: { statusCode: number; payload: unknown } +} { + return ( + v instanceof Error && + (v as any).isBoom === true && + isHttpErrorCode(v['output']?.['statusCode']) + ) +} + +function isXrpcError(v: unknown): v is Error & { + type: number + payload: { error: string; message: string } +} { + return ( + v instanceof Error && + isHttpErrorCode(v['type']) && + isPayloadLike(v['payload']) + ) +} + +function isHttpErrorCode(v: unknown): v is number { + return typeof v === 'number' && v >= 400 && v < 600 && v === (v | 0) +} + +function isPayloadLike(v: unknown): v is { error: string; message: string } { + return ( + v != null && + typeof v === 'object' && + typeof v['error'] === 'string' && + typeof v['message'] === 'string' + ) +} diff --git a/packages/oauth-provider/src/output/send-authorize-page.ts b/packages/oauth-provider/src/output/send-authorize-page.ts new file mode 100644 index 00000000000..f3bc052c29d --- /dev/null +++ b/packages/oauth-provider/src/output/send-authorize-page.ts @@ -0,0 +1,140 @@ +import { createHash } from 'node:crypto' +import { IncomingMessage, ServerResponse } from 'node:http' + +import { html } from '@atproto/html' +import { Middleware, writeHtml, writeStream } from '@atproto/http-util' + +import { Account } from '../account/account.js' +import { findAsset } from '../assets/index.js' +import { Client } from '../client/client.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { RequestUri } from '../request/request-uri.js' + +export function authorizeAssetsMiddleware(prefix: string): Middleware { + if (!prefix.startsWith('/')) throw new TypeError('Prefix must start with /') + if (!prefix.endsWith('/')) prefix += '/' + + return async function assetsMiddleware(req, res, next): Promise { + if (req.method !== 'GET' && req.method !== 'HEAD') return next() + if (!req.url?.startsWith(prefix)) return next() + + const [pathname, query] = req.url.split('?', 2) as [ + string, + string | undefined, + ] + const filename = pathname.slice(prefix.length) + + const item = await findAsset(filename).catch(() => null) + if (!item) return next() + + if (req.headers['if-none-match'] === item.asset.sha256) { + return void res.writeHead(304).end() + } + + res.setHeader('ETag', item.asset.sha256) + + if (query === item.asset.sha256) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } + + await writeStream(res, item.getStream(), item.asset.mime) + } +} + +export type AuthorizationResultAuthorize = { + issuer: string + client: Client + parameters: AuthorizationParameters + authorize: { + uri: RequestUri + sessions: readonly { + account: Account + loginRequired: boolean + consentRequired: boolean + initiallySelected: boolean + }[] + } +} + +function buildBackendData(data: AuthorizationResultAuthorize) { + return { + csrfCookie: `csrf-${data.authorize.uri}`, + requestUri: data.authorize.uri, + clientId: data.client.id, + clientMetadata: data.client.metadata, + loginHint: data.parameters.login_hint, + consentRequired: data.parameters.prompt === 'consent', + sessions: data.authorize.sessions, + } +} + +export async function sendAuthorizePage( + req: IncomingMessage, + res: ServerResponse, + data: AuthorizationResultAuthorize, +): Promise { + const [{ asset: mainJs }, { asset: mainCss }] = await Promise.all([ + findAsset('main.js'), + findAsset('main.css'), + ]) + + const backendDataScript = html + .dangerouslyCreate( + `window.__backendData=${html.jsonForScriptTag(buildBackendData(data))};`, + ) + .toString() + const backendDataScriptSha = createHash('sha256') + .update(backendDataScript) + .digest('base64') + const backendDataScriptHtml = html.dangerouslyCreate( + ``, + ) + + /* Security headers */ + + res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless') + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Referrer-Policy', 'same-origin') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-XSS-Protection', '0') + res.setHeader('Permissions-Policy', 'otp-credentials=*, document-domain=()') + res.setHeader('Strict-Transport-Security', 'max-age=63072000') + res.setHeader('Cache-Control', 'no-store') + res.setHeader( + 'Content-Security-Policy', + [ + `default-src 'none'`, + `frame-ancestors 'none'`, + `form-action 'none'`, + `base-uri 'none'`, + + `script-src 'self' 'sha256-${mainJs.sha256}' 'sha256-${backendDataScriptSha}'`, + `style-src 'self' 'sha256-${mainCss.sha256}'`, + `img-src 'self' https:`, + `connect-src 'self'`, + `upgrade-insecure-requests`, + ].join('; '), + ) + + const payload = html` + + + + + + + Authorize + + +
+ + ${backendDataScriptHtml} + + + + ` + + writeHtml(res, payload.toBuffer()) +} diff --git a/packages/oauth-provider/src/output/send-authorize-redirect.ts b/packages/oauth-provider/src/output/send-authorize-redirect.ts new file mode 100644 index 00000000000..70a469dd5d0 --- /dev/null +++ b/packages/oauth-provider/src/output/send-authorize-redirect.ts @@ -0,0 +1,130 @@ +import { IncomingMessage, ServerResponse } from 'node:http' + +import { html } from '@atproto/html' +import { writeBuffer } from '@atproto/http-util' + +import { Client } from '../client/client.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { Code } from '../request/code.js' +import { TokenType } from '../token/token-type.js' + +export type AuthorizationResponseParameters = { + // Will be added from AuthorizationResultRedirect['issuer'] + // iss: string // rfc9207 + + // Will be added from AuthorizationResultRedirect['parameters'] + // state?: string + + code?: Code + id_token?: string + access_token?: string + token_type?: TokenType + expires_in?: string + + response?: string // FAPI JARM + session_state?: string // OIDC Session Management + + error?: string + error_description?: string + error_uri?: string +} + +export type AuthorizationResultRedirect = { + issuer: string + client: Client + parameters: AuthorizationParameters + redirect: AuthorizationResponseParameters +} + +export async function sendAuthorizeRedirect( + req: IncomingMessage, + res: ServerResponse, + result: AuthorizationResultRedirect, +): Promise { + const { issuer, parameters, redirect, client } = result + + const uri = parameters.redirect_uri || client.metadata.redirect_uris[0] + const mode = parameters.response_mode || 'query' + + const entries: [string, string][] = Object.entries({ + iss: issuer, // rfc9207 + state: parameters.state, + + response: redirect.response, // FAPI JARM + session_state: redirect.session_state, // OIDC Session Management + + code: redirect.code, + id_token: redirect.id_token, + access_token: redirect.access_token, + expires_in: redirect.expires_in, + token_type: redirect.token_type, + + error: redirect.error, + error_description: redirect.error_description, + error_uri: redirect.error_uri, + }).filter((entry): entry is [string, string] => entry[1] != null) + + res.setHeader('Cache-Control', 'no-store') + + switch (mode) { + case 'query': + return writeQuery(res, uri, entries) + case 'fragment': + return writeFragment(res, uri, entries) + case 'form_post': + return writeFormPost(res, uri, entries) + } + + // @ts-expect-error fool proof + throw new Error(`Unsupported mode: ${mode}`) +} + +function writeQuery( + res: ServerResponse, + uri: string, + entries: readonly [string, string][], +) { + const url = new URL(uri) + for (const [key, value] of entries) url.searchParams.set(key, value) + res.writeHead(302, { Location: url.href }).end() +} + +function writeFragment( + res: ServerResponse, + uri: string, + entries: readonly [string, string][], +) { + const url = new URL(uri) + const searchParams = new URLSearchParams() + for (const [key, value] of entries) searchParams.set(key, value) + url.hash = searchParams.toString() + res.writeHead(302, { Location: url.href }).end() +} + +async function writeFormPost( + res: ServerResponse, + uri: string, + entries: readonly [string, string][], +) { + // Prevent the Chrome from caching this page + // see: https://latesthackingnews.com/2023/12/12/google-updates-chrome-bfcache-for-faster-page-viewing/ + res.setHeader('Set-Cookie', `bfCacheBypass=foo; max-age=1; SameSite=Lax`) + + const payload = html` + + +
+ ${entries.map(([key, value]) => [ + html``, + ])} + +
+ + + + ` + + return writeBuffer(res, payload.toBuffer()) +} diff --git a/packages/oauth-provider/src/output/send-error-page.ts b/packages/oauth-provider/src/output/send-error-page.ts new file mode 100644 index 00000000000..820f654b1cb --- /dev/null +++ b/packages/oauth-provider/src/output/send-error-page.ts @@ -0,0 +1,58 @@ +import { IncomingMessage, ServerResponse } from 'node:http' + +import { html } from '@atproto/html' +import { writeHtml } from '@atproto/http-util' + +export async function sendErrorPage( + req: IncomingMessage, + res: ServerResponse, + _err: unknown, +): Promise { + // TODO : actually display the error + + /* Security headers */ + res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless') + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Referrer-Policy', 'same-origin') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-XSS-Protection', '0') + res.setHeader('Permissions-Policy', 'otp-credentials=* document-domain=()') + res.setHeader('Strict-Transport-Security', 'max-age=63072000') + res.setHeader('Cache-Control', 'no-store') + res.setHeader( + 'Content-Security-Policy', + [ + `default-src 'none'`, + `frame-ancestors 'none'`, + `form-action 'none'`, + `base-uri 'none'`, + + `script-src 'self' https://cdn.jsdelivr.net https://cdn.tailwindcss.com`, + `style-src 'self' 'unsafe-inline'`, + `img-src 'self' https:`, + `connect-src 'self'`, + `upgrade-insecure-requests`, + ].join('; '), + ) + + const payload = html` + + + + + + + Authorize + + + + +

Authorization Error

+ + + ` + + writeHtml(res, payload.toBuffer()) +} diff --git a/packages/oauth-provider/src/parameters/authorization-details.ts b/packages/oauth-provider/src/parameters/authorization-details.ts new file mode 100644 index 00000000000..d78005bed13 --- /dev/null +++ b/packages/oauth-provider/src/parameters/authorization-details.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-2 | RFC 9396, Section 2} + */ +export const authorizationDetailsSchema = z.array( + z.object({ + type: z.string(), + locations: z.array(z.string().url()).optional(), + actions: z.array(z.string()).optional(), + datatypes: z.array(z.string()).optional(), + identifier: z.string().optional(), + privileges: z.array(z.string()).optional(), + }), +) + +export type AuthorizationDetails = z.infer diff --git a/packages/oauth-provider/src/parameters/authorization-parameters.ts b/packages/oauth-provider/src/parameters/authorization-parameters.ts new file mode 100644 index 00000000000..a6d3a27c895 --- /dev/null +++ b/packages/oauth-provider/src/parameters/authorization-parameters.ts @@ -0,0 +1,147 @@ +import { z } from 'zod' +import { jwtSchema } from '@atproto/jwk' + +import { authorizationDetailsSchema } from '../parameters/authorization-details.js' + +// Matches the claims_supported from the authorization server metadata +export const entityClaimsSchema = z.enum([ + // https://openid.net/specs/openid-provider-authentication-policy-extension-1_0.html#rfc.section.5.2 + // if client metadata "require_auth_time" is true, this *must* be provided + 'auth_time', + + // OIDC + 'nonce', + + // OpenID: "profile" scope + 'name', + 'family_name', + 'given_name', + 'middle_name', + 'nickname', + 'preferred_username', + 'gender', + 'picture', + 'profile', + 'website', + 'birthdate', + 'zoneinfo', + 'locale', + 'updated_at', + + // OpenID: "email" scope + 'email', + 'email_verified', + + // OpenID: "phone" scope + 'phone_number', + 'phone_number_verified', + + // OpenID: "address" scope + 'address', +]) + +export type EntityClaims = z.infer + +export const claimsEntityTypeSchema = z.enum(['userinfo', 'id_token']) + +export type ClaimsEntityType = z.infer + +export const claimsParameterMemberSchema = z.object({ + essential: z.boolean().optional(), + value: z.string().optional(), + values: z.array(z.string()).optional(), +}) + +export type ClaimsParameterMember = z.infer + +// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest +export const authorizationParametersSchema = z.object({ + // Will be added by other schemas + // client_id: clientIdSchema, + + state: z.string().optional(), + nonce: z.string().optional(), + dpop_jkt: z.string().optional(), + + response_type: z.enum([ + // OAuth2 (https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-10#section-4.1.1) + 'code', + 'token', + + // OIDC (https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html) + 'id_token', + 'none', + 'code token', + 'code id_token', + 'id_token token', + 'code id_token token', + ]), + + // Default depend on response_type + response_mode: z.enum(['query', 'fragment', 'form_post']).optional(), + + // PKCE + code_challenge: z.string().optional(), + code_challenge_method: z.enum(['S256', 'plain']).default('S256').optional(), + + redirect_uri: z.string().url().optional(), + + // email profile openid (other?) + scope: z + .string() + .regex(/^[a-zA-Z0-9_]+( [a-zA-Z0-9_]+)*$/) + .optional(), + + // OIDC + + // Specifies the allowable elapsed time in seconds since the last time the + // End-User was actively authenticated by the OP. If the elapsed time is + // greater than this value, the OP MUST attempt to actively re-authenticate + // the End-User. (The max_age request parameter corresponds to the OpenID 2.0 + // PAPE [OpenID.PAPE] max_auth_age request parameter.) When max_age is used, + // the ID Token returned MUST include an auth_time Claim Value. Note that + // max_age=0 is equivalent to prompt=login. + max_age: z.number().int().min(0).optional(), + + claims: z + .record( + claimsEntityTypeSchema, + z.record( + entityClaimsSchema, + z.union([z.literal(null), claimsParameterMemberSchema]), + ), + ) + .optional(), + + // https://openid.net/specs/openid-connect-core-1_0.html#RegistrationParameter + // Not supported by this library (yet?) + // registration: clientMetadataSchema.optional(), + + login_hint: z.string().optional(), + + ui_locales: z + .string() + .regex(/^[a-z]{2}(-[A-Z]{2})?( [a-z]{2}(-[A-Z]{2})?)*$/) // fr-CA fr en + .optional(), + + // Previous ID Token, should be provided when prompt=none is used + id_token_hint: jwtSchema.optional(), + + // Type of UI the AS is displayed on + display: z.enum(['page', 'popup', 'touch']).optional(), + + /** + * - "none" will only be allowed if the user already allowed the client on the same device + * - "login" will force the user to login again, unless he very recently logged in + * - "consent" will force the user to consent again + * - "select_account" will force the user to select an account + */ + prompt: z.enum(['none', 'login', 'consent', 'select_account']).optional(), + + // https://datatracker.ietf.org/doc/html/rfc9396 + authorization_details: authorizationDetailsSchema.optional(), +}) + +export type AuthorizationParameters = z.infer< + typeof authorizationParametersSchema +> diff --git a/packages/oauth-provider/src/parameters/claims-requested.ts b/packages/oauth-provider/src/parameters/claims-requested.ts new file mode 100644 index 00000000000..dd65099f253 --- /dev/null +++ b/packages/oauth-provider/src/parameters/claims-requested.ts @@ -0,0 +1,30 @@ +import { InvalidRequestError } from '../errors/invalid-request-error.js' +import { + AuthorizationParameters, + ClaimsEntityType, + EntityClaims, +} from './authorization-parameters.js' + +export function claimRequested( + parameters: AuthorizationParameters, + entityType: ClaimsEntityType, + claimName: EntityClaims, + available: boolean, +): boolean { + if (parameters.claims) { + const entityClaims = parameters.claims[entityType] + if (entityClaims === undefined) return false + + const claimConfig = entityClaims[claimName] + if (claimConfig === undefined) return false + if (claimConfig === null) return available + + if (!available && claimConfig?.essential === true) { + throw new InvalidRequestError( + `Unable to provide requested claim: ${claimName}`, + ) + } + } + + return available +} diff --git a/packages/oauth-provider/src/parameters/oidc-payload.ts b/packages/oauth-provider/src/parameters/oidc-payload.ts new file mode 100644 index 00000000000..0d7f0d8a2fd --- /dev/null +++ b/packages/oauth-provider/src/parameters/oidc-payload.ts @@ -0,0 +1,25 @@ +import { Account } from '../account/account.js' +import { OIDCStandardPayload, OIDC_SCOPE_CLAIMS } from '../oidc/claims.js' +import { AuthorizationParameters } from './authorization-parameters.js' +import { claimRequested } from './claims-requested.js' + +export function oidcPayload(params: AuthorizationParameters, account: Account) { + const payload: OIDCStandardPayload = {} + + const scopes = params.scope ? params.scope?.split(' ') : undefined + if (scopes) { + for (const [scope, claims] of Object.entries(OIDC_SCOPE_CLAIMS)) { + const allowed = scopes.includes(scope) + for (const claim of claims) { + const value = allowed ? account[claim] : undefined + // Should not throw as RequestManager should have already checked + // that all the essential claims are available. + if (claimRequested(params, 'id_token', claim, value !== undefined)) { + payload[claim] = value as any // All good as long as the account props match the claims + } + } + } + } + + return payload +} diff --git a/packages/oauth-provider/src/replay/replay-manager.ts b/packages/oauth-provider/src/replay/replay-manager.ts new file mode 100644 index 00000000000..0433a7a0b66 --- /dev/null +++ b/packages/oauth-provider/src/replay/replay-manager.ts @@ -0,0 +1,38 @@ +import { ClientId } from '../client/client-id.js' +import { + CLIENT_ASSERTION_MAX_AGE, + DPOP_NONCE_MAX_AGE, + JAR_MAX_AGE, +} from '../constants.js' +import { ReplayStore } from './replay-store.js' + +const SECURITY_RATIO = 1.1 // 10% extra time for security +const asTimeFrame = (timeFrame: number) => Math.ceil(timeFrame * SECURITY_RATIO) + +export class ReplayManager { + constructor(protected readonly replayStore: ReplayStore) {} + + async uniqueAuth(jti: string, clientId: ClientId): Promise { + return this.replayStore.unique( + `Auth@${clientId}`, + jti, + asTimeFrame(CLIENT_ASSERTION_MAX_AGE), + ) + } + + async uniqueJar(jti: string, clientId: ClientId): Promise { + return this.replayStore.unique( + `JAR@${clientId}`, + jti, + asTimeFrame(JAR_MAX_AGE), + ) + } + + async uniqueDpop(jti: string, clientId?: ClientId): Promise { + return this.replayStore.unique( + clientId ? `DPoP@${clientId}` : `DPoP`, + jti, + asTimeFrame(DPOP_NONCE_MAX_AGE), + ) + } +} diff --git a/packages/oauth-provider/src/replay/replay-store.ts b/packages/oauth-provider/src/replay/replay-store.ts new file mode 100644 index 00000000000..1163c5da41b --- /dev/null +++ b/packages/oauth-provider/src/replay/replay-store.ts @@ -0,0 +1,34 @@ +import { Awaitable } from '../util/awaitable.js' + +// Export all types needed to implement the ReplayStore interface +export type { Awaitable } + +export interface ReplayStore { + /** + * Returns true if the nonce is unique within the given time frame. While not + * strictly necessary for security purposes, the namespace should be used to + * mitigate denial of service attacks from one client to the other. + * + * @param timeFrame expressed in milliseconds. Will never exceed 24 hours. + */ + unique( + namespace: string, + nonce: string, + timeFrame: number, + ): Awaitable +} + +export function isReplayStore( + implementation: Record & Partial, +): implementation is Record & ReplayStore { + return typeof implementation.unique === 'function' +} + +export function asReplayStore( + implementation?: Record & Partial, +): ReplayStore { + if (!implementation || !isReplayStore(implementation)) { + throw new Error('Invalid ReplayStore implementation') + } + return implementation +} diff --git a/packages/oauth-provider/src/request/code.ts b/packages/oauth-provider/src/request/code.ts new file mode 100644 index 00000000000..8ed95138da8 --- /dev/null +++ b/packages/oauth-provider/src/request/code.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +import { CODE_BYTES_LENGTH, CODE_PREFIX } from '../constants.js' +import { randomHexId } from '../util/crypto.js' + +export const codeSchema = z + .string() + .length(CODE_PREFIX.length + CODE_BYTES_LENGTH * 2) // hex encoding + .refine( + (v): v is `${typeof CODE_PREFIX}${string}` => v.startsWith(CODE_PREFIX), + { + message: `Invalid code format`, + }, + ) + +export const isCode = (data: unknown): data is Code => + codeSchema.safeParse(data).success + +export type Code = z.infer +export const generateCode = async (): Promise => { + return `${CODE_PREFIX}${await randomHexId(CODE_BYTES_LENGTH)}` +} diff --git a/packages/oauth-provider/src/request/request-data.ts b/packages/oauth-provider/src/request/request-data.ts new file mode 100644 index 00000000000..a9a5408c8f2 --- /dev/null +++ b/packages/oauth-provider/src/request/request-data.ts @@ -0,0 +1,16 @@ +import { ClientAuth } from '../client/client-auth.js' +import { ClientId } from '../client/client-id.js' +import { DeviceId } from '../device/device-id.js' +import { Sub } from '../oidc/sub.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { Code } from './code.js' + +export type RequestData = { + clientId: ClientId + clientAuth: ClientAuth + parameters: AuthorizationParameters + expiresAt: Date + deviceId: DeviceId | null + sub: Sub | null + code: Code | null +} diff --git a/packages/oauth-provider/src/request/request-id.ts b/packages/oauth-provider/src/request/request-id.ts new file mode 100644 index 00000000000..ecf47ddfa03 --- /dev/null +++ b/packages/oauth-provider/src/request/request-id.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +import { REQUEST_ID_BYTES_LENGTH, REQUEST_ID_PREFIX } from '../constants.js' +import { randomHexId } from '../util/crypto.js' + +export const requestIdSchema = z + .string() + .length( + REQUEST_ID_PREFIX.length + REQUEST_ID_BYTES_LENGTH * 2, // hex encoding + ) + .refine( + (v): v is `${typeof REQUEST_ID_PREFIX}${string}` => + v.startsWith(REQUEST_ID_PREFIX), + { + message: `Invalid request ID format`, + }, + ) + +export type RequestId = z.infer +export const generateRequestId = async (): Promise => { + return `${REQUEST_ID_PREFIX}${await randomHexId(REQUEST_ID_BYTES_LENGTH)}` +} diff --git a/packages/oauth-provider/src/request/request-manager.ts b/packages/oauth-provider/src/request/request-manager.ts new file mode 100644 index 00000000000..7fc419e1faa --- /dev/null +++ b/packages/oauth-provider/src/request/request-manager.ts @@ -0,0 +1,441 @@ +import { DeviceAccountInfo } from '../account/account-store.js' +import { Account } from '../account/account.js' +import { ClientAuth } from '../client/client-auth.js' +import { Client } from '../client/client.js' +import { ClientId } from '../client/client-id.js' +import { + AUTHORIZATION_INACTIVITY_TIMEOUT, + PAR_EXPIRES_IN, + TOKEN_MAX_AGE, +} from '../constants.js' +import { DeviceId } from '../device/device-id.js' +import { AccessDeniedError } from '../errors/access-denied-error.js' +import { InvalidRequestError } from '../errors/invalid-request-error.js' +import { OIDC_SCOPE_CLAIMS } from '../oidc/claims.js' +import { Sub } from '../oidc/sub.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { Signer } from '../signer/signer.js' +import { Awaitable } from '../util/awaitable.js' +import { matchRedirectUri } from '../util/redirect-uri.js' +import { Code, generateCode } from './code.js' +import { RequestId, generateRequestId } from './request-id.js' +import { RequestStore, UpdateRequestData } from './request-store.js' +import { + RequestUri, + decodeRequestUri, + encodeRequestUri, +} from './request-uri.js' + +export type RequestInfo = { + id: RequestId + uri: RequestUri + parameters: AuthorizationParameters + expiresAt: Date + clientAuth: ClientAuth +} + +/** + * Allows validating and modifying the authorization parameters before the + * authorization request is processed. + * + * @throws {InvalidAuthorizationDetailsError} + */ +export type AuthorizationRequestHook = ( + this: null, + parameters: AuthorizationParameters, + data: { + client: Client + clientAuth: ClientAuth + }, +) => Awaitable + +export class RequestManager { + constructor( + protected readonly store: RequestStore, + protected readonly signer: Signer, + protected readonly hooks: { + onAuthorizationRequest?: AuthorizationRequestHook + }, + protected readonly pkceRequired = true, + protected readonly tokenMaxAge = TOKEN_MAX_AGE, + ) {} + + protected createTokenExpiry() { + return new Date(Date.now() + this.tokenMaxAge) + } + + async pushedAuthorizationRequest( + client: Client, + clientAuth: ClientAuth, + input: AuthorizationParameters, + dpopJkt: null | string, + ): Promise { + const params = await this.validate(client, clientAuth, input, dpopJkt) + return this.create(client, clientAuth, params, null) + } + + async authorizationRequest( + client: Client, + clientAuth: ClientAuth, + input: AuthorizationParameters, + deviceId: DeviceId, + ): Promise { + const params = await this.validate(client, clientAuth, input, null) + return this.create(client, clientAuth, params, deviceId) + } + + protected async create( + client: Client, + clientAuth: ClientAuth, + parameters: AuthorizationParameters, + deviceId: null | DeviceId = null, + ): Promise { + const expiresAt = new Date(Date.now() + PAR_EXPIRES_IN) + const id = await generateRequestId() + + await this.store.createRequest(id, { + clientId: client.id, + clientAuth, + parameters, + expiresAt, + deviceId, + sub: null, + code: null, + }) + + const uri = encodeRequestUri(id) + return { id, uri, expiresAt, parameters, clientAuth } + } + + async validate( + client: Client, + clientAuth: ClientAuth, + parameters: Readonly, + dpopJkt: null | string, + pkceRequired = this.pkceRequired, + ): Promise { + const scopes = parameters.scope?.split(' ') + const responseTypes = parameters.response_type.split(' ') + + if (parameters.authorization_details) { + const cAuthDetailsTypes = client.metadata.authorization_details_types + if (!cAuthDetailsTypes) { + throw new InvalidRequestError( + 'Client does not support authorization_details', + ) + } + + for (const detail of parameters.authorization_details) { + if (!cAuthDetailsTypes?.includes(detail.type)) { + throw new InvalidRequestError( + `Unsupported authorization_details type "${detail.type}"`, + ) + } + } + } + + const { redirect_uri } = parameters + if ( + redirect_uri && + !client.metadata.redirect_uris.some((uri) => + matchRedirectUri(uri, redirect_uri), + ) + ) { + throw new InvalidRequestError(`Invalid redirect_uri ${redirect_uri}`) + } + + // https://datatracker.ietf.org/doc/html/rfc9449#section-10 + if (!parameters.dpop_jkt) { + if (dpopJkt) parameters = { ...parameters, dpop_jkt: dpopJkt } + } else if (parameters.dpop_jkt !== dpopJkt) { + throw new InvalidRequestError('DPoP header and dpop_jkt do not match') + } + + if ( + clientAuth.method === + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + ) { + if (parameters.dpop_jkt && clientAuth.jkt === parameters.dpop_jkt) { + throw new InvalidRequestError( + 'The DPoP proof must be signed with a different key than the client assertion', + ) + } + } + + if (!client.metadata.response_types.includes(parameters.response_type)) { + throw new InvalidRequestError( + `Unsupported response_type "${parameters.response_type}"`, + ) + } + + if (pkceRequired && responseTypes.includes('token')) { + throw new InvalidRequestError( + `${parameters.response_type} is not compatible with PKCE`, + ) + } + + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1 + if (pkceRequired && !parameters.code_challenge) { + throw new InvalidRequestError('code_challenge is required') + } + + if ( + parameters.code_challenge && + clientAuth.method === 'none' && + (parameters.code_challenge_method ?? 'plain') === 'plain' + ) { + throw new InvalidRequestError( + 'code_challenge_method=plain requires client authentication', + ) + } + + // https://datatracker.ietf.org/doc/html/rfc7636#section-4.3 + if (parameters.code_challenge_method && !parameters.code_challenge) { + throw new InvalidRequestError( + 'code_challenge_method requires code_challenge', + ) + } + + // https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthRequest + if (responseTypes.includes('id_token') && !parameters.nonce) { + throw new InvalidRequestError('openid scope requires a nonce') + } + + if (responseTypes.includes('id_token') && !scopes?.includes('openid')) { + throw new InvalidRequestError( + 'id_token response_type requires openid scope', + ) + } + + // TODO Validate parameters agains client metadata !!!! + + if (!parameters.scope) { + parameters = { ...parameters, scope: client.metadata.scope } + } + + if (scopes) { + const cScopes = client.metadata.scope?.split(' ') + for (const scope of scopes) { + if (!cScopes?.includes(scope)) { + throw new InvalidRequestError( + `Scope "${scope}" is not registered for this client`, + ) + } + } + } + + for (const [scope, claims] of Object.entries(OIDC_SCOPE_CLAIMS)) { + for (const claim of claims) { + if ( + parameters?.claims?.id_token?.[claim]?.essential === true || + parameters?.claims?.userinfo?.[claim]?.essential === true + ) { + if (!scopes?.includes(scope)) { + throw new InvalidRequestError( + `essential ${claim} claim requires "${scope}" scope`, + ) + } + } + } + } + + // Make "expensive" checks after the "cheaper" checks + + if (parameters.id_token_hint != null) { + await this.signer.verify(parameters.id_token_hint, { + // these are meant to be outdated when used as a hint + clockTolerance: Infinity, + }) + } + + await this.hooks.onAuthorizationRequest?.call( + null, + parameters, // can be modified by the hook (already a cloned value) + { client, clientAuth }, + ) + + return parameters + } + + async get( + uri: RequestUri, + clientId: ClientId, + deviceId: DeviceId, + ): Promise { + const id = decodeRequestUri(uri) + + const data = await this.store.readRequest(id) + if (!data) { + throw new InvalidRequestError('Invalid request_uri') + } + + const updates: UpdateRequestData = {} + + try { + if (data.sub || data.code) { + // If an account was linked to the request, the next step is to exchange + // the code for a token. + throw new InvalidRequestError('This request was already authorized') + } + + if (data.expiresAt < new Date()) { + throw new InvalidRequestError('This request has expired') + } else { + updates.expiresAt = new Date( + Date.now() + AUTHORIZATION_INACTIVITY_TIMEOUT, + ) + } + + if (data.clientId !== clientId) { + throw new InvalidRequestError( + 'This request was initiated for another client', + ) + } + + if (!data.deviceId) { + updates.deviceId = deviceId + } else if (data.deviceId !== deviceId) { + throw new InvalidRequestError( + 'This request was initiated from another device', + ) + } + } catch (err) { + await this.store.deleteRequest(id) + throw err + } + + if (Object.keys(updates).length > 0) { + await this.store.updateRequest(id, updates) + } + + return { + id, + uri, + expiresAt: updates.expiresAt || data.expiresAt, + parameters: data.parameters, + clientAuth: data.clientAuth, + } + } + + async setAuthorized( + client: Client, + uri: RequestUri, + deviceId: DeviceId, + account: Account, + info: DeviceAccountInfo, + ): Promise<{ code?: Code; id_token?: string }> { + const id = decodeRequestUri(uri) + try { + const data = await this.store.readRequest(id) + if (!data) { + throw new AccessDeniedError('This request was not initiated') + } + + if (data.expiresAt < new Date()) { + throw new AccessDeniedError('This request has expired') + } + if (!data.deviceId) { + throw new AccessDeniedError('This request was not initiated') + } + if (data.deviceId !== deviceId) { + throw new AccessDeniedError( + 'This request was initiated from another device', + ) + } + if (data.sub || data.code) { + throw new AccessDeniedError('This request was already authorized') + } + + const responseType = data.parameters.response_type.split(' ') + + const code = responseType.includes('code') + ? await generateCode() + : undefined + + // Bind the request to the account, preventing it from being used again. + await this.store.updateRequest(id, { + sub: account.sub, + code, + // Allow the client to exchange the code for a token within the next 60 seconds. + expiresAt: new Date(Date.now() + AUTHORIZATION_INACTIVITY_TIMEOUT), + }) + + const id_token = responseType.includes('id_token') + ? await this.signer.idToken(client, data.parameters, account, { + auth_time: info.authenticatedAt, + exp: this.createTokenExpiry(), + code, + }) + : undefined + + return { code, id_token } + } catch (err) { + await this.store.deleteRequest(id) + throw err + } + } + + /** + * @note If this method throws an error, any token previously generated from + * the same `code` **must** me revoked. + */ + public async findCode( + client: Client, + clientAuth: ClientAuth, + code: Code, + ): Promise<{ + sub: Sub + deviceId: DeviceId + parameters: AuthorizationParameters + }> { + const result = await this.store.findRequestByCode(code) + if (!result) throw new InvalidRequestError('Invalid code') + + try { + const { data } = result + + if (data.sub == null || data.deviceId == null) { + // Maybe the store implementation is faulty ? + throw new Error('Unexpected request state') + } + + if (data.clientId !== client.id) { + throw new InvalidRequestError('This code was issued for another client') + } + + if (data.expiresAt < new Date()) { + throw new InvalidRequestError('This code has expired') + } + + if (data.clientAuth.method === 'none') { + // If the client did not use PAR, it was not authenticated when the + // request was created (see authorize() method above). Since PAR is not + // mandatory, and since the token exchange currently taking place *is* + // authenticated (`clientAuth`), we allow "upgrading" the authentication + // method (the token created will be bound to the current clientAuth). + } else { + if (clientAuth.method !== data.clientAuth.method) { + throw new InvalidRequestError('Invalid client authentication') + } + + if (!(await client.validateClientAuth(data.clientAuth))) { + throw new InvalidRequestError('Invalid client authentication') + } + } + + return { + sub: data.sub, + deviceId: data.deviceId, + parameters: data.parameters, + } + } finally { + // A "code" can only be used once + await this.store.deleteRequest(result.id) + } + } + + async delete(uri: RequestUri): Promise { + const id = decodeRequestUri(uri) + await this.store.deleteRequest(id) + } +} diff --git a/packages/oauth-provider/src/request/request-store-memory.ts b/packages/oauth-provider/src/request/request-store-memory.ts new file mode 100644 index 00000000000..243f42f23ed --- /dev/null +++ b/packages/oauth-provider/src/request/request-store-memory.ts @@ -0,0 +1,39 @@ +import { Code } from './code.js' +import { RequestId } from './request-id.js' +import { RequestData } from './request-data.js' +import { RequestStore } from './request-store.js' + +export class RequestStoreMemory implements RequestStore { + #requests = new Map() + + async readRequest(id: RequestId): Promise { + return this.#requests.get(id) ?? null + } + + async createRequest(id: RequestId, data: RequestData): Promise { + this.#requests.set(id, data) + } + + async updateRequest( + id: RequestId, + data: Partial, + ): Promise { + const current = this.#requests.get(id) + if (!current) throw new Error('Request not found') + const newData = { ...current, ...data } + this.#requests.set(id, newData) + } + + async deleteRequest(id: RequestId): Promise { + this.#requests.delete(id) + } + + async findRequestByCode( + code: Code, + ): Promise<{ id: RequestId; data: RequestData } | null> { + for (const [id, data] of this.#requests) { + if (data.code === code) return { id, data } + } + return null + } +} diff --git a/packages/oauth-provider/src/request/request-store.ts b/packages/oauth-provider/src/request/request-store.ts new file mode 100644 index 00000000000..c288ce719ac --- /dev/null +++ b/packages/oauth-provider/src/request/request-store.ts @@ -0,0 +1,36 @@ +import { Awaitable } from '../util/awaitable.js' +import { Code } from './code.js' +import { RequestData } from './request-data.js' +import { RequestId } from './request-id.js' + +// Export all types needed to implement the RequestStore interface +export type { Awaitable, Code, RequestData, RequestId } +export type UpdateRequestData = Pick< + Partial, + 'sub' | 'code' | 'deviceId' | 'expiresAt' +> + +export type FoundRequestResult = { + id: RequestId + data: RequestData +} + +export interface RequestStore { + createRequest(id: RequestId, data: RequestData): Awaitable + readRequest(id: RequestId): Awaitable + updateRequest(id: RequestId, data: UpdateRequestData): Awaitable + deleteRequest(id: RequestId): void | Awaitable + findRequestByCode(code: Code): Awaitable +} + +export function isRequestStore( + implementation: Record & Partial, +): implementation is Record & RequestStore { + return ( + typeof implementation.createRequest === 'function' && + typeof implementation.readRequest === 'function' && + typeof implementation.updateRequest === 'function' && + typeof implementation.deleteRequest === 'function' && + typeof implementation.findRequestByCode === 'function' + ) +} diff --git a/packages/oauth-provider/src/request/request-uri.ts b/packages/oauth-provider/src/request/request-uri.ts new file mode 100644 index 00000000000..18f69548038 --- /dev/null +++ b/packages/oauth-provider/src/request/request-uri.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' + +import { RequestId, requestIdSchema } from './request-id.js' + +export const REQUEST_URI_PREFIX = 'urn:ietf:params:oauth:request_uri:' + +export const requestUriSchema = z + .string() + .url() + .refinement( + (data): data is `${typeof REQUEST_URI_PREFIX}${RequestId}` => + data.startsWith(REQUEST_URI_PREFIX) && + requestIdSchema.safeParse(decodeRequestUri(data as any)).success, + { + code: z.ZodIssueCode.custom, + message: 'Invalid request_uri format', + }, + ) + +export type RequestUri = z.infer + +export function encodeRequestUri(requestId: RequestId): RequestUri { + return `${REQUEST_URI_PREFIX}${encodeURIComponent(requestId) as RequestId}` +} + +export function decodeRequestUri(requestUri: RequestUri): RequestId { + const requestIdEnc = requestUri.slice(REQUEST_URI_PREFIX.length) + return decodeURIComponent(requestIdEnc) as RequestId +} diff --git a/packages/oauth-provider/src/request/types.ts b/packages/oauth-provider/src/request/types.ts new file mode 100644 index 00000000000..ca5951da952 --- /dev/null +++ b/packages/oauth-provider/src/request/types.ts @@ -0,0 +1,47 @@ +import { jwtSchema } from '@atproto/jwk' +import { z } from 'zod' + +import { clientIdentificationSchema } from '../client/client-credentials.js' +import { authorizationParametersSchema } from '../parameters/authorization-parameters.js' +import { requestUriSchema } from './request-uri.js' + +export const authorizationRequestJarSchema = z.object({ + /** + * AuthorizationRequest inside a JWT: + * - "iat" is required and **MUST** be less than one minute + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc9101} + * @see {AuthorizationServer.getAuthorizationRequest} + */ + request: jwtSchema, +}) + +export type AuthorizationRequestJar = z.infer< + typeof authorizationRequestJarSchema +> + +export const pushedAuthorizationRequestSchema = z.intersection( + clientIdentificationSchema, + z.union([ + authorizationParametersSchema, + authorizationRequestJarSchema, + // + ]), +) + +export type PushedAuthorizationRequest = z.infer< + typeof pushedAuthorizationRequestSchema +> + +export const authorizationRequestQuerySchema = z.intersection( + clientIdentificationSchema, + z.union([ + authorizationParametersSchema, + authorizationRequestJarSchema, + z.object({ request_uri: requestUriSchema }), + ]), +) + +export type AuthorizationRequestQuery = z.infer< + typeof authorizationRequestQuerySchema +> diff --git a/packages/oauth-provider/src/session/session-data.ts b/packages/oauth-provider/src/session/session-data.ts new file mode 100644 index 00000000000..9e7be4ae6fc --- /dev/null +++ b/packages/oauth-provider/src/session/session-data.ts @@ -0,0 +1,29 @@ +import { z } from 'zod' + +import { SESSION_ID_BYTES_LENGTH, SESSION_ID_PREFIX } from '../constants.js' +import { devideDetailsSchema } from '../device/device-details.js' +import { randomHexId } from '../util/crypto.js' + +export const sessionIdSchema = z + .string() + .length( + SESSION_ID_PREFIX.length + SESSION_ID_BYTES_LENGTH * 2, // hex encoding + ) + .refine( + (v): v is `${typeof SESSION_ID_PREFIX}${string}` => + v.startsWith(SESSION_ID_PREFIX), + { + message: `Invalid session ID format`, + }, + ) +export type SessionId = z.infer +export const generateSessionId = async (): Promise => { + return `${SESSION_ID_PREFIX}${await randomHexId(SESSION_ID_BYTES_LENGTH)}` +} + +export const deviceSessionDataSchema = devideDetailsSchema.extend({ + sessionId: sessionIdSchema, + lastSeenAt: z.date(), +}) + +export type SessionData = z.infer diff --git a/packages/oauth-provider/src/session/session-manager.ts b/packages/oauth-provider/src/session/session-manager.ts new file mode 100644 index 00000000000..5abd2f8539c --- /dev/null +++ b/packages/oauth-provider/src/session/session-manager.ts @@ -0,0 +1,293 @@ +import { IncomingMessage, ServerResponse } from 'node:http' + +import { appendHeader, parseHttpCookies } from '@atproto/http-util' +import { serialize as serializeCookie } from 'cookie' +import type Keygrip from 'keygrip' +import { z } from 'zod' + +import { SESSION_FIXATION_MAX_AGE } from '../constants.js' +import { extractDeviceDetails } from '../device/device-details.js' +import { + DeviceId, + deviceIdSchema, + generateDeviceId, +} from '../device/device-id.js' +import { + SessionData, + generateSessionId, + sessionIdSchema, +} from './session-data.js' +import { SessionStore } from './session-store.js' + +export const DEFAULT_OPTIONS = { + /** + * Controls whether the IP address is read from the `X-Forwarded-For` header + * (if `true`), or from the `req.socket.remoteAddress` property (if `false`). + * + * @default true // (nowadays, most requests are proxied) + */ + trustProxy: true, + + /** + * Amount of time (in ms) after which session IDs will be rotated + * + * @default 300e3 // (5 minutes) + */ + rotationRate: 5 * 60e3, + + /** + * Cookie options + */ + cookie: { + keys: undefined as undefined | Keygrip, + + /** + * Name of the cookie used to identify the device + * + * @default 'session-id' + */ + device: 'device-id', + + /** + * Name of the cookie used to identify the session + * + * @default 'session-id' + */ + session: 'session-id', + + /** + * Url path for the cookie + * + * @default '/oauth/authorize' + */ + path: '/oauth/authorize', + + /** + * Amount of time (in ms) after which the session cookie will expire. + * If set to `null`, the cookie will be a session cookie (deleted when the + * browser is closed). + * + * @default 10 * 365.2 * 24 * 60 * 60e3 // 10 years (in ms) + */ + age: (10 * 365.2 * 24 * 60 * 60e3), + + /** + * Controls whether the cookie is only sent over HTTPS (if `true`), or also + * over HTTP (if `false`). This should **NOT** be set to `false` in + * production. + */ + secure: true, + + /** + * Controls whether the cookie is sent along with cross-site requests. + * + * @default 'lax' + */ + sameSite: 'lax' as 'lax' | 'strict', + }, +} + +export type DeviceSessionManagerOptions = typeof DEFAULT_OPTIONS + +const cookieValueSchema = z.tuple([deviceIdSchema, sessionIdSchema]) +type CookieValue = z.infer + +/** + * This class provides an abstraction for keeping track of DEVICE sessions. It + * relies on a {@link SessionStore} to persist session data and a cookie to + * identify the session. + */ +export class SessionManager { + constructor( + private readonly store: SessionStore, + private readonly options: DeviceSessionManagerOptions = DEFAULT_OPTIONS, + ) {} + + public async load( + req: IncomingMessage, + res: ServerResponse, + ): Promise<{ deviceId: DeviceId }> { + const cookie = await this.getCookie(req) + if (cookie) { + return this.refresh(req, res, cookie.value, cookie.mustRotate) + } else { + return this.create(req, res) + } + } + + private async create( + req: IncomingMessage, + res: ServerResponse, + ): Promise<{ deviceId: DeviceId }> { + const { userAgent, ipAddress } = this.getDeviceDetails(req) + + const [deviceId, sessionId] = await Promise.all([ + generateDeviceId(), + generateSessionId(), + ] as const) + + await this.store.createDeviceSession(deviceId, { + sessionId, + lastSeenAt: new Date(), + userAgent, + ipAddress, + }) + + this.setCookie(res, [deviceId, sessionId]) + + return { deviceId } + } + + private async refresh( + req: IncomingMessage, + res: ServerResponse, + [deviceId, sessionId]: CookieValue, + forceRotate = false, + ): Promise<{ deviceId: DeviceId }> { + const data = await this.store.readDeviceSession(deviceId) + if (!data) return this.create(req, res) + + const lastSeenAt = new Date(data.lastSeenAt) + const age = Date.now() - lastSeenAt.getTime() + + if (sessionId !== data.sessionId) { + if (age <= SESSION_FIXATION_MAX_AGE) { + // The cookie was probably rotated by a concurrent request. Let's + // update the cookie with the new sessionId. + forceRotate = true + } else { + // Something's wrong. Let's create a new session. + await this.store.deleteDeviceSession(deviceId) + return this.create(req, res) + } + } + + const details = this.getDeviceDetails(req) + + if ( + forceRotate || + details.ipAddress !== data.ipAddress || + details.userAgent !== data.userAgent || + age > this.options.rotationRate + ) { + await this.rotate(req, res, deviceId, { + ipAddress: details.ipAddress, + userAgent: details.userAgent || data.userAgent, + }) + } + + return { deviceId } + } + + public async rotate( + req: IncomingMessage, + res: ServerResponse, + deviceId: DeviceId, + data?: Partial>, + ): Promise { + const sessionId = await generateSessionId() + + await this.store.updateDeviceSession(deviceId, { + ...data, + sessionId, + lastSeenAt: new Date(), + }) + + this.setCookie(res, [deviceId, sessionId]) + } + + private async getCookie( + req: IncomingMessage, + ): Promise<{ value: CookieValue; mustRotate: boolean } | null> { + const cookies = parseHttpCookies(req) + if (!cookies) return null + + const device = this.parseCookie( + cookies, + this.options.cookie.device, + deviceIdSchema, + ) + const session = this.parseCookie( + cookies, + this.options.cookie.session, + sessionIdSchema, + ) + + // Silently ignore invalid cookies + if (!device || !session) { + // If the device cookie is valid, let's cleanup the DB + if (device) await this.store.deleteDeviceSession(device.value) + + return null + } + + return { + value: [device.value, session.value], + mustRotate: device.mustRotate || session.mustRotate, + } + } + + private parseCookie( + cookies: Record, + name: string, + schema: z.ZodType | z.ZodEffects, + ): null | { value: T; mustRotate: boolean } { + const result = schema.safeParse(cookies[name], { path: ['cookie', name] }) + if (!result.success) return null + + const value = result.data + + if (this.options.cookie.keys) { + const hash = cookies[`${name}:hash`] + if (!hash) return null + + const idx = this.options.cookie.keys.index(value, hash) + if (idx < 0) return null + + return { value, mustRotate: idx !== 0 } + } + + return { value, mustRotate: false } + } + + private setCookie(res: ServerResponse, cookieValue: null | CookieValue) { + this.writeCookie(res, this.options.cookie.device, cookieValue?.[0]) + this.writeCookie(res, this.options.cookie.session, cookieValue?.[1]) + } + + private writeCookie(res: ServerResponse, name: string, value?: string) { + const cookieOptions = { + maxAge: value + ? this.options.cookie.age == null + ? undefined + : this.options.cookie.age / 1000 + : 0, + httpOnly: true, + path: this.options.cookie.path, + secure: this.options.cookie.secure !== false, + sameSite: this.options.cookie.sameSite === 'lax' ? 'lax' : 'strict', + } as const + + appendHeader( + res, + 'Set-Cookie', + serializeCookie(name, value || '', cookieOptions), + ) + + if (this.options.cookie.keys) { + appendHeader( + res, + 'Set-Cookie', + serializeCookie( + `${name}:hash`, + value ? this.options.cookie.keys.sign(value) : '', + cookieOptions, + ), + ) + } + } + + private getDeviceDetails(req: IncomingMessage) { + return extractDeviceDetails(req, this.options.trustProxy) + } +} diff --git a/packages/oauth-provider/src/session/session-store.ts b/packages/oauth-provider/src/session/session-store.ts new file mode 100644 index 00000000000..7fc474935bd --- /dev/null +++ b/packages/oauth-provider/src/session/session-store.ts @@ -0,0 +1,36 @@ +import { DeviceId } from '../device/device-id.js' +import { Awaitable } from '../util/awaitable.js' +import { SessionId, SessionData } from './session-data.js' + +// Export all types needed to implement the SessionStore interface +export type { Awaitable, SessionId, DeviceId, SessionData } + +export interface SessionStore { + createDeviceSession(deviceId: DeviceId, data: SessionData): Awaitable + readDeviceSession(deviceId: DeviceId): Awaitable + updateDeviceSession( + deviceId: DeviceId, + data: Partial, + ): Awaitable + deleteDeviceSession(deviceId: DeviceId): Awaitable +} + +export function isSessionStore( + implementation: Record & Partial, +): implementation is Record & SessionStore { + return ( + typeof implementation.createDeviceSession === 'function' && + typeof implementation.readDeviceSession === 'function' && + typeof implementation.updateDeviceSession === 'function' && + typeof implementation.deleteDeviceSession === 'function' + ) +} + +export function asSessionStore( + implementation?: Record & Partial, +): SessionStore { + if (!implementation || !isSessionStore(implementation)) { + throw new Error('Invalid SessionStore implementation') + } + return implementation +} diff --git a/packages/oauth-provider/src/signer/signed-token-payload.ts b/packages/oauth-provider/src/signer/signed-token-payload.ts new file mode 100644 index 00000000000..6dbdfe3287d --- /dev/null +++ b/packages/oauth-provider/src/signer/signed-token-payload.ts @@ -0,0 +1,35 @@ +import { jwtPayloadSchema } from '@atproto/jwk' +import z from 'zod' + +import { clientIdSchema } from '../client/client-id.js' +import { subSchema } from '../oidc/sub.js' +import { tokenIdSchema } from '../token/token-id.js' +import { Simplify } from '../util/type.js' + +export const signedTokenPayloadSchema = z.intersection( + jwtPayloadSchema + .pick({ + exp: true, + iat: true, + iss: true, + aud: true, + }) + .required(), + jwtPayloadSchema + .omit({ + exp: true, + iat: true, + iss: true, + aud: true, + }) + .partial() + .extend({ + jti: tokenIdSchema, + sub: subSchema, + client_id: clientIdSchema, + }), +) + +export type SignedTokenPayload = Simplify< + z.infer +> diff --git a/packages/oauth-provider/src/signer/signer.ts b/packages/oauth-provider/src/signer/signer.ts new file mode 100644 index 00000000000..7e7d08c2642 --- /dev/null +++ b/packages/oauth-provider/src/signer/signer.ts @@ -0,0 +1,164 @@ +import { randomBytes } from 'node:crypto' + +import { + JWTVerifyOptions, + Jwt, + JwtPayload, + JwtPayloadGetter, + JwtSignHeader, + Keyset, +} from '@atproto/jwk' +import { generate as hash } from 'oidc-token-hash' + +import { Account } from '../account/account.js' +import { Client } from '../client/client.js' +import { InvalidRequestError } from '../errors/invalid-request-error.js' +import { AuthorizationDetails } from '../parameters/authorization-details.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { claimRequested } from '../parameters/claims-requested.js' +import { oidcPayload } from '../parameters/oidc-payload.js' +import { TokenId } from '../token/token-id.js' +import { dateToEpoch } from '../util/date.js' +import { + SignedTokenPayload, + signedTokenPayloadSchema, +} from './signed-token-payload.js' + +export type VerifyOptions = Omit +export type SignPayload = Omit + +export class Signer { + constructor(public readonly issuer: string, public readonly keyset: Keyset) {} + + async verify

(token: Jwt, options?: VerifyOptions) { + return this.keyset.verify

(token, { ...options, issuer: [this.issuer] }) + } + + public async sign( + signHeader: JwtSignHeader, + payload: SignPayload | JwtPayloadGetter, + ): Promise { + return this.keyset.sign(signHeader, async (protectedHeader, key) => ({ + ...(typeof payload === 'function' + ? await payload(protectedHeader, key) + : payload), + iss: this.issuer, + })) + } + + async accessToken( + client: Client, + parameters: AuthorizationParameters, + account: Account, + extra: { + jti: TokenId + exp: Date + iat?: Date + alg?: string + cnf?: Record + authorization_details?: AuthorizationDetails + }, + ): Promise { + const header: JwtSignHeader = { + // https://datatracker.ietf.org/doc/html/rfc9068#section-2.1 + alg: extra.alg, + typ: 'at+jwt', + } + + const payload: Omit = { + aud: account.aud, + iat: dateToEpoch(extra?.iat), + exp: dateToEpoch(extra.exp), + sub: account.sub, + jti: extra.jti, + cnf: extra.cnf, + // https://datatracker.ietf.org/doc/html/rfc8693#section-4.3 + client_id: client.id, + scope: parameters.scope || client.metadata.scope, + authorization_details: extra.authorization_details, + } + + return this.sign(header, payload) + } + + async verifyAccessToken(token: Jwt) { + const result = await this.verify(token, { + typ: 'at+jwt', + }) + + // The result is already type casted as an AccessTokenPayload, but we need + // to actually verify this. That should already be covered by the fact that + // we don't sign 'at+jwt' tokens without a valid token ID. Let's double + // check in case another version/implementation was used to generate the + // token. + signedTokenPayloadSchema.parse(result.payload) + + return result + } + + async idToken( + client: Client, + params: AuthorizationParameters, + account: Account, + extra: { + exp: Date + iat?: Date + auth_time?: Date + code?: string + access_token?: string + }, + ): Promise { + // Should be enforced by the request manager. Let's be sure. + // https://openid.net/specs/openid-connect-core-1_0.html#HybridAuthRequest + if (!params.nonce) { + throw new InvalidRequestError('Missing required "nonce" parameter') + } + + // This can happen when a client is using password_grant. If a client is + // using password_grant, it should not set "require_auth_time" to true. + if (client.metadata.require_auth_time && extra.auth_time == null) { + throw new InvalidRequestError('Missing required "auth_time" parameter') + } + + return this.sign( + { + alg: client.metadata.id_token_signed_response_alg, + typ: 'JWT', + }, + async ({ alg }, key) => ({ + ...oidcPayload(params, account), + + aud: client.id, + iat: dateToEpoch(extra.iat), + exp: dateToEpoch(extra.exp), + sub: account.sub, + jti: randomBytes(16).toString('hex'), + scope: params.scope, + nonce: params.nonce, + + s_hash: params.state // + ? await hash(params.state, alg, key.crv) + : undefined, + c_hash: extra.code // + ? await hash(extra.code, alg, key.crv) + : undefined, + at_hash: extra.access_token // + ? await hash(extra.access_token, alg, key.crv) + : undefined, + + // https://openid.net/specs/openid-provider-authentication-policy-extension-1_0.html#rfc.section.5.2 + auth_time: + client.metadata.require_auth_time || + (extra.auth_time != null && params.max_age != null) || + claimRequested( + params, + 'id_token', + 'auth_time', + extra.auth_time != null, + ) + ? dateToEpoch(extra.auth_time!) + : undefined, + }), + ) + } +} diff --git a/packages/oauth-provider/src/token/refresh-token.ts b/packages/oauth-provider/src/token/refresh-token.ts new file mode 100644 index 00000000000..0fda128354f --- /dev/null +++ b/packages/oauth-provider/src/token/refresh-token.ts @@ -0,0 +1,30 @@ +import { z } from 'zod' + +import { + REFRESH_TOKEN_BYTES_LENGTH, + REFRESH_TOKEN_PREFIX, +} from '../constants.js' +import { randomHexId } from '../util/crypto.js' + +export const refreshTokenSchema = z + .string() + .length( + REFRESH_TOKEN_PREFIX.length + REFRESH_TOKEN_BYTES_LENGTH * 2, // hex encoding + ) + .refine( + (v): v is `${typeof REFRESH_TOKEN_PREFIX}${string}` => + v.startsWith(REFRESH_TOKEN_PREFIX), + { + message: `Invalid refresh token format`, + }, + ) + +export const isRefreshToken = (data: unknown): data is RefreshToken => + refreshTokenSchema.safeParse(data).success + +export type RefreshToken = z.infer +export const generateRefreshToken = async (): Promise => { + return `${REFRESH_TOKEN_PREFIX}${await randomHexId( + REFRESH_TOKEN_BYTES_LENGTH, + )}` +} diff --git a/packages/oauth-provider/src/token/token-claims.ts b/packages/oauth-provider/src/token/token-claims.ts new file mode 100644 index 00000000000..61dab335e9b --- /dev/null +++ b/packages/oauth-provider/src/token/token-claims.ts @@ -0,0 +1,30 @@ +import { jwtPayloadSchema } from '@atproto/jwk' +import z from 'zod' + +import { clientIdSchema } from '../client/client-id.js' +import { subSchema } from '../oidc/sub.js' +import { Simplify } from '../util/type.js' + +export const tokenClaimsSchema = z.intersection( + jwtPayloadSchema + .pick({ + exp: true, + aud: true, + }) + .required(), + jwtPayloadSchema + .omit({ + exp: true, + iat: true, + iss: true, + aud: true, + jti: true, + }) + .partial() + .extend({ + sub: subSchema, + client_id: clientIdSchema, + }), +) + +export type TokenClaims = Simplify> diff --git a/packages/oauth-provider/src/token/token-data.ts b/packages/oauth-provider/src/token/token-data.ts new file mode 100644 index 00000000000..18280548272 --- /dev/null +++ b/packages/oauth-provider/src/token/token-data.ts @@ -0,0 +1,30 @@ +import { ClientId } from '../client/client-id.js' +import { ClientAuth } from '../client/client-auth.js' +import { DeviceId } from '../device/device-id.js' +import { Sub } from '../oidc/sub.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { AuthorizationDetails } from '../parameters/authorization-details.js' +import { Code } from '../request/code.js' + +export type { + ClientId, + ClientAuth, + DeviceId, + Sub, + AuthorizationParameters, + AuthorizationDetails, + Code, +} + +export type TokenData = { + createdAt: Date + updatedAt: Date + expiresAt: Date + clientId: ClientId + clientAuth: ClientAuth + deviceId: DeviceId | null + sub: Sub + parameters: AuthorizationParameters + details: AuthorizationDetails | null + code: Code | null +} diff --git a/packages/oauth-provider/src/token/token-id.ts b/packages/oauth-provider/src/token/token-id.ts new file mode 100644 index 00000000000..4a570eb4475 --- /dev/null +++ b/packages/oauth-provider/src/token/token-id.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' + +import { TOKEN_ID_BYTES_LENGTH, TOKEN_ID_PREFIX } from '../constants.js' +import { randomHexId } from '../util/crypto.js' + +export const tokenIdSchema = z + .string() + .length( + TOKEN_ID_PREFIX.length + TOKEN_ID_BYTES_LENGTH * 2, // hex encoding + ) + .refine( + (v): v is `${typeof TOKEN_ID_PREFIX}${string}` => + v.startsWith(TOKEN_ID_PREFIX), + { + message: `Invalid token ID format`, + }, + ) + +export type TokenId = z.infer +export const generateTokenId = async (): Promise => { + return `${TOKEN_ID_PREFIX}${await randomHexId(TOKEN_ID_BYTES_LENGTH)}` +} + +export const isTokenId = (data: unknown): data is TokenId => + tokenIdSchema.safeParse(data).success diff --git a/packages/oauth-provider/src/token/token-manager.ts b/packages/oauth-provider/src/token/token-manager.ts new file mode 100644 index 00000000000..b0989175bb8 --- /dev/null +++ b/packages/oauth-provider/src/token/token-manager.ts @@ -0,0 +1,617 @@ +import { createHash } from 'node:crypto' + +import { isJwt } from '@atproto/jwk' + +import { AccessTokenType } from '../access-token/access-token-type.js' +import { AccessToken } from '../access-token/access-token.js' +import { DeviceAccountInfo } from '../account/account-store.js' +import { Account } from '../account/account.js' +import { ClientAuth } from '../client/client-auth.js' +import { Client } from '../client/client.js' +import { + AUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT, + TOKEN_MAX_AGE, + TOTAL_REFRESH_LIFETIME, + UNAUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT, +} from '../constants.js' +import { DeviceId } from '../device/device-id.js' +import { AccessDeniedError } from '../errors/access-denied-error.js' +import { InvalidDpopKeyBindingError } from '../errors/invalid-dpop-key-binding.js' +import { InvalidRequestError } from '../errors/invalid-request-error.js' +import { InvalidTokenError } from '../errors/invalid-token-error.js' +import { UnauthorizedDpopError } from '../errors/unauthorized-dpop-error.js' +import { UnauthorizedClientError } from '../errors/unauthorized-client-error.js' +import { AuthorizationDetails } from '../parameters/authorization-details.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { isCode } from '../request/code.js' +import { Signer } from '../signer/signer.js' +import { Awaitable } from '../util/awaitable.js' +import { dateToEpoch, dateToRelativeSeconds } from '../util/date.js' +import { matchRedirectUri } from '../util/redirect-uri.js' +import { + RefreshToken, + generateRefreshToken, + isRefreshToken, +} from './refresh-token.js' +import { TokenClaims } from './token-claims.js' +import { TokenData } from './token-data.js' +import { + TokenId, + generateTokenId, + isTokenId, + tokenIdSchema, +} from './token-id.js' +import { TokenInfo, TokenStore } from './token-store.js' +import { TokenType } from './token-type.js' +import { + CodeGrantRequest, + PasswordGrantRequest, + RefreshGrantRequest, +} from './types.js' +import { + VerifyTokenClaimsOptions, + VerifyTokenClaimsResult, + verifyTokenClaims, +} from './verify-token-claims.js' + +/** + * @see {@link https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 | RFC 6749 (OAuth2), Section 5.1} + */ +export type TokenResponse = { + id_token?: string + access_token?: AccessToken + token_type?: TokenType + expires_in?: number + refresh_token?: RefreshToken + scope?: string + authorization_details?: AuthorizationDetails + + // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 + // > The client MUST ignore unrecognized value names in the response. + [k: string]: unknown +} + +export type AuthenticateTokenIdResult = VerifyTokenClaimsResult & { + tokenInfo: TokenInfo +} + +/** + * Allows enriching the authorization details with additional information + * before the tokens are issued. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc9396 | RFC 9396} + */ +export type AuthorizationDetailsHook = ( + this: null, + data: { + client: Client + parameters: AuthorizationParameters + account: Account + }, +) => Awaitable + +export type TokenResponseHook = ( + this: null, + tokenResponse: TokenResponse, + data: { + client: Client + parameters: AuthorizationParameters + account: Account + }, +) => Awaitable + +export class TokenManager { + constructor( + protected readonly store: TokenStore, + protected readonly signer: Signer, + protected readonly hooks: { + onAuthorizationDetails?: AuthorizationDetailsHook + onTokenResponse?: TokenResponseHook + }, + protected readonly accessTokenType: AccessTokenType, + protected readonly tokenMaxAge = TOKEN_MAX_AGE, + ) {} + + protected createTokenExpiry(now = new Date()) { + return new Date(now.getTime() + this.tokenMaxAge) + } + + protected useJwtAccessToken(account: Account) { + if (this.accessTokenType === AccessTokenType.auto) { + return this.signer.issuer !== account.aud + } + + return this.accessTokenType === AccessTokenType.jwt + } + + async create( + client: Client, + clientAuth: ClientAuth, + account: Account, + device: null | { id: DeviceId; info: DeviceAccountInfo }, + parameters: AuthorizationParameters, + input: CodeGrantRequest | PasswordGrantRequest, + dpopJkt: null | string, + ): Promise { + if (client.metadata.dpop_bound_access_tokens && !dpopJkt) { + throw new UnauthorizedDpopError() + } + + if (!parameters.dpop_jkt) { + if (dpopJkt) parameters = { ...parameters, dpop_jkt: dpopJkt } + } else if (!dpopJkt) { + throw new UnauthorizedDpopError() + } else if (parameters.dpop_jkt !== dpopJkt) { + throw new InvalidDpopKeyBindingError() + } + + if ( + clientAuth.method === + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + ) { + if (parameters.dpop_jkt && clientAuth.jkt === parameters.dpop_jkt) { + throw new InvalidRequestError( + 'The DPoP proof must be signed with a different key than the client assertion', + ) + } + } + + if (!client.metadata.grant_types.includes(input.grant_type)) { + throw new InvalidRequestError( + `This client is not allowed to use the "${input.grant_type}" grant type`, + ) + } + + switch (input.grant_type) { + case 'authorization_code': + if (!parameters.code_challenge || !parameters.code_challenge_method) { + throw new InvalidRequestError('PKCE is required') + } + + if (!parameters.redirect_uri) { + const redirect_uri = client.metadata.redirect_uris.find((uri) => + matchRedirectUri(uri, input.redirect_uri), + ) + if (redirect_uri) { + parameters = { ...parameters, redirect_uri } + } else { + throw new InvalidRequestError(`Invalid redirect_uri`) + } + } else if (parameters.redirect_uri !== input.redirect_uri) { + throw new InvalidRequestError( + 'This code was issued for another redirect_uri', + ) + } + + break + case 'password': + break + default: + // @ts-expect-error: fool proofing + throw new Error(`Unsupported grant type "${input.grant_type}"`) + } + + if (parameters.code_challenge) { + if (!('code_verifier' in input) || !input.code_verifier) { + throw new InvalidRequestError('code_verifier is required') + } + switch (parameters.code_challenge_method) { + case undefined: // Default is "plain" (per spec) + case 'plain': { + if (parameters.code_challenge !== input.code_verifier) { + throw new InvalidRequestError('Invalid code_verifier') + } + break + } + case 'S256': { + // Because the code_challenge is base64url-encoded, we will decode + // it in order to compare based on bytes. + const inputChallenge = Buffer.from( + parameters.code_challenge, + 'base64', + ) + const computedChallenge = createHash('sha256') + .update(input.code_verifier) + .digest() + if (inputChallenge.compare(computedChallenge) !== 0) { + throw new InvalidRequestError('Invalid code_verifier') + } + break + } + default: { + throw new InvalidRequestError( + `Unsupported code_challenge_method ${parameters.code_challenge_method}`, + ) + } + } + } + + const code = 'code' in input ? input.code : undefined + if (code) { + const tokenInfo = await this.store.findTokenByCode(code) + if (tokenInfo) { + await this.store.deleteToken(tokenInfo.id) + throw new AccessDeniedError(`Code replayed`) + } + } + + const tokenId = await generateTokenId() + const scopes = parameters.scope?.split(' ') + const refreshToken = scopes?.includes('offline_access') + ? await generateRefreshToken() + : undefined + + const now = new Date() + const expiresAt = this.createTokenExpiry(now) + + const authorizationDetails = await this.hooks.onAuthorizationDetails?.call( + null, + { client, parameters, account }, + ) + + const tokenData: TokenData = { + createdAt: now, + updatedAt: now, + expiresAt, + clientId: client.id, + clientAuth, + deviceId: device?.id ?? null, + sub: account.sub, + parameters, + details: authorizationDetails ?? null, + code: code ?? null, + } + + await this.store.createToken(tokenId, tokenData, refreshToken) + + const accessToken: AccessToken = !this.useJwtAccessToken(account) + ? tokenId + : await this.signer.accessToken(client, parameters, account, { + // We don't specify the alg here. We suppose the Resource server will be + // able to verify the token using any alg. + alg: undefined, + exp: expiresAt, + iat: now, + jti: tokenId, + cnf: parameters.dpop_jkt ? { jkt: parameters.dpop_jkt } : undefined, + authorization_details: authorizationDetails, + }) + + const responseTypes = parameters.response_type.split(' ') + const idToken = responseTypes.includes('id_token') + ? await this.signer.idToken(client, parameters, account, { + exp: expiresAt, + iat: now, + // If there is no deviceInfo, we are in a "password_grant" context + auth_time: device?.info.authenticatedAt || new Date(), + access_token: accessToken, + code, + }) + : undefined + + const tokenResponse: TokenResponse = { + access_token: accessToken, + token_type: parameters.dpop_jkt ? 'DPoP' : 'Bearer', + refresh_token: refreshToken, + id_token: idToken, + scope: parameters.scope, + authorization_details: authorizationDetails, + get expires_in() { + return dateToRelativeSeconds(expiresAt) + }, + } + + await this.hooks.onTokenResponse?.call(null, tokenResponse, { + client, + parameters, + account, + }) + + return tokenResponse + } + + protected async validateAccess( + client: Client, + clientAuth: ClientAuth, + tokenInfo: TokenInfo, + ) { + if (tokenInfo.data.clientId !== client.id) { + throw new AccessDeniedError(`Token was not issued to this client`) + } + + if (tokenInfo.info?.authorizedClients.includes(client.id) === false) { + throw new AccessDeniedError(`Client no longer trusted by user`) + } + + if (tokenInfo.data.clientAuth.method !== clientAuth.method) { + throw new AccessDeniedError(`Client authentication method mismatch`) + } + + if (!(await client.validateClientAuth(tokenInfo.data.clientAuth))) { + throw new AccessDeniedError(`Client authentication mismatch`) + } + } + + async refresh( + client: Client, + clientAuth: ClientAuth, + input: RefreshGrantRequest, + dpopJkt: null | string, + ): Promise { + const tokenInfo = await this.store.findTokenByRefreshToken( + input.refresh_token, + ) + if (!tokenInfo?.currentRefreshToken) { + throw new AccessDeniedError(`Invalid refresh token`) + } + + const { account, info, data } = tokenInfo + const { parameters } = data + + try { + if (tokenInfo.currentRefreshToken !== input.refresh_token) { + throw new AccessDeniedError(`refresh token replayed`) + } + + await this.validateAccess(client, clientAuth, tokenInfo) + + if (parameters.dpop_jkt) { + if (!dpopJkt) { + throw new UnauthorizedDpopError() + } else if (parameters.dpop_jkt !== dpopJkt) { + throw new InvalidDpopKeyBindingError() + } + } + + const lastActivity = data.updatedAt + const inactivityTimeout = + clientAuth.method === 'none' + ? UNAUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT + : AUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT + if (lastActivity.getTime() + inactivityTimeout < Date.now()) { + throw new AccessDeniedError(`Refresh token exceeded inactivity timeout`) + } + + if (data.createdAt.getTime() + TOTAL_REFRESH_LIFETIME < Date.now()) { + throw new AccessDeniedError(`Refresh token expired`) + } + + const authorization_details = + await this.hooks.onAuthorizationDetails?.call(null, { + client, + parameters, + account, + }) + + const nextTokenId = await generateTokenId() + const nextRefreshToken = await generateRefreshToken() + + const now = new Date() + const expiresAt = this.createTokenExpiry(now) + + await this.store.rotateToken( + tokenInfo.id, + nextTokenId, + nextRefreshToken, + { + updatedAt: now, + expiresAt, + // When clients rotate their public keys, we store the key that was + // used by the client to authenticate itself while requesting new + // tokens. The validateAccess() method will ensure that the client + // still advertises the key that was used to issue the previous + // refresh token. If a client stops advertising a key, all tokens + // bound to that key will no longer be be refreshable. This allows + // clients to proactively invalidate tokens when a key is compromised. + // Note that the original DPoP key cannot be rotated. This protects + // users in case the ownership of the client id changes. In the latter + // case, a malicious actor could still advertises the public keys of + // the previous owner, but the new owner would not be able to present + // a valid DPoP proof. + clientAuth, + }, + ) + + const accessToken: AccessToken = !this.useJwtAccessToken(account) + ? nextTokenId + : await this.signer.accessToken(client, parameters, account, { + // We don't specify the alg here. We suppose the Resource server will be + // able to verify the token using any alg. + alg: undefined, + exp: expiresAt, + iat: now, + jti: nextTokenId, + cnf: parameters.dpop_jkt ? { jkt: parameters.dpop_jkt } : undefined, + authorization_details, + }) + + const responseTypes = parameters.response_type.split(' ') + const idToken = responseTypes.includes('id_token') + ? await this.signer.idToken(client, parameters, account, { + exp: expiresAt, + iat: now, + auth_time: info?.authenticatedAt, + access_token: accessToken, + }) + : undefined + + const tokenResponse: TokenResponse = { + id_token: idToken, + access_token: accessToken, + token_type: parameters.dpop_jkt ? 'DPoP' : 'Bearer', + refresh_token: nextRefreshToken, + scope: parameters.scope, + authorization_details, + get expires_in() { + return dateToRelativeSeconds(expiresAt) + }, + } + + await this.hooks.onTokenResponse?.call(null, tokenResponse, { + client, + parameters, + account, + }) + + return tokenResponse + } catch (err) { + if (err instanceof AccessDeniedError) { + // Consider the refresh token might be compromised + await this.store.deleteToken(tokenInfo.id) + } + throw err + } + } + + /** + * @see {@link https://datatracker.ietf.org/doc/html/rfc7009#section-2.2 rfc7009} + */ + async revoke(token: string): Promise { + switch (true) { + case isTokenId(token): { + await this.store.deleteToken(token) + return + } + + case isJwt(token): { + const { payload } = await this.signer.verify(token, { + clockTolerance: Infinity, + }) + const tokenId = tokenIdSchema.parse(payload.jti) + await this.store.deleteToken(tokenId) + return + } + + case isRefreshToken(token): { + const tokenInfo = await this.store.findTokenByRefreshToken(token) + if (tokenInfo) await this.store.deleteToken(tokenInfo.id) + return + } + + case isCode(token): { + const tokenInfo = await this.store.findTokenByCode(token) + if (tokenInfo) await this.store.deleteToken(tokenInfo.id) + return + } + + default: + // No error should be returned if the token is not valid + return + } + } + + /** + * Allows an (authenticated) client to obtain information about a token. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc7662 RFC7662} + */ + async clientTokenInfo( + client: Client, + clientAuth: ClientAuth, + token: string, + ): Promise { + const tokenInfo = await this.findTokenInfo(token) + if (!tokenInfo) { + throw new UnauthorizedClientError(`Invalid token`) + } + + try { + await this.validateAccess(client, clientAuth, tokenInfo) + } catch (err) { + await this.store.deleteToken(tokenInfo.id) + throw err + } + + if (tokenInfo.data.expiresAt.getTime() < Date.now()) { + throw new UnauthorizedClientError(`Token expired`) + } + + return tokenInfo + } + + protected async findTokenInfo(token: string): Promise { + switch (true) { + case isTokenId(token): + return this.store.readToken(token) + + case isJwt(token): { + const { payload } = await this.signer + .verifyAccessToken(token) + .catch((_) => ({ payload: null })) + if (!payload) return null + + const tokenInfo = await this.store.readToken(payload.jti) + if (!tokenInfo) return null + + // Audience changed (e.g. user was moved to another resource server) + if (payload.aud !== tokenInfo.account.aud) { + return null + } + + // Invalid store implementation ? + if (payload.sub !== tokenInfo.account.sub) { + throw new Error( + `Account sub (${tokenInfo.account.sub}) does not match token sub (${payload.sub})`, + ) + } + + return tokenInfo + } + + case isRefreshToken(token): { + const tokenInfo = await this.store.findTokenByRefreshToken(token) + if (!tokenInfo?.currentRefreshToken) return null + if (tokenInfo.currentRefreshToken !== token) return null + return tokenInfo + } + + default: + // Should never happen + return null + } + } + + async getTokenInfo(tokenType: TokenType, tokenId: TokenId) { + const tokenInfo = await this.store.readToken(tokenId) + + if (!tokenInfo) { + throw new InvalidTokenError(`Invalid token`, { [tokenType]: {} }) + } + + if (!(tokenInfo.data.expiresAt.getTime() > Date.now())) { + throw new InvalidTokenError(`Token expired`, { [tokenType]: {} }) + } + + return tokenInfo + } + + async authenticateTokenId( + tokenType: TokenType, + token: TokenId, + dpopJkt: string | null, + verifyOptions?: VerifyTokenClaimsOptions, + ): Promise { + const tokenInfo = await this.getTokenInfo(tokenType, token) + const { parameters } = tokenInfo.data + + const claims: TokenClaims = { + aud: tokenInfo.account.aud, + sub: tokenInfo.account.sub, + exp: dateToEpoch(tokenInfo.data.expiresAt), + scope: tokenInfo.data.parameters.scope, + client_id: tokenInfo.data.clientId, + cnf: parameters.dpop_jkt ? { jkt: parameters.dpop_jkt } : undefined, + } + + const result = verifyTokenClaims( + token, + token, + tokenType, + dpopJkt, + claims, + verifyOptions, + ) + + return { ...result, tokenInfo } + } +} diff --git a/packages/oauth-provider/src/token/token-store.ts b/packages/oauth-provider/src/token/token-store.ts new file mode 100644 index 00000000000..fd4c3cd358b --- /dev/null +++ b/packages/oauth-provider/src/token/token-store.ts @@ -0,0 +1,76 @@ +import { DeviceAccountInfo } from '../account/account-store.js' +import { Account } from '../account/account.js' +import { Code } from '../request/code.js' +import { Awaitable } from '../util/awaitable.js' +import { RefreshToken } from './refresh-token.js' +import { TokenData } from './token-data.js' +import { TokenId } from './token-id.js' + +// Export all types needed to implement the TokenStore interface +export type * from './token-data.js' +export type { Awaitable, TokenId, TokenData, RefreshToken } + +export type TokenInfo = { + id: TokenId + data: TokenData + account: Account + info?: DeviceAccountInfo + currentRefreshToken: null | RefreshToken +} + +export type NewTokenData = Pick< + TokenData, + 'clientAuth' | 'expiresAt' | 'updatedAt' +> + +export interface TokenStore { + createToken( + tokenId: TokenId, + data: TokenData, + refreshToken?: RefreshToken, + ): Awaitable + + readToken(tokenId: TokenId): Awaitable + + deleteToken(tokenId: TokenId): Awaitable + + rotateToken( + tokenId: TokenId, + newTokenId: TokenId, + newRefreshToken: RefreshToken, + newData: NewTokenData, + ): Awaitable + + /** + * Find a token by its refresh token. Note that previous refresh tokens + * should also return the token. The data model is reponsible for storing + * old refresh tokens when a new one is issued. + */ + findTokenByRefreshToken( + refreshToken: RefreshToken, + ): Awaitable + + findTokenByCode(code: Code): Awaitable +} + +export function isTokenStore( + implementation: Record & Partial, +): implementation is Record & TokenStore { + return ( + typeof implementation.createToken === 'function' && + typeof implementation.readToken === 'function' && + typeof implementation.rotateToken === 'function' && + typeof implementation.deleteToken === 'function' && + typeof implementation.findTokenByCode === 'function' && + typeof implementation.findTokenByRefreshToken === 'function' + ) +} + +export function asTokenStore( + implementation?: Record & Partial, +): TokenStore { + if (!implementation || !isTokenStore(implementation)) { + throw new Error('Invalid TokenStore implementation') + } + return implementation +} diff --git a/packages/oauth-provider/src/token/token-type.ts b/packages/oauth-provider/src/token/token-type.ts new file mode 100644 index 00000000000..14b309cf469 --- /dev/null +++ b/packages/oauth-provider/src/token/token-type.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +// Case insensitive input, normalized output +export const tokenTypeSchema = z.union([ + z + .string() + .regex(/^DPoP$/i) + .transform(() => 'DPoP' as const), + z + .string() + .regex(/^Bearer$/i) + .transform(() => 'Bearer' as const), +]) + +export type TokenType = z.infer diff --git a/packages/oauth-provider/src/token/types.ts b/packages/oauth-provider/src/token/types.ts new file mode 100644 index 00000000000..9327ba85adf --- /dev/null +++ b/packages/oauth-provider/src/token/types.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' + +import { accessTokenSchema } from '../access-token/access-token.js' +import { clientIdentificationSchema } from '../client/client-credentials.js' +import { clientIdSchema } from '../client/client-id.js' +import { AuthorizationDetails } from '../parameters/authorization-details.js' +import { codeSchema } from '../request/code.js' +import { refreshTokenSchema } from './refresh-token.js' +import { TokenType } from './token-type.js' + +export const codeGrantRequestSchema = z.intersection( + clientIdentificationSchema, + z.object({ + grant_type: z.literal('authorization_code'), + code: codeSchema, + /** @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1} */ + code_verifier: z + .string() + .min(43) + .max(128) + .regex(/^[a-zA-Z0-9-._~]+$/), + redirect_uri: z.string().url(), + // request_uri ??? + }), +) + +export type CodeGrantRequest = z.infer + +export const refreshGrantRequestSchema = z.intersection( + clientIdentificationSchema, + z.object({ + grant_type: z.literal('refresh_token'), + refresh_token: refreshTokenSchema, + client_id: clientIdSchema, + }), +) + +export type RefreshGrantRequest = z.infer + +export const passwordGrantRequestSchema = z.intersection( + clientIdentificationSchema, + z.object({ + grant_type: z.literal('password'), + username: z.string(), + password: z.string(), + scope: z.string().optional(), + }), +) + +export type PasswordGrantRequest = z.infer + +export const tokenRequestSchema = z.union([ + codeGrantRequestSchema, + refreshGrantRequestSchema, + passwordGrantRequestSchema, +]) + +export type TokenRequest = z.infer + +export const tokenIdentification = z.object({ + token: z.union([accessTokenSchema, refreshTokenSchema]), + token_type_hint: z.enum(['access_token', 'refresh_token']).optional(), +}) + +export type TokenIdentification = z.infer + +export const revokeSchema = tokenIdentification + +export type Revoke = z.infer + +export const introspectSchema = z.intersection( + clientIdentificationSchema, + tokenIdentification, +) + +export type Introspect = z.infer + +// https://datatracker.ietf.org/doc/html/rfc7662#section-2.2 +export type IntrospectionResponse = + | { active: false } + | { + active: true + + scope?: string + client_id?: string + username?: string + token_type?: TokenType + authorization_details?: AuthorizationDetails + + aud?: string | [string, ...string[]] + exp?: number + iat?: number + iss?: string + jti?: string + nbf?: number + sub?: string + } diff --git a/packages/oauth-provider/src/token/verify-token-claims.ts b/packages/oauth-provider/src/token/verify-token-claims.ts new file mode 100644 index 00000000000..a34659333dc --- /dev/null +++ b/packages/oauth-provider/src/token/verify-token-claims.ts @@ -0,0 +1,66 @@ +import { AccessToken } from '../access-token/access-token.js' +import { InvalidDpopKeyBindingError } from '../errors/invalid-dpop-key-binding.js' +import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' +import { UnauthorizedError } from '../errors/unauthorized-error.js' +import { asArray } from '../util/cast.js' +import { TokenClaims } from './token-claims.js' +import { TokenId } from './token-id.js' +import { TokenType } from './token-type.js' + +export type VerifyTokenClaimsOptions = { + /** One of these audience must be included in the token audience(s) */ + audience?: [string, ...string[]] + /** One of these scope must be included in the token scope(s) */ + scope?: [string, ...string[]] +} + +export type VerifyTokenClaimsResult = { + token: AccessToken + tokenId: TokenId + tokenType: TokenType + claims: TokenClaims +} + +export function verifyTokenClaims( + token: AccessToken, + tokenId: TokenId, + tokenType: TokenType, + dpopJkt: string | null, + claims: TokenClaims, + options?: VerifyTokenClaimsOptions, +): VerifyTokenClaimsResult { + const claimsJkt = claims.cnf?.jkt ?? null + + const expectedTokenType: TokenType = claimsJkt ? 'DPoP' : 'Bearer' + if (expectedTokenType !== tokenType) { + throw new UnauthorizedError(`Invalid token type`, { + [expectedTokenType]: {}, + }) + } + if (tokenType === 'DPoP' && !dpopJkt) { + throw new InvalidDpopProofError(`jkt is required for DPoP tokens`) + } + if (claimsJkt !== dpopJkt) { + throw new InvalidDpopKeyBindingError() + } + + if (options?.audience) { + const aud = asArray(claims.aud) + if (!options.audience.some((v) => aud.includes(v))) { + throw new UnauthorizedError(`Invalid audience`, { + [tokenType]: {}, + }) + } + } + + if (options?.scope) { + const scopes = claims.scope?.split(' ') + if (!scopes || !options.scope.some((v) => scopes.includes(v))) { + throw new UnauthorizedError(`Invalid scope`, { + [tokenType]: {}, + }) + } + } + + return { token, tokenId, tokenType, claims } +} diff --git a/packages/oauth-provider/src/util/authorization-header.ts b/packages/oauth-provider/src/util/authorization-header.ts new file mode 100644 index 00000000000..b9a390fad12 --- /dev/null +++ b/packages/oauth-provider/src/util/authorization-header.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +import { accessTokenSchema } from '../access-token/access-token.js' +import { UnauthorizedError } from '../errors/unauthorized-error.js' +import { tokenTypeSchema } from '../token/token-type.js' + +export const authorizationHeaderSchema = z.tuple([ + tokenTypeSchema, + accessTokenSchema, +]) + +export const parseAuthorizationHeader = (header?: string) => { + const parsed = authorizationHeaderSchema.safeParse(header?.split(' ', 2)) + if (!parsed.success) { + throw new UnauthorizedError('Invalid authorization header', { + Bearer: {}, + DPoP: {}, + }) + } + return parsed.data +} diff --git a/packages/oauth-provider/src/util/awaitable.ts b/packages/oauth-provider/src/util/awaitable.ts new file mode 100644 index 00000000000..f800e7208e9 --- /dev/null +++ b/packages/oauth-provider/src/util/awaitable.ts @@ -0,0 +1 @@ +export type Awaitable = T | Promise diff --git a/packages/oauth-provider/src/util/cast.ts b/packages/oauth-provider/src/util/cast.ts new file mode 100644 index 00000000000..9302f74efe0 --- /dev/null +++ b/packages/oauth-provider/src/util/cast.ts @@ -0,0 +1,4 @@ +export function asArray(value: T | T[]): T[] { + if (value == null) return [] + return Array.isArray(value) ? value : [value] +} diff --git a/packages/oauth-provider/src/util/crypto.ts b/packages/oauth-provider/src/util/crypto.ts new file mode 100644 index 00000000000..ef18ab0f6f1 --- /dev/null +++ b/packages/oauth-provider/src/util/crypto.ts @@ -0,0 +1,27 @@ +import { randomBytes } from 'node:crypto' + +export async function randomHexId(bytesLength = 16) { + return new Promise((resolve, reject) => { + randomBytes(bytesLength, (err, buf) => { + if (err) return reject(err) + resolve(buf.toString('hex')) + }) + }) +} + +// Basically all algorithms supported by "jose"'s jwtVerify(). +// TODO: Is there a way to get this list from the runtime instead of hardcoding it? +export const VERIFY_ALGOS = [ + 'RS256', + 'RS384', + 'RS512', + + 'PS256', + 'PS384', + 'PS512', + + 'ES256', + 'ES256K', + 'ES384', + 'ES512', +] as const diff --git a/packages/oauth-provider/src/util/date.ts b/packages/oauth-provider/src/util/date.ts new file mode 100644 index 00000000000..829030d2a53 --- /dev/null +++ b/packages/oauth-provider/src/util/date.ts @@ -0,0 +1,7 @@ +export function dateToEpoch(date: Date = new Date()) { + return Math.floor(date.getTime() / 1000) +} + +export function dateToRelativeSeconds(date: Date) { + return Math.floor((date.getTime() - Date.now()) / 1000) +} diff --git a/packages/oauth-provider/src/util/redirect-uri.ts b/packages/oauth-provider/src/util/redirect-uri.ts new file mode 100644 index 00000000000..67be72ba8b7 --- /dev/null +++ b/packages/oauth-provider/src/util/redirect-uri.ts @@ -0,0 +1,41 @@ +/** + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc8252#section-8.4} + */ +export function matchRedirectUri( + allowed_uri: string, + request_uri: string, +): boolean { + if (allowed_uri === request_uri) return true + + const allowed_uri_parsed = new URL(allowed_uri) + + // https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 + if ( + allowed_uri_parsed.hostname !== 'localhost' && + allowed_uri_parsed.hostname !== '127.0.0.1' && + allowed_uri_parsed.hostname !== '[::1]' + ) { + return false + } + + const request_uri_parsed = new URL(request_uri) + + // https://datatracker.ietf.org/doc/html/rfc8252#section-8.4 + // + // > Authorization servers MUST require clients to register their complete + // > redirect URI (including the path component) and reject authorization + // > requests that specify a redirect URI that doesn't exactly match the + // > one that was registered; the exception is loopback redirects, where + // > an exact match is required except for the port URI component. + return ( + // allowed_uri_parsed.port === request_uri_parsed.port && + allowed_uri_parsed.hostname === request_uri_parsed.hostname && + allowed_uri_parsed.pathname === request_uri_parsed.pathname && + allowed_uri_parsed.protocol === request_uri_parsed.protocol && + allowed_uri_parsed.search === request_uri_parsed.search && + allowed_uri_parsed.hash === request_uri_parsed.hash && + allowed_uri_parsed.username === request_uri_parsed.username && + allowed_uri_parsed.password === request_uri_parsed.password + ) +} diff --git a/packages/oauth-provider/src/util/type.ts b/packages/oauth-provider/src/util/type.ts new file mode 100644 index 00000000000..7bbf3b6d659 --- /dev/null +++ b/packages/oauth-provider/src/util/type.ts @@ -0,0 +1,3 @@ +// eslint-disable-next-line @typescript-eslint/ban-types +export type Simplify = { [K in keyof T]: T[K] } & {} +export type Override = V & Simplify> diff --git a/packages/oauth-provider/tailwind.config.js b/packages/oauth-provider/tailwind.config.js new file mode 100644 index 00000000000..9719c558257 --- /dev/null +++ b/packages/oauth-provider/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['src/app/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/packages/oauth-provider/tsconfig.backend.json b/packages/oauth-provider/tsconfig.backend.json new file mode 100644 index 00000000000..f10d082b10d --- /dev/null +++ b/packages/oauth-provider/tsconfig.backend.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig/nodenext.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/app"] +} diff --git a/packages/oauth-provider/tsconfig.frontend.json b/packages/oauth-provider/tsconfig.frontend.json new file mode 100644 index 00000000000..08f471b84d2 --- /dev/null +++ b/packages/oauth-provider/tsconfig.frontend.json @@ -0,0 +1,8 @@ +{ + "extends": ["../../tsconfig/browser.json", "../../tsconfig/bundler.json"], + "compilerOptions": { + "rootDir": "src/app", + "outDir": "dist/app" + }, + "include": ["src/app/**/*.ts", "src/app/**/*.tsx"] +} diff --git a/packages/oauth-provider/tsconfig.json b/packages/oauth-provider/tsconfig.json new file mode 100644 index 00000000000..a6bf0ef639d --- /dev/null +++ b/packages/oauth-provider/tsconfig.json @@ -0,0 +1,8 @@ +{ + "include": [], + "references": [ + { "path": "./tsconfig.frontend.json" }, + { "path": "./tsconfig.backend.json" }, + { "path": "./tsconfig.tools.json" } + ] +} diff --git a/packages/oauth-provider/tsconfig.tools.json b/packages/oauth-provider/tsconfig.tools.json new file mode 100644 index 00000000000..2c32f01beb9 --- /dev/null +++ b/packages/oauth-provider/tsconfig.tools.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/nodenext.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["./*.js", "./*.cjs", "./*.mjs", "./*.ts"] +} diff --git a/packages/rollup-plugin-bundle-manifest/package.json b/packages/rollup-plugin-bundle-manifest/package.json new file mode 100644 index 00000000000..d089df7c97a --- /dev/null +++ b/packages/rollup-plugin-bundle-manifest/package.json @@ -0,0 +1,26 @@ +{ + "name": "@atproto/rollup-plugin-bundle-manifest", + "version": "0.0.1", + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "mime": "^3.0.0" + }, + "peerDependencies": { + "rollup": "^4.0.0" + }, + "devDependencies": { + "rollup": "^4.10.0", + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/rollup-plugin-bundle-manifest/src/index.ts b/packages/rollup-plugin-bundle-manifest/src/index.ts new file mode 100644 index 00000000000..83c4b0266cd --- /dev/null +++ b/packages/rollup-plugin-bundle-manifest/src/index.ts @@ -0,0 +1,76 @@ +import { createHash } from 'node:crypto' +import { extname } from 'node:path' + +import mime from 'mime' +import { Plugin } from 'rollup' + +type AssetItem = { + type: 'asset' + mime?: string + sha256: string + data?: string +} + +type ChunkItem = { + type: 'chunk' + mime: string + sha256: string + dynamicImports: string[] + isDynamicEntry: boolean + isEntry: boolean + isImplicitEntry: boolean + name: string + data?: string +} + +export type ManifestItem = AssetItem | ChunkItem + +export type Manifest = Record + +export default function bundleManifest({ + name = 'bundle-manifest.json', + data = false, +}: { + name?: string + data?: boolean +} = {}): Plugin { + return { + name: 'bundle-manifest', + generateBundle(outputOptions, bundle) { + const manifest: Manifest = {} + + for (const [fileName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'asset') { + manifest[fileName] = { + type: chunk.type, + data: data + ? Buffer.from(chunk.source).toString('base64') + : undefined, + mime: mime.getType(extname(fileName)) || undefined, + sha256: createHash('sha256').update(chunk.source).digest('base64'), + } + } + + if (chunk.type === 'chunk') { + manifest[fileName] = { + type: chunk.type, + data: data ? Buffer.from(chunk.code).toString('base64') : undefined, + mime: 'application/javascript', + sha256: createHash('sha256').update(chunk.code).digest('base64'), + dynamicImports: chunk.dynamicImports, + isDynamicEntry: chunk.isDynamicEntry, + isEntry: chunk.isEntry, + isImplicitEntry: chunk.isImplicitEntry, + name: chunk.name, + } + } + } + + this.emitFile({ + type: 'asset', + fileName: name, + source: JSON.stringify(manifest, null, 2), + }) + }, + } +} diff --git a/packages/rollup-plugin-bundle-manifest/tsconfig.json b/packages/rollup-plugin-bundle-manifest/tsconfig.json new file mode 100644 index 00000000000..5c47f3f86ef --- /dev/null +++ b/packages/rollup-plugin-bundle-manifest/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/transformer/package.json b/packages/transformer/package.json new file mode 100644 index 00000000000..fbb95823ba7 --- /dev/null +++ b/packages/transformer/package.json @@ -0,0 +1,22 @@ +{ + "name": "@atproto/transformer", + "version": "0.0.1", + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "tslib": "^2.6.2" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/transformer/src/compose.ts b/packages/transformer/src/compose.ts new file mode 100644 index 00000000000..9a0ab279aa9 --- /dev/null +++ b/packages/transformer/src/compose.ts @@ -0,0 +1,60 @@ +import { Transformer } from './transformer.js' + +export type FirstTransformerInput[]> = T extends [ + Transformer, + ...any[], +] + ? I + : T extends Transformer[] + ? I + : never + +export type LastTransformerOutput[]> = T extends [ + ...any[], + Transformer, +] + ? O + : T extends Transformer[] + ? O + : never + +export type TansformerCompose< + F extends Transformer[], + Acc extends Transformer[] = [], +> = F extends [Transformer] + ? [...Acc, Transformer] + : F extends [Transformer, ...infer Tail] + ? Tail extends [Transformer, ...any[]] + ? TansformerCompose]> + : Acc + : Acc + +export function compose(): (v: V) => Promise +export function compose[]>( + ...transformers: TansformerCompose extends T ? T : TansformerCompose +): (input: FirstTransformerInput) => Promise> +export function compose[]>( + ...transformers: TansformerCompose extends T ? T : TansformerCompose +): (input: FirstTransformerInput) => Promise> { + const { length, 0: a, 1: b, 2: c, 3: d } = transformers + switch (length) { + case 0: + return async (v) => v as any + case 1: + return async (v) => a!(v) + case 2: + return async (v) => b!(await a!(v)) + case 3: + return async (v) => c!(await b!(await a!(v))) + case 4: + return async (v) => d!(await c!(await b!(await a!(v)))) + default: { + return async (v: any) => { + for (let i = 0; i < length; i++) { + v = await transformers[i]!.call(null, v) + } + return v + } + } + } +} diff --git a/packages/transformer/src/index.ts b/packages/transformer/src/index.ts new file mode 100644 index 00000000000..69c163dda2f --- /dev/null +++ b/packages/transformer/src/index.ts @@ -0,0 +1,2 @@ +export { type Transformer } from './transformer.js' +export { compose } from './compose.js' diff --git a/packages/transformer/src/transformer.ts b/packages/transformer/src/transformer.ts new file mode 100644 index 00000000000..9cd7f965f00 --- /dev/null +++ b/packages/transformer/src/transformer.ts @@ -0,0 +1 @@ +export type Transformer = (input: I) => O | PromiseLike diff --git a/packages/transformer/tsconfig.json b/packages/transformer/tsconfig.json new file mode 100644 index 00000000000..74b7e3462ca --- /dev/null +++ b/packages/transformer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ceab7fd608..d419098f76b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,7 +49,7 @@ importers: version: 5.1.3(eslint-config-prettier@9.1.0)(eslint@8.57.0)(prettier@3.2.5) jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) node-gyp: specifier: ^9.3.1 version: 9.3.1 @@ -95,7 +95,7 @@ importers: version: 6.1.2 jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) prettier: specifier: ^3.2.5 version: 3.2.5 @@ -270,10 +270,10 @@ importers: version: 0.27.2 jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@20.12.6)(ts-node@10.8.2) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) ts-node: specifier: ^10.8.2 - version: 10.8.2(@swc/core@1.3.42)(@types/node@20.12.6)(typescript@5.4.4) + version: 10.8.2(@swc/core@1.3.42)(@types/node@18.19.24)(typescript@5.4.4) packages/bsync: dependencies: @@ -322,10 +322,10 @@ importers: version: 8.6.6 jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@20.12.6)(ts-node@10.8.2) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) ts-node: specifier: ^10.8.2 - version: 10.8.2(@swc/core@1.3.42)(@types/node@20.12.6)(typescript@5.4.4) + version: 10.8.2(@swc/core@1.3.42)(@types/node@18.19.24)(typescript@5.4.4) packages/common: dependencies: @@ -350,7 +350,7 @@ importers: devDependencies: jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) typescript: specifier: ^5.3.3 version: 5.3.3 @@ -375,7 +375,7 @@ importers: devDependencies: jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) packages/crypto: dependencies: @@ -394,7 +394,7 @@ importers: version: link:../common jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) packages/dev-env: dependencies: @@ -456,6 +456,91 @@ importers: specifier: 3.0.0 version: 3.0.0 + packages/fetch: + dependencies: + '@atproto/transformer': + specifier: workspace:* + version: link:../transformer + tslib: + specifier: ^2.6.2 + version: 2.6.2 + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + '@types/http-errors': + specifier: ^2.0.4 + version: 2.0.4 + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/fetch-node: + dependencies: + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/transformer': + specifier: workspace:* + version: link:../transformer + http-errors: + specifier: ^2.0.0 + version: 2.0.0 + ipaddr.js: + specifier: ^2.1.0 + version: 2.1.0 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + '@types/http-errors': + specifier: ^2.0.4 + version: 2.0.4 + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/html: + dependencies: + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/http-util: + dependencies: + '@hapi/accept': + specifier: ^6.0.3 + version: 6.0.3 + '@hapi/bourne': + specifier: ^3.0.0 + version: 3.0.0 + cookie: + specifier: ^0.6.0 + version: 0.6.0 + http-errors: + specifier: ^2.0.0 + version: 2.0.0 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + '@types/cookie': + specifier: ^0.6.0 + version: 0.6.0 + '@types/http-errors': + specifier: ^2.0.4 + version: 2.0.4 + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/identity: dependencies: '@atproto/common-web': @@ -485,7 +570,39 @@ importers: version: 6.1.2 jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) + + packages/jwk: + dependencies: + jose: + specifier: ^5.2.2 + version: 5.2.4 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/jwk-node: + dependencies: + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + jose: + specifier: ^5.2.0 + version: 5.2.4 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 packages/lex-cli: dependencies: @@ -534,7 +651,166 @@ importers: devDependencies: jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) + + packages/oauth-provider: + dependencies: + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/fetch-node': + specifier: workspace:* + version: link:../fetch-node + '@atproto/html': + specifier: workspace:* + version: link:../html + '@atproto/http-util': + specifier: workspace:* + version: link:../http-util + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/jwk-node': + specifier: workspace:* + version: link:../jwk-node + cookie: + specifier: ^0.6.0 + version: 0.6.0 + jose: + specifier: ^5.2.0 + version: 5.2.4 + lru-cache: + specifier: ^10.2.0 + version: 10.2.0 + oidc-token-hash: + specifier: ^5.0.3 + version: 5.0.3 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + zod: + specifier: ^3.22.4 + version: 3.22.4 + optionalDependencies: + keygrip: + specifier: ^1.1.0 + version: 1.1.0 + devDependencies: + '@atproto/rollup-plugin-bundle-manifest': + specifier: workspace:* + version: link:../rollup-plugin-bundle-manifest + '@rollup/plugin-commonjs': + specifier: ^25.0.7 + version: 25.0.7(rollup@4.14.1) + '@rollup/plugin-node-resolve': + specifier: ^15.2.3 + version: 15.2.3(rollup@4.14.1) + '@rollup/plugin-replace': + specifier: ^5.0.5 + version: 5.0.5(rollup@4.14.1) + '@rollup/plugin-terser': + specifier: ^0.4.4 + version: 0.4.4(rollup@4.14.1) + '@rollup/plugin-typescript': + specifier: ^11.1.6 + version: 11.1.6(rollup@4.14.1)(tslib@2.6.2)(typescript@5.4.4) + '@types/cookie': + specifier: ^0.6.0 + version: 0.6.0 + '@types/keygrip': + specifier: ^1.0.6 + version: 1.0.6 + '@types/react': + specifier: ^18.2.50 + version: 18.2.75 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.2.24 + '@types/send': + specifier: ^0.17.4 + version: 0.17.4 + autoprefixer: + specifier: ^10.4.17 + version: 10.4.19(postcss@8.4.38) + postcss: + specifier: ^8.4.33 + version: 8.4.38 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + rollup: + specifier: ^4.10.0 + version: 4.14.1 + rollup-plugin-postcss: + specifier: ^4.0.2 + version: 4.0.2(postcss@8.4.38) + tailwindcss: + specifier: ^3.4.1 + version: 3.4.3 + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/oauth-provider-client-fqdn: + dependencies: + '@atproto/oauth-provider': + specifier: workspace:* + version: link:../oauth-provider + '@atproto/oauth-provider-client-uri': + specifier: workspace:* + version: link:../oauth-provider-client-uri + tslib: + specifier: ^2.6.2 + version: 2.6.2 + + packages/oauth-provider-client-uri: + dependencies: + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/oauth-provider': + specifier: workspace:* + version: link:../oauth-provider + '@atproto/transformer': + specifier: workspace:* + version: link:../transformer + psl: + specifier: ^1.9.0 + version: 1.9.0 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + '@types/psl': + specifier: ^1.1.3 + version: 1.1.3 + + packages/oauth-provider-replay-memory: + dependencies: + '@atproto/oauth-provider': + specifier: workspace:* + version: link:../oauth-provider + tslib: + specifier: ^2.6.2 + version: 2.6.2 + + packages/oauth-provider-replay-redis: + dependencies: + '@atproto/oauth-provider': + specifier: workspace:* + version: link:../oauth-provider + ioredis: + specifier: ^5.3.2 + version: 5.3.2 + tslib: + specifier: ^2.6.2 + version: 2.6.2 packages/ozone: dependencies: @@ -628,10 +904,10 @@ importers: version: 6.9.7 jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@20.12.6)(ts-node@10.8.2) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) ts-node: specifier: ^10.8.2 - version: 10.8.2(@swc/core@1.3.42)(@types/node@20.12.6)(typescript@5.4.4) + version: 10.8.2(@swc/core@1.3.42)(@types/node@18.19.24)(typescript@5.4.4) packages/pds: dependencies: @@ -788,10 +1064,10 @@ importers: version: 6.1.2 jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@20.12.6)(ts-node@10.8.2) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) ts-node: specifier: ^10.8.2 - version: 10.8.2(@swc/core@1.3.42)(@types/node@20.12.6)(typescript@5.4.4) + version: 10.8.2(@swc/core@1.3.42)(@types/node@18.19.24)(typescript@5.4.4) ws: specifier: ^8.12.0 version: 8.12.0 @@ -828,13 +1104,36 @@ importers: devDependencies: jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) + + packages/rollup-plugin-bundle-manifest: + dependencies: + mime: + specifier: ^3.0.0 + version: 3.0.0 + devDependencies: + rollup: + specifier: ^4.10.0 + version: 4.14.1 + typescript: + specifier: ^5.3.3 + version: 5.4.4 packages/syntax: devDependencies: jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) + + packages/transformer: + dependencies: + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 packages/xrpc: dependencies: @@ -905,7 +1204,7 @@ importers: version: 6.1.2 jest: specifier: ^28.1.2 - version: 28.1.2(@types/node@18.19.24) + version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) jose: specifier: ^4.15.4 version: 4.15.4 @@ -971,6 +1270,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /@alloc/quick-lru@5.2.0: + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + dev: true + /@ampproject/remapping@2.2.1: resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} engines: {node: '>=6.0.0'} @@ -985,7 +1289,7 @@ packages: '@ipld/dag-cbor': 7.0.3 multiformats: 9.9.0 pino: 8.15.0 - zod: 3.21.4 + zod: 3.22.4 /@atproto/common@0.1.1: resolution: {integrity: sha512-GYwot5wF/z8iYGSPjrLHuratLc0CVgovmwfJss7+BUOB6y2/Vw8+1Vw0n9DDI0gb5vmx3UI8z0uJgC8aa8yuJg==} @@ -993,7 +1297,7 @@ packages: '@ipld/dag-cbor': 7.0.3 multiformats: 9.9.0 pino: 8.15.0 - zod: 3.21.4 + zod: 3.22.4 /@atproto/crypto@0.1.0: resolution: {integrity: sha512-9xgFEPtsCiJEPt9o3HtJT30IdFTGw5cQRSJVIy5CFhqBA4vDLcdXiRDLCjkzHEVbtNCsHUW6CrlfOgbeLPcmcg==} @@ -1049,7 +1353,7 @@ packages: twilio: 4.21.0 typed-emitter: 2.1.0 uint8arrays: 3.0.0 - zod: 3.21.4 + zod: 3.22.4 transitivePeerDependencies: - debug - pg-native @@ -4224,7 +4528,7 @@ packages: axios: 1.6.7 multiformats: 9.9.0 uint8arrays: 3.0.0 - zod: 3.21.4 + zod: 3.22.4 transitivePeerDependencies: - debug @@ -4297,6 +4601,27 @@ packages: resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} dev: true + /@hapi/accept@6.0.3: + resolution: {integrity: sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==} + dependencies: + '@hapi/boom': 10.0.1 + '@hapi/hoek': 11.0.4 + dev: false + + /@hapi/boom@10.0.1: + resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} + dependencies: + '@hapi/hoek': 11.0.4 + dev: false + + /@hapi/bourne@3.0.0: + resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} + dev: false + + /@hapi/hoek@11.0.4: + resolution: {integrity: sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==} + dev: false + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -4344,7 +4669,6 @@ packages: strip-ansi-cjs: /strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: /wrap-ansi@7.0.0 - dev: false /@istanbuljs/load-nyc-config@1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} @@ -4374,49 +4698,6 @@ packages: slash: 3.0.0 dev: true - /@jest/core@28.1.3: - resolution: {integrity: sha512-CIKBrlaKOzA7YG19BEqCw3SLIsEwjZkeJzf5bdooVnW4bH5cktqe3JX+G2YV1aK5vP8N9na1IGWFzYaTp6k6NA==} - engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/console': 28.1.3 - '@jest/reporters': 28.1.3 - '@jest/test-result': 28.1.3 - '@jest/transform': 28.1.3 - '@jest/types': 28.1.3 - '@types/node': 18.19.24 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.8.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 28.1.3 - jest-config: 28.1.3(@types/node@18.19.24) - jest-haste-map: 28.1.3 - jest-message-util: 28.1.3 - jest-regex-util: 28.0.2 - jest-resolve: 28.1.3 - jest-resolve-dependencies: 28.1.3 - jest-runner: 28.1.3 - jest-runtime: 28.1.3 - jest-snapshot: 28.1.3 - jest-util: 28.1.3 - jest-validate: 28.1.3 - jest-watcher: 28.1.3 - micromatch: 4.0.5 - pretty-format: 28.1.3 - rimraf: 3.0.2 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - supports-color - - ts-node - dev: true - /@jest/core@28.1.3(ts-node@10.8.2): resolution: {integrity: sha512-CIKBrlaKOzA7YG19BEqCw3SLIsEwjZkeJzf5bdooVnW4bH5cktqe3JX+G2YV1aK5vP8N9na1IGWFzYaTp6k6NA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -4646,6 +4927,15 @@ packages: '@jridgewell/trace-mapping': 0.3.19 dev: true + /@jridgewell/gen-mapping@0.3.5: + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + /@jridgewell/resolve-uri@3.1.1: resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} engines: {node: '>=6.0.0'} @@ -4656,6 +4946,18 @@ packages: engines: {node: '>=6.0.0'} dev: true + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/source-map@0.3.6: + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + /@jridgewell/sourcemap-codec@1.4.15: resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} dev: true @@ -4667,6 +4969,13 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + /@jridgewell/trace-mapping@0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: @@ -4804,7 +5113,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} requiresBuild: true - dev: false optional: true /@pkgr/core@0.1.1: @@ -4855,111 +5163,331 @@ packages: resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} dev: false - /@sinclair/typebox@0.24.51: - resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} + /@rollup/plugin-commonjs@25.0.7(rollup@4.14.1): + resolution: {integrity: sha512-nEvcR+LRjEjsaSsc4x3XZfCCvZIaSMenZu/OiwOKGN2UhQpAYI7ru7czFvyWbErlpoGjnSX3D5Ch5FcMA3kRWQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.14.1) + commondir: 1.0.1 + estree-walker: 2.0.2 + glob: 8.1.0 + is-reference: 1.2.1 + magic-string: 0.30.9 + rollup: 4.14.1 dev: true - /@sinonjs/commons@1.8.6: - resolution: {integrity: sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==} + /@rollup/plugin-node-resolve@15.2.3(rollup@4.14.1): + resolution: {integrity: sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true dependencies: - type-detect: 4.0.8 + '@rollup/pluginutils': 5.1.0(rollup@4.14.1) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-builtin-module: 3.2.1 + is-module: 1.0.0 + resolve: 1.22.4 + rollup: 4.14.1 dev: true - /@sinonjs/fake-timers@9.1.2: - resolution: {integrity: sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==} + /@rollup/plugin-replace@5.0.5(rollup@4.14.1): + resolution: {integrity: sha512-rYO4fOi8lMaTg/z5Jb+hKnrHHVn8j2lwkqwyS4kTRhKyWOLf2wST2sWXr4WzWiTcoHTp2sTjqUbqIj2E39slKQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true dependencies: - '@sinonjs/commons': 1.8.6 + '@rollup/pluginutils': 5.1.0(rollup@4.14.1) + magic-string: 0.30.9 + rollup: 4.14.1 dev: true - /@smithy/abort-controller@1.1.0: - resolution: {integrity: sha512-5imgGUlZL4dW4YWdMYAKLmal9ny/tlenM81QZY7xYyb76z9Z/QOg7oM5Ak9HQl8QfFTlGVWwcMXl+54jroRgEQ==} + /@rollup/plugin-terser@0.4.4(rollup@4.14.1): + resolution: {integrity: sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==} engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true dependencies: - '@smithy/types': 1.2.0 - tslib: 2.6.2 - dev: false + rollup: 4.14.1 + serialize-javascript: 6.0.2 + smob: 1.5.0 + terser: 5.30.3 + dev: true - /@smithy/types@1.2.0: - resolution: {integrity: sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA==} + /@rollup/plugin-typescript@11.1.6(rollup@4.14.1)(tslib@2.6.2)(typescript@5.4.4): + resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==} engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.14.1) + resolve: 1.22.4 + rollup: 4.14.1 tslib: 2.6.2 - dev: false + typescript: 5.4.4 + dev: true - /@swc/core-darwin-arm64@1.3.42: - resolution: {integrity: sha512-hM6RrZFyoCM9mX3cj/zM5oXwhAqjUdOCLXJx7KTQps7NIkv/Qjvobgvyf2gAb89j3ARNo9NdIoLjTjJ6oALtiA==} - engines: {node: '>=10'} + /@rollup/pluginutils@5.1.0(rollup@4.14.1): + resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.5 + estree-walker: 2.0.2 + picomatch: 2.3.1 + rollup: 4.14.1 + dev: true + + /@rollup/rollup-android-arm-eabi@4.14.1: + resolution: {integrity: sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-android-arm64@4.14.1: + resolution: {integrity: sha512-Y/9OHLjzkunF+KGEoJr3heiD5X9OLa8sbT1lm0NYeKyaM3oMhhQFvPB0bNZYJwlq93j8Z6wSxh9+cyKQaxS7PQ==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-arm64@4.14.1: + resolution: {integrity: sha512-+kecg3FY84WadgcuSVm6llrABOdQAEbNdnpi5X3UwWiFVhZIZvKgGrF7kmLguvxHNQy+UuRV66cLVl3S+Rkt+Q==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /@swc/core-darwin-x64@1.3.42: - resolution: {integrity: sha512-bjsWtHMb6wJK1+RGlBs2USvgZ0txlMk11y0qBLKo32gLKTqzUwRw0Fmfzuf6Ue2a/w//7eqMlPFEre4LvJajGw==} - engines: {node: '>=10'} + /@rollup/rollup-darwin-x64@4.14.1: + resolution: {integrity: sha512-2pYRzEjVqq2TB/UNv47BV/8vQiXkFGVmPFwJb+1E0IFFZbIX8/jo1olxqqMbo6xCXf8kabANhp5bzCij2tFLUA==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /@swc/core-linux-arm-gnueabihf@1.3.42: - resolution: {integrity: sha512-Oe0ggMz3MyqXNfeVmY+bBTL0hFSNY3bx8dhcqsh4vXk/ZVGse94QoC4dd92LuPHmKT0x6nsUzB86x2jU9QHW5g==} - engines: {node: '>=10'} + /@rollup/rollup-linux-arm-gnueabihf@4.14.1: + resolution: {integrity: sha512-mS6wQ6Do6/wmrF9aTFVpIJ3/IDXhg1EZcQFYHZLHqw6AzMBjTHWnCG35HxSqUNphh0EHqSM6wRTT8HsL1C0x5g==} cpu: [arm] os: [linux] requiresBuild: true dev: true optional: true - /@swc/core-linux-arm64-gnu@1.3.42: - resolution: {integrity: sha512-ZJsa8NIW1RLmmHGTJCbM7OPSbBZ9rOMrLqDtUOGrT0uoJXZnnQqolflamB5wviW0X6h3Z3/PSTNGNDCJ3u3Lqg==} - engines: {node: '>=10'} + /@rollup/rollup-linux-arm64-gnu@4.14.1: + resolution: {integrity: sha512-p9rGKYkHdFMzhckOTFubfxgyIO1vw//7IIjBBRVzyZebWlzRLeNhqxuSaZ7kCEKVkm/kuC9fVRW9HkC/zNRG2w==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@swc/core-linux-arm64-musl@1.3.42: - resolution: {integrity: sha512-YpZwlFAfOp5vkm/uVUJX1O7N3yJDO1fDQRWqsOPPNyIJkI2ydlRQtgN6ZylC159Qv+TimfXnGTlNr7o3iBAqjg==} - engines: {node: '>=10'} + /@rollup/rollup-linux-arm64-musl@4.14.1: + resolution: {integrity: sha512-nDY6Yz5xS/Y4M2i9JLQd3Rofh5OR8Bn8qe3Mv/qCVpHFlwtZSBYSPaU4mrGazWkXrdQ98GB//H0BirGR/SKFSw==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /@swc/core-linux-x64-gnu@1.3.42: - resolution: {integrity: sha512-0ccpKnsZbyHBzaQFdP8U9i29nvOfKitm6oJfdJzlqsY/jCqwvD8kv2CAKSK8WhJz//ExI2LqNrDI0yazx5j7+A==} - engines: {node: '>=10'} - cpu: [x64] + /@rollup/rollup-linux-powerpc64le-gnu@4.14.1: + resolution: {integrity: sha512-im7HE4VBL+aDswvcmfx88Mp1soqL9OBsdDBU8NqDEYtkri0qV0THhQsvZtZeNNlLeCUQ16PZyv7cqutjDF35qw==} + cpu: [ppc64le] os: [linux] requiresBuild: true dev: true optional: true - /@swc/core-linux-x64-musl@1.3.42: - resolution: {integrity: sha512-7eckRRuTZ6+3K21uyfXXgc2ZCg0mSWRRNwNT3wap2bYkKPeqTgb8pm8xYSZNEiMuDonHEat6XCCV36lFY6kOdQ==} - engines: {node: '>=10'} - cpu: [x64] + /@rollup/rollup-linux-riscv64-gnu@4.14.1: + resolution: {integrity: sha512-RWdiHuAxWmzPJgaHJdpvUUlDz8sdQz4P2uv367T2JocdDa98iRw2UjIJ4QxSyt077mXZT2X6pKfT2iYtVEvOFw==} + cpu: [riscv64] os: [linux] requiresBuild: true dev: true optional: true - /@swc/core-win32-arm64-msvc@1.3.42: - resolution: {integrity: sha512-t27dJkdw0GWANdN4TV0lY/V5vTYSx5SRjyzzZolep358ueCGuN1XFf1R0JcCbd1ojosnkQg2L7A7991UjXingg==} - engines: {node: '>=10'} - cpu: [arm64] - os: [win32] + /@rollup/rollup-linux-s390x-gnu@4.14.1: + resolution: {integrity: sha512-VMgaGQ5zRX6ZqV/fas65/sUGc9cPmsntq2FiGmayW9KMNfWVG/j0BAqImvU4KTeOOgYSf1F+k6at1UfNONuNjA==} + cpu: [s390x] + os: [linux] requiresBuild: true dev: true optional: true - /@swc/core-win32-ia32-msvc@1.3.42: - resolution: {integrity: sha512-xfpc/Zt/aMILX4IX0e3loZaFyrae37u3MJCv1gJxgqrpeLi7efIQr3AmERkTK3mxTO6R5urSliWw2W3FyZ7D3Q==} + /@rollup/rollup-linux-x64-gnu@4.14.1: + resolution: {integrity: sha512-9Q7DGjZN+hTdJomaQ3Iub4m6VPu1r94bmK2z3UeWP3dGUecRC54tmVu9vKHTm1bOt3ASoYtEz6JSRLFzrysKlA==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-musl@4.14.1: + resolution: {integrity: sha512-JNEG/Ti55413SsreTguSx0LOVKX902OfXIKVg+TCXO6Gjans/k9O6ww9q3oLGjNDaTLxM+IHFMeXy/0RXL5R/g==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-arm64-msvc@4.14.1: + resolution: {integrity: sha512-ryS22I9y0mumlLNwDFYZRDFLwWh3aKaC72CWjFcFvxK0U6v/mOkM5Up1bTbCRAhv3kEIwW2ajROegCIQViUCeA==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-ia32-msvc@4.14.1: + resolution: {integrity: sha512-TdloItiGk+T0mTxKx7Hp279xy30LspMso+GzQvV2maYePMAWdmrzqSNZhUpPj3CGw12aGj57I026PgLCTu8CGg==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-x64-msvc@4.14.1: + resolution: {integrity: sha512-wQGI+LY/Py20zdUPq+XCem7JcPOyzIJBm3dli+56DJsQOHbnXZFEwgmnC6el1TPAfC8lBT3m+z69RmLykNUbew==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@sinclair/typebox@0.24.51: + resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} + dev: true + + /@sinonjs/commons@1.8.6: + resolution: {integrity: sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers@9.1.2: + resolution: {integrity: sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==} + dependencies: + '@sinonjs/commons': 1.8.6 + dev: true + + /@smithy/abort-controller@1.1.0: + resolution: {integrity: sha512-5imgGUlZL4dW4YWdMYAKLmal9ny/tlenM81QZY7xYyb76z9Z/QOg7oM5Ak9HQl8QfFTlGVWwcMXl+54jroRgEQ==} + engines: {node: '>=14.0.0'} + dependencies: + '@smithy/types': 1.2.0 + tslib: 2.6.2 + dev: false + + /@smithy/types@1.2.0: + resolution: {integrity: sha512-z1r00TvBqF3dh4aHhya7nz1HhvCg4TRmw51fjMrh5do3h+ngSstt/yKlNbHeb9QxJmFbmN8KEVSWgb1bRvfEoA==} + engines: {node: '>=14.0.0'} + dependencies: + tslib: 2.6.2 + dev: false + + /@swc/core-darwin-arm64@1.3.42: + resolution: {integrity: sha512-hM6RrZFyoCM9mX3cj/zM5oXwhAqjUdOCLXJx7KTQps7NIkv/Qjvobgvyf2gAb89j3ARNo9NdIoLjTjJ6oALtiA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@swc/core-darwin-x64@1.3.42: + resolution: {integrity: sha512-bjsWtHMb6wJK1+RGlBs2USvgZ0txlMk11y0qBLKo32gLKTqzUwRw0Fmfzuf6Ue2a/w//7eqMlPFEre4LvJajGw==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm-gnueabihf@1.3.42: + resolution: {integrity: sha512-Oe0ggMz3MyqXNfeVmY+bBTL0hFSNY3bx8dhcqsh4vXk/ZVGse94QoC4dd92LuPHmKT0x6nsUzB86x2jU9QHW5g==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm64-gnu@1.3.42: + resolution: {integrity: sha512-ZJsa8NIW1RLmmHGTJCbM7OPSbBZ9rOMrLqDtUOGrT0uoJXZnnQqolflamB5wviW0X6h3Z3/PSTNGNDCJ3u3Lqg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm64-musl@1.3.42: + resolution: {integrity: sha512-YpZwlFAfOp5vkm/uVUJX1O7N3yJDO1fDQRWqsOPPNyIJkI2ydlRQtgN6ZylC159Qv+TimfXnGTlNr7o3iBAqjg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-x64-gnu@1.3.42: + resolution: {integrity: sha512-0ccpKnsZbyHBzaQFdP8U9i29nvOfKitm6oJfdJzlqsY/jCqwvD8kv2CAKSK8WhJz//ExI2LqNrDI0yazx5j7+A==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-x64-musl@1.3.42: + resolution: {integrity: sha512-7eckRRuTZ6+3K21uyfXXgc2ZCg0mSWRRNwNT3wap2bYkKPeqTgb8pm8xYSZNEiMuDonHEat6XCCV36lFY6kOdQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-arm64-msvc@1.3.42: + resolution: {integrity: sha512-t27dJkdw0GWANdN4TV0lY/V5vTYSx5SRjyzzZolep358ueCGuN1XFf1R0JcCbd1ojosnkQg2L7A7991UjXingg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-ia32-msvc@1.3.42: + resolution: {integrity: sha512-xfpc/Zt/aMILX4IX0e3loZaFyrae37u3MJCv1gJxgqrpeLi7efIQr3AmERkTK3mxTO6R5urSliWw2W3FyZ7D3Q==} engines: {node: '>=10'} cpu: [ia32] os: [win32] @@ -5012,6 +5540,11 @@ packages: engines: {node: '>= 10'} dev: true + /@trysound/sax@0.2.0: + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + dev: true + /@ts-morph/common@0.17.0: resolution: {integrity: sha512-RMSSvSfs9kb0VzkvQ2NWobwnj7TxCA9vI/IjR9bDHqgAyVbu2T0DN4wiKVqomyDWqO7dPr/tErSfq7urQ1Q37g==} dependencies: @@ -5082,6 +5615,10 @@ packages: dependencies: '@types/node': 18.19.24 + /@types/cookie@0.6.0: + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + dev: true + /@types/cors@2.8.12: resolution: {integrity: sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==} dev: true @@ -5095,6 +5632,10 @@ packages: dependencies: '@types/bn.js': 5.1.1 + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + dev: true + /@types/express-serve-static-core@4.17.36: resolution: {integrity: sha512-zbivROJ0ZqLAtMzgzIUC4oNqDG9iF0lSsAqpOD9kbs5xcIM3dTiyuHvBc7R8MtWBp3AAWGaovJa+wzWPjLYW7Q==} dependencies: @@ -5129,6 +5670,10 @@ packages: /@types/http-errors@2.0.1: resolution: {integrity: sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==} + dev: true + + /@types/http-errors@2.0.4: + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} /@types/is-ci@3.0.0: resolution: {integrity: sha512-Q0Op0hdWbYd1iahB+IFNQcWXFq4O0Q5MwQP7uN0souuQ4rPg1vEYcnIOfr1gY+M+6rc8FGoRaBO1mOOvL29sEQ==} @@ -5163,6 +5708,10 @@ packages: resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} dev: true + /@types/keygrip@1.0.6: + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + dev: true + /@types/mime@1.3.2: resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==} @@ -5185,12 +5734,6 @@ packages: dependencies: undici-types: 5.26.5 - /@types/node@20.12.6: - resolution: {integrity: sha512-3KurE8taB8GCvZBPngVbp0lk5CKi8M9f9k1rsADh0Evdz5SzJ+Q+Hx9uHoFGsLnLnd1xmkDQr2hVhlA0Mn0lKQ==} - dependencies: - undici-types: 5.26.5 - dev: true - /@types/nodemailer@6.4.6: resolution: {integrity: sha512-pD6fL5GQtUKvD2WnPmg5bC2e8kWCAPDwMPmHe/ohQbW+Dy0EcHgZ2oCSuPlWNqk74LS5BVMig1SymQbFMPPK3w==} dependencies: @@ -5213,12 +5756,37 @@ packages: resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} dev: true + /@types/prop-types@15.7.12: + resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + dev: true + + /@types/psl@1.1.3: + resolution: {integrity: sha512-Iu174JHfLd7i/XkXY6VDrqSlPvTDQOtQI7wNAXKKOAADJ9TduRLkNdMgjGiMxSttUIZnomv81JAbAbC0DhggxA==} + dev: true + /@types/qs@6.9.7: resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} /@types/range-parser@1.2.4: resolution: {integrity: sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==} + /@types/react-dom@18.2.24: + resolution: {integrity: sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==} + dependencies: + '@types/react': 18.2.75 + dev: true + + /@types/react@18.2.75: + resolution: {integrity: sha512-+DNnF7yc5y0bHkBTiLKqXFe+L4B3nvOphiMY3tuA5X10esmjqk7smyBZzbGTy2vsiy/Bnzj8yFIBL8xhRacoOg==} + dependencies: + '@types/prop-types': 15.7.12 + csstype: 3.1.3 + dev: true + + /@types/resolve@1.20.2: + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + dev: true + /@types/semver@7.5.0: resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} dev: true @@ -5229,10 +5797,17 @@ packages: '@types/mime': 1.3.2 '@types/node': 18.19.24 + /@types/send@0.17.4: + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + dependencies: + '@types/mime': 1.3.2 + '@types/node': 18.19.24 + dev: true + /@types/serve-static@1.15.2: resolution: {integrity: sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==} dependencies: - '@types/http-errors': 2.0.1 + '@types/http-errors': 2.0.4 '@types/mime': 3.0.1 '@types/node': 18.19.24 @@ -5523,7 +6098,6 @@ packages: /ansi-regex@6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - dev: false /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} @@ -5546,7 +6120,10 @@ packages: /ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - dev: false + + /any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + dev: true /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} @@ -5572,6 +6149,10 @@ packages: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} dev: true + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + dev: true + /argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} dependencies: @@ -5639,6 +6220,22 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} + /autoprefixer@10.4.19(postcss@8.4.38): + resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + dependencies: + browserslist: 4.23.0 + caniuse-lite: 1.0.30001607 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -5786,6 +6383,11 @@ packages: resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} engines: {node: '>=0.6'} + /binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + dev: true + /bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} dependencies: @@ -5820,6 +6422,10 @@ packages: transitivePeerDependencies: - supports-color + /boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + dev: true + /boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} @@ -5865,6 +6471,17 @@ packages: update-browserslist-db: 1.0.11(browserslist@4.21.10) dev: true + /browserslist@4.23.0: + resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001607 + electron-to-chromium: 1.4.730 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.23.0) + dev: true + /bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} dependencies: @@ -5902,6 +6519,11 @@ packages: base64-js: 1.5.1 ieee754: 1.2.1 + /builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + dev: true + /bytes@3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -5947,6 +6569,11 @@ packages: engines: {node: '>=6'} dev: true + /camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + dev: true + /camelcase-keys@6.2.2: resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} engines: {node: '>=8'} @@ -5966,10 +6593,23 @@ packages: engines: {node: '>=10'} dev: true + /caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + dependencies: + browserslist: 4.21.10 + caniuse-lite: 1.0.30001522 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + dev: true + /caniuse-lite@1.0.30001522: resolution: {integrity: sha512-TKiyTVZxJGhsTszLuzb+6vUZSjVOAhClszBr2Ta2k9IwtNBT/4dzmL6aywt0HCgEZlmwJzXJd8yNiob6HgwTRg==} dev: true + /caniuse-lite@1.0.30001607: + resolution: {integrity: sha512-WcvhVRjXLKFB/kmOFVwELtMxyhq3iM/MvmXcyCe2PNf166c39mptscOc/45TTS96n2gpNV2z7+NakArTWZCQ3w==} + dev: true + /cbor-extract@2.2.0: resolution: {integrity: sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==} hasBin: true @@ -6021,6 +6661,21 @@ packages: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} dev: true + /chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -6118,6 +6773,10 @@ packages: color-convert: 2.0.1 color-string: 1.9.1 + /colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + dev: true + /colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} dev: true @@ -6128,11 +6787,29 @@ packages: dependencies: delayed-stream: 1.0.0 + /commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + dev: true + + /commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + dev: true + + /commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + dev: true + /commander@9.4.0: resolution: {integrity: sha512-sRPT+umqkz90UA8M1yqYfnHlZA7fF6nSphDtxeywPZ49ysjxDQybzk13CL+mXekDRG92skbcqCLVovuCusNmFw==} engines: {node: ^12.20.0 || >=14} dev: false + /commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + dev: true + /compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -6157,6 +6834,12 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /concat-with-sourcemaps@1.1.0: + resolution: {integrity: sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==} + dependencies: + source-map: 0.6.1 + dev: true + /console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} dev: true @@ -6182,6 +6865,11 @@ packages: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} + /cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + dev: false + /cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -6213,29 +6901,137 @@ packages: resolution: {integrity: sha512-/RC5F4l1SCqD/jazwUF6+t34Cd8zTSAGZ7rvvZu1whZUhD2a5MOGKjSGowoGcpj/cbVZk1ZODIooJEQQq3nNAA==} dev: false - /csv-generate@3.4.3: - resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} + /css-declaration-sorter@6.4.1(postcss@8.4.38): + resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==} + engines: {node: ^10 || ^12 || >=14} + peerDependencies: + postcss: ^8.0.9 + dependencies: + postcss: 8.4.38 dev: true - /csv-parse@4.16.3: - resolution: {integrity: sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg==} + /css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 dev: true - /csv-stringify@5.6.5: - resolution: {integrity: sha512-PjiQ659aQ+fUTQqSrd1XEDnOr52jh30RBurfzkscaE2tPaFsDH5wOAHJiw8XAHphRknCwMUE9KRayc4K/NbO8A==} + /css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 dev: true - /csv@5.5.3: - resolution: {integrity: sha512-QTaY0XjjhTQOdguARF0lGKm5/mEq9PD9/VhZZegHDIBq2tQwgNpHc3dneD4mGo2iJs+fTKv5Bp0fZ+BRuY3Z0g==} - engines: {node: '>= 0.1.90'} - dependencies: - csv-generate: 3.4.3 - csv-parse: 4.16.3 - csv-stringify: 5.6.5 - stream-transform: 2.1.3 + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} dev: true - /dataloader@1.4.0: + /cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /cssnano-preset-default@5.2.14(postcss@8.4.38): + resolution: {integrity: sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + css-declaration-sorter: 6.4.1(postcss@8.4.38) + cssnano-utils: 3.1.0(postcss@8.4.38) + postcss: 8.4.38 + postcss-calc: 8.2.4(postcss@8.4.38) + postcss-colormin: 5.3.1(postcss@8.4.38) + postcss-convert-values: 5.1.3(postcss@8.4.38) + postcss-discard-comments: 5.1.2(postcss@8.4.38) + postcss-discard-duplicates: 5.1.0(postcss@8.4.38) + postcss-discard-empty: 5.1.1(postcss@8.4.38) + postcss-discard-overridden: 5.1.0(postcss@8.4.38) + postcss-merge-longhand: 5.1.7(postcss@8.4.38) + postcss-merge-rules: 5.1.4(postcss@8.4.38) + postcss-minify-font-values: 5.1.0(postcss@8.4.38) + postcss-minify-gradients: 5.1.1(postcss@8.4.38) + postcss-minify-params: 5.1.4(postcss@8.4.38) + postcss-minify-selectors: 5.2.1(postcss@8.4.38) + postcss-normalize-charset: 5.1.0(postcss@8.4.38) + postcss-normalize-display-values: 5.1.0(postcss@8.4.38) + postcss-normalize-positions: 5.1.1(postcss@8.4.38) + postcss-normalize-repeat-style: 5.1.1(postcss@8.4.38) + postcss-normalize-string: 5.1.0(postcss@8.4.38) + postcss-normalize-timing-functions: 5.1.0(postcss@8.4.38) + postcss-normalize-unicode: 5.1.1(postcss@8.4.38) + postcss-normalize-url: 5.1.0(postcss@8.4.38) + postcss-normalize-whitespace: 5.1.1(postcss@8.4.38) + postcss-ordered-values: 5.1.3(postcss@8.4.38) + postcss-reduce-initial: 5.1.2(postcss@8.4.38) + postcss-reduce-transforms: 5.1.0(postcss@8.4.38) + postcss-svgo: 5.1.0(postcss@8.4.38) + postcss-unique-selectors: 5.1.1(postcss@8.4.38) + dev: true + + /cssnano-utils@3.1.0(postcss@8.4.38): + resolution: {integrity: sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + dev: true + + /cssnano@5.1.15(postcss@8.4.38): + resolution: {integrity: sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + cssnano-preset-default: 5.2.14(postcss@8.4.38) + lilconfig: 2.1.0 + postcss: 8.4.38 + yaml: 1.10.2 + dev: true + + /csso@4.2.0: + resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} + engines: {node: '>=8.0.0'} + dependencies: + css-tree: 1.1.3 + dev: true + + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + dev: true + + /csv-generate@3.4.3: + resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} + dev: true + + /csv-parse@4.16.3: + resolution: {integrity: sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg==} + dev: true + + /csv-stringify@5.6.5: + resolution: {integrity: sha512-PjiQ659aQ+fUTQqSrd1XEDnOr52jh30RBurfzkscaE2tPaFsDH5wOAHJiw8XAHphRknCwMUE9KRayc4K/NbO8A==} + dev: true + + /csv@5.5.3: + resolution: {integrity: sha512-QTaY0XjjhTQOdguARF0lGKm5/mEq9PD9/VhZZegHDIBq2tQwgNpHc3dneD4mGo2iJs+fTKv5Bp0fZ+BRuY3Z0g==} + engines: {node: '>= 0.1.90'} + dependencies: + csv-generate: 3.4.3 + csv-parse: 4.16.3 + csv-stringify: 5.6.5 + stream-transform: 2.1.3 + dev: true + + /dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} dev: true @@ -6442,6 +7238,10 @@ packages: engines: {node: '>=4'} dev: false + /didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dev: true + /diff-sequences@28.1.1: resolution: {integrity: sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -6462,6 +7262,10 @@ packages: /disposable-email@0.2.3: resolution: {integrity: sha512-gkBQQ5Res431ZXqLlAafrXHizG7/1FWmi8U2RTtriD78Vc10HhBUvdJun3R4eSF0KRIQQJs+wHlxjkED/Hr1EQ==} + /dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dev: true + /doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -6503,7 +7307,6 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - dev: false /ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -6518,6 +7321,10 @@ packages: resolution: {integrity: sha512-0NmjlYBLKVHva4GABWAaHuPJolnDuL0AhV3h1hES6rcLCWEIbRL6/8TghfsVwkx6TEroQVdliX7+aLysUpKvjw==} dev: true + /electron-to-chromium@1.4.730: + resolution: {integrity: sha512-oJRPo82XEqtQAobHpJIR3zW5YO3sSRRkPz2an4yxi1UvqhsGm54vR/wzTFV74a3soDOJ8CKW7ajOOX5ESzddwg==} + dev: true + /elliptic@6.5.4: resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} dependencies: @@ -6539,7 +7346,6 @@ packages: /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - dev: false /encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} @@ -7014,6 +7820,14 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + dev: true + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -7331,7 +8145,6 @@ packages: dependencies: cross-spawn: 7.0.3 signal-exit: 4.1.0 - dev: false /form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} @@ -7345,6 +8158,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + /fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + dev: true + /fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -7420,6 +8237,12 @@ packages: wide-align: 1.1.5 dev: true + /generic-names@4.0.0: + resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==} + dependencies: + loader-utils: 3.2.1 + dev: true + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -7491,7 +8314,6 @@ packages: minimatch: 9.0.3 minipass: 5.0.0 path-scurry: 1.10.1 - dev: false /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -7759,6 +8581,19 @@ packages: dev: true optional: true + /icss-replace-symbols@1.1.0: + resolution: {integrity: sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==} + dev: true + + /icss-utils@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.38 + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -7766,6 +8601,13 @@ packages: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} + /import-cwd@3.0.0: + resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==} + engines: {node: '>=8'} + dependencies: + import-from: 3.0.0 + dev: true + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -7774,6 +8616,13 @@ packages: resolve-from: 4.0.0 dev: true + /import-from@3.0.0: + resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} + engines: {node: '>=8'} + dependencies: + resolve-from: 5.0.0 + dev: true + /import-in-the-middle@1.4.2: resolution: {integrity: sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==} dependencies: @@ -7882,6 +8731,13 @@ packages: has-bigints: 1.0.2 dev: true + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.3.0 + dev: true + /is-boolean-object@1.1.2: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} engines: {node: '>= 0.4'} @@ -7890,6 +8746,13 @@ packages: has-tostringtag: 1.0.0 dev: true + /is-builtin-module@3.2.1: + resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} + engines: {node: '>=6'} + dependencies: + builtin-modules: 3.3.0 + dev: true + /is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} @@ -7937,6 +8800,10 @@ packages: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} dev: true + /is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + dev: true + /is-negative-zero@2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} @@ -7963,6 +8830,12 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + dependencies: + '@types/estree': 1.0.5 + dev: true + /is-regex@1.1.4: resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} engines: {node: '>= 0.4'} @@ -8088,7 +8961,6 @@ packages: '@isaacs/cliui': 8.0.2 optionalDependencies: '@pkgjs/parseargs': 0.11.0 - dev: false /jest-changed-files@28.1.3: resolution: {integrity: sha512-esaOfUWJXk2nfZt9SPyC8gA1kNfdKLkQWyzsMlqq8msYSlNKfmZxfRgZn4Cd4MGVUF+7v6dBs0d5TOAKa7iIiA==} @@ -8125,35 +8997,7 @@ packages: - supports-color dev: true - /jest-cli@28.1.3(@types/node@18.19.24): - resolution: {integrity: sha512-roY3kvrv57Azn1yPgdTebPAXvdR2xfezaKKYzVxZ6It/5NCxzJym6tUI5P1zkdWhfUYkxEI9uZWcQdaFLo8mJQ==} - engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 28.1.3 - '@jest/test-result': 28.1.3 - '@jest/types': 28.1.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - import-local: 3.1.0 - jest-config: 28.1.3(@types/node@18.19.24) - jest-util: 28.1.3 - jest-validate: 28.1.3 - prompts: 2.4.2 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - supports-color - - ts-node - dev: true - - /jest-cli@28.1.3(@types/node@20.12.6)(ts-node@10.8.2): + /jest-cli@28.1.3(@types/node@18.19.24)(ts-node@10.8.2): resolution: {integrity: sha512-roY3kvrv57Azn1yPgdTebPAXvdR2xfezaKKYzVxZ6It/5NCxzJym6tUI5P1zkdWhfUYkxEI9uZWcQdaFLo8mJQ==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} hasBin: true @@ -8170,7 +9014,7 @@ packages: exit: 0.1.2 graceful-fs: 4.2.11 import-local: 3.1.0 - jest-config: 28.1.3(@types/node@20.12.6)(ts-node@10.8.2) + jest-config: 28.1.3(@types/node@18.19.24)(ts-node@10.8.2) jest-util: 28.1.3 jest-validate: 28.1.3 prompts: 2.4.2 @@ -8181,45 +9025,6 @@ packages: - ts-node dev: true - /jest-config@28.1.3(@types/node@18.19.24): - resolution: {integrity: sha512-MG3INjByJ0J4AsNBm7T3hsuxKQqFIiRo/AUqb1q9LRKI5UU6Aar9JHbr9Ivn1TVwfUD9KirRoM/T6u8XlcQPHQ==} - engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - dependencies: - '@babel/core': 7.18.6 - '@jest/test-sequencer': 28.1.3 - '@jest/types': 28.1.3 - '@types/node': 18.19.24 - babel-jest: 28.1.3(@babel/core@7.18.6) - chalk: 4.1.2 - ci-info: 3.8.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 28.1.3 - jest-environment-node: 28.1.3 - jest-get-type: 28.0.2 - jest-regex-util: 28.0.2 - jest-resolve: 28.1.3 - jest-runner: 28.1.3 - jest-util: 28.1.3 - jest-validate: 28.1.3 - micromatch: 4.0.5 - parse-json: 5.2.0 - pretty-format: 28.1.3 - slash: 3.0.0 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - dev: true - /jest-config@28.1.3(@types/node@18.19.24)(ts-node@10.8.2): resolution: {integrity: sha512-MG3INjByJ0J4AsNBm7T3hsuxKQqFIiRo/AUqb1q9LRKI5UU6Aar9JHbr9Ivn1TVwfUD9KirRoM/T6u8XlcQPHQ==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -8255,47 +9060,7 @@ packages: pretty-format: 28.1.3 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.8.2(@swc/core@1.3.42)(@types/node@20.12.6)(typescript@5.4.4) - transitivePeerDependencies: - - supports-color - dev: true - - /jest-config@28.1.3(@types/node@20.12.6)(ts-node@10.8.2): - resolution: {integrity: sha512-MG3INjByJ0J4AsNBm7T3hsuxKQqFIiRo/AUqb1q9LRKI5UU6Aar9JHbr9Ivn1TVwfUD9KirRoM/T6u8XlcQPHQ==} - engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} - peerDependencies: - '@types/node': '*' - ts-node: '>=9.0.0' - peerDependenciesMeta: - '@types/node': - optional: true - ts-node: - optional: true - dependencies: - '@babel/core': 7.18.6 - '@jest/test-sequencer': 28.1.3 - '@jest/types': 28.1.3 - '@types/node': 20.12.6 - babel-jest: 28.1.3(@babel/core@7.18.6) - chalk: 4.1.2 - ci-info: 3.8.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 28.1.3 - jest-environment-node: 28.1.3 - jest-get-type: 28.0.2 - jest-regex-util: 28.0.2 - jest-resolve: 28.1.3 - jest-runner: 28.1.3 - jest-util: 28.1.3 - jest-validate: 28.1.3 - micromatch: 4.0.5 - parse-json: 5.2.0 - pretty-format: 28.1.3 - slash: 3.0.0 - strip-json-comments: 3.1.1 - ts-node: 10.8.2(@swc/core@1.3.42)(@types/node@20.12.6)(typescript@5.4.4) + ts-node: 10.8.2(@swc/core@1.3.42)(@types/node@18.19.24)(typescript@5.4.4) transitivePeerDependencies: - supports-color dev: true @@ -8591,7 +9356,7 @@ packages: supports-color: 8.1.1 dev: true - /jest@28.1.2(@types/node@18.19.24): + /jest@28.1.2(@types/node@18.19.24)(ts-node@10.8.2): resolution: {integrity: sha512-Tuf05DwLeCh2cfWCQbcz9UxldoDyiR1E9Igaei5khjonKncYdc6LDfynKCEWozK0oLE3GD+xKAo2u8x/0s6GOg==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} hasBin: true @@ -8601,34 +9366,19 @@ packages: node-notifier: optional: true dependencies: - '@jest/core': 28.1.3 + '@jest/core': 28.1.3(ts-node@10.8.2) '@jest/types': 28.1.3 import-local: 3.1.0 - jest-cli: 28.1.3(@types/node@18.19.24) + jest-cli: 28.1.3(@types/node@18.19.24)(ts-node@10.8.2) transitivePeerDependencies: - '@types/node' - supports-color - ts-node dev: true - /jest@28.1.2(@types/node@20.12.6)(ts-node@10.8.2): - resolution: {integrity: sha512-Tuf05DwLeCh2cfWCQbcz9UxldoDyiR1E9Igaei5khjonKncYdc6LDfynKCEWozK0oLE3GD+xKAo2u8x/0s6GOg==} - engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + /jiti@1.21.0: + resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 28.1.3(ts-node@10.8.2) - '@jest/types': 28.1.3 - import-local: 3.1.0 - jest-cli: 28.1.3(@types/node@20.12.6)(ts-node@10.8.2) - transitivePeerDependencies: - - '@types/node' - - supports-color - - ts-node dev: true /jose@4.15.4: @@ -8639,6 +9389,10 @@ packages: resolution: {integrity: sha512-GPExOkcMsCLBTi1YetY2LmkoY559fss0+0KVa6kOfb2YFe84nAM7Nm/XzuZozah4iHgmBGrCOHL5/cy670SBRw==} dev: false + /jose@5.2.4: + resolution: {integrity: sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==} + dev: false + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -8739,6 +9493,15 @@ packages: bn.js: 4.12.0 elliptic: 6.5.4 + /keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + requiresBuild: true + dependencies: + tsscmp: 1.0.6 + dev: false + optional: true + /kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -8780,6 +9543,16 @@ packages: type-check: 0.4.0 dev: true + /lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + dev: true + + /lilconfig@3.1.1: + resolution: {integrity: sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==} + engines: {node: '>=14'} + dev: true + /limiter@1.1.5: resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} dev: false @@ -8798,6 +9571,11 @@ packages: strip-bom: 3.0.0 dev: true + /loader-utils@3.2.1: + resolution: {integrity: sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==} + engines: {node: '>= 12.13.0'} + dev: true + /locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -8812,6 +9590,10 @@ packages: p-locate: 5.0.0 dev: true + /lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + dev: true + /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -8846,6 +9628,10 @@ packages: resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} dev: false + /lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + dev: true + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -8868,16 +9654,21 @@ packages: /lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - dev: false /long@5.2.3: resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} dev: false + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: true + /lru-cache@10.2.0: resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} engines: {node: 14 || >=16.14} - dev: false /lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} @@ -8902,6 +9693,13 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + /magic-string@0.30.9: + resolution: {integrity: sha512-S1+hd+dIrC8EZqKyT9DstTH/0Z+f76kmmvZnkfQVmOpDEF9iVgdYif3Q/pIWHmCoo59bQVGW0kVL3e2nl+9+Sw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + /make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -8954,6 +9752,10 @@ packages: engines: {node: '>=8'} dev: true + /mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + dev: true + /media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -9012,6 +9814,12 @@ packages: engines: {node: '>=4'} hasBin: true + /mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + dev: false + /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -9159,6 +9967,20 @@ packages: /multiformats@9.9.0: resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + /mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + dev: true + + /nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + /napi-build-utils@1.0.2: resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} @@ -9252,6 +10074,10 @@ packages: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} dev: true + /node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + dev: true + /nodemailer-html-to-text@3.2.0: resolution: {integrity: sha512-RJUC6640QV1PzTHHapOrc6IzrAJUZtk2BdVdINZ9VTLm+mcQNyBO9LYyhrnufkzqiD9l8hPLJ97rSyK4WanPNg==} engines: {node: '>= 10.23.0'} @@ -9284,6 +10110,16 @@ packages: engines: {node: '>=0.10.0'} dev: true + /normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + dev: true + + /normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + dev: true + /npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -9301,10 +10137,21 @@ packages: set-blocking: 2.0.0 dev: true + /nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + dependencies: + boolbase: 1.0.0 + dev: true + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + dev: true + /object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} @@ -9322,6 +10169,11 @@ packages: object-keys: 1.1.1 dev: true + /oidc-token-hash@5.0.3: + resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} + engines: {node: ^10.13.0 || >=12.0.0} + dev: false + /on-exit-leak-free@2.1.0: resolution: {integrity: sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==} @@ -9515,7 +10367,6 @@ packages: dependencies: lru-cache: 10.2.0 minipass: 5.0.0 - dev: false /path-to-regexp@0.1.7: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} @@ -9556,119 +10407,549 @@ packages: postgres-date: 1.0.7 postgres-interval: 1.2.0 - /pg@8.10.0: - resolution: {integrity: sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ==} - engines: {node: '>= 8.0.0'} + /pg@8.10.0: + resolution: {integrity: sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + buffer-writer: 2.0.0 + packet-reader: 1.0.0 + pg-connection-string: 2.6.2 + pg-pool: 3.6.1(pg@8.10.0) + pg-protocol: 1.6.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + + /pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + dependencies: + split2: 4.2.0 + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + /pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + dev: true + + /pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + dev: true + + /pify@5.0.0: + resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} + engines: {node: '>=10'} + + /pino-abstract-transport@1.0.0: + resolution: {integrity: sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==} + dependencies: + readable-stream: 4.4.2 + split2: 4.2.0 + + /pino-http@8.2.1: + resolution: {integrity: sha512-bdWAE4HYfFjDhKw2/N7BLNSIFAs+WDLZnetsGRpBdNEKq7/RoZUgblLS5OlMY257RPQml6J5QiiLkwxbstzWbA==} + dependencies: + fast-url-parser: 1.1.3 + get-caller-file: 2.0.5 + pino: 8.15.0 + pino-std-serializers: 6.2.2 + process-warning: 2.2.0 + dev: false + + /pino-http@8.4.0: + resolution: {integrity: sha512-9I1eRLxsujQJwLQTrHBU0wDlwnry2HzV2TlDwAsmZ9nT3Y2NQBLrz+DYp73L4i11vl/eudnFT8Eg0Kp62tMwEw==} + dependencies: + get-caller-file: 2.0.5 + pino: 8.15.0 + pino-std-serializers: 6.2.2 + process-warning: 2.2.0 + + /pino-pretty@9.1.0: + resolution: {integrity: sha512-IM6NY9LLo/dVgY7/prJhCh4rAJukafdt0ibxeNOWc2fxKMyTk90SOB9Ao2HfbtShT9QPeP0ePpJktksMhSQMYA==} + hasBin: true + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 2.1.7 + fast-safe-stringify: 2.1.1 + help-me: 4.2.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.0 + pino-abstract-transport: 1.0.0 + pump: 3.0.0 + readable-stream: 4.4.2 + secure-json-parse: 2.7.0 + sonic-boom: 3.3.0 + strip-json-comments: 3.1.1 + dev: true + + /pino-std-serializers@6.2.2: + resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} + + /pino@8.15.0: + resolution: {integrity: sha512-olUADJByk4twxccmAxb1RiGKOSvddHugCV3wkqjyv+3Sooa2KLrmXrKEWOKi0XPCLasRR5jBXxioE1jxUa4KzQ==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.3.0 + on-exit-leak-free: 2.1.0 + pino-abstract-transport: 1.0.0 + pino-std-serializers: 6.2.2 + process-warning: 2.2.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 3.3.0 + thread-stream: 2.4.0 + + /pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + dev: true + + /pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + dev: true + + /postcss-calc@8.2.4(postcss@8.4.38): + resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==} + peerDependencies: + postcss: ^8.2.2 + dependencies: + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-colormin@5.3.1(postcss@8.4.38): + resolution: {integrity: sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.10 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-convert-values@5.1.3(postcss@8.4.38): + resolution: {integrity: sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.10 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-discard-comments@5.1.2(postcss@8.4.38): + resolution: {integrity: sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + dev: true + + /postcss-discard-duplicates@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + dev: true + + /postcss-discard-empty@5.1.1(postcss@8.4.38): + resolution: {integrity: sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + dev: true + + /postcss-discard-overridden@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + dev: true + + /postcss-import@15.1.0(postcss@8.4.38): + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + dependencies: + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.4 + dev: true + + /postcss-js@4.0.1(postcss@8.4.38): + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.38 + dev: true + + /postcss-load-config@3.1.4(postcss@8.4.38): + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 2.1.0 + postcss: 8.4.38 + yaml: 1.10.2 + dev: true + + /postcss-load-config@4.0.2(postcss@8.4.38): + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 3.1.1 + postcss: 8.4.38 + yaml: 2.4.1 + dev: true + + /postcss-merge-longhand@5.1.7(postcss@8.4.38): + resolution: {integrity: sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + stylehacks: 5.1.1(postcss@8.4.38) + dev: true + + /postcss-merge-rules@5.1.4(postcss@8.4.38): + resolution: {integrity: sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.10 + caniuse-api: 3.0.0 + cssnano-utils: 3.1.0(postcss@8.4.38) + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 + dev: true + + /postcss-minify-font-values@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-minify-gradients@5.1.1(postcss@8.4.38): + resolution: {integrity: sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + colord: 2.9.3 + cssnano-utils: 3.1.0(postcss@8.4.38) + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-minify-params@5.1.4(postcss@8.4.38): + resolution: {integrity: sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.10 + cssnano-utils: 3.1.0(postcss@8.4.38) + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-minify-selectors@5.2.1(postcss@8.4.38): + resolution: {integrity: sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 + dev: true + + /postcss-modules-extract-imports@3.1.0(postcss@8.4.38): + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.38 + dev: true + + /postcss-modules-local-by-default@4.0.5(postcss@8.4.38): + resolution: {integrity: sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + icss-utils: 5.1.0(postcss@8.4.38) + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-modules-scope@3.2.0(postcss@8.4.38): + resolution: {integrity: sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 + dev: true + + /postcss-modules-values@4.0.0(postcss@8.4.38): + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + icss-utils: 5.1.0(postcss@8.4.38) + postcss: 8.4.38 + dev: true + + /postcss-modules@4.3.1(postcss@8.4.38): + resolution: {integrity: sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==} + peerDependencies: + postcss: ^8.0.0 + dependencies: + generic-names: 4.0.0 + icss-replace-symbols: 1.1.0 + lodash.camelcase: 4.3.0 + postcss: 8.4.38 + postcss-modules-extract-imports: 3.1.0(postcss@8.4.38) + postcss-modules-local-by-default: 4.0.5(postcss@8.4.38) + postcss-modules-scope: 3.2.0(postcss@8.4.38) + postcss-modules-values: 4.0.0(postcss@8.4.38) + string-hash: 1.1.3 + dev: true + + /postcss-nested@6.0.1(postcss@8.4.38): + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 + dev: true + + /postcss-normalize-charset@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + dev: true + + /postcss-normalize-display-values@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-positions@5.1.1(postcss@8.4.38): + resolution: {integrity: sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-normalize-repeat-style@5.1.1(postcss@8.4.38): + resolution: {integrity: sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==} + engines: {node: ^10 || ^12 || >=14.0} peerDependencies: - pg-native: '>=3.0.1' - peerDependenciesMeta: - pg-native: - optional: true + postcss: ^8.2.15 dependencies: - buffer-writer: 2.0.0 - packet-reader: 1.0.0 - pg-connection-string: 2.6.2 - pg-pool: 3.6.1(pg@8.10.0) - pg-protocol: 1.6.0 - pg-types: 2.2.0 - pgpass: 1.0.5 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true - /pgpass@1.0.5: - resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + /postcss-normalize-string@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 dependencies: - split2: 4.2.0 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true - /picocolors@1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + /postcss-normalize-timing-functions@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + postcss-value-parser: 4.2.0 dev: true - /picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} + /postcss-normalize-unicode@5.1.1(postcss@8.4.38): + resolution: {integrity: sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.10 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true - /pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} + /postcss-normalize-url@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + normalize-url: 6.1.0 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 dev: true - /pify@5.0.0: - resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} - engines: {node: '>=10'} - dev: false + /postcss-normalize-whitespace@5.1.1(postcss@8.4.38): + resolution: {integrity: sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true - /pino-abstract-transport@1.0.0: - resolution: {integrity: sha512-c7vo5OpW4wIS42hUVcT5REsL8ZljsUfBjqV/e2sFxmFEFZiq1XLUp5EYLtuDH6PEHq9W1egWqRbnLUP5FuZmOA==} + /postcss-ordered-values@5.1.3(postcss@8.4.38): + resolution: {integrity: sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 dependencies: - readable-stream: 4.4.2 - split2: 4.2.0 + cssnano-utils: 3.1.0(postcss@8.4.38) + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true - /pino-http@8.2.1: - resolution: {integrity: sha512-bdWAE4HYfFjDhKw2/N7BLNSIFAs+WDLZnetsGRpBdNEKq7/RoZUgblLS5OlMY257RPQml6J5QiiLkwxbstzWbA==} + /postcss-reduce-initial@5.1.2(postcss@8.4.38): + resolution: {integrity: sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 dependencies: - fast-url-parser: 1.1.3 - get-caller-file: 2.0.5 - pino: 8.15.0 - pino-std-serializers: 6.2.2 - process-warning: 2.2.0 - dev: false + browserslist: 4.21.10 + caniuse-api: 3.0.0 + postcss: 8.4.38 + dev: true - /pino-http@8.4.0: - resolution: {integrity: sha512-9I1eRLxsujQJwLQTrHBU0wDlwnry2HzV2TlDwAsmZ9nT3Y2NQBLrz+DYp73L4i11vl/eudnFT8Eg0Kp62tMwEw==} + /postcss-reduce-transforms@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 dependencies: - get-caller-file: 2.0.5 - pino: 8.15.0 - pino-std-serializers: 6.2.2 - process-warning: 2.2.0 + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + dev: true - /pino-pretty@9.1.0: - resolution: {integrity: sha512-IM6NY9LLo/dVgY7/prJhCh4rAJukafdt0ibxeNOWc2fxKMyTk90SOB9Ao2HfbtShT9QPeP0ePpJktksMhSQMYA==} - hasBin: true + /postcss-selector-parser@6.0.16: + resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==} + engines: {node: '>=4'} dependencies: - colorette: 2.0.20 - dateformat: 4.6.3 - fast-copy: 2.1.7 - fast-safe-stringify: 2.1.1 - help-me: 4.2.0 - joycon: 3.1.1 - minimist: 1.2.8 - on-exit-leak-free: 2.1.0 - pino-abstract-transport: 1.0.0 - pump: 3.0.0 - readable-stream: 4.4.2 - secure-json-parse: 2.7.0 - sonic-boom: 3.3.0 - strip-json-comments: 3.1.1 + cssesc: 3.0.0 + util-deprecate: 1.0.2 dev: true - /pino-std-serializers@6.2.2: - resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} + /postcss-svgo@5.1.0(postcss@8.4.38): + resolution: {integrity: sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + postcss: 8.4.38 + postcss-value-parser: 4.2.0 + svgo: 2.8.0 + dev: true - /pino@8.15.0: - resolution: {integrity: sha512-olUADJByk4twxccmAxb1RiGKOSvddHugCV3wkqjyv+3Sooa2KLrmXrKEWOKi0XPCLasRR5jBXxioE1jxUa4KzQ==} - hasBin: true + /postcss-unique-selectors@5.1.1(postcss@8.4.38): + resolution: {integrity: sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 dependencies: - atomic-sleep: 1.0.0 - fast-redact: 3.3.0 - on-exit-leak-free: 2.1.0 - pino-abstract-transport: 1.0.0 - pino-std-serializers: 6.2.2 - process-warning: 2.2.0 - quick-format-unescaped: 4.0.4 - real-require: 0.2.0 - safe-stable-stringify: 2.4.3 - sonic-boom: 3.3.0 - thread-stream: 2.4.0 + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 + dev: true - /pirates@4.0.6: - resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} - engines: {node: '>= 6'} + /postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} dev: true - /pkg-dir@4.2.0: - resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} - engines: {node: '>=8'} + /postcss@8.4.38: + resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} + engines: {node: ^10 || ^12 || >=14} dependencies: - find-up: 4.1.0 + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.2.0 dev: true /postgres-array@2.0.0: @@ -9805,6 +11086,11 @@ packages: retry: 0.12.0 dev: true + /promise.series@0.2.0: + resolution: {integrity: sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==} + engines: {node: '>=0.12'} + dev: true + /prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -9846,6 +11132,10 @@ packages: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} dev: true + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: false + /pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: @@ -9884,6 +11174,12 @@ packages: engines: {node: '>=8'} dev: true + /randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + dependencies: + safe-buffer: 5.2.1 + dev: true + /range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -9909,10 +11205,33 @@ packages: minimist: 1.2.8 strip-json-comments: 2.0.1 + /react-dom@18.2.0(react@18.2.0): + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.0 + dev: true + /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: true + + /read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + dependencies: + pify: 2.3.0 + dev: true + /read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} @@ -9966,6 +11285,13 @@ packages: dependencies: readable-stream: 3.6.2 + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + /real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -10098,6 +11424,61 @@ packages: safe-stable-stringify: 2.4.3 semver-compare: 1.0.0 + /rollup-plugin-postcss@4.0.2(postcss@8.4.38): + resolution: {integrity: sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==} + engines: {node: '>=10'} + peerDependencies: + postcss: 8.x + dependencies: + chalk: 4.1.2 + concat-with-sourcemaps: 1.1.0 + cssnano: 5.1.15(postcss@8.4.38) + import-cwd: 3.0.0 + p-queue: 6.6.2 + pify: 5.0.0 + postcss: 8.4.38 + postcss-load-config: 3.1.4(postcss@8.4.38) + postcss-modules: 4.3.1(postcss@8.4.38) + promise.series: 0.2.0 + resolve: 1.22.4 + rollup-pluginutils: 2.8.2 + safe-identifier: 0.4.2 + style-inject: 0.3.0 + transitivePeerDependencies: + - ts-node + dev: true + + /rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + dependencies: + estree-walker: 0.6.1 + dev: true + + /rollup@4.14.1: + resolution: {integrity: sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.14.1 + '@rollup/rollup-android-arm64': 4.14.1 + '@rollup/rollup-darwin-arm64': 4.14.1 + '@rollup/rollup-darwin-x64': 4.14.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.14.1 + '@rollup/rollup-linux-arm64-gnu': 4.14.1 + '@rollup/rollup-linux-arm64-musl': 4.14.1 + '@rollup/rollup-linux-powerpc64le-gnu': 4.14.1 + '@rollup/rollup-linux-riscv64-gnu': 4.14.1 + '@rollup/rollup-linux-s390x-gnu': 4.14.1 + '@rollup/rollup-linux-x64-gnu': 4.14.1 + '@rollup/rollup-linux-x64-musl': 4.14.1 + '@rollup/rollup-win32-arm64-msvc': 4.14.1 + '@rollup/rollup-win32-ia32-msvc': 4.14.1 + '@rollup/rollup-win32-x64-msvc': 4.14.1 + fsevents: 2.3.3 + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -10126,6 +11507,10 @@ packages: /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + /safe-identifier@0.4.2: + resolution: {integrity: sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==} + dev: true + /safe-regex-test@1.0.0: resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} dependencies: @@ -10142,6 +11527,12 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} requiresBuild: true + /scheduler@0.23.0: + resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + dependencies: + loose-envify: 1.4.0 + dev: true + /scmp@2.1.0: resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} dev: true @@ -10197,6 +11588,12 @@ packages: transitivePeerDependencies: - supports-color + /serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + dependencies: + randombytes: 2.1.0 + dev: true + /serve-static@1.15.0: resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} engines: {node: '>= 0.8.0'} @@ -10269,7 +11666,6 @@ packages: /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - dev: false /simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -10313,6 +11709,10 @@ packages: yargs: 15.4.1 dev: true + /smob@1.5.0: + resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} + dev: true + /socks-proxy-agent@7.0.0: resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} engines: {node: '>= 10'} @@ -10337,6 +11737,11 @@ packages: dependencies: atomic-sleep: 1.0.0 + /source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + engines: {node: '>=0.10.0'} + dev: true + /source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} dependencies: @@ -10344,6 +11749,13 @@ packages: source-map: 0.6.1 dev: true + /source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + dev: true + /source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -10403,6 +11815,11 @@ packages: minipass: 3.3.6 dev: true + /stable@0.1.8: + resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} + deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' + dev: true + /stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -10436,6 +11853,10 @@ packages: fast-fifo: 1.3.2 queue-tick: 1.0.1 + /string-hash@1.1.3: + resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} + dev: true + /string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} @@ -10459,7 +11880,6 @@ packages: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 strip-ansi: 7.1.0 - dev: false /string.prototype.trim@1.2.7: resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} @@ -10502,7 +11922,6 @@ packages: engines: {node: '>=12'} dependencies: ansi-regex: 6.0.1 - dev: false /strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} @@ -10551,6 +11970,35 @@ packages: engines: {node: '>= 14', npm: '>=6'} dev: false + /style-inject@0.3.0: + resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==} + dev: true + + /stylehacks@5.1.1(postcss@8.4.38): + resolution: {integrity: sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + dependencies: + browserslist: 4.21.10 + postcss: 8.4.38 + postcss-selector-parser: 6.0.16 + dev: true + + /sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + commander: 4.1.1 + glob: 10.3.10 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + dev: true + /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -10583,6 +12031,20 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /svgo@2.8.0: + resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==} + engines: {node: '>=10.13.0'} + hasBin: true + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 4.3.0 + css-tree: 1.1.3 + csso: 4.2.0 + picocolors: 1.0.0 + stable: 0.1.8 + dev: true + /synckit@0.8.8: resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -10591,6 +12053,37 @@ packages: tslib: 2.6.2 dev: true + /tailwindcss@3.4.3: + resolution: {integrity: sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.1 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.38 + postcss-import: 15.1.0(postcss@8.4.38) + postcss-js: 4.0.1(postcss@8.4.38) + postcss-load-config: 4.0.2(postcss@8.4.38) + postcss-nested: 6.0.1(postcss@8.4.38) + postcss-selector-parser: 6.0.16 + resolve: 1.22.4 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + dev: true + /tar-fs@2.1.1: resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} dependencies: @@ -10648,6 +12141,17 @@ packages: supports-hyperlinks: 2.3.0 dev: true + /terser@5.30.3: + resolution: {integrity: sha512-STdUgOUx8rLbMGO9IOwHLpCqolkDITFFQSMYYwKE1N2lY6MVSaeoi10z/EhWxRc6ybqoVmKSkhKYH/XUpl7vSA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.10.0 + commander: 2.20.3 + source-map-support: 0.5.21 + dev: true + /test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -10661,6 +12165,19 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + dependencies: + thenify: 3.3.1 + dev: true + + /thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + dependencies: + any-promise: 1.3.0 + dev: true + /thread-stream@2.4.0: resolution: {integrity: sha512-xZYtOtmnA63zj04Q+F9bdEay5r47bvpo1CaNqsKi7TpoJHcotUez8Fkfo2RJWpW91lnnaApdpRbVwCWsy+ifcw==} dependencies: @@ -10726,6 +12243,10 @@ packages: typescript: 5.4.4 dev: true + /ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + dev: true + /ts-morph@16.0.0: resolution: {integrity: sha512-jGNF0GVpFj0orFw55LTsQxVYEUOCWBAbR5Ls7fTYE5pQsbW18ssTb/6UXx/GYAEjS+DQTp8VoTw0vqYMiaaQuw==} dependencies: @@ -10733,7 +12254,7 @@ packages: code-block-writer: 11.0.3 dev: false - /ts-node@10.8.2(@swc/core@1.3.42)(@types/node@20.12.6)(typescript@5.4.4): + /ts-node@10.8.2(@swc/core@1.3.42)(@types/node@18.19.24)(typescript@5.4.4): resolution: {integrity: sha512-LYdGnoGddf1D6v8REPtIH+5iq/gTDuZqv2/UJUU7tKjuEU8xVZorBM+buCGNjj+pGEud+sOoM4CX3/YzINpENA==} hasBin: true peerDependencies: @@ -10753,7 +12274,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.12.6 + '@types/node': 18.19.24 acorn: 8.10.0 acorn-walk: 8.2.0 arg: 4.1.3 @@ -10773,6 +12294,13 @@ packages: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} requiresBuild: true + /tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + requiresBuild: true + dev: false + optional: true + /tty-table@4.2.1: resolution: {integrity: sha512-xz0uKo+KakCQ+Dxj1D/tKn2FSyreSYWzdkL/BYhgN6oMW808g8QRMuh1atAV9fjTPbWBjfbkKQpI/5rEcnAc7g==} engines: {node: '>=8.0.0'} @@ -10982,6 +12510,17 @@ packages: picocolors: 1.0.0 dev: true + /update-browserslist-db@1.0.13(browserslist@4.23.0): + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.23.0 + escalade: 3.1.1 + picocolors: 1.0.0 + dev: true + /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: @@ -11137,7 +12676,6 @@ packages: ansi-styles: 6.2.1 string-width: 5.1.2 strip-ansi: 7.1.0 - dev: false /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -11191,6 +12729,17 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: true + + /yaml@2.4.1: + resolution: {integrity: sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==} + engines: {node: '>= 14'} + hasBin: true + dev: true + /yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -11249,3 +12798,6 @@ packages: /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} diff --git a/tsconfig.json b/tsconfig.json index 73a52e54516..5a400fd4e8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,13 +9,26 @@ { "path": "./packages/common-web" }, { "path": "./packages/crypto" }, { "path": "./packages/dev-env" }, + { "path": "./packages/fetch" }, + { "path": "./packages/fetch-node" }, + { "path": "./packages/html" }, + { "path": "./packages/http-util" }, { "path": "./packages/identity" }, + { "path": "./packages/jwk" }, + { "path": "./packages/jwk-node" }, { "path": "./packages/lex-cli" }, { "path": "./packages/lexicon" }, + { "path": "./packages/oauth-provider" }, + { "path": "./packages/oauth-provider-client-uri" }, + { "path": "./packages/oauth-provider-client-fqdn" }, + { "path": "./packages/oauth-provider-replay-memory" }, + { "path": "./packages/oauth-provider-replay-redis" }, { "path": "./packages/ozone" }, { "path": "./packages/pds" }, { "path": "./packages/repo" }, + { "path": "./packages/rollup-plugin-bundle-manifest" }, { "path": "./packages/syntax" }, + { "path": "./packages/transformer" }, { "path": "./packages/xrpc" }, { "path": "./packages/xrpc-server" } ] diff --git a/tsconfig/browser.json b/tsconfig/browser.json new file mode 100644 index 00000000000..e60c4970688 --- /dev/null +++ b/tsconfig/browser.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable", "ESNext.Disposable"], + "jsx": "react-jsx" + } +} diff --git a/tsconfig/bundler.json b/tsconfig/bundler.json new file mode 100644 index 00000000000..7caa5849002 --- /dev/null +++ b/tsconfig/bundler.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "allowImportingTsExtensions": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": false, + "declarationMap": false, + "noEmit": true + } +} diff --git a/tsconfig/nodenext.json b/tsconfig/nodenext.json new file mode 100644 index 00000000000..5072557d696 --- /dev/null +++ b/tsconfig/nodenext.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2022", "ScriptHost"], + "types": ["node"], + "module": "Node16", + "target": "ES2022", + "moduleResolution": "Node16" + } +} From 813d614b382ccaa3b8f72871a31b759da87a40e5 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 22 Feb 2024 16:04:48 +0100 Subject: [PATCH 005/140] add oauth provider capability to PDS --- packages/pds/example.env | 3 +- packages/pds/package.json | 10 + .../db/migrations/003-oauth.ts | 99 +++++++ .../account-manager/db/migrations/index.ts | 2 + .../db/schema/authorization-request.ts | 21 ++ .../db/schema/device-account.ts | 15 + .../src/account-manager/db/schema/device.ts | 18 ++ .../src/account-manager/db/schema/index.ts | 15 + .../src/account-manager/db/schema/token.ts | 34 +++ .../db/schema/used-refresh-token.ts | 13 + .../src/account-manager/helpers/account.ts | 2 +- .../helpers/authorization-request.ts | 105 +++++++ .../account-manager/helpers/device-account.ts | 180 ++++++++++++ .../pds/src/account-manager/helpers/device.ts | 76 +++++ .../pds/src/account-manager/helpers/token.ts | 228 +++++++++++++++ .../helpers/used-refresh-token.ts | 24 ++ packages/pds/src/account-manager/index.ts | 265 +++++++++++++++++- .../api/com/atproto/server/createSession.ts | 103 +++---- packages/pds/src/api/proxy.ts | 20 +- packages/pds/src/auth-verifier.ts | 112 +++++++- packages/pds/src/auth.ts | 15 + packages/pds/src/config/config.ts | 42 +++ packages/pds/src/config/env.ts | 10 + packages/pds/src/config/secrets.ts | 6 + packages/pds/src/context.ts | 99 ++++++- packages/pds/src/db/cast.ts | 43 +++ packages/pds/src/db/index.ts | 1 + packages/pds/src/error.ts | 7 + packages/pds/src/index.ts | 2 + packages/pds/src/logger.ts | 2 + packages/pds/src/oauth/oauth-client-store.ts | 125 +++++++++ pnpm-lock.yaml | 30 ++ 32 files changed, 1639 insertions(+), 88 deletions(-) create mode 100644 packages/pds/src/account-manager/db/migrations/003-oauth.ts create mode 100644 packages/pds/src/account-manager/db/schema/authorization-request.ts create mode 100644 packages/pds/src/account-manager/db/schema/device-account.ts create mode 100644 packages/pds/src/account-manager/db/schema/device.ts create mode 100644 packages/pds/src/account-manager/db/schema/token.ts create mode 100644 packages/pds/src/account-manager/db/schema/used-refresh-token.ts create mode 100644 packages/pds/src/account-manager/helpers/authorization-request.ts create mode 100644 packages/pds/src/account-manager/helpers/device-account.ts create mode 100644 packages/pds/src/account-manager/helpers/device.ts create mode 100644 packages/pds/src/account-manager/helpers/token.ts create mode 100644 packages/pds/src/account-manager/helpers/used-refresh-token.ts create mode 100644 packages/pds/src/auth.ts create mode 100644 packages/pds/src/db/cast.ts create mode 100644 packages/pds/src/oauth/oauth-client-store.ts diff --git a/packages/pds/example.env b/packages/pds/example.env index fc3c3520eb0..d314e170af7 100644 --- a/packages/pds/example.env +++ b/packages/pds/example.env @@ -14,6 +14,7 @@ PDS_REPO_SIGNING_KEY_K256_PRIVATE_KEY_HEX="3ee68..." PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX="e049f..." # Secrets - update to secure high-entropy strings +PDS_DPOP_SECRET="32-random-bytes-hex-encoded" PDS_JWT_SECRET="jwt-secret" PDS_ADMIN_PASSWORD="admin-pass" @@ -21,4 +22,4 @@ PDS_ADMIN_PASSWORD="admin-pass" PDS_DID_PLC_URL="https://plc.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_ENDPOINT="https://api.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_DID="did:web:api.bsky-sandbox.dev" -PDS_CRAWLERS="https://bgs.bsky-sandbox.dev" \ No newline at end of file +PDS_CRAWLERS="https://bgs.bsky-sandbox.dev" diff --git a/packages/pds/package.json b/packages/pds/package.json index 7193c8b54bd..71e8763d116 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -33,8 +33,18 @@ "@atproto/aws": "workspace:^", "@atproto/common": "workspace:^", "@atproto/crypto": "workspace:^", + "@atproto/fetch": "workspace:*", + "@atproto/fetch-node": "workspace:*", + "@atproto/http-util": "workspace:*", "@atproto/identity": "workspace:^", + "@atproto/jwk": "workspace:^", + "@atproto/jwk-node": "workspace:^", "@atproto/lexicon": "workspace:^", + "@atproto/oauth-provider": "workspace:^", + "@atproto/oauth-provider-client-fqdn": "workspace:^", + "@atproto/oauth-provider-client-uri": "workspace:^", + "@atproto/oauth-provider-replay-memory": "workspace:^", + "@atproto/oauth-provider-replay-redis": "workspace:^", "@atproto/repo": "workspace:^", "@atproto/syntax": "workspace:^", "@atproto/xrpc": "workspace:^", diff --git a/packages/pds/src/account-manager/db/migrations/003-oauth.ts b/packages/pds/src/account-manager/db/migrations/003-oauth.ts new file mode 100644 index 00000000000..d0373fbba4a --- /dev/null +++ b/packages/pds/src/account-manager/db/migrations/003-oauth.ts @@ -0,0 +1,99 @@ +import { Kysely, sql } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('authorization_request') + .addColumn('id', 'varchar', (col) => col.primaryKey()) + .addColumn('did', 'varchar') + .addColumn('deviceId', 'varchar') + .addColumn('clientId', 'varchar', (col) => col.notNull()) + .addColumn('clientAuth', 'varchar', (col) => col.notNull()) + .addColumn('parameters', 'varchar', (col) => col.notNull()) + .addColumn('expiresAt', 'varchar', (col) => col.notNull()) + .addColumn('code', 'varchar') + .execute() + + await db.schema + .createIndex('authorization_request_code_idx') + .unique() + .on('authorization_request') + // https://github.com/kysely-org/kysely/issues/302 + .expression(sql`code DESC) WHERE (code IS NOT NULL`) + .execute() + + await db.schema + .createIndex('authorization_request_expires_at_idx') + .on('authorization_request') + .column('expiresAt') + .execute() + + await db.schema + .createTable('device') + .addColumn('id', 'varchar', (col) => col.primaryKey()) + .addColumn('sessionId', 'varchar', (col) => col.notNull()) + .addColumn('userAgent', 'varchar') + .addColumn('ipAddress', 'varchar', (col) => col.notNull()) + .addColumn('lastSeenAt', 'varchar', (col) => col.notNull()) + .addUniqueConstraint('device_session_id_idx', ['sessionId']) + .execute() + + await db.schema + .createTable('device_account') + .addColumn('did', 'varchar', (col) => col.notNull()) + .addColumn('deviceId', 'varchar', (col) => col.notNull()) + .addColumn('authenticatedAt', 'varchar', (col) => col.notNull()) + .addColumn('remember', 'boolean', (col) => col.notNull()) + .addColumn('authorizedClients', 'varchar', (col) => col.notNull()) + .addUniqueConstraint('device_account_did_device_id_idx', [ + 'deviceId', // first because this table will be joined from the "device" table + 'did', + ]) + .execute() + + await db.schema + .createTable('token') + .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement()) + .addColumn('did', 'varchar', (col) => col.notNull()) + .addColumn('tokenId', 'varchar', (col) => col.notNull()) + .addColumn('createdAt', 'varchar', (col) => col.notNull()) + .addColumn('updatedAt', 'varchar', (col) => col.notNull()) + .addColumn('expiresAt', 'varchar', (col) => col.notNull()) + .addColumn('clientId', 'varchar', (col) => col.notNull()) + .addColumn('clientAuth', 'varchar', (col) => col.notNull()) + .addColumn('deviceId', 'varchar') + .addColumn('parameters', 'varchar', (col) => col.notNull()) + .addColumn('details', 'varchar') + .addColumn('code', 'varchar') + .addColumn('currentRefreshToken', 'varchar') + .addUniqueConstraint('token_current_refresh_token_unique_idx', [ + 'currentRefreshToken', + ]) + .addUniqueConstraint('token_id_unique_idx', ['tokenId']) + .execute() + + await db.schema + .createIndex('token_code_idx') + .unique() + .on('token') + // https://github.com/kysely-org/kysely/issues/302 + .expression(sql`code DESC) WHERE (code IS NOT NULL`) + .execute() + + await db.schema + .createTable('used_refresh_token') + .addColumn('id', 'integer', (col) => col.notNull()) + .addColumn('usedRefreshToken', 'varchar', (col) => col.notNull()) + .addUniqueConstraint('used_refresh_token_used_refresh_token_idx', [ + 'usedRefreshToken', + 'id', + ]) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('used_refresh_token').execute() + await db.schema.dropTable('token').execute() + await db.schema.dropTable('device_account').execute() + await db.schema.dropTable('device').execute() + await db.schema.dropTable('authorization_request').execute() +} diff --git a/packages/pds/src/account-manager/db/migrations/index.ts b/packages/pds/src/account-manager/db/migrations/index.ts index 154dcd3ea9a..115bc3aeadf 100644 --- a/packages/pds/src/account-manager/db/migrations/index.ts +++ b/packages/pds/src/account-manager/db/migrations/index.ts @@ -1,7 +1,9 @@ import * as mig001 from './001-init' import * as mig002 from './002-account-deactivation' +import * as mig003 from './003-oauth' export default { '001': mig001, '002': mig002, + '003': mig003, } diff --git a/packages/pds/src/account-manager/db/schema/authorization-request.ts b/packages/pds/src/account-manager/db/schema/authorization-request.ts new file mode 100644 index 00000000000..c111f3a8919 --- /dev/null +++ b/packages/pds/src/account-manager/db/schema/authorization-request.ts @@ -0,0 +1,21 @@ +import { ClientId, Code, DeviceId, RequestId } from '@atproto/oauth-provider' +import { Selectable } from 'kysely' +import { DateISO, JsonObject } from '../../../db' + +export interface AuthorizationRequest { + id: RequestId + did: string | null + deviceId: DeviceId | null + + clientId: ClientId + clientAuth: JsonObject + parameters: JsonObject + expiresAt: DateISO // TODO: Index this + code: Code | null +} + +export type AuthorizationRequestEntry = Selectable + +export const tableName = 'authorization_request' + +export type PartialDB = { [tableName]: AuthorizationRequest } diff --git a/packages/pds/src/account-manager/db/schema/device-account.ts b/packages/pds/src/account-manager/db/schema/device-account.ts new file mode 100644 index 00000000000..3cf6d139163 --- /dev/null +++ b/packages/pds/src/account-manager/db/schema/device-account.ts @@ -0,0 +1,15 @@ +import { DeviceId } from '@atproto/oauth-provider' +import { DateISO, JsonArray } from '../../../db' + +export interface DeviceAccount { + did: string + deviceId: DeviceId + + authenticatedAt: DateISO + authorizedClients: JsonArray + remember: 0 | 1 +} + +export const tableName = 'device_account' + +export type PartialDB = { [tableName]: DeviceAccount } diff --git a/packages/pds/src/account-manager/db/schema/device.ts b/packages/pds/src/account-manager/db/schema/device.ts new file mode 100644 index 00000000000..23b98acb0ba --- /dev/null +++ b/packages/pds/src/account-manager/db/schema/device.ts @@ -0,0 +1,18 @@ +import { DeviceId, SessionId } from '@atproto/oauth-provider' +import { Selectable } from 'kysely' +import { DateISO } from '../../../db' + +export interface Device { + id: DeviceId + sessionId: SessionId + + userAgent: string | null + ipAddress: string + lastSeenAt: DateISO +} + +export type DeviceEntry = Selectable + +export const tableName = 'device' + +export type PartialDB = { [tableName]: Device } diff --git a/packages/pds/src/account-manager/db/schema/index.ts b/packages/pds/src/account-manager/db/schema/index.ts index 6bcd95a9138..8280136c7f7 100644 --- a/packages/pds/src/account-manager/db/schema/index.ts +++ b/packages/pds/src/account-manager/db/schema/index.ts @@ -1,5 +1,10 @@ import * as actor from './actor' import * as account from './account' +import * as device from './device.js' +import * as deviceAccount from './device-account.js' +import * as oauthRequest from './authorization-request.js' +import * as token from './token.js' +import * as usedRefreshToken from './used-refresh-token.js' import * as repoRoot from './repo-root' import * as refreshToken from './refresh-token' import * as appPassword from './app-password' @@ -8,6 +13,11 @@ import * as emailToken from './email-token' export type DatabaseSchema = actor.PartialDB & account.PartialDB & + device.PartialDB & + deviceAccount.PartialDB & + oauthRequest.PartialDB & + token.PartialDB & + usedRefreshToken.PartialDB & refreshToken.PartialDB & appPassword.PartialDB & repoRoot.PartialDB & @@ -16,6 +26,11 @@ export type DatabaseSchema = actor.PartialDB & export type { Actor, ActorEntry } from './actor' export type { Account, AccountEntry } from './account' +export type { Device } from './device' +export type { DeviceAccount } from './device-account' +export type { AuthorizationRequest } from './authorization-request' +export type { Token } from './token' +export type { UsedRefreshToken } from './used-refresh-token' export type { RepoRoot } from './repo-root' export type { RefreshToken } from './refresh-token' export type { AppPassword } from './app-password' diff --git a/packages/pds/src/account-manager/db/schema/token.ts b/packages/pds/src/account-manager/db/schema/token.ts new file mode 100644 index 00000000000..f4beb9544ff --- /dev/null +++ b/packages/pds/src/account-manager/db/schema/token.ts @@ -0,0 +1,34 @@ +import { + ClientId, + Code, + DeviceId, + RefreshToken, + Sub, + TokenId, +} from '@atproto/oauth-provider' +import { Generated, Selectable } from 'kysely' + +import { DateISO, JsonArray, JsonObject } from '../../../db/cast.js' + +export interface Token { + id: Generated + did: Sub + + tokenId: TokenId + createdAt: DateISO + updatedAt: DateISO + expiresAt: DateISO + clientId: ClientId + clientAuth: JsonObject + deviceId: DeviceId | null + parameters: JsonObject + details: JsonArray | null + code: Code | null + currentRefreshToken: RefreshToken | null // TODO: Index this +} + +export type TokenEntry = Selectable + +export const tableName = 'token' + +export type PartialDB = { [tableName]: Token } diff --git a/packages/pds/src/account-manager/db/schema/used-refresh-token.ts b/packages/pds/src/account-manager/db/schema/used-refresh-token.ts new file mode 100644 index 00000000000..bd3cce74400 --- /dev/null +++ b/packages/pds/src/account-manager/db/schema/used-refresh-token.ts @@ -0,0 +1,13 @@ +import { RefreshToken } from '@atproto/oauth-provider' +import { Selectable } from 'kysely' + +export interface UsedRefreshToken { + id: number // TODO: Index this (foreign key to token) + usedRefreshToken: RefreshToken +} + +export type UsedRefreshTokenEntry = Selectable + +export const tableName = 'used_refresh_token' + +export type PartialDB = { [tableName]: UsedRefreshToken } diff --git a/packages/pds/src/account-manager/helpers/account.ts b/packages/pds/src/account-manager/helpers/account.ts index 344c06a3778..4917687184a 100644 --- a/packages/pds/src/account-manager/helpers/account.ts +++ b/packages/pds/src/account-manager/helpers/account.ts @@ -16,7 +16,7 @@ export type AvailabilityFlags = { includeDeactivated?: boolean } -const selectAccountQB = (db: AccountDb, flags?: AvailabilityFlags) => { +export const selectAccountQB = (db: AccountDb, flags?: AvailabilityFlags) => { const { includeTakenDown = false, includeDeactivated = false } = flags ?? {} const { ref } = db.db.dynamic return db.db diff --git a/packages/pds/src/account-manager/helpers/authorization-request.ts b/packages/pds/src/account-manager/helpers/authorization-request.ts new file mode 100644 index 00000000000..5ea568ea268 --- /dev/null +++ b/packages/pds/src/account-manager/helpers/authorization-request.ts @@ -0,0 +1,105 @@ +import { + Code, + RequestData, + RequestId, + UpdateRequestData, +} from '@atproto/oauth-provider' +import { AccountDb, AuthorizationRequest } from '../db' +import { fromDateISO, fromJsonObject, toDateISO, toJsonObject } from '../../db' +import { Insertable, Selectable } from 'kysely' + +const rowToRequestData = ( + row: Selectable, +): RequestData => ({ + clientId: row.clientId, + clientAuth: fromJsonObject(row.clientAuth), + parameters: fromJsonObject(row.parameters), + expiresAt: fromDateISO(row.expiresAt), + deviceId: row.deviceId, + sub: row.did, + code: row.code, +}) + +const requestDataToRow = ( + id: RequestId, + data: RequestData, +): Insertable => ({ + id, + did: data.sub, + deviceId: data.deviceId, + + clientId: data.clientId, + clientAuth: toJsonObject(data.clientAuth), + parameters: toJsonObject(data.parameters), + expiresAt: toDateISO(data.expiresAt), + code: data.code, +}) + +export const create = async ( + db: AccountDb, + id: RequestId, + data: RequestData, +) => { + await db.db + .insertInto('authorization_request') + .values(requestDataToRow(id, data)) + .execute() +} + +export const deleteExpired = async (db: AccountDb) => { + await db.db + .deleteFrom('authorization_request') + .where('expiresAt', '<', toDateISO(new Date())) + .execute() +} + +export const get = async (db: AccountDb, id: RequestId) => { + const row = await db.db + .selectFrom('authorization_request') + .where('id', '=', id) + .selectAll() + .executeTakeFirst() + + if (!row) return null + return rowToRequestData(row) +} + +export const update = async ( + db: AccountDb, + id: RequestId, + data: UpdateRequestData, +) => { + const { code, sub, deviceId, expiresAt, ...rest } = data + + // Fool proof: in case the OauthProvider library is updated with new fields + for (const k in rest) throw new Error(`Unexpected update field "${k}"`) + + await db.db + .updateTable('authorization_request') + .if(code !== undefined, (qb) => qb.set({ code })) + .if(sub !== undefined, (qb) => qb.set({ did: sub })) + .if(deviceId !== undefined, (qb) => qb.set({ deviceId })) + .if(expiresAt != null, (qb) => qb.set({ expiresAt: toDateISO(expiresAt!) })) + .where('id', '=', id) + .execute() +} + +export const deleteById = async (db: AccountDb, id: RequestId) => { + await db.db.deleteFrom('authorization_request').where('id', '=', id).execute() +} + +export const findByCode = async (db: AccountDb, code: Code) => { + const row = await db.db + .selectFrom('authorization_request') + .where('code', '=', code) + .where('code', 'is not', null) // use "authorization_request_code_idx" + .selectAll() + .executeTakeFirst() + + if (!row) return null + + return { + id: row.id, + data: rowToRequestData(row), + } +} diff --git a/packages/pds/src/account-manager/helpers/device-account.ts b/packages/pds/src/account-manager/helpers/device-account.ts new file mode 100644 index 00000000000..7a9e31ad57f --- /dev/null +++ b/packages/pds/src/account-manager/helpers/device-account.ts @@ -0,0 +1,180 @@ +import { + Account, + ClientId, + DeviceAccountInfo, + DeviceId, +} from '@atproto/oauth-provider' +import { Insertable, Selectable } from 'kysely' + +import { fromDateISO, fromJsonArray, toDateISO, toJsonArray } from '../../db' +import { AccountDb } from '../db' +import { DeviceAccount } from '../db/schema/device-account' +import { ActorAccount, selectAccountQB } from './account' + +export type SelectableDeviceAccount = Pick< + Selectable, + 'authenticatedAt' | 'authorizedClients' | 'remember' +> + +const selectAccountInfoQB = (db: AccountDb, deviceId: DeviceId) => + selectAccountQB(db) + .innerJoin('device_account', 'device_account.did', 'actor.did') + .innerJoin('device', 'device.id', 'device_account.deviceId') + .where('device.id', '=', deviceId) + .select([ + 'device_account.authenticatedAt', + 'device_account.remember', + 'device_account.authorizedClients', + ]) + +export type InsertableField = { + authenticatedAt: Date + authorizedClients: ClientId[] + remember: boolean +} + +function toInsertable( + values: Partial & { [k in K]: unknown }, +): Pick, K & keyof Insertable> +function toInsertable( + values: Partial, +): Partial> { + const row: Partial> = {} + if (values.authenticatedAt) { + row.authenticatedAt = toDateISO(values.authenticatedAt) + } + if (values.remember !== undefined) { + row.remember = values.remember === true ? 1 : 0 + } + if (values.authorizedClients) { + row.authorizedClients = toJsonArray(values.authorizedClients) + } + return row +} + +export function toDeviceAccountInfo( + row: SelectableDeviceAccount, +): DeviceAccountInfo { + return { + remembered: row.remember === 1, + authenticatedAt: fromDateISO(row.authenticatedAt), + authorizedClients: fromJsonArray(row.authorizedClients), + } +} + +export function toAccount( + row: Selectable, + audience: string, +): Account { + return { + sub: row.did, + aud: audience, + email: row.email || undefined, + email_verified: row.email ? row.emailConfirmedAt != null : undefined, + preferred_username: row.handle || undefined, + } +} + +export const getAuthorizedClients = async ( + db: AccountDb, + deviceId: DeviceId, + did: string, +) => { + const row = await db.db + .selectFrom('device_account') + .where('did', '=', did) + .where('deviceId', '=', deviceId) + .select('authorizedClients') + .executeTakeFirstOrThrow() + + return fromJsonArray(row.authorizedClients) +} + +export const update = async ( + db: AccountDb, + deviceId: DeviceId, + did: string, + entry: { + authenticatedAt?: Date + authorizedClients?: ClientId[] + remember?: boolean + }, +): Promise => { + await db.db + .updateTable('device_account') + .set(toInsertable(entry)) + .where('did', '=', did) + .where('deviceId', '=', deviceId) + .execute() +} + +export const createOrUpdate = async ( + db: AccountDb, + deviceId: DeviceId, + did: string, + remember: boolean, +) => { + const { authorizedClients, ...values } = toInsertable({ + remember, + authenticatedAt: new Date(), + authorizedClients: [], + }) + + await db.db + .insertInto('device_account') + .values({ did, deviceId, authorizedClients, ...values }) + .onConflict((oc) => oc.columns(['deviceId', 'did']).doUpdateSet(values)) + .executeTakeFirstOrThrow() +} + +export const get = async ( + db: AccountDb, + deviceId: DeviceId, + did: string, + audience: string, +) => { + const row = await selectAccountInfoQB(db, deviceId) + .where('actor.did', '=', did) + .executeTakeFirst() + + if (!row) return null + + return { + account: toAccount(row, audience), + info: toDeviceAccountInfo(row), + } +} + +export const listRemembered = async ( + db: AccountDb, + deviceId: DeviceId, + audience: string, +) => { + const rows = await selectAccountInfoQB(db, deviceId) + .where('device_account.remember', '=', 1) + .execute() + + return rows.map((row) => ({ + account: toAccount(row, audience), + info: toDeviceAccountInfo(row), + })) +} + +export const remove = async ( + db: AccountDb, + deviceId: DeviceId, + did: string, +) => { + await db.db + .deleteFrom('device_account') + .where('deviceId', '=', deviceId) + .where('did', '=', did) + .execute() +} + +export const removeByDevice = async (db: AccountDb, deviceId: DeviceId) => { + await db.db + .deleteFrom('device_account') + .where('deviceId', '=', deviceId) + .execute() +} diff --git a/packages/pds/src/account-manager/helpers/device.ts b/packages/pds/src/account-manager/helpers/device.ts new file mode 100644 index 00000000000..0463537c1ec --- /dev/null +++ b/packages/pds/src/account-manager/helpers/device.ts @@ -0,0 +1,76 @@ +import { DeviceId, SessionData } from '@atproto/oauth-provider' +import { AccountDb, Device } from '../db' +import { fromDateISO, toDateISO } from '../../db' +import { Selectable } from 'kysely' + +const rowToSessionData = (row: Selectable): SessionData => ({ + sessionId: row.sessionId, + userAgent: row.userAgent, + ipAddress: row.ipAddress, + lastSeenAt: fromDateISO(row.lastSeenAt), +}) + +/** + * Future-proofs the session data by ensuring that only the expected fields are + * present. If the @atproto/oauth-provider package adds new fields to the + * SessionData type, this function will throw an error. + */ +const futureProof = >(data: T): T => { + const { sessionId, userAgent, ipAddress, lastSeenAt, ...rest } = data + if (Object.keys(rest).length > 0) throw new Error('Unexpected fields') + return { sessionId, userAgent, ipAddress, lastSeenAt } as T +} + +export const create = async ( + db: AccountDb, + deviceId: DeviceId, + data: SessionData, +) => { + const { sessionId, userAgent, ipAddress, lastSeenAt } = futureProof(data) + + await db.db + .insertInto('device') + .values({ + id: deviceId, + sessionId, + userAgent, + ipAddress, + lastSeenAt: toDateISO(lastSeenAt), + }) + .execute() +} + +export const getById = async (db: AccountDb, deviceId: DeviceId) => { + const row = await db.db + .selectFrom('device') + .where('id', '=', deviceId) + .selectAll() + .executeTakeFirst() + + if (row == null) return null + + return rowToSessionData(row) +} + +export const update = async ( + db: AccountDb, + deviceId: DeviceId, + data: Partial, +) => { + const { sessionId, userAgent, ipAddress, lastSeenAt } = futureProof(data) + + await db.db + .updateTable('device') + .if(sessionId != null, (qb) => qb.set({ sessionId })) + .if(userAgent != null, (qb) => qb.set({ userAgent })) + .if(ipAddress != null, (qb) => qb.set({ ipAddress })) + .if(lastSeenAt != null, (qb) => + qb.set({ lastSeenAt: toDateISO(lastSeenAt!) }), + ) + .where('id', '=', deviceId) + .execute() +} + +export const remove = async (db: AccountDb, deviceId: DeviceId) => { + await db.db.deleteFrom('device').where('id', '=', deviceId).execute() +} diff --git a/packages/pds/src/account-manager/helpers/token.ts b/packages/pds/src/account-manager/helpers/token.ts new file mode 100644 index 00000000000..a74a83d62c7 --- /dev/null +++ b/packages/pds/src/account-manager/helpers/token.ts @@ -0,0 +1,228 @@ +import { + AuthorizationDetails, + Code, + NewTokenData, + RefreshToken, + TokenData, + TokenId, + TokenInfo, +} from '@atproto/oauth-provider' +import { Insertable, Selectable } from 'kysely' +import { + fromDateISO, + fromJsonArray, + fromJsonObject, + toDateISO, + toJsonArray, + toJsonObject, +} from '../../db' +import { AccountDb, Token } from '../db' +import { ActorAccount, selectAccountQB } from './account' +import { + SelectableDeviceAccount, + toAccount, + toDeviceAccountInfo, +} from './device-account' + +type LeftJoined = { [K in keyof T]: null | T[K] } + +export type ActorAccountToken = Selectable & + Selectable> & + LeftJoined + +export const toInsertable = ( + tokenId: TokenId, + data: TokenData, + refreshToken?: RefreshToken, +): Insertable => ({ + tokenId, + createdAt: toDateISO(data.createdAt), + expiresAt: toDateISO(data.expiresAt), + updatedAt: toDateISO(data.updatedAt), + clientId: data.clientId, + clientAuth: toJsonObject(data.clientAuth), + deviceId: data.deviceId, + did: data.sub, + parameters: toJsonObject(data.parameters), + details: data.details ? toJsonArray(data.details) : null, + code: data.code, + currentRefreshToken: refreshToken || null, +}) + +export const toTokenData = ( + row: Pick, +): TokenData => ({ + createdAt: fromDateISO(row.createdAt), + expiresAt: fromDateISO(row.expiresAt), + updatedAt: fromDateISO(row.updatedAt), + clientId: row.clientId, + clientAuth: fromJsonObject(row.clientAuth), + deviceId: row.deviceId, + sub: row.did, + parameters: fromJsonObject(row.parameters), + details: row.details + ? fromJsonArray(row.details) + : null, + code: row.code, +}) + +export const toTokenInfo = ( + row: ActorAccountToken, + audience: string, +): TokenInfo => ({ + id: row.tokenId, + data: toTokenData(row), + account: toAccount(row, audience), + info: + row.authenticatedAt != null && + row.authorizedClients != null && + row.remember != null + ? toDeviceAccountInfo(row as SelectableDeviceAccount) + : undefined, + currentRefreshToken: row.currentRefreshToken, +}) + +const selectTokenInfoQB = (db: AccountDb) => + selectAccountQB(db) + .innerJoin('token', 'token.did', 'actor.did') + .leftJoin('device_account', (join) => + join + .on('device_account.did', '=', 'token.did') + // @ts-expect-error "deviceId" is nullable in token + .on('device_account.deviceId', '=', 'token.deviceId'), + ) + .select([ + 'token.tokenId', + 'token.createdAt', + 'token.updatedAt', + 'token.expiresAt', + 'token.clientId', + 'token.clientAuth', + 'token.deviceId', + 'token.did', + 'token.parameters', + 'token.details', + 'token.code', + 'token.currentRefreshToken', + 'device_account.authenticatedAt', + 'device_account.authorizedClients', + 'device_account.remember', + ]) + +export const create = async ( + db: AccountDb, + tokenId: TokenId, + data: TokenData, + refreshToken?: RefreshToken, +) => { + await db.db + .insertInto('token') + .values(toInsertable(tokenId, data, refreshToken)) + .execute() +} + +export const getForRefresh = async (db: AccountDb, id: TokenId) => { + return db.db + .selectFrom('token') + .where('tokenId', '=', id) + .where('currentRefreshToken', 'is not', null) + .select(['id', 'currentRefreshToken']) + .executeTakeFirstOrThrow() +} + +export const findBy = async ( + db: AccountDb, + search: { + tokenId?: TokenId + id?: number + currentRefreshToken?: RefreshToken + }, + audience: string, +): Promise => { + if ( + search.id === undefined && + search.tokenId === undefined && + search.currentRefreshToken === undefined + ) { + // Prevent accidental scan + throw new Error('At least one search parameter is required') + } + + const row = await selectTokenInfoQB(db) + .if(search.id !== undefined, (qb) => + // primary key + qb.where('token.id', '=', search.id!), + ) + .if(search.tokenId !== undefined, (qb) => + // uses "token_token_id_idx" + qb.where('token.tokenId', '=', search.tokenId!), + ) + .if(search.currentRefreshToken !== undefined, (qb) => + // uses "token_refresh_token_unique_idx" + qb.where('token.currentRefreshToken', '=', search.currentRefreshToken!), + ) + .executeTakeFirst() + + if (!row) return null + + return toTokenInfo(row, audience) +} + +export const rotate = async ( + db: AccountDb, + id: number, + newTokenId: TokenId, + newRefreshToken: RefreshToken, + newData: NewTokenData, +) => { + const { expiresAt, updatedAt, clientAuth, ...rest } = newData + + // Future proofing + if (Object.keys(rest).length > 0) throw new Error('Unexpected fields') + + await db.db + .updateTable('token') + .set({ + tokenId: newTokenId, + currentRefreshToken: newRefreshToken, + + expiresAt: toDateISO(expiresAt), + updatedAt: toDateISO(updatedAt), + clientAuth: toJsonObject(clientAuth), + }) + .where('id', '=', id) + .execute() +} + +export const remove = async ( + db: AccountDb, + tokenId: TokenId, +): Promise => { + const row = await db.db + .deleteFrom('token') + .where('tokenId', '=', tokenId) + .returning('id') + .executeTakeFirst() + + if (!row) return + + // TODO: can use use foreign key constraint to delete this row ? + await db.db + .deleteFrom('used_refresh_token') + .where('id', '=', row.id) + .execute() +} + +export const findByCode = async ( + db: AccountDb, + code: Code, + audience: string, +): Promise => { + const row = await selectTokenInfoQB(db) + .where('code', '=', code) + .where('code', 'is not', null) // uses "token_code_idx" + .executeTakeFirst() + + if (!row) return null + return toTokenInfo(row, audience) +} diff --git a/packages/pds/src/account-manager/helpers/used-refresh-token.ts b/packages/pds/src/account-manager/helpers/used-refresh-token.ts new file mode 100644 index 00000000000..2eb23465d5a --- /dev/null +++ b/packages/pds/src/account-manager/helpers/used-refresh-token.ts @@ -0,0 +1,24 @@ +import { RefreshToken } from '@atproto/oauth-provider' +import { AccountDb } from '../db' + +export const insert = async ( + db: AccountDb, + id: number, + usedRefreshToken: RefreshToken, +) => { + await db.executeWithRetry( + db.db + .insertInto('used_refresh_token') + .values({ id, usedRefreshToken }) + .onConflict((oc) => oc.doNothing()), + ) +} + +export const findByToken = (db: AccountDb, usedRefreshToken: RefreshToken) => { + return db.db + .selectFrom('used_refresh_token') + .where('usedRefreshToken', '=', usedRefreshToken) + .select('id') + .executeTakeFirst() + .then((row) => row?.id ?? null) +} diff --git a/packages/pds/src/account-manager/index.ts b/packages/pds/src/account-manager/index.ts index 89fb4ac00b1..009b8528d30 100644 --- a/packages/pds/src/account-manager/index.ts +++ b/packages/pds/src/account-manager/index.ts @@ -1,19 +1,50 @@ import { KeyObject } from 'node:crypto' -import { HOUR } from '@atproto/common' +import { HOUR, wait } from '@atproto/common' +import { + Account, + AccountInfo, + AccountStore, + Code, + DeviceId, + FoundRequestResult, + LoginCredentials, + NewTokenData, + RefreshToken, + RequestData, + RequestId, + RequestStore, + SessionData, + SessionStore, + TokenData, + TokenId, + TokenInfo, + TokenStore, + UpdateRequestData, +} from '@atproto/oauth-provider' +import { AuthRequiredError } from '@atproto/xrpc-server' import { CID } from 'multiformats/cid' + +import { AuthScope } from '../auth-verifier' +import { softDeleted } from '../db' +import { StatusAttr } from '../lexicon/types/com/atproto/admin/defs' import { AccountDb, EmailTokenPurpose, getDb, getMigrator } from './db' -import * as scrypt from './helpers/scrypt' import * as account from './helpers/account' import { ActorAccount } from './helpers/account' -import * as repo from './helpers/repo' import * as auth from './helpers/auth' +import * as authorizationRequest from './helpers/authorization-request.js' +import * as device from './helpers/device.js' +import * as deviceAccount from './helpers/device-account.js' +import * as emailToken from './helpers/email-token' import * as invite from './helpers/invite' import * as password from './helpers/password' -import * as emailToken from './helpers/email-token' -import { AuthScope } from '../auth-verifier' -import { StatusAttr } from '../lexicon/types/com/atproto/admin/defs' +import * as repo from './helpers/repo' +import * as scrypt from './helpers/scrypt' +import * as token from './helpers/token.js' +import * as usedRefreshToken from './helpers/used-refresh-token.js' -export class AccountManager { +export class AccountManager + implements AccountStore, RequestStore, SessionStore, TokenStore +{ db: AccountDb constructor( @@ -235,6 +266,59 @@ export class AccountManager { return auth.revokeRefreshToken(this.db, id) } + // Login + // ---------- + + async login( + { identifier, password }: { identifier: string; password: string }, + allowAppPassword = false, + ): Promise<{ + user: ActorAccount + appPasswordName: string | null + }> { + const start = Date.now() + try { + const identifierNormalized = identifier.toLowerCase() + const user = identifier.includes('@') + ? await this.getAccountByEmail(identifierNormalized, { + includeDeactivated: true, + includeTakenDown: true, + }) + : await this.getAccount(identifierNormalized, { + includeDeactivated: true, + includeTakenDown: true, + }) + + if (!user) throw new AuthRequiredError('Invalid identifier or password') + + let appPasswordName: string | null = null + const validAccountPass = await this.verifyAccountPassword( + user.did, + password, + ) + if (!validAccountPass) { + if (allowAppPassword) { + appPasswordName = await this.verifyAppPassword(user.did, password) + } + if (appPasswordName === null) { + throw new AuthRequiredError('Invalid identifier or password') + } + } + + if (softDeleted(user)) { + throw new AuthRequiredError( + 'Account has been taken down', + 'AccountTakedown', + ) + } + + return { user, appPasswordName } + } finally { + // Mitigate timing attacks + await wait(350 - (Date.now() - start)) + } + } + // Passwords // ---------- @@ -384,4 +468,171 @@ export class AccountManager { ]), ) } + + // AccountStore + + async authenticateAccount( + { username: identifier, password, remember = false }: LoginCredentials, + deviceId: DeviceId | null, + ): Promise { + try { + const { user } = await this.login({ identifier, password }, false) + + if (deviceId) { + await deviceAccount.createOrUpdate( + this.db, + deviceId, + user.did, + remember, + ) + } + + return deviceAccount.toAccount(user, this.serviceDid) + } catch (err) { + if (err instanceof AuthRequiredError) return null + throw err + } + } + + async addAuthorizedClient( + deviceId: DeviceId, + sub: string, + clientId: string, + ): Promise { + await this.db.transaction(async (dbTxn) => { + const authorizedClients = await deviceAccount.getAuthorizedClients( + dbTxn, + deviceId, + sub, + ) + + if (authorizedClients.includes(clientId)) return + + await deviceAccount.update(dbTxn, deviceId, sub, { + authorizedClients: [...authorizedClients, clientId], + }) + }) + } + + async getDeviceAccount( + deviceId: DeviceId, + sub: string, + ): Promise { + return deviceAccount.get(this.db, deviceId, sub, this.serviceDid) + } + + async listDeviceAccounts(deviceId: DeviceId): Promise { + return deviceAccount.listRemembered(this.db, deviceId, this.serviceDid) + } + + async removeDeviceAccount(deviceId: DeviceId, sub: string): Promise { + return deviceAccount.remove(this.db, deviceId, sub) + } + + // RequestStore + + async createRequest(id: RequestId, data: RequestData): Promise { + await authorizationRequest.create(this.db, id, data) + } + + async readRequest(id: RequestId): Promise { + // Take the opportunity to clean up expired requests. + // TODO: Do this less often? + await authorizationRequest.deleteExpired(this.db) + + return authorizationRequest.get(this.db, id) + } + + async updateRequest(id: RequestId, data: UpdateRequestData): Promise { + await authorizationRequest.update(this.db, id, data) + } + + async deleteRequest(id: RequestId): Promise { + await authorizationRequest.deleteById(this.db, id) + } + + async findRequestByCode(code: Code): Promise { + return authorizationRequest.findByCode(this.db, code) + } + + // SessionStore + + async createDeviceSession( + deviceId: DeviceId, + data: SessionData, + ): Promise { + await device.create(this.db, deviceId, data) + } + + async readDeviceSession(deviceId: DeviceId): Promise { + return device.getById(this.db, deviceId) + } + + async updateDeviceSession( + deviceId: DeviceId, + data: Partial, + ): Promise { + await device.update(this.db, deviceId, data) + } + + async deleteDeviceSession(deviceId: DeviceId): Promise { + await device.remove(this.db, deviceId) + + // TODO: can use use foreign key constraint to delete this row ? + await deviceAccount.removeByDevice(this.db, deviceId) + } + + // TokenStore + + async createToken( + id: TokenId, + data: TokenData, + refreshToken?: RefreshToken, + ): Promise { + await token.create(this.db, id, data, refreshToken) + } + + async readToken(tokenId: TokenId): Promise { + return token.findBy(this.db, { tokenId }, this.serviceDid) + } + + async deleteToken(tokenId: TokenId): Promise { + await token.remove(this.db, tokenId) + } + + async rotateToken( + tokenId: TokenId, + newTokenId: TokenId, + newRefreshToken: RefreshToken, + newData: NewTokenData, + ): Promise { + // No transaction because we want to make sure that the token is added + // to the used refresh tokens even if the rotate() fails. + + const { id, currentRefreshToken } = await token.getForRefresh( + this.db, + tokenId, + ) + + if (currentRefreshToken) { + await usedRefreshToken.insert(this.db, id, currentRefreshToken) + } + + await token.rotate(this.db, id, newTokenId, newRefreshToken, newData) + } + + async findTokenByRefreshToken( + refreshToken: RefreshToken, + ): Promise { + const id = await usedRefreshToken.findByToken(this.db, refreshToken) + return token.findBy( + this.db, + id ? { id } : { currentRefreshToken: refreshToken }, + this.serviceDid, + ) + } + + async findTokenByCode(code: Code): Promise { + return token.findByCode(this.db, code, this.serviceDid) + } } diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index c315f726f3a..3f75dcc407a 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -1,13 +1,14 @@ import { DAY, MINUTE } from '@atproto/common' import { INVALID_HANDLE } from '@atproto/syntax' -import { AuthRequiredError } from '@atproto/xrpc-server' + import AppContext from '../../../../context' -import { softDeleted } from '../../../../db/util' import { Server } from '../../../../lexicon' -import { didDocForSession } from './util' import { authPassthru, resultPassthru } from '../../../proxy' +import { didDocForSession } from './util' export default function (server: Server, ctx: AppContext) { + const { entrywayAgent } = ctx + server.com.atproto.server.createSession({ rateLimit: [ { @@ -21,72 +22,38 @@ export default function (server: Server, ctx: AppContext) { calcKey: ({ input, req }) => `${input.body.identifier}-${req.ip}`, }, ], - handler: async ({ input, req }) => { - if (ctx.entrywayAgent) { - return resultPassthru( - await ctx.entrywayAgent.com.atproto.server.createSession( - input.body, - authPassthru(req, true), - ), - ) - } - - const { password } = input.body - const identifier = input.body.identifier.toLowerCase() - - const user = identifier.includes('@') - ? await ctx.accountManager.getAccountByEmail(identifier, { - includeDeactivated: true, - includeTakenDown: true, - }) - : await ctx.accountManager.getAccount(identifier, { - includeDeactivated: true, - includeTakenDown: true, - }) - - if (!user) { - throw new AuthRequiredError('Invalid identifier or password') - } - - let appPasswordName: string | null = null - const validAccountPass = await ctx.accountManager.verifyAccountPassword( - user.did, - password, - ) - if (!validAccountPass) { - appPasswordName = await ctx.accountManager.verifyAppPassword( - user.did, - password, - ) - if (appPasswordName === null) { - throw new AuthRequiredError('Invalid identifier or password') + handler: entrywayAgent + ? async ({ input, req }) => { + return resultPassthru( + await entrywayAgent.com.atproto.server.createSession( + input.body, + authPassthru(req, true), + ), + ) } - } - - if (softDeleted(user)) { - throw new AuthRequiredError( - 'Account has been taken down', - 'AccountTakedown', - ) - } - - const [{ accessJwt, refreshJwt }, didDoc] = await Promise.all([ - ctx.accountManager.createSession(user.did, appPasswordName), - didDocForSession(ctx, user.did), - ]) - - return { - encoding: 'application/json', - body: { - did: user.did, - didDoc, - handle: user.handle ?? INVALID_HANDLE, - email: user.email ?? undefined, - emailConfirmed: !!user.emailConfirmedAt, - accessJwt, - refreshJwt, + : async ({ input }) => { + const { user, appPasswordName } = await ctx.accountManager.login( + input.body, + true, + ) + + const [{ accessJwt, refreshJwt }, didDoc] = await Promise.all([ + ctx.accountManager.createSession(user.did, appPasswordName), + didDocForSession(ctx, user.did), + ]) + + return { + encoding: 'application/json', + body: { + did: user.did, + didDoc, + handle: user.handle ?? INVALID_HANDLE, + email: user.email ?? undefined, + emailConfirmed: !!user.emailConfirmedAt, + accessJwt, + refreshJwt, + }, + } }, - } - }, }) } diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index 554898dbbaf..ad16a337a14 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -1,4 +1,5 @@ import { Headers } from '@atproto/xrpc' +import { InvalidRequestError } from '@atproto/xrpc-server' import { IncomingMessage } from 'node:http' export const resultPassthru = (result: { headers: Headers; data: T }) => { @@ -24,9 +25,24 @@ export function authPassthru( | undefined export function authPassthru(req: IncomingMessage, withEncoding?: boolean) { - if (req.headers.authorization) { + const { authorization } = req.headers + + if (authorization) { + // DPoP requests are bound to the endpoint being called. Allowing them to be + // proxied would require that the receiving end allows DPoP proof not + // created for him. Since proxying is mainly there to support legacy + // clients, and DPoP is a new feature, we don't support DPoP requests + // through the proxy. + + // This is fine since app views are usually called using the requester's + // credentials when "auth.credentials.type === 'access'", which is the only + // case were DPoP is used. + if (authorization.startsWith('DPoP ') || req.headers['dpop']) { + throw new InvalidRequestError('DPoP requests cannot be proxied') + } + return { - headers: { authorization: req.headers.authorization }, + headers: { authorization }, encoding: withEncoding ? 'application/json' : undefined, } } diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index b6c7ebbc2b2..5c6ebcdaf85 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -1,8 +1,10 @@ import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto' +import { OAuthError, OAuthVerifier } from '@atproto/oauth-provider' import { AuthRequiredError, ForbiddenError, InvalidRequestError, + XRPCError, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' import { IdResolver, getDidKeyFromMultibase } from '@atproto/identity' @@ -92,6 +94,8 @@ type ValidatedRefreshBearer = ValidatedBearer & { } export type AuthVerifierOpts = { + oauthVerifier: OAuthVerifier + publicUrl: string jwtKey: KeyObject adminPass: string dids: { @@ -102,6 +106,8 @@ export type AuthVerifierOpts = { } export class AuthVerifier { + private _oauthVerifier: OAuthVerifier + private _publicUrl: string private _jwtKey: KeyObject private _adminPass: string public dids: AuthVerifierOpts['dids'] @@ -111,6 +117,8 @@ export class AuthVerifier { public idResolver: IdResolver, opts: AuthVerifierOpts, ) { + this._oauthVerifier = opts.oauthVerifier + this._publicUrl = opts.publicUrl this._jwtKey = opts.jwtKey this._adminPass = opts.adminPass this.dids = opts.dids @@ -118,7 +126,8 @@ export class AuthVerifier { // verifiers (arrow fns to preserve scope) - access = (ctx: ReqCtx): Promise => { + access = async (ctx: ReqCtx): Promise => { + await this.setAuthHeaders(ctx) return this.validateAccessToken(ctx.req, [ AuthScope.Access, AuthScope.AppPass, @@ -126,6 +135,7 @@ export class AuthVerifier { } accessCheckTakedown = async (ctx: ReqCtx): Promise => { + await this.setAuthHeaders(ctx) const result = await this.validateAccessToken(ctx.req, [ AuthScope.Access, AuthScope.AppPass, @@ -146,11 +156,13 @@ export class AuthVerifier { return result } - accessNotAppPassword = (ctx: ReqCtx): Promise => { + accessNotAppPassword = async (ctx: ReqCtx): Promise => { + await this.setAuthHeaders(ctx) return this.validateAccessToken(ctx.req, [AuthScope.Access]) } - accessDeactived = (ctx: ReqCtx): Promise => { + accessDeactived = async (ctx: ReqCtx): Promise => { + await this.setAuthHeaders(ctx) return this.validateAccessToken(ctx.req, [ AuthScope.Access, AuthScope.AppPass, @@ -159,6 +171,7 @@ export class AuthVerifier { } refresh = async (ctx: ReqCtx): Promise => { + await this.setAuthHeaders(ctx) const { did, scope, token, tokenId, audience } = await this.validateRefreshToken(ctx.req) @@ -175,6 +188,7 @@ export class AuthVerifier { } refreshExpired = async (ctx: ReqCtx): Promise => { + await this.setAuthHeaders(ctx) const { did, scope, token, tokenId, audience } = await this.validateRefreshToken(ctx.req, { clockTolerance: Infinity }) @@ -190,7 +204,8 @@ export class AuthVerifier { } } - adminToken = (ctx: ReqCtx): AdminTokenOutput => { + adminToken = async (ctx: ReqCtx): Promise => { + await this.setAuthHeaders(ctx) const parsed = parseBasicAuth(ctx.req.headers.authorization || '') if (!parsed) { throw new AuthRequiredError() @@ -205,6 +220,7 @@ export class AuthVerifier { optionalAccessOrAdminToken = async ( ctx: ReqCtx, ): Promise => { + await this.setAuthHeaders(ctx) if (isAccessToken(ctx.req)) { return await this.access(ctx) } else if (isBasicToken(ctx.req)) { @@ -215,6 +231,7 @@ export class AuthVerifier { } userDidAuth = async (reqCtx: ReqCtx): Promise => { + await this.setAuthHeaders(reqCtx) const payload = await this.verifyServiceJwt(reqCtx, { aud: this.dids.entryway ?? this.dids.pds, iss: null, @@ -231,6 +248,7 @@ export class AuthVerifier { userDidAuthOptional = async ( reqCtx: ReqCtx, ): Promise => { + await this.setAuthHeaders(reqCtx) if (isBearerToken(reqCtx.req)) { return await this.userDidAuth(reqCtx) } else { @@ -239,6 +257,7 @@ export class AuthVerifier { } modService = async (reqCtx: ReqCtx): Promise => { + await this.setAuthHeaders(reqCtx) if (!this.dids.modService) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } @@ -267,6 +286,7 @@ export class AuthVerifier { moderator = async ( reqCtx: ReqCtx, ): Promise => { + await this.setAuthHeaders(reqCtx) if (isBearerToken(reqCtx.req)) { return this.modService(reqCtx) } else { @@ -303,7 +323,15 @@ export class AuthVerifier { throw new AuthRequiredError(undefined, 'AuthMissing') } - const { payload } = await this.jwtVerify(token, verifyOptions) + const { payload, protectedHeader } = await this.jwtVerify( + token, + verifyOptions, + ) + + if (protectedHeader.typ) { + // Only OAuth Provider sets this claim + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } const { sub, aud, scope } = payload if (typeof sub !== 'string' || !sub.startsWith('did:')) { @@ -315,7 +343,7 @@ export class AuthVerifier { ) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } - if (payload.cnf) { + if ((payload.cnf as any)?.jkt) { // DPoP bound tokens must not be usable as regular Bearer tokens throw new InvalidRequestError('Malformed token', 'InvalidToken') } @@ -339,6 +367,8 @@ export class AuthVerifier { switch (type) { case BEARER: return this.validateBearerAccessToken(req, scopes) + case DPOP: + return this.validateDpopAccessToken(req, scopes) case null: throw new AuthRequiredError(undefined, 'AuthMissing') default: @@ -349,6 +379,51 @@ export class AuthVerifier { } } + async validateDpopAccessToken( + req: express.Request, + scopes: AuthScope[], + ): Promise { + if (!scopes.includes(AuthScope.Access)) { + throw new InvalidRequestError( + 'DPoP access token cannot be used for this request', + 'InvalidToken', + ) + } + + try { + const url = new URL(req.originalUrl || req.url, this._publicUrl) + const result = await this._oauthVerifier.authenticateHttpRequest( + req.method, + url, + req.headers, + { audience: [this.dids.pds] }, + ) + + const { sub } = result.claims + if (typeof sub !== 'string' || !sub.startsWith('did:')) { + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } + + return { + credentials: { + type: 'access', + did: result.claims.sub, + scope: AuthScope.Access, + audience: this.dids.pds, + }, + artifacts: result.token, + } + } catch (err) { + // 'use_dpop_nonce' is expected to be in a particular format. Let's + // also transform any other OAuthError into an XRPCError. + if (err instanceof OAuthError) { + throw new XRPCError(err.status, err.error_description, err.error) + } + + throw err + } + } + async validateBearerAccessToken( req: express.Request, scopes: AuthScope[], @@ -441,6 +516,28 @@ export class AuthVerifier { ) } } + + protected async setAuthHeaders(ctx: ReqCtx) { + // Prevent caching (on proxies) of auth dependent responses + ctx.req.res?.setHeader('Cache-Control', 'private') + + // Make sure that browsers do not return cached responses when the auth header changes + ctx.req.res?.appendHeader('Vary', 'Authorization') + + /** + * Return next DPoP nonce in response headers for DPoP bound tokens. + * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-8.2} + */ + const authHeader = ctx.req.headers.authorization + if (authHeader?.startsWith('DPoP')) { + const dpopNonce = this._oauthVerifier.nextDpopNonce() + if (dpopNonce) { + const name = 'DPoP-Nonce' + ctx.req.res?.setHeader(name, dpopNonce) + ctx.req.res?.appendHeader('Access-Control-Expose-Headers', name) + } + } + } } // HELPERS @@ -448,6 +545,7 @@ export class AuthVerifier { const BASIC = 'Basic' const BEARER = 'Bearer' +const DPOP = 'DPoP' export const parseAuthorizationHeader = (authorization?: string) => { const result = authorization?.split(' ', 2) @@ -457,7 +555,7 @@ export const parseAuthorizationHeader = (authorization?: string) => { const isAccessToken = (req: express.Request): boolean => { const [type] = parseAuthorizationHeader(req.headers.authorization) - return type === BEARER + return type === BEARER || type === DPOP } const isBearerToken = (req: express.Request): boolean => { diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts new file mode 100644 index 00000000000..8e335580a73 --- /dev/null +++ b/packages/pds/src/auth.ts @@ -0,0 +1,15 @@ +import { combine } from '@atproto/http-util' + +import AppContext from './context' +import { oauthLogger } from './logger' + +export const createRouter = (ctx: AppContext) => { + return combine([ + ctx.oauthProvider?.httpHandler({ + // Log oauth provider errors using our own logger + onError: (req, res, err) => { + oauthLogger.error({ err }, 'oauth-provider error') + }, + }), + ]) +} diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 355b02cab7d..48aaf3d9b76 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -233,6 +233,32 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { const crawlersCfg: ServerConfig['crawlers'] = env.crawlers ?? [] + const oauthCfg: ServerConfig['oauth'] = entrywayCfg + ? { + enableProvider: false, + issuer: entrywayCfg.url, + } + : { + enableProvider: true, + issuer: serviceCfg.publicUrl, + } + + const safeFetchCfg: ServerConfig['safeFetch'] = { + allowHttp: false, + forbiddenDomainNames: [ + 'example.com', + 'example.org', + 'example.net', + 'bsky.social', + 'bsky.network', + 'googleusercontent.com', + ], + responseMaxSize: env.fetchDisableSafeties + ? Infinity + : (env.fetchResponseMaxSizeKb ?? 512) * 1024, // defaults to 512kB + ssrfProtection: env.fetchDisableSafeties ? false : true, + } + return { service: serviceCfg, db: dbCfg, @@ -250,6 +276,8 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { redis: redisCfg, rateLimits: rateLimitsCfg, crawlers: crawlersCfg, + oauth: oauthCfg, + safeFetch: safeFetchCfg, } } @@ -270,6 +298,8 @@ export type ServerConfig = { redis: RedisScratchConfig | null rateLimits: RateLimitsConfig crawlers: string[] + oauth: OAuthConfig + safeFetch: SafeFetchConfig } export type ServiceConfig = { @@ -335,6 +365,18 @@ export type EntrywayConfig = { plcRotationKey: string } +export type OAuthConfig = { + issuer: string + enableProvider: boolean +} + +export type SafeFetchConfig = { + allowHttp: boolean + ssrfProtection: boolean + responseMaxSize: number + forbiddenDomainNames: string[] +} + export type InvitesConfig = | { required: true diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index f7b9849a1a9..ed907402dcc 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -96,6 +96,7 @@ export const readEnv = (): ServerEnvironment => { crawlers: envList('PDS_CRAWLERS'), // secrets + dpopSecret: envStr('PDS_DPOP_SECRET'), jwtSecret: envStr('PDS_JWT_SECRET'), adminPassword: envStr('PDS_ADMIN_PASSWORD'), @@ -105,6 +106,10 @@ export const readEnv = (): ServerEnvironment => { plcRotationKeyK256PrivateKeyHex: envStr( 'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX', ), + + // oauth + fetchDisableSafeties: envBool('PDS_FETCH_DISABLE_SAFETIES'), + fetchResponseMaxSizeKb: envInt('PDS_FETCH_RESPONSE_MAX_SIZE_KB'), } } @@ -201,10 +206,15 @@ export type ServerEnvironment = { crawlers?: string[] // secrets + dpopSecret?: string jwtSecret?: string adminPassword?: string // keys plcRotationKeyKmsKeyId?: string plcRotationKeyK256PrivateKeyHex?: string + + // fetch + fetchDisableSafeties?: boolean + fetchResponseMaxSizeKb?: number } diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index eb0f33d182c..1d165a7c89c 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -18,6 +18,10 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { throw new Error('Must configure plc rotation key') } + if (!env.dpopSecret) { + throw new Error('Must provide a DPoP secret') + } + if (!env.jwtSecret) { throw new Error('Must provide a JWT secret') } @@ -27,6 +31,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { } return { + dpopSecret: env.dpopSecret, jwtSecret: env.jwtSecret, adminPassword: env.adminPassword, plcRotationKey, @@ -34,6 +39,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { } export type ServerSecrets = { + dpopSecret: string jwtSecret: string adminPassword: string plcRotationKey: SigningKeyKms | SigningKeyMemory diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 7da2ac980ea..56d81a64e43 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -1,12 +1,27 @@ import assert from 'node:assert' + import * as nodemailer from 'nodemailer' import { Redis } from 'ioredis' import * as plc from '@did-plc/lib' import * as crypto from '@atproto/crypto' +import { Fetch } from '@atproto/fetch' +import { safeFetchWrap } from '@atproto/fetch-node' import { IdResolver } from '@atproto/identity' import { AtpAgent } from '@atproto/api' import { KmsKeypair, S3BlobStore } from '@atproto/aws' +import { NodeKeyset } from '@atproto/jwk-node' import { createServiceAuthHeaders } from '@atproto/xrpc-server' +import { BlobStore } from '@atproto/repo' +import { + AccessTokenType, + DpopNonce, + OAuthVerifier, + OAuthProvider, + ReplayStore, +} from '@atproto/oauth-provider' +import { OAuthReplayStoreRedis } from '@atproto/oauth-provider-replay-redis' +import { OAuthReplayStoreMemory } from '@atproto/oauth-provider-replay-memory' + import { ServerConfig, ServerSecrets } from './config' import { AuthVerifier, @@ -15,7 +30,6 @@ import { } from './auth-verifier' import { ServerMailer } from './mailer' import { ModerationMailer } from './mailer/moderation' -import { BlobStore } from '@atproto/repo' import { AccountManager } from './account-manager' import { Sequencer } from './sequencer' import { BackgroundQueue } from './background' @@ -25,11 +39,14 @@ import { DiskBlobStore } from './disk-blobstore' import { getRedisClient } from './redis' import { ActorStore } from './actor-store' import { LocalViewer, LocalViewerCreator } from './read-after-write/viewer' +import { OauthClientStore } from './oauth/oauth-client-store' +import { fetchLogger } from './logger' export type AppContextOptions = { actorStore: ActorStore blobstore: (did: string) => BlobStore localViewer: LocalViewerCreator + safeFetch: Fetch mailer: ServerMailer moderationMailer: ModerationMailer didCache: DidSqliteCache @@ -44,6 +61,7 @@ export type AppContextOptions = { moderationAgent?: AtpAgent reportingAgent?: AtpAgent entrywayAgent?: AtpAgent + oauthProvider?: OAuthProvider authVerifier: AuthVerifier plcRotationKey: crypto.Keypair cfg: ServerConfig @@ -68,6 +86,7 @@ export class AppContext { public reportingAgent: AtpAgent | undefined public entrywayAgent: AtpAgent | undefined public authVerifier: AuthVerifier + public oauthProvider?: OAuthProvider public plcRotationKey: crypto.Keypair public cfg: ServerConfig @@ -90,6 +109,7 @@ export class AppContext { this.reportingAgent = opts.reportingAgent this.entrywayAgent = opts.entrywayAgent this.authVerifier = opts.authVerifier + this.oauthProvider = opts.oauthProvider this.plcRotationKey = opts.plcRotationKey this.cfg = opts.cfg } @@ -185,8 +205,81 @@ export class AppContext { ? createPublicKeyObject(cfg.entryway.jwtPublicKeyHex) : jwtSecretKey + const keyset = await NodeKeyset.fromImportables({ + // @TODO: load keys from config + ['kid-1']: + '-----BEGIN PRIVATE KEY-----\n' + + 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4D4H8/CFAVuKMgQD\n' + + 'BIK9m53AEUrCxQKrgtMNSTNV9A2hRANCAARAwyllCZOflLEQM0MaYujz7ITxqczZ\n' + + '6Vxhj4urrdXUN3MEliQcc14ImTWHt7h7+xbxIXETLj0kTzctAxSbtwZf\n' + + '-----END PRIVATE KEY-----\n', + }) + + // OAuthVerifier is capable of generating its own dpop nonce secret. Using a + // pre-generated nonce is particularly useful to avoid invalid_nonce errors + // when the server restarts or when more tha one instance are running. This + // can also reduce the number of invalid_nonce errors when the PDS and + // entryway are using the same dpop nonce secret. + const dpopNonce = new DpopNonce(Buffer.from(secrets.dpopSecret, 'hex')) + + const replayStore: ReplayStore = redisScratch + ? new OAuthReplayStoreRedis(redisScratch) + : new OAuthReplayStoreMemory() + + // A Fetch function that protects against SSRF attacks, large responses & + // known bad domains. This function can safely be used to fetch user + // provided URLs. + const safeFetch: Fetch = safeFetchWrap({ + ...cfg.safeFetch, + fetch: async (request) => { + fetchLogger.info({ method: request.method, uri: request.url }, 'fetch') + return globalThis.fetch(request) + }, + }) + + const oauthProvider = cfg.oauth.enableProvider + ? new OAuthProvider({ + issuer: cfg.oauth.issuer, + keyset, + dpopNonce, + + accountStore: accountManager, + requestStore: accountManager, + sessionStore: accountManager, + tokenStore: accountManager, + replayStore, + clientStore: new OauthClientStore({ fetch: safeFetch }), + + // If the PDS is both an authorization server & resource server (no + // entryway), there is no need to use JWTs as access tokens. Instead, + // the PDS can use tokenId as access tokens. This allows the PDS to + // always use up-to-date token data from the token store. + accessTokenType: AccessTokenType.id, + + onTokenResponse: (tokenResponse, { account }) => { + // ATPROTO extension: add the sub claim to the token response to allow + // clients to resolve the PDS url (audience) using the did resolution + // mechanism. + tokenResponse['sub'] = account.sub + }, + }) + : undefined + const authVerifier = new AuthVerifier(accountManager, idResolver, { - jwtKey, // @TODO support multiple keys? + publicUrl: cfg.service.publicUrl, + oauthVerifier: + // Using the oauthProvider as oauthVerifier allows clients to use the + // same nonce when authenticating and making requests, avoiding + // un-necessary "invalid_nonce" errors. It also allows the use of + // AccessTokenType.id as access token type. + oauthProvider ?? + new OAuthVerifier({ + issuer: cfg.oauth.issuer, + keyset, + dpopNonce, + replayStore, + }), + jwtKey, adminPass: secrets.adminPassword, dids: { pds: cfg.service.did, @@ -221,6 +314,7 @@ export class AppContext { actorStore, blobstore, localViewer, + safeFetch, mailer, moderationMailer, didCache, @@ -236,6 +330,7 @@ export class AppContext { reportingAgent, entrywayAgent, authVerifier, + oauthProvider, plcRotationKey, cfg, ...(overrides ?? {}), diff --git a/packages/pds/src/db/cast.ts b/packages/pds/src/db/cast.ts new file mode 100644 index 00000000000..d9ff0f26b4a --- /dev/null +++ b/packages/pds/src/db/cast.ts @@ -0,0 +1,43 @@ +export type DateISO = `${string}T${string}Z` +export const toDateISO = (date: Date): DateISO => date.toISOString() as DateISO +export const fromDateISO = (date: DateISO): Date => new Date(date) + +export type Json = string +export const toJson = (obj: unknown): Json => { + const json = JSON.stringify(obj) + if (typeof json !== 'string') { + throw new TypeError('Invalid JSON') + } + return json as Json +} +export const fromJson = (json: Json): T => { + return JSON.parse(json) as T +} + +export type JsonArray = `[${string}]` +export const toJsonArray = (obj: readonly unknown[]): JsonArray => { + const json = toJson(obj) + if (!json.startsWith('[') || !json.endsWith(']')) { + throw new TypeError('Not a JSON Array') + } + return json as JsonArray +} +export const fromJsonArray = (json: JsonArray): T => { + return fromJson(json) as T +} + +export type JsonObject = `{${string}}` +export const toJsonObject = ( + obj: Readonly>, +): JsonObject => { + const json = toJson(obj) + if (!json.startsWith('{') || !json.endsWith('}')) { + throw new TypeError('Not a JSON Object') + } + return json as JsonObject +} +export const fromJsonObject = >( + json: JsonObject, +): T => { + return fromJson(json) as T +} diff --git a/packages/pds/src/db/index.ts b/packages/pds/src/db/index.ts index 2ccf49b90c7..23a50b0210b 100644 --- a/packages/pds/src/db/index.ts +++ b/packages/pds/src/db/index.ts @@ -1,3 +1,4 @@ export * from './db' +export * from './cast.js' export * from './migrator' export * from './util' diff --git a/packages/pds/src/error.ts b/packages/pds/src/error.ts index a4de90f580e..b1033b26b3c 100644 --- a/packages/pds/src/error.ts +++ b/packages/pds/src/error.ts @@ -1,12 +1,19 @@ import { XRPCError } from '@atproto/xrpc-server' import { ErrorRequestHandler } from 'express' import { httpLogger as log } from './logger' +import { OAuthError } from '@atproto/oauth-provider' export const handler: ErrorRequestHandler = (err, _req, res, next) => { log.error(err, 'unexpected internal server error') if (res.headersSent) { return next(err) } + + if (err instanceof OAuthError) { + res.status(err.status).json(err.toJSON()) + return + } + const serverError = XRPCError.fromError(err) res.status(serverError.type).json(serverError.payload) } diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 3471f288070..857602227d0 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -16,6 +16,7 @@ import { } from '@atproto/xrpc-server' import { DAY, HOUR, MINUTE } from '@atproto/common' import API from './api' +import * as auth from './auth' import * as basicRoutes from './basic-routes' import * as wellKnown from './well-known' import * as error from './error' @@ -122,6 +123,7 @@ export class PDS { server = API(server, ctx) + app.use(auth.createRouter(ctx)) app.use(basicRoutes.createRouter(ctx)) app.use(wellKnown.createRouter(ctx)) app.use(server.xrpc.router) diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index 737e05a5e67..c48ea9fa657 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -13,6 +13,8 @@ export const mailerLogger = subsystemLogger('pds:mailer') export const labelerLogger = subsystemLogger('pds:labeler') export const crawlerLogger = subsystemLogger('pds:crawler') export const httpLogger = subsystemLogger('pds') +export const fetchLogger = subsystemLogger('pds:fetch') +export const oauthLogger = subsystemLogger('pds:oauth') export const loggerMiddleware = pinoHttp({ logger: httpLogger, diff --git a/packages/pds/src/oauth/oauth-client-store.ts b/packages/pds/src/oauth/oauth-client-store.ts new file mode 100644 index 00000000000..ef6b73e6a7e --- /dev/null +++ b/packages/pds/src/oauth/oauth-client-store.ts @@ -0,0 +1,125 @@ +import { + ClientId, + ClientMetadata, + ClientStore, + InvalidClientMetadataError, + InvalidRedirectUriError, + parseRedirectUri, +} from '@atproto/oauth-provider' +import { + OAuthClientUriStore, + OAuthClientUriStoreConfig, +} from '@atproto/oauth-provider-client-uri' +import { isAbsolute, relative } from 'node:path' + +export class OauthClientStore + extends OAuthClientUriStore + implements ClientStore +{ + constructor({ fetch }: Pick) { + super({ + fetch, + loopbackMetadata, + validateMetadata, + }) + } +} + +/** + * Allow "loopback" clients using the following client metadata (as defined in + * the ATPROTO spec). + */ +function loopbackMetadata({ href }: URL): Partial { + return { + client_name: 'Loopback ATPROTO client', + client_uri: href, + response_types: ['code', 'code id_token'], + grant_types: ['authorization_code', 'refresh_token'], + scope: 'profile offline_access', + redirect_uris: ['127.0.0.1', '[::1]'].map( + (ip) => Object.assign(new URL(href), { hostname: ip }).href, + ) as [string, string], + token_endpoint_auth_method: 'none', + application_type: 'native', + dpop_bound_access_tokens: true, + } +} + +/** + * Make sure that fetched metadata are spec compliant + */ +function validateMetadata( + clientId: ClientId, + clientUrl: URL, + metadata: ClientMetadata, +) { + // ATPROTO spec requires the use of DPoP (default is false) + if (metadata.dpop_bound_access_tokens !== true) { + throw new InvalidClientMetadataError( + '"dpop_bound_access_tokens" must be true', + ) + } + + // ATPROTO spec requires the use of PKCE + if (metadata.response_types.some((rt) => rt.split(' ').includes('token'))) { + throw new InvalidClientMetadataError( + '"token" response type is not compatible with PKCE (use "code" instead)', + ) + } + + for (const redirectUri of metadata.redirect_uris) { + const uri = parseRedirectUri(redirectUri) + + switch (true) { + case uri.protocol === 'http:': + // Only loopback redirect URIs are allowed to use HTTP + switch (uri.hostname) { + // ATPROTO spec requires that the IP is used in case of loopback redirect URIs + case '127.0.0.1': + case '[::1]': + continue + + // ATPROTO spec forbids use of localhost as redirect URI hostname + case 'localhost': + throw new InvalidRedirectUriError( + `Loopback redirect URI ${uri} is not allowed (use explicit IPs instead)`, + ) + } + + // ATPROTO spec forbids http redirects (except for loopback, covered before) + throw new InvalidRedirectUriError(`Redirect URI ${uri} must use HTTPS`) + + // ATPROTO spec requires that the redirect URI is a sub-url of the client URL + case uri.protocol === 'https:': + if (!isSubUrl(clientUrl, uri)) { + throw new InvalidRedirectUriError( + `Redirect URI ${uri} must be a sub-url of ${clientUrl}`, + ) + } + continue + + // Custom URI schemes are allowed by ATPROTO, following the rules + // defined in the spec & current best practices. These are already + // enforced by the @atproto/oauth-provider & + // @atproto/oauth-provider-client-uri packages. + default: + continue + } + } +} + +function isSubUrl(reference: URL, url: URL): boolean { + if (url.origin !== reference.origin) return false + if (url.username !== reference.username) return false + if (url.password !== reference.password) return false + + return ( + reference.pathname === url.pathname || + isSubPath(reference.pathname, url.pathname) + ) +} + +function isSubPath(reference: string, path: string): boolean { + const rel = relative(reference, path) + return !rel.startsWith('..') && !isAbsolute(rel) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d419098f76b..72bb8072807 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -923,12 +923,42 @@ importers: '@atproto/crypto': specifier: workspace:^ version: link:../crypto + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/fetch-node': + specifier: workspace:* + version: link:../fetch-node + '@atproto/http-util': + specifier: workspace:* + version: link:../http-util '@atproto/identity': specifier: workspace:^ version: link:../identity + '@atproto/jwk': + specifier: workspace:^ + version: link:../jwk + '@atproto/jwk-node': + specifier: workspace:^ + version: link:../jwk-node '@atproto/lexicon': specifier: workspace:^ version: link:../lexicon + '@atproto/oauth-provider': + specifier: workspace:^ + version: link:../oauth-provider + '@atproto/oauth-provider-client-fqdn': + specifier: workspace:^ + version: link:../oauth-provider-client-fqdn + '@atproto/oauth-provider-client-uri': + specifier: workspace:^ + version: link:../oauth-provider-client-uri + '@atproto/oauth-provider-replay-memory': + specifier: workspace:^ + version: link:../oauth-provider-replay-memory + '@atproto/oauth-provider-replay-redis': + specifier: workspace:^ + version: link:../oauth-provider-replay-redis '@atproto/repo': specifier: workspace:^ version: link:../repo From 9368180fe1497944b3195b632d78133bbb0d0e5b Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Tue, 5 Mar 2024 09:39:30 +0100 Subject: [PATCH 006/140] feat(pds): split tracer code in own file --- services/pds/Dockerfile | 2 +- services/pds/index.js | 41 ----------------------------------------- services/pds/tracer.js | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 42 deletions(-) create mode 100644 services/pds/tracer.js diff --git a/services/pds/Dockerfile b/services/pds/Dockerfile index e2d8053f294..54a352bd841 100644 --- a/services/pds/Dockerfile +++ b/services/pds/Dockerfile @@ -56,7 +56,7 @@ ENV UV_USE_IO_URING=0 # https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user USER node -CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "index.js"] +CMD ["node", "--heapsnapshot-signal=SIGUSR2", "--enable-source-maps", "--require=./tracer.js", "index.js"] LABEL org.opencontainers.image.source=https://github.com/bluesky-social/atproto LABEL org.opencontainers.image.description="ATP Personal Data Server (PDS)" diff --git a/services/pds/index.js b/services/pds/index.js index 214350bcdc8..9022216d741 100644 --- a/services/pds/index.js +++ b/services/pds/index.js @@ -2,32 +2,6 @@ 'use strict' -const { registerInstrumentations } = require('@opentelemetry/instrumentation') - -const { - BetterSqlite3Instrumentation, -} = require('opentelemetry-plugin-better-sqlite3') - -const { TracerProvider } = require('dd-trace') // Only works with commonjs - .init({ logInjection: true }) - .use('express', { - hooks: { - request: (span, req) => { - maintainXrpcResource(span, req) - }, - }, - }) - -const tracer = new TracerProvider() -tracer.register() - -registerInstrumentations({ - tracerProvider: tracer, - instrumentations: [new BetterSqlite3Instrumentation()], -}) - -// Tracer code above must come before anything else -const path = require('path') const { PDS, envToCfg, @@ -55,19 +29,4 @@ const main = async () => { }) } -const maintainXrpcResource = (span, req) => { - // Show actual xrpc method as resource rather than the route pattern - if (span && req.originalUrl?.startsWith('/xrpc/')) { - span.setTag( - 'resource.name', - [ - req.method, - path.posix.join(req.baseUrl || '', req.path || '', '/').slice(0, -1), // Ensures no trailing slash - ] - .filter(Boolean) - .join(' '), - ) - } -} - main() diff --git a/services/pds/tracer.js b/services/pds/tracer.js new file mode 100644 index 00000000000..67e9cce5f87 --- /dev/null +++ b/services/pds/tracer.js @@ -0,0 +1,40 @@ +/* eslint-env node */ + +'use strict' + +const { registerInstrumentations } = require('@opentelemetry/instrumentation') + +const { + BetterSqlite3Instrumentation, +} = require('opentelemetry-plugin-better-sqlite3') + +const { TracerProvider } = require('dd-trace') // Only works with commonjs + .init({ logInjection: true }) + .use('express', { + hooks: { request: maintainXrpcResource }, + }) + +const tracer = new TracerProvider() +tracer.register() + +registerInstrumentations({ + tracerProvider: tracer, + instrumentations: [new BetterSqlite3Instrumentation()], +}) + +const path = require('path') + +function maintainXrpcResource(span, req) { + // Show actual xrpc method as resource rather than the route pattern + if (span && req.originalUrl?.startsWith('/xrpc/')) { + span.setTag( + 'resource.name', + [ + req.method, + path.posix.join(req.baseUrl || '', req.path || '', '/').slice(0, -1), // Ensures no trailing slash + ] + .filter(Boolean) + .join(' '), + ) + } +} From 8db0d74ed20372f97054d082467417be72b54071 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Tue, 5 Mar 2024 09:39:52 +0100 Subject: [PATCH 007/140] chore(dev): add dev script --- pnpm-lock.yaml | 9 +++++++++ services/pds/package.json | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72bb8072807..daf3cbd8a23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1292,6 +1292,10 @@ importers: opentelemetry-plugin-better-sqlite3: specifier: ^1.1.0 version: 1.1.0(better-sqlite3@9.4.5) + devDependencies: + dotenv: + specifier: ^16.4.5 + version: 16.4.5 packages: @@ -7330,6 +7334,11 @@ packages: resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} engines: {node: '>=12'} + /dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + dev: true + /dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} diff --git a/services/pds/package.json b/services/pds/package.json index 398b2038047..efd42d65322 100644 --- a/services/pds/package.json +++ b/services/pds/package.json @@ -6,5 +6,12 @@ "@opentelemetry/instrumentation": "^0.45.0", "dd-trace": "^4.18.0", "opentelemetry-plugin-better-sqlite3": "^1.1.0" + }, + "devDependencies": { + "dotenv": "^16.4.5" + }, + "scripts": { + "start": "node --enable-source-maps --heapsnapshot-signal=SIGUSR2 --require=./tracer.js index.js", + "dev": "node --enable-source-maps --require=dotenv/config --watch index.js" } } From fb6fed694dab78166cb6eea4bdfeef677680b455 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Tue, 5 Mar 2024 09:55:23 +0100 Subject: [PATCH 008/140] dps: update example .env file --- packages/pds/example.env | 11 +++++++++-- services/pds/.gitignore | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/pds/example.env b/packages/pds/example.env index d314e170af7..b4fbcc1fe27 100644 --- a/packages/pds/example.env +++ b/packages/pds/example.env @@ -1,10 +1,10 @@ # See more env options in src/config/env.ts # Hostname - the public domain that you intend to deploy your service at PDS_HOSTNAME="example.com" +PDS_PORT="2583" # Database config - use one or the other -PDS_DB_SQLITE_LOCATION="db.test" -# PDS_DB_POSTGRES_URL="postgresql://pg:password@localhost:5433/postgres" +PDS_DATA_DIRECTORY="data" # Blobstore - filesystem location to store uploaded blobs PDS_BLOBSTORE_DISK_LOCATION="blobs" @@ -23,3 +23,10 @@ PDS_DID_PLC_URL="https://plc.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_ENDPOINT="https://api.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_DID="did:web:api.bsky-sandbox.dev" PDS_CRAWLERS="https://bgs.bsky-sandbox.dev" + +# Debugging +NODE_TLS_REJECT_UNAUTHORIZED=1 +LOG_ENABLED=0 +LOG_LEVEL=info +PDS_INVITE_REQUIRED=1 +PDS_FETCH_DISABLE_SAFETIES=0 diff --git a/services/pds/.gitignore b/services/pds/.gitignore index 9b19b93c9f1..60baa9cb833 100644 --- a/services/pds/.gitignore +++ b/services/pds/.gitignore @@ -1 +1 @@ -*.sqlite* +data/* From 9c56ed490cac6e1b9b671f55f25c9092bdbd98b6 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 7 Mar 2024 12:10:36 +0100 Subject: [PATCH 009/140] build: disable allowImportingTsExtensions --- tsconfig/bundler.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tsconfig/bundler.json b/tsconfig/bundler.json index 7caa5849002..6aea841facb 100644 --- a/tsconfig/bundler.json +++ b/tsconfig/bundler.json @@ -1,7 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "allowImportingTsExtensions": true, "module": "ESNext", "moduleResolution": "Bundler", "declaration": false, From fb84144e9d5efc13afab528a3dd07f930a5e4023 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 7 Mar 2024 16:47:00 +0100 Subject: [PATCH 010/140] feat(oauth-provider): render login errors using HTML feat(oauth-provider): pave the way for branding the UI --- packages/html/src/encode.ts | 12 ++ packages/html/src/html.ts | 57 ++++++++ packages/html/src/index.ts | 79 +++++----- packages/html/src/util.ts | 30 ++++ packages/oauth-provider/package.json | 2 + packages/oauth-provider/rollup.config.js | 65 +++++---- .../oauth-provider/src/app/backend-data.ts | 16 -- .../oauth-provider/src/app/csrf-cookie.ts | 13 -- packages/oauth-provider/src/app/main.tsx | 16 -- .../oauth-provider/src/assets/app/app.tsx | 24 +++ .../src/assets/app/backend-data.ts | 21 +++ .../app/components/account-list.tsx | 0 .../app/components/authorize.tsx} | 24 +-- .../src/assets/app/components/error.tsx | 33 +++++ .../app/components/grant-accept.tsx} | 24 +-- .../{ => assets}/app/components/layout.tsx | 0 .../app/components/login-form.tsx | 0 .../app/components/session-selector.tsx | 0 .../oauth-provider/src/assets/app/cookies.ts | 9 ++ .../src/{ => assets}/app/main.css | 0 .../oauth-provider/src/assets/app/main.tsx | 30 ++++ .../src/{ => assets}/app/types.ts | 0 packages/oauth-provider/src/assets/asset.ts | 8 + .../src/assets/assets-middleware.ts | 32 ++++ packages/oauth-provider/src/assets/index.ts | 97 ++++-------- packages/oauth-provider/src/oauth-provider.ts | 138 ++++++++++-------- .../src/output/send-authorize-page.ts | 109 ++------------ .../src/output/send-error-page.ts | 64 ++------ .../oauth-provider/src/output/send-web-app.ts | 109 ++++++++++++++ packages/oauth-provider/tailwind.config.js | 2 +- packages/oauth-provider/tsconfig.backend.json | 2 +- .../oauth-provider/tsconfig.frontend.json | 6 +- pnpm-lock.yaml | 66 +++++++++ 33 files changed, 674 insertions(+), 414 deletions(-) create mode 100644 packages/html/src/encode.ts create mode 100644 packages/html/src/html.ts create mode 100644 packages/html/src/util.ts delete mode 100644 packages/oauth-provider/src/app/backend-data.ts delete mode 100644 packages/oauth-provider/src/app/csrf-cookie.ts delete mode 100644 packages/oauth-provider/src/app/main.tsx create mode 100644 packages/oauth-provider/src/assets/app/app.tsx create mode 100644 packages/oauth-provider/src/assets/app/backend-data.ts rename packages/oauth-provider/src/{ => assets}/app/components/account-list.tsx (100%) rename packages/oauth-provider/src/{app/app.tsx => assets/app/components/authorize.tsx} (88%) create mode 100644 packages/oauth-provider/src/assets/app/components/error.tsx rename packages/oauth-provider/src/{app/components/authorize.tsx => assets/app/components/grant-accept.tsx} (79%) rename packages/oauth-provider/src/{ => assets}/app/components/layout.tsx (100%) rename packages/oauth-provider/src/{ => assets}/app/components/login-form.tsx (100%) rename packages/oauth-provider/src/{ => assets}/app/components/session-selector.tsx (100%) create mode 100644 packages/oauth-provider/src/assets/app/cookies.ts rename packages/oauth-provider/src/{ => assets}/app/main.css (100%) create mode 100644 packages/oauth-provider/src/assets/app/main.tsx rename packages/oauth-provider/src/{ => assets}/app/types.ts (100%) create mode 100644 packages/oauth-provider/src/assets/asset.ts create mode 100644 packages/oauth-provider/src/assets/assets-middleware.ts create mode 100644 packages/oauth-provider/src/output/send-web-app.ts diff --git a/packages/html/src/encode.ts b/packages/html/src/encode.ts new file mode 100644 index 00000000000..a18507d2d74 --- /dev/null +++ b/packages/html/src/encode.ts @@ -0,0 +1,12 @@ +const specialCharRegExp = /[<>"'&]/g +const specialCharMap = new Map([ + ['<', '<'], + ['>', '>'], + ['"', '"'], + ["'", '''], + ['&', '&'], +]) +const specialCharMapGet = (c: string) => specialCharMap.get(c)! +export function encode(value: string): string { + return value.replace(specialCharRegExp, specialCharMapGet) +} diff --git a/packages/html/src/html.ts b/packages/html/src/html.ts new file mode 100644 index 00000000000..3d06599607e --- /dev/null +++ b/packages/html/src/html.ts @@ -0,0 +1,57 @@ +const symbol = Symbol('Html.dangerouslyCreate') + +/** + * This class represents trusted HTML that can be safely embedded in a web page, + * or used as fragments to build a larger HTML document. + */ +export class Html { + #fragments: Iterable + + private constructor(fragments: Iterable, guard: symbol) { + if (guard !== symbol) { + // Force developers to use `Html.dangerouslyCreate` to create an Html + // instance, to make it clear that the content needs to be trusted. + throw new TypeError( + 'Use Html.dangerouslyCreate to create an Html instance', + ) + } + + this.#fragments = fragments + } + + /** + * Returns the HTML fragments as an array of strings. If the fragments are + * not already an array, they are lazily consumed into an array. + */ + get fragments(): readonly string[] { + if (!Array.isArray(this.#fragments)) { + const array = Array.from(this.#fragments) + this.#fragments = array + return array + } + + return this.#fragments + } + + toString(): string { + const { fragments } = this + + // Lazily join the fragments when they are used, to avoid unnecessary + // intermediate strings when concatenating multiple Html as fragments. + if (fragments.length > 1) { + const string = fragments.join('') + this.#fragments = [string] + return string + } + + return fragments.join('') + } + + toBuffer(): Buffer { + return Buffer.from(this.toString(), 'utf8') + } + + static dangerouslyCreate(fragments: Iterable): Html { + return new Html(fragments, symbol) + } +} diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 69bffb8341c..ace40de0d49 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -1,39 +1,52 @@ +import { encode } from './encode.js' +import { Html } from './html.js' +import { javascriptEscaper, jsonEscaper } from './util.js' + type NestedArray = V | readonly NestedArray[] +export { Html } + +/** + * Escapes code to use as a JavaScript string inside a `" can only appear in javascript strings, so we can safely escape - // the "<" without breaking the javascript. - new TrustedHtml([fragment.replace(/<\/script>/g, '\\u003c/script>')]) - -/** - * @see {@link https://redux.js.org/usage/server-rendering#security-considerations} - */ -html.jsonForScriptTag = (value: unknown) => - new TrustedHtml([JSON.stringify(value).replace(/, + value: NestedArray, ): Generator { if (typeof value === 'string') { yield encode(value) - } else if (value instanceof TrustedHtml) { + } else if (value instanceof Html) { yield* value.fragments } else { for (const v of value) { @@ -41,25 +54,3 @@ function* valueToFragment( } } } - -const specialCharRegExp = /[<>"'&]/g -const specialCharMap = new Map([ - ['<', '<'], - ['>', '>'], - ['"', '"'], - ["'", '''], - ['&', '&'], -]) -function encode(value: string): string { - return value.replace(specialCharRegExp, (c) => specialCharMap.get(c)!) -} - -class TrustedHtml { - constructor(readonly fragments: readonly string[]) {} - toString() { - return this.fragments.join('') - } - toBuffer() { - return Buffer.concat(this.fragments.map((f) => Buffer.from(f))) - } -} diff --git a/packages/html/src/util.ts b/packages/html/src/util.ts new file mode 100644 index 00000000000..3815362f11d --- /dev/null +++ b/packages/html/src/util.ts @@ -0,0 +1,30 @@ +export function* stringReplacer( + source: string, + searchValue: string, + replaceValue: string, +) { + let previousIndex = 0 + let index = source.indexOf(searchValue) + while (index !== -1) { + yield source.slice(previousIndex, index) + yield replaceValue + previousIndex = index + searchValue.length + index = source.indexOf(searchValue, previousIndex) + } + yield source.slice(previousIndex) +} + +/** + * "" can only appear in javascript strings, so we can safely escape + * the "<" without breaking the javascript. + */ +export function* javascriptEscaper(code: string) { + yield* stringReplacer(code, '', '\\u003c/script>') +} + +/** + * @see {@link https://redux.js.org/usage/server-rendering#security-considerations} + */ +export function* jsonEscaper(value: unknown) { + yield* stringReplacer(JSON.stringify(value), '<', '\\u003c') +} diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index e79de4edde3..b2310552d7f 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -54,10 +54,12 @@ "@types/react": "^18.2.50", "@types/react-dom": "^18.2.18", "@types/send": "^0.17.4", + "@web/rollup-plugin-import-meta-assets": "^2.2.1", "autoprefixer": "^10.4.17", "postcss": "^8.4.33", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.13", "rollup": "^4.10.0", "rollup-plugin-postcss": "^4.0.2", "tailwindcss": "^3.4.1", diff --git a/packages/oauth-provider/rollup.config.js b/packages/oauth-provider/rollup.config.js index 564b72341f5..e15ec9cc869 100644 --- a/packages/oauth-provider/rollup.config.js +++ b/packages/oauth-provider/rollup.config.js @@ -10,31 +10,44 @@ const { default: terser } = require('@rollup/plugin-terser') const { default: typescript } = require('@rollup/plugin-typescript') const postcss = ((m) => m.default || m)(require('rollup-plugin-postcss')) -const NODE_ENV = - process.env['NODE_ENV'] === 'development' ? 'development' : 'production' -const devMode = NODE_ENV === 'development' +module.exports = defineConfig((commandLineArguments) => { + const NODE_ENV = + process.env['NODE_ENV'] ?? + (commandLineArguments.watch ? 'development' : 'production') -module.exports = defineConfig({ - input: 'src/app/main.tsx', - output: { - manualChunks: undefined, - sourcemap: devMode, - file: 'dist/app/main.js', - format: 'iife', - }, - plugins: [ - nodeResolve({ preferBuiltins: false, browser: true }), - commonjs(), - postcss({ config: true, extract: true, minimize: !devMode }), - typescript({ - tsconfig: './tsconfig.frontend.json', - outputToFilesystem: true, - }), - replace({ - preventAssignment: true, - values: { 'process.env.NODE_ENV': JSON.stringify(NODE_ENV) }, - }), - manifest(), - !devMode && terser({}), - ], + const minify = NODE_ENV !== 'development' + + return { + input: 'src/assets/app/main.tsx', + output: { + manualChunks: undefined, + sourcemap: true, + file: 'dist/assets/app/main.js', + format: 'iife', + }, + plugins: [ + nodeResolve({ preferBuiltins: false, browser: true }), + commonjs(), + postcss({ config: true, extract: true, minimize: minify }), + typescript({ + tsconfig: './tsconfig.frontend.json', + outputToFilesystem: true, + }), + replace({ + preventAssignment: true, + values: { 'process.env.NODE_ENV': JSON.stringify(NODE_ENV) }, + }), + // Change `data` to `true` to include assets data in the manifest, + // allowing for easier bundling of the backend code (eg. using esbuild) as + // bundlers know how to bundle JSON files but not how to bundle assets + // referenced at runtime. + manifest({ data: false }), + minify && terser({}), + ], + onwarn(warning, warn) { + // 'use client' directives are fine + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return + warn(warning) + }, + } }) diff --git a/packages/oauth-provider/src/app/backend-data.ts b/packages/oauth-provider/src/app/backend-data.ts deleted file mode 100644 index cfada2c0c45..00000000000 --- a/packages/oauth-provider/src/app/backend-data.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ClientMetadata, Session } from './types' - -// This is injected by the backend in the HTML template -declare const __backendData: { - clientId: string - clientMetadata: ClientMetadata - requestUri: string - csrfCookie: string - sessions: readonly Session[] - consentRequired: boolean - loginHint?: string -} - -export const backendData = __backendData - -export type BackendData = typeof __backendData diff --git a/packages/oauth-provider/src/app/csrf-cookie.ts b/packages/oauth-provider/src/app/csrf-cookie.ts deleted file mode 100644 index b7641e0284e..00000000000 --- a/packages/oauth-provider/src/app/csrf-cookie.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { backendData } from './backend-data' - -const parseCookieString = (cookie: string) => - Object.fromEntries( - cookie - .split(';') - .filter(Boolean) - .map((str) => str.split('=', 2).map((s) => decodeURIComponent(s.trim()))), - ) - -export const csrfToken = parseCookieString(document.cookie)[ - backendData.csrfCookie -] diff --git a/packages/oauth-provider/src/app/main.tsx b/packages/oauth-provider/src/app/main.tsx deleted file mode 100644 index 2e0df5fcdd9..00000000000 --- a/packages/oauth-provider/src/app/main.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import './main.css' - -import { createRoot } from 'react-dom/client' - -import { App } from './app' -import { backendData } from './backend-data' - -const url = new URL(window.location.href) -url.search = '' -url.searchParams.set('request_uri', backendData.requestUri) -url.searchParams.set('client_id', backendData.clientId) -window.history.replaceState(history.state, '', url.pathname + url.search) - -const container = document.getElementById('root')! -const root = createRoot(container) -root.render() diff --git a/packages/oauth-provider/src/assets/app/app.tsx b/packages/oauth-provider/src/assets/app/app.tsx new file mode 100644 index 00000000000..b330d89d05b --- /dev/null +++ b/packages/oauth-provider/src/assets/app/app.tsx @@ -0,0 +1,24 @@ +import { ErrorBoundary } from 'react-error-boundary' + +import type { BackendData } from './backend-data' +import { Authorize } from './components/authorize' +import { Error } from './components/error' + +function FallbackRender({ error, resetErrorBoundary }) { + return ( + + ) +} + +export function App(data: BackendData) { + return ( + + {'error' in data ? : } + + ) +} diff --git a/packages/oauth-provider/src/assets/app/backend-data.ts b/packages/oauth-provider/src/assets/app/backend-data.ts new file mode 100644 index 00000000000..ce6c280d616 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/backend-data.ts @@ -0,0 +1,21 @@ +import type { ClientMetadata, Session } from './types' + +// This is injected by the backend in the HTML template +declare const __backendData: + | Readonly<{ + clientId: string + clientMetadata: Readonly + requestUri: string + csrfCookie: string + sessions: readonly Readonly[] + consentRequired: boolean + loginHint?: string + }> + | Readonly<{ + error: string + error_description: string + }> + +export const backendData = __backendData + +export type BackendData = typeof __backendData diff --git a/packages/oauth-provider/src/app/components/account-list.tsx b/packages/oauth-provider/src/assets/app/components/account-list.tsx similarity index 100% rename from packages/oauth-provider/src/app/components/account-list.tsx rename to packages/oauth-provider/src/assets/app/components/account-list.tsx diff --git a/packages/oauth-provider/src/app/app.tsx b/packages/oauth-provider/src/assets/app/components/authorize.tsx similarity index 88% rename from packages/oauth-provider/src/app/app.tsx rename to packages/oauth-provider/src/assets/app/components/authorize.tsx index 61ad2a011d5..c291ef77a1b 100644 --- a/packages/oauth-provider/src/app/app.tsx +++ b/packages/oauth-provider/src/assets/app/components/authorize.tsx @@ -1,22 +1,24 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' -import { Authorize } from './components/authorize' -import { Layout } from './components/layout' -import { LoginForm } from './components/login-form' -import { SessionSelector } from './components/session-selector' -import { csrfToken } from './csrf-cookie' -import { Account, Session } from './types' +import type { BackendData } from '../backend-data' +import { cookies } from '../cookies' +import { Account, Session } from '../types' -import type { BackendData } from './backend-data' +import { GrantAccept } from './grant-accept' +import { Layout } from './layout' +import { LoginForm } from './login-form' +import { SessionSelector } from './session-selector' -export function App({ +export function Authorize({ requestUri, clientId, clientMetadata, + csrfCookie, consentRequired: initialConsentRequired, loginHint: initialLoginHint, sessions: initialSessions, -}: BackendData) { +}: Exclude) { + const csrfToken = useMemo(() => cookies[csrfCookie], [csrfCookie]) const [isDone, setIsDone] = useState(false) const [loginHint, setLoginHint] = useState(initialLoginHint) const [sessions, setSessions] = useState(initialSessions) @@ -117,7 +119,7 @@ export function App({ if (selectedSession) { if (selectedSession.loginRequired === false) { return ( - setSub(null)} onAccept={() => authorizeAccept(selectedSession.account)} onReject={() => authorizeReject()} diff --git a/packages/oauth-provider/src/assets/app/components/error.tsx b/packages/oauth-provider/src/assets/app/components/error.tsx new file mode 100644 index 00000000000..f3c7c829ea8 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/error.tsx @@ -0,0 +1,33 @@ +import type { BackendData } from '../backend-data' + +import { Layout } from './layout' + +export function Error({ + error, + error_description, +}: Extract) { + return ( + +

+
+
+ + + +
+
+

Sorry, something went wrong.

+

{error_description}

+
+
+
+ + ) +} diff --git a/packages/oauth-provider/src/app/components/authorize.tsx b/packages/oauth-provider/src/assets/app/components/grant-accept.tsx similarity index 79% rename from packages/oauth-provider/src/app/components/authorize.tsx rename to packages/oauth-provider/src/assets/app/components/grant-accept.tsx index 8ab31fb0b42..508aa5101b2 100644 --- a/packages/oauth-provider/src/app/components/authorize.tsx +++ b/packages/oauth-provider/src/assets/app/components/grant-accept.tsx @@ -1,7 +1,7 @@ import { Account, ClientMetadata } from '../types' import { Layout } from './layout' -export function Authorize({ +export function GrantAccept({ account, clientId, clientMetadata, @@ -16,18 +16,15 @@ export function Authorize({ onReject: () => void onBack?: () => void }) { - const clientUriHost = clientMetadata.client_uri - ? new URL(clientMetadata.client_uri).host - : null - const clientName = clientMetadata.client_name || clientUriHost || clientId + const clientUri = clientMetadata.client_uri + const clientName = clientMetadata.client_name || clientUri || clientId return ( - Grant {clientUriHost} access to your{' '} - {account.preferred_username} account + Grant access to your {account.preferred_username} account. } > @@ -44,17 +41,24 @@ export function Authorize({ )}

- {clientUriHost || clientId} + {clientName}

- {clientName} is asking for permission to access your account. + {clientId} is asking for permission to access your account.

By clicking Accept, you allow this application to access your information in accordance to its{' '} - terms of service. + + terms of service + + .

diff --git a/packages/oauth-provider/src/app/components/layout.tsx b/packages/oauth-provider/src/assets/app/components/layout.tsx similarity index 100% rename from packages/oauth-provider/src/app/components/layout.tsx rename to packages/oauth-provider/src/assets/app/components/layout.tsx diff --git a/packages/oauth-provider/src/app/components/login-form.tsx b/packages/oauth-provider/src/assets/app/components/login-form.tsx similarity index 100% rename from packages/oauth-provider/src/app/components/login-form.tsx rename to packages/oauth-provider/src/assets/app/components/login-form.tsx diff --git a/packages/oauth-provider/src/app/components/session-selector.tsx b/packages/oauth-provider/src/assets/app/components/session-selector.tsx similarity index 100% rename from packages/oauth-provider/src/app/components/session-selector.tsx rename to packages/oauth-provider/src/assets/app/components/session-selector.tsx diff --git a/packages/oauth-provider/src/assets/app/cookies.ts b/packages/oauth-provider/src/assets/app/cookies.ts new file mode 100644 index 00000000000..1f04cf3ae1a --- /dev/null +++ b/packages/oauth-provider/src/assets/app/cookies.ts @@ -0,0 +1,9 @@ +export const parseCookieString = (cookie: string) => + Object.fromEntries( + cookie + .split(';') + .filter(Boolean) + .map((str) => str.split('=', 2).map((s) => decodeURIComponent(s.trim()))), + ) + +export const cookies = parseCookieString(document.cookie) diff --git a/packages/oauth-provider/src/app/main.css b/packages/oauth-provider/src/assets/app/main.css similarity index 100% rename from packages/oauth-provider/src/app/main.css rename to packages/oauth-provider/src/assets/app/main.css diff --git a/packages/oauth-provider/src/assets/app/main.tsx b/packages/oauth-provider/src/assets/app/main.tsx new file mode 100644 index 00000000000..248f3b864ed --- /dev/null +++ b/packages/oauth-provider/src/assets/app/main.tsx @@ -0,0 +1,30 @@ +import './main.css' + +import { createRoot } from 'react-dom/client' + +import { App } from './app' +import { backendData } from './backend-data' + +const url = new URL(window.location.href) + +// When the user is logging in, make sure the page URL contains the +// "request_uri" in case the user refreshes the page. +if ( + url.pathname === '/oauth/authorize' && + 'clientId' in backendData && + 'requestUri' in backendData +) { + if ( + !url.searchParams.has('client_id') && + !url.searchParams.has('request_uri') + ) { + url.search = '' + url.searchParams.set('client_id', backendData.clientId) + url.searchParams.set('request_uri', backendData.requestUri) + window.history.replaceState(history.state, '', url.pathname + url.search) + } +} + +const container = document.getElementById('root')! +const root = createRoot(container) +root.render() diff --git a/packages/oauth-provider/src/app/types.ts b/packages/oauth-provider/src/assets/app/types.ts similarity index 100% rename from packages/oauth-provider/src/app/types.ts rename to packages/oauth-provider/src/assets/app/types.ts diff --git a/packages/oauth-provider/src/assets/asset.ts b/packages/oauth-provider/src/assets/asset.ts new file mode 100644 index 00000000000..136e8794572 --- /dev/null +++ b/packages/oauth-provider/src/assets/asset.ts @@ -0,0 +1,8 @@ +import type { Readable } from 'node:stream' + +export type Asset = { + url: string + type?: string + sha256: string + createStream: () => Readable +} diff --git a/packages/oauth-provider/src/assets/assets-middleware.ts b/packages/oauth-provider/src/assets/assets-middleware.ts new file mode 100644 index 00000000000..9b19423ded3 --- /dev/null +++ b/packages/oauth-provider/src/assets/assets-middleware.ts @@ -0,0 +1,32 @@ +import { writeStream } from '@atproto/http-util' + +import { ASSETS_URL_PREFIX, getAsset } from './index.js' + +export function authorizeAssetsMiddleware() { + return async function assetsMiddleware(req, res, next): Promise { + if (req.method !== 'GET' && req.method !== 'HEAD') return next() + if (!req.url?.startsWith(ASSETS_URL_PREFIX)) return next() + + const [pathname, query] = req.url.split('?', 2) as [ + string, + string | undefined, + ] + const filename = pathname.slice(ASSETS_URL_PREFIX.length) + if (!filename) return next() + + const asset = await getAsset(filename).catch(() => null) + if (!asset) return next() + + if (req.headers['if-none-match'] === asset.sha256) { + return void res.writeHead(304).end() + } + + res.setHeader('ETag', asset.sha256) + + if (query === asset.sha256) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } + + await writeStream(res, asset.createStream(), asset.type) + } +} diff --git a/packages/oauth-provider/src/assets/index.ts b/packages/oauth-provider/src/assets/index.ts index a65337bba7b..abb16dea71a 100644 --- a/packages/oauth-provider/src/assets/index.ts +++ b/packages/oauth-provider/src/assets/index.ts @@ -1,5 +1,4 @@ -type ManifestItem = - import('@atproto/rollup-plugin-bundle-manifest').ManifestItem +import type { ManifestItem } from '@atproto/rollup-plugin-bundle-manifest' // If this library is used as a regular dependency (e.g. from node_modules), the // assets will simply be referenced from the node_modules directory. However, if @@ -16,85 +15,53 @@ type ManifestItem = // imports out of the box. import { createReadStream } from 'node:fs' -import { join } from 'node:path' +import { join, posix } from 'node:path' import { Readable } from 'node:stream' // @ts-expect-error: This file is generated at build time -import manifestData from '../app/bundle-manifest.json' +import appBundleManifestJson from './app/bundle-manifest.json' +import { Asset } from './asset' -const assets: Map = new Map(Object.entries(manifestData)) +const appBundleManifest: Map = new Map( + Object.entries(appBundleManifestJson), +) + +export const ASSETS_URL_PREFIX = '/@atproto/oauth-provider/~assets/' + +export async function getAsset(inputFilename: string): Promise { + const filename = posix.normalize(inputFilename) -async function getAsset( - filename: string, -): Promise<{ asset: ManifestItem; path: string }> { - // Prevent directory traversal attacks if ( - filename.includes(':') || - filename.includes('/') || - filename.includes('\\') || - filename.startsWith('.') + filename.startsWith('/') || // Prevent absolute paths + filename.startsWith('../') || // Prevent directory traversal attacks + /[<>:"|?*\\]/.test(filename) // Windows disallowed characters ) { throw new AssetNotFoundError(filename) } - const asset = assets.get(filename) - if (!asset) throw new AssetNotFoundError(filename) - - // We make it extra easy on the bundler by providing a list of known assets - // instead of relying on globbing (globbing with - // 'rollup-plugin-import-meta-assets' requires a file extension anyway). - - // return { - // asset, - // url: new URL(`../app/${filename}`, import.meta.url), - // } - - switch (filename) { - case 'main.js': - return { - asset, - path: join(__dirname, '../app/main.js'), - } - case 'main.js.map': - return { - asset, - path: join(__dirname, '../app/main.js.map'), - } - case 'main.css': - return { - asset, - path: join(__dirname, '../app/main.css'), - } - case 'main.css.map': - return { - asset, - path: join(__dirname, '../app/main.css.map'), - } - default: - // Should never happen - throw new AssetNotFoundError(filename) - } -} - -export async function findAsset( - filename: string, -): Promise<{ asset: ManifestItem; getStream: () => Readable }> { - const { asset, path } = await getAsset(filename) + const manifest = appBundleManifest.get(filename) + if (!manifest) throw new AssetNotFoundError(filename) // When this package is used as a regular "node_modules" dependency, and gets - // bundled by the consumer, the assets should be copied to the output - // directory. In case the bundler does not support copying assets based on the - // "new URL(path, import.meta.url)" pattern, this package's build system can - // be modified to embed the asset data directly into the bundle metadata (see - // rollup.config.mjs). + // bundled by the consumer, the assets should be copied to the bundle's output + // directory. In case the bundler does not support copying assets from the + // "dist/assets/app" folder, this package's build system can be modified to + // embed the asset data directly into the bundle-manifest.json (see the `data` + // option of "@atproto/rollup-plugin-bundle-manifest" in rollup.config.js). - const { data } = asset + const { data } = manifest return { - asset, - getStream: data + url: posix.join(ASSETS_URL_PREFIX, filename), + type: manifest.mime, + sha256: manifest.sha256, + createStream: data ? () => Readable.from(Buffer.from(data, 'base64')) - : () => createReadStream(path), + : () => + // ESM version: + // createReadStream(new URL(`./app/${filename}`, import.meta.url)) + // CJS version: + createReadStream(join(__dirname, './app', filename)), } } diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index d53c7d5d34a..eab7c10856e 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -56,13 +56,13 @@ import { } from './metadata/build-metadata.js' import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js' import { Userinfo } from './oidc/userinfo.js' +import { authorizeAssetsMiddleware } from './assets/assets-middleware.js' import { buildErrorPayload, buildErrorStatus, } from './output/build-error-payload.js' import { AuthorizationResultAuthorize, - authorizeAssetsMiddleware, sendAuthorizePage, } from './output/send-authorize-page.js' import { @@ -1105,7 +1105,7 @@ export class OAuthProvider extends OAuthVerifier { //- Private authorization endpoints - router.use(authorizeAssetsMiddleware('/oauth/')) + router.use(authorizeAssetsMiddleware()) router.get('/oauth/authorize', async function (req, res) { try { @@ -1194,42 +1194,50 @@ export class OAuthProvider extends OAuthVerifier { }) router.get('/oauth/authorize/accept', async function (req, res) { - res.setHeader('Cache-Control', 'no-store') + try { + res.setHeader('Cache-Control', 'no-store') - validateFetchMode(req, res, ['navigate']) - validateSameOrigin(req, res, issuerOrigin) + validateFetchMode(req, res, ['navigate']) + validateSameOrigin(req, res, issuerOrigin) - const query = Object.fromEntries(this.url.searchParams) - const input = await acceptQuerySchema.parseAsync(query, { - path: ['query'], - }) + const query = Object.fromEntries(this.url.searchParams) + const input = await acceptQuerySchema.parseAsync(query, { + path: ['query'], + }) - validateReferer(req, res, { - origin: issuerOrigin, - pathname: '/oauth/authorize', - searchParams: [ - ['request_uri', input.request_uri], - ['client_id', input.client_id], - ], - }) - validateCsrfToken( - req, - res, - input.csrf_token, - csrfCookie(input.request_uri), - true, - ) + validateReferer(req, res, { + origin: issuerOrigin, + pathname: '/oauth/authorize', + searchParams: [ + ['request_uri', input.request_uri], + ['client_id', input.client_id], + ], + }) + validateCsrfToken( + req, + res, + input.csrf_token, + csrfCookie(input.request_uri), + true, + ) - const { deviceId } = await sessionManager.load(req, res) + const { deviceId } = await sessionManager.load(req, res) - const data = await server.acceptRequest( - deviceId, - input.request_uri, - input.client_id, - input.account_sub, - ) + const data = await server.acceptRequest( + deviceId, + input.request_uri, + input.client_id, + input.account_sub, + ) + + return await sendAuthorizeRedirect(req, res, data) + } catch (err) { + await onError?.(req, res, err) - return await sendAuthorizeRedirect(req, res, data) + if (!res.headersSent) { + await sendErrorPage(req, res, err) + } + } }) const rejectQuerySchema = z.object({ @@ -1239,41 +1247,49 @@ export class OAuthProvider extends OAuthVerifier { }) router.get('/oauth/authorize/reject', async function (req, res) { - res.setHeader('Cache-Control', 'no-store') + try { + res.setHeader('Cache-Control', 'no-store') - validateFetchMode(req, res, ['navigate']) - validateSameOrigin(req, res, issuerOrigin) + validateFetchMode(req, res, ['navigate']) + validateSameOrigin(req, res, issuerOrigin) - const query = Object.fromEntries(this.url.searchParams) - const input = await rejectQuerySchema.parseAsync(query, { - path: ['query'], - }) + const query = Object.fromEntries(this.url.searchParams) + const input = await rejectQuerySchema.parseAsync(query, { + path: ['query'], + }) - validateReferer(req, res, { - origin: issuerOrigin, - pathname: '/oauth/authorize', - searchParams: [ - ['request_uri', input.request_uri], - ['client_id', input.client_id], - ], - }) - validateCsrfToken( - req, - res, - input.csrf_token, - csrfCookie(input.request_uri), - true, - ) + validateReferer(req, res, { + origin: issuerOrigin, + pathname: '/oauth/authorize', + searchParams: [ + ['request_uri', input.request_uri], + ['client_id', input.client_id], + ], + }) + validateCsrfToken( + req, + res, + input.csrf_token, + csrfCookie(input.request_uri), + true, + ) - const { deviceId } = await sessionManager.load(req, res) + const { deviceId } = await sessionManager.load(req, res) - const data = await server.rejectRequest( - deviceId, - input.request_uri, - input.client_id, - ) + const data = await server.rejectRequest( + deviceId, + input.request_uri, + input.client_id, + ) + + return await sendAuthorizeRedirect(req, res, data) + } catch (err) { + await onError?.(req, res, err) - return await sendAuthorizeRedirect(req, res, data) + if (!res.headersSent) { + await sendErrorPage(req, res, err) + } + } }) return router.handler diff --git a/packages/oauth-provider/src/output/send-authorize-page.ts b/packages/oauth-provider/src/output/send-authorize-page.ts index f3bc052c29d..676f96c5c41 100644 --- a/packages/oauth-provider/src/output/send-authorize-page.ts +++ b/packages/oauth-provider/src/output/send-authorize-page.ts @@ -1,45 +1,13 @@ -import { createHash } from 'node:crypto' import { IncomingMessage, ServerResponse } from 'node:http' import { html } from '@atproto/html' -import { Middleware, writeHtml, writeStream } from '@atproto/http-util' import { Account } from '../account/account.js' -import { findAsset } from '../assets/index.js' +import { getAsset } from '../assets/index.js' import { Client } from '../client/client.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { RequestUri } from '../request/request-uri.js' - -export function authorizeAssetsMiddleware(prefix: string): Middleware { - if (!prefix.startsWith('/')) throw new TypeError('Prefix must start with /') - if (!prefix.endsWith('/')) prefix += '/' - - return async function assetsMiddleware(req, res, next): Promise { - if (req.method !== 'GET' && req.method !== 'HEAD') return next() - if (!req.url?.startsWith(prefix)) return next() - - const [pathname, query] = req.url.split('?', 2) as [ - string, - string | undefined, - ] - const filename = pathname.slice(prefix.length) - - const item = await findAsset(filename).catch(() => null) - if (!item) return next() - - if (req.headers['if-none-match'] === item.asset.sha256) { - return void res.writeHead(304).end() - } - - res.setHeader('ETag', item.asset.sha256) - - if (query === item.asset.sha256) { - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') - } - - await writeStream(res, item.getStream(), item.asset.mime) - } -} +import { declareBrowserGlobalVar, sendWebApp } from './send-web-app.js' export type AuthorizationResultAuthorize = { issuer: string @@ -73,68 +41,13 @@ export async function sendAuthorizePage( res: ServerResponse, data: AuthorizationResultAuthorize, ): Promise { - const [{ asset: mainJs }, { asset: mainCss }] = await Promise.all([ - findAsset('main.js'), - findAsset('main.css'), - ]) - - const backendDataScript = html - .dangerouslyCreate( - `window.__backendData=${html.jsonForScriptTag(buildBackendData(data))};`, - ) - .toString() - const backendDataScriptSha = createHash('sha256') - .update(backendDataScript) - .digest('base64') - const backendDataScriptHtml = html.dangerouslyCreate( - ``, - ) - - /* Security headers */ - - res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless') - res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') - res.setHeader('Referrer-Policy', 'same-origin') - res.setHeader('X-Frame-Options', 'DENY') - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('X-XSS-Protection', '0') - res.setHeader('Permissions-Policy', 'otp-credentials=*, document-domain=()') - res.setHeader('Strict-Transport-Security', 'max-age=63072000') - res.setHeader('Cache-Control', 'no-store') - res.setHeader( - 'Content-Security-Policy', - [ - `default-src 'none'`, - `frame-ancestors 'none'`, - `form-action 'none'`, - `base-uri 'none'`, - - `script-src 'self' 'sha256-${mainJs.sha256}' 'sha256-${backendDataScriptSha}'`, - `style-src 'self' 'sha256-${mainCss.sha256}'`, - `img-src 'self' https:`, - `connect-src 'self'`, - `upgrade-insecure-requests`, - ].join('; '), - ) - - const payload = html` - - - - - - - Authorize - - -
- - ${backendDataScriptHtml} - - - - ` - - writeHtml(res, payload.toBuffer()) + return sendWebApp(req, res, { + scripts: [ + declareBrowserGlobalVar('__backendData', buildBackendData(data)), + await getAsset('main.js'), + ], + styles: [await getAsset('main.css')], + title: 'Authorize', + body: html`
`, + }) } diff --git a/packages/oauth-provider/src/output/send-error-page.ts b/packages/oauth-provider/src/output/send-error-page.ts index 820f654b1cb..6390938c18d 100644 --- a/packages/oauth-provider/src/output/send-error-page.ts +++ b/packages/oauth-provider/src/output/send-error-page.ts @@ -1,58 +1,24 @@ import { IncomingMessage, ServerResponse } from 'node:http' import { html } from '@atproto/html' -import { writeHtml } from '@atproto/http-util' + +import { getAsset } from '../assets' +import { buildErrorPayload, buildErrorStatus } from './build-error-payload' +import { declareBrowserGlobalVar, sendWebApp } from './send-web-app' export async function sendErrorPage( req: IncomingMessage, res: ServerResponse, - _err: unknown, + err: unknown, ): Promise { - // TODO : actually display the error - - /* Security headers */ - res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless') - res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') - res.setHeader('Referrer-Policy', 'same-origin') - res.setHeader('X-Frame-Options', 'DENY') - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('X-XSS-Protection', '0') - res.setHeader('Permissions-Policy', 'otp-credentials=* document-domain=()') - res.setHeader('Strict-Transport-Security', 'max-age=63072000') - res.setHeader('Cache-Control', 'no-store') - res.setHeader( - 'Content-Security-Policy', - [ - `default-src 'none'`, - `frame-ancestors 'none'`, - `form-action 'none'`, - `base-uri 'none'`, - - `script-src 'self' https://cdn.jsdelivr.net https://cdn.tailwindcss.com`, - `style-src 'self' 'unsafe-inline'`, - `img-src 'self' https:`, - `connect-src 'self'`, - `upgrade-insecure-requests`, - ].join('; '), - ) - - const payload = html` - - - - - - - Authorize - - - - -

Authorization Error

- - - ` - - writeHtml(res, payload.toBuffer()) + return sendWebApp(req, res, { + status: buildErrorStatus(err), + scripts: [ + declareBrowserGlobalVar('__backendData', buildErrorPayload(err)), + await getAsset('main.js'), + ], + styles: [await getAsset('main.css')], + title: 'Error', + body: html`
`, + }) } diff --git a/packages/oauth-provider/src/output/send-web-app.ts b/packages/oauth-provider/src/output/send-web-app.ts new file mode 100644 index 00000000000..845453cb0f8 --- /dev/null +++ b/packages/oauth-provider/src/output/send-web-app.ts @@ -0,0 +1,109 @@ +import { createHash } from 'node:crypto' +import { IncomingMessage, ServerResponse } from 'node:http' + +import { html, Html, jsonCode } from '@atproto/html' +import { writeHtml } from '@atproto/http-util' + +import { Asset } from '../assets/asset.js' + +export async function sendWebApp( + req: IncomingMessage, + res: ServerResponse, + { + head, + title, + body, + base, + status = 200, + scripts = [], + styles = [], + }: { + scripts?: (Html | Asset)[] + styles?: (Html | Asset)[] + status?: number + base?: URL + title: string + head?: Html + body: Html + dataVarName?: string + }, +): Promise { + res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless') + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Referrer-Policy', 'same-origin') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-XSS-Protection', '0') + res.setHeader('Permissions-Policy', 'otp-credentials=*, document-domain=()') + res.setHeader('Strict-Transport-Security', 'max-age=63072000') + res.setHeader('Cache-Control', 'no-store') + res.setHeader( + 'Content-Security-Policy', + [ + `default-src 'none'`, + `frame-ancestors 'none'`, + `form-action 'none'`, + `base-uri ${base?.origin || `'none'`}`, + `script-src 'self' ${scripts + .map(assetToHash) + .map(hashToCspRule) + .join(' ')}`, + `style-src 'self' ${styles + .map(assetToHash) + .map(hashToCspRule) + .join(' ')}`, + `img-src 'self' data: https:`, + `connect-src 'self'`, + `upgrade-insecure-requests`, + ].join('; '), + ) + + const payload = html` + + + + + + ${base ? html`` : ''} + ${title} + ${head || []} ${styles.map(styleToHtml)} + + + ${body} ${scripts.map(scriptToHtml)} + + + ` + + writeHtml(res, payload.toString(), status) +} + +export function declareBrowserGlobalVar(name: string, data: unknown) { + const nameJson = jsonCode(name) + const dataJson = jsonCode(data) + return html`window[${nameJson}]=${dataJson};` +} + +function scriptToHtml(a: Html | Asset) { + return a instanceof Html + ? // prettier-ignore + html`` + : html`` +} + +function styleToHtml(a: Html | Asset) { + return a instanceof Html + ? // prettier-ignore + html`` + : html`` +} + +function assetToHash(a: Html | Asset): string { + return a instanceof Html + ? createHash('sha256').update(a.toString()).digest('base64') + : a.sha256 +} + +function hashToCspRule(hash: string): string { + return `'sha256-${hash}'` +} diff --git a/packages/oauth-provider/tailwind.config.js b/packages/oauth-provider/tailwind.config.js index 9719c558257..89eabe180e4 100644 --- a/packages/oauth-provider/tailwind.config.js +++ b/packages/oauth-provider/tailwind.config.js @@ -1,6 +1,6 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ['src/app/**/*.{js,ts,jsx,tsx}'], + content: ['src/assets/app/**/*.{js,ts,jsx,tsx}'], theme: { extend: {}, }, diff --git a/packages/oauth-provider/tsconfig.backend.json b/packages/oauth-provider/tsconfig.backend.json index f10d082b10d..3847f63798f 100644 --- a/packages/oauth-provider/tsconfig.backend.json +++ b/packages/oauth-provider/tsconfig.backend.json @@ -5,5 +5,5 @@ "rootDir": "src" }, "include": ["src"], - "exclude": ["src/app"] + "exclude": ["src/assets/app"] } diff --git a/packages/oauth-provider/tsconfig.frontend.json b/packages/oauth-provider/tsconfig.frontend.json index 08f471b84d2..33536899b09 100644 --- a/packages/oauth-provider/tsconfig.frontend.json +++ b/packages/oauth-provider/tsconfig.frontend.json @@ -1,8 +1,8 @@ { "extends": ["../../tsconfig/browser.json", "../../tsconfig/bundler.json"], "compilerOptions": { - "rootDir": "src/app", - "outDir": "dist/app" + "rootDir": "src/assets/app", + "outDir": "dist/assets/app" }, - "include": ["src/app/**/*.ts", "src/app/**/*.tsx"] + "include": ["src/assets/app/**/*.ts", "src/assets/app/**/*.tsx"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf3cbd8a23..c8a60bbd626 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -729,6 +729,9 @@ importers: '@types/send': specifier: ^0.17.4 version: 0.17.4 + '@web/rollup-plugin-import-meta-assets': + specifier: ^2.2.1 + version: 2.2.1(rollup@4.14.1) autoprefixer: specifier: ^10.4.17 version: 10.4.19(postcss@8.4.38) @@ -741,6 +744,9 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-error-boundary: + specifier: ^4.0.13 + version: 4.0.13(react@18.2.0) rollup: specifier: ^4.10.0 version: 4.14.1 @@ -5215,6 +5221,23 @@ packages: rollup: 4.14.1 dev: true + /@rollup/plugin-dynamic-import-vars@2.1.2(rollup@4.14.1): + resolution: {integrity: sha512-4lr2oXxs9hcxtGGaK8s0i9evfjzDrAs7ngw28TqruWKTEm0+U4Eljb+F6HXGYdFv8xRojQlrQwV7M/yxeh3yzQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@rollup/pluginutils': 5.1.0(rollup@4.14.1) + astring: 1.8.6 + estree-walker: 2.0.2 + fast-glob: 3.3.1 + magic-string: 0.30.9 + rollup: 4.14.1 + dev: true + /@rollup/plugin-node-resolve@15.2.3(rollup@4.14.1): resolution: {integrity: sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==} engines: {node: '>=14.0.0'} @@ -6019,6 +6042,19 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /@web/rollup-plugin-import-meta-assets@2.2.1(rollup@4.14.1): + resolution: {integrity: sha512-nG7nUQqSJWdl63pBTmnIElJuFi2V1x9eVje19BJuFvfz266jSmZtX3m30ncb7fOJxQt3/ge+FVL8tuNI9+63dQ==} + engines: {node: '>=18.0.0'} + dependencies: + '@rollup/plugin-dynamic-import-vars': 2.1.2(rollup@4.14.1) + '@rollup/pluginutils': 5.1.0(rollup@4.14.1) + estree-walker: 2.0.2 + globby: 13.2.2 + magic-string: 0.30.9 + transitivePeerDependencies: + - rollup + dev: true + /abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} dev: true @@ -6247,6 +6283,11 @@ packages: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + /astring@1.8.6: + resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==} + hasBin: true + dev: true + /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -8406,6 +8447,17 @@ packages: slash: 3.0.0 dev: true + /globby@13.2.2: + resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + dir-glob: 3.0.1 + fast-glob: 3.3.1 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 4.0.0 + dev: true + /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -11254,6 +11306,15 @@ packages: scheduler: 0.23.0 dev: true + /react-error-boundary@4.0.13(react@18.2.0): + resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.22.10 + react: 18.2.0 + dev: true + /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true @@ -11730,6 +11791,11 @@ packages: engines: {node: '>=8'} dev: true + /slash@4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + dev: true + /smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} From 75b134f88277c337591c056c2bf69d0ba0624f30 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 7 Mar 2024 16:47:27 +0100 Subject: [PATCH 011/140] feat(pds): prepare account enrichment --- packages/pds/src/account-manager/index.ts | 38 +++++++++++++++++++++-- packages/pds/src/context.ts | 28 ++++++++++------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/pds/src/account-manager/index.ts b/packages/pds/src/account-manager/index.ts index 009b8528d30..29b5c90248f 100644 --- a/packages/pds/src/account-manager/index.ts +++ b/packages/pds/src/account-manager/index.ts @@ -471,6 +471,15 @@ export class AccountManager // AccountStore + private async enrichAccount(account: Account): Promise { + // TODO: get profile data from api.app.bsky.actor.getProfile somehow + + // account.picture ||= profile.avatar + // account.name ||= profile.displayName + + return account + } + async authenticateAccount( { username: identifier, password, remember = false }: LoginCredentials, deviceId: DeviceId | null, @@ -487,7 +496,8 @@ export class AccountManager ) } - return deviceAccount.toAccount(user, this.serviceDid) + const account = await deviceAccount.toAccount(user, this.serviceDid) + return this.enrichAccount(account) } catch (err) { if (err instanceof AuthRequiredError) return null throw err @@ -518,11 +528,33 @@ export class AccountManager deviceId: DeviceId, sub: string, ): Promise { - return deviceAccount.get(this.db, deviceId, sub, this.serviceDid) + const accountInfo = await deviceAccount.get( + this.db, + deviceId, + sub, + this.serviceDid, + ) + + if (!accountInfo) return null + + return { + ...accountInfo, + account: await this.enrichAccount(accountInfo.account), + } } async listDeviceAccounts(deviceId: DeviceId): Promise { - return deviceAccount.listRemembered(this.db, deviceId, this.serviceDid) + const accountInfos = await deviceAccount.listRemembered( + this.db, + deviceId, + this.serviceDid, + ) + return Promise.all( + accountInfos.map(async (accountInfo) => ({ + ...accountInfo, + account: await this.enrichAccount(accountInfo.account), + })), + ) } async removeDeviceAccount(deviceId: DeviceId, sub: string): Promise { diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 56d81a64e43..dfea7f59be8 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -265,20 +265,24 @@ export class AppContext { }) : undefined + /** + * Using the oauthProvider as oauthVerifier allows clients to use the + * same nonce when authenticating and making requests, avoiding + * un-necessary "invalid_nonce" errors. It also allows the use of + * AccessTokenType.id as access token type. + */ + const oauthVerifier: OAuthVerifier = + oauthProvider ?? + new OAuthVerifier({ + issuer: cfg.oauth.issuer, + keyset, + dpopNonce, + replayStore, + }) + const authVerifier = new AuthVerifier(accountManager, idResolver, { publicUrl: cfg.service.publicUrl, - oauthVerifier: - // Using the oauthProvider as oauthVerifier allows clients to use the - // same nonce when authenticating and making requests, avoiding - // un-necessary "invalid_nonce" errors. It also allows the use of - // AccessTokenType.id as access token type. - oauthProvider ?? - new OAuthVerifier({ - issuer: cfg.oauth.issuer, - keyset, - dpopNonce, - replayStore, - }), + oauthVerifier, jwtKey, adminPass: secrets.adminPassword, dids: { From ff749ea399027c5fd9772962fc4ef959230650be Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 7 Mar 2024 22:05:21 +0100 Subject: [PATCH 012/140] fix(pds): better type ReqCtx --- packages/pds/src/auth-verifier.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 5c6ebcdaf85..f0f042af4dc 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -18,6 +18,8 @@ import { getVerificationMaterial } from '@atproto/common' type ReqCtx = { req: express.Request + // StreamAuthVerifier does not have "res" + res?: express.Response } // @TODO sync-up with current method names, consider backwards compat. @@ -519,10 +521,10 @@ export class AuthVerifier { protected async setAuthHeaders(ctx: ReqCtx) { // Prevent caching (on proxies) of auth dependent responses - ctx.req.res?.setHeader('Cache-Control', 'private') + ctx.res?.setHeader('Cache-Control', 'private') // Make sure that browsers do not return cached responses when the auth header changes - ctx.req.res?.appendHeader('Vary', 'Authorization') + ctx.res?.appendHeader('Vary', 'Authorization') /** * Return next DPoP nonce in response headers for DPoP bound tokens. @@ -533,8 +535,8 @@ export class AuthVerifier { const dpopNonce = this._oauthVerifier.nextDpopNonce() if (dpopNonce) { const name = 'DPoP-Nonce' - ctx.req.res?.setHeader(name, dpopNonce) - ctx.req.res?.appendHeader('Access-Control-Expose-Headers', name) + ctx.res?.setHeader(name, dpopNonce) + ctx.res?.appendHeader('Access-Control-Expose-Headers', name) } } } From e15e675eb2cd782606f38be56d09e37938d7c073 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 8 Mar 2024 15:14:30 +0100 Subject: [PATCH 013/140] small improvements to the fetch-node package --- packages/fetch-node/package.json | 2 - packages/fetch-node/src/index.ts | 3 +- .../fetch-node/src/{fetch-wrap.ts => safe.ts} | 39 +++----------- packages/fetch-node/src/ssrf.ts | 52 ++++++++++++++----- packages/fetch/src/fetch-request.ts | 1 - packages/pds/src/config/config.ts | 3 +- pnpm-lock.yaml | 6 --- 7 files changed, 51 insertions(+), 55 deletions(-) rename packages/fetch-node/src/{fetch-wrap.ts => safe.ts} (60%) diff --git a/packages/fetch-node/package.json b/packages/fetch-node/package.json index 89c77cf7f8f..96d69b2486c 100644 --- a/packages/fetch-node/package.json +++ b/packages/fetch-node/package.json @@ -26,12 +26,10 @@ "dependencies": { "@atproto/fetch": "workspace:*", "@atproto/transformer": "workspace:*", - "http-errors": "^2.0.0", "ipaddr.js": "^2.1.0", "tslib": "^2.6.2" }, "devDependencies": { - "@types/http-errors": "^2.0.4", "typescript": "^5.3.3" }, "scripts": { diff --git a/packages/fetch-node/src/index.ts b/packages/fetch-node/src/index.ts index 3d1acba67f7..e8beb920c88 100644 --- a/packages/fetch-node/src/index.ts +++ b/packages/fetch-node/src/index.ts @@ -1 +1,2 @@ -export * from './fetch-wrap.js' +export * from './safe.js' +export * from './ssrf.js' diff --git a/packages/fetch-node/src/fetch-wrap.ts b/packages/fetch-node/src/safe.ts similarity index 60% rename from packages/fetch-node/src/fetch-wrap.ts rename to packages/fetch-node/src/safe.ts index 5b74296a562..3bbea231672 100644 --- a/packages/fetch-node/src/fetch-wrap.ts +++ b/packages/fetch-node/src/safe.ts @@ -6,11 +6,16 @@ import { } from '@atproto/fetch' import { compose } from '@atproto/transformer' -import { ssrfSafeHostname } from './ssrf.js' +import { ssrfFetchWrap } from './ssrf.js' export type SafeFetchWrapOptions = NonNullable< Parameters[0] > + +/** + * Wrap a fetch function with safety checks so that it can be safely used + * with user provided input (URL). + */ export const safeFetchWrap = ({ fetch = globalThis.fetch as Fetch, responseMaxSize = 512 * 1024, // 512kB @@ -44,7 +49,7 @@ export const safeFetchWrap = ({ * input, we need to make sure that the request is not vulnerable to SSRF * attacks. */ - ssrfProtection ? ssrfSafeFetchWrap({ fetch }) : fetch, + ssrfProtection ? ssrfFetchWrap({ fetch }) : fetch, /** * Since we will be fetching user owned data, we need to make sure that an @@ -52,33 +57,3 @@ export const safeFetchWrap = ({ */ fetchMaxSizeProcessor(responseMaxSize), ) - -export type SsrfSafeFetchWrapOptions = NonNullable< - Parameters[0] -> -export const ssrfSafeFetchWrap = ({ - fetch = globalThis.fetch as Fetch, -} = {}): Fetch => { - const ssrfSafeFetch: Fetch = async (request) => { - const { hostname } = new URL(request.url) - - // Make sure the hostname is a valid IP address - const ip = await ssrfSafeHostname(hostname) - if (ip) { - // Normally we would replace the hostname with the IP address and set the - // Host header to the original hostname. However, since we are using - // fetch() we can't set the Host header. - } - - if (request.redirect === 'follow') { - // TODO: actually implement by calling ssrfSafeFetch recursively - throw new Error( - 'Request redirect must be "error" or "manual" when SSRF is enabled', - ) - } - - return fetch(request) - } - - return ssrfSafeFetch -} diff --git a/packages/fetch-node/src/ssrf.ts b/packages/fetch-node/src/ssrf.ts index f9202223296..24bf44131ff 100644 --- a/packages/fetch-node/src/ssrf.ts +++ b/packages/fetch-node/src/ssrf.ts @@ -1,23 +1,51 @@ import dns, { promises as dnsPromises } from 'node:dns' -import createError from 'http-errors' +import { Fetch, FetchError } from '@atproto/fetch' import ipaddr from 'ipaddr.js' const { IPv4, IPv6 } = ipaddr -export async function ssrfSafeHostname(hostname: string): Promise { - const ip = await hostnameLookup(hostname).catch((cause) => { - throw cause?.code === 'ENOTFOUND' - ? createError(400, `Invalid hostname ${hostname}`, { cause }) - : createError(500, `Unable resolve DNS for ${hostname}`, { cause }) - }) - if (ip.range() !== 'unicast') { - throw createError(400, `Invalid hostname IP address ${ip}`) +export type SsrfSafeFetchWrapOptions = NonNullable< + Parameters[0] +> +export const ssrfFetchWrap = ({ + fetch = globalThis.fetch as Fetch, +} = {}): Fetch => { + const ssrfSafeFetch: Fetch = async (request) => { + if (request.redirect === 'follow') { + // TODO: actually implement by calling ssrfSafeFetch recursively + throw new Error( + 'Request redirect must be "error" or "manual" when SSRF is enabled', + ) + } + + const { hostname } = new URL(request.url) + + // Make sure the hostname is a unicast IP address + const ip = await hostnameLookup(hostname).catch((cause) => { + throw cause?.code === 'ENOTFOUND' + ? new FetchError(400, `Invalid hostname ${hostname}`, { + request, + cause, + }) + : new FetchError(500, `Unable resolve DNS for ${hostname}`, { + request, + cause, + }) + }) + if (ip.range() !== 'unicast') { + throw new FetchError(400, `Invalid hostname IP address ${ip}`, { + request, + }) + } + + return fetch(request) } - return ip.toString() + + return ssrfSafeFetch } -export async function hostnameLookup( +async function hostnameLookup( hostname: string, ): Promise { if (IPv4.isIPv4(hostname)) { @@ -31,7 +59,7 @@ export async function hostnameLookup( return domainLookup(hostname) } -export async function domainLookup( +async function domainLookup( domain: string, ): Promise { const addr = await dnsPromises.lookup(domain, { diff --git a/packages/fetch/src/fetch-request.ts b/packages/fetch/src/fetch-request.ts index 0f25ab925ba..e50fe1635a3 100644 --- a/packages/fetch/src/fetch-request.ts +++ b/packages/fetch/src/fetch-request.ts @@ -24,7 +24,6 @@ export function forbiddenDomainNameRequestTransform( forbiddenDomainNames: Iterable, ): RequestTranformer { const forbiddenDomainNameSet = new Set(forbiddenDomainNames) - if (forbiddenDomainNameSet.size === 0) return (request) => request return async (request) => { const { hostname } = new URL(request.url) diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 48aaf3d9b76..047aaf5a8e5 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -244,8 +244,9 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { } const safeFetchCfg: ServerConfig['safeFetch'] = { - allowHttp: false, + allowHttp: env.fetchDisableSafeties ? true : false, forbiddenDomainNames: [ + 'google.com', 'example.com', 'example.org', 'example.net', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8a60bbd626..29c96fbe985 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -483,9 +483,6 @@ importers: '@atproto/transformer': specifier: workspace:* version: link:../transformer - http-errors: - specifier: ^2.0.0 - version: 2.0.0 ipaddr.js: specifier: ^2.1.0 version: 2.1.0 @@ -493,9 +490,6 @@ importers: specifier: ^2.6.2 version: 2.6.2 devDependencies: - '@types/http-errors': - specifier: ^2.0.4 - version: 2.0.4 typescript: specifier: ^5.3.3 version: 5.4.4 From 500eaa22a576382eeedb141795ecd8e657c67a53 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 8 Mar 2024 17:34:23 +0100 Subject: [PATCH 014/140] feat(oauth-provider): allow customizing branding of the login page --- packages/oauth-provider/package.json | 1 - .../oauth-provider/src/assets/app/app.tsx | 24 ------- .../src/assets/app/backend-data.ts | 42 ++++++----- .../{grant-accept.tsx => accept-page.tsx} | 16 ++--- ...unt-list.tsx => account-selector-page.tsx} | 13 ++-- .../{authorize.tsx => authorize-page.tsx} | 26 +++---- .../components/{error.tsx => error-page.tsx} | 13 ++-- .../{login-form.tsx => login-page.tsx} | 14 ++-- .../{layout.tsx => page-layout.tsx} | 4 +- ...selector.tsx => session-selector-page.tsx} | 10 +-- .../oauth-provider/src/assets/app/main.tsx | 38 +++++----- packages/oauth-provider/src/oauth-provider.ts | 11 +-- .../oauth-provider/src/output/branding.ts | 70 +++++++++++++++++++ .../src/output/send-authorize-page.ts | 14 ++-- .../src/output/send-error-page.ts | 18 +++-- packages/oauth-provider/tailwind.config.js | 3 + pnpm-lock.yaml | 12 ---- 17 files changed, 195 insertions(+), 134 deletions(-) delete mode 100644 packages/oauth-provider/src/assets/app/app.tsx rename packages/oauth-provider/src/assets/app/components/{grant-accept.tsx => accept-page.tsx} (83%) rename packages/oauth-provider/src/assets/app/components/{account-list.tsx => account-selector-page.tsx} (88%) rename packages/oauth-provider/src/assets/app/components/{authorize.tsx => authorize-page.tsx} (90%) rename packages/oauth-provider/src/assets/app/components/{error.tsx => error-page.tsx} (77%) rename packages/oauth-provider/src/assets/app/components/{login-form.tsx => login-page.tsx} (89%) rename packages/oauth-provider/src/assets/app/components/{layout.tsx => page-layout.tsx} (88%) rename packages/oauth-provider/src/assets/app/components/{session-selector.tsx => session-selector-page.tsx} (83%) create mode 100644 packages/oauth-provider/src/output/branding.ts diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index b2310552d7f..11b1a26450c 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -59,7 +59,6 @@ "postcss": "^8.4.33", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.13", "rollup": "^4.10.0", "rollup-plugin-postcss": "^4.0.2", "tailwindcss": "^3.4.1", diff --git a/packages/oauth-provider/src/assets/app/app.tsx b/packages/oauth-provider/src/assets/app/app.tsx deleted file mode 100644 index b330d89d05b..00000000000 --- a/packages/oauth-provider/src/assets/app/app.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { ErrorBoundary } from 'react-error-boundary' - -import type { BackendData } from './backend-data' -import { Authorize } from './components/authorize' -import { Error } from './components/error' - -function FallbackRender({ error, resetErrorBoundary }) { - return ( - - ) -} - -export function App(data: BackendData) { - return ( - - {'error' in data ? : } - - ) -} diff --git a/packages/oauth-provider/src/assets/app/backend-data.ts b/packages/oauth-provider/src/assets/app/backend-data.ts index ce6c280d616..4734b3a27be 100644 --- a/packages/oauth-provider/src/assets/app/backend-data.ts +++ b/packages/oauth-provider/src/assets/app/backend-data.ts @@ -1,21 +1,29 @@ import type { ClientMetadata, Session } from './types' -// This is injected by the backend in the HTML template -declare const __backendData: - | Readonly<{ - clientId: string - clientMetadata: Readonly - requestUri: string - csrfCookie: string - sessions: readonly Readonly[] - consentRequired: boolean - loginHint?: string - }> - | Readonly<{ - error: string - error_description: string - }> +export type BrandingData = { + logo?: string +} -export const backendData = __backendData +export type ErrorData = { + error: string + error_description: string +} -export type BackendData = typeof __backendData +export type AuthorizeData = { + clientId: string + clientMetadata: ClientMetadata + requestUri: string + csrfCookie: string + sessions: Session[] + consentRequired: boolean + loginHint?: string +} + +// These values are injected by the backend when it builds the +// page HTML. + +export const brandingData = window['__brandingData'] as BrandingData | undefined +export const errorData = window['__errorData'] as ErrorData | undefined +export const authorizeData = window['__authorizeData'] as + | AuthorizeData + | undefined diff --git a/packages/oauth-provider/src/assets/app/components/grant-accept.tsx b/packages/oauth-provider/src/assets/app/components/accept-page.tsx similarity index 83% rename from packages/oauth-provider/src/assets/app/components/grant-accept.tsx rename to packages/oauth-provider/src/assets/app/components/accept-page.tsx index 508aa5101b2..755ec1feedd 100644 --- a/packages/oauth-provider/src/assets/app/components/grant-accept.tsx +++ b/packages/oauth-provider/src/assets/app/components/accept-page.tsx @@ -1,7 +1,7 @@ import { Account, ClientMetadata } from '../types' -import { Layout } from './layout' +import { PageLayout } from './page-layout' -export function GrantAccept({ +export function AcceptPage({ account, clientId, clientMetadata, @@ -20,7 +20,7 @@ export function GrantAccept({ const clientName = clientMetadata.client_name || clientUri || clientId return ( - @@ -40,7 +40,7 @@ export function GrantAccept({
)} -

+

{clientName}

@@ -66,7 +66,7 @@ export function GrantAccept({ @@ -77,7 +77,7 @@ export function GrantAccept({ @@ -85,12 +85,12 @@ export function GrantAccept({ -
+ ) } diff --git a/packages/oauth-provider/src/assets/app/components/account-list.tsx b/packages/oauth-provider/src/assets/app/components/account-selector-page.tsx similarity index 88% rename from packages/oauth-provider/src/assets/app/components/account-list.tsx rename to packages/oauth-provider/src/assets/app/components/account-selector-page.tsx index 0f74a6c181b..938166bf35d 100644 --- a/packages/oauth-provider/src/assets/app/components/account-list.tsx +++ b/packages/oauth-provider/src/assets/app/components/account-selector-page.tsx @@ -1,7 +1,7 @@ import { Account } from '../types' -import { Layout } from './layout' +import { PageLayout } from './page-layout' -export function AccountList({ +export function AccountSelectorPage({ accounts, onAccount, another = undefined, @@ -14,7 +14,10 @@ export function AccountList({ onBack?: () => void }) { return ( - +

Sign in as...

    @@ -60,13 +63,13 @@ export function AccountList({
)} -
+ ) } diff --git a/packages/oauth-provider/src/assets/app/components/authorize.tsx b/packages/oauth-provider/src/assets/app/components/authorize-page.tsx similarity index 90% rename from packages/oauth-provider/src/assets/app/components/authorize.tsx rename to packages/oauth-provider/src/assets/app/components/authorize-page.tsx index c291ef77a1b..e6daddc7357 100644 --- a/packages/oauth-provider/src/assets/app/components/authorize.tsx +++ b/packages/oauth-provider/src/assets/app/components/authorize-page.tsx @@ -1,15 +1,15 @@ import { useMemo, useState } from 'react' -import type { BackendData } from '../backend-data' +import type { AuthorizeData } from '../backend-data' import { cookies } from '../cookies' import { Account, Session } from '../types' -import { GrantAccept } from './grant-accept' -import { Layout } from './layout' -import { LoginForm } from './login-form' -import { SessionSelector } from './session-selector' +import { AcceptPage } from './accept-page' +import { PageLayout } from './page-layout' +import { LoginPage } from './login-page' +import { SessionSelectorPage } from './session-selector-page' -export function Authorize({ +export function AuthorizePage({ requestUri, clientId, clientMetadata, @@ -17,7 +17,7 @@ export function Authorize({ consentRequired: initialConsentRequired, loginHint: initialLoginHint, sessions: initialSessions, -}: Exclude) { +}: AuthorizeData) { const csrfToken = useMemo(() => cookies[csrfCookie], [csrfCookie]) const [isDone, setIsDone] = useState(false) const [loginHint, setLoginHint] = useState(initialLoginHint) @@ -113,13 +113,15 @@ export function Authorize({ if (isDone) { // TODO - return You are being redirected + return ( + You are being redirected + ) } if (selectedSession) { if (selectedSession.loginRequired === false) { return ( - setSub(null)} onAccept={() => authorizeAccept(selectedSession.account)} onReject={() => authorizeReject()} @@ -130,7 +132,7 @@ export function Authorize({ ) } else { return ( - setLoginHint(undefined)} @@ -151,7 +153,7 @@ export function Authorize({ } return ( - diff --git a/packages/oauth-provider/src/assets/app/components/error.tsx b/packages/oauth-provider/src/assets/app/components/error-page.tsx similarity index 77% rename from packages/oauth-provider/src/assets/app/components/error.tsx rename to packages/oauth-provider/src/assets/app/components/error-page.tsx index f3c7c829ea8..87d72b6dde5 100644 --- a/packages/oauth-provider/src/assets/app/components/error.tsx +++ b/packages/oauth-provider/src/assets/app/components/error-page.tsx @@ -1,13 +1,10 @@ -import type { BackendData } from '../backend-data' +import type { ErrorData } from '../backend-data' -import { Layout } from './layout' +import { PageLayout } from './page-layout' -export function Error({ - error, - error_description, -}: Extract) { +export function ErrorPage({ error, error_description }: ErrorData) { return ( - +
-
+ ) } diff --git a/packages/oauth-provider/src/assets/app/components/login-form.tsx b/packages/oauth-provider/src/assets/app/components/login-page.tsx similarity index 89% rename from packages/oauth-provider/src/assets/app/components/login-form.tsx rename to packages/oauth-provider/src/assets/app/components/login-page.tsx index f135353302d..59ca3ba7209 100644 --- a/packages/oauth-provider/src/assets/app/components/login-form.tsx +++ b/packages/oauth-provider/src/assets/app/components/login-page.tsx @@ -1,8 +1,8 @@ import { FormHTMLAttributes } from 'react' -import { Layout } from './layout' +import { PageLayout } from './page-layout' -export function LoginForm({ +export function LoginPage({ onLogin, onBack = undefined, username = '', @@ -37,7 +37,7 @@ export function LoginForm({ } return ( - +
@@ -77,7 +77,7 @@ export function LoginForm({ id="remember" name="remember" type="checkbox" - className="text-blue-600" + className="text-primary" /> @@ -93,7 +93,7 @@ export function LoginForm({
@@ -102,13 +102,13 @@ export function LoginForm({ )}
- + ) } diff --git a/packages/oauth-provider/src/assets/app/components/layout.tsx b/packages/oauth-provider/src/assets/app/components/page-layout.tsx similarity index 88% rename from packages/oauth-provider/src/assets/app/components/layout.tsx rename to packages/oauth-provider/src/assets/app/components/page-layout.tsx index e37dd9406ca..94c7cbe00c6 100644 --- a/packages/oauth-provider/src/assets/app/components/layout.tsx +++ b/packages/oauth-provider/src/assets/app/components/page-layout.tsx @@ -1,6 +1,6 @@ import { HTMLAttributes } from 'react' -export function Layout({ +export function PageLayout({ title, subTitle, children, @@ -16,7 +16,7 @@ export function Layout({ {...props} >
-

+

{title}

{subTitle}

diff --git a/packages/oauth-provider/src/assets/app/components/session-selector.tsx b/packages/oauth-provider/src/assets/app/components/session-selector-page.tsx similarity index 83% rename from packages/oauth-provider/src/assets/app/components/session-selector.tsx rename to packages/oauth-provider/src/assets/app/components/session-selector-page.tsx index 02db236c75e..43ba9609d44 100644 --- a/packages/oauth-provider/src/assets/app/components/session-selector.tsx +++ b/packages/oauth-provider/src/assets/app/components/session-selector-page.tsx @@ -1,10 +1,10 @@ import { useState } from 'react' import { Session } from '../types' -import { AccountList } from './account-list' -import { LoginForm } from './login-form' +import { AccountSelectorPage } from './account-selector-page' +import { LoginPage } from './login-page' -export function SessionSelector({ +export function SessionSelectorPage({ sessions, onSession, onLogin, @@ -22,7 +22,7 @@ export function SessionSelector({ const [showLogin, setShowLogin] = useState(sessions.length === 0) return showLogin ? ( - 0 @@ -33,7 +33,7 @@ export function SessionSelector({ } /> ) : ( - s.account)} onAccount={(a) => { const session = sessions.find((s) => s.account.sub === a.sub) diff --git a/packages/oauth-provider/src/assets/app/main.tsx b/packages/oauth-provider/src/assets/app/main.tsx index 248f3b864ed..6e7ad37c304 100644 --- a/packages/oauth-provider/src/assets/app/main.tsx +++ b/packages/oauth-provider/src/assets/app/main.tsx @@ -2,29 +2,29 @@ import './main.css' import { createRoot } from 'react-dom/client' -import { App } from './app' -import { backendData } from './backend-data' - -const url = new URL(window.location.href) +import { authorizeData, errorData } from './backend-data' +import { AuthorizePage } from './components/authorize-page' +import { ErrorPage } from './components/error-page' // When the user is logging in, make sure the page URL contains the // "request_uri" in case the user refreshes the page. -if ( - url.pathname === '/oauth/authorize' && - 'clientId' in backendData && - 'requestUri' in backendData -) { - if ( - !url.searchParams.has('client_id') && - !url.searchParams.has('request_uri') - ) { - url.search = '' - url.searchParams.set('client_id', backendData.clientId) - url.searchParams.set('request_uri', backendData.requestUri) - window.history.replaceState(history.state, '', url.pathname + url.search) - } +const url = new URL(window.location.href) +if (authorizeData && url.pathname === '/oauth/authorize') { + url.search = '' + url.searchParams.set('client_id', authorizeData.clientId) + url.searchParams.set('request_uri', authorizeData.requestUri) + window.history.replaceState(history.state, '', url.pathname + url.search) } +// TODO: inject brandingData (from backend-data.ts) into the page (logo & co) + const container = document.getElementById('root')! const root = createRoot(container) -root.render() + +if (authorizeData) { + root.render() +} else if (errorData) { + root.render() +} else { + throw new Error('No data found') +} diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index eab7c10856e..d94ac71490d 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -115,6 +115,7 @@ import { } from './token/types.js' import { VerifyTokenClaimsOptions } from './token/verify-token-claims.js' import { dateToEpoch, dateToRelativeSeconds } from './util/date.js' +import { Branding } from './output/branding.js' export type OAuthProviderStore = Partial< ClientStore & @@ -854,10 +855,12 @@ export class OAuthProvider extends OAuthVerifier { Req extends IncomingMessage = IncomingMessage, Res extends ServerResponse = ServerResponse, >({ + branding, onError = process.env['NODE_ENV'] === 'development' ? (req, res, err): void => console.error('OAuthProvider error:', err) : undefined, }: { + branding?: Branding onError?: (req: Req, res: Res, err: unknown) => void }): Handler { const sessionManager = new SessionManager(this.sessionStore) @@ -1128,7 +1131,7 @@ export class OAuthProvider extends OAuthVerifier { } case 'authorize' in data: { await setupCsrfToken(req, res, csrfCookie(data.authorize.uri)) - return await sendAuthorizePage(req, res, data) + return await sendAuthorizePage(req, res, data, branding) } default: { // Should never happen @@ -1139,7 +1142,7 @@ export class OAuthProvider extends OAuthVerifier { await onError?.(req, res, err) if (!res.headersSent) { - await sendErrorPage(req, res, err) + await sendErrorPage(req, res, err, branding) } } }) @@ -1235,7 +1238,7 @@ export class OAuthProvider extends OAuthVerifier { await onError?.(req, res, err) if (!res.headersSent) { - await sendErrorPage(req, res, err) + await sendErrorPage(req, res, err, branding) } } }) @@ -1287,7 +1290,7 @@ export class OAuthProvider extends OAuthVerifier { await onError?.(req, res, err) if (!res.headersSent) { - await sendErrorPage(req, res, err) + await sendErrorPage(req, res, err, branding) } } }) diff --git a/packages/oauth-provider/src/output/branding.ts b/packages/oauth-provider/src/output/branding.ts new file mode 100644 index 00000000000..1c38bc7d978 --- /dev/null +++ b/packages/oauth-provider/src/output/branding.ts @@ -0,0 +1,70 @@ +const DEFAULT_COLORS = { + primary: parseColor('#6231af')!, +} +export type BrandingColors = Record + +export type Branding = { + logo?: string + colors?: Partial +} + +export function buildBrandingData({ logo }: Branding = {}) { + return { + logo, + } +} + +const DEFAULT_COLOR_ENTRIES = Object.entries(DEFAULT_COLORS) +export function buildBrandingCss({ colors = {} }: Branding = {}) { + const vars = DEFAULT_COLOR_ENTRIES.map(([name, value]) => { + const color = Object.hasOwn(colors, name) ? colors[name] : undefined + const { r, g, b } = (color && parseColor(color)) || value + // alpha not supported by tailwind (it does not work that way) + return `--color-${name}: ${r} ${g} ${b};` + }) + return `:root { ${vars.join(' ')} }` +} + +function parseColor( + color: string, +): undefined | { r: number; g: number; b: number; a?: number } { + if (color.startsWith('#')) { + if (color.length === 4 || color.length === 5) { + const [r, g, b, a] = color + .slice(1) + .split('') + .map((c) => parseInt(`${c}${c}`, 16)) + return { r, g, b, a } + } + + if (color.length === 7 || color.length === 9) { + const r = parseInt(color.substr(1, 2), 16) + const g = parseInt(color.substr(3, 2), 16) + const b = parseInt(color.substr(5, 2), 16) + const a = + color.length === 9 ? parseInt(color.substr(7, 2), 16) : undefined + return { r, g, b, a } + } + + return undefined + } + + const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/) + if (rgbMatch) { + const [, r, g, b] = rgbMatch + return { r: parseInt(r, 10), g: parseInt(g, 10), b: parseInt(b, 10) } + } + + const rgbaMatch = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*(\d+)\)/) + if (rgbaMatch) { + const [, r, g, b, a] = rgbaMatch + return { + r: parseInt(r, 10), + g: parseInt(g, 10), + b: parseInt(b, 10), + a: parseInt(a, 10), + } + } + + return undefined +} diff --git a/packages/oauth-provider/src/output/send-authorize-page.ts b/packages/oauth-provider/src/output/send-authorize-page.ts index 676f96c5c41..7b89161382a 100644 --- a/packages/oauth-provider/src/output/send-authorize-page.ts +++ b/packages/oauth-provider/src/output/send-authorize-page.ts @@ -1,12 +1,13 @@ import { IncomingMessage, ServerResponse } from 'node:http' -import { html } from '@atproto/html' +import { Html, html } from '@atproto/html' import { Account } from '../account/account.js' import { getAsset } from '../assets/index.js' import { Client } from '../client/client.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { RequestUri } from '../request/request-uri.js' +import { Branding, buildBrandingCss, buildBrandingData } from './branding.js' import { declareBrowserGlobalVar, sendWebApp } from './send-web-app.js' export type AuthorizationResultAuthorize = { @@ -24,7 +25,7 @@ export type AuthorizationResultAuthorize = { } } -function buildBackendData(data: AuthorizationResultAuthorize) { +function buildAuthorizeData(data: AuthorizationResultAuthorize) { return { csrfCookie: `csrf-${data.authorize.uri}`, requestUri: data.authorize.uri, @@ -40,13 +41,18 @@ export async function sendAuthorizePage( req: IncomingMessage, res: ServerResponse, data: AuthorizationResultAuthorize, + branding?: Branding, ): Promise { return sendWebApp(req, res, { scripts: [ - declareBrowserGlobalVar('__backendData', buildBackendData(data)), + declareBrowserGlobalVar('__brandingData', buildBrandingData(branding)), + declareBrowserGlobalVar('__authorizeData', buildAuthorizeData(data)), await getAsset('main.js'), ], - styles: [await getAsset('main.css')], + styles: [ + await getAsset('main.css'), + Html.dangerouslyCreate([buildBrandingCss(branding)]), + ], title: 'Authorize', body: html`
`, }) diff --git a/packages/oauth-provider/src/output/send-error-page.ts b/packages/oauth-provider/src/output/send-error-page.ts index 6390938c18d..2ba023176e8 100644 --- a/packages/oauth-provider/src/output/send-error-page.ts +++ b/packages/oauth-provider/src/output/send-error-page.ts @@ -1,23 +1,29 @@ import { IncomingMessage, ServerResponse } from 'node:http' -import { html } from '@atproto/html' +import { Html, html } from '@atproto/html' -import { getAsset } from '../assets' -import { buildErrorPayload, buildErrorStatus } from './build-error-payload' -import { declareBrowserGlobalVar, sendWebApp } from './send-web-app' +import { getAsset } from '../assets/index.js' +import { Branding, buildBrandingCss, buildBrandingData } from './branding.js' +import { buildErrorPayload, buildErrorStatus } from './build-error-payload.js' +import { declareBrowserGlobalVar, sendWebApp } from './send-web-app.js' export async function sendErrorPage( req: IncomingMessage, res: ServerResponse, err: unknown, + branding?: Branding, ): Promise { return sendWebApp(req, res, { status: buildErrorStatus(err), scripts: [ - declareBrowserGlobalVar('__backendData', buildErrorPayload(err)), + declareBrowserGlobalVar('__brandingData', buildBrandingData(branding)), + declareBrowserGlobalVar('__errorData', buildErrorPayload(err)), await getAsset('main.js'), ], - styles: [await getAsset('main.css')], + styles: [ + await getAsset('main.css'), + Html.dangerouslyCreate([buildBrandingCss(branding)]), + ], title: 'Error', body: html`
`, }) diff --git a/packages/oauth-provider/tailwind.config.js b/packages/oauth-provider/tailwind.config.js index 89eabe180e4..6a011d407b2 100644 --- a/packages/oauth-provider/tailwind.config.js +++ b/packages/oauth-provider/tailwind.config.js @@ -2,6 +2,9 @@ export default { content: ['src/assets/app/**/*.{js,ts,jsx,tsx}'], theme: { + colors: { + primary: 'rgb(var(--color-primary) / )', + }, extend: {}, }, plugins: [], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29c96fbe985..8f63683537e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -738,9 +738,6 @@ importers: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) - react-error-boundary: - specifier: ^4.0.13 - version: 4.0.13(react@18.2.0) rollup: specifier: ^4.10.0 version: 4.14.1 @@ -11300,15 +11297,6 @@ packages: scheduler: 0.23.0 dev: true - /react-error-boundary@4.0.13(react@18.2.0): - resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} - peerDependencies: - react: '>=16.13.1' - dependencies: - '@babel/runtime': 7.22.10 - react: 18.2.0 - dev: true - /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true From 084e62942ac26e398aae801d1d98179d763bb05c Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 8 Mar 2024 17:35:00 +0100 Subject: [PATCH 015/140] feat(pds): use Bluesky blue as primary color --- packages/pds/src/auth.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 8e335580a73..02e128b0208 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -6,6 +6,12 @@ import { oauthLogger } from './logger' export const createRouter = (ctx: AppContext) => { return combine([ ctx.oauthProvider?.httpHandler({ + // TODO: This must come from config + branding: { + colors: { + primary: '#0085ff', + }, + }, // Log oauth provider errors using our own logger onError: (req, res, err) => { oauthLogger.error({ err }, 'oauth-provider error') From 55bd2048d55c0679cb8dfaa4becd7bd6e856981e Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sun, 10 Mar 2024 00:10:51 +0100 Subject: [PATCH 016/140] feat(oauth-provider): improve customizability --- packages/html/src/index.ts | 10 +- packages/html/src/util.ts | 14 +- .../oauth-provider/src/assets/app/app.tsx | 137 ++++++++++++++ .../src/assets/app/components/accept-form.tsx | 92 ++++++++++ .../src/assets/app/components/accept-page.tsx | 96 ---------- .../assets/app/components/account-picker.tsx | 63 +++++++ .../app/components/account-selector-page.tsx | 75 -------- .../assets/app/components/authorize-page.tsx | 169 ------------------ .../src/assets/app/components/login-form.tsx | 138 ++++++++++++++ .../src/assets/app/components/login-page.tsx | 114 ------------ .../src/assets/app/components/page-layout.tsx | 26 +-- .../assets/app/components/session-picker.tsx | 118 ++++++++++++ .../app/components/session-selector-page.tsx | 46 ----- .../oauth-provider/src/assets/app/main.css | 9 + .../oauth-provider/src/assets/app/main.tsx | 6 +- .../src/assets/app/pages/accept-page.tsx | 26 +++ .../app/{components => pages}/error-page.tsx | 25 ++- .../app/pages/session-selector-page.tsx | 35 ++++ .../oauth-provider/src/output/branding.ts | 43 ++--- .../src/output/send-authorize-page.ts | 7 +- .../src/output/send-error-page.ts | 7 +- packages/oauth-provider/tailwind.config.js | 9 +- 22 files changed, 701 insertions(+), 564 deletions(-) create mode 100644 packages/oauth-provider/src/assets/app/app.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/accept-form.tsx delete mode 100644 packages/oauth-provider/src/assets/app/components/accept-page.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/account-picker.tsx delete mode 100644 packages/oauth-provider/src/assets/app/components/account-selector-page.tsx delete mode 100644 packages/oauth-provider/src/assets/app/components/authorize-page.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/login-form.tsx delete mode 100644 packages/oauth-provider/src/assets/app/components/login-page.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/session-picker.tsx delete mode 100644 packages/oauth-provider/src/assets/app/components/session-selector-page.tsx create mode 100644 packages/oauth-provider/src/assets/app/pages/accept-page.tsx rename packages/oauth-provider/src/assets/app/{components => pages}/error-page.tsx (51%) create mode 100644 packages/oauth-provider/src/assets/app/pages/session-selector-page.tsx diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index ace40de0d49..d3ca45d1451 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -1,6 +1,6 @@ import { encode } from './encode.js' import { Html } from './html.js' -import { javascriptEscaper, jsonEscaper } from './util.js' +import { cssEscaper, javascriptEscaper, jsonEscaper } from './util.js' type NestedArray = V | readonly NestedArray[] @@ -14,10 +14,18 @@ export const javascriptCode = (code: string) => /** * Escapes a value to use as an JSON variable definition inside a `" can only appear in javascript strings, so we can safely escape - * the "<" without breaking the javascript. - */ export function* javascriptEscaper(code: string) { + // "" can only appear in javascript strings, so we can safely escape + // the "<" without breaking the javascript. yield* stringReplacer(code, '', '\\u003c/script>') } -/** - * @see {@link https://redux.js.org/usage/server-rendering#security-considerations} - */ export function* jsonEscaper(value: unknown) { + // https://redux.js.org/usage/server-rendering#security-considerations yield* stringReplacer(JSON.stringify(value), '<', '\\u003c') } + +export function* cssEscaper(css: string) { + yield* stringReplacer(css, '', '\\u003c/style>') +} diff --git a/packages/oauth-provider/src/assets/app/app.tsx b/packages/oauth-provider/src/assets/app/app.tsx new file mode 100644 index 00000000000..55ecd8a2335 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/app.tsx @@ -0,0 +1,137 @@ +import { useMemo, useState } from 'react' + +import type { AuthorizeData } from './backend-data' +import { cookies } from './cookies' +import { Account, Session } from './types' + +import { AcceptPage } from './pages/accept-page' +import { PageLayout } from './components/page-layout' +import { SessionSelectionPage } from './pages/session-selector-page' + +export function App({ + requestUri, + clientId, + clientMetadata, + csrfCookie, + loginHint, + sessions: initialSessions, +}: AuthorizeData) { + const csrfToken = useMemo(() => cookies[csrfCookie], [csrfCookie]) + const [isDone, setIsDone] = useState(false) + const [sessions, setSessions] = useState(initialSessions) + const [selectedSession, onSession] = useState(null) + + const updateSession = useMemo(() => { + return (account: Account, consentRequired: boolean): Session => { + const sessionIdx = sessions.findIndex( + (s) => s.account.sub === account.sub, + ) + if (sessionIdx === -1) { + const newSession: Session = { + initiallySelected: false, + account, + loginRequired: false, + consentRequired, + } + setSessions([...sessions, newSession]) + return newSession + } else { + const curSession = sessions[sessionIdx] + const newSession: Session = { + ...curSession, + initiallySelected: false, + account, + consentRequired, + loginRequired: false, + } + setSessions([ + ...sessions.slice(0, sessionIdx), + newSession, + ...sessions.slice(sessionIdx + 1), + ]) + return newSession + } + } + }, [sessions, setSessions]) + + const onLogin = async (credentials: { + username: string + password: string + remember: boolean + }) => { + const r = await fetch('/oauth/authorize/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'same-origin', + body: JSON.stringify({ + csrf_token: csrfToken, + request_uri: requestUri, + client_id: clientId, + credentials, + }), + }) + const json = await r.json() + if (!r.ok) throw new Error(json.error || 'Error', { cause: json }) + + const { account, info } = json + const consentRequired = !info.authorizedClients.includes(clientId) + const session = updateSession(account, consentRequired) + onSession(session) + } + + const authorizeAccept = async (account: Account) => { + setIsDone(true) + + const url = new URL('/oauth/authorize/accept', window.origin) + url.searchParams.set('request_uri', requestUri) + url.searchParams.set('account_sub', account.sub) + url.searchParams.set('client_id', clientId) + url.searchParams.set('csrf_token', csrfToken) + + window.location.href = url.href + } + + const authorizeReject = () => { + setIsDone(true) + + const url = new URL('/oauth/authorize/reject', window.origin) + url.searchParams.set('request_uri', requestUri) + url.searchParams.set('client_id', clientId) + url.searchParams.set('csrf_token', csrfToken) + + window.location.href = url.href + } + + if (isDone) { + // TODO + return ( + You are being redirected + ) + } + + if (selectedSession) { + return ( + onSession(null)} + onAccept={() => authorizeAccept(selectedSession.account)} + onReject={() => authorizeReject()} + account={selectedSession.account} + clientId={clientId} + clientMetadata={clientMetadata} + /> + ) + } + + return ( + authorizeReject()} + backLabel="Deny access" + /> + ) +} diff --git a/packages/oauth-provider/src/assets/app/components/accept-form.tsx b/packages/oauth-provider/src/assets/app/components/accept-form.tsx new file mode 100644 index 00000000000..f0f3af8cbab --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/accept-form.tsx @@ -0,0 +1,92 @@ +import { type HTMLAttributes } from 'react' +import { Account, ClientMetadata } from '../types' + +export type AcceptFormProps = { + account: Account + clientId: string + clientMetadata: ClientMetadata + onAccept: () => void + onReject: () => void + onBack?: () => void +} + +export function AcceptForm({ + account, + clientId, + clientMetadata, + onAccept, + onReject, + onBack, + ...props +}: AcceptFormProps & HTMLAttributes) { + const clientName = + clientMetadata.client_name || clientMetadata.client_uri || clientId + + const accountName = account.preferred_username || account.email || account.sub + + return ( +
+ {clientMetadata.logo_uri && ( +
+ {clientMetadata.client_name} +
+ )} + +

+ {clientName} +

+ +

+ {clientId} is asking for permission to access your{' '} + {accountName} account. +

+ +

+ By clicking Accept, you allow this application to access your + information in accordance to its{' '} + + terms of service + + . +

+ +
+ + + {onBack && ( + + )} +
+ + +
+
+ ) +} diff --git a/packages/oauth-provider/src/assets/app/components/accept-page.tsx b/packages/oauth-provider/src/assets/app/components/accept-page.tsx deleted file mode 100644 index 755ec1feedd..00000000000 --- a/packages/oauth-provider/src/assets/app/components/accept-page.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Account, ClientMetadata } from '../types' -import { PageLayout } from './page-layout' - -export function AcceptPage({ - account, - clientId, - clientMetadata, - onAccept, - onReject, - onBack = undefined, -}: { - account: Account - clientId: string - clientMetadata: ClientMetadata - onAccept: () => void - onReject: () => void - onBack?: () => void -}) { - const clientUri = clientMetadata.client_uri - const clientName = clientMetadata.client_name || clientUri || clientId - - return ( - - Grant access to your {account.preferred_username} account. - - } - > -
- {clientMetadata.logo_uri && ( -
- {clientMetadata.client_name} -
- )} - -

- {clientName} -

- -

- {clientId} is asking for permission to access your account. -

- -

- By clicking Accept, you allow this application to access your - information in accordance to its{' '} - - terms of service - - . -

- -
- {onBack && ( - - )} - -
- - - - -
-
-
- ) -} diff --git a/packages/oauth-provider/src/assets/app/components/account-picker.tsx b/packages/oauth-provider/src/assets/app/components/account-picker.tsx new file mode 100644 index 00000000000..235d050e3f8 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/account-picker.tsx @@ -0,0 +1,63 @@ +import type { HTMLAttributes } from 'react' +import { Account } from '../types' + +export type AccountPickerProps = { + accounts: readonly Account[] + onAccount: (account: Account) => void + onOther?: () => void +} & HTMLAttributes + +export function AccountPicker({ + accounts, + onAccount, + onOther = undefined, + ...props +}: AccountPickerProps) { + return ( +
    + {accounts.map((account) => { + const identifier = + account.preferred_username || account.email || account.sub + const name = account.name || identifier + + return ( +
  • onAccount(account)} + > +
    + {account.picture && ( + {account.name} + )} + + {name} + {identifier && identifier !== name && ( + + {account.preferred_username || account.email || account.sub} + + )} + +
    + > +
  • + ) + })} + {onOther && ( +
  • + Other account + + > +
  • + )} +
+ ) +} diff --git a/packages/oauth-provider/src/assets/app/components/account-selector-page.tsx b/packages/oauth-provider/src/assets/app/components/account-selector-page.tsx deleted file mode 100644 index 938166bf35d..00000000000 --- a/packages/oauth-provider/src/assets/app/components/account-selector-page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Account } from '../types' -import { PageLayout } from './page-layout' - -export function AccountSelectorPage({ - accounts, - onAccount, - another = undefined, - onBack = undefined, - ...props -}: { - accounts: readonly Account[] - onAccount: (account: Account) => void - another?: () => void - onBack?: () => void -}) { - return ( - -
-

Sign in as...

-
    - {accounts.map((account) => ( -
  • onAccount(account)} - > -
    - {account.picture && ( - {account.name} - )} - - {account.name} - - {account.preferred_username} - - -
    - > -
  • - ))} - {another && ( -
  • another()} - > - Other account - - > -
  • - )} -
- - {onBack && ( -
- -
- )} -
-
- ) -} diff --git a/packages/oauth-provider/src/assets/app/components/authorize-page.tsx b/packages/oauth-provider/src/assets/app/components/authorize-page.tsx deleted file mode 100644 index e6daddc7357..00000000000 --- a/packages/oauth-provider/src/assets/app/components/authorize-page.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useMemo, useState } from 'react' - -import type { AuthorizeData } from '../backend-data' -import { cookies } from '../cookies' -import { Account, Session } from '../types' - -import { AcceptPage } from './accept-page' -import { PageLayout } from './page-layout' -import { LoginPage } from './login-page' -import { SessionSelectorPage } from './session-selector-page' - -export function AuthorizePage({ - requestUri, - clientId, - clientMetadata, - csrfCookie, - consentRequired: initialConsentRequired, - loginHint: initialLoginHint, - sessions: initialSessions, -}: AuthorizeData) { - const csrfToken = useMemo(() => cookies[csrfCookie], [csrfCookie]) - const [isDone, setIsDone] = useState(false) - const [loginHint, setLoginHint] = useState(initialLoginHint) - const [sessions, setSessions] = useState(initialSessions) - const [sub, setSub] = useState( - initialSessions.find((s) => s.initiallySelected)?.account.sub || null, - ) - - const selectedSession = sub && sessions.find((s) => s.account.sub == sub) - - const setAccount = (account: Account, consentRequired: boolean) => { - setLoginHint(undefined) - setSub(account.sub) - if (consentRequired === false && initialConsentRequired === false) { - authorizeAccept(account) - } - } - - const updateSession = (account: Account, consentRequired: boolean) => { - const sessionIdx = sessions.findIndex((s) => s.account.sub === account.sub) - if (sessionIdx === -1) { - const newSession: Session = { - initiallySelected: false, - account, - loginRequired: false, - consentRequired, - } - setSessions([...sessions, newSession]) - } else { - const curSession = sessions[sessionIdx] - const newSession: Session = { - ...curSession, - initiallySelected: false, - account, - consentRequired, - loginRequired: false, - } - setSessions([ - ...sessions.slice(0, sessionIdx), - newSession, - ...sessions.slice(sessionIdx + 1), - ]) - } - } - - const performLogin = async (credentials: { - username: string - password: string - remember: boolean - }) => { - const r = await fetch('/oauth/authorize/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'same-origin', - body: JSON.stringify({ - csrf_token: csrfToken, - request_uri: requestUri, - client_id: clientId, - credentials, - }), - }) - const json = await r.json() - if (!r.ok) throw new Error(json.error || 'Error', { cause: json }) - - const { account, info } = json - const consentRequired = !info.authorizedClients.includes(clientId) - updateSession(account, consentRequired) - setAccount(account, consentRequired) - } - - const authorizeAccept = async (account: Account) => { - setIsDone(true) - - const url = new URL('/oauth/authorize/accept', window.origin) - url.searchParams.set('request_uri', requestUri) - url.searchParams.set('account_sub', account.sub) - url.searchParams.set('client_id', clientId) - url.searchParams.set('csrf_token', csrfToken) - - window.location.href = url.href - } - - const authorizeReject = () => { - setIsDone(true) - - const url = new URL('/oauth/authorize/reject', window.origin) - url.searchParams.set('request_uri', requestUri) - url.searchParams.set('client_id', clientId) - url.searchParams.set('csrf_token', csrfToken) - - window.location.href = url.href - } - - if (isDone) { - // TODO - return ( - You are being redirected - ) - } - - if (selectedSession) { - if (selectedSession.loginRequired === false) { - return ( - setSub(null)} - onAccept={() => authorizeAccept(selectedSession.account)} - onReject={() => authorizeReject()} - account={selectedSession.account} - clientId={clientId} - clientMetadata={clientMetadata} - /> - ) - } else { - return ( - authorizeReject()} - /> - ) - } - } - - if (loginHint) { - return ( - setLoginHint(undefined)} - /> - ) - } - - return ( - - setAccount( - account, - sessions.find((s) => s.account.sub === account.sub) - ?.consentRequired || false, - ) - } - onBack={() => authorizeReject()} - /> - ) -} diff --git a/packages/oauth-provider/src/assets/app/components/login-form.tsx b/packages/oauth-provider/src/assets/app/components/login-form.tsx new file mode 100644 index 00000000000..a2df781d894 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/login-form.tsx @@ -0,0 +1,138 @@ +import type { FormHTMLAttributes } from 'react' + +export type LoginFormOutput = { + username: string + password: string + remember: boolean +} + +export type LoginFormProps = { + onLogin: (credentials: LoginFormOutput) => void + onBack?: () => void + backLabel?: string | JSX.Element + username?: string + usernameReadonly?: boolean +} & FormHTMLAttributes + +export function LoginForm({ + onLogin, + onBack = undefined, + backLabel = 'Back', + username = '', + usernameReadonly = false, + ...props +}: LoginFormProps) { + const onSubmit = ( + event: React.SyntheticEvent< + HTMLFormElement & { + username: HTMLInputElement + password: HTMLInputElement + remember: HTMLInputElement + }, + SubmitEvent + >, + ) => { + event.preventDefault() + onLogin({ + username: event.currentTarget.username.value, + password: event.currentTarget.password.value, + remember: event.currentTarget.remember.checked, + }) + } + + return ( +
+
+
+ @ + +
+ +
+ +
+ * + +
+ +
+ +
+ + + + + +
+ +
+
+
+ + + +
+
+

Warning

+

+ Please verify the domain name of the website before entering + your password. Never enter your password on a domain you do not + trust. +

+
+
+
+
+ +
+ + + {onBack && ( + + )} +
+
+ ) +} diff --git a/packages/oauth-provider/src/assets/app/components/login-page.tsx b/packages/oauth-provider/src/assets/app/components/login-page.tsx deleted file mode 100644 index 59ca3ba7209..00000000000 --- a/packages/oauth-provider/src/assets/app/components/login-page.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { FormHTMLAttributes } from 'react' - -import { PageLayout } from './page-layout' - -export function LoginPage({ - onLogin, - onBack = undefined, - username = '', - usernameReadonly = false, - ...props -}: FormHTMLAttributes & { - onLogin: (credentials: { - username: string - password: string - remember: boolean - }) => void - onBack?: () => void - username?: string - usernameReadonly?: boolean -}) { - const onSubmit = ( - event: React.SyntheticEvent< - HTMLFormElement & { - username: HTMLInputElement - password: HTMLInputElement - remember: HTMLInputElement - }, - SubmitEvent - >, - ) => { - event.preventDefault() - onLogin({ - username: event.currentTarget.username.value, - password: event.currentTarget.password.value, - remember: event.currentTarget.remember.checked, - }) - } - - return ( - -
-
-
- @ - -
- -
- -
- * - -
- -
- -
- - - - - -
-
- -
- - - {onBack && ( - - )} -
-
-
- ) -} diff --git a/packages/oauth-provider/src/assets/app/components/page-layout.tsx b/packages/oauth-provider/src/assets/app/components/page-layout.tsx index 94c7cbe00c6..869cac68d1b 100644 --- a/packages/oauth-provider/src/assets/app/components/page-layout.tsx +++ b/packages/oauth-provider/src/assets/app/components/page-layout.tsx @@ -1,25 +1,25 @@ import { HTMLAttributes } from 'react' +export type PageLayoutProps = { + column?: string | JSX.Element + children: string | JSX.Element +} + export function PageLayout({ - title, - subTitle, + column, children, + className, ...props -}: HTMLAttributes & { - title: string | JSX.Element - subTitle?: string | JSX.Element - children?: string | JSX.Element -}) { +}: PageLayoutProps & HTMLAttributes) { return (
-
-

- {title} -

-

{subTitle}

+
+ {column}
diff --git a/packages/oauth-provider/src/assets/app/components/session-picker.tsx b/packages/oauth-provider/src/assets/app/components/session-picker.tsx new file mode 100644 index 00000000000..d3695e52d2c --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/session-picker.tsx @@ -0,0 +1,118 @@ +import { HTMLAttributes, useMemo, useState } from 'react' + +import { Session } from '../types' +import { AccountPicker } from './account-picker' +import { LoginForm } from './login-form' + +export type SessionPickerProps = { + sessions: readonly Session[] + loginHint?: string + onSession: (session: Session) => void + onLogin: (credentials: { + username: string + password: string + remember: boolean + }) => void + onBack?: () => void + backLabel?: string | JSX.Element +} + +export function SessionPicker({ + sessions, + onSession, + onLogin, + onBack = undefined, + loginHint = undefined, + backLabel = 'Back', + ...props +}: SessionPickerProps & HTMLAttributes) { + const [showOther, setShowOther] = useState(sessions.length === 0) + + const [sub, setSub] = useState( + sessions.find((s) => s.initiallySelected)?.account.sub || null, + ) + + const selectedSession = useMemo(() => { + return sub ? sessions.find((s) => s.account.sub == sub) : undefined + }, [sub, sessions]) + + const onAccount = useMemo(() => { + return (a) => { + const session = sessions.find((s) => s.account.sub === a.sub) + if (session?.loginRequired) setSub(a.sub) + else if (session) onSession(session) + } + }, [sessions, onSession]) + + if (loginHint) { + return ( + + ) + } + + if (sessions.length === 0) { + return ( + + ) + } + + if (showOther) { + return ( + setShowOther(false)} + backLabel={'Back'} + {...props} + /> + ) + } + + if (selectedSession && selectedSession.loginRequired) { + return ( + setSub(null)} + backLabel={'Back'} + {...props} + /> + ) + } + + const { className, ...rest } = props + return ( +
+

Sign in as...

+ s.account)} + onAccount={onAccount} + onOther={() => setShowOther(true)} + {...rest} + /> + {onBack && ( +
+ +
+ )} +
+ ) +} diff --git a/packages/oauth-provider/src/assets/app/components/session-selector-page.tsx b/packages/oauth-provider/src/assets/app/components/session-selector-page.tsx deleted file mode 100644 index 43ba9609d44..00000000000 --- a/packages/oauth-provider/src/assets/app/components/session-selector-page.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useState } from 'react' - -import { Session } from '../types' -import { AccountSelectorPage } from './account-selector-page' -import { LoginPage } from './login-page' - -export function SessionSelectorPage({ - sessions, - onSession, - onLogin, - onBack = undefined, -}: { - sessions: readonly Session[] - onSession: (session: Session) => void - onLogin: (credentials: { - username: string - password: string - remember: boolean - }) => void - onBack?: () => void -}) { - const [showLogin, setShowLogin] = useState(sessions.length === 0) - - return showLogin ? ( - 0 - ? () => { - if (sessions.length > 0) setShowLogin(false) - } - : onBack - } - /> - ) : ( - s.account)} - onAccount={(a) => { - const session = sessions.find((s) => s.account.sub === a.sub) - if (session) onSession(session) - }} - another={() => setShowLogin(true)} - onBack={onBack} - /> - ) -} diff --git a/packages/oauth-provider/src/assets/app/main.css b/packages/oauth-provider/src/assets/app/main.css index b5c61c95671..4ccefccb1f9 100644 --- a/packages/oauth-provider/src/assets/app/main.css +++ b/packages/oauth-provider/src/assets/app/main.css @@ -1,3 +1,12 @@ @tailwind base; @tailwind components; @tailwind utilities; + +/* Matches colors defined in tailwind.config.js */ +@layer base { + :root { + --color-primary: 255 115 179; + --color-column: 189 255 253; + --color-error: 255 0 0; + } +} diff --git a/packages/oauth-provider/src/assets/app/main.tsx b/packages/oauth-provider/src/assets/app/main.tsx index 6e7ad37c304..3bfc08386b4 100644 --- a/packages/oauth-provider/src/assets/app/main.tsx +++ b/packages/oauth-provider/src/assets/app/main.tsx @@ -3,8 +3,8 @@ import './main.css' import { createRoot } from 'react-dom/client' import { authorizeData, errorData } from './backend-data' -import { AuthorizePage } from './components/authorize-page' -import { ErrorPage } from './components/error-page' +import { App } from './app' +import { ErrorPage } from './pages/error-page' // When the user is logging in, make sure the page URL contains the // "request_uri" in case the user refreshes the page. @@ -22,7 +22,7 @@ const container = document.getElementById('root')! const root = createRoot(container) if (authorizeData) { - root.render() + root.render() } else if (errorData) { root.render() } else { diff --git a/packages/oauth-provider/src/assets/app/pages/accept-page.tsx b/packages/oauth-provider/src/assets/app/pages/accept-page.tsx new file mode 100644 index 00000000000..4dc6c76ea14 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/pages/accept-page.tsx @@ -0,0 +1,26 @@ +import { AcceptForm, AcceptFormProps } from '../components/accept-form' +import { PageLayout } from '../components/page-layout' + +export type AcceptPageProps = AcceptFormProps + +export function AcceptPage(props: AcceptPageProps) { + const { account } = props + const accountName = account.preferred_username || account.email || account.sub + + return ( + +

+ Authorize +

+

+ Grant access to your {accountName} account. +

+ + } + > + +
+ ) +} diff --git a/packages/oauth-provider/src/assets/app/components/error-page.tsx b/packages/oauth-provider/src/assets/app/pages/error-page.tsx similarity index 51% rename from packages/oauth-provider/src/assets/app/components/error-page.tsx rename to packages/oauth-provider/src/assets/app/pages/error-page.tsx index 87d72b6dde5..a9185a962cb 100644 --- a/packages/oauth-provider/src/assets/app/components/error-page.tsx +++ b/packages/oauth-provider/src/assets/app/pages/error-page.tsx @@ -1,18 +1,31 @@ import type { ErrorData } from '../backend-data' -import { PageLayout } from './page-layout' +import { PageLayout } from '../components/page-layout' -export function ErrorPage({ error, error_description }: ErrorData) { +export type ErrorPageProps = ErrorData + +export function ErrorPage({ + error: _code, + error_description: message, +}: ErrorPageProps) { return ( - + +

+ An error occurred +

+ + } + >
@@ -21,7 +34,7 @@ export function ErrorPage({ error, error_description }: ErrorData) {

Sorry, something went wrong.

-

{error_description}

+

{message}

diff --git a/packages/oauth-provider/src/assets/app/pages/session-selector-page.tsx b/packages/oauth-provider/src/assets/app/pages/session-selector-page.tsx new file mode 100644 index 00000000000..e444a4fada7 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/pages/session-selector-page.tsx @@ -0,0 +1,35 @@ +import { PageLayout } from '../components/page-layout' +import { SessionPicker, SessionPickerProps } from '../components/session-picker' +import { ClientMetadata } from '../types' + +export type SessionSelectionPageProps = SessionPickerProps & { + clientId: string + clientMetadata: ClientMetadata +} + +export function SessionSelectionPage({ + clientId, + clientMetadata, + ...props +}: SessionSelectionPageProps) { + const clientName = + clientMetadata.client_name || clientMetadata.client_uri || clientId + + return ( + +

+ Sign in as... +

+ +

+ Select an account to access to {clientName}. +

+ + } + > + +
+ ) +} diff --git a/packages/oauth-provider/src/output/branding.ts b/packages/oauth-provider/src/output/branding.ts index 1c38bc7d978..295295bb4ad 100644 --- a/packages/oauth-provider/src/output/branding.ts +++ b/packages/oauth-provider/src/output/branding.ts @@ -1,11 +1,12 @@ -const DEFAULT_COLORS = { - primary: parseColor('#6231af')!, -} -export type BrandingColors = Record +// Matches colors defined in tailwind.config.js +const colorNames = ['primary', 'column', 'error'] as const +type ColorName = typeof colorNames[number] +const isColorName = (name: string): name is ColorName => + (colorNames as readonly string[]).includes(name) export type Branding = { logo?: string - colors?: Partial + colors?: { [_ in ColorName]?: string } } export function buildBrandingData({ logo }: Branding = {}) { @@ -14,35 +15,35 @@ export function buildBrandingData({ logo }: Branding = {}) { } } -const DEFAULT_COLOR_ENTRIES = Object.entries(DEFAULT_COLORS) -export function buildBrandingCss({ colors = {} }: Branding = {}) { - const vars = DEFAULT_COLOR_ENTRIES.map(([name, value]) => { - const color = Object.hasOwn(colors, name) ? colors[name] : undefined - const { r, g, b } = (color && parseColor(color)) || value +export function buildBrandingCss(branding?: Branding) { + if (!branding?.colors) return '' + + const vars = Object.entries(branding.colors) + .filter((e) => isColorName(e[0])) + .map(([name, value]) => [name, parseColor(value)] as const) + .filter((e): e is [ColorName, ParsedColor] => e[1] != null) // alpha not supported by tailwind (it does not work that way) - return `--color-${name}: ${r} ${g} ${b};` - }) + .map(([name, { r, g, b }]) => `--color-${name}: ${r} ${g} ${b};`) + return `:root { ${vars.join(' ')} }` } -function parseColor( - color: string, -): undefined | { r: number; g: number; b: number; a?: number } { +type ParsedColor = { r: number; g: number; b: number; a?: number } +function parseColor(color: string): undefined | ParsedColor { if (color.startsWith('#')) { if (color.length === 4 || color.length === 5) { const [r, g, b, a] = color .slice(1) .split('') - .map((c) => parseInt(`${c}${c}`, 16)) + .map((c) => parseInt(c + c, 16)) return { r, g, b, a } } if (color.length === 7 || color.length === 9) { - const r = parseInt(color.substr(1, 2), 16) - const g = parseInt(color.substr(3, 2), 16) - const b = parseInt(color.substr(5, 2), 16) - const a = - color.length === 9 ? parseInt(color.substr(7, 2), 16) : undefined + const r = parseInt(color.slice(1, 3), 16) + const g = parseInt(color.slice(3, 5), 16) + const b = parseInt(color.slice(5, 7), 16) + const a = color.length > 8 ? parseInt(color.slice(7, 9), 16) : undefined return { r, g, b, a } } diff --git a/packages/oauth-provider/src/output/send-authorize-page.ts b/packages/oauth-provider/src/output/send-authorize-page.ts index 7b89161382a..ba079e2d32f 100644 --- a/packages/oauth-provider/src/output/send-authorize-page.ts +++ b/packages/oauth-provider/src/output/send-authorize-page.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from 'node:http' -import { Html, html } from '@atproto/html' +import { cssCode, html } from '@atproto/html' import { Account } from '../account/account.js' import { getAsset } from '../assets/index.js' @@ -49,10 +49,7 @@ export async function sendAuthorizePage( declareBrowserGlobalVar('__authorizeData', buildAuthorizeData(data)), await getAsset('main.js'), ], - styles: [ - await getAsset('main.css'), - Html.dangerouslyCreate([buildBrandingCss(branding)]), - ], + styles: [await getAsset('main.css'), cssCode(buildBrandingCss(branding))], title: 'Authorize', body: html`
`, }) diff --git a/packages/oauth-provider/src/output/send-error-page.ts b/packages/oauth-provider/src/output/send-error-page.ts index 2ba023176e8..fbb0bbf0092 100644 --- a/packages/oauth-provider/src/output/send-error-page.ts +++ b/packages/oauth-provider/src/output/send-error-page.ts @@ -1,6 +1,6 @@ import { IncomingMessage, ServerResponse } from 'node:http' -import { Html, html } from '@atproto/html' +import { cssCode, html } from '@atproto/html' import { getAsset } from '../assets/index.js' import { Branding, buildBrandingCss, buildBrandingData } from './branding.js' @@ -20,10 +20,7 @@ export async function sendErrorPage( declareBrowserGlobalVar('__errorData', buildErrorPayload(err)), await getAsset('main.js'), ], - styles: [ - await getAsset('main.css'), - Html.dangerouslyCreate([buildBrandingCss(branding)]), - ], + styles: [await getAsset('main.css'), cssCode(buildBrandingCss(branding))], title: 'Error', body: html`
`, }) diff --git a/packages/oauth-provider/tailwind.config.js b/packages/oauth-provider/tailwind.config.js index 6a011d407b2..56bd42b5e22 100644 --- a/packages/oauth-provider/tailwind.config.js +++ b/packages/oauth-provider/tailwind.config.js @@ -2,10 +2,13 @@ export default { content: ['src/assets/app/**/*.{js,ts,jsx,tsx}'], theme: { - colors: { - primary: 'rgb(var(--color-primary) / )', + extend: { + colors: { + primary: 'rgb(var(--color-primary) / )', + column: 'rgb(var(--color-column) / )', + error: 'rgb(var(--color-error) / )', + }, }, - extend: {}, }, plugins: [], } From 77d0ad39556de498ab885ab8270c8f10bd5f7e7f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sun, 10 Mar 2024 00:11:09 +0100 Subject: [PATCH 017/140] feat(pds): change login column color --- packages/pds/src/auth.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 02e128b0208..cba7e776607 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -10,6 +10,7 @@ export const createRouter = (ctx: AppContext) => { branding: { colors: { primary: '#0085ff', + column: '#f0f2f5', }, }, // Log oauth provider errors using our own logger From 583cbab0531fd89c1607f44c3c43e73ea1791abf Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sun, 10 Mar 2024 22:45:36 +0100 Subject: [PATCH 018/140] feat: prepare for branding page --- .../oauth-provider/src/assets/app/app.tsx | 303 ++++++++++++------ .../src/assets/app/components/accept-form.tsx | 3 +- .../assets/app/components/account-picker.tsx | 103 +++--- .../src/assets/app/components/error-card.tsx | 34 ++ .../src/assets/app/components/page-layout.tsx | 35 +- .../assets/app/components/session-picker.tsx | 118 ------- .../{login-form.tsx => sign-in-form.tsx} | 106 +++--- .../oauth-provider/src/assets/app/lib/api.ts | 54 ++++ .../oauth-provider/src/assets/app/main.css | 1 - .../oauth-provider/src/assets/app/main.tsx | 15 +- .../src/assets/app/pages/accept-page.tsx | 26 -- .../src/assets/app/pages/error-page.tsx | 43 --- .../app/pages/session-selector-page.tsx | 35 -- .../oauth-provider/src/assets/app/types.ts | 6 + packages/oauth-provider/src/oauth-provider.ts | 2 +- .../oauth-provider/src/output/branding.ts | 2 +- packages/oauth-provider/tailwind.config.js | 1 - packages/pds/src/auth.ts | 1 - 18 files changed, 454 insertions(+), 434 deletions(-) create mode 100644 packages/oauth-provider/src/assets/app/components/error-card.tsx delete mode 100644 packages/oauth-provider/src/assets/app/components/session-picker.tsx rename packages/oauth-provider/src/assets/app/components/{login-form.tsx => sign-in-form.tsx} (71%) create mode 100644 packages/oauth-provider/src/assets/app/lib/api.ts delete mode 100644 packages/oauth-provider/src/assets/app/pages/accept-page.tsx delete mode 100644 packages/oauth-provider/src/assets/app/pages/error-page.tsx delete mode 100644 packages/oauth-provider/src/assets/app/pages/session-selector-page.tsx diff --git a/packages/oauth-provider/src/assets/app/app.tsx b/packages/oauth-provider/src/assets/app/app.tsx index 55ecd8a2335..b60fc8326ac 100644 --- a/packages/oauth-provider/src/assets/app/app.tsx +++ b/packages/oauth-provider/src/assets/app/app.tsx @@ -1,137 +1,228 @@ import { useMemo, useState } from 'react' -import type { AuthorizeData } from './backend-data' +import type { AuthorizeData, BrandingData } from './backend-data' import { cookies } from './cookies' import { Account, Session } from './types' -import { AcceptPage } from './pages/accept-page' +import { AcceptForm } from './components/accept-form' +import { AccountPicker } from './components/account-picker' import { PageLayout } from './components/page-layout' -import { SessionSelectionPage } from './pages/session-selector-page' - -export function App({ - requestUri, - clientId, - clientMetadata, - csrfCookie, - loginHint, - sessions: initialSessions, -}: AuthorizeData) { +import { SignInForm, SignInFormOutput } from './components/sign-in-form' +import { Api, SignInResponse } from './lib/api' + +type AppProps = { + authorizeData: AuthorizeData + brandingData?: BrandingData +} + +// TODO: show brandingData when "flow" is null + +export function App({ authorizeData }: AppProps) { + const { + requestUri, + clientId, + clientMetadata, + csrfCookie, + loginHint, + sessions: initialSessions, + } = authorizeData + const csrfToken = useMemo(() => cookies[csrfCookie], [csrfCookie]) const [isDone, setIsDone] = useState(false) + // const [flow, setFlow] = useState( + // loginHint != null ? 'sign-in' : null, + // ) const [sessions, setSessions] = useState(initialSessions) - const [selectedSession, onSession] = useState(null) - - const updateSession = useMemo(() => { - return (account: Account, consentRequired: boolean): Session => { - const sessionIdx = sessions.findIndex( - (s) => s.account.sub === account.sub, - ) - if (sessionIdx === -1) { - const newSession: Session = { - initiallySelected: false, - account, - loginRequired: false, - consentRequired, - } - setSessions([...sessions, newSession]) - return newSession - } else { - const curSession = sessions[sessionIdx] - const newSession: Session = { - ...curSession, - initiallySelected: false, - account, - consentRequired, - loginRequired: false, - } - setSessions([ - ...sessions.slice(0, sessionIdx), - newSession, - ...sessions.slice(sessionIdx + 1), - ]) - return newSession - } - } - }, [sessions, setSessions]) - - const onLogin = async (credentials: { - username: string - password: string - remember: boolean - }) => { - const r = await fetch('/oauth/authorize/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'same-origin', - body: JSON.stringify({ - csrf_token: csrfToken, - request_uri: requestUri, - client_id: clientId, - credentials, - }), - }) - const json = await r.json() - if (!r.ok) throw new Error(json.error || 'Error', { cause: json }) - - const { account, info } = json - const consentRequired = !info.authorizedClients.includes(clientId) - const session = updateSession(account, consentRequired) - onSession(session) - } + const accounts = useMemo(() => sessions.map((s) => s.account), [sessions]) + const [showSignInForm, setShowSignInForm] = useState(sessions.length === 0) + const [sub, setSub] = useState( + sessions.find((s) => s.initiallySelected)?.account.sub || null, + ) + const clearSub = () => setSub(null) - const authorizeAccept = async (account: Account) => { - setIsDone(true) + const session = useMemo(() => { + return sub ? sessions.find((s) => s.account.sub == sub) : undefined + }, [sub, sessions]) - const url = new URL('/oauth/authorize/accept', window.origin) - url.searchParams.set('request_uri', requestUri) - url.searchParams.set('account_sub', account.sub) - url.searchParams.set('client_id', clientId) - url.searchParams.set('csrf_token', csrfToken) + const api = useMemo( + () => new Api(requestUri, clientId, csrfToken), + [requestUri, clientId, csrfToken], + ) + + const handleSignIn = async (credentials: SignInFormOutput) => { + const { account, info } = await api.signIn(credentials) + + const newSessions = updateSessions(sessions, { account, info }, clientId) - window.location.href = url.href + setSessions(newSessions) + setSub(account.sub) } - const authorizeReject = () => { + const handleAccept = async (account: Account) => { setIsDone(true) + api.accept(account) + } - const url = new URL('/oauth/authorize/reject', window.origin) - url.searchParams.set('request_uri', requestUri) - url.searchParams.set('client_id', clientId) - url.searchParams.set('csrf_token', csrfToken) - - window.location.href = url.href + const handleReject = () => { + setIsDone(true) + api.reject() } if (isDone) { - // TODO return ( You are being redirected ) } - if (selectedSession) { + // if (!flow) { + // return ( + // + // + // + // + // + // ) + // } + + if (session && !session.loginRequired) { + const { account } = session return ( - onSession(null)} - onAccept={() => authorizeAccept(selectedSession.account)} - onReject={() => authorizeReject()} - account={selectedSession.account} - clientId={clientId} - clientMetadata={clientMetadata} - /> + + Grant access to your{' '} + {account.preferred_username || account.email || account.sub}{' '} + account. + + } + > + handleAccept(account)} + onReject={handleReject} + /> + + ) + } + + if (session && session.loginRequired) { + return ( + + + + ) + } + + if (loginHint) { + return ( + + + + ) + } + + if (sessions.length === 0) { + return ( + + + + ) + } + + if (showSignInForm) { + return ( + + setShowSignInForm(false)} + cancelLabel={'Back' /* to account picker */} + /> + ) } return ( - authorizeReject()} - backLabel="Deny access" - /> + + Select an account to access to{' '} + + {clientMetadata.client_name || + clientMetadata.client_uri || + clientId} + + . + + } + > + setSub(a.sub)} + onOther={() => setShowSignInForm(true)} + onBack={handleReject} + backLabel="Back" + /> + ) } + +function updateSessions( + sessions: readonly Session[], + { account, info }: SignInResponse, + clientId: string, +): Session[] { + const consentRequired = !info.authorizedClients.includes(clientId) + + const sessionIdx = sessions.findIndex((s) => s.account.sub === account.sub) + if (sessionIdx === -1) { + const newSession: Session = { + initiallySelected: false, + account, + loginRequired: false, + consentRequired, + } + return [...sessions, newSession] + } else { + const curSession = sessions[sessionIdx] + const newSession: Session = { + ...curSession, + initiallySelected: false, + account, + consentRequired, + loginRequired: false, + } + return [ + ...sessions.slice(0, sessionIdx), + newSession, + ...sessions.slice(sessionIdx + 1), + ] + } +} diff --git a/packages/oauth-provider/src/assets/app/components/accept-form.tsx b/packages/oauth-provider/src/assets/app/components/accept-form.tsx index f0f3af8cbab..7c79799ff42 100644 --- a/packages/oauth-provider/src/assets/app/components/accept-form.tsx +++ b/packages/oauth-provider/src/assets/app/components/accept-form.tsx @@ -74,9 +74,10 @@ export function AcceptForm({ onClick={() => onBack()} className="bg-transparent font-light text-primary rounded-md py-2" > - Select another account + Back )} +
+
)} - +
) } diff --git a/packages/oauth-provider/src/assets/app/components/error-card.tsx b/packages/oauth-provider/src/assets/app/components/error-card.tsx new file mode 100644 index 00000000000..a7c3419dc9c --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/error-card.tsx @@ -0,0 +1,34 @@ +import { HtmlHTMLAttributes } from 'react' +import type { ErrorData } from '../backend-data' + +export type ErrorCardProps = ErrorData + +export function ErrorCard({ + error: _code, + error_description: message, + ...props +}: ErrorCardProps & HtmlHTMLAttributes) { + return ( +
+
+
+ + + +
+
+

Sorry, something went wrong.

+

{message}

+
+
+
+ ) +} diff --git a/packages/oauth-provider/src/assets/app/components/page-layout.tsx b/packages/oauth-provider/src/assets/app/components/page-layout.tsx index 869cac68d1b..ba74d4922e7 100644 --- a/packages/oauth-provider/src/assets/app/components/page-layout.tsx +++ b/packages/oauth-provider/src/assets/app/components/page-layout.tsx @@ -1,25 +1,36 @@ -import { HTMLAttributes } from 'react' +import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react' -export type PageLayoutProps = { - column?: string | JSX.Element - children: string | JSX.Element -} +export type PageLayoutProps = PropsWithChildren<{ + title?: ReactNode + subtitle?: ReactNode +}> export function PageLayout({ - column, children, - className, + title, + subtitle, ...props -}: PageLayoutProps & HTMLAttributes) { +}: PageLayoutProps & + Omit, keyof PageLayoutProps>) { return (
-
- {column} +
+ {title && ( +

+ {title} +

+ )} + + {subtitle && ( +

+ {subtitle} +

+ )}
diff --git a/packages/oauth-provider/src/assets/app/components/session-picker.tsx b/packages/oauth-provider/src/assets/app/components/session-picker.tsx deleted file mode 100644 index d3695e52d2c..00000000000 --- a/packages/oauth-provider/src/assets/app/components/session-picker.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { HTMLAttributes, useMemo, useState } from 'react' - -import { Session } from '../types' -import { AccountPicker } from './account-picker' -import { LoginForm } from './login-form' - -export type SessionPickerProps = { - sessions: readonly Session[] - loginHint?: string - onSession: (session: Session) => void - onLogin: (credentials: { - username: string - password: string - remember: boolean - }) => void - onBack?: () => void - backLabel?: string | JSX.Element -} - -export function SessionPicker({ - sessions, - onSession, - onLogin, - onBack = undefined, - loginHint = undefined, - backLabel = 'Back', - ...props -}: SessionPickerProps & HTMLAttributes) { - const [showOther, setShowOther] = useState(sessions.length === 0) - - const [sub, setSub] = useState( - sessions.find((s) => s.initiallySelected)?.account.sub || null, - ) - - const selectedSession = useMemo(() => { - return sub ? sessions.find((s) => s.account.sub == sub) : undefined - }, [sub, sessions]) - - const onAccount = useMemo(() => { - return (a) => { - const session = sessions.find((s) => s.account.sub === a.sub) - if (session?.loginRequired) setSub(a.sub) - else if (session) onSession(session) - } - }, [sessions, onSession]) - - if (loginHint) { - return ( - - ) - } - - if (sessions.length === 0) { - return ( - - ) - } - - if (showOther) { - return ( - setShowOther(false)} - backLabel={'Back'} - {...props} - /> - ) - } - - if (selectedSession && selectedSession.loginRequired) { - return ( - setSub(null)} - backLabel={'Back'} - {...props} - /> - ) - } - - const { className, ...rest } = props - return ( -
-

Sign in as...

- s.account)} - onAccount={onAccount} - onOther={() => setShowOther(true)} - {...rest} - /> - {onBack && ( -
- -
- )} -
- ) -} diff --git a/packages/oauth-provider/src/assets/app/components/login-form.tsx b/packages/oauth-provider/src/assets/app/components/sign-in-form.tsx similarity index 71% rename from packages/oauth-provider/src/assets/app/components/login-form.tsx rename to packages/oauth-provider/src/assets/app/components/sign-in-form.tsx index a2df781d894..cfa3015bd7e 100644 --- a/packages/oauth-provider/src/assets/app/components/login-form.tsx +++ b/packages/oauth-provider/src/assets/app/components/sign-in-form.tsx @@ -1,28 +1,37 @@ -import type { FormHTMLAttributes } from 'react' +import { useState, type FormHTMLAttributes, ReactNode } from 'react' -export type LoginFormOutput = { +export type SignInFormOutput = { username: string password: string remember: boolean } -export type LoginFormProps = { - onLogin: (credentials: LoginFormOutput) => void - onBack?: () => void - backLabel?: string | JSX.Element +export type SignInFormProps = { + onSubmit: (credentials: SignInFormOutput) => void + onCancel?: () => void + submitLabel?: ReactNode + cancelLabel?: ReactNode username?: string usernameReadonly?: boolean -} & FormHTMLAttributes + remember?: boolean +} -export function LoginForm({ - onLogin, - onBack = undefined, - backLabel = 'Back', +export function SignInForm({ + onSubmit, + onCancel = undefined, + submitLabel = 'Sign in', + cancelLabel = 'Cancel', username = '', usernameReadonly = false, + remember = false, ...props -}: LoginFormProps) { - const onSubmit = ( +}: SignInFormProps & + Omit, keyof SignInFormProps>) { + const [focused, setFocused] = useState(false) + const onFocus = () => setFocused(true) + const onBlur = () => setFocused(false) + + const handleFormSubmit = ( event: React.SyntheticEvent< HTMLFormElement & { username: HTMLInputElement @@ -33,7 +42,7 @@ export function LoginForm({ >, ) => { event.preventDefault() - onLogin({ + onSubmit({ username: event.currentTarget.username.value, password: event.currentTarget.password.value, remember: event.currentTarget.remember.checked, @@ -41,14 +50,14 @@ export function LoginForm({ } return ( -
+
@
-
- -
- - - - - -
+
-
-
-
+
+
+
-
+

Warning

Please verify the domain name of the website before entering @@ -113,6 +112,27 @@ export function LoginForm({

+ +
+ +
+ + + + + +
@@ -120,16 +140,16 @@ export function LoginForm({ type="submit" className="bg-transparent text-primary rounded-md py-2 font-semibold order-last" > - Next + {submitLabel} - {onBack && ( + {onCancel && ( )}
diff --git a/packages/oauth-provider/src/assets/app/lib/api.ts b/packages/oauth-provider/src/assets/app/lib/api.ts new file mode 100644 index 00000000000..098ef5277df --- /dev/null +++ b/packages/oauth-provider/src/assets/app/lib/api.ts @@ -0,0 +1,54 @@ +import { SignInFormOutput } from '../components/sign-in-form' +import { Account, SignInInfo } from '../types' + +export type SignInResponse = { + account: Account + info: SignInInfo +} + +export class Api { + constructor( + private requestUri: string, + private clientId: string, + private csrfToken: string, + ) {} + + async signIn(credentials: SignInFormOutput): Promise { + const r = await fetch('/oauth/authorize/sign-in', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'same-origin', + body: JSON.stringify({ + csrf_token: this.csrfToken, + request_uri: this.requestUri, + client_id: this.clientId, + credentials, + }), + }) + const json = await r.json() + + // TODO: better error handling + if (!r.ok) throw new Error(json.error || 'Error', { cause: json }) + + return json as SignInResponse + } + + accept(account: Account) { + const url = new URL('/oauth/authorize/accept', window.origin) + url.searchParams.set('request_uri', this.requestUri) + url.searchParams.set('account_sub', account.sub) + url.searchParams.set('client_id', this.clientId) + url.searchParams.set('csrf_token', this.csrfToken) + + window.location.href = url.href + } + + reject() { + const url = new URL('/oauth/authorize/reject', window.origin) + url.searchParams.set('request_uri', this.requestUri) + url.searchParams.set('client_id', this.clientId) + url.searchParams.set('csrf_token', this.csrfToken) + + window.location.href = url.href + } +} diff --git a/packages/oauth-provider/src/assets/app/main.css b/packages/oauth-provider/src/assets/app/main.css index 4ccefccb1f9..1dd899cb889 100644 --- a/packages/oauth-provider/src/assets/app/main.css +++ b/packages/oauth-provider/src/assets/app/main.css @@ -6,7 +6,6 @@ @layer base { :root { --color-primary: 255 115 179; - --color-column: 189 255 253; --color-error: 255 0 0; } } diff --git a/packages/oauth-provider/src/assets/app/main.tsx b/packages/oauth-provider/src/assets/app/main.tsx index 3bfc08386b4..1cecaba4920 100644 --- a/packages/oauth-provider/src/assets/app/main.tsx +++ b/packages/oauth-provider/src/assets/app/main.tsx @@ -2,9 +2,10 @@ import './main.css' import { createRoot } from 'react-dom/client' -import { authorizeData, errorData } from './backend-data' +import { authorizeData, brandingData, errorData } from './backend-data' import { App } from './app' -import { ErrorPage } from './pages/error-page' +import { PageLayout } from './components/page-layout' +import { ErrorCard } from './components/error-card' // When the user is logging in, make sure the page URL contains the // "request_uri" in case the user refreshes the page. @@ -16,15 +17,17 @@ if (authorizeData && url.pathname === '/oauth/authorize') { window.history.replaceState(history.state, '', url.pathname + url.search) } -// TODO: inject brandingData (from backend-data.ts) into the page (logo & co) - const container = document.getElementById('root')! const root = createRoot(container) if (authorizeData) { - root.render() + root.render() } else if (errorData) { - root.render() + root.render( + + + , + ) } else { throw new Error('No data found') } diff --git a/packages/oauth-provider/src/assets/app/pages/accept-page.tsx b/packages/oauth-provider/src/assets/app/pages/accept-page.tsx deleted file mode 100644 index 4dc6c76ea14..00000000000 --- a/packages/oauth-provider/src/assets/app/pages/accept-page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { AcceptForm, AcceptFormProps } from '../components/accept-form' -import { PageLayout } from '../components/page-layout' - -export type AcceptPageProps = AcceptFormProps - -export function AcceptPage(props: AcceptPageProps) { - const { account } = props - const accountName = account.preferred_username || account.email || account.sub - - return ( - -

- Authorize -

-

- Grant access to your {accountName} account. -

- - } - > - -
- ) -} diff --git a/packages/oauth-provider/src/assets/app/pages/error-page.tsx b/packages/oauth-provider/src/assets/app/pages/error-page.tsx deleted file mode 100644 index a9185a962cb..00000000000 --- a/packages/oauth-provider/src/assets/app/pages/error-page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { ErrorData } from '../backend-data' - -import { PageLayout } from '../components/page-layout' - -export type ErrorPageProps = ErrorData - -export function ErrorPage({ - error: _code, - error_description: message, -}: ErrorPageProps) { - return ( - -

- An error occurred -

- - } - > -
-
-
- - - -
-
-

Sorry, something went wrong.

-

{message}

-
-
-
-
- ) -} diff --git a/packages/oauth-provider/src/assets/app/pages/session-selector-page.tsx b/packages/oauth-provider/src/assets/app/pages/session-selector-page.tsx deleted file mode 100644 index e444a4fada7..00000000000 --- a/packages/oauth-provider/src/assets/app/pages/session-selector-page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { PageLayout } from '../components/page-layout' -import { SessionPicker, SessionPickerProps } from '../components/session-picker' -import { ClientMetadata } from '../types' - -export type SessionSelectionPageProps = SessionPickerProps & { - clientId: string - clientMetadata: ClientMetadata -} - -export function SessionSelectionPage({ - clientId, - clientMetadata, - ...props -}: SessionSelectionPageProps) { - const clientName = - clientMetadata.client_name || clientMetadata.client_uri || clientId - - return ( - -

- Sign in as... -

- -

- Select an account to access to {clientName}. -

- - } - > - -
- ) -} diff --git a/packages/oauth-provider/src/assets/app/types.ts b/packages/oauth-provider/src/assets/app/types.ts index 124808a9df6..59485c65ea3 100644 --- a/packages/oauth-provider/src/assets/app/types.ts +++ b/packages/oauth-provider/src/assets/app/types.ts @@ -52,3 +52,9 @@ export type Session = { consentRequired: boolean initiallySelected: boolean } + +export type SignInInfo = { + remembered: boolean + authenticatedAt: string + authorizedClients: readonly string[] +} diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index d94ac71490d..143aa059342 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -1158,7 +1158,7 @@ export class OAuthProvider extends OAuthVerifier { }), }) - router.post('/oauth/authorize/login', async function (req, res) { + router.post('/oauth/authorize/sign-in', async function (req, res) { validateFetchMode(req, res, ['same-origin']) validateSameOrigin(req, res, issuerOrigin) diff --git a/packages/oauth-provider/src/output/branding.ts b/packages/oauth-provider/src/output/branding.ts index 295295bb4ad..26d2c3fe17b 100644 --- a/packages/oauth-provider/src/output/branding.ts +++ b/packages/oauth-provider/src/output/branding.ts @@ -1,5 +1,5 @@ // Matches colors defined in tailwind.config.js -const colorNames = ['primary', 'column', 'error'] as const +const colorNames = ['primary', 'error'] as const type ColorName = typeof colorNames[number] const isColorName = (name: string): name is ColorName => (colorNames as readonly string[]).includes(name) diff --git a/packages/oauth-provider/tailwind.config.js b/packages/oauth-provider/tailwind.config.js index 56bd42b5e22..f2710384390 100644 --- a/packages/oauth-provider/tailwind.config.js +++ b/packages/oauth-provider/tailwind.config.js @@ -5,7 +5,6 @@ export default { extend: { colors: { primary: 'rgb(var(--color-primary) / )', - column: 'rgb(var(--color-column) / )', error: 'rgb(var(--color-error) / )', }, }, diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index cba7e776607..02e128b0208 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -10,7 +10,6 @@ export const createRouter = (ctx: AppContext) => { branding: { colors: { primary: '#0085ff', - column: '#f0f2f5', }, }, // Log oauth provider errors using our own logger From aa559607540ff07883a96e2f7361b1f0b25ba834 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Tue, 12 Mar 2024 17:27:52 +0100 Subject: [PATCH 019/140] feat: add welcome page feat: force consent when logging in from untrusted client feat: merge id_token_hint to login_hint feat(security): prevent extensions scripts from accessing backend injected variables --- .../oauth-provider/src/assets/app/app.tsx | 232 +----------------- .../src/assets/app/backend-data.ts | 17 +- .../src/assets/app/components/accept-form.tsx | 57 +++-- .../app/components/account-identifier.tsx | 17 ++ .../assets/app/components/account-picker.tsx | 34 +-- .../app/components/client-identifier.tsx | 31 +++ .../src/assets/app/components/client-name.tsx | 29 +++ .../src/assets/app/components/error-card.tsx | 11 +- .../src/assets/app/components/page-layout.tsx | 16 +- .../assets/app/components/sign-in-form.tsx | 86 ++++--- .../src/assets/app/components/url-viewer.tsx | 70 ++++++ .../src/assets/app/hooks/use-api.ts | 87 +++++++ .../assets/app/hooks/use-bound-dispatch.ts | 5 + .../src/assets/app/hooks/use-csrf-token.ts | 6 + .../oauth-provider/src/assets/app/lib/api.ts | 34 ++- .../oauth-provider/src/assets/app/lib/clsx.ts | 4 + .../oauth-provider/src/assets/app/lib/util.ts | 10 + .../oauth-provider/src/assets/app/main.tsx | 39 ++- .../oauth-provider/src/assets/app/types.ts | 16 +- .../src/assets/app/views/authorize-view.tsx | 65 +++++ .../src/assets/app/views/error-view.tsx | 17 ++ .../src/assets/app/views/sign-in-view.tsx | 167 +++++++++++++ .../src/assets/app/views/welcome-view.tsx | 61 +++++ packages/oauth-provider/src/oauth-provider.ts | 82 ++----- .../oauth-provider/src/output/branding.ts | 4 +- .../src/output/send-authorize-page.ts | 9 +- .../oauth-provider/src/output/send-web-app.ts | 2 +- .../parameters/authorization-parameters.ts | 2 +- .../src/request/request-manager.ts | 20 +- .../account-manager/helpers/device-account.ts | 2 +- packages/pds/src/auth.ts | 4 +- packages/pds/src/context.ts | 12 + 32 files changed, 835 insertions(+), 413 deletions(-) create mode 100644 packages/oauth-provider/src/assets/app/components/account-identifier.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/client-identifier.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/client-name.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/url-viewer.tsx create mode 100644 packages/oauth-provider/src/assets/app/hooks/use-api.ts create mode 100644 packages/oauth-provider/src/assets/app/hooks/use-bound-dispatch.ts create mode 100644 packages/oauth-provider/src/assets/app/hooks/use-csrf-token.ts create mode 100644 packages/oauth-provider/src/assets/app/lib/clsx.ts create mode 100644 packages/oauth-provider/src/assets/app/lib/util.ts create mode 100644 packages/oauth-provider/src/assets/app/views/authorize-view.tsx create mode 100644 packages/oauth-provider/src/assets/app/views/error-view.tsx create mode 100644 packages/oauth-provider/src/assets/app/views/sign-in-view.tsx create mode 100644 packages/oauth-provider/src/assets/app/views/welcome-view.tsx diff --git a/packages/oauth-provider/src/assets/app/app.tsx b/packages/oauth-provider/src/assets/app/app.tsx index b60fc8326ac..e3348bfc312 100644 --- a/packages/oauth-provider/src/assets/app/app.tsx +++ b/packages/oauth-provider/src/assets/app/app.tsx @@ -1,228 +1,22 @@ -import { useMemo, useState } from 'react' +import type { AuthorizeData, BrandingData, ErrorData } from './backend-data' +import { AuthorizeView } from './views/authorize-view' +import { ErrorView } from './views/error-view' -import type { AuthorizeData, BrandingData } from './backend-data' -import { cookies } from './cookies' -import { Account, Session } from './types' - -import { AcceptForm } from './components/accept-form' -import { AccountPicker } from './components/account-picker' -import { PageLayout } from './components/page-layout' -import { SignInForm, SignInFormOutput } from './components/sign-in-form' -import { Api, SignInResponse } from './lib/api' - -type AppProps = { - authorizeData: AuthorizeData +export type AppProps = { + authorizeData?: AuthorizeData brandingData?: BrandingData + errorData?: ErrorData } -// TODO: show brandingData when "flow" is null - -export function App({ authorizeData }: AppProps) { - const { - requestUri, - clientId, - clientMetadata, - csrfCookie, - loginHint, - sessions: initialSessions, - } = authorizeData - - const csrfToken = useMemo(() => cookies[csrfCookie], [csrfCookie]) - const [isDone, setIsDone] = useState(false) - // const [flow, setFlow] = useState( - // loginHint != null ? 'sign-in' : null, - // ) - const [sessions, setSessions] = useState(initialSessions) - const accounts = useMemo(() => sessions.map((s) => s.account), [sessions]) - const [showSignInForm, setShowSignInForm] = useState(sessions.length === 0) - const [sub, setSub] = useState( - sessions.find((s) => s.initiallySelected)?.account.sub || null, - ) - const clearSub = () => setSub(null) - - const session = useMemo(() => { - return sub ? sessions.find((s) => s.account.sub == sub) : undefined - }, [sub, sessions]) - - const api = useMemo( - () => new Api(requestUri, clientId, csrfToken), - [requestUri, clientId, csrfToken], - ) - - const handleSignIn = async (credentials: SignInFormOutput) => { - const { account, info } = await api.signIn(credentials) - - const newSessions = updateSessions(sessions, { account, info }, clientId) - - setSessions(newSessions) - setSub(account.sub) - } - - const handleAccept = async (account: Account) => { - setIsDone(true) - api.accept(account) - } - - const handleReject = () => { - setIsDone(true) - api.reject() - } - - if (isDone) { +export function App({ authorizeData, brandingData, errorData }: AppProps) { + if (authorizeData) { return ( - You are being redirected - ) - } - - // if (!flow) { - // return ( - // - // - // - // - // - // ) - // } - - if (session && !session.loginRequired) { - const { account } = session - return ( - - Grant access to your{' '} - {account.preferred_username || account.email || account.sub}{' '} - account. - - } - > - handleAccept(account)} - onReject={handleReject} - /> - - ) - } - - if (session && session.loginRequired) { - return ( - - - - ) - } - - if (loginHint) { - return ( - - - - ) - } - - if (sessions.length === 0) { - return ( - - - - ) - } - - if (showSignInForm) { - return ( - - setShowSignInForm(false)} - cancelLabel={'Back' /* to account picker */} - /> - - ) - } - - return ( - - Select an account to access to{' '} - - {clientMetadata.client_name || - clientMetadata.client_uri || - clientId} - - . - - } - > - setSub(a.sub)} - onOther={() => setShowSignInForm(true)} - onBack={handleReject} - backLabel="Back" + - - ) -} - -function updateSessions( - sessions: readonly Session[], - { account, info }: SignInResponse, - clientId: string, -): Session[] { - const consentRequired = !info.authorizedClients.includes(clientId) - - const sessionIdx = sessions.findIndex((s) => s.account.sub === account.sub) - if (sessionIdx === -1) { - const newSession: Session = { - initiallySelected: false, - account, - loginRequired: false, - consentRequired, - } - return [...sessions, newSession] + ) } else { - const curSession = sessions[sessionIdx] - const newSession: Session = { - ...curSession, - initiallySelected: false, - account, - consentRequired, - loginRequired: false, - } - return [ - ...sessions.slice(0, sessionIdx), - newSession, - ...sessions.slice(sessionIdx + 1), - ] + return } } diff --git a/packages/oauth-provider/src/assets/app/backend-data.ts b/packages/oauth-provider/src/assets/app/backend-data.ts index 4734b3a27be..cd09169082b 100644 --- a/packages/oauth-provider/src/assets/app/backend-data.ts +++ b/packages/oauth-provider/src/assets/app/backend-data.ts @@ -1,6 +1,7 @@ import type { ClientMetadata, Session } from './types' export type BrandingData = { + name?: string logo?: string } @@ -15,15 +16,19 @@ export type AuthorizeData = { requestUri: string csrfCookie: string sessions: Session[] - consentRequired: boolean + newSessionsRequireConsent: boolean loginHint?: string } +const readBackendData = (key: string): T | undefined => { + const value = window[key] as T | undefined + delete window[key] // Prevent accidental usage / potential leaks to dependencies + return value +} + // These values are injected by the backend when it builds the // page HTML. -export const brandingData = window['__brandingData'] as BrandingData | undefined -export const errorData = window['__errorData'] as ErrorData | undefined -export const authorizeData = window['__authorizeData'] as - | AuthorizeData - | undefined +export const brandingData = readBackendData('__brandingData') +export const errorData = readBackendData('__errorData') +export const authorizeData = readBackendData('__authorizeData') diff --git a/packages/oauth-provider/src/assets/app/components/accept-form.tsx b/packages/oauth-provider/src/assets/app/components/accept-form.tsx index 7c79799ff42..7dd1f630b03 100644 --- a/packages/oauth-provider/src/assets/app/components/accept-form.tsx +++ b/packages/oauth-provider/src/assets/app/components/accept-form.tsx @@ -1,13 +1,22 @@ import { type HTMLAttributes } from 'react' +import { clsx } from '../lib/clsx' import { Account, ClientMetadata } from '../types' +import { ClientIdentifier } from './client-identifier' +import { ClientName } from './client-name' +import { AccountIdentifier } from './account-identifier' export type AcceptFormProps = { account: Account clientId: string clientMetadata: ClientMetadata onAccept: () => void + acceptLabel?: string + onReject: () => void + rejectLabel?: string + onBack?: () => void + backLabel?: string } export function AcceptForm({ @@ -15,17 +24,16 @@ export function AcceptForm({ clientId, clientMetadata, onAccept, + acceptLabel = 'Accept', onReject, + rejectLabel = 'Deny access', onBack, - ...props -}: AcceptFormProps & HTMLAttributes) { - const clientName = - clientMetadata.client_name || clientMetadata.client_uri || clientId - - const accountName = account.preferred_username || account.email || account.sub + backLabel = 'Back', + ...attrs +}: AcceptFormProps & HTMLAttributes) { return ( -
+
{clientMetadata.logo_uri && (
)} -

- {clientName} -

+

- {clientId} is asking for permission to access your{' '} - {accountName} account. + {' '} + is asking for permission to access your{' '} + account.

- By clicking Accept, you allow this application to access your - information in accordance to its{' '} + By clicking {acceptLabel}, you allow this application to access + your information in accordance to its{' '} terms of service .

-
+
+ +
{onBack && ( )} @@ -83,9 +98,9 @@ export function AcceptForm({
diff --git a/packages/oauth-provider/src/assets/app/components/account-identifier.tsx b/packages/oauth-provider/src/assets/app/components/account-identifier.tsx new file mode 100644 index 00000000000..f8981d5ac05 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/account-identifier.tsx @@ -0,0 +1,17 @@ +import { HTMLAttributes } from 'react' +import { Account } from '../types' + +export type AccountIdentifierProps = { + account: Account +} + +export function AccountIdentifier({ + account, + ...attrs +}: AccountIdentifierProps & HTMLAttributes) { + return ( + + {account.preferred_username || account.email || account.sub} + + ) +} diff --git a/packages/oauth-provider/src/assets/app/components/account-picker.tsx b/packages/oauth-provider/src/assets/app/components/account-picker.tsx index 932fff4ab16..356a5a6a9c3 100644 --- a/packages/oauth-provider/src/assets/app/components/account-picker.tsx +++ b/packages/oauth-provider/src/assets/app/components/account-picker.tsx @@ -1,5 +1,6 @@ import type { HTMLAttributes, ReactNode } from 'react' import { Account } from '../types' +import { clsx } from '../lib/clsx' export type AccountPickerProps = { accounts: readonly Account[] @@ -10,7 +11,7 @@ export type AccountPickerProps = { onBack?: () => void backLabel?: ReactNode -} & HTMLAttributes +} export function AccountPicker({ accounts, @@ -19,16 +20,21 @@ export function AccountPicker({ otherLabel = 'Other account', onBack, backLabel, - ...props -}: AccountPickerProps) { + + className, + ...attrs +}: AccountPickerProps & HTMLAttributes) { return ( -
+

Sign in as...

    {accounts.map((account) => { - const identifier = - account.preferred_username || account.email || account.sub - const name = account.name || identifier + const [name, identifier] = [ + account.name, + account.preferred_username, + account.email, + account.sub, + ].filter(Boolean) as [string, string?] return (
  • onAccount(account)} > -
    +
    {account.picture && ( {account.name} )} {name} - {identifier && identifier !== name && ( + {identifier && ( - {account.preferred_username || - account.email || - account.sub} + {identifier} )} @@ -72,8 +76,10 @@ export function AccountPicker({ )}
+
+ {onBack && ( -
+
diff --git a/packages/oauth-provider/src/assets/app/components/url-viewer.tsx b/packages/oauth-provider/src/assets/app/components/url-viewer.tsx new file mode 100644 index 00000000000..4879a5d8e23 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/url-viewer.tsx @@ -0,0 +1,70 @@ +import { HTMLAttributes, useMemo } from 'react' + +export type UrlPartRenderingOptions = { + faded?: boolean + bold?: boolean +} + +export type UrlRendererProps = { + url: string | URL + proto?: boolean | UrlPartRenderingOptions + host?: boolean | UrlPartRenderingOptions + path?: boolean | UrlPartRenderingOptions + query?: boolean | UrlPartRenderingOptions + hash?: boolean | UrlPartRenderingOptions + as?: keyof JSX.IntrinsicElements +} + +export function UrlViewer({ + url, + proto = false, + host = true, + path = false, + query = false, + hash = false, + as: As = 'span', + ...attrs +}: UrlRendererProps & HTMLAttributes) { + const urlObj = useMemo(() => new URL(url), [url]) + + return ( + + {proto && ( + + )} + {host && ( + + )} + {path && ( + + )} + {query && ( + + )} + {hash && ( + + )} + + ) +} + +function UrlPartViewer({ + value, + faded = true, + bold = false, +}: { value: string } & UrlPartRenderingOptions) { + const Comp = bold ? 'b' : 'span' + return {value} +} diff --git a/packages/oauth-provider/src/assets/app/hooks/use-api.ts b/packages/oauth-provider/src/assets/app/hooks/use-api.ts new file mode 100644 index 00000000000..8e4086c8ec6 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/hooks/use-api.ts @@ -0,0 +1,87 @@ +import { useCallback, useMemo, useState } from 'react' + +import { AuthorizeData } from '../backend-data' +import { SignInFormOutput } from '../components/sign-in-form' +import { Api } from '../lib/api' +import { Account, Session } from '../types' +import { useCsrfToken } from './use-csrf-token' +import { upsert } from '../lib/util' + +export function useApi( + { + clientId, + requestUri, + csrfCookie, + sessions: initialSessions, + newSessionsRequireConsent, + }: AuthorizeData, + { + onRedirected, + }: { + onRedirected?: () => void + } = {}, +) { + const csrfToken = useCsrfToken(csrfCookie) + const [sessions, setSessions] = useState(initialSessions) + + const setSession = useCallback( + (sub: string | null) => { + setSessions((sessions) => + sub === (sessions.find((s) => s.selected)?.account.sub || null) + ? sessions + : sessions.map((s) => ({ ...s, selected: s.account.sub === sub })), + ) + }, + [setSessions], + ) + + const api = useMemo( + () => new Api(requestUri, clientId, csrfToken, newSessionsRequireConsent), + [requestUri, clientId, csrfToken, newSessionsRequireConsent], + ) + + const performRedirect = useCallback( + (url: URL) => { + window.location.href = String(url) + if (onRedirected) setTimeout(onRedirected) + }, + [onRedirected], + ) + + const doSignIn = useCallback( + async (credentials: SignInFormOutput): Promise => { + const session = await api.signIn(credentials) + const { sub } = session.account + + setSessions((sessions) => { + return upsert(sessions, session, (s) => s.account.sub === sub).map( + // Make sure to de-select any other selected session + (s) => (s === session || !s.selected ? s : { ...s, selected: false }), + ) + }) + + return sub + }, + [api, performRedirect, clientId, setSessions], + ) + + const doAccept = useCallback( + async (account: Account) => { + performRedirect(await api.accept(account)) + }, + [api, performRedirect], + ) + + const doReject = useCallback(async () => { + performRedirect(await api.reject()) + }, [api, performRedirect]) + + return { + sessions, + setSession, + + doSignIn, + doAccept, + doReject, + } +} diff --git a/packages/oauth-provider/src/assets/app/hooks/use-bound-dispatch.ts b/packages/oauth-provider/src/assets/app/hooks/use-bound-dispatch.ts new file mode 100644 index 00000000000..8945ea41989 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/hooks/use-bound-dispatch.ts @@ -0,0 +1,5 @@ +import { Dispatch, useCallback } from 'react' + +export function useBoundDispatch(dispatch: Dispatch, value: A) { + return useCallback(() => dispatch(value), [dispatch, value]) +} diff --git a/packages/oauth-provider/src/assets/app/hooks/use-csrf-token.ts b/packages/oauth-provider/src/assets/app/hooks/use-csrf-token.ts new file mode 100644 index 00000000000..3dc92ef67ae --- /dev/null +++ b/packages/oauth-provider/src/assets/app/hooks/use-csrf-token.ts @@ -0,0 +1,6 @@ +import { useMemo } from 'react' +import { cookies } from '../cookies' + +export function useCsrfToken(cookieName: string) { + return useMemo(() => cookies[cookieName], [cookieName]) +} diff --git a/packages/oauth-provider/src/assets/app/lib/api.ts b/packages/oauth-provider/src/assets/app/lib/api.ts index 098ef5277df..6c6d04e4336 100644 --- a/packages/oauth-provider/src/assets/app/lib/api.ts +++ b/packages/oauth-provider/src/assets/app/lib/api.ts @@ -1,19 +1,15 @@ import { SignInFormOutput } from '../components/sign-in-form' -import { Account, SignInInfo } from '../types' - -export type SignInResponse = { - account: Account - info: SignInInfo -} +import { Account, Info, Session } from '../types' export class Api { constructor( private requestUri: string, private clientId: string, private csrfToken: string, + private newSessionsRequireConsent: boolean, ) {} - async signIn(credentials: SignInFormOutput): Promise { + async signIn(credentials: SignInFormOutput): Promise { const r = await fetch('/oauth/authorize/sign-in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -30,25 +26,39 @@ export class Api { // TODO: better error handling if (!r.ok) throw new Error(json.error || 'Error', { cause: json }) - return json as SignInResponse + const { account, info } = json as { + account: Account + info: Info + } + + return { + account, + info, + + selected: true, + consentRequired: + this.newSessionsRequireConsent || + !info.authorizedClients.includes(this.clientId), + loginRequired: false, + } } - accept(account: Account) { + async accept(account: Account): Promise { const url = new URL('/oauth/authorize/accept', window.origin) url.searchParams.set('request_uri', this.requestUri) url.searchParams.set('account_sub', account.sub) url.searchParams.set('client_id', this.clientId) url.searchParams.set('csrf_token', this.csrfToken) - window.location.href = url.href + return url } - reject() { + async reject(): Promise { const url = new URL('/oauth/authorize/reject', window.origin) url.searchParams.set('request_uri', this.requestUri) url.searchParams.set('client_id', this.clientId) url.searchParams.set('csrf_token', this.csrfToken) - window.location.href = url.href + return url } } diff --git a/packages/oauth-provider/src/assets/app/lib/clsx.ts b/packages/oauth-provider/src/assets/app/lib/clsx.ts new file mode 100644 index 00000000000..d60a7e5d722 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/lib/clsx.ts @@ -0,0 +1,4 @@ +export function clsx(a: string | undefined, b?: string) { + if (a && b) return `${a} ${b}` + return a || b +} diff --git a/packages/oauth-provider/src/assets/app/lib/util.ts b/packages/oauth-provider/src/assets/app/lib/util.ts new file mode 100644 index 00000000000..b27faabb0d1 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/lib/util.ts @@ -0,0 +1,10 @@ +export function upsert( + arr: readonly T[], + item: T, + predicate: (value: T, index: number, obj: readonly T[]) => boolean, +): T[] { + const idx = arr.findIndex(predicate) + return idx === -1 + ? [...arr, item] + : [...arr.slice(0, idx), item, ...arr.slice(idx + 1)] +} diff --git a/packages/oauth-provider/src/assets/app/main.tsx b/packages/oauth-provider/src/assets/app/main.tsx index 1cecaba4920..a9b357f33f5 100644 --- a/packages/oauth-provider/src/assets/app/main.tsx +++ b/packages/oauth-provider/src/assets/app/main.tsx @@ -2,32 +2,25 @@ import './main.css' import { createRoot } from 'react-dom/client' -import { authorizeData, brandingData, errorData } from './backend-data' import { App } from './app' -import { PageLayout } from './components/page-layout' -import { ErrorCard } from './components/error-card' +import * as backendData from './backend-data' +import { authorizeData } from './backend-data' -// When the user is logging in, make sure the page URL contains the -// "request_uri" in case the user refreshes the page. -const url = new URL(window.location.href) -if (authorizeData && url.pathname === '/oauth/authorize') { - url.search = '' - url.searchParams.set('client_id', authorizeData.clientId) - url.searchParams.set('request_uri', authorizeData.requestUri) - window.history.replaceState(history.state, '', url.pathname + url.search) +if (authorizeData) { + // When the user is logging in, make sure the page URL contains the + // "request_uri" in case the user refreshes the page. + const url = new URL(window.location.href) + if ( + url.pathname === '/oauth/authorize' && + !url.searchParams.has('request_uri') + ) { + url.search = '' + url.searchParams.set('client_id', authorizeData.clientId) + url.searchParams.set('request_uri', authorizeData.requestUri) + window.history.replaceState(history.state, '', url.pathname + url.search) + } } const container = document.getElementById('root')! const root = createRoot(container) - -if (authorizeData) { - root.render() -} else if (errorData) { - root.render( - - - , - ) -} else { - throw new Error('No data found') -} +root.render() diff --git a/packages/oauth-provider/src/assets/app/types.ts b/packages/oauth-provider/src/assets/app/types.ts index 59485c65ea3..36512d5c2f1 100644 --- a/packages/oauth-provider/src/assets/app/types.ts +++ b/packages/oauth-provider/src/assets/app/types.ts @@ -46,15 +46,17 @@ export type Account = { updated_at?: number } +export type Info = { + remembered: boolean + authenticatedAt: string + authorizedClients: readonly string[] +} + export type Session = { account: Account + info: Info + + selected: boolean loginRequired: boolean consentRequired: boolean - initiallySelected: boolean -} - -export type SignInInfo = { - remembered: boolean - authenticatedAt: string - authorizedClients: readonly string[] } diff --git a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx new file mode 100644 index 00000000000..fb2bc78cb99 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' + +import type { AuthorizeData, BrandingData } from '../backend-data' +import { PageLayout } from '../components/page-layout' +import { useBoundDispatch } from '../hooks/use-bound-dispatch' +import { useApi } from '../hooks/use-api' +import { SignInView } from './sign-in-view' +import { WelcomeView } from './welcome-view' + +export type AuthorizeViewProps = { + authorizeData: AuthorizeData + brandingData?: BrandingData +} + +export function AuthorizeView({ + authorizeData, + brandingData, +}: AuthorizeViewProps) { + const forceSignIn = authorizeData?.loginHint != null + + const [view, setView] = useState<'welcome' | 'sign-in' | 'done'>( + forceSignIn ? 'sign-in' : 'welcome', + ) + + const showDone = useBoundDispatch(setView, 'done') + const showSignIn = useBoundDispatch(setView, 'sign-in') + const showWelcome = useBoundDispatch(setView, 'welcome') + + const { sessions, setSession, doAccept, doReject, doSignIn } = useApi( + authorizeData, + { onRedirected: showDone }, + ) + + if (view === 'welcome') { + return ( + + ) + } + + if (view === 'sign-in') { + return ( + + ) + } + + return ( + You are being redirected... + ) +} diff --git a/packages/oauth-provider/src/assets/app/views/error-view.tsx b/packages/oauth-provider/src/assets/app/views/error-view.tsx new file mode 100644 index 00000000000..9350bfe0509 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/views/error-view.tsx @@ -0,0 +1,17 @@ +import { ErrorCard, ErrorCardProps } from '../components/error-card' +import { PageLayout } from '../components/page-layout' + +export type ErrorViewProps = ErrorCardProps & { + title?: string +} + +export function ErrorView({ + title = 'An error occurred', + ...props +}: ErrorViewProps) { + return ( + + + + ) +} diff --git a/packages/oauth-provider/src/assets/app/views/sign-in-view.tsx b/packages/oauth-provider/src/assets/app/views/sign-in-view.tsx new file mode 100644 index 00000000000..c0b82fb4171 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/views/sign-in-view.tsx @@ -0,0 +1,167 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { AcceptForm } from '../components/accept-form' +import { AccountPicker } from '../components/account-picker' +import { ClientName } from '../components/client-name' +import { PageLayout } from '../components/page-layout' +import { SignInForm, SignInFormOutput } from '../components/sign-in-form' +import { Account, ClientMetadata, Session } from '../types' + +export type SignInViewProps = { + clientId: string + clientMetadata: ClientMetadata + sessions: readonly Session[] + setSession: (sub: string | null) => void + loginHint?: string + + onAccept: (account: Account) => void + onReject: () => void + onSignIn: (credentials: SignInFormOutput) => string | PromiseLike + onBack?: () => void +} + +export function SignInView({ + clientId, + clientMetadata, + loginHint, + sessions, + setSession, + + onAccept, + onReject, + onSignIn, + onBack = onReject, +}: SignInViewProps) { + const session = useMemo(() => sessions.find((s) => s.selected), [sessions]) + const clearSession = useCallback(() => setSession(null), [setSession]) + const accounts = useMemo(() => sessions.map((s) => s.account), [sessions]) + const [showSignInForm, setShowSignInForm] = useState(sessions.length === 0) + + // Automatically accept + useEffect(() => { + if (session && !session.loginRequired && !session.consentRequired) { + onAccept(session.account) + } + }, [session]) + + const doAccept = useCallback( + () => (session ? onAccept(session.account) : undefined), + [onAccept, session], + ) + + const doSignIn = useCallback( + async (credentials: SignInFormOutput) => { + await onSignIn(credentials) + }, + [onSignIn], + ) + + if (session && !session.loginRequired) { + const { account } = session + return ( + + Grant access to your{' '} + {account.preferred_username || account.email || account.sub}{' '} + account. + + } + > + + + ) + } + + if (session && session.loginRequired) { + return ( + + + + ) + } + + if (loginHint) { + return ( + + + + ) + } + + if (sessions.length === 0) { + return ( + + + + ) + } + + if (showSignInForm) { + return ( + + setShowSignInForm(false)} + cancelLabel="Back" // to account picker + /> + + ) + } + + return ( + + Select an account to access to{' '} + + . + + } + > + setSession(a.sub)} + onOther={() => setShowSignInForm(true)} + onBack={onBack} + backLabel="Back" // to previous view + /> + + ) +} diff --git a/packages/oauth-provider/src/assets/app/views/welcome-view.tsx b/packages/oauth-provider/src/assets/app/views/welcome-view.tsx new file mode 100644 index 00000000000..964b62c5f5e --- /dev/null +++ b/packages/oauth-provider/src/assets/app/views/welcome-view.tsx @@ -0,0 +1,61 @@ +export type WelcomeViewParams = { + title?: string + logo?: string + logoAlt?: string + + onSignIn?: () => void + signInLabel?: string + + onSignUp?: () => void + signUpLabel?: string + + onCancel?: () => void + cancelLabel?: string +} + +export function WelcomeView({ + title, + logo, + logoAlt = title || 'Logo', + onSignIn, + signInLabel = 'Sign in', + onSignUp, + signUpLabel = 'Sign up', + onCancel, + cancelLabel = 'Cancel', +}: WelcomeViewParams) { + return ( +
+ {logo && {logoAlt}} + + {title &&

{title}

} + + {onSignIn && ( + + )} + + {onSignUp && ( + + )} + + {onCancel && ( + + )} +
+ ) +} diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 143aa059342..bd5a4a45102 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -16,7 +16,7 @@ import { writeJson, } from '@atproto/http-util' import { Jwks, Jwt, Keyset, jwtSchema } from '@atproto/jwk' -import { JWTHeaderParameters, ResolvedKey, decodeJwt } from 'jose' +import { JWTHeaderParameters, ResolvedKey } from 'jose' import { z } from 'zod' import { AccessTokenType } from './access-token/access-token-type.js' @@ -30,6 +30,7 @@ import { asAccountStore, } from './account/account-store.js' import { Account } from './account/account.js' +import { authorizeAssetsMiddleware } from './assets/assets-middleware.js' import { ClientAuth, authJwkThumbprint } from './client/client-auth.js' import { CLIENT_ASSERTION_TYPE_JWT_BEARER, @@ -56,7 +57,7 @@ import { } from './metadata/build-metadata.js' import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js' import { Userinfo } from './oidc/userinfo.js' -import { authorizeAssetsMiddleware } from './assets/assets-middleware.js' +import { Branding } from './output/branding.js' import { buildErrorPayload, buildErrorStatus, @@ -115,7 +116,6 @@ import { } from './token/types.js' import { VerifyTokenClaimsOptions } from './token/verify-token-claims.js' import { dateToEpoch, dateToRelativeSeconds } from './util/date.js' -import { Branding } from './output/branding.js' export type OAuthProviderStore = Partial< ClientStore & @@ -252,25 +252,6 @@ export class OAuthProvider extends OAuthVerifier { return Math.floor(authAge) > Math.floor(maxAge) } - protected consentRequired( - client: Client, - clientAuth: ClientAuth, - parameters: AuthorizationParameters, - info: DeviceAccountInfo, - ) { - // Every client must have been granted consent at least once - if (!info.authorizedClients.includes(client.id)) return true - - // Allow listed clients can skip consent event without credentials - // TODO: make this configurable - if (client.id === 'bsky.app') return false - - // Unauthenticated clients must always go through consent - if (clientAuth.method === 'none') return true - - return false - } - protected async authenticateClient( client: Client, endpoint: 'token' | 'introspection' | 'revocation', @@ -490,48 +471,33 @@ export class OAuthProvider extends OAuthVerifier { info: DeviceAccountInfo ssoAllowed: boolean - initiallySelected: boolean + selected: boolean loginRequired: boolean consentRequired: boolean }[] > { const accounts = await this.accountManager.list(deviceId) - const idTokenSub = - parameters.id_token_hint != null - ? // token already validated by RequestManager.validate() - decodeJwt(parameters.id_token_hint).sub || null - : null - - const hasHint = Boolean(parameters.id_token_hint || parameters.login_hint) - const hintSub = // Only take the hint into account if they match each other - parameters.login_hint && idTokenSub - ? parameters.login_hint === idTokenSub - ? idTokenSub - : null - : parameters.login_hint || idTokenSub || null - - return accounts.map(({ account, info }) => { - const consentRequired = this.consentRequired( - client, - clientAuth, - parameters, - info, - ) - const loginRequired = this.loginRequired(client, parameters, info) - const matchesHint = hintSub != null && hintSub === account.sub - - return { - account, - info, - - ssoAllowed: parameters.prompt === 'none' && (!hasHint || matchesHint), - loginRequired: parameters.prompt === 'login' || loginRequired, - consentRequired: parameters.prompt === 'consent' || consentRequired, - initiallySelected: - parameters.prompt !== 'select_account' && matchesHint, - } - }) + return accounts.map(({ account, info }) => ({ + account, + info, + + selected: + parameters.prompt !== 'select_account' && + parameters.login_hint === account.sub, + loginRequired: + parameters.prompt === 'login' || + this.loginRequired(client, parameters, info), + consentRequired: + parameters.prompt === 'login' || + parameters.prompt === 'consent' || + !info.authorizedClients.includes(client.id), + + ssoAllowed: + parameters.prompt === 'none' && + (parameters.login_hint === account.sub || + parameters.login_hint == null), + })) } protected async login( diff --git a/packages/oauth-provider/src/output/branding.ts b/packages/oauth-provider/src/output/branding.ts index 26d2c3fe17b..6cb16158238 100644 --- a/packages/oauth-provider/src/output/branding.ts +++ b/packages/oauth-provider/src/output/branding.ts @@ -5,12 +5,14 @@ const isColorName = (name: string): name is ColorName => (colorNames as readonly string[]).includes(name) export type Branding = { + name?: string logo?: string colors?: { [_ in ColorName]?: string } } -export function buildBrandingData({ logo }: Branding = {}) { +export function buildBrandingData({ name, logo }: Branding = {}) { return { + name, logo, } } diff --git a/packages/oauth-provider/src/output/send-authorize-page.ts b/packages/oauth-provider/src/output/send-authorize-page.ts index ba079e2d32f..8a8a321ef20 100644 --- a/packages/oauth-provider/src/output/send-authorize-page.ts +++ b/packages/oauth-provider/src/output/send-authorize-page.ts @@ -2,6 +2,7 @@ import { IncomingMessage, ServerResponse } from 'node:http' import { cssCode, html } from '@atproto/html' +import { DeviceAccountInfo } from '../account/account-store.js' import { Account } from '../account/account.js' import { getAsset } from '../assets/index.js' import { Client } from '../client/client.js' @@ -18,9 +19,11 @@ export type AuthorizationResultAuthorize = { uri: RequestUri sessions: readonly { account: Account + info: DeviceAccountInfo + + selected: boolean loginRequired: boolean consentRequired: boolean - initiallySelected: boolean }[] } } @@ -32,7 +35,9 @@ function buildAuthorizeData(data: AuthorizationResultAuthorize) { clientId: data.client.id, clientMetadata: data.client.metadata, loginHint: data.parameters.login_hint, - consentRequired: data.parameters.prompt === 'consent', + newSessionsRequireConsent: + data.parameters.prompt === 'login' || + data.parameters.prompt === 'consent', sessions: data.authorize.sessions, } } diff --git a/packages/oauth-provider/src/output/send-web-app.ts b/packages/oauth-provider/src/output/send-web-app.ts index 845453cb0f8..31475a41b46 100644 --- a/packages/oauth-provider/src/output/send-web-app.ts +++ b/packages/oauth-provider/src/output/send-web-app.ts @@ -81,7 +81,7 @@ export async function sendWebApp( export function declareBrowserGlobalVar(name: string, data: unknown) { const nameJson = jsonCode(name) const dataJson = jsonCode(data) - return html`window[${nameJson}]=${dataJson};` + return html`window[${nameJson}]=${dataJson};document.currentScript.remove();` } function scriptToHtml(a: Html | Asset) { diff --git a/packages/oauth-provider/src/parameters/authorization-parameters.ts b/packages/oauth-provider/src/parameters/authorization-parameters.ts index a6d3a27c895..391ee555c2b 100644 --- a/packages/oauth-provider/src/parameters/authorization-parameters.ts +++ b/packages/oauth-provider/src/parameters/authorization-parameters.ts @@ -117,7 +117,7 @@ export const authorizationParametersSchema = z.object({ // Not supported by this library (yet?) // registration: clientMetadataSchema.optional(), - login_hint: z.string().optional(), + login_hint: z.string().min(1).optional(), ui_locales: z .string() diff --git a/packages/oauth-provider/src/request/request-manager.ts b/packages/oauth-provider/src/request/request-manager.ts index 7fc419e1faa..56cbcee9426 100644 --- a/packages/oauth-provider/src/request/request-manager.ts +++ b/packages/oauth-provider/src/request/request-manager.ts @@ -207,7 +207,8 @@ export class RequestManager { ) } - // TODO Validate parameters agains client metadata !!!! + // TODO Validate parameters against **all** client metadata (are some checks + // missing?) !!! if (!parameters.scope) { parameters = { ...parameters, scope: client.metadata.scope } @@ -232,7 +233,7 @@ export class RequestManager { ) { if (!scopes?.includes(scope)) { throw new InvalidRequestError( - `essential ${claim} claim requires "${scope}" scope`, + `Essential ${claim} claim requires "${scope}" scope`, ) } } @@ -242,15 +243,26 @@ export class RequestManager { // Make "expensive" checks after the "cheaper" checks if (parameters.id_token_hint != null) { - await this.signer.verify(parameters.id_token_hint, { + const { payload } = await this.signer.verify(parameters.id_token_hint, { // these are meant to be outdated when used as a hint clockTolerance: Infinity, }) + + if (!payload.sub) { + throw new InvalidRequestError(`Unexpected empty id_token_hint "sub"`) + } else if (parameters.login_hint == null) { + parameters = { ...parameters, login_hint: payload.sub } + } else if (parameters.login_hint !== payload.sub) { + throw new InvalidRequestError( + 'login_hint does not match "sub" of id_token_hint', + ) + } } await this.hooks.onAuthorizationRequest?.call( null, - parameters, // can be modified by the hook (already a cloned value) + // allow hooks to alter the parameters, without altering the (readonly) input + (parameters = structuredClone(parameters)), { client, clientAuth }, ) diff --git a/packages/pds/src/account-manager/helpers/device-account.ts b/packages/pds/src/account-manager/helpers/device-account.ts index 7a9e31ad57f..0979f952dd9 100644 --- a/packages/pds/src/account-manager/helpers/device-account.ts +++ b/packages/pds/src/account-manager/helpers/device-account.ts @@ -71,7 +71,7 @@ export function toAccount( aud: audience, email: row.email || undefined, email_verified: row.email ? row.emailConfirmedAt != null : undefined, - preferred_username: row.handle || undefined, + preferred_username: row.handle ? `@${row.handle}` : undefined, } } diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts index 02e128b0208..4894f975c9d 100644 --- a/packages/pds/src/auth.ts +++ b/packages/pds/src/auth.ts @@ -8,8 +8,10 @@ export const createRouter = (ctx: AppContext) => { ctx.oauthProvider?.httpHandler({ // TODO: This must come from config branding: { + name: 'My PDS', + logo: 'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png', colors: { - primary: '#0085ff', + primary: '#ffcb1e', // #0085ff }, }, // Log oauth provider errors using our own logger diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index dfea7f59be8..68fa338c17b 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -256,6 +256,18 @@ export class AppContext { // always use up-to-date token data from the token store. accessTokenType: AccessTokenType.id, + onAuthorizationRequest: (parameters, { client, clientAuth }) => { + // ATPROTO extension: if the client is not "trustable", force the + // user to consent to the request. We do this to avoid + // unauthenticated clients from being able to silently + // re-authenticate users. + + // TODO: make allow listed client ids configurable + if (clientAuth.method === 'none' && client.id !== 'bsky.app') { + parameters.prompt ||= 'consent' + } + }, + onTokenResponse: (tokenResponse, { account }) => { // ATPROTO extension: add the sub claim to the token response to allow // clients to resolve the PDS url (audience) using the did resolution From 576722e8005ac2d38b0d2963a9e785ee3a4683dd Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Tue, 12 Mar 2024 17:32:39 +0100 Subject: [PATCH 020/140] fix: minor ux improvement --- .../oauth-provider/src/assets/app/views/sign-in-view.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/oauth-provider/src/assets/app/views/sign-in-view.tsx b/packages/oauth-provider/src/assets/app/views/sign-in-view.tsx index c0b82fb4171..1a03e7812c9 100644 --- a/packages/oauth-provider/src/assets/app/views/sign-in-view.tsx +++ b/packages/oauth-provider/src/assets/app/views/sign-in-view.tsx @@ -37,11 +37,18 @@ export function SignInView({ const accounts = useMemo(() => sessions.map((s) => s.account), [sessions]) const [showSignInForm, setShowSignInForm] = useState(sessions.length === 0) - // Automatically accept useEffect(() => { + // Automatically accept if (session && !session.loginRequired && !session.consentRequired) { onAccept(session.account) } + + // Make sure the "back" action shows the account picker instead of the + // sign-in form (since the account was added to the list of current + // sessions). + if (session) { + setShowSignInForm(false) + } }, [session]) const doAccept = useCallback( From 6cbcc585980447f697d0683c078d7568912d4914 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 13 Mar 2024 12:13:59 +0100 Subject: [PATCH 021/140] feat(pds): load branding from env --- .../src/assets/app/backend-data.ts | 5 + .../src/assets/app/views/authorize-view.tsx | 1 + .../src/assets/app/views/welcome-view.tsx | 86 +++++++---- .../oauth-provider/src/dpop/dpop-manager.ts | 24 +-- packages/oauth-provider/src/oauth-provider.ts | 2 +- .../oauth-provider/src/output/branding.ts | 10 +- packages/pds/example.env | 10 +- packages/pds/package.json | 1 - packages/pds/src/auth-provider.ts | 95 ++++++++++++ packages/pds/src/auth-routes.ts | 11 ++ packages/pds/src/auth-verifier.ts | 57 ++++---- packages/pds/src/auth.ts | 23 --- packages/pds/src/config/config.ts | 75 ++++++---- packages/pds/src/config/env.ts | 20 ++- packages/pds/src/context.ts | 138 +++++------------- packages/pds/src/index.ts | 4 +- pnpm-lock.yaml | 3 - 17 files changed, 329 insertions(+), 236 deletions(-) create mode 100644 packages/pds/src/auth-provider.ts create mode 100644 packages/pds/src/auth-routes.ts delete mode 100644 packages/pds/src/auth.ts diff --git a/packages/oauth-provider/src/assets/app/backend-data.ts b/packages/oauth-provider/src/assets/app/backend-data.ts index cd09169082b..d4403a274ce 100644 --- a/packages/oauth-provider/src/assets/app/backend-data.ts +++ b/packages/oauth-provider/src/assets/app/backend-data.ts @@ -3,6 +3,11 @@ import type { ClientMetadata, Session } from './types' export type BrandingData = { name?: string logo?: string + links?: Array<{ + name: string + href: string + rel?: string + }> } export type ErrorData = { diff --git a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx index fb2bc78cb99..9131ecdefc0 100644 --- a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx +++ b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx @@ -36,6 +36,7 @@ export function AuthorizeView({ onSignIn?: () => void signInLabel?: string @@ -17,6 +22,7 @@ export function WelcomeView({ title, logo, logoAlt = title || 'Logo', + links, onSignIn, signInLabel = 'Sign in', onSignUp, @@ -25,36 +31,60 @@ export function WelcomeView({ cancelLabel = 'Cancel', }: WelcomeViewParams) { return ( -
- {logo && {logoAlt}} - - {title &&

{title}

} - - {onSignIn && ( - - )} +
) diff --git a/packages/oauth-provider/src/dpop/dpop-manager.ts b/packages/oauth-provider/src/dpop/dpop-manager.ts index e51b1fe3498..e337a3cc71a 100644 --- a/packages/oauth-provider/src/dpop/dpop-manager.ts +++ b/packages/oauth-provider/src/dpop/dpop-manager.ts @@ -9,22 +9,28 @@ import { DpopNonce } from './dpop-nonce.js' export { DpopNonce } export type DpopManagerOptions = { /** - * Set this to `false` to disable the use of DPoP nonces. Set this to a secret - * Uint8Array to use a predictable seed for all nonces (typically useful when - * multiple instances are running). Leave undefined to generate a random seed - * at startup. + * Set this to `false` to disable the use of nonces in DPoP proofs. Set this + * to a secret Uint8Array or hex encoded string to use a predictable seed for + * all nonces (typically useful when multiple instances are running). Leave + * undefined to generate a random seed at startup. */ - dpopNonce?: false | Uint8Array | DpopNonce + dpopSecret?: false | string | Uint8Array | DpopNonce } export class DpopManager { protected readonly dpopNonce?: DpopNonce - constructor({ dpopNonce = new DpopNonce() }: DpopManagerOptions = {}) { + constructor({ dpopSecret }: DpopManagerOptions = {}) { this.dpopNonce = - dpopNonce instanceof Uint8Array - ? new DpopNonce(dpopNonce) - : dpopNonce || undefined + dpopSecret === false + ? undefined + : typeof dpopSecret === 'string' + ? new DpopNonce(Buffer.from(dpopSecret, 'hex')) + : dpopSecret instanceof Uint8Array + ? new DpopNonce(dpopSecret) + : dpopSecret instanceof DpopNonce + ? dpopSecret + : new DpopNonce() } nextNonce(): string | undefined { diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index bd5a4a45102..7ff99192667 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -126,7 +126,7 @@ export type OAuthProviderStore = Partial< ReplayStore > -export { Keyset, type CustomMetadata, type Handler } +export { Keyset, type Branding, type CustomMetadata, type Handler } export type OAuthProviderOptions = OAuthVerifierOptions & { /** * Maximum age a device/account session can be before requiring diff --git a/packages/oauth-provider/src/output/branding.ts b/packages/oauth-provider/src/output/branding.ts index 6cb16158238..a542204481f 100644 --- a/packages/oauth-provider/src/output/branding.ts +++ b/packages/oauth-provider/src/output/branding.ts @@ -8,12 +8,18 @@ export type Branding = { name?: string logo?: string colors?: { [_ in ColorName]?: string } + links?: Array<{ + name: string + href: string + rel?: string + }> } -export function buildBrandingData({ name, logo }: Branding = {}) { +export function buildBrandingData({ name, logo, links }: Branding = {}) { return { name, logo, + links, } } @@ -21,7 +27,7 @@ export function buildBrandingCss(branding?: Branding) { if (!branding?.colors) return '' const vars = Object.entries(branding.colors) - .filter((e) => isColorName(e[0])) + .filter((e) => isColorName(e[0]) && e[1] != null) .map(([name, value]) => [name, parseColor(value)] as const) .filter((e): e is [ColorName, ParsedColor] => e[1] != null) // alpha not supported by tailwind (it does not work that way) diff --git a/packages/pds/example.env b/packages/pds/example.env index b4fbcc1fe27..37e17331f29 100644 --- a/packages/pds/example.env +++ b/packages/pds/example.env @@ -24,9 +24,17 @@ PDS_BSKY_APP_VIEW_ENDPOINT="https://api.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_DID="did:web:api.bsky-sandbox.dev" PDS_CRAWLERS="https://bgs.bsky-sandbox.dev" +# OAuth Provider +PDS_OAUTH_PROVIDER_NAME="John's self hosted PDS" +PDS_OAUTH_PROVIDER_LOGO= +PDS_OAUTH_PROVIDER_PRIMARY_COLOR="#7507e3" +PDS_OAUTH_PROVIDER_ERROR_COLOR= +PDS_OAUTH_PROVIDER_HOME_LINK= +PDS_OAUTH_PROVIDER_TOS_LINK= + # Debugging NODE_TLS_REJECT_UNAUTHORIZED=1 LOG_ENABLED=0 LOG_LEVEL=info PDS_INVITE_REQUIRED=1 -PDS_FETCH_DISABLE_SAFETIES=0 +PDS_DISABLE_SSRF=0 diff --git a/packages/pds/package.json b/packages/pds/package.json index 71e8763d116..0f144ea2ed8 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -35,7 +35,6 @@ "@atproto/crypto": "workspace:^", "@atproto/fetch": "workspace:*", "@atproto/fetch-node": "workspace:*", - "@atproto/http-util": "workspace:*", "@atproto/identity": "workspace:^", "@atproto/jwk": "workspace:^", "@atproto/jwk-node": "workspace:^", diff --git a/packages/pds/src/auth-provider.ts b/packages/pds/src/auth-provider.ts new file mode 100644 index 00000000000..ccb2de45ffb --- /dev/null +++ b/packages/pds/src/auth-provider.ts @@ -0,0 +1,95 @@ +import { safeFetchWrap } from '@atproto/fetch-node' +import { + AccessTokenType, + Branding, + Keyset, + OAuthProvider, +} from '@atproto/oauth-provider' +import { AccountManager } from './account-manager' +import { fetchLogger, oauthLogger } from './logger' +import { OauthClientStore } from './oauth/oauth-client-store' +import { Redis } from 'ioredis' +import { OAuthReplayStoreRedis } from '@atproto/oauth-provider-replay-redis' +import { OAuthReplayStoreMemory } from '@atproto/oauth-provider-replay-memory' + +export class AuthProvider extends OAuthProvider { + constructor( + accountManager: AccountManager, + keyset: Keyset, + redis: Redis | undefined, + dpopSecret: false | string | Uint8Array, + issuer: string, + private branding?: Branding, + disableSsrf = false, + ) { + super({ + issuer, + keyset, + dpopSecret, + + accountStore: accountManager, + requestStore: accountManager, + sessionStore: accountManager, + tokenStore: accountManager, + replayStore: redis + ? new OAuthReplayStoreRedis(redis) + : new OAuthReplayStoreMemory(), + clientStore: new OauthClientStore({ + // A Fetch function that protects against SSRF attacks, large responses & + // known bad domains. This function can safely be used to fetch user + // provided URLs. + fetch: safeFetchWrap({ + allowHttp: disableSsrf, + responseMaxSize: 512 * 1024, // 512kB + ssrfProtection: !disableSsrf, + fetch: async (request) => { + fetchLogger.info( + { method: request.method, uri: request.url }, + 'fetch', + ) + return globalThis.fetch(request) + }, + }), + }), + + // If the PDS is both an authorization server & resource server (no + // entryway), there is no need to use JWTs as access tokens. Instead, + // the PDS can use tokenId as access tokens. This allows the PDS to + // always use up-to-date token data from the token store. + accessTokenType: AccessTokenType.id, + + onAuthorizationRequest: (parameters, { client, clientAuth }) => { + // ATPROTO extension: if the client is not "trustable", force the + // user to consent to the request. We do this to avoid + // unauthenticated clients from being able to silently + // re-authenticate users. + + // TODO: make allow listed client ids configurable + if (clientAuth.method === 'none' && client.id !== 'https://bsky.app/') { + // Prevent sso and require consent by default + if (!parameters.prompt || parameters.prompt === 'none') { + parameters.prompt = 'consent' + } + } + }, + + onTokenResponse: (tokenResponse, { account }) => { + // ATPROTO extension: add the sub claim to the token response to allow + // clients to resolve the PDS url (audience) using the did resolution + // mechanism. + tokenResponse['sub'] = account.sub + }, + }) + } + + createRouter() { + return this.httpHandler({ + branding: this.branding, + + // Log oauth provider errors using our own logger + onError: (req, res, err) => { + oauthLogger.error({ err }, 'oauth-provider error') + }, + }) + } +} diff --git a/packages/pds/src/auth-routes.ts b/packages/pds/src/auth-routes.ts new file mode 100644 index 00000000000..7b56b8e649e --- /dev/null +++ b/packages/pds/src/auth-routes.ts @@ -0,0 +1,11 @@ +import { type RequestHandler } from 'express' +import AppContext from './context' + +export const createRouter = (ctx: AppContext): RequestHandler => { + return ( + ctx.authProvider?.createRouter() ?? + ((req, res, next) => { + next() + }) + ) +} diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index f0f042af4dc..cba97c10e6a 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -1,4 +1,6 @@ import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto' + +import { getVerificationMaterial } from '@atproto/common' import { OAuthError, OAuthVerifier } from '@atproto/oauth-provider' import { AuthRequiredError, @@ -12,9 +14,9 @@ import * as ui8 from 'uint8arrays' import express from 'express' import * as jose from 'jose' import KeyEncoder from 'key-encoder' + import { AccountManager } from './account-manager' import { softDeleted } from './db' -import { getVerificationMaterial } from '@atproto/common' type ReqCtx = { req: express.Request @@ -96,7 +98,6 @@ type ValidatedRefreshBearer = ValidatedBearer & { } export type AuthVerifierOpts = { - oauthVerifier: OAuthVerifier publicUrl: string jwtKey: KeyObject adminPass: string @@ -108,7 +109,6 @@ export type AuthVerifierOpts = { } export class AuthVerifier { - private _oauthVerifier: OAuthVerifier private _publicUrl: string private _jwtKey: KeyObject private _adminPass: string @@ -117,9 +117,9 @@ export class AuthVerifier { constructor( public accountManager: AccountManager, public idResolver: IdResolver, + public oauthVerifier: OAuthVerifier, opts: AuthVerifierOpts, ) { - this._oauthVerifier = opts.oauthVerifier this._publicUrl = opts.publicUrl this._jwtKey = opts.jwtKey this._adminPass = opts.adminPass @@ -129,7 +129,7 @@ export class AuthVerifier { // verifiers (arrow fns to preserve scope) access = async (ctx: ReqCtx): Promise => { - await this.setAuthHeaders(ctx) + this.setAuthHeaders(ctx) return this.validateAccessToken(ctx.req, [ AuthScope.Access, AuthScope.AppPass, @@ -137,7 +137,7 @@ export class AuthVerifier { } accessCheckTakedown = async (ctx: ReqCtx): Promise => { - await this.setAuthHeaders(ctx) + this.setAuthHeaders(ctx) const result = await this.validateAccessToken(ctx.req, [ AuthScope.Access, AuthScope.AppPass, @@ -159,12 +159,12 @@ export class AuthVerifier { } accessNotAppPassword = async (ctx: ReqCtx): Promise => { - await this.setAuthHeaders(ctx) + this.setAuthHeaders(ctx) return this.validateAccessToken(ctx.req, [AuthScope.Access]) } accessDeactived = async (ctx: ReqCtx): Promise => { - await this.setAuthHeaders(ctx) + this.setAuthHeaders(ctx) return this.validateAccessToken(ctx.req, [ AuthScope.Access, AuthScope.AppPass, @@ -173,7 +173,7 @@ export class AuthVerifier { } refresh = async (ctx: ReqCtx): Promise => { - await this.setAuthHeaders(ctx) + this.setAuthHeaders(ctx) const { did, scope, token, tokenId, audience } = await this.validateRefreshToken(ctx.req) @@ -190,7 +190,7 @@ export class AuthVerifier { } refreshExpired = async (ctx: ReqCtx): Promise => { - await this.setAuthHeaders(ctx) + this.setAuthHeaders(ctx) const { did, scope, token, tokenId, audience } = await this.validateRefreshToken(ctx.req, { clockTolerance: Infinity }) @@ -207,7 +207,7 @@ export class AuthVerifier { } adminToken = async (ctx: ReqCtx): Promise => { - await this.setAuthHeaders(ctx) + this.setAuthHeaders(ctx) const parsed = parseBasicAuth(ctx.req.headers.authorization || '') if (!parsed) { throw new AuthRequiredError() @@ -222,7 +222,7 @@ export class AuthVerifier { optionalAccessOrAdminToken = async ( ctx: ReqCtx, ): Promise => { - await this.setAuthHeaders(ctx) + this.setAuthHeaders(ctx) if (isAccessToken(ctx.req)) { return await this.access(ctx) } else if (isBasicToken(ctx.req)) { @@ -233,7 +233,7 @@ export class AuthVerifier { } userDidAuth = async (reqCtx: ReqCtx): Promise => { - await this.setAuthHeaders(reqCtx) + this.setAuthHeaders(reqCtx) const payload = await this.verifyServiceJwt(reqCtx, { aud: this.dids.entryway ?? this.dids.pds, iss: null, @@ -250,7 +250,7 @@ export class AuthVerifier { userDidAuthOptional = async ( reqCtx: ReqCtx, ): Promise => { - await this.setAuthHeaders(reqCtx) + this.setAuthHeaders(reqCtx) if (isBearerToken(reqCtx.req)) { return await this.userDidAuth(reqCtx) } else { @@ -259,7 +259,7 @@ export class AuthVerifier { } modService = async (reqCtx: ReqCtx): Promise => { - await this.setAuthHeaders(reqCtx) + this.setAuthHeaders(reqCtx) if (!this.dids.modService) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } @@ -288,7 +288,7 @@ export class AuthVerifier { moderator = async ( reqCtx: ReqCtx, ): Promise => { - await this.setAuthHeaders(reqCtx) + this.setAuthHeaders(reqCtx) if (isBearerToken(reqCtx.req)) { return this.modService(reqCtx) } else { @@ -394,7 +394,7 @@ export class AuthVerifier { try { const url = new URL(req.originalUrl || req.url, this._publicUrl) - const result = await this._oauthVerifier.authenticateHttpRequest( + const result = await this.oauthVerifier.authenticateHttpRequest( req.method, url, req.headers, @@ -519,24 +519,19 @@ export class AuthVerifier { } } - protected async setAuthHeaders(ctx: ReqCtx) { + protected setAuthHeaders({ req, res }: ReqCtx) { // Prevent caching (on proxies) of auth dependent responses - ctx.res?.setHeader('Cache-Control', 'private') + res?.setHeader('Cache-Control', 'private') // Make sure that browsers do not return cached responses when the auth header changes - ctx.res?.appendHeader('Vary', 'Authorization') - - /** - * Return next DPoP nonce in response headers for DPoP bound tokens. - * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-8.2} - */ - const authHeader = ctx.req.headers.authorization - if (authHeader?.startsWith('DPoP')) { - const dpopNonce = this._oauthVerifier.nextDpopNonce() + res?.appendHeader('Vary', 'Authorization') + + // https://datatracker.ietf.org/doc/html/rfc9449#section-8.2 + if (req.headers.authorization?.startsWith('DPoP')) { + const dpopNonce = this.oauthVerifier.nextDpopNonce() if (dpopNonce) { - const name = 'DPoP-Nonce' - ctx.res?.setHeader(name, dpopNonce) - ctx.res?.appendHeader('Access-Control-Expose-Headers', name) + res?.setHeader('DPoP-Nonce', dpopNonce) + res?.appendHeader('Access-Control-Expose-Headers', 'DPoP-Nonce') } } } diff --git a/packages/pds/src/auth.ts b/packages/pds/src/auth.ts deleted file mode 100644 index 4894f975c9d..00000000000 --- a/packages/pds/src/auth.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { combine } from '@atproto/http-util' - -import AppContext from './context' -import { oauthLogger } from './logger' - -export const createRouter = (ctx: AppContext) => { - return combine([ - ctx.oauthProvider?.httpHandler({ - // TODO: This must come from config - branding: { - name: 'My PDS', - logo: 'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png', - colors: { - primary: '#ffcb1e', // #0085ff - }, - }, - // Log oauth provider errors using our own logger - onError: (req, res, err) => { - oauthLogger.error({ err }, 'oauth-provider error') - }, - }), - ]) -} diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 047aaf5a8e5..8125cda8601 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -235,31 +235,40 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { const oauthCfg: ServerConfig['oauth'] = entrywayCfg ? { - enableProvider: false, issuer: entrywayCfg.url, + provider: false, } : { - enableProvider: true, issuer: serviceCfg.publicUrl, + provider: { + disableSsrf: env.oauthDisableSsrf ?? false, + + branding: { + name: env.oauthProviderName ?? 'Personal PDS', + logo: env.oauthProviderLogo, + colors: { + primary: env.oauthProviderPrimaryColor, + error: env.oauthProviderErrorColor, + }, + links: [ + { + name: 'Home', + href: env.oauthProviderHomeLink, + rel: 'home', + }, + { + name: 'Terms of Service', + href: env.oauthProviderTosLink, + rel: 'terms-of-service', + }, + ].filter( + (f): f is typeof f & { href: NonNullable<(typeof f)['href']> } => + f.href != null, + ), + }, + }, } - const safeFetchCfg: ServerConfig['safeFetch'] = { - allowHttp: env.fetchDisableSafeties ? true : false, - forbiddenDomainNames: [ - 'google.com', - 'example.com', - 'example.org', - 'example.net', - 'bsky.social', - 'bsky.network', - 'googleusercontent.com', - ], - responseMaxSize: env.fetchDisableSafeties - ? Infinity - : (env.fetchResponseMaxSizeKb ?? 512) * 1024, // defaults to 512kB - ssrfProtection: env.fetchDisableSafeties ? false : true, - } - return { service: serviceCfg, db: dbCfg, @@ -278,7 +287,6 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { rateLimits: rateLimitsCfg, crawlers: crawlersCfg, oauth: oauthCfg, - safeFetch: safeFetchCfg, } } @@ -300,7 +308,6 @@ export type ServerConfig = { rateLimits: RateLimitsConfig crawlers: string[] oauth: OAuthConfig - safeFetch: SafeFetchConfig } export type ServiceConfig = { @@ -368,14 +375,24 @@ export type EntrywayConfig = { export type OAuthConfig = { issuer: string - enableProvider: boolean -} - -export type SafeFetchConfig = { - allowHttp: boolean - ssrfProtection: boolean - responseMaxSize: number - forbiddenDomainNames: string[] + provider: + | false + | { + disableSsrf: boolean + branding: { + name: string + logo?: string + colors?: { + primary?: string + error?: string + } + links?: Array<{ + name: string + href: string + rel?: string + }> + } + } } export type InvitesConfig = diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index ed907402dcc..bb20f089073 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -108,8 +108,13 @@ export const readEnv = (): ServerEnvironment => { ), // oauth - fetchDisableSafeties: envBool('PDS_FETCH_DISABLE_SAFETIES'), - fetchResponseMaxSizeKb: envInt('PDS_FETCH_RESPONSE_MAX_SIZE_KB'), + oauthDisableSsrf: envBool('PDS_DISABLE_SSRF'), + oauthProviderName: envStr('PDS_OAUTH_PROVIDER_NAME'), + oauthProviderLogo: envStr('PDS_OAUTH_PROVIDER_LOGO'), + oauthProviderPrimaryColor: envStr('PDS_OAUTH_PROVIDER_PRIMARY_COLOR'), + oauthProviderErrorColor: envStr('PDS_OAUTH_PROVIDER_ERROR_COLOR'), + oauthProviderHomeLink: envStr('PDS_OAUTH_PROVIDER_HOME_LINK'), + oauthProviderTosLink: envStr('PDS_OAUTH_PROVIDER_TOS_LINK'), } } @@ -214,7 +219,12 @@ export type ServerEnvironment = { plcRotationKeyKmsKeyId?: string plcRotationKeyK256PrivateKeyHex?: string - // fetch - fetchDisableSafeties?: boolean - fetchResponseMaxSizeKb?: number + // oauth + oauthDisableSsrf?: boolean + oauthProviderName?: string + oauthProviderLogo?: string + oauthProviderPrimaryColor?: string + oauthProviderErrorColor?: string + oauthProviderHomeLink?: string + oauthProviderTosLink?: string } diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 68fa338c17b..ff6c5bf8deb 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -4,25 +4,18 @@ import * as nodemailer from 'nodemailer' import { Redis } from 'ioredis' import * as plc from '@did-plc/lib' import * as crypto from '@atproto/crypto' -import { Fetch } from '@atproto/fetch' -import { safeFetchWrap } from '@atproto/fetch-node' import { IdResolver } from '@atproto/identity' import { AtpAgent } from '@atproto/api' import { KmsKeypair, S3BlobStore } from '@atproto/aws' import { NodeKeyset } from '@atproto/jwk-node' import { createServiceAuthHeaders } from '@atproto/xrpc-server' import { BlobStore } from '@atproto/repo' -import { - AccessTokenType, - DpopNonce, - OAuthVerifier, - OAuthProvider, - ReplayStore, -} from '@atproto/oauth-provider' +import { OAuthVerifier } from '@atproto/oauth-provider' import { OAuthReplayStoreRedis } from '@atproto/oauth-provider-replay-redis' import { OAuthReplayStoreMemory } from '@atproto/oauth-provider-replay-memory' import { ServerConfig, ServerSecrets } from './config' +import { AuthProvider } from './auth-provider' import { AuthVerifier, createPublicKeyObject, @@ -39,14 +32,11 @@ import { DiskBlobStore } from './disk-blobstore' import { getRedisClient } from './redis' import { ActorStore } from './actor-store' import { LocalViewer, LocalViewerCreator } from './read-after-write/viewer' -import { OauthClientStore } from './oauth/oauth-client-store' -import { fetchLogger } from './logger' export type AppContextOptions = { actorStore: ActorStore blobstore: (did: string) => BlobStore localViewer: LocalViewerCreator - safeFetch: Fetch mailer: ServerMailer moderationMailer: ModerationMailer didCache: DidSqliteCache @@ -61,7 +51,7 @@ export type AppContextOptions = { moderationAgent?: AtpAgent reportingAgent?: AtpAgent entrywayAgent?: AtpAgent - oauthProvider?: OAuthProvider + authProvider?: AuthProvider authVerifier: AuthVerifier plcRotationKey: crypto.Keypair cfg: ServerConfig @@ -86,7 +76,7 @@ export class AppContext { public reportingAgent: AtpAgent | undefined public entrywayAgent: AtpAgent | undefined public authVerifier: AuthVerifier - public oauthProvider?: OAuthProvider + public authProvider?: AuthProvider public plcRotationKey: crypto.Keypair public cfg: ServerConfig @@ -109,7 +99,7 @@ export class AppContext { this.reportingAgent = opts.reportingAgent this.entrywayAgent = opts.entrywayAgent this.authVerifier = opts.authVerifier - this.oauthProvider = opts.oauthProvider + this.authProvider = opts.authProvider this.plcRotationKey = opts.plcRotationKey this.cfg = opts.cfg } @@ -215,94 +205,41 @@ export class AppContext { '-----END PRIVATE KEY-----\n', }) - // OAuthVerifier is capable of generating its own dpop nonce secret. Using a - // pre-generated nonce is particularly useful to avoid invalid_nonce errors - // when the server restarts or when more tha one instance are running. This - // can also reduce the number of invalid_nonce errors when the PDS and - // entryway are using the same dpop nonce secret. - const dpopNonce = new DpopNonce(Buffer.from(secrets.dpopSecret, 'hex')) - - const replayStore: ReplayStore = redisScratch - ? new OAuthReplayStoreRedis(redisScratch) - : new OAuthReplayStoreMemory() - - // A Fetch function that protects against SSRF attacks, large responses & - // known bad domains. This function can safely be used to fetch user - // provided URLs. - const safeFetch: Fetch = safeFetchWrap({ - ...cfg.safeFetch, - fetch: async (request) => { - fetchLogger.info({ method: request.method, uri: request.url }, 'fetch') - return globalThis.fetch(request) - }, - }) - - const oauthProvider = cfg.oauth.enableProvider - ? new OAuthProvider({ - issuer: cfg.oauth.issuer, + const authProvider = cfg.oauth.provider + ? new AuthProvider( + accountManager, keyset, - dpopNonce, - - accountStore: accountManager, - requestStore: accountManager, - sessionStore: accountManager, - tokenStore: accountManager, - replayStore, - clientStore: new OauthClientStore({ fetch: safeFetch }), - - // If the PDS is both an authorization server & resource server (no - // entryway), there is no need to use JWTs as access tokens. Instead, - // the PDS can use tokenId as access tokens. This allows the PDS to - // always use up-to-date token data from the token store. - accessTokenType: AccessTokenType.id, - - onAuthorizationRequest: (parameters, { client, clientAuth }) => { - // ATPROTO extension: if the client is not "trustable", force the - // user to consent to the request. We do this to avoid - // unauthenticated clients from being able to silently - // re-authenticate users. - - // TODO: make allow listed client ids configurable - if (clientAuth.method === 'none' && client.id !== 'bsky.app') { - parameters.prompt ||= 'consent' - } - }, - - onTokenResponse: (tokenResponse, { account }) => { - // ATPROTO extension: add the sub claim to the token response to allow - // clients to resolve the PDS url (audience) using the did resolution - // mechanism. - tokenResponse['sub'] = account.sub - }, - }) + redisScratch, + secrets.dpopSecret, + cfg.oauth.issuer, + cfg.oauth.provider.branding, + cfg.oauth.provider.disableSsrf, + ) : undefined - /** - * Using the oauthProvider as oauthVerifier allows clients to use the - * same nonce when authenticating and making requests, avoiding - * un-necessary "invalid_nonce" errors. It also allows the use of - * AccessTokenType.id as access token type. - */ - const oauthVerifier: OAuthVerifier = - oauthProvider ?? - new OAuthVerifier({ - issuer: cfg.oauth.issuer, - keyset, - dpopNonce, - replayStore, - }) - - const authVerifier = new AuthVerifier(accountManager, idResolver, { - publicUrl: cfg.service.publicUrl, - oauthVerifier, - jwtKey, - adminPass: secrets.adminPassword, - dids: { - pds: cfg.service.did, - entryway: cfg.entryway?.did, - modService: cfg.modService?.did, + const authVerifier = new AuthVerifier( + accountManager, + idResolver, + authProvider ?? // OAuthProvider is an OAuthVerifier so let's use it + new OAuthVerifier({ + issuer: cfg.oauth.issuer, + keyset, + dpopSecret: secrets.dpopSecret, + replayStore: redisScratch + ? new OAuthReplayStoreRedis(redisScratch) + : new OAuthReplayStoreMemory(), + }), + { + publicUrl: cfg.service.publicUrl, + jwtKey, + adminPass: secrets.adminPassword, + dids: { + pds: cfg.service.did, + entryway: cfg.entryway?.did, + modService: cfg.modService?.did, + }, }, - }) + ) const plcRotationKey = secrets.plcRotationKey.provider === 'kms' @@ -330,7 +267,6 @@ export class AppContext { actorStore, blobstore, localViewer, - safeFetch, mailer, moderationMailer, didCache, @@ -346,7 +282,7 @@ export class AppContext { reportingAgent, entrywayAgent, authVerifier, - oauthProvider, + authProvider, plcRotationKey, cfg, ...(overrides ?? {}), diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 857602227d0..2de4ef79877 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -16,7 +16,7 @@ import { } from '@atproto/xrpc-server' import { DAY, HOUR, MINUTE } from '@atproto/common' import API from './api' -import * as auth from './auth' +import * as authRoutes from './auth-routes' import * as basicRoutes from './basic-routes' import * as wellKnown from './well-known' import * as error from './error' @@ -123,7 +123,7 @@ export class PDS { server = API(server, ctx) - app.use(auth.createRouter(ctx)) + app.use(authRoutes.createRouter(ctx)) app.use(basicRoutes.createRouter(ctx)) app.use(wellKnown.createRouter(ctx)) app.use(server.xrpc.router) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f63683537e..e641c62a92a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -926,9 +926,6 @@ importers: '@atproto/fetch-node': specifier: workspace:* version: link:../fetch-node - '@atproto/http-util': - specifier: workspace:* - version: link:../http-util '@atproto/identity': specifier: workspace:^ version: link:../identity From 70b1256122715d037c2f53f37fe10c019470b68f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 13 Mar 2024 16:38:56 +0100 Subject: [PATCH 022/140] chore: use "default" instead of "require" export condition --- packages/fetch-node/package.json | 2 +- packages/fetch/package.json | 2 +- packages/html/package.json | 2 +- packages/http-util/package.json | 2 +- packages/jwk-node/package.json | 2 +- packages/jwk/package.json | 2 +- packages/oauth-provider-client-fqdn/package.json | 2 +- packages/oauth-provider-client-uri/package.json | 2 +- packages/oauth-provider-replay-memory/package.json | 2 +- packages/oauth-provider-replay-redis/package.json | 2 +- packages/oauth-provider/package.json | 2 +- packages/rollup-plugin-bundle-manifest/package.json | 2 +- packages/transformer/package.json | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/fetch-node/package.json b/packages/fetch-node/package.json index 96d69b2486c..472689d9ef0 100644 --- a/packages/fetch-node/package.json +++ b/packages/fetch-node/package.json @@ -19,7 +19,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/fetch/package.json b/packages/fetch/package.json index fbf78930830..45ecb71459c 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -18,7 +18,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/html/package.json b/packages/html/package.json index 9d5d6edac12..687e264069d 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -18,7 +18,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/http-util/package.json b/packages/http-util/package.json index aa247f80daa..a00699f5aac 100644 --- a/packages/http-util/package.json +++ b/packages/http-util/package.json @@ -21,7 +21,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/jwk-node/package.json b/packages/jwk-node/package.json index f6e130c6737..ec0283fe194 100644 --- a/packages/jwk-node/package.json +++ b/packages/jwk-node/package.json @@ -20,7 +20,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/jwk/package.json b/packages/jwk/package.json index 49d221a3bbf..ed1d63babb1 100644 --- a/packages/jwk/package.json +++ b/packages/jwk/package.json @@ -21,7 +21,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/oauth-provider-client-fqdn/package.json b/packages/oauth-provider-client-fqdn/package.json index bed76f83bde..7ad62a3878d 100644 --- a/packages/oauth-provider-client-fqdn/package.json +++ b/packages/oauth-provider-client-fqdn/package.json @@ -23,7 +23,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/oauth-provider-client-uri/package.json b/packages/oauth-provider-client-uri/package.json index e974964604d..311a9237d9d 100644 --- a/packages/oauth-provider-client-uri/package.json +++ b/packages/oauth-provider-client-uri/package.json @@ -23,7 +23,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/oauth-provider-replay-memory/package.json b/packages/oauth-provider-replay-memory/package.json index 8b8bcdbe03c..f064d1f5f4a 100644 --- a/packages/oauth-provider-replay-memory/package.json +++ b/packages/oauth-provider-replay-memory/package.json @@ -22,7 +22,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/oauth-provider-replay-redis/package.json b/packages/oauth-provider-replay-redis/package.json index c41bf61e2b2..b936505132f 100644 --- a/packages/oauth-provider-replay-redis/package.json +++ b/packages/oauth-provider-replay-redis/package.json @@ -22,7 +22,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index 11b1a26450c..d330057df50 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -23,7 +23,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/rollup-plugin-bundle-manifest/package.json b/packages/rollup-plugin-bundle-manifest/package.json index d089df7c97a..fee0bee547b 100644 --- a/packages/rollup-plugin-bundle-manifest/package.json +++ b/packages/rollup-plugin-bundle-manifest/package.json @@ -6,7 +6,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, diff --git a/packages/transformer/package.json b/packages/transformer/package.json index fbb95823ba7..63ee4f17479 100644 --- a/packages/transformer/package.json +++ b/packages/transformer/package.json @@ -6,7 +6,7 @@ "types": "dist/index.d.ts", "exports": { ".": { - "require": "./dist/index.js", + "default": "./dist/index.js", "types": "./dist/index.d.ts" } }, From 480a3d3247fce096636f5b3efe5d4616f44212b7 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 13 Mar 2024 17:47:47 +0100 Subject: [PATCH 023/140] chore: update rollupjs --- packages/oauth-provider/package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index d330057df50..5864c85c832 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -59,7 +59,7 @@ "postcss": "^8.4.33", "react": "^18.2.0", "react-dom": "^18.2.0", - "rollup": "^4.10.0", + "rollup": "^4.13.0", "rollup-plugin-postcss": "^4.0.2", "tailwindcss": "^3.4.1", "typescript": "^5.3.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e641c62a92a..93fde753034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -739,7 +739,7 @@ importers: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) rollup: - specifier: ^4.10.0 + specifier: ^4.13.0 version: 4.14.1 rollup-plugin-postcss: specifier: ^4.0.2 From a7423b5c713020d2fe5f295ab93d4dbf43641132 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 13 Mar 2024 17:49:20 +0100 Subject: [PATCH 024/140] feat: improve error management --- packages/fetch/src/fetch-error.ts | 20 ++-- packages/fetch/src/fetch-response.ts | 57 ++++++++++- .../oauth-provider/src/assets/app/app.tsx | 4 +- .../src/assets/app/components/error-card.tsx | 46 +++++---- .../src/assets/app/components/page-layout.tsx | 9 +- .../assets/app/components/sign-in-form.tsx | 24 ++++- .../assets/app/components/welcome-layout.tsx | 52 ++++++++++ .../oauth-provider/src/assets/app/lib/api.ts | 25 +++-- .../oauth-provider/src/assets/app/main.css | 2 +- .../src/assets/app/views/authorize-view.tsx | 4 +- .../src/assets/app/views/error-view.tsx | 32 ++++--- .../src/assets/app/views/welcome-view.tsx | 95 ++++++------------- .../src/request/request-manager.ts | 2 +- .../src/request/request-store.ts | 4 + .../helpers/authorization-request.ts | 6 +- packages/pds/src/account-manager/index.ts | 13 ++- 16 files changed, 253 insertions(+), 142 deletions(-) create mode 100644 packages/oauth-provider/src/assets/app/components/welcome-layout.tsx diff --git a/packages/fetch/src/fetch-error.ts b/packages/fetch/src/fetch-error.ts index e97f8560511..6c319f91be8 100644 --- a/packages/fetch/src/fetch-error.ts +++ b/packages/fetch/src/fetch-error.ts @@ -1,5 +1,11 @@ import { Transformer } from '@atproto/transformer' +export type FetchErrorOptions = { + cause?: unknown + request?: Request + response?: Response +} + export class FetchError extends Error { public readonly request?: Request public readonly response?: Response @@ -7,26 +13,24 @@ export class FetchError extends Error { constructor( public readonly statusCode: number, message?: string, - { - cause = undefined as unknown, - request = undefined as undefined | Request, - response = undefined as undefined | Response, - } = {}, + { cause, request, response }: FetchErrorOptions = {}, ) { super(message, { cause }) this.request = request this.response = response } - static from(err: unknown) { + static async from(err: unknown) { const cause = extractCause(err) return new FetchError(...extractInfo(cause), { cause }) } } -export const fetchFailureHandler: Transformer = ( +export const fetchFailureHandler: Transformer = async ( err: unknown, -) => Promise.reject(FetchError.from(err)) +) => { + throw await FetchError.from(err) +} function extractCause(err: unknown): unknown { // Unwrap the Network error from undici (i.e. Node's internal fetch() implementation) diff --git a/packages/fetch/src/fetch-response.ts b/packages/fetch/src/fetch-response.ts index 3264d113cb7..d974ffe2df4 100644 --- a/packages/fetch/src/fetch-response.ts +++ b/packages/fetch/src/fetch-response.ts @@ -1,18 +1,65 @@ import { Transformer, compose } from '@atproto/transformer' import { z } from 'zod' -import { FetchError } from './fetch-error.js' +import { FetchError, FetchErrorOptions } from './fetch-error.js' import { overrideResponseBody } from './utils.js' export type ResponseTranformer = Transformer +async function extractResponseMessage(response: Response): Promise { + try { + const contentType = response.headers + .get('content-type') + ?.split(';')[0] + .trim() + if (contentType && response.body && !response.bodyUsed) { + if (contentType === 'text/plain') { + return response.clone().text() + } else if (/^application\/(?:[^+]+\+)?json$/i.test(contentType)) { + const json = await response.clone().json() + if (typeof json?.error_description === 'string') { + return json.error_description + } else if (typeof json?.error === 'string') { + return json.error + } else if (typeof json?.message === 'string') { + return json.message + } + } + } + } catch { + // noop + } + return response.statusText +} + +export class FetchResponseError extends FetchError { + constructor( + statusCode: number, + message?: string, + public readonly body?: Blob, + options?: FetchErrorOptions, + ) { + super(statusCode, message, options) + } + + static async from(response: Response) { + const message = await extractResponseMessage(response) + const body: undefined | Blob = + response.body && !response.bodyUsed + ? await response.clone().blob() + : undefined + + return new FetchResponseError(response.status, message, body, { + response, + }) + } +} + export function fetchOkProcessor(): ResponseTranformer { return async (response) => { - if (!response.ok) { - throw new FetchError(response.status, response.statusText, { response }) - } + if (response.ok) return response - return response + throw await FetchResponseError.from(response) } } diff --git a/packages/oauth-provider/src/assets/app/app.tsx b/packages/oauth-provider/src/assets/app/app.tsx index e3348bfc312..5d4e2bfb1e7 100644 --- a/packages/oauth-provider/src/assets/app/app.tsx +++ b/packages/oauth-provider/src/assets/app/app.tsx @@ -12,11 +12,11 @@ export function App({ authorizeData, brandingData, errorData }: AppProps) { if (authorizeData) { return ( ) } else { - return + return } } diff --git a/packages/oauth-provider/src/assets/app/components/error-card.tsx b/packages/oauth-provider/src/assets/app/components/error-card.tsx index 20dd00c8554..75eba692c35 100644 --- a/packages/oauth-provider/src/assets/app/components/error-card.tsx +++ b/packages/oauth-provider/src/assets/app/components/error-card.tsx @@ -1,34 +1,40 @@ import { HtmlHTMLAttributes } from 'react' -import type { ErrorData } from '../backend-data' +import { clsx } from '../lib/clsx' -export type ErrorCardProps = Partial +export type ErrorCardProps = { + message?: null | string + role?: 'alert' | 'status' +} export function ErrorCard({ - error: _code, - error_description: message, + message, + + role = 'alert', + className, ...attrs }: Partial & Omit, keyof ErrorCardProps>) { return (
-
-
- - - -
-
-

Sorry, something went wrong.

- {message &&

{message}

} -
+ + + + +
+

+ {typeof message === 'string' ? message : 'An unknown error occurred'} +

) diff --git a/packages/oauth-provider/src/assets/app/components/page-layout.tsx b/packages/oauth-provider/src/assets/app/components/page-layout.tsx index 86b40084c2b..123ca6fb92a 100644 --- a/packages/oauth-provider/src/assets/app/components/page-layout.tsx +++ b/packages/oauth-provider/src/assets/app/components/page-layout.tsx @@ -1,18 +1,19 @@ import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react' import { clsx } from '../lib/clsx' -export type PageLayoutProps = PropsWithChildren<{ +export type PageLayoutProps = { title?: ReactNode subtitle?: ReactNode -}> +} export function PageLayout({ children, title, subtitle, ...attrs -}: PageLayoutProps & - Omit, keyof PageLayoutProps>) { +}: PropsWithChildren< + PageLayoutProps & Omit, keyof PageLayoutProps> +>) { return (
, keyof SignInFormProps>) { const [focused, setFocused] = useState(false) const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) const doSubmit = useCallback( async ( @@ -51,17 +53,20 @@ export function SignInForm({ ) => { event.preventDefault() setLoading(true) + setErrorMessage(null) try { await onSubmit({ username: event.currentTarget.username.value, password: event.currentTarget.password.value, remember: event.currentTarget.remember.checked, }) + } catch (err) { + setErrorMessage(parseErrorMessage(err)) } finally { setLoading(false) } }, - [onSubmit, setLoading], + [onSubmit, setErrorMessage, setLoading], ) return ( @@ -72,7 +77,7 @@ export function SignInForm({ >

Sign in

@@ -87,6 +92,7 @@ export function SignInForm({ defaultValue={username} readOnly={usernameReadonly} disabled={usernameReadonly} + onChange={() => setErrorMessage(null)} />
@@ -99,6 +105,7 @@ export function SignInForm({ type="password" onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} + onChange={() => setErrorMessage(null)} className="relative m-0 block w-[1px] min-w-0 flex-auto px-3 py-[0.25rem] leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100" placeholder="Password" aria-label="Password" @@ -146,6 +153,7 @@ export function SignInForm({ type="checkbox" className="text-primary" defaultChecked={remember} + onChange={() => setErrorMessage(null)} /> @@ -158,6 +166,8 @@ export function SignInForm({
+ {errorMessage && } +
@@ -182,3 +192,13 @@ export function SignInForm({ ) } + +function parseErrorMessage(err: unknown): string { + switch ((err as any)?.message) { + case 'Invalid credentials': + return 'Invalid username or password' + default: + console.error(err) + return 'An unknown error occurred' + } +} diff --git a/packages/oauth-provider/src/assets/app/components/welcome-layout.tsx b/packages/oauth-provider/src/assets/app/components/welcome-layout.tsx new file mode 100644 index 00000000000..8b82ea4c803 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/welcome-layout.tsx @@ -0,0 +1,52 @@ +import { PropsWithChildren } from 'react' +import { BrandingData } from '../backend-data' + +export type WelcomeLayoutProps = BrandingData & { + logoAlt?: string +} + +export function WelcomeLayout({ + name, + logo, + logoAlt = name || 'Logo', + links, + children, +}: PropsWithChildren) { + return ( +
+
+ {logo && ( + {logoAlt} + )} + + {name && ( +

+ {name} +

+ )} + + {children} +
+ + {links != null && links.length > 0 && ( + + )} +
+ ) +} diff --git a/packages/oauth-provider/src/assets/app/lib/api.ts b/packages/oauth-provider/src/assets/app/lib/api.ts index 6c6d04e4336..d21f526c879 100644 --- a/packages/oauth-provider/src/assets/app/lib/api.ts +++ b/packages/oauth-provider/src/assets/app/lib/api.ts @@ -1,3 +1,9 @@ +import { + fetchFailureHandler, + fetchJsonProcessor, + fetchOkProcessor, +} from '@atproto/fetch' + import { SignInFormOutput } from '../components/sign-in-form' import { Account, Info, Session } from '../types' @@ -10,7 +16,7 @@ export class Api { ) {} async signIn(credentials: SignInFormOutput): Promise { - const r = await fetch('/oauth/authorize/sign-in', { + const { body } = await fetch('/oauth/authorize/sign-in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, mode: 'same-origin', @@ -21,24 +27,17 @@ export class Api { credentials, }), }) - const json = await r.json() - - // TODO: better error handling - if (!r.ok) throw new Error(json.error || 'Error', { cause: json }) - - const { account, info } = json as { - account: Account - info: Info - } + .then(fetchOkProcessor(), fetchFailureHandler) + .then(fetchJsonProcessor<{ account: Account; info: Info }>()) return { - account, - info, + account: body.account, + info: body.info, selected: true, consentRequired: this.newSessionsRequireConsent || - !info.authorizedClients.includes(this.clientId), + !body.info.authorizedClients.includes(this.clientId), loginRequired: false, } } diff --git a/packages/oauth-provider/src/assets/app/main.css b/packages/oauth-provider/src/assets/app/main.css index 1dd899cb889..74dcc88675c 100644 --- a/packages/oauth-provider/src/assets/app/main.css +++ b/packages/oauth-provider/src/assets/app/main.css @@ -6,6 +6,6 @@ @layer base { :root { --color-primary: 255 115 179; - --color-error: 255 0 0; + --color-error: 235 65 49; } } diff --git a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx index 9131ecdefc0..4c128f8f0d8 100644 --- a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx +++ b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx @@ -34,9 +34,7 @@ export function AuthorizeView({ if (view === 'welcome') { return ( - - + + + ) } + +function getUserFriendlyMessage(errorData?: ErrorData) { + const desc = errorData?.error_description + switch (desc) { + case 'Unknown request_uri': // Request was removed from database + case 'This request has expired': + return 'This sign-in session has expired' + default: + return desc || 'An unknown error occurred' + } +} diff --git a/packages/oauth-provider/src/assets/app/views/welcome-view.tsx b/packages/oauth-provider/src/assets/app/views/welcome-view.tsx index 70c78a371a2..63c3ab05e81 100644 --- a/packages/oauth-provider/src/assets/app/views/welcome-view.tsx +++ b/packages/oauth-provider/src/assets/app/views/welcome-view.tsx @@ -1,13 +1,6 @@ -export type WelcomeViewParams = { - title?: string - logo?: string - logoAlt?: string - links?: Array<{ - name: string - href: string - rel?: string - }> +import { WelcomeLayout, WelcomeLayoutProps } from '../components/welcome-layout' +export type WelcomeViewParams = WelcomeLayoutProps & { onSignIn?: () => void signInLabel?: string @@ -19,73 +12,43 @@ export type WelcomeViewParams = { } export function WelcomeView({ - title, - logo, - logoAlt = title || 'Logo', - links, onSignIn, signInLabel = 'Sign in', onSignUp, signUpLabel = 'Sign up', onCancel, cancelLabel = 'Cancel', + + ...props }: WelcomeViewParams) { return ( -
-
- {logo && ( - {logoAlt} - )} - - {title && ( -

- {title} -

- )} - - {onSignIn && ( - - )} - - {onSignUp && ( - - )} + + {onSignIn && ( + + )} - {onCancel && ( - - )} -
+ {onSignUp && ( + + )} - {links != null && links.length > 0 && ( - + {onCancel && ( + )} -
+ ) } diff --git a/packages/oauth-provider/src/request/request-manager.ts b/packages/oauth-provider/src/request/request-manager.ts index 56cbcee9426..c8a23b7e467 100644 --- a/packages/oauth-provider/src/request/request-manager.ts +++ b/packages/oauth-provider/src/request/request-manager.ts @@ -278,7 +278,7 @@ export class RequestManager { const data = await this.store.readRequest(id) if (!data) { - throw new InvalidRequestError('Invalid request_uri') + throw new InvalidRequestError('Unknown request_uri') } const updates: UpdateRequestData = {} diff --git a/packages/oauth-provider/src/request/request-store.ts b/packages/oauth-provider/src/request/request-store.ts index c288ce719ac..8ef822e957f 100644 --- a/packages/oauth-provider/src/request/request-store.ts +++ b/packages/oauth-provider/src/request/request-store.ts @@ -17,6 +17,10 @@ export type FoundRequestResult = { export interface RequestStore { createRequest(id: RequestId, data: RequestData): Awaitable + /** + * Note that expired requests **can** be returned to yield a different error + * message than if the request was not found. + */ readRequest(id: RequestId): Awaitable updateRequest(id: RequestId, data: UpdateRequestData): Awaitable deleteRequest(id: RequestId): void | Awaitable diff --git a/packages/pds/src/account-manager/helpers/authorization-request.ts b/packages/pds/src/account-manager/helpers/authorization-request.ts index 5ea568ea268..27852634068 100644 --- a/packages/pds/src/account-manager/helpers/authorization-request.ts +++ b/packages/pds/src/account-manager/helpers/authorization-request.ts @@ -46,10 +46,12 @@ export const create = async ( .execute() } -export const deleteExpired = async (db: AccountDb) => { +export const deleteOldExpired = async (db: AccountDb, delay = 600e3) => { + // We allow some delay for the expiration time so that expired requests + // can still be returned to the OauthProvider library for error handling. await db.db .deleteFrom('authorization_request') - .where('expiresAt', '<', toDateISO(new Date())) + .where('expiresAt', '<', toDateISO(new Date(Date.now() - delay))) .execute() } diff --git a/packages/pds/src/account-manager/index.ts b/packages/pds/src/account-manager/index.ts index 29b5c90248f..1dc177bf3bb 100644 --- a/packages/pds/src/account-manager/index.ts +++ b/packages/pds/src/account-manager/index.ts @@ -568,11 +568,16 @@ export class AccountManager } async readRequest(id: RequestId): Promise { - // Take the opportunity to clean up expired requests. - // TODO: Do this less often? - await authorizationRequest.deleteExpired(this.db) + try { + return authorizationRequest.get(this.db, id) + } finally { + // Take the opportunity to clean up expired requests. Do this after we got + // the current (potentially expired) request data to allow the provider to + // handle expired requests. - return authorizationRequest.get(this.db, id) + // TODO: Do this less often? + await authorizationRequest.deleteOldExpired(this.db) + } } async updateRequest(id: RequestId, data: UpdateRequestData): Promise { From 811d28e52788ce2a4a8c25c652925c1e8d12a8c0 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 14 Mar 2024 12:18:22 +0100 Subject: [PATCH 025/140] chore(deps): remove unused lru-cache --- packages/oauth-provider/package.json | 1 - pnpm-lock.yaml | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index 5864c85c832..2807a2a4773 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -37,7 +37,6 @@ "cookie": "^0.6.0", "jose": "^5.2.0", "keygrip": "^1.1.0", - "lru-cache": "^10.2.0", "oidc-token-hash": "^5.0.3", "tslib": "^2.6.2", "zod": "^3.22.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93fde753034..e599f2fc230 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -673,9 +673,6 @@ importers: jose: specifier: ^5.2.0 version: 5.2.4 - lru-cache: - specifier: ^10.2.0 - version: 10.2.0 oidc-token-hash: specifier: ^5.0.3 version: 5.0.3 From 02a56889567b75bf83acc0f4861bf48e7d2ebbff Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 14 Mar 2024 14:14:13 +0100 Subject: [PATCH 026/140] chore(dev): add blobs to ignored files --- services/pds/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/services/pds/.gitignore b/services/pds/.gitignore index 60baa9cb833..b99933b7680 100644 --- a/services/pds/.gitignore +++ b/services/pds/.gitignore @@ -1 +1,2 @@ data/* +blobs/* From c162268eafda7d1c9731ae039f4b3f6927260c06 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 14 Mar 2024 15:18:24 +0100 Subject: [PATCH 027/140] fix(oauth-provider): various UI improvements --- .../src/assets/app/components/accept-form.tsx | 8 +- .../assets/app/components/account-picker.tsx | 106 ++++++---- .../src/assets/app/components/page-layout.tsx | 2 +- .../assets/app/components/sign-in-form.tsx | 198 ++++++++++++------ .../src/assets/app/views/welcome-view.tsx | 2 +- 5 files changed, 202 insertions(+), 114 deletions(-) diff --git a/packages/oauth-provider/src/assets/app/components/accept-form.tsx b/packages/oauth-provider/src/assets/app/components/accept-form.tsx index 7dd1f630b03..34b8a82c161 100644 --- a/packages/oauth-provider/src/assets/app/components/accept-form.tsx +++ b/packages/oauth-provider/src/assets/app/components/accept-form.tsx @@ -74,11 +74,11 @@ export function AcceptForm({
-
+
@@ -87,7 +87,7 @@ export function AcceptForm({ @@ -98,7 +98,7 @@ export function AcceptForm({ diff --git a/packages/oauth-provider/src/assets/app/components/account-picker.tsx b/packages/oauth-provider/src/assets/app/components/account-picker.tsx index 356a5a6a9c3..bfc89434613 100644 --- a/packages/oauth-provider/src/assets/app/components/account-picker.tsx +++ b/packages/oauth-provider/src/assets/app/components/account-picker.tsx @@ -4,22 +4,32 @@ import { clsx } from '../lib/clsx' export type AccountPickerProps = { accounts: readonly Account[] + onAccount: (account: Account) => void + accountAria?: (account: Account) => string onOther?: () => void otherLabel?: ReactNode + otherAria?: string onBack?: () => void backLabel?: ReactNode + backAria?: string } export function AccountPicker({ accounts, + onAccount, + accountAria = (a) => `Sign in as ${a.name}`, + onOther = undefined, otherLabel = 'Other account', + otherAria = 'Login to account that is not listed', + onBack, backLabel, + backAria, className, ...attrs @@ -27,63 +37,67 @@ export function AccountPicker({ return (

Sign in as...

-
    - {accounts.map((account) => { - const [name, identifier] = [ - account.name, - account.preferred_username, - account.email, - account.sub, - ].filter(Boolean) as [string, string?] - return ( -
  • onAccount(account)} - > -
    - {account.picture && ( - {name} + {accounts.map((account) => { + const [name, identifier] = [ + account.name, + account.preferred_username, + account.email, + account.sub, + ].filter(Boolean) as [string, string?] + + return ( +
  • - ) - })} - {onOther && ( -
  • - {otherLabel} +
+ > + + ) + })} + {onOther && ( + + )}
{onBack && ( -
+
diff --git a/packages/oauth-provider/src/assets/app/components/page-layout.tsx b/packages/oauth-provider/src/assets/app/components/page-layout.tsx index 123ca6fb92a..bd4712197d4 100644 --- a/packages/oauth-provider/src/assets/app/components/page-layout.tsx +++ b/packages/oauth-provider/src/assets/app/components/page-layout.tsx @@ -36,7 +36,7 @@ export function PageLayout({ )}
-
+
{children}
diff --git a/packages/oauth-provider/src/assets/app/components/sign-in-form.tsx b/packages/oauth-provider/src/assets/app/components/sign-in-form.tsx index fc5820f7fd8..de20d2ecdfd 100644 --- a/packages/oauth-provider/src/assets/app/components/sign-in-form.tsx +++ b/packages/oauth-provider/src/assets/app/components/sign-in-form.tsx @@ -2,8 +2,10 @@ import { ReactNode, useCallback, useState, - type FormHTMLAttributes, + FormHTMLAttributes, + SyntheticEvent, } from 'react' + import { clsx } from '../lib/clsx' import { ErrorCard } from './error-card' @@ -14,23 +16,61 @@ export type SignInFormOutput = { } export type SignInFormProps = { + title?: ReactNode + onSubmit: (credentials: SignInFormOutput) => void | Promise - onCancel?: () => void submitLabel?: ReactNode + submitAria?: string + + onCancel?: () => void cancelLabel?: ReactNode + cancelAria?: string + username?: string usernameReadonly?: boolean + usernamePlaceholder?: string + usernameAria?: string + + passwordPlaceholder?: string + passwordAria?: string + passwordWarning?: ReactNode + remember?: boolean + rememberAria?: string + rememberLabel?: ReactNode } export function SignInForm({ + title = 'Sign in', + onSubmit, + submitAria = 'Next', + submitLabel = submitAria, + onCancel = undefined, - submitLabel = 'Continue', - cancelLabel = 'Cancel', - username = '', + cancelAria = 'Cancel', + cancelLabel = cancelAria, + + username: defaultUsername = '', usernameReadonly = false, - remember = false, + usernamePlaceholder = 'Username or email address', + usernameAria = usernamePlaceholder, + + passwordPlaceholder = 'Password', + passwordAria = passwordPlaceholder, + passwordWarning = ( + <> +

Warning

+

+ Please verify the domain name of the website before entering your + password. Never enter your password on a domain you do not trust. +

+ + ), + + remember: defaultRemember = false, + rememberAria = 'Remember this account on this device', + rememberLabel = rememberAria, className, ...attrs @@ -40,9 +80,14 @@ export function SignInForm({ const [loading, setLoading] = useState(false) const [errorMessage, setErrorMessage] = useState(null) + const [hasUsername, setHasUsername] = useState(!!defaultUsername) + const [hasPassword, setHasPassword] = useState(false) + + const canSubmit = hasUsername && hasPassword + const doSubmit = useCallback( async ( - event: React.SyntheticEvent< + event: SyntheticEvent< HTMLFormElement & { username: HTMLInputElement password: HTMLInputElement @@ -52,14 +97,19 @@ export function SignInForm({ >, ) => { event.preventDefault() + + const credentials = { + username: event.currentTarget.username.value, + password: event.currentTarget.password.value, + remember: event.currentTarget.remember.checked, + } + + if (!credentials.username || !credentials.password) return + setLoading(true) setErrorMessage(null) try { - await onSubmit({ - username: event.currentTarget.username.value, - password: event.currentTarget.password.value, - remember: event.currentTarget.remember.checked, - }) + await onSubmit(credentials) } catch (err) { setErrorMessage(parseErrorMessage(err)) } finally { @@ -75,9 +125,9 @@ export function SignInForm({ className={clsx('flex flex-col', className)} onSubmit={doSubmit} > -

Sign in

+

{title}

@@ -85,14 +135,23 @@ export function SignInForm({ { + setHasUsername(!!e.target.value) + setErrorMessage(null) + }} className="relative m-0 block w-[1px] min-w-0 flex-auto px-3 py-[0.25rem] leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100 disabled:text-gray-500" - placeholder="Username or email address" - aria-label="Username or email address" + placeholder={usernamePlaceholder} + aria-label={usernameAria} + autoCapitalize="none" + autoCorrect="off" + autoComplete="username" + spellCheck="false" + dir="auto" + enterKeyHint="next" required - defaultValue={username} + defaultValue={defaultUsername} readOnly={usernameReadonly} disabled={usernameReadonly} - onChange={() => setErrorMessage(null)} />
@@ -103,56 +162,65 @@ export function SignInForm({ { + setHasPassword(!!e.target.value) + setErrorMessage(null) + }} onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - onChange={() => setErrorMessage(null)} + onBlur={() => setTimeout(setFocused, 100, false)} className="relative m-0 block w-[1px] min-w-0 flex-auto px-3 py-[0.25rem] leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100" - placeholder="Password" - aria-label="Password" + placeholder={passwordPlaceholder} + aria-label={passwordAria} + autoCapitalize="none" + autoCorrect="off" + autoComplete="password" + dir="auto" + enterKeyHint="done" + spellCheck="false" required />
-
- -
-
-
- - - -
-
-

Warning

-

- Please verify the domain name of the website before entering - your password. Never enter your password on a domain you do not - trust. -

+ {passwordWarning && ( + <> +
+
+
+
+ + + +
+
{passwordWarning}
+
-
-
+ + )}
setErrorMessage(null)} /> @@ -161,7 +229,7 @@ export function SignInForm({ htmlFor="remember" className="relative m-0 block w-[1px] min-w-0 flex-auto px-3 py-[0.25rem] leading-[1.6]" > - Remember this account on this device + {rememberLabel}
@@ -170,20 +238,26 @@ export function SignInForm({
-
- +
+ {canSubmit && ( + + )} {onCancel && ( diff --git a/packages/oauth-provider/src/assets/app/views/welcome-view.tsx b/packages/oauth-provider/src/assets/app/views/welcome-view.tsx index 63c3ab05e81..95adbc7e245 100644 --- a/packages/oauth-provider/src/assets/app/views/welcome-view.tsx +++ b/packages/oauth-provider/src/assets/app/views/welcome-view.tsx @@ -43,7 +43,7 @@ export function WelcomeView({ {onCancel && ( )} - {onSignUp && ( + {onSignIn && ( )} {onCancel && ( - )} +
+ {onCancel && ( )} - + ) } diff --git a/packages/oauth-provider/src/output/customization.ts b/packages/oauth-provider/src/output/customization.ts index 610a708b38f..8265019779d 100644 --- a/packages/oauth-provider/src/output/customization.ts +++ b/packages/oauth-provider/src/output/customization.ts @@ -4,26 +4,43 @@ type ColorName = typeof colorNames[number] const isColorName = (name: string): name is ColorName => (colorNames as readonly string[]).includes(name) +export type FieldDefinition = { + label?: string + placeholder?: string + pattern?: string + title?: string +} + export type Customization = { name?: string logo?: string colors?: { [_ in ColorName]?: string } links?: Array<{ - name: string + title: string href: string rel?: string }> + + signIn?: { + fields?: { + username?: FieldDefinition + password?: FieldDefinition + remember?: FieldDefinition + } + } } export function buildCustomizationData({ name, logo, links, + signIn, }: Customization = {}) { return { name, logo, links, + signIn, } } diff --git a/packages/pds/example.env b/packages/pds/example.env index 37e17331f29..4a833a09769 100644 --- a/packages/pds/example.env +++ b/packages/pds/example.env @@ -31,6 +31,8 @@ PDS_OAUTH_PROVIDER_PRIMARY_COLOR="#7507e3" PDS_OAUTH_PROVIDER_ERROR_COLOR= PDS_OAUTH_PROVIDER_HOME_LINK= PDS_OAUTH_PROVIDER_TOS_LINK= +PDS_OAUTH_PROVIDER_POLICY_LINK= +PDS_OAUTH_PROVIDER_SUPPORT_LINK= # Debugging NODE_TLS_REJECT_UNAUTHORIZED=1 diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 5e97b8b1730..70c069f4b64 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -1,6 +1,7 @@ import path from 'node:path' import assert from 'node:assert' import { DAY, HOUR, SECOND } from '@atproto/common' +import { Customization } from '@atproto/oauth-provider' import { ServerEnvironment } from './env' // off-config but still from env: @@ -252,19 +253,39 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { }, links: [ { - name: 'Home', + title: 'Home', href: env.oauthProviderHomeLink, - rel: 'home', + rel: 'bookmark', }, { - name: 'Terms of Service', + title: 'Terms of Service', href: env.oauthProviderTosLink, rel: 'terms-of-service', }, + { + title: 'Privacy Policy', + href: env.oauthProviderPrivacyPolicyLink, + rel: 'privacy-policy', + }, + { + title: 'Support', + href: env.oauthProviderSupportLink, + rel: 'help', + }, ].filter( (f): f is typeof f & { href: NonNullable<(typeof f)['href']> } => f.href != null, ), + signIn: { + fields: { + username: { + label: 'Email address or handle', + pattern: '.{3,}', + title: 'Must be at least 3 characters long', + }, + password: {}, + }, + }, }, }, } @@ -379,19 +400,7 @@ export type OAuthConfig = { | false | { disableSsrf: boolean - customization: { - name: string - logo?: string - colors?: { - primary?: string - error?: string - } - links?: Array<{ - name: string - href: string - rel?: string - }> - } + customization: Customization } } diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index bb20f089073..0f40dbb610f 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -115,6 +115,8 @@ export const readEnv = (): ServerEnvironment => { oauthProviderErrorColor: envStr('PDS_OAUTH_PROVIDER_ERROR_COLOR'), oauthProviderHomeLink: envStr('PDS_OAUTH_PROVIDER_HOME_LINK'), oauthProviderTosLink: envStr('PDS_OAUTH_PROVIDER_TOS_LINK'), + oauthProviderPrivacyPolicyLink: envStr('PDS_OAUTH_PROVIDER_POLICY_LINK'), + oauthProviderSupportLink: envStr('PDS_OAUTH_PROVIDER_SUPPORT_LINK'), } } @@ -227,4 +229,6 @@ export type ServerEnvironment = { oauthProviderErrorColor?: string oauthProviderHomeLink?: string oauthProviderTosLink?: string + oauthProviderPrivacyPolicyLink?: string + oauthProviderSupportLink?: string } From 47f6207cfdc89ec58a0bc82ff82b16681272cae1 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 18 Mar 2024 18:54:31 +0100 Subject: [PATCH 049/140] wip: sign-up --- .../src/assets/app/backend-data.ts | 14 ++ .../src/assets/app/components/help-card.tsx | 42 ++++ .../app/components/sign-up-account-form.tsx | 210 ++++++++++++++++++ .../app/components/sign-up-disclaimer.tsx | 44 ++++ .../src/assets/app/hooks/use-api.ts | 15 ++ .../src/assets/app/views/authorize-view.tsx | 28 ++- .../src/assets/app/views/sign-up-view.tsx | 141 ++++++++++++ .../src/output/customization.ts | 18 ++ packages/pds/src/config/config.ts | 45 +++- 9 files changed, 547 insertions(+), 10 deletions(-) create mode 100644 packages/oauth-provider/src/assets/app/components/help-card.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/sign-up-account-form.tsx create mode 100644 packages/oauth-provider/src/assets/app/components/sign-up-disclaimer.tsx create mode 100644 packages/oauth-provider/src/assets/app/views/sign-up-view.tsx diff --git a/packages/oauth-provider/src/assets/app/backend-data.ts b/packages/oauth-provider/src/assets/app/backend-data.ts index f98e7829f37..a163bb672f5 100644 --- a/packages/oauth-provider/src/assets/app/backend-data.ts +++ b/packages/oauth-provider/src/assets/app/backend-data.ts @@ -7,6 +7,12 @@ export type FieldDefinition = { title?: string } +export type ExtraFieldDefinition = FieldDefinition & { + type: 'text' | 'password' | 'date' | 'captcha' + required?: boolean + [_: string]: unknown +} + export type LinkDefinition = { title: string href: string @@ -24,6 +30,14 @@ export type CustomizationData = { remember?: FieldDefinition } } + signUp?: { + fields?: { + username?: FieldDefinition + password?: FieldDefinition + remember?: FieldDefinition + } + extraFields?: Record + } } export type ErrorData = { diff --git a/packages/oauth-provider/src/assets/app/components/help-card.tsx b/packages/oauth-provider/src/assets/app/components/help-card.tsx new file mode 100644 index 00000000000..23c0434fd06 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/help-card.tsx @@ -0,0 +1,42 @@ +import { HTMLAttributes } from 'react' +import { LinkDefinition } from '../backend-data' +import { clsx } from '../lib/clsx' + +export type HelpCardProps = { + links?: readonly LinkDefinition[] +} + +export function HelpCard({ + links, + + className, + ...attrs +}: HelpCardProps & + Omit< + HTMLAttributes, + keyof HelpCardProps | 'children' + >) { + const helpLink = links?.find((l) => l.rel === 'help') + + if (!helpLink) return null + + return ( +

+ Having trouble?{' '} + + Contact {helpLink.title} + +

+ ) +} diff --git a/packages/oauth-provider/src/assets/app/components/sign-up-account-form.tsx b/packages/oauth-provider/src/assets/app/components/sign-up-account-form.tsx new file mode 100644 index 00000000000..de7bbf410c3 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/sign-up-account-form.tsx @@ -0,0 +1,210 @@ +import { + FormHTMLAttributes, + ReactNode, + SyntheticEvent, + useCallback, + useState, +} from 'react' + +import { clsx } from '../lib/clsx' +import { ErrorCard } from './error-card' + +export type SignUpAccountFormOutput = { + username: string + password: string +} + +export type SignUpAccountFormProps = { + onSubmit: (credentials: SignUpAccountFormOutput) => void | PromiseLike + submitLabel?: ReactNode + submitAria?: string + + onCancel?: () => void + cancelLabel?: ReactNode + cancelAria?: string + + username?: string + usernamePlaceholder?: string + usernameLabel?: string + usernameAria?: string + usernamePattern?: string + usernameTitle?: string + + passwordPlaceholder?: string + passwordLabel?: string + passwordAria?: string + passwordPattern?: string + passwordTitle?: string +} + +export function SignUpAccountForm({ + onSubmit, + submitAria = 'Next', + submitLabel = submitAria, + + onCancel = undefined, + cancelAria = 'Cancel', + cancelLabel = cancelAria, + + username: defaultUsername = '', + usernameLabel = 'Username', + usernameAria = usernameLabel, + usernamePlaceholder = usernameLabel, + usernamePattern, + usernameTitle, + + passwordLabel = 'Password', + passwordAria = passwordLabel, + passwordPlaceholder = passwordLabel, + passwordPattern, + passwordTitle, + + className, + children, + ...attrs +}: SignUpAccountFormProps & + Omit, keyof SignUpAccountFormProps>) { + const [loading, setLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + const doSubmit = useCallback( + async ( + event: SyntheticEvent< + HTMLFormElement & { + username: HTMLInputElement + password: HTMLInputElement + }, + SubmitEvent + >, + ) => { + event.preventDefault() + + const credentials = { + username: event.currentTarget.username.value, + password: event.currentTarget.password.value, + } + + setLoading(true) + setErrorMessage(null) + try { + await onSubmit(credentials) + } catch (err) { + setErrorMessage(parseErrorMessage(err)) + } finally { + setLoading(false) + } + }, + [onSubmit, setErrorMessage, setLoading], + ) + + return ( +
+
+ + +
+ @ + setErrorMessage(null)} + className="relative m-1 block w-[1px] min-w-0 flex-auto leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100 disabled:text-gray-500" + placeholder={usernamePlaceholder} + aria-label={usernameAria} + autoCapitalize="none" + autoCorrect="off" + autoComplete="username" + spellCheck="false" + dir="auto" + enterKeyHint="next" + required + defaultValue={defaultUsername} + pattern={usernamePattern} + title={usernameTitle} + /> +
+ + + +
+ + * + + setErrorMessage(null)} + className="relative m-1 block w-[1px] min-w-0 flex-auto leading-[1.6] bg-transparent bg-clip-padding text-base text-inherit outline-none dark:placeholder:text-neutral-100" + placeholder={passwordPlaceholder} + aria-label={passwordAria} + autoCapitalize="none" + autoCorrect="off" + autoComplete="new-password" + dir="auto" + enterKeyHint="done" + spellCheck="false" + required + pattern={passwordPattern} + title={passwordTitle} + /> +
+
+ + {children &&
{children}
} + + {errorMessage && } + +
+ +
+ + + {onCancel && ( + + )} + +
+
+ + ) +} + +function parseErrorMessage(err: unknown): string { + switch ((err as any)?.message) { + case 'Invalid credentials': + return 'Invalid username or password' + default: + console.error(err) + return 'An unknown error occurred' + } +} diff --git a/packages/oauth-provider/src/assets/app/components/sign-up-disclaimer.tsx b/packages/oauth-provider/src/assets/app/components/sign-up-disclaimer.tsx new file mode 100644 index 00000000000..0acc39f6fa8 --- /dev/null +++ b/packages/oauth-provider/src/assets/app/components/sign-up-disclaimer.tsx @@ -0,0 +1,44 @@ +import { HTMLAttributes } from 'react' +import { LinkDefinition } from '../backend-data' +import { clsx } from '../lib/clsx' + +export type SignUpDisclaimerProps = { + links?: readonly LinkDefinition[] +} + +export function SignUpDisclaimer({ + links, + + className, + ...attrs +}: SignUpDisclaimerProps & + Omit< + HTMLAttributes, + keyof SignUpDisclaimerProps | 'children' + >) { + const relevantLinks = links?.filter( + (l) => l.rel === 'privacy-policy' || l.rel === 'terms-of-service', + ) + + return ( +

+ By creating an account you agree to the{' '} + {relevantLinks && relevantLinks.length + ? relevantLinks.map((l, i, a) => ( + + {i > 0 && (i < a.length - 1 ? ', ' : ' and ')} + + {l.title} + + + )) + : 'Terms of Service and Privacy Policy'} + . +

+ ) +} diff --git a/packages/oauth-provider/src/assets/app/hooks/use-api.ts b/packages/oauth-provider/src/assets/app/hooks/use-api.ts index b93dd0afa4a..a83b1fb54bc 100644 --- a/packages/oauth-provider/src/assets/app/hooks/use-api.ts +++ b/packages/oauth-provider/src/assets/app/hooks/use-api.ts @@ -12,6 +12,12 @@ export type SignInCredentials = { remember?: boolean } +export type SignUpData = { + username: string + password: string + extra?: Record +} + export function useApi( { clientId, @@ -68,6 +74,14 @@ export function useApi( [api, performRedirect, clientId, setSessions], ) + const doSignUp = useCallback( + (data: SignUpData) => { + // + console.error('SIGNUPPP', data, api) + }, + [api], + ) + const doAccept = useCallback( async (account: Account) => { performRedirect(await api.accept(account)) @@ -84,6 +98,7 @@ export function useApi( setSession, doSignIn, + doSignUp, doAccept, doReject, } diff --git a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx index eaa574ba49b..6534a656428 100644 --- a/packages/oauth-provider/src/assets/app/views/authorize-view.tsx +++ b/packages/oauth-provider/src/assets/app/views/authorize-view.tsx @@ -6,6 +6,7 @@ import { useApi } from '../hooks/use-api' import { useBoundDispatch } from '../hooks/use-bound-dispatch' import { AcceptView } from './accept-view' import { SignInView } from './sign-in-view' +import { SignUpView } from './sign-up-view' import { WelcomeView } from './welcome-view' export type AuthorizeViewProps = { @@ -19,19 +20,18 @@ export function AuthorizeView({ }: AuthorizeViewProps) { const forceSignIn = authorizeData?.loginHint != null - const [view, setView] = useState<'welcome' | 'sign-in' | 'accept' | 'done'>( - forceSignIn ? 'sign-in' : 'welcome', - ) + const [view, setView] = useState< + 'welcome' | 'sign-in' | 'sign-up' | 'accept' | 'done' + >(forceSignIn ? 'sign-in' : 'welcome') const showDone = useBoundDispatch(setView, 'done') const showSignIn = useBoundDispatch(setView, 'sign-in') + const showSignUp = useBoundDispatch(setView, 'sign-up') const showAccept = useBoundDispatch(setView, 'accept') const showWelcome = useBoundDispatch(setView, 'welcome') - const { sessions, setSession, doAccept, doReject, doSignIn } = useApi( - authorizeData, - { onRedirected: showDone }, - ) + const { sessions, setSession, doAccept, doReject, doSignIn, doSignUp } = + useApi(authorizeData, { onRedirected: showDone }) const session = sessions.find((s) => s.selected && !s.loginRequired) useEffect(() => { @@ -48,12 +48,24 @@ export function AuthorizeView({ logo={customizationData?.logo} links={customizationData?.links} onSignIn={showSignIn} - onSignUp={undefined} + onSignUp={showSignUp} onCancel={doReject} /> ) } + if (view === 'sign-up') { + return ( + + ) + } + if (view === 'sign-in') { return ( ReactNode + stepTitle?: (step: number, total: number) => ReactNode + + links?: LinkDefinition[] + fields?: { + username?: FieldDefinition + password?: FieldDefinition + } + extraFields?: Record + onSignUp: (data: { + username: string + password: string + extra?: Record + }) => void | PromiseLike + onBack?: () => void +} + +const defaultStepName: NonNullable = ( + step, + total, +) => `Step ${step} of ${total}` +const defaultStepTitle: NonNullable = ( + step, + total, +) => { + switch (step) { + case 1: + return 'Your account' + default: + return null + } +} + +export function SignUpView({ + stepName = defaultStepName, + stepTitle = defaultStepTitle, + + links, + fields, + extraFields, + + onSignUp, + onBack, +}: SignUpViewProps) { + const [_credentials, setCredentials] = + useState(null) + const [step, setStep] = useState<1 | 2>(1) + + const [extraFieldsEntries, setExtraFieldsEntries] = useState( + extraFields != null ? Object.entries(extraFields) : [], + ) + + const hasExtraFields = extraFieldsEntries.length > 0 + const stepCount = hasExtraFields ? 2 : 1 + + const doSubmitAccount = useCallback( + (credentials: SignUpAccountFormOutput) => { + setCredentials(credentials) + if (hasExtraFields) { + setStep(2) + } else { + onSignUp(credentials) + } + }, + [hasExtraFields, onSignUp, setCredentials, setStep], + ) + + useEffect(() => { + let ef = extraFieldsEntries + for (const entry of extraFields != null + ? Object.entries(extraFields) + : []) { + ef = upsert(ef || [], entry, (a) => a[0] === entry[0]) + } + if (ef !== extraFieldsEntries) setExtraFieldsEntries(ef) + }, [extraFields]) + + return ( + +
+

+ {stepName(step, stepCount)} +

+

+ {stepTitle(step, stepCount)} +

+ + {step === 1 && ( + + + + )} + + {step === 2 && ( + + )} + + +
+
+ ) +} diff --git a/packages/oauth-provider/src/output/customization.ts b/packages/oauth-provider/src/output/customization.ts index 8265019779d..56c1ca806a4 100644 --- a/packages/oauth-provider/src/output/customization.ts +++ b/packages/oauth-provider/src/output/customization.ts @@ -11,6 +11,12 @@ export type FieldDefinition = { title?: string } +export type ExtraFieldDefinition = FieldDefinition & { + type: 'text' | 'password' | 'date' | 'captcha' + required?: boolean + [_: string]: unknown +} + export type Customization = { name?: string logo?: string @@ -28,6 +34,16 @@ export type Customization = { remember?: FieldDefinition } } + + signUp?: + | false + | { + fields?: { + username?: FieldDefinition + password?: FieldDefinition + } + extraFields?: Record + } } export function buildCustomizationData({ @@ -35,12 +51,14 @@ export function buildCustomizationData({ logo, links, signIn, + signUp = false, }: Customization = {}) { return { name, logo, links, signIn, + signUp: signUp || undefined, } } diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 70c069f4b64..996981ddf5c 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -280,12 +280,53 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { fields: { username: { label: 'Email address or handle', - pattern: '.{3,}', - title: 'Must be at least 3 characters long', }, password: {}, }, }, + signUp: { + fields: { + username: { + label: 'Email address', + placeholder: 'Enter your email address', + pattern: + '[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}', + title: 'An email address to identify your account.', + }, + password: { + placeholder: 'Choose your password', + pattern: + // '(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^0-9a-zA-Z]).{8,}' + '.{8,}', + title: + // 'A strong password containing at least one uppercase, one lowercase, one number, and one special character, and at least 8 characters long.' + 'A strong password containing at least 8 characters.', + }, + }, + extraFields: { + handle: { + label: 'Enter your user handle', + placeholder: 'e.g. alice', + type: 'text' as const, + pattern: '[a-z0-9-]{3,20}', + }, + birthdate: { + label: 'Birth date', + type: 'date' as const, + required: true, + }, + inviteCode: { + label: 'Invite Code', + type: 'text' as const, + required: invitesCfg.required, + }, + captcha: { + label: 'Are you human?', + type: 'captcha' as const, + required: true, + }, + }, + }, }, }, } From 8bae593b903f1e58bdefdb89c5bef58a16877135 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Tue, 19 Mar 2024 14:50:13 +0100 Subject: [PATCH 050/140] feat: allow dpopSecret to be left empty --- .../oauth-provider/src/dpop/dpop-manager.ts | 19 +++----- .../oauth-provider/src/dpop/dpop-nonce.ts | 22 +++++++++- packages/pds/src/auth-provider.ts | 43 +++++++++++++------ packages/pds/src/config/secrets.ts | 6 +-- packages/pds/src/context.ts | 16 +++---- 5 files changed, 65 insertions(+), 41 deletions(-) diff --git a/packages/oauth-provider/src/dpop/dpop-manager.ts b/packages/oauth-provider/src/dpop/dpop-manager.ts index e337a3cc71a..d8e42105bd0 100644 --- a/packages/oauth-provider/src/dpop/dpop-manager.ts +++ b/packages/oauth-provider/src/dpop/dpop-manager.ts @@ -4,9 +4,9 @@ import { EmbeddedJWK, calculateJwkThumbprint, jwtVerify } from 'jose' import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' import { UseDpopNonceError } from '../errors/use-dpop-nonce-error.js' -import { DpopNonce } from './dpop-nonce.js' +import { DpopNonce, DpopNonceInput } from './dpop-nonce.js' -export { DpopNonce } +export { DpopNonce, type DpopNonceInput } export type DpopManagerOptions = { /** * Set this to `false` to disable the use of nonces in DPoP proofs. Set this @@ -14,23 +14,16 @@ export type DpopManagerOptions = { * all nonces (typically useful when multiple instances are running). Leave * undefined to generate a random seed at startup. */ - dpopSecret?: false | string | Uint8Array | DpopNonce + dpopSecret?: false | DpopNonceInput + dpopStep?: number } export class DpopManager { protected readonly dpopNonce?: DpopNonce - constructor({ dpopSecret }: DpopManagerOptions = {}) { + constructor({ dpopSecret, dpopStep }: DpopManagerOptions = {}) { this.dpopNonce = - dpopSecret === false - ? undefined - : typeof dpopSecret === 'string' - ? new DpopNonce(Buffer.from(dpopSecret, 'hex')) - : dpopSecret instanceof Uint8Array - ? new DpopNonce(dpopSecret) - : dpopSecret instanceof DpopNonce - ? dpopSecret - : new DpopNonce() + dpopSecret === false ? undefined : DpopNonce.from(dpopSecret, dpopStep) } nextNonce(): string | undefined { diff --git a/packages/oauth-provider/src/dpop/dpop-nonce.ts b/packages/oauth-provider/src/dpop/dpop-nonce.ts index 851b7c7c8d3..4a74e8af294 100644 --- a/packages/oauth-provider/src/dpop/dpop-nonce.ts +++ b/packages/oauth-provider/src/dpop/dpop-nonce.ts @@ -15,6 +15,8 @@ function numTo64bits(num: number) { return arr } +export type DpopNonceInput = string | Uint8Array | DpopNonce + export class DpopNonce { #secret: Uint8Array #counter: number @@ -24,8 +26,8 @@ export class DpopNonce { #next: string constructor( - protected readonly secret: Uint8Array = randomBytes(32), - protected readonly step = DPOP_NONCE_MAX_AGE / 3, + protected readonly secret: Uint8Array, + protected readonly step: number, ) { if (secret.length !== 32) throw new TypeError('Expected 32 bytes') if (this.step < 0 || this.step > DPOP_NONCE_MAX_AGE / 3) { @@ -83,4 +85,20 @@ export class DpopNonce { public check(nonce: string) { return this.#next === nonce || this.#now === nonce || this.#prev === nonce } + + static from( + input: DpopNonceInput = randomBytes(32), + step = DPOP_NONCE_MAX_AGE / 3, + ): DpopNonce { + if (input instanceof DpopNonce) { + return input + } + if (input instanceof Uint8Array) { + return new DpopNonce(input, step) + } + if (typeof input === 'string') { + return new DpopNonce(Buffer.from(input, 'hex'), step) + } + return new DpopNonce(input, step) + } } diff --git a/packages/pds/src/auth-provider.ts b/packages/pds/src/auth-provider.ts index 4a15bea1167..8576dcd854a 100644 --- a/packages/pds/src/auth-provider.ts +++ b/packages/pds/src/auth-provider.ts @@ -6,6 +6,7 @@ import { AccountStore, Customization, DeviceId, + DpopManagerOptions, Keyset, LoginCredentials, OAuthProvider, @@ -21,18 +22,32 @@ import { ActorStore } from './actor-store' import { LocalViewerCreator } from './read-after-write' import { ProfileViewBasic } from './lexicon/types/app/bsky/actor/defs' +export type AuthProviderOptions = { + issuer: string + keyset: Keyset + accountManager: AccountManager + actorStore: ActorStore + localViewer: LocalViewerCreator + redis?: Redis + dpopSecret?: DpopManagerOptions['dpopSecret'] + customization?: Customization + disableSsrf?: boolean +} + export class AuthProvider extends OAuthProvider { - constructor( - accountManager: AccountManager, - actorStore: ActorStore, - localViewerCreator: LocalViewerCreator, - keyset: Keyset, - redis: Redis | undefined, - dpopSecret: false | string | Uint8Array, - issuer: string, - private customization?: Customization, + private customization?: Customization + + constructor({ + accountManager, + actorStore, + localViewer, + keyset, + redis, + dpopSecret, + issuer, + customization, disableSsrf = false, - ) { + }: AuthProviderOptions) { super({ issuer, keyset, @@ -46,7 +61,7 @@ export class AuthProvider extends OAuthProvider { // profile information using the account's repos through the actorStore. accountStore: new DetailedAccountStore( accountManager, - new ActorProfileStoreCached(actorStore, localViewerCreator), + new ActorProfileStoreCached(actorStore, localViewer), ), requestStore: accountManager, sessionStore: accountManager, @@ -100,6 +115,8 @@ export class AuthProvider extends OAuthProvider { tokenResponse['sub'] = account.sub }, }) + + this.customization = customization } createRouter() { @@ -188,12 +205,12 @@ class DetailedAccountStore implements AccountStore { class ActorProfileStore { constructor( private actorStore: ActorStore, - private localViewerCreator: LocalViewerCreator, + private localViewer: LocalViewerCreator, ) {} public async get(did: string): Promise { return this.actorStore.read(did, async (store) => { - const localViewer = this.localViewerCreator(store) + const localViewer = this.localViewer(store) return localViewer.getProfileBasic() }) } diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index 1d165a7c89c..d36f95f3869 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -18,10 +18,6 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { throw new Error('Must configure plc rotation key') } - if (!env.dpopSecret) { - throw new Error('Must provide a DPoP secret') - } - if (!env.jwtSecret) { throw new Error('Must provide a JWT secret') } @@ -39,7 +35,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { } export type ServerSecrets = { - dpopSecret: string + dpopSecret?: string jwtSecret: string adminPassword: string plcRotationKey: SigningKeyKms | SigningKeyMemory diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 05007e04da9..b612419b83e 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -224,17 +224,17 @@ export class AppContext { }) const authProvider = cfg.oauth.provider - ? new AuthProvider( + ? new AuthProvider({ + issuer: cfg.oauth.issuer, + keyset, accountManager, actorStore, localViewer, - keyset, - redisScratch, - secrets.dpopSecret, - cfg.oauth.issuer, - cfg.oauth.provider.customization, - cfg.oauth.provider.disableSsrf, - ) + redis: redisScratch, + dpopSecret: secrets.dpopSecret, + customization: cfg.oauth.provider.customization, + disableSsrf: cfg.oauth.provider.disableSsrf, + }) : undefined const oauthVerifier: OAuthVerifier = From a622709a908a63abfd713aa76971551cf361bc10 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 20 Mar 2024 19:56:32 +0100 Subject: [PATCH 051/140] feat(fetch): add timeout in safe fetch mode --- packages/fetch-node/src/safe.ts | 39 +++++++++++++++------- packages/fetch/src/fetch-request.ts | 51 +++++++++++++++++++++++++---- packages/fetch/src/fetch-wrap.ts | 20 +++++++++++ 3 files changed, 92 insertions(+), 18 deletions(-) diff --git a/packages/fetch-node/src/safe.ts b/packages/fetch-node/src/safe.ts index 3bbea231672..5b4cc531a5a 100644 --- a/packages/fetch-node/src/safe.ts +++ b/packages/fetch-node/src/safe.ts @@ -1,8 +1,11 @@ import { + DEFAULT_FORBIDDEN_DOMAIN_NAMES, Fetch, fetchMaxSizeProcessor, forbiddenDomainNameRequestTransform, protocolCheckRequestTransform, + requireHostHeaderTranform, + timeoutFetchWrap, } from '@atproto/fetch' import { compose } from '@atproto/transformer' @@ -20,21 +23,25 @@ export const safeFetchWrap = ({ fetch = globalThis.fetch as Fetch, responseMaxSize = 512 * 1024, // 512kB allowHttp = false, + allowData = false, ssrfProtection = true, - forbiddenDomainNames = [ - 'example.com', - 'example.org', - 'example.net', - 'bsky.social', - 'bsky.network', - 'googleusercontent.com', - ] as Iterable, + timeout = 10e3 as number, + forbiddenDomainNames = DEFAULT_FORBIDDEN_DOMAIN_NAMES as Iterable, } = {}): Fetch => compose( /** * Prevent using http:, file: or data: protocols. */ - protocolCheckRequestTransform(allowHttp ? ['http:', 'https:'] : ['https:']), + protocolCheckRequestTransform( + ['https:'] + .concat(allowHttp ? ['http:'] : []) + .concat(allowData ? ['data:'] : []), + ), + + /** + * Only requests that will be issued with a "Host" header are allowed. + */ + requireHostHeaderTranform(), /** * Disallow fetching from domains we know are not atproto/OIDC client @@ -46,10 +53,18 @@ export const safeFetchWrap = ({ /** * Since we will be fetching from the network based on user provided - * input, we need to make sure that the request is not vulnerable to SSRF - * attacks. + * input, let's mitigate resource exhaustion attacks by setting a timeout. */ - ssrfProtection ? ssrfFetchWrap({ fetch }) : fetch, + timeoutFetchWrap({ + timeout, + + /** + * Since we will be fetching from the network based on user provided + * input, we need to make sure that the request is not vulnerable to SSRF + * attacks. + */ + fetch: ssrfProtection ? ssrfFetchWrap({ fetch }) : fetch, + }), /** * Since we will be fetching user owned data, we need to make sure that an diff --git a/packages/fetch/src/fetch-request.ts b/packages/fetch/src/fetch-request.ts index e50fe1635a3..fc50219e6a5 100644 --- a/packages/fetch/src/fetch-request.ts +++ b/packages/fetch/src/fetch-request.ts @@ -20,12 +20,11 @@ export function protocolCheckRequestTransform( } } -export function forbiddenDomainNameRequestTransform( - forbiddenDomainNames: Iterable, -): RequestTranformer { - const forbiddenDomainNameSet = new Set(forbiddenDomainNames) - +export function requireHostHeaderTranform(): RequestTranformer { return async (request) => { + // Note that fetch() will automatically add the Host header from the URL and + // discard any Host header manually set in the request. + const { hostname } = new URL(request.url) // IPv4 @@ -38,10 +37,50 @@ export function forbiddenDomainNameRequestTransform( throw new FetchError(400, 'Invalid hostname', { request }) } - if (forbiddenDomainNameSet.has(hostname)) { + return request + } +} + +export const DEFAULT_FORBIDDEN_DOMAIN_NAMES = [ + 'example.com', + '*.example.com', + 'example.org', + '*.example.org', + 'example.net', + '*.example.net', + 'googleusercontent.com', + '*.googleusercontent.com', +] + +export function forbiddenDomainNameRequestTransform( + denyList: Iterable = DEFAULT_FORBIDDEN_DOMAIN_NAMES, +): RequestTranformer { + const denySet = new Set(denyList) + + // Optimization: if no forbidden domain names are provided, we can skip the + // check entirely. + if (denySet.size === 0) { + return async (request) => request + } + + return async (request) => { + const { hostname } = new URL(request.url) + + // Full domain name check + if (denySet.has(hostname)) { throw new FetchError(403, 'Forbidden hostname', { request }) } + // Sub domain name check + let curDot = hostname.indexOf('.') + while (curDot !== -1) { + const subdomain = hostname.slice(curDot + 1) + if (denySet.has(`*.${subdomain}`)) { + throw new FetchError(403, 'Forbidden hostname', { request }) + } + curDot = hostname.indexOf('.', curDot + 1) + } + return request } } diff --git a/packages/fetch/src/fetch-wrap.ts b/packages/fetch/src/fetch-wrap.ts index bb077986292..fb84ef40dfe 100644 --- a/packages/fetch/src/fetch-wrap.ts +++ b/packages/fetch/src/fetch-wrap.ts @@ -41,3 +41,23 @@ const stringifyHeaders = (headers: Headers) => const stringifyBody = (body: string) => body ? `\n ${body.replace(/\r?\n/g, '\\n')}` : '' + +export const timeoutFetchWrap = ({ + fetch = globalThis.fetch as Fetch, + timeout = 60e3, +} = {}): Fetch => { + if (timeout === Infinity) return fetch + if (!(timeout > 0)) throw new TypeError('Timeout must be positive') + + return async (request) => { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout).unref() + const signal = controller.signal + signal.addEventListener('abort', () => clearTimeout(timeoutId)) + request.signal?.addEventListener('abort', () => controller.abort(), { + signal, + }) + + return fetch(new Request(request, { signal })) + } +} From 0ce839c65432caa98e0c3f0330a21278ff6de0fa Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 21 Mar 2024 10:44:26 +0100 Subject: [PATCH 052/140] refactor: factorize client-metadata in own lib --- packages/oauth-client-metadata/README.md | 1 + packages/oauth-client-metadata/package.json | 31 ++++++++++++ packages/oauth-client-metadata/src/index.ts | 2 + .../src/oauth-client-id.ts | 4 ++ .../src/oauth-client-metadata.ts} | 48 +++++++++---------- .../oauth-client-metadata/tsconfig.build.json | 8 ++++ packages/oauth-client-metadata/tsconfig.json | 4 ++ .../src/oauth-client-fqdn-store.ts | 4 +- .../src/oauth-client-uri-store.ts | 44 ++++++++--------- packages/oauth-provider/package.json | 1 + .../src/account/account-manager.ts | 4 +- .../src/account/account-store.ts | 7 +-- .../src/client/client-credentials.ts | 9 ++-- .../oauth-provider/src/client/client-data.ts | 5 +- .../oauth-provider/src/client/client-id.ts | 4 -- .../src/client/client-manager.ts | 12 +++-- .../oauth-provider/src/client/client-store.ts | 10 ++-- packages/oauth-provider/src/client/client.ts | 10 ++-- packages/oauth-provider/src/oauth-client.ts | 2 +- packages/oauth-provider/src/oauth-provider.ts | 14 ++++-- .../src/replay/replay-manager.ts | 9 ++-- .../src/request/request-data.ts | 5 +- .../src/request/request-manager.ts | 5 +- .../src/signer/signed-token-payload.ts | 4 +- .../oauth-provider/src/token/token-claims.ts | 4 +- .../oauth-provider/src/token/token-data.ts | 15 +++--- packages/oauth-provider/src/token/types.ts | 4 +- .../db/schema/authorization-request.ts | 9 +++- .../src/account-manager/db/schema/token.ts | 4 +- .../account-manager/helpers/device-account.ts | 10 ++-- packages/pds/src/oauth/oauth-client-store.ts | 10 ++-- tsconfig.json | 1 + 32 files changed, 184 insertions(+), 120 deletions(-) create mode 100644 packages/oauth-client-metadata/README.md create mode 100644 packages/oauth-client-metadata/package.json create mode 100644 packages/oauth-client-metadata/src/index.ts create mode 100644 packages/oauth-client-metadata/src/oauth-client-id.ts rename packages/{oauth-provider/src/client/client-metadata.ts => oauth-client-metadata/src/oauth-client-metadata.ts} (71%) create mode 100644 packages/oauth-client-metadata/tsconfig.build.json create mode 100644 packages/oauth-client-metadata/tsconfig.json delete mode 100644 packages/oauth-provider/src/client/client-id.ts diff --git a/packages/oauth-client-metadata/README.md b/packages/oauth-client-metadata/README.md new file mode 100644 index 00000000000..4a224b639fd --- /dev/null +++ b/packages/oauth-client-metadata/README.md @@ -0,0 +1 @@ +# @atproto/oauth-client-metadata diff --git a/packages/oauth-client-metadata/package.json b/packages/oauth-client-metadata/package.json new file mode 100644 index 00000000000..c1b8b3b1f9c --- /dev/null +++ b/packages/oauth-client-metadata/package.json @@ -0,0 +1,31 @@ +{ + "name": "@atproto/oauth-client-metadata", + "version": "0.0.1", + "license": "MIT", + "description": "OAuth Client metadata lib", + "keywords": [ + "atproto", + "oauth", + "client", + "metadata", + "isomorphic" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-client-metadata" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/jwk": "workspace:*", + "zod": "^3.22.4" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/oauth-client-metadata/src/index.ts b/packages/oauth-client-metadata/src/index.ts new file mode 100644 index 00000000000..2d3ef763ed5 --- /dev/null +++ b/packages/oauth-client-metadata/src/index.ts @@ -0,0 +1,2 @@ +export * from './oauth-client-id.js' +export * from './oauth-client-metadata.js' diff --git a/packages/oauth-client-metadata/src/oauth-client-id.ts b/packages/oauth-client-metadata/src/oauth-client-id.ts new file mode 100644 index 00000000000..6d5dcb55381 --- /dev/null +++ b/packages/oauth-client-metadata/src/oauth-client-id.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const oauthClientIdSchema = z.string().min(1) +export type OAuthClientId = z.infer diff --git a/packages/oauth-provider/src/client/client-metadata.ts b/packages/oauth-client-metadata/src/oauth-client-metadata.ts similarity index 71% rename from packages/oauth-provider/src/client/client-metadata.ts rename to packages/oauth-client-metadata/src/oauth-client-metadata.ts index 4ffb83af8c8..d8dfb452863 100644 --- a/packages/oauth-provider/src/client/client-metadata.ts +++ b/packages/oauth-client-metadata/src/oauth-client-metadata.ts @@ -1,8 +1,7 @@ import { jwksPubSchema } from '@atproto/jwk' import { z } from 'zod' -import { VERIFY_ALGOS } from '../util/crypto.js' -import { clientIdSchema } from './client-id.js' +import { oauthClientIdSchema } from './oauth-client-id.js' export const endpointAuthMethod = z.enum([ 'client_secret_basic', @@ -14,12 +13,9 @@ export const endpointAuthMethod = z.enum([ 'tls_client_auth', ]) -export const algSchema = z.enum(VERIFY_ALGOS) - -// TODO: Move in shared package // https://openid.net/specs/openid-connect-registration-1_0.html // https://datatracker.ietf.org/doc/html/rfc7591 -export const clientMetadataSchema = z +export const oauthClientMetadataSchema = z .object({ redirect_uris: z.array(z.string().url()).nonempty().readonly(), response_types: z @@ -31,11 +27,11 @@ export const clientMetadataSchema = z // OpenID 'none', - 'id_token', + 'code id_token token', 'code id_token', - 'id_token token', 'code token', - 'code id_token token', + 'id_token token', + 'id_token', ]), ) .nonempty() @@ -59,26 +55,29 @@ export const clientMetadataSchema = z .default(['authorization_code']) .readonly(), scope: z.string().optional(), - token_endpoint_auth_method: endpointAuthMethod.default('none'), - token_endpoint_auth_signing_alg: algSchema.optional(), + token_endpoint_auth_method: endpointAuthMethod.default('none').optional(), + token_endpoint_auth_signing_alg: z.string().optional(), introspection_endpoint_auth_method: endpointAuthMethod.optional(), - introspection_endpoint_auth_signing_alg: algSchema.optional(), + introspection_endpoint_auth_signing_alg: z.string().optional(), revocation_endpoint_auth_method: endpointAuthMethod.optional(), - revocation_endpoint_auth_signing_alg: algSchema.optional(), - userinfo_signed_response_alg: algSchema.optional(), + revocation_endpoint_auth_signing_alg: z.string().optional(), + pushed_authorization_request_endpoint_auth_method: + endpointAuthMethod.optional(), + pushed_authorization_request_endpoint_auth_signing_alg: z + .string() + .optional(), + userinfo_signed_response_alg: z.string().optional(), userinfo_encrypted_response_alg: z.string().optional(), jwks_uri: z.string().url().optional(), jwks: jwksPubSchema.optional(), - application_type: z.enum(['web', 'native']).default('web'), // default, per spec, is "web" - subject_type: z.enum(['public', 'pairwise']).default('public'), - request_object_signing_alg: z - .union([algSchema, z.literal('none')]) - .optional(), - id_token_signed_response_alg: algSchema.optional(), - authorization_signed_response_alg: algSchema.default('RS256').optional(), + application_type: z.enum(['web', 'native']).default('web').optional(), // default, per spec, is "web" + subject_type: z.enum(['public', 'pairwise']).default('public').optional(), + request_object_signing_alg: z.string().optional(), + id_token_signed_response_alg: z.string().optional(), + authorization_signed_response_alg: z.string().default('RS256').optional(), authorization_encrypted_response_enc: z.enum(['A128CBC-HS256']).optional(), - authorization_encrypted_response_alg: algSchema.optional(), - client_id: clientIdSchema.optional(), + authorization_encrypted_response_alg: z.string().optional(), + client_id: oauthClientIdSchema.optional(), client_name: z.string().optional(), client_uri: z.string().url().optional(), policy_uri: z.string().url().optional(), @@ -96,6 +95,5 @@ export const clientMetadataSchema = z authorization_details_types: z.array(z.string()).readonly().optional(), }) .readonly() - .brand('ClientMetadata') -export type ClientMetadata = z.infer +export type OAuthClientMetadata = z.infer diff --git a/packages/oauth-client-metadata/tsconfig.build.json b/packages/oauth-client-metadata/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/oauth-client-metadata/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/oauth-client-metadata/tsconfig.json b/packages/oauth-client-metadata/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/oauth-client-metadata/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/oauth-provider-client-fqdn/src/oauth-client-fqdn-store.ts b/packages/oauth-provider-client-fqdn/src/oauth-client-fqdn-store.ts index 74032f28c4d..a56428017c5 100644 --- a/packages/oauth-provider-client-fqdn/src/oauth-client-fqdn-store.ts +++ b/packages/oauth-provider-client-fqdn/src/oauth-client-fqdn-store.ts @@ -1,7 +1,7 @@ import { - ClientId, ClientStore, InvalidClientMetadataError, + OAuthClientId, } from '@atproto/oauth-provider' import { OAuthClientUriStore, @@ -20,7 +20,7 @@ export class OAuthClientFQDNStore extends OAuthClientUriStore implements ClientStore { - override async buildClientUrl(clientId: ClientId): Promise { + override async buildClientUrl(clientId: OAuthClientId): Promise { if (clientId === 'localhost') { return super.buildClientUrl('http://localhost/') } diff --git a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts index 3815f48f591..0bed036f331 100644 --- a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts +++ b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts @@ -9,12 +9,12 @@ import { Jwks, jwksSchema } from '@atproto/jwk' import { Awaitable, ClientData, - ClientId, - ClientMetadata, ClientStore, InvalidClientMetadataError, InvalidRedirectUriError, - clientMetadataSchema, + OAuthClientId, + OAuthClientMetadata, + oauthClientMetadataSchema, parseRedirectUri, } from '@atproto/oauth-provider' import { compose } from '@atproto/transformer' @@ -24,7 +24,7 @@ import { buildWellknownUrl, isInternetHost, isLoopbackHost } from './util.js' const metadataTransformer = compose( fetchOkProcessor(), fetchJsonProcessor('application/json', false), - fetchZodBodyProcessor(clientMetadataSchema), + fetchZodBodyProcessor(oauthClientMetadataSchema), ) const responseToJwksTransformer = compose( @@ -35,11 +35,11 @@ const responseToJwksTransformer = compose( export type LoopbackMetadataGetter = ( url: URL, -) => Awaitable> +) => Awaitable> export type ClientMetadataValidator = ( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, - metadata: ClientMetadata, + metadata: OAuthClientMetadata, ) => Awaitable export type OAuthClientUriStoreConfig = { @@ -87,7 +87,7 @@ export class OAuthClientUriStore implements ClientStore { this.validateMetadataCustom = validateMetadata || undefined } - public async findClient(clientId: ClientId): Promise { + public async findClient(clientId: OAuthClientId): Promise { const clientUrl = await this.buildClientUrl(clientId) if (isLoopbackHost(clientUrl.hostname)) { @@ -104,7 +104,7 @@ export class OAuthClientUriStore implements ClientStore { } } - protected async buildClientUrl(clientId: ClientId): Promise { + protected async buildClientUrl(clientId: OAuthClientId): Promise { const url = (() => { try { return new URL(clientId) @@ -139,7 +139,7 @@ export class OAuthClientUriStore implements ClientStore { } protected async loopbackClient( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, ): Promise { if (!this.loopbackMetadata) { @@ -158,7 +158,7 @@ export class OAuthClientUriStore implements ClientStore { ) } - const metadata = clientMetadataSchema.parse( + const metadata = oauthClientMetadataSchema.parse( await this.loopbackMetadata(clientUrl), ) @@ -168,7 +168,7 @@ export class OAuthClientUriStore implements ClientStore { } protected async internetClient( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, ): Promise { const metadataEndpoint = await this.getMetadataEndpoint(clientId, clientUrl) @@ -184,7 +184,7 @@ export class OAuthClientUriStore implements ClientStore { } protected async getMetadataEndpoint( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, ): Promise { return buildWellknownUrl(clientUrl, `oauth-client-metadata`) @@ -192,7 +192,7 @@ export class OAuthClientUriStore implements ClientStore { protected async fetchMetadata( metadataEndpoint: string | URL, - ): Promise { + ): Promise { const request = new Request(metadataEndpoint, { redirect: 'error', headers: { accept: 'application/json' }, @@ -216,9 +216,9 @@ export class OAuthClientUriStore implements ClientStore { * ClientManager class in the oauth-provider package. */ protected async validateMetadata( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, - metadata: ClientMetadata, + metadata: OAuthClientMetadata, ): Promise { await this.validateMetadataClientId(clientId, clientUrl, metadata) await this.validateMetadataClientUri(clientId, clientUrl, metadata) @@ -227,9 +227,9 @@ export class OAuthClientUriStore implements ClientStore { } protected async validateMetadataClientId( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, - metadata: ClientMetadata, + metadata: OAuthClientMetadata, ): Promise { if (metadata.client_id && metadata.client_id !== clientId) { throw new InvalidClientMetadataError('client_id must match the client ID') @@ -237,9 +237,9 @@ export class OAuthClientUriStore implements ClientStore { } protected async validateMetadataClientUri( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, - metadata: ClientMetadata, + metadata: OAuthClientMetadata, ): Promise { if (metadata.client_uri && metadata.client_uri !== clientUrl.href) { throw new InvalidClientMetadataError( @@ -249,9 +249,9 @@ export class OAuthClientUriStore implements ClientStore { } protected async validateMetadataRedirectUris( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, - metadata: ClientMetadata, + metadata: OAuthClientMetadata, ): Promise { for (const redirectUri of metadata.redirect_uris) { const uri = parseRedirectUri(redirectUri) diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index 2807a2a4773..ba74ed4bf9a 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -34,6 +34,7 @@ "@atproto/http-util": "workspace:*", "@atproto/jwk": "workspace:*", "@atproto/jwk-node": "workspace:*", + "@atproto/oauth-client-metadata": "workspace:*", "cookie": "^0.6.0", "jose": "^5.2.0", "keygrip": "^1.1.0", diff --git a/packages/oauth-provider/src/account/account-manager.ts b/packages/oauth-provider/src/account/account-manager.ts index 7f16b5cb7c0..dc3e52e8f4d 100644 --- a/packages/oauth-provider/src/account/account-manager.ts +++ b/packages/oauth-provider/src/account/account-manager.ts @@ -1,4 +1,4 @@ -import { ClientId } from '../client/client-id.js' +import { OAuthClientId } from '@atproto/oauth-client-metadata' import { DeviceId } from '../device/device-id.js' import { UnauthorizedError } from '../errors/unauthorized-error.js' import { Sub } from '../oidc/sub.js' @@ -32,7 +32,7 @@ export class AccountManager { public async addAuthorizedClient( deviceId: DeviceId, sub: Sub, - clientId: ClientId, + clientId: OAuthClientId, ): Promise { await this.store.addAuthorizedClient(deviceId, sub, clientId) } diff --git a/packages/oauth-provider/src/account/account-store.ts b/packages/oauth-provider/src/account/account-store.ts index 05cf0682377..c53f8961a77 100644 --- a/packages/oauth-provider/src/account/account-store.ts +++ b/packages/oauth-provider/src/account/account-store.ts @@ -1,4 +1,5 @@ -import { ClientId } from '../client/client-id.js' +import { OAuthClientId } from '@atproto/oauth-client-metadata' + import { DeviceId } from '../device/device-id.js' import { Sub } from '../oidc/sub.js' import { Awaitable } from '../util/awaitable.js' @@ -19,7 +20,7 @@ export type LoginCredentials = { export type DeviceAccountInfo = { remembered: boolean authenticatedAt: Date - authorizedClients: readonly ClientId[] + authorizedClients: readonly OAuthClientId[] } // Export all types needed to implement the AccountStore interface @@ -39,7 +40,7 @@ export interface AccountStore { addAuthorizedClient( deviceId: DeviceId, sub: Sub, - clientId: ClientId, + clientId: OAuthClientId, ): Awaitable getDeviceAccount(deviceId: DeviceId, sub: Sub): Awaitable diff --git a/packages/oauth-provider/src/client/client-credentials.ts b/packages/oauth-provider/src/client/client-credentials.ts index ac586790caf..63b7121de8c 100644 --- a/packages/oauth-provider/src/client/client-credentials.ts +++ b/packages/oauth-provider/src/client/client-credentials.ts @@ -1,12 +1,11 @@ import { z } from 'zod' - -import { clientIdSchema } from './client-id.js' +import { oauthClientIdSchema } from '@atproto/oauth-client-metadata' export const CLIENT_ASSERTION_TYPE_JWT_BEARER = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' export const clientJwtBearerAssertionSchema = z.object({ - client_id: clientIdSchema, + client_id: oauthClientIdSchema, client_assertion_type: z.literal(CLIENT_ASSERTION_TYPE_JWT_BEARER), /** * - "sub" the subject MUST be the "client_id" of the OAuth client @@ -23,7 +22,7 @@ export const clientJwtBearerAssertionSchema = z.object({ }) export const clientSecretPostSchema = z.object({ - client_id: clientIdSchema, + client_id: oauthClientIdSchema, client_secret: z.string(), }) @@ -37,7 +36,7 @@ export type ClientCredentials = z.infer export const clientIdentificationSchema = z.union([ clientCredentialsSchema, // Must be last since it is less specific - z.object({ client_id: clientIdSchema }), + z.object({ client_id: oauthClientIdSchema }), ]) export type ClientIdentification = z.infer diff --git a/packages/oauth-provider/src/client/client-data.ts b/packages/oauth-provider/src/client/client-data.ts index 45e64d20dde..5a4fad2da26 100644 --- a/packages/oauth-provider/src/client/client-data.ts +++ b/packages/oauth-provider/src/client/client-data.ts @@ -1,8 +1,7 @@ import { Jwks } from '@atproto/jwk' - -import { ClientMetadata } from './client-metadata.js' +import { OAuthClientMetadata } from '@atproto/oauth-client-metadata' export type ClientData = { - metadata: ClientMetadata + metadata: OAuthClientMetadata jwks?: Jwks } diff --git a/packages/oauth-provider/src/client/client-id.ts b/packages/oauth-provider/src/client/client-id.ts deleted file mode 100644 index a0d4975e9c1..00000000000 --- a/packages/oauth-provider/src/client/client-id.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { z } from 'zod' - -export const clientIdSchema = z.string().min(1) -export type ClientId = z.infer diff --git a/packages/oauth-provider/src/client/client-manager.ts b/packages/oauth-provider/src/client/client-manager.ts index fa872345455..75f8ee88637 100644 --- a/packages/oauth-provider/src/client/client-manager.ts +++ b/packages/oauth-provider/src/client/client-manager.ts @@ -1,12 +1,14 @@ import { Jwks, Keyset } from '@atproto/jwk' +import { + OAuthClientId, + OAuthClientMetadata, +} from '@atproto/oauth-client-metadata' import { InvalidClientMetadataError } from '../errors/invalid-client-metadata-error.js' import { InvalidRedirectUriError } from '../errors/invalid-redirect-uri-error.js' import { OAuthError } from '../errors/oauth-error.js' import { Awaitable } from '../util/awaitable.js' import { ClientData } from './client-data.js' -import { ClientId } from './client-id.js' -import { ClientMetadata } from './client-metadata.js' import { ClientStore } from './client-store.js' import { parseRedirectUri } from './client-utils.js' import { Client } from './client.js' @@ -15,8 +17,8 @@ import { Client } from './client.js' * Use this to alter or override client metadata & jwks before they are used. */ export type ClientDataHook = ( - clientId: ClientId, - data: { metadata: ClientMetadata; jwks?: Jwks }, + clientId: OAuthClientId, + data: { metadata: OAuthClientMetadata; jwks?: Jwks }, ) => Awaitable export class ClientManager { @@ -31,7 +33,7 @@ export class ClientManager { * and OIDC specifications. It will also ensure that the metadata is * compatible with this implementation. */ - protected async findClient(clientId: ClientId): Promise { + protected async findClient(clientId: OAuthClientId): Promise { try { const { metadata, jwks } = await this.store.findClient(clientId) diff --git a/packages/oauth-provider/src/client/client-store.ts b/packages/oauth-provider/src/client/client-store.ts index 3984c9912e6..43ee0d048f0 100644 --- a/packages/oauth-provider/src/client/client-store.ts +++ b/packages/oauth-provider/src/client/client-store.ts @@ -1,13 +1,15 @@ +import { + OAuthClientId, + OAuthClientMetadata, +} from '@atproto/oauth-client-metadata' import { Awaitable } from '../util/awaitable.js' -import { ClientId } from './client-id.js' -import { ClientMetadata } from './client-metadata.js' import { ClientData } from './client-data.js' // Export all types needed to implement the ClientStore interface -export type { ClientId, ClientMetadata, ClientData, Awaitable } +export type { Awaitable, ClientData, OAuthClientId, OAuthClientMetadata } export interface ClientStore { - findClient(clientId: ClientId): Awaitable + findClient(clientId: OAuthClientId): Awaitable } export function isClientStore( diff --git a/packages/oauth-provider/src/client/client.ts b/packages/oauth-provider/src/client/client.ts index f34b9a2614b..f7d6c6c3a9c 100644 --- a/packages/oauth-provider/src/client/client.ts +++ b/packages/oauth-provider/src/client/client.ts @@ -1,4 +1,8 @@ import { Jwks } from '@atproto/jwk' +import { + OAuthClientId, + OAuthClientMetadata, +} from '@atproto/oauth-client-metadata' import { JWTPayload, JWTVerifyGetKey, @@ -22,8 +26,6 @@ import { CLIENT_ASSERTION_TYPE_JWT_BEARER, ClientIdentification, } from './client-credentials.js' -import { ClientId } from './client-id.js' -import { ClientMetadata } from './client-metadata.js' type AuthEndpoint = 'token' | 'introspection' | 'revocation' @@ -36,8 +38,8 @@ export class Client { private readonly keyGetter: JWTVerifyGetKey constructor( - public readonly id: ClientId, - public readonly metadata: ClientMetadata, + public readonly id: OAuthClientId, + public readonly metadata: OAuthClientMetadata, jwks: undefined | Jwks = metadata.jwks, ) { // If the remote JWKS content is provided, we don't need to fetch it again. diff --git a/packages/oauth-provider/src/oauth-client.ts b/packages/oauth-provider/src/oauth-client.ts index 73be8e6ca85..ad9ffbc6264 100644 --- a/packages/oauth-provider/src/oauth-client.ts +++ b/packages/oauth-provider/src/oauth-client.ts @@ -1,2 +1,2 @@ -export * from './client/client-metadata.js' +export * from '@atproto/oauth-client-metadata' export * from './client/client-utils.js' diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 506461c4e50..d14773c1597 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -16,6 +16,11 @@ import { writeJson, } from '@atproto/http-util' import { Jwks, Jwt, Keyset, jwtSchema } from '@atproto/jwk' +import { + OAuthClientId, + oauthClientIdSchema, +} from '@atproto/oauth-client-metadata' + import { JWTHeaderParameters, ResolvedKey } from 'jose' import { z } from 'zod' @@ -36,7 +41,6 @@ import { CLIENT_ASSERTION_TYPE_JWT_BEARER, ClientIdentification, } from './client/client-credentials.js' -import { ClientId, clientIdSchema } from './client/client-id.js' import { ClientDataHook, ClientManager } from './client/client-manager.js' import { ClientStore, asClientStore } from './client/client-store.js' import { Client } from './client/client.js' @@ -536,7 +540,7 @@ export class OAuthProvider extends OAuthVerifier { protected async acceptRequest( deviceId: DeviceId, uri: RequestUri, - clientId: ClientId, + clientId: OAuthClientId, sub: string, ): Promise { const { issuer } = this @@ -593,7 +597,7 @@ export class OAuthProvider extends OAuthVerifier { protected async rejectRequest( deviceId: DeviceId, uri: RequestUri, - clientId: ClientId, + clientId: OAuthClientId, ): Promise { try { const { parameters } = await this.requestManager.get( @@ -1148,7 +1152,7 @@ export class OAuthProvider extends OAuthVerifier { const acceptQuerySchema = z.object({ csrf_token: z.string(), request_uri: requestUriSchema, - client_id: clientIdSchema, + client_id: oauthClientIdSchema, account_sub: z.string(), }) @@ -1202,7 +1206,7 @@ export class OAuthProvider extends OAuthVerifier { const rejectQuerySchema = z.object({ csrf_token: z.string(), request_uri: requestUriSchema, - client_id: clientIdSchema, + client_id: oauthClientIdSchema, }) router.get('/oauth/authorize/reject', async function (req, res) { diff --git a/packages/oauth-provider/src/replay/replay-manager.ts b/packages/oauth-provider/src/replay/replay-manager.ts index 0433a7a0b66..42e17ed18da 100644 --- a/packages/oauth-provider/src/replay/replay-manager.ts +++ b/packages/oauth-provider/src/replay/replay-manager.ts @@ -1,4 +1,5 @@ -import { ClientId } from '../client/client-id.js' +import { OAuthClientId } from '@atproto/oauth-client-metadata' + import { CLIENT_ASSERTION_MAX_AGE, DPOP_NONCE_MAX_AGE, @@ -12,7 +13,7 @@ const asTimeFrame = (timeFrame: number) => Math.ceil(timeFrame * SECURITY_RATIO) export class ReplayManager { constructor(protected readonly replayStore: ReplayStore) {} - async uniqueAuth(jti: string, clientId: ClientId): Promise { + async uniqueAuth(jti: string, clientId: OAuthClientId): Promise { return this.replayStore.unique( `Auth@${clientId}`, jti, @@ -20,7 +21,7 @@ export class ReplayManager { ) } - async uniqueJar(jti: string, clientId: ClientId): Promise { + async uniqueJar(jti: string, clientId: OAuthClientId): Promise { return this.replayStore.unique( `JAR@${clientId}`, jti, @@ -28,7 +29,7 @@ export class ReplayManager { ) } - async uniqueDpop(jti: string, clientId?: ClientId): Promise { + async uniqueDpop(jti: string, clientId?: OAuthClientId): Promise { return this.replayStore.unique( clientId ? `DPoP@${clientId}` : `DPoP`, jti, diff --git a/packages/oauth-provider/src/request/request-data.ts b/packages/oauth-provider/src/request/request-data.ts index a9a5408c8f2..58aeea9f9d6 100644 --- a/packages/oauth-provider/src/request/request-data.ts +++ b/packages/oauth-provider/src/request/request-data.ts @@ -1,12 +1,13 @@ +import { OAuthClientId } from '@atproto/oauth-client-metadata' + import { ClientAuth } from '../client/client-auth.js' -import { ClientId } from '../client/client-id.js' import { DeviceId } from '../device/device-id.js' import { Sub } from '../oidc/sub.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { Code } from './code.js' export type RequestData = { - clientId: ClientId + clientId: OAuthClientId clientAuth: ClientAuth parameters: AuthorizationParameters expiresAt: Date diff --git a/packages/oauth-provider/src/request/request-manager.ts b/packages/oauth-provider/src/request/request-manager.ts index d966bf2493e..174c590c33e 100644 --- a/packages/oauth-provider/src/request/request-manager.ts +++ b/packages/oauth-provider/src/request/request-manager.ts @@ -1,7 +1,8 @@ +import { OAuthClientId } from '@atproto/oauth-client-metadata' + import { DeviceAccountInfo } from '../account/account-store.js' import { Account } from '../account/account.js' import { ClientAuth } from '../client/client-auth.js' -import { ClientId } from '../client/client-id.js' import { Client } from '../client/client.js' import { AUTHORIZATION_INACTIVITY_TIMEOUT, @@ -298,7 +299,7 @@ export class RequestManager { async get( uri: RequestUri, - clientId: ClientId, + clientId: OAuthClientId, deviceId: DeviceId, ): Promise { const id = decodeRequestUri(uri) diff --git a/packages/oauth-provider/src/signer/signed-token-payload.ts b/packages/oauth-provider/src/signer/signed-token-payload.ts index 6dbdfe3287d..6b5beeb85a1 100644 --- a/packages/oauth-provider/src/signer/signed-token-payload.ts +++ b/packages/oauth-provider/src/signer/signed-token-payload.ts @@ -1,7 +1,7 @@ import { jwtPayloadSchema } from '@atproto/jwk' +import { oauthClientIdSchema } from '@atproto/oauth-client-metadata' import z from 'zod' -import { clientIdSchema } from '../client/client-id.js' import { subSchema } from '../oidc/sub.js' import { tokenIdSchema } from '../token/token-id.js' import { Simplify } from '../util/type.js' @@ -26,7 +26,7 @@ export const signedTokenPayloadSchema = z.intersection( .extend({ jti: tokenIdSchema, sub: subSchema, - client_id: clientIdSchema, + client_id: oauthClientIdSchema, }), ) diff --git a/packages/oauth-provider/src/token/token-claims.ts b/packages/oauth-provider/src/token/token-claims.ts index 61dab335e9b..588df105efc 100644 --- a/packages/oauth-provider/src/token/token-claims.ts +++ b/packages/oauth-provider/src/token/token-claims.ts @@ -1,7 +1,7 @@ import { jwtPayloadSchema } from '@atproto/jwk' +import { oauthClientIdSchema } from '@atproto/oauth-client-metadata' import z from 'zod' -import { clientIdSchema } from '../client/client-id.js' import { subSchema } from '../oidc/sub.js' import { Simplify } from '../util/type.js' @@ -23,7 +23,7 @@ export const tokenClaimsSchema = z.intersection( .partial() .extend({ sub: subSchema, - client_id: clientIdSchema, + client_id: oauthClientIdSchema, }), ) diff --git a/packages/oauth-provider/src/token/token-data.ts b/packages/oauth-provider/src/token/token-data.ts index 18280548272..2379079b41f 100644 --- a/packages/oauth-provider/src/token/token-data.ts +++ b/packages/oauth-provider/src/token/token-data.ts @@ -1,26 +1,27 @@ -import { ClientId } from '../client/client-id.js' +import { OAuthClientId } from '@atproto/oauth-client-metadata' + import { ClientAuth } from '../client/client-auth.js' import { DeviceId } from '../device/device-id.js' import { Sub } from '../oidc/sub.js' -import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { AuthorizationDetails } from '../parameters/authorization-details.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { Code } from '../request/code.js' export type { - ClientId, + AuthorizationDetails, + AuthorizationParameters, ClientAuth, + Code, DeviceId, + OAuthClientId, Sub, - AuthorizationParameters, - AuthorizationDetails, - Code, } export type TokenData = { createdAt: Date updatedAt: Date expiresAt: Date - clientId: ClientId + clientId: OAuthClientId clientAuth: ClientAuth deviceId: DeviceId | null sub: Sub diff --git a/packages/oauth-provider/src/token/types.ts b/packages/oauth-provider/src/token/types.ts index a161d6ec39c..852f8d7587d 100644 --- a/packages/oauth-provider/src/token/types.ts +++ b/packages/oauth-provider/src/token/types.ts @@ -1,8 +1,8 @@ +import { oauthClientIdSchema } from '@atproto/oauth-client-metadata' import { z } from 'zod' import { accessTokenSchema } from '../access-token/access-token.js' import { clientIdentificationSchema } from '../client/client-credentials.js' -import { clientIdSchema } from '../client/client-id.js' import { AuthorizationDetails } from '../parameters/authorization-details.js' import { codeSchema } from '../request/code.js' import { refreshTokenSchema } from './refresh-token.js' @@ -31,7 +31,7 @@ export const refreshGrantRequestSchema = z.intersection( z.object({ grant_type: z.literal('refresh_token'), refresh_token: refreshTokenSchema, - client_id: clientIdSchema, + client_id: oauthClientIdSchema, }), ) diff --git a/packages/pds/src/account-manager/db/schema/authorization-request.ts b/packages/pds/src/account-manager/db/schema/authorization-request.ts index c111f3a8919..4a81e46434f 100644 --- a/packages/pds/src/account-manager/db/schema/authorization-request.ts +++ b/packages/pds/src/account-manager/db/schema/authorization-request.ts @@ -1,4 +1,9 @@ -import { ClientId, Code, DeviceId, RequestId } from '@atproto/oauth-provider' +import { + Code, + DeviceId, + OAuthClientId, + RequestId, +} from '@atproto/oauth-provider' import { Selectable } from 'kysely' import { DateISO, JsonObject } from '../../../db' @@ -7,7 +12,7 @@ export interface AuthorizationRequest { did: string | null deviceId: DeviceId | null - clientId: ClientId + clientId: OAuthClientId clientAuth: JsonObject parameters: JsonObject expiresAt: DateISO // TODO: Index this diff --git a/packages/pds/src/account-manager/db/schema/token.ts b/packages/pds/src/account-manager/db/schema/token.ts index f4beb9544ff..dd40e0c4e69 100644 --- a/packages/pds/src/account-manager/db/schema/token.ts +++ b/packages/pds/src/account-manager/db/schema/token.ts @@ -1,7 +1,7 @@ import { - ClientId, Code, DeviceId, + OAuthClientId, RefreshToken, Sub, TokenId, @@ -18,7 +18,7 @@ export interface Token { createdAt: DateISO updatedAt: DateISO expiresAt: DateISO - clientId: ClientId + clientId: OAuthClientId clientAuth: JsonObject deviceId: DeviceId | null parameters: JsonObject diff --git a/packages/pds/src/account-manager/helpers/device-account.ts b/packages/pds/src/account-manager/helpers/device-account.ts index 0979f952dd9..1bcf7d2efb6 100644 --- a/packages/pds/src/account-manager/helpers/device-account.ts +++ b/packages/pds/src/account-manager/helpers/device-account.ts @@ -1,8 +1,8 @@ import { Account, - ClientId, DeviceAccountInfo, DeviceId, + OAuthClientId, } from '@atproto/oauth-provider' import { Insertable, Selectable } from 'kysely' @@ -29,7 +29,7 @@ const selectAccountInfoQB = (db: AccountDb, deviceId: DeviceId) => export type InsertableField = { authenticatedAt: Date - authorizedClients: ClientId[] + authorizedClients: OAuthClientId[] remember: boolean } @@ -58,7 +58,7 @@ export function toDeviceAccountInfo( return { remembered: row.remember === 1, authenticatedAt: fromDateISO(row.authenticatedAt), - authorizedClients: fromJsonArray(row.authorizedClients), + authorizedClients: fromJsonArray(row.authorizedClients), } } @@ -87,7 +87,7 @@ export const getAuthorizedClients = async ( .select('authorizedClients') .executeTakeFirstOrThrow() - return fromJsonArray(row.authorizedClients) + return fromJsonArray(row.authorizedClients) } export const update = async ( @@ -96,7 +96,7 @@ export const update = async ( did: string, entry: { authenticatedAt?: Date - authorizedClients?: ClientId[] + authorizedClients?: OAuthClientId[] remember?: boolean }, ): Promise => { diff --git a/packages/pds/src/oauth/oauth-client-store.ts b/packages/pds/src/oauth/oauth-client-store.ts index ef6b73e6a7e..e79b64cab70 100644 --- a/packages/pds/src/oauth/oauth-client-store.ts +++ b/packages/pds/src/oauth/oauth-client-store.ts @@ -1,9 +1,9 @@ import { - ClientId, - ClientMetadata, ClientStore, InvalidClientMetadataError, InvalidRedirectUriError, + OAuthClientId, + OAuthClientMetadata, parseRedirectUri, } from '@atproto/oauth-provider' import { @@ -29,7 +29,7 @@ export class OauthClientStore * Allow "loopback" clients using the following client metadata (as defined in * the ATPROTO spec). */ -function loopbackMetadata({ href }: URL): Partial { +function loopbackMetadata({ href }: URL): Partial { return { client_name: 'Loopback ATPROTO client', client_uri: href, @@ -49,9 +49,9 @@ function loopbackMetadata({ href }: URL): Partial { * Make sure that fetched metadata are spec compliant */ function validateMetadata( - clientId: ClientId, + clientId: OAuthClientId, clientUrl: URL, - metadata: ClientMetadata, + metadata: OAuthClientMetadata, ) { // ATPROTO spec requires the use of DPoP (default is false) if (metadata.dpop_bound_access_tokens !== true) { diff --git a/tsconfig.json b/tsconfig.json index 5a400fd4e8d..06f89fce301 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ { "path": "./packages/jwk-node" }, { "path": "./packages/lex-cli" }, { "path": "./packages/lexicon" }, + { "path": "./packages/oauth-client-metadata" }, { "path": "./packages/oauth-provider" }, { "path": "./packages/oauth-provider-client-uri" }, { "path": "./packages/oauth-provider-client-fqdn" }, From d24bb08a8277ee18fb104105afbacded540db825 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 21 Mar 2024 10:45:51 +0100 Subject: [PATCH 053/140] feat(caching): add generic caching utils --- packages/caching/package.json | 24 ++++ packages/caching/src/cached-getter.ts | 141 +++++++++++++++++++++ packages/caching/src/generic-store.ts | 18 +++ packages/caching/src/index.ts | 3 + packages/caching/src/memory-store.ts | 176 ++++++++++++++++++++++++++ packages/caching/tsconfig.build.json | 8 ++ packages/caching/tsconfig.json | 4 + tsconfig.json | 1 + 8 files changed, 375 insertions(+) create mode 100644 packages/caching/package.json create mode 100644 packages/caching/src/cached-getter.ts create mode 100644 packages/caching/src/generic-store.ts create mode 100644 packages/caching/src/index.ts create mode 100644 packages/caching/src/memory-store.ts create mode 100644 packages/caching/tsconfig.build.json create mode 100644 packages/caching/tsconfig.json diff --git a/packages/caching/package.json b/packages/caching/package.json new file mode 100644 index 00000000000..f56b5565f6c --- /dev/null +++ b/packages/caching/package.json @@ -0,0 +1,24 @@ +{ + "name": "@atproto/caching", + "version": "0.0.1", + "license": "MIT", + "description": "Small caching utilities", + "keywords": [ + "caching", + "isomorphic" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/caching" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "lru-cache": "^10.2.0" + } +} diff --git a/packages/caching/src/cached-getter.ts b/packages/caching/src/cached-getter.ts new file mode 100644 index 00000000000..aacad683b7a --- /dev/null +++ b/packages/caching/src/cached-getter.ts @@ -0,0 +1,141 @@ +import { Awaitable, GenericStore, Key, Value } from './generic-store.js' +import { MemoryStore } from './memory-store.js' + +export type GetOptions = { + signal?: AbortSignal + noCache?: boolean + allowStale?: boolean +} + +export type Getter = ( + key: K, + options?: GetOptions, + storedValue?: V, +) => Awaitable + +export type PendingItem = { + promise: Promise + allowCached: boolean + signal?: AbortSignal +} + +export type CachedGetterOptions = { + isStale?: (key: K, value: V) => boolean | PromiseLike + onStoreError?: (err: unknown, key: K, value: V) => void | PromiseLike + deleteOnError?: ( + err: unknown, + key: K, + value: V, + ) => boolean | PromiseLike +} + +/** + * Wrapper utility that uses a cache to speed up the retrieval of values from a + * getter function. + */ +export class CachedGetter { + private pending = new Map>() + + constructor( + readonly getter: Getter, + readonly store: GenericStore = new MemoryStore({ + max: 1000, + ttl: 600e3, + }), + readonly options?: CachedGetterOptions, + ) {} + + async get(key: K, options?: GetOptions): Promise { + const allowCached = options?.noCache !== true + const allowStale = + this.options?.isStale == null ? true : options?.allowStale ?? false + + const checkCached = async (value: V) => + allowCached && + (allowStale || (await this.options?.isStale?.(key, value)) !== true) + + // As long as concurrent requests are made for the same key, only one + // request will be made to the cache & getter function. This works because + // there is no async operation between the while() loop and the + // pending.set() call. Because of the "single threaded" nature of + // JavaScript, the pending item will be set before the next iteration of the + // while loop. + let pending: undefined | PendingItem + while ((pending = this.pending.get(key))) { + options?.signal?.throwIfAborted() + + try { + const value = await pending.promise + + const isFresh = !pending.allowCached + if (isFresh || (await checkCached(value))) { + return value + } + } catch { + // Ignore errors from pending promises. + } + } + + options?.signal?.throwIfAborted() + + try { + const promise = Promise.resolve().then(async () => { + const storedValue = await this.getStored(key, options) + if (storedValue !== undefined) { + if (await checkCached(storedValue)) { + return storedValue + } + } + + return Promise.resolve() + .then(async () => this.getter(key, options, storedValue)) + .catch(async (err) => { + if (storedValue !== undefined && this.options?.deleteOnError) { + if (await this.options.deleteOnError(err, key, storedValue)) { + await this.delStored(key) + } + } + throw err + }) + .then(async (value) => { + await this.setStored(key, value) + return value + }) + }) + + this.pending.set(key, { + promise, + signal: options?.signal, + allowCached, + }) + + return await promise + } finally { + this.pending.delete(key) + } + } + + bind(key: K): (options?: GetOptions) => Promise { + return async (options) => this.get(key, options) + } + + async getStored(key: K, options?: GetOptions): Promise { + try { + return await this.store.get(key, options) + } catch (err) { + return undefined + } + } + + async setStored(key: K, value: V): Promise { + try { + await this.store.set(key, value) + } catch (err) { + await this.options?.onStoreError?.(err, key, value) + } + } + + async delStored(key: K): Promise { + await this.store.del(key) + } +} diff --git a/packages/caching/src/generic-store.ts b/packages/caching/src/generic-store.ts new file mode 100644 index 00000000000..fcffb011ce5 --- /dev/null +++ b/packages/caching/src/generic-store.ts @@ -0,0 +1,18 @@ +export type Awaitable = V | PromiseLike + +export type Key = string | number +export type Value = NonNullable | null + +export type StoreGetOptions = { + signal?: AbortSignal +} + +export interface GenericStore { + /** + * @return undefined if the key is not in the cache. + */ + get: (key: K, options?: StoreGetOptions) => Awaitable + set: (key: K, value: V) => Awaitable + del: (key: K) => Awaitable + clear?: () => Awaitable +} diff --git a/packages/caching/src/index.ts b/packages/caching/src/index.ts new file mode 100644 index 00000000000..4b2c606b807 --- /dev/null +++ b/packages/caching/src/index.ts @@ -0,0 +1,3 @@ +export * from './cached-getter.js' +export * from './generic-store.js' +export * from './memory-store.js' diff --git a/packages/caching/src/memory-store.ts b/packages/caching/src/memory-store.ts new file mode 100644 index 00000000000..78d63728ebd --- /dev/null +++ b/packages/caching/src/memory-store.ts @@ -0,0 +1,176 @@ +import { LRUCache } from 'lru-cache' + +import { GenericStore, Key, Value } from './generic-store.js' + +export type DidCacheMemoryOptions = LRUCache.Options + +export type MemoryStoreOptions = { + max?: number + ttl?: number + ttlAutopurge?: boolean + + /** + * The maximum total size of the cache, in units defined by the sizeCalculation + * function. + * + * @default No limit + */ + maxSize?: number + + /** + * The maximum size of a single cache entry, in units defined by the + * sizeCalculation function. + * + * @default No limit + */ + maxEntrySize?: number + + /** + * A function that returns the size of a value. The size is used to determine + * when the cache should be pruned, based on `maxSize`. + * + * @default The (rough) size in bytes used in memory. + */ + sizeCalculation?: (value: V, key: K) => number +} & ( + | { + max: number + } + | { + ttl: number + ttlAutopurge: boolean + } + | { + maxSize: number + } +) + +// LRUCache does not allow storing "null", so we use a symbol to represent it. + +const nullSymbol = Symbol('nullItem') + +type NormalizedValue = Exclude | typeof nullSymbol + +const denormalize = (value: NormalizedValue) => + (value === nullSymbol ? null : value) as V + +const normalize = (value: V) => + value === null ? nullSymbol : (value as Exclude) + +export class MemoryStore + implements GenericStore +{ + #cache: LRUCache> + + constructor({ sizeCalculation, ...options }: MemoryStoreOptions) { + this.#cache = new LRUCache>({ + ...options, + allowStale: false, + updateAgeOnGet: false, + updateAgeOnHas: false, + sizeCalculation: sizeCalculation + ? (value, key) => sizeCalculation(denormalize(value), key) + : options.maxEntrySize != null || options.maxSize != null + ? // maxEntrySize and maxSize require a size calculation function. + roughSizeOfObject + : undefined, + }) + } + + get(key: K): V | undefined { + const value = this.#cache.get(key) + if (value === undefined) return undefined + + return denormalize(value) + } + + set(key: K, value: V): void { + this.#cache.set(key, normalize(value)) + } + + del(key: K): void { + this.#cache.delete(key) + } + + clear(): void { + this.#cache.clear() + } +} + +const knownSizes = new WeakMap() + +/** + * @see {@link https://stackoverflow.com/a/11900218/356537} + */ +function roughSizeOfObject(value: unknown): number { + const objectList = new Set() + const stack = [value] // This would be more efficient using a circular buffer + let bytes = 0 + + while (stack.length) { + const value = stack.pop() + + // > All objects on the heap start with a shape descriptor, which takes one + // > pointer size (usually 4 bytes these days, thanks to "pointer + // > compression" on 64-bit platforms). + + switch (typeof value) { + // Types are ordered by frequency + case 'string': + // https://stackoverflow.com/a/68791382/356537 + bytes += 12 + 4 * Math.ceil(value.length / 4) + break + case 'number': + bytes += 12 // Shape descriptor + double + break + case 'boolean': + bytes += 4 // Shape descriptor + break + case 'object': + bytes += 4 // Shape descriptor + + if (value === null) { + break + } + + if (knownSizes.has(value)) { + bytes += knownSizes.get(value)! + break + } + + if (objectList.has(value)) continue + objectList.add(value) + + if (Array.isArray(value)) { + bytes += 4 + stack.push(...value) + } else { + bytes += 8 + const keys = Object.getOwnPropertyNames(value) + for (let i = 0; i < keys.length; i++) { + bytes += 4 + const key = keys[i] + const val = value[key] + if (val !== undefined) stack.push(val) + stack.push(key) + } + } + break + case 'function': + bytes += 8 // Shape descriptor + pointer (assuming functions are shared) + break + case 'symbol': + bytes += 8 // Shape descriptor + pointer + break + case 'bigint': + bytes += 16 // Shape descriptor + BigInt + break + } + } + + if (typeof value === 'object' && value !== null) { + knownSizes.set(value, bytes) + } + + return bytes +} diff --git a/packages/caching/tsconfig.build.json b/packages/caching/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/caching/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/caching/tsconfig.json b/packages/caching/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/caching/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/tsconfig.json b/tsconfig.json index 06f89fce301..e04ea30262c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./packages/aws" }, { "path": "./packages/bsky" }, { "path": "./packages/bsync" }, + { "path": "./packages/caching" }, { "path": "./packages/common" }, { "path": "./packages/common-web" }, { "path": "./packages/crypto" }, From 60c086195457b5d9b06ea0b9ecb57225d22f6cb4 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 21 Mar 2024 10:46:41 +0100 Subject: [PATCH 054/140] feat(oauth-provider-client-uri): cache http responses --- .../oauth-provider-client-uri/package.json | 1 + .../src/oauth-client-uri-store.ts | 66 ++++++++++--------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/packages/oauth-provider-client-uri/package.json b/packages/oauth-provider-client-uri/package.json index 311a9237d9d..f5997689af6 100644 --- a/packages/oauth-provider-client-uri/package.json +++ b/packages/oauth-provider-client-uri/package.json @@ -28,6 +28,7 @@ } }, "dependencies": { + "@atproto/caching": "workspace:*", "@atproto/fetch": "workspace:*", "@atproto/jwk": "workspace:*", "@atproto/oauth-provider": "workspace:*", diff --git a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts index 0bed036f331..7a8a4230fc2 100644 --- a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts +++ b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts @@ -1,9 +1,9 @@ +import { CachedGetter, GenericStore, MemoryStore } from '@atproto/caching' import { Fetch, fetchFailureHandler, fetchJsonProcessor, fetchOkProcessor, - fetchZodBodyProcessor, } from '@atproto/fetch' import { Jwks, jwksSchema } from '@atproto/jwk' import { @@ -21,18 +21,6 @@ import { compose } from '@atproto/transformer' import { buildWellknownUrl, isInternetHost, isLoopbackHost } from './util.js' -const metadataTransformer = compose( - fetchOkProcessor(), - fetchJsonProcessor('application/json', false), - fetchZodBodyProcessor(oauthClientMetadataSchema), -) - -const responseToJwksTransformer = compose( - fetchOkProcessor(), - fetchJsonProcessor('application/json', false), - fetchZodBodyProcessor(jwksSchema), -) - export type LoopbackMetadataGetter = ( url: URL, ) => Awaitable> @@ -51,6 +39,12 @@ export type OAuthClientUriStoreConfig = { */ fetch: Fetch + /** + * In order to speed up the client fetching process, you can provide a cache + * to store HTTP responses. + */ + cache?: GenericStore + /** * In order to enable loopback clients, you can provide a function that * returns the client metadata for a given loopback URL. This is useful for @@ -72,19 +66,37 @@ export type OAuthClientUriStoreConfig = { * clients are not pre-registered, we need to fetch their data from the network. */ export class OAuthClientUriStore implements ClientStore { - protected readonly fetch: Fetch + #jsonFetch: CachedGetter + protected readonly loopbackMetadata?: LoopbackMetadataGetter protected readonly validateMetadataCustom?: ClientMetadataValidator constructor({ fetch, + cache = new MemoryStore({ maxSize: 50 * 1024 * 1024 }), loopbackMetadata, validateMetadata, }: OAuthClientUriStoreConfig) { - this.fetch = fetch - this.loopbackMetadata = loopbackMetadata || undefined this.validateMetadataCustom = validateMetadata || undefined + + const jsonFetch = compose( + fetch, + fetchOkProcessor(), + fetchJsonProcessor('application/json', false), + (jsonResponse: { body: unknown }) => jsonResponse.body, + ) + + this.#jsonFetch = new CachedGetter(async (url, options) => { + const headers = new Headers([['accept', 'application/json']]) + if (options?.noCache) headers.set('cache-control', 'no-cache') + const request = new Request(url, { + headers, + signal: options?.signal, + redirect: 'error', + }) + return jsonFetch(request).catch(fetchFailureHandler) + }, cache) } public async findClient(clientId: OAuthClientId): Promise { @@ -178,7 +190,7 @@ export class OAuthClientUriStore implements ClientStore { return { metadata, jwks: metadata.jwks_uri - ? await this.fetchJwks(metadata.jwks_uri) + ? await this.fetchJwks(new URL(metadata.jwks_uri)) : undefined, } } @@ -191,23 +203,15 @@ export class OAuthClientUriStore implements ClientStore { } protected async fetchMetadata( - metadataEndpoint: string | URL, + metadataEndpoint: URL, ): Promise { - const request = new Request(metadataEndpoint, { - redirect: 'error', - headers: { accept: 'application/json' }, - }) - const { fetch } = this - return fetch(request).then(metadataTransformer, fetchFailureHandler) + const json = await this.#jsonFetch.get(metadataEndpoint.href) + return oauthClientMetadataSchema.parse(json) } - protected async fetchJwks(jwksUri: string): Promise { - const request = new Request(jwksUri, { - redirect: 'error', - headers: { accept: 'application/json' }, - }) - const { fetch } = this - return fetch(request).then(responseToJwksTransformer, fetchFailureHandler) + protected async fetchJwks(jwksUri: URL): Promise { + const json = this.#jsonFetch.get(jwksUri.href) + return jwksSchema.parse(json) } /** From 0f6a30c1ab41d570d925345c628f5ca51ab86a9f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 21 Mar 2024 10:51:31 +0100 Subject: [PATCH 055/140] feat(oauth-server-metadata): add OAuth server metadata validation utilities --- packages/oauth-server-metadata/package.json | 29 ++++ packages/oauth-server-metadata/src/index.ts | 1 + .../src/oauth-server-metadata.ts | 126 ++++++++++++++++++ .../oauth-server-metadata/tsconfig.build.json | 8 ++ packages/oauth-server-metadata/tsconfig.json | 4 + tsconfig.json | 1 + 6 files changed, 169 insertions(+) create mode 100644 packages/oauth-server-metadata/package.json create mode 100644 packages/oauth-server-metadata/src/index.ts create mode 100644 packages/oauth-server-metadata/src/oauth-server-metadata.ts create mode 100644 packages/oauth-server-metadata/tsconfig.build.json create mode 100644 packages/oauth-server-metadata/tsconfig.json diff --git a/packages/oauth-server-metadata/package.json b/packages/oauth-server-metadata/package.json new file mode 100644 index 00000000000..aba7679e65d --- /dev/null +++ b/packages/oauth-server-metadata/package.json @@ -0,0 +1,29 @@ +{ + "name": "@atproto/oauth-server-metadata", + "version": "0.0.1", + "license": "MIT", + "description": "OAuth Server Metadata Lib", + "keywords": [ + "atproto", + "oauth", + "metadata", + "server" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-server-metadata" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "zod": "^3.22.4" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/oauth-server-metadata/src/index.ts b/packages/oauth-server-metadata/src/index.ts new file mode 100644 index 00000000000..1c9fb41d3b8 --- /dev/null +++ b/packages/oauth-server-metadata/src/index.ts @@ -0,0 +1 @@ +export * from './oauth-server-metadata.js' diff --git a/packages/oauth-server-metadata/src/oauth-server-metadata.ts b/packages/oauth-server-metadata/src/oauth-server-metadata.ts new file mode 100644 index 00000000000..4e6d2ce0607 --- /dev/null +++ b/packages/oauth-server-metadata/src/oauth-server-metadata.ts @@ -0,0 +1,126 @@ +import { z } from 'zod' + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} + */ +export const oauthServerMetadataSchema = z.object({ + issuer: z.string().superRefine((value, ctx) => { + try { + const url = new URL(value) + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Issuer URL must use "https" or "http"', + }) + return false + } + + if (value !== `${url.origin}/`) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Issuer URL must not contain a path, username, password, query, or fragment', + }) + return false + } + + return true + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Issuer must be a valid URL', + }) + + return false + } + }), + + claims_supported: z.array(z.string()).optional(), + claims_locales_supported: z.array(z.string()).optional(), + claims_parameter_supported: z.boolean().optional(), + request_parameter_supported: z.boolean().optional(), + request_uri_parameter_supported: z.boolean().optional(), + require_request_uri_registration: z.boolean().optional(), + scopes_supported: z.array(z.string()).optional(), + subject_types_supported: z.array(z.string()).optional(), + response_types_supported: z.array(z.string()).optional(), + response_modes_supported: z.array(z.string()).optional(), + grant_types_supported: z.array(z.string()).optional(), + code_challenge_methods_supported: z.array(z.string()).min(1).optional(), + ui_locales_supported: z.array(z.string()).optional(), + id_token_signing_alg_values_supported: z.array(z.string()).optional(), + display_values_supported: z.array(z.string()).optional(), + request_object_signing_alg_values_supported: z.array(z.string()).optional(), + authorization_response_iss_parameter_supported: z.boolean().optional(), + authorization_details_types_supported: z.array(z.string()).optional(), + request_object_encryption_alg_values_supported: z + .array(z.string()) + .optional(), + request_object_encryption_enc_values_supported: z + .array(z.string()) + .optional(), + + jwks_uri: z.string().url().optional(), + + authorization_endpoint: z.string().url(), // .optional(), + + token_endpoint: z.string().url(), // .optional(), + token_endpoint_auth_methods_supported: z.array(z.string()).optional(), + token_endpoint_auth_signing_alg_values_supported: z + .array(z.string()) + .optional(), + + revocation_endpoint: z.string().url().optional(), + revocation_endpoint_auth_methods_supported: z.array(z.string()).optional(), + revocation_endpoint_auth_signing_alg_values_supported: z + .array(z.string()) + .optional(), + + introspection_endpoint: z.string().url().optional(), + introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), + introspection_endpoint_auth_signing_alg_values_supported: z + .array(z.string()) + .optional(), + + pushed_authorization_request_endpoint: z.string().url().optional(), + pushed_authorization_request_endpoint_auth_methods_supported: z + .array(z.string()) + .optional(), + pushed_authorization_request_endpoint_auth_signing_alg_values_supported: z + .array(z.string()) + .optional(), + + require_pushed_authorization_requests: z.boolean().optional(), + + userinfo_endpoint: z.string().url().optional(), + end_session_endpoint: z.string().url().optional(), + registration_endpoint: z.string().url().optional(), + + // https://datatracker.ietf.org/doc/html/rfc9449#section-5.1 + dpop_signing_alg_values_supported: z.array(z.string()).optional(), +}) + +export type OAuthServerMetadata = z.infer + +export const oauthServerMetadataValidator = oauthServerMetadataSchema + .refinement( + (data) => + !data.require_pushed_authorization_requests || + data.pushed_authorization_request_endpoint != null, + { + code: z.ZodIssueCode.custom, + message: + '"pushed_authorization_request_endpoint" required when "require_pushed_authorization_requests" is true', + }, + ) + .refinement( + (data) => { + // Validate the issuer (MIX-UP attacks) + return new URL(data.issuer).pathname === '/' + }, + { + code: z.ZodIssueCode.custom, + message: 'Invalid issuer', + }, + ) diff --git a/packages/oauth-server-metadata/tsconfig.build.json b/packages/oauth-server-metadata/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/oauth-server-metadata/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/oauth-server-metadata/tsconfig.json b/packages/oauth-server-metadata/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/oauth-server-metadata/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/tsconfig.json b/tsconfig.json index e04ea30262c..8ac334e3f3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,7 @@ { "path": "./packages/oauth-provider-client-fqdn" }, { "path": "./packages/oauth-provider-replay-memory" }, { "path": "./packages/oauth-provider-replay-redis" }, + { "path": "./packages/oauth-server-metadata" }, { "path": "./packages/ozone" }, { "path": "./packages/pds" }, { "path": "./packages/repo" }, From 3f54b6dc20b8414bc29f9eccc8ecf3a8ae03020d Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 21 Mar 2024 11:07:32 +0100 Subject: [PATCH 056/140] refactor(oauth-provider): use shared oauth-server-metadata lib --- packages/oauth-provider/package.json | 1 + .../src/metadata/build-metadata.ts | 28 +++++++++++-------- packages/oauth-provider/src/oauth-provider.ts | 18 ++++++------ 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index ba74ed4bf9a..3f969f6dda0 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -35,6 +35,7 @@ "@atproto/jwk": "workspace:*", "@atproto/jwk-node": "workspace:*", "@atproto/oauth-client-metadata": "workspace:*", + "@atproto/oauth-server-metadata": "workspace:*", "cookie": "^0.6.0", "jose": "^5.2.0", "keygrip": "^1.1.0", diff --git a/packages/oauth-provider/src/metadata/build-metadata.ts b/packages/oauth-provider/src/metadata/build-metadata.ts index 8218d650da7..3c38dce21fc 100644 --- a/packages/oauth-provider/src/metadata/build-metadata.ts +++ b/packages/oauth-provider/src/metadata/build-metadata.ts @@ -1,4 +1,5 @@ import { Keyset } from '@atproto/jwk' +import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' import { Client } from '../client/client.js' import { OIDC_STANDARD_CLAIMS } from '../oidc/claims.js' @@ -10,9 +11,6 @@ export type CustomMetadata = { authorization_details_types_supported?: string[] } -// TODO: Load from shared package (with client class) -export type Metadata = ReturnType - /** * @see {@link https://datatracker.ietf.org/doc/html/rfc8414#section-2} * @see {@link https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata} @@ -21,7 +19,7 @@ export function buildMetadata( issuer: string, keyset: Keyset, customMetadata?: CustomMetadata, -) { +): OAuthServerMetadata { return { issuer: issuer, @@ -116,31 +114,37 @@ export function buildMetadata( request_uri_parameter_supported: true, require_request_uri_registration: true, - jwks_uri: new URL('/oauth/jwks', issuer), + jwks_uri: new URL('/oauth/jwks', issuer).href, - authorization_endpoint: new URL('/oauth/authorize', issuer), + authorization_endpoint: new URL('/oauth/authorize', issuer).href, - token_endpoint: new URL('/oauth/token', issuer), + token_endpoint: new URL('/oauth/token', issuer).href, token_endpoint_auth_methods_supported: [...Client.AUTH_METHODS_SUPPORTED], token_endpoint_auth_signing_alg_values_supported: [...VERIFY_ALGOS], - revocation_endpoint: new URL('/oauth/revoke', issuer), + revocation_endpoint: new URL('/oauth/revoke', issuer).href, revocation_endpoint_auth_methods_supported: [ ...Client.AUTH_METHODS_SUPPORTED, ], revocation_endpoint_auth_signing_alg_values_supported: [...VERIFY_ALGOS], - introspection_endpoint: new URL('/oauth/introspect', issuer), + introspection_endpoint: new URL('/oauth/introspect', issuer).href, introspection_endpoint_auth_methods_supported: [ ...Client.AUTH_METHODS_SUPPORTED, ], introspection_endpoint_auth_signing_alg_values_supported: [...VERIFY_ALGOS], - userinfo_endpoint: new URL('/oauth/userinfo', issuer), - // end_session_endpoint: new URL('/oauth/logout', issuer), + userinfo_endpoint: new URL('/oauth/userinfo', issuer).href, + // end_session_endpoint: new URL('/oauth/logout', issuer).href, // https://datatracker.ietf.org/doc/html/rfc9126#section-5 - pushed_authorization_request_endpoint: new URL('/oauth/par', issuer), + pushed_authorization_request_endpoint: new URL('/oauth/par', issuer).href, + pushed_authorization_request_endpoint_auth_methods_supported: [ + ...Client.AUTH_METHODS_SUPPORTED, + ], + pushed_authorization_request_endpoint_auth_signing_alg_values_supported: [ + ...VERIFY_ALGOS, + ], require_pushed_authorization_requests: true, diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index d14773c1597..64a466be0de 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -20,7 +20,7 @@ import { OAuthClientId, oauthClientIdSchema, } from '@atproto/oauth-client-metadata' - +import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' import { JWTHeaderParameters, ResolvedKey } from 'jose' import { z } from 'zod' @@ -54,11 +54,7 @@ import { InvalidRequestError } from './errors/invalid-request-error.js' import { LoginRequiredError } from './errors/login-required-error.js' import { UnauthorizedClientError } from './errors/unauthorized-client-error.js' import { WWWAuthenticateError } from './errors/www-authenticate-error.js' -import { - CustomMetadata, - Metadata, - buildMetadata, -} from './metadata/build-metadata.js' +import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js' import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js' import { Userinfo } from './oidc/userinfo.js' import { @@ -129,7 +125,13 @@ export type OAuthProviderStore = Partial< ReplayStore > -export { Keyset, type CustomMetadata, type Customization, type Handler } +export { + Keyset, + type CustomMetadata, + type Customization, + type Handler, + type OAuthServerMetadata, +} export type OAuthProviderOptions = OAuthVerifierOptions & { /** * Maximum age a device/account session can be before requiring @@ -172,7 +174,7 @@ export type OAuthProviderOptions = OAuthVerifierOptions & { } export class OAuthProvider extends OAuthVerifier { - public readonly metadata: Metadata + public readonly metadata: OAuthServerMetadata public readonly defaultMaxAge: number From 23c6e387ff11a3f44ffbc6f79a379782c34d166f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 21 Mar 2024 10:57:00 +0100 Subject: [PATCH 057/140] feat(oauth-server-metadata-resolver): utility class that allows fetching OAuth server metadata --- .../oauth-server-metadata-resolver/README.md | 1 + .../package.json | 32 +++++ .../src/index.ts | 4 + ...omorphic-oauth-server-metadata-resolver.ts | 116 ++++++++++++++++++ .../src/oauth-server-metadata-resolver.ts | 12 ++ .../tsconfig.build.json | 8 ++ .../tsconfig.json | 4 + tsconfig.json | 1 + 8 files changed, 178 insertions(+) create mode 100644 packages/oauth-server-metadata-resolver/README.md create mode 100644 packages/oauth-server-metadata-resolver/package.json create mode 100644 packages/oauth-server-metadata-resolver/src/index.ts create mode 100644 packages/oauth-server-metadata-resolver/src/isomorphic-oauth-server-metadata-resolver.ts create mode 100644 packages/oauth-server-metadata-resolver/src/oauth-server-metadata-resolver.ts create mode 100644 packages/oauth-server-metadata-resolver/tsconfig.build.json create mode 100644 packages/oauth-server-metadata-resolver/tsconfig.json diff --git a/packages/oauth-server-metadata-resolver/README.md b/packages/oauth-server-metadata-resolver/README.md new file mode 100644 index 00000000000..6fb706cc5c5 --- /dev/null +++ b/packages/oauth-server-metadata-resolver/README.md @@ -0,0 +1 @@ +# @atproto/oauth-client: atproto flavoured OAuth client diff --git a/packages/oauth-server-metadata-resolver/package.json b/packages/oauth-server-metadata-resolver/package.json new file mode 100644 index 00000000000..b6d5dcdd04c --- /dev/null +++ b/packages/oauth-server-metadata-resolver/package.json @@ -0,0 +1,32 @@ +{ + "name": "@atproto/oauth-server-metadata-resolver", + "version": "0.0.1", + "license": "MIT", + "description": "OAuth Server Metadata Resolver", + "keywords": [ + "atproto", + "oauth", + "metadata", + "server" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-server-metadata-resolver" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/caching": "workspace:*", + "@atproto/fetch": "workspace:*", + "@atproto/oauth-server-metadata": "workspace:*", + "zod": "^3.22.4" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/oauth-server-metadata-resolver/src/index.ts b/packages/oauth-server-metadata-resolver/src/index.ts new file mode 100644 index 00000000000..f13672435ea --- /dev/null +++ b/packages/oauth-server-metadata-resolver/src/index.ts @@ -0,0 +1,4 @@ +export * from './isomorphic-oauth-server-metadata-resolver.js' +export * from './oauth-server-metadata-resolver.js' + +export { IsomorphicOAuthServerMetadataResolver as default } from './isomorphic-oauth-server-metadata-resolver.js' diff --git a/packages/oauth-server-metadata-resolver/src/isomorphic-oauth-server-metadata-resolver.ts b/packages/oauth-server-metadata-resolver/src/isomorphic-oauth-server-metadata-resolver.ts new file mode 100644 index 00000000000..c6453b278c3 --- /dev/null +++ b/packages/oauth-server-metadata-resolver/src/isomorphic-oauth-server-metadata-resolver.ts @@ -0,0 +1,116 @@ +import { CachedGetter, GenericStore } from '@atproto/caching' +import { Fetch, FetchError } from '@atproto/fetch' +import { + OAuthServerMetadata, + oauthServerMetadataValidator, +} from '@atproto/oauth-server-metadata' +import { + OAuthServerMetadataResolver, + ResolveOptions, +} from './oauth-server-metadata-resolver.js' + +export type OAuthServerMetadataCache = GenericStore + +export type IsomorphicOAuthServerMetadataResolverOptions = { + fetch?: Fetch + cache?: OAuthServerMetadataCache +} + +export class IsomorphicOAuthServerMetadataResolver + implements OAuthServerMetadataResolver +{ + readonly #fetch: Fetch + readonly #getter: CachedGetter + + constructor({ + fetch = globalThis.fetch, + cache, + }: IsomorphicOAuthServerMetadataResolverOptions = {}) { + this.#fetch = fetch + this.#getter = new CachedGetter( + async (origin, options) => + this.fetchServerMetadata(origin, 'oauth-authorization-server', options), + cache, + ) + } + + async resolve(origin: string): Promise { + return this.#getter.get(origin) + } + + async fetchServerMetadata( + origin: string, + suffix: 'openid-configuration' | 'oauth-authorization-server', + options?: ResolveOptions, + ): Promise { + const originUrl = new URL(origin) + if (originUrl.origin !== origin) { + throw new TypeError( + `OAuth server origin must not contain a path, query, or fragment.`, + ) + } + + if (originUrl.protocol !== 'https:' && originUrl.protocol !== 'http:') { + throw new TypeError(`Issuer origin must use "https" or "http"`) + } + + const oauthServerMetadataEndpoint = new URL( + `/.well-known/${suffix}`, + originUrl, + ) + + const headers = new Headers([['accept', 'application/json']]) + if (options?.noCache) headers.set('cache-control', 'no-cache') + + const request = new Request(oauthServerMetadataEndpoint, { + signal: options?.signal, + headers, + // This is a particularity of the Atproto OAuth implementation. PDS's will + // use a redirect to their AS's metadata endpoint. This might not be spec + // compliant but we *do* check that the issuer is valid w.r.t the origin + // of the last redirect url (see below). + redirect: 'follow', + }) + + const response = await this.#fetch.call(globalThis, request) + + if (!response.ok) { + // Fallback to openid-configuration endpoint + if (suffix !== 'openid-configuration') { + return this.fetchServerMetadata(origin, 'openid-configuration', options) + } + + throw new FetchError( + response.status, + `Unable to fetch OAuth server metadata for "${origin}"`, + { request, response }, + ) + } + + const metadata = oauthServerMetadataValidator.parse(await response.json()) + + // Validate the issuer (MIX-UP attacks) + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-mix-up-attacks + const issuerUrl = new URL(metadata.issuer) + if (issuerUrl.pathname !== '/') { + throw new Error(`Invalid issuer ${metadata.issuer}`) + } + const responseUrl = new URL( + response.redirected ? response.url : request.url, + ) + if (issuerUrl.origin !== responseUrl.origin) { + throw new FetchError(502, `Invalid issuer ${metadata.issuer}`, { + request, + response, + }) + } + if (responseUrl.pathname !== `/.well-known/${suffix}`) { + throw new FetchError(502, `Invalid metadata endpoint ${response.url}`, { + request, + response, + }) + } + + return metadata + } +} diff --git a/packages/oauth-server-metadata-resolver/src/oauth-server-metadata-resolver.ts b/packages/oauth-server-metadata-resolver/src/oauth-server-metadata-resolver.ts new file mode 100644 index 00000000000..f328fe9b419 --- /dev/null +++ b/packages/oauth-server-metadata-resolver/src/oauth-server-metadata-resolver.ts @@ -0,0 +1,12 @@ +import { GetOptions } from '@atproto/caching' +import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' + +export type ResolveOptions = GetOptions +export type { OAuthServerMetadata } + +export interface OAuthServerMetadataResolver { + resolve( + origin: string, + options?: ResolveOptions, + ): Promise +} diff --git a/packages/oauth-server-metadata-resolver/tsconfig.build.json b/packages/oauth-server-metadata-resolver/tsconfig.build.json new file mode 100644 index 00000000000..fafdab3d6f7 --- /dev/null +++ b/packages/oauth-server-metadata-resolver/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/oauth-server-metadata-resolver/tsconfig.json b/packages/oauth-server-metadata-resolver/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/oauth-server-metadata-resolver/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/tsconfig.json b/tsconfig.json index 8ac334e3f3f..be405159722 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,7 @@ { "path": "./packages/oauth-provider-replay-memory" }, { "path": "./packages/oauth-provider-replay-redis" }, { "path": "./packages/oauth-server-metadata" }, + { "path": "./packages/oauth-server-metadata-resolver" }, { "path": "./packages/ozone" }, { "path": "./packages/pds" }, { "path": "./packages/repo" }, From b2bde5ddfea42193c3028372dc589ad47481e886 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 22 Mar 2024 08:08:45 +0100 Subject: [PATCH 058/140] feat(jwk): allow searching keys using multiple kids --- packages/jwk/src/keyset.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/jwk/src/keyset.ts b/packages/jwk/src/keyset.ts index 4e9611c5504..fe81c77764b 100644 --- a/packages/jwk/src/keyset.ts +++ b/packages/jwk/src/keyset.ts @@ -31,7 +31,7 @@ export type JwtVerifyResult

= { export type KeySearch = { use?: 'sig' | 'enc' - kid?: string + kid?: string | string[] alg?: string | string[] } @@ -109,9 +109,19 @@ export class Keyset implements Iterable { } *list(search: KeySearch): Generator { + // Optimization: Empty string or empty array will not match any key + if (search.kid?.length === 0) return + if (search.alg?.length === 0) return + for (const key of this) { - if (search.kid && key.kid !== search.kid) continue if (search.use && key.use !== search.use) continue + + if (Array.isArray(search.kid)) { + if (!search.kid.includes(key.kid)) continue + } else if (search.kid) { + if (key.kid !== search.kid) continue + } + if (Array.isArray(search.alg)) { if (!search.alg.some((a) => key.algorithms.includes(a))) continue } else if (typeof search.alg === 'string') { From b5fdcd69971a2aa1fad58d7ff2154d2bbb55d684 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 22 Mar 2024 08:13:46 +0100 Subject: [PATCH 059/140] fix(oauth-provider): read pushed_authorization_request_endpoint_auth_method on PAR endpoint --- packages/oauth-provider/src/client/client.ts | 6 +++++- packages/oauth-provider/src/oauth-provider.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/oauth-provider/src/client/client.ts b/packages/oauth-provider/src/client/client.ts index f7d6c6c3a9c..2ddac15bcef 100644 --- a/packages/oauth-provider/src/client/client.ts +++ b/packages/oauth-provider/src/client/client.ts @@ -27,7 +27,11 @@ import { ClientIdentification, } from './client-credentials.js' -type AuthEndpoint = 'token' | 'introspection' | 'revocation' +export type AuthEndpoint = + | 'token' + | 'introspection' + | 'revocation' + | 'pushed_authorization_request' export class Client { /** diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 64a466be0de..cbdbfc8a2bb 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -43,7 +43,7 @@ import { } from './client/client-credentials.js' import { ClientDataHook, ClientManager } from './client/client-manager.js' import { ClientStore, asClientStore } from './client/client-store.js' -import { Client } from './client/client.js' +import { AuthEndpoint, Client } from './client/client.js' import { AUTH_MAX_AGE, TOKEN_MAX_AGE } from './constants.js' import { DeviceId } from './device/device-id.js' import { AccessDeniedError } from './errors/access-denied-error.js' @@ -259,7 +259,7 @@ export class OAuthProvider extends OAuthVerifier { protected async authenticateClient( client: Client, - endpoint: 'token' | 'introspection' | 'revocation', + endpoint: AuthEndpoint, credentials: ClientIdentification, ): Promise { const { clientAuth, nonce } = await client.verifyCredentials( @@ -342,7 +342,11 @@ export class OAuthProvider extends OAuthVerifier { dpopJkt: null | string, ) { const client = await this.clientManager.getClient(input.client_id) - const clientAuth = await this.authenticateClient(client, 'token', input) + const clientAuth = await this.authenticateClient( + client, + 'pushed_authorization_request', + input, + ) // TODO (?) should we allow using signed JAR for client authentication? const { payload: parameters } = From e53c45bdba7616325bf29518659aa546a80a4bf5 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sat, 23 Mar 2024 05:14:46 -0700 Subject: [PATCH 060/140] feat(fetch)!: expose response object in json processor result --- packages/fetch/src/fetch-response.ts | 41 ++++++------------- .../src/oauth-client-uri-store.ts | 2 +- .../oauth-provider/src/assets/app/lib/api.ts | 8 ++-- 3 files changed, 18 insertions(+), 33 deletions(-) diff --git a/packages/fetch/src/fetch-response.ts b/packages/fetch/src/fetch-response.ts index d974ffe2df4..93239fd266d 100644 --- a/packages/fetch/src/fetch-response.ts +++ b/packages/fetch/src/fetch-response.ts @@ -144,54 +144,39 @@ export function fetchTypeProcessor( } } -type ParsedJsonResponse = { - status: number - headers: Headers - body: Body +export type ParsedJsonResponse = { + response: Response + json: T } -export async function jsonTranformer( +export async function jsonTranformer( response: Response, -): Promise> { +): Promise> { return response .json() - .then((body) => ({ - status: response.status, - headers: response.headers, - body: body as Body, + .then((json) => ({ + response, + json: json as T, })) .catch((err) => { throw new FetchError(502, err, { response }) }) } -export function fetchJsonProcessor( +export function fetchJsonProcessor( contentType: ContentTypeCheck = /^application\/(?:[^+]+\+)?json$/, contentTypeRequired = true, -): Transformer> { +): Transformer> { return compose( fetchTypeProcessor(contentType, contentTypeRequired), - jsonTranformer, + jsonTranformer, ) } -export function fetchZodProcessor( - schema: S, - params?: Partial, -): Transformer> { - return async ({ - body, - ...rest - }: ParsedJsonResponse): Promise>> => ({ - body: schema.parseAsync(body, params), - ...rest, - }) -} - -export function fetchZodBodyProcessor( +export function fetchJsonZodProcessor( schema: S, params?: Partial, ): Transformer> { return async (jsonResponse: ParsedJsonResponse): Promise> => - schema.parseAsync(jsonResponse.body, params) + schema.parseAsync(jsonResponse.json, params) } diff --git a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts index 7a8a4230fc2..449ece7b325 100644 --- a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts +++ b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts @@ -84,7 +84,7 @@ export class OAuthClientUriStore implements ClientStore { fetch, fetchOkProcessor(), fetchJsonProcessor('application/json', false), - (jsonResponse: { body: unknown }) => jsonResponse.body, + (r: { json: unknown }) => r.json, ) this.#jsonFetch = new CachedGetter(async (url, options) => { diff --git a/packages/oauth-provider/src/assets/app/lib/api.ts b/packages/oauth-provider/src/assets/app/lib/api.ts index d21f526c879..7b72475b855 100644 --- a/packages/oauth-provider/src/assets/app/lib/api.ts +++ b/packages/oauth-provider/src/assets/app/lib/api.ts @@ -16,7 +16,7 @@ export class Api { ) {} async signIn(credentials: SignInFormOutput): Promise { - const { body } = await fetch('/oauth/authorize/sign-in', { + const { json } = await fetch('/oauth/authorize/sign-in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, mode: 'same-origin', @@ -31,13 +31,13 @@ export class Api { .then(fetchJsonProcessor<{ account: Account; info: Info }>()) return { - account: body.account, - info: body.info, + account: json.account, + info: json.info, selected: true, consentRequired: this.newSessionsRequireConsent || - !body.info.authorizedClients.includes(this.clientId), + !json.info.authorizedClients.includes(this.clientId), loginRequired: false, } } From 63460961c17d1d312f482d0e83e31e86f33095f9 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 21 Mar 2024 10:47:40 +0100 Subject: [PATCH 061/140] feat(did): create isomorphic DID utility lib --- packages/did-node/package.json | 27 +++ packages/did-node/src/index.ts | 1 + packages/did-node/src/node-did-resolver.ts | 23 +++ packages/did-node/tsconfig.build.json | 8 + packages/did-node/tsconfig.json | 4 + packages/did/package.json | 30 +++ packages/did/src/did-cache-memory.ts | 23 +++ packages/did/src/did-cache.ts | 6 + packages/did/src/did-document.ts | 127 ++++++++++++ packages/did/src/did-error.ts | 103 ++++++++++ packages/did/src/did-method.ts | 18 ++ packages/did/src/did-resolver-base.ts | 113 +++++++++++ packages/did/src/did-resolver.ts | 34 ++++ packages/did/src/did.ts | 202 ++++++++++++++++++++ packages/did/src/index.ts | 10 + packages/did/src/isomorphic-did-resolver.ts | 23 +++ packages/did/src/methods.ts | 2 + packages/did/src/methods/plc.ts | 81 ++++++++ packages/did/src/methods/web.ts | 91 +++++++++ packages/did/src/util.ts | 67 +++++++ packages/did/tsconfig.build.json | 8 + packages/did/tsconfig.json | 4 + tsconfig.json | 2 + 23 files changed, 1007 insertions(+) create mode 100644 packages/did-node/package.json create mode 100644 packages/did-node/src/index.ts create mode 100644 packages/did-node/src/node-did-resolver.ts create mode 100644 packages/did-node/tsconfig.build.json create mode 100644 packages/did-node/tsconfig.json create mode 100644 packages/did/package.json create mode 100644 packages/did/src/did-cache-memory.ts create mode 100644 packages/did/src/did-cache.ts create mode 100644 packages/did/src/did-document.ts create mode 100644 packages/did/src/did-error.ts create mode 100644 packages/did/src/did-method.ts create mode 100644 packages/did/src/did-resolver-base.ts create mode 100644 packages/did/src/did-resolver.ts create mode 100644 packages/did/src/did.ts create mode 100644 packages/did/src/index.ts create mode 100644 packages/did/src/isomorphic-did-resolver.ts create mode 100644 packages/did/src/methods.ts create mode 100644 packages/did/src/methods/plc.ts create mode 100644 packages/did/src/methods/web.ts create mode 100644 packages/did/src/util.ts create mode 100644 packages/did/tsconfig.build.json create mode 100644 packages/did/tsconfig.json diff --git a/packages/did-node/package.json b/packages/did-node/package.json new file mode 100644 index 00000000000..7de884fdd65 --- /dev/null +++ b/packages/did-node/package.json @@ -0,0 +1,27 @@ +{ + "name": "@atproto/did-node", + "version": "0.0.1", + "license": "MIT", + "description": "Backend safe version of the @atproto/did DID library for node.js", + "keywords": [ + "atproto", + "oauth", + "did", + "node" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/did-node" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/did": "workspace:*", + "@atproto/fetch-node": "workspace:*" + } +} diff --git a/packages/did-node/src/index.ts b/packages/did-node/src/index.ts new file mode 100644 index 00000000000..fd8f31275ca --- /dev/null +++ b/packages/did-node/src/index.ts @@ -0,0 +1 @@ +export * from './node-did-resolver.js' diff --git a/packages/did-node/src/node-did-resolver.ts b/packages/did-node/src/node-did-resolver.ts new file mode 100644 index 00000000000..366faa64340 --- /dev/null +++ b/packages/did-node/src/node-did-resolver.ts @@ -0,0 +1,23 @@ +import { + IsomorphicDidResolver, + IsomorphicDidResolverOptions, +} from '@atproto/did' +import { safeFetchWrap } from '@atproto/fetch-node' + +export type NodeDidResolverOptions = IsomorphicDidResolverOptions & { + dangerouslyDisableSafeFetch?: boolean +} + +export class NodeDidResolver extends IsomorphicDidResolver { + constructor({ + dangerouslyDisableSafeFetch = false, + fetch, + ...options + }: NodeDidResolverOptions = {}) { + super({ + fetch: + dangerouslyDisableSafeFetch === true ? fetch : safeFetchWrap({ fetch }), + ...options, + }) + } +} diff --git a/packages/did-node/tsconfig.build.json b/packages/did-node/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/did-node/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/did-node/tsconfig.json b/packages/did-node/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/did-node/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/did/package.json b/packages/did/package.json new file mode 100644 index 00000000000..f62ea459040 --- /dev/null +++ b/packages/did/package.json @@ -0,0 +1,30 @@ +{ + "name": "@atproto/did", + "version": "0.0.1", + "license": "MIT", + "description": "DID resolution and verification library", + "keywords": [ + "atproto", + "oauth", + "did", + "isomorphic" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/did" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/caching": "workspace:*", + "@atproto/fetch": "workspace:*", + "@atproto/jwk": "workspace:*", + "@atproto/transformer": "workspace:*", + "zod": "^3.22.4" + } +} diff --git a/packages/did/src/did-cache-memory.ts b/packages/did/src/did-cache-memory.ts new file mode 100644 index 00000000000..ea7b9ee0a98 --- /dev/null +++ b/packages/did/src/did-cache-memory.ts @@ -0,0 +1,23 @@ +import { MemoryStore, MemoryStoreOptions } from '@atproto/caching' + +import { DidCache } from './did-cache.js' +import { DidDocument } from './did-document.js' +import { Did } from './did.js' + +export type DidCacheMemoryOptions = MemoryStoreOptions + +export const DEFAULT_TTL = 3600 * 1000 // 1 hour +export const DEFAULT_MAX_SIZE = 50 * 1024 * 1024 // ~50MB + +export class DidCacheMemory + extends MemoryStore + implements DidCache +{ + constructor(options?: DidCacheMemoryOptions) { + super( + options?.max == null + ? { ttl: DEFAULT_TTL, maxSize: DEFAULT_MAX_SIZE, ...options } + : { ttl: DEFAULT_TTL, ...options }, + ) + } +} diff --git a/packages/did/src/did-cache.ts b/packages/did/src/did-cache.ts new file mode 100644 index 00000000000..ce4b5ed0ff7 --- /dev/null +++ b/packages/did/src/did-cache.ts @@ -0,0 +1,6 @@ +import { GenericStore } from '@atproto/caching' + +import { DidDocument } from './did-document.js' +import { Did } from './did.js' + +export type DidCache = GenericStore diff --git a/packages/did/src/did-document.ts b/packages/did/src/did-document.ts new file mode 100644 index 00000000000..43f65956172 --- /dev/null +++ b/packages/did/src/did-document.ts @@ -0,0 +1,127 @@ +import { jwkSchema } from '@atproto/jwk' +import { z } from 'zod' + +import { Did, didSchema } from './did.js' + +/** + * RFC3968 compliant URI + * + * @see {@link https://www.rfc-editor.org/rfc/rfc3986} + */ +export const rfc3968UriSchema = z.string().refine((data) => { + try { + new URL(data) + return true + } catch { + return false + } +}) + +export const didControllerSchema = z.union([didSchema, z.array(didSchema)]) + +/** + * @note this schema might be too permissive + */ +export const didRelativeUriSchema = z.union([ + rfc3968UriSchema, + z.string().regex(/^#[^#]+$/), +]) + +export const didVerificationMethodSchema = z.object({ + id: didRelativeUriSchema, + type: z.string().min(1), + controller: didControllerSchema, + publicKeyJwk: jwkSchema.optional(), + publicKeyMultibase: z.string().optional(), +}) + +/** + * The value of the id property MUST be a URI conforming to [RFC3986]. A + * conforming producer MUST NOT produce multiple service entries with the same + * id. A conforming consumer MUST produce an error if it detects multiple + * service entries with the same id. + * + * @note Normally, only rfc3968UriSchema should be allowed here. However, the + * did:plc uses relative URI. For this reason, we also allow relative URIs + * here. + */ +export const didServiceIdSchema = didRelativeUriSchema + +/** + * The value of the type property MUST be a string or a set of strings. In order + * to maximize interoperability, the service type and its associated properties + * SHOULD be registered in the DID Specification Registries + * [DID-SPEC-REGISTRIES]. + */ +export const didServiceTypeSchema = z.union([z.string(), z.array(z.string())]) + +/** + * The value of the serviceEndpoint property MUST be a string, a map, or a set + * composed of one or more strings and/or maps. All string values MUST be valid + * URIs conforming to [RFC3986] and normalized according to the Normalization + * and Comparison rules in RFC3986 and to any normalization rules in its + * applicable URI scheme specification. + */ +export const didServiceEndpointSchema = z.union([ + rfc3968UriSchema, + z.record(z.string(), rfc3968UriSchema), + z.array(z.union([rfc3968UriSchema, z.record(z.string(), rfc3968UriSchema)])), +]) + +/** + * Each service map MUST contain id, type, and serviceEndpoint properties. + * @see {@link https://www.w3.org/TR/did-core/#services} + */ +export const didServiceSchema = z.object({ + id: didServiceIdSchema, + type: didServiceTypeSchema, + serviceEndpoint: didServiceEndpointSchema, +}) + +export type DidService = z.infer + +export const didAuthenticationSchema = z.union([ + // + didRelativeUriSchema, + didVerificationMethodSchema, +]) + +/** + * @note This schema is incomplete + * @see {@link https://www.w3.org/TR/did-core/#production-0} + */ +export const didDocumentSchema = z.object({ + '@context': z.union([ + z.literal('https://www.w3.org/ns/did/v1'), + z + .array(z.string().url()) + .nonempty() + .refine((data) => data[0] === 'https://www.w3.org/ns/did/v1'), + ]), + id: didSchema, + controller: didControllerSchema.optional(), + alsoKnownAs: z.array(rfc3968UriSchema).optional(), + service: z.array(didServiceSchema).optional(), + authentication: z.array(didAuthenticationSchema).optional(), + verificationMethod: z + .array(z.union([didVerificationMethodSchema, didRelativeUriSchema])) + .optional(), +}) + +export type DidDocument = z.infer< + typeof didDocumentSchema +> & { id: Did } + +export const didDocumentValidator = didDocumentSchema.refinement( + (data) => + !data.service?.some((s, i, a) => { + for (let j = i + 1; j < a.length; j++) { + if (s.id === a[j]!.id) return true + } + return false + }), + { + code: z.ZodIssueCode.custom, + message: 'Duplicate service id', + }, +) diff --git a/packages/did/src/did-error.ts b/packages/did/src/did-error.ts new file mode 100644 index 00000000000..cffdfa08e3a --- /dev/null +++ b/packages/did/src/did-error.ts @@ -0,0 +1,103 @@ +import { Did } from './did.js' + +export class DidError extends Error { + constructor( + public readonly did: string, + message: string, + public readonly code: string, + public readonly status = 400, + cause?: unknown, + ) { + super(message, { cause }) + } + + /** + * For compatibility with common error handlers + */ + get statusCode() { + return this.status + } + + override toString() { + return `${this.constructor.name} ${this.code} (${this.did}): ${this.message}` + } + + static from(cause: unknown, did: string): DidError { + if (cause instanceof DidError) { + return cause + } + + const message = + cause instanceof Error + ? cause.message + : typeof cause === 'string' + ? cause + : 'An unknown error occurred' + + return new DidError(did, message, 'did-unknown-error', 500, cause) + } +} + +export class InvalidDidError extends DidError { + constructor( + did: string, + message: string, + code = 'did-invalid', + status = 400, + cause?: unknown, + ) { + super(did, message, code, status, cause) + } +} + +export class DidResolutionError extends DidError { + constructor( + did: Did, + message: string, + code = 'did-resolution-error', + status = 400, + cause?: unknown, + ) { + super(did, message, code, status, cause) + } + + static fromHttpError(err: Error, did: Did, status?: number) { + status ??= + (typeof (err as any)?.statusCode === 'number' + ? (err as any).statusCode + : undefined) ?? + (typeof (err as any)?.status === 'number' + ? (err as any).status + : undefined) + + return new DidResolutionError( + did, + err.message, + 'did-fetch-error', + status, + err, + ) + } +} + +export class DidDocumentFormatError extends DidError { + constructor( + did: Did, + message: string, + code = 'did-document-format-error', + status = 503, + cause?: unknown, + ) { + super(did, message, code, status, cause) + } + + static fromValidationError(err: Error, did: Did) { + return new DidDocumentFormatError( + did, + err.message, + 'did-document-validation-error', + undefined, + err, + ) + } +} diff --git a/packages/did/src/did-method.ts b/packages/did/src/did-method.ts new file mode 100644 index 00000000000..4ab14ff844c --- /dev/null +++ b/packages/did/src/did-method.ts @@ -0,0 +1,18 @@ +import { DidDocument } from './did-document.js' +import { Did } from './did.js' + +export type ResolveOptions = { + signal?: AbortSignal + noCache?: boolean +} + +export interface DidMethod { + resolve: ( + did: Did, + options?: ResolveOptions, + ) => DidDocument | PromiseLike +} + +export type DidMethods = { + [K in M]: DidMethod +} diff --git a/packages/did/src/did-resolver-base.ts b/packages/did/src/did-resolver-base.ts new file mode 100644 index 00000000000..fa118b78390 --- /dev/null +++ b/packages/did/src/did-resolver-base.ts @@ -0,0 +1,113 @@ +import { FetchError } from '@atproto/fetch' +import { ZodError } from 'zod' + +import { DidDocument, DidService } from './did-document.js' +import { + DidDocumentFormatError, + DidError, + DidResolutionError, +} from './did-error.js' +import { DidMethod, DidMethods, ResolveOptions } from './did-method.js' +import { Did, extractDidMethod } from './did.js' + +export type { DidMethod, ResolveOptions } +export type ResolvedDocument = + D extends Did ? DidDocument : never + +export class DidResolverBase { + protected readonly methods: Map> + + constructor(methods: DidMethods) { + this.methods = new Map(Object.entries(methods)) + } + + async resolve( + did: D, + options?: ResolveOptions, + ): Promise> { + const method = extractDidMethod(did) + const resolver = this.methods.get(method) + if (!resolver) { + throw new DidResolutionError( + did, + `Unsupported DID method`, + 'did-method-invalid', + 400, + ) + } + + try { + const document = await resolver.resolve(did as Did, options) + if (document.id !== did) { + throw new DidDocumentFormatError( + did, + `DID document id (${document.id}) does not match DID`, + ) + } + + return document as ResolvedDocument + } catch (err) { + if (err instanceof FetchError) { + throw DidResolutionError.fromHttpError(err, did) + } + if (err instanceof ZodError) { + throw DidDocumentFormatError.fromValidationError(err, did) + } + throw DidError.from(err, did) + } + } + + async resolveService( + did: Did, + filter: { id?: string; type?: string }, + options?: ResolveOptions, + ): Promise { + if (!filter.id && !filter.type) { + throw new DidResolutionError( + did, + 'Either "filter.id" or "filter.type" must be specified', + 'did-service-filter-invalid', + ) + } + const document = await this.resolve(did, options) + const service = document.service?.find( + (s) => + (!filter.id || s.id === filter.id) && + (!filter.type || s.type === filter.type), + ) + if (service) return service + + throw new DidDocumentFormatError( + did, + `No service matching "${filter.id || filter.type}" in DID Document`, + 'did-service-not-found', + 404, + ) + } + + async resolveServiceEndpoint( + did: Did, + filter: { id?: string; type?: string }, + options?: ResolveOptions, + ): Promise { + const service = await this.resolveService(did, filter, options) + if (typeof service.serviceEndpoint === 'string') { + return new URL(service.serviceEndpoint) + } else if (Array.isArray(service.serviceEndpoint)) { + // set of strings or maps + if (service.serviceEndpoint.length === 1) { + const first = service.serviceEndpoint[0]! + if (typeof first === 'string') return new URL(first) + } + } else { + // map + } + + throw new DidDocumentFormatError( + did, + `Unable to determine serviceEndpoint for "${filter.id || filter.type}"`, + 'did-service-endpoint-not-found', + 404, + ) + } +} diff --git a/packages/did/src/did-resolver.ts b/packages/did/src/did-resolver.ts new file mode 100644 index 00000000000..86b7854c7a5 --- /dev/null +++ b/packages/did/src/did-resolver.ts @@ -0,0 +1,34 @@ +import { CachedGetter } from '@atproto/caching' +import { DidCacheMemory } from './did-cache-memory.js' +import { DidCache } from './did-cache.js' +import { DidDocument } from './did-document.js' +import { DidMethod, DidMethods, ResolveOptions } from './did-method.js' +import { DidResolverBase, ResolvedDocument } from './did-resolver-base.js' +import { Did } from './did.js' + +export type { DidMethod, ResolveOptions, ResolvedDocument } + +export type DidResolverOptions = { + cache?: DidCache +} + +export class DidResolver extends DidResolverBase { + readonly #getter: CachedGetter + + constructor(methods: DidMethods, options?: DidResolverOptions) { + super(methods) + + this.#getter = new CachedGetter( + (did, options) => super.resolve(did, options), + options?.cache ?? new DidCacheMemory(), + ) + } + + async resolve( + did: D, + options?: ResolveOptions, + ): Promise> + async resolve(did: Did, options?: ResolveOptions): Promise { + return this.#getter.get(did, options) + } +} diff --git a/packages/did/src/did.ts b/packages/did/src/did.ts new file mode 100644 index 00000000000..a9b49bc9666 --- /dev/null +++ b/packages/did/src/did.ts @@ -0,0 +1,202 @@ +import { z } from 'zod' +import { DigitChar, LowerAlphaChar, asRefinement } from './util.js' +import { DidError, InvalidDidError } from './did-error.js' + +const DID_PREFIX = 'did:' +const DID_PREFIX_LENGTH = DID_PREFIX.length +export { DID_PREFIX } + +/** + * Type representation of a Did, with method. + * + * ```bnf + * did = "did:" method-name ":" method-specific-id + * method-name = 1*method-char + * method-char = %x61-7A / DIGIT + * method-specific-id = *( *idchar ":" ) 1*idchar + * idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded + * pct-encoded = "%" HEXDIG HEXDIG + * ``` + * + * @example + * ```ts + * type DidWeb = Did<'web'> // `did:web:${string}` + * type DidCustom = Did<'web' | 'plc'> // `did:${'web' | 'plc'}:${string}` + * type DidNever = Did<' invalid 🥴 '> // never + * type DidFoo = Did<'foo' | ' invalid 🥴 '> // `did:foo:${string}` + * ``` + * + * @see {@link https://www.w3.org/TR/did-core/#did-syntax} + */ +export type Did = `did:${AsDidMethod}:${string}` + +/** + * DID Method + */ + +export type AsDidMethod = string extends M + ? string // can't know... + : AsDidMethodInternal + +type AsDidMethodInternal< + S, + Acc extends string, +> = S extends `${infer H}${infer T}` + ? H extends DigitChar | LowerAlphaChar + ? AsDidMethodInternal + : never + : Acc extends '' + ? never + : Acc + +/** + * DID Method-name check function. + * + * Check if the input is a valid DID method name, at the position between + * `start` (inclusive) and `end` (exclusive). + */ +export function checkDidMethod( + input: string, + start = 0, + end = input.length, +): void { + if (!(end >= start)) { + throw new TypeError('end < start') + } + if (end === start) { + throw new InvalidDidError(input, `Empty method name`) + } + + let c: number + for (let i = start; i < end; i++) { + c = input.charCodeAt(i) + if ( + (c < 0x61 || c > 0x7a) && // a-z + (c < 0x30 || c > 0x39) // 0-9 + ) { + throw new InvalidDidError(input, `Invalid character at position ${i}`) + } + } +} + +/** + * This method assumes the input is a valid Did + */ +export function extractDidMethod(did: D) { + const msidSep = did.indexOf(':', DID_PREFIX_LENGTH) + const method = did.slice(DID_PREFIX_LENGTH, msidSep) + return method as D extends Did ? M : string +} + +/** + * DID Method-specific identifier check function. + * + * Check if the input is a valid DID method-specific identifier, at the position + * between `start` (inclusive) and `end` (exclusive). + */ +export function checkDidMsid( + input: string, + start = 0, + end = input.length, +): void { + if (!(end >= start)) { + throw new TypeError('end < start') + } + if (end === start) { + throw new InvalidDidError(input, `Empty method-specific id`) + } + + let c: number + for (let i = start; i < end; i++) { + c = input.charCodeAt(i) + + // Check for frequent chars first + if ( + (c < 0x61 || c > 0x7a) && // a-z + (c < 0x41 || c > 0x5a) && // A-Z + (c < 0x30 || c > 0x39) && // 0-9 + c !== 0x2e && // . + c !== 0x2d && // - + c !== 0x5f // _ + ) { + // Less frequent chars are checked here + + // ":" + if (c === 0x3a) { + if (i === end - 1) { + throw new InvalidDidError(input, `DID cannot end with ":"`) + } + continue + } + + // pct-encoded + if (c === 0x25) { + c = input.charCodeAt(++i) + if ((c < 0x30 || c > 0x39) && (c < 0x41 || c > 0x46)) { + throw new InvalidDidError( + input, + `Invalid pct-encoded character at position ${i}`, + ) + } + + c = input.charCodeAt(++i) + if ((c < 0x30 || c > 0x39) && (c < 0x41 || c > 0x46)) { + throw new InvalidDidError( + input, + `Invalid pct-encoded character at position ${i}`, + ) + } + + // There must always be 2 HEXDIG after a "%" + if (i >= end) { + throw new InvalidDidError( + input, + `Incomplete pct-encoded character at position ${i - 2}`, + ) + } + + continue + } + + throw new InvalidDidError( + input, + `Disallowed characters in DID at position ${i}`, + ) + } + } +} + +export function checkDid(input: string): asserts input is Did { + const { length } = input + if (length > 2048) { + throw new InvalidDidError(input, `DID is too long (2048 chars max)`) + } + + if (!input.startsWith(DID_PREFIX)) { + throw new InvalidDidError(input, `DID requires "${DID_PREFIX}" prefix`) + } + + const idSep = input.indexOf(':', DID_PREFIX_LENGTH) + if (idSep === -1) { + throw new InvalidDidError(input, `Missing colon after method name`) + } + + checkDidMethod(input, DID_PREFIX_LENGTH, idSep) + checkDidMsid(input, idSep + 1, length) +} + +export function isDid(input: string): input is Did { + try { + checkDid(input) + return true + } catch (err) { + if (err instanceof DidError) { + return false + } + throw err + } +} + +export const didSchema = z + .string() + .superRefine(asRefinement(checkDid)) diff --git a/packages/did/src/index.ts b/packages/did/src/index.ts new file mode 100644 index 00000000000..d38ca5bfe29 --- /dev/null +++ b/packages/did/src/index.ts @@ -0,0 +1,10 @@ +export * from './isomorphic-did-resolver.js' +export * from './did-cache-memory.js' +export * from './did-cache.js' +export * from './did-document.js' +export * from './did-error.js' +export * from './did-method.js' +export * from './did-resolver.js' +export * from './did.js' +export * from './methods.js' +export * from './util.js' diff --git a/packages/did/src/isomorphic-did-resolver.ts b/packages/did/src/isomorphic-did-resolver.ts new file mode 100644 index 00000000000..e969d4a01b7 --- /dev/null +++ b/packages/did/src/isomorphic-did-resolver.ts @@ -0,0 +1,23 @@ +import { DidCache } from './did-cache.js' +import { DidResolver } from './did-resolver.js' + +import { DidPlcMethod, DidPlcMethodOptions } from './methods/plc.js' +import { DidWebMethod, DidWebMethodOptions } from './methods/web.js' +import { Simplify } from './util.js' + +export type { DidCache } +export type IsomorphicDidResolverOptions = Simplify< + { cache?: DidCache } & DidPlcMethodOptions & DidWebMethodOptions +> + +export class IsomorphicDidResolver extends DidResolver<'plc' | 'web'> { + constructor({ cache, ...options }: IsomorphicDidResolverOptions = {}) { + super( + { + plc: new DidPlcMethod(options), + web: new DidWebMethod(options), + }, + { cache }, + ) + } +} diff --git a/packages/did/src/methods.ts b/packages/did/src/methods.ts new file mode 100644 index 00000000000..9fb8254af2d --- /dev/null +++ b/packages/did/src/methods.ts @@ -0,0 +1,2 @@ +export * from './methods/plc.js' +export * from './methods/web.js' diff --git a/packages/did/src/methods/plc.ts b/packages/did/src/methods/plc.ts new file mode 100644 index 00000000000..1533be24315 --- /dev/null +++ b/packages/did/src/methods/plc.ts @@ -0,0 +1,81 @@ +import { + Fetch, + fetchFailureHandler, + fetchJsonProcessor, + fetchJsonZodProcessor, + fetchOkProcessor, +} from '@atproto/fetch' +import { compose } from '@atproto/transformer' + +import { didDocumentValidator } from '../did-document.js' +import { InvalidDidError } from '../did-error.js' +import { DidMethod, ResolveOptions } from '../did-method.js' +import { Did } from '../did.js' + +const DID_PLC_PREFIX = `did:plc:` +const DID_PLC_PREFIX_LENGTH = DID_PLC_PREFIX.length +const DID_PLC_LENGTH = 32 + +export { DID_PLC_PREFIX } + +export function checkDidPlc(input: string) { + if (input.length !== DID_PLC_LENGTH) { + throw new InvalidDidError( + input, + `did:plc must be ${DID_PLC_LENGTH} characters long`, + ) + } + + if (!input.startsWith(DID_PLC_PREFIX)) { + throw new InvalidDidError(input, `Invalid did:plc prefix`) + } + + let c: number + for (let i = DID_PLC_PREFIX_LENGTH; i < DID_PLC_LENGTH; i++) { + c = input.charCodeAt(i) + // Base32 encoding ([a-z2-7]) + if ((c < 0x61 || c > 0x7a) && (c < 0x32 || c > 0x37)) { + throw new InvalidDidError(input, `Invalid character at position ${i}`) + } + } +} + +const didWebDocumentTransformer = compose( + fetchOkProcessor(), + fetchJsonProcessor(/^application\/(did\+ld\+)?json$/), + fetchJsonZodProcessor(didDocumentValidator), +) + +export type DidPlcMethodOptions = { + plcDirectoryUrl?: string | URL + fetch?: Fetch +} + +export class DidPlcMethod implements DidMethod<'plc'> { + readonly plcDirectoryUrl: URL + protected readonly fetch: Fetch + + constructor({ + plcDirectoryUrl = 'https://plc.directory/', + fetch = globalThis.fetch, + }: DidPlcMethodOptions = {}) { + this.plcDirectoryUrl = new URL(plcDirectoryUrl) + this.fetch = fetch + } + + async resolve(did: Did<'plc'>, options?: ResolveOptions) { + checkDidPlc(did) + + const url = new URL(`/${did}`, this.plcDirectoryUrl) + + const request = new Request(url, { + redirect: 'error', + headers: { accept: 'application/did+ld+json,application/json' }, + signal: options?.signal, + }) + + return this.fetch + .call(globalThis, request) + .then(didWebDocumentTransformer, fetchFailureHandler) + } +} diff --git a/packages/did/src/methods/web.ts b/packages/did/src/methods/web.ts new file mode 100644 index 00000000000..34b9a7e1ec7 --- /dev/null +++ b/packages/did/src/methods/web.ts @@ -0,0 +1,91 @@ +import { + Fetch, + fetchFailureHandler, + fetchJsonProcessor, + fetchJsonZodProcessor, + fetchOkProcessor, +} from '@atproto/fetch' +import { compose } from '@atproto/transformer' + +import { didDocumentValidator } from '../did-document.js' +import { InvalidDidError } from '../did-error.js' +import { DidMethod, ResolveOptions } from '../did-method.js' +import { Did, checkDidMsid } from '../did.js' +import { wellKnownUrl } from '../util.js' + +export const DID_WEB_PREFIX = `did:web:` + +export function didWebToUrl(did: string): URL { + if (!did.startsWith(DID_WEB_PREFIX)) { + throw new InvalidDidError(did, `did:web must start with ${DID_WEB_PREFIX}`) + } + + if (did.charAt(DID_WEB_PREFIX.length) === ':') { + throw new InvalidDidError(did, 'did:web MSID must not start with a colon') + } + + // Make sure every char is valid + checkDidMsid(did, DID_WEB_PREFIX.length) + + try { + const msid = did.slice(DID_WEB_PREFIX.length) + const parts = msid.split(':').map(decodeURIComponent) + return new URL(`https://${parts.join('/')}`) + } catch (cause) { + throw new InvalidDidError( + did, + 'Invalid Web DID', + undefined, + undefined, + cause, + ) + } +} + +export function urlToDidWeb(url: URL): Did<'web'> { + const path = + url.pathname === '/' + ? '' + : url.pathname.slice(1).split('/').map(encodeURIComponent).join(':') + + return `did:web:${encodeURIComponent(url.host)}${path ? `:${path}` : ''}` +} + +export function didWebDocumentUrl(did: Did<'web'>): URL { + const url = didWebToUrl(did) + return wellKnownUrl(url, 'did.json') +} + +const didWebDocumentTransformer = compose( + fetchOkProcessor(), + fetchJsonProcessor(/^application\/(did\+ld\+)?json$/), + fetchJsonZodProcessor(didDocumentValidator), +) + +export type DidWebMethodOptions = { + fetch?: Fetch +} + +export class DidWebMethod implements DidMethod<'web'> { + protected readonly fetch: Fetch + + constructor({ fetch = globalThis.fetch }: DidWebMethodOptions = {}) { + this.fetch = fetch + } + + async resolve(did: Did<'web'>, options?: ResolveOptions) { + didWebToUrl(did) + + const url = didWebDocumentUrl(did) + + const request = new Request(url, { + redirect: 'error', + headers: { accept: 'application/did+ld+json,application/json' }, + signal: options?.signal, + }) + + return this.fetch + .call(globalThis, request) + .then(didWebDocumentTransformer, fetchFailureHandler) + } +} diff --git a/packages/did/src/util.ts b/packages/did/src/util.ts new file mode 100644 index 00000000000..645bbcd8a6a --- /dev/null +++ b/packages/did/src/util.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' + +export type Simplify = { [K in keyof T]: T[K] } & NonNullable + +export function wellKnownUrl(base: URL, path: string) { + const lastSlash = base.pathname.lastIndexOf('/') + + const prefix = + lastSlash <= 0 ? `/.well-known/` : base.pathname.slice(0, lastSlash) + + return new URL(`${prefix}/${path}`, base) +} + +export type DigitChar = + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + +export type LowerAlphaChar = + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'k' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z' + +export function asRefinement(check: (value: T) => void) { + return (value: T, ctx: z.RefinementCtx): value is T & R => { + try { + check(value) + return true + } catch (err) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: err instanceof Error ? err.message : 'Unexpected error', + }) + return false + } + } +} diff --git a/packages/did/tsconfig.build.json b/packages/did/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/did/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/did/tsconfig.json b/packages/did/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/did/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/tsconfig.json b/tsconfig.json index be405159722..35f9c58ac4f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,8 @@ { "path": "./packages/common-web" }, { "path": "./packages/crypto" }, { "path": "./packages/dev-env" }, + { "path": "./packages/did" }, + { "path": "./packages/did-node" }, { "path": "./packages/fetch" }, { "path": "./packages/fetch-node" }, { "path": "./packages/html" }, From bf2c755e222981fcea905be1df0f5bc223e3a995 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 21 Mar 2024 10:48:57 +0100 Subject: [PATCH 062/140] feat(handle-resolver): isomorphic and node specific handle resolution utilities --- packages/handle-resolver-node/package.json | 33 +++++++ .../src/dns-handle-resolver.ts | 76 +++++++++++++++ packages/handle-resolver-node/src/index.ts | 6 ++ .../src/node-handle-resolver.ts | 96 +++++++++++++++++++ .../handle-resolver-node/tsconfig.build.json | 8 ++ packages/handle-resolver-node/tsconfig.json | 4 + packages/handle-resolver/package.json | 34 +++++++ .../src/atproto-lexicon-handle-resolver.ts | 88 +++++++++++++++++ .../src/cached-handle-resolver.ts | 40 ++++++++ .../handle-resolver/src/handle-resolver.ts | 27 ++++++ packages/handle-resolver/src/index.ts | 11 +++ .../src/serial-handle-resolver.ts | 29 ++++++ .../src/universal-handle-resolver.ts | 58 +++++++++++ .../src/well-known-handler-resolver.ts | 62 ++++++++++++ packages/handle-resolver/tsconfig.build.json | 8 ++ packages/handle-resolver/tsconfig.json | 4 + tsconfig.json | 2 + 17 files changed, 586 insertions(+) create mode 100644 packages/handle-resolver-node/package.json create mode 100644 packages/handle-resolver-node/src/dns-handle-resolver.ts create mode 100644 packages/handle-resolver-node/src/index.ts create mode 100644 packages/handle-resolver-node/src/node-handle-resolver.ts create mode 100644 packages/handle-resolver-node/tsconfig.build.json create mode 100644 packages/handle-resolver-node/tsconfig.json create mode 100644 packages/handle-resolver/package.json create mode 100644 packages/handle-resolver/src/atproto-lexicon-handle-resolver.ts create mode 100644 packages/handle-resolver/src/cached-handle-resolver.ts create mode 100644 packages/handle-resolver/src/handle-resolver.ts create mode 100644 packages/handle-resolver/src/index.ts create mode 100644 packages/handle-resolver/src/serial-handle-resolver.ts create mode 100644 packages/handle-resolver/src/universal-handle-resolver.ts create mode 100644 packages/handle-resolver/src/well-known-handler-resolver.ts create mode 100644 packages/handle-resolver/tsconfig.build.json create mode 100644 packages/handle-resolver/tsconfig.json diff --git a/packages/handle-resolver-node/package.json b/packages/handle-resolver-node/package.json new file mode 100644 index 00000000000..b11480285b9 --- /dev/null +++ b/packages/handle-resolver-node/package.json @@ -0,0 +1,33 @@ +{ + "name": "@atproto/handle-resolver-node", + "version": "0.0.1", + "license": "MIT", + "description": "Node specific ATProto handle resolver", + "keywords": [ + "atproto", + "oauth", + "handle", + "identity", + "node" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/handle-resolver-node" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/did": "workspace:*", + "@atproto/handle-resolver": "workspace:*", + "@atproto/fetch": "workspace:*", + "@atproto/fetch-node": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/handle-resolver-node/src/dns-handle-resolver.ts b/packages/handle-resolver-node/src/dns-handle-resolver.ts new file mode 100644 index 00000000000..e1e01fb90eb --- /dev/null +++ b/packages/handle-resolver-node/src/dns-handle-resolver.ts @@ -0,0 +1,76 @@ +import { Resolver, lookup, resolveTxt } from 'node:dns/promises' + +import { + HandleResolveOptions, + HandleResolver, + ResolvedHandle, + isResolvedHandle, +} from '@atproto/handle-resolver' + +const SUBDOMAIN = '_atproto' +const PREFIX = 'did=' + +export type DnsHandleResolverOptions = { + /** + * Nameservers to use in place of the system's default nameservers. + */ + nameservers?: string[] +} + +export class DnsHandleResolver implements HandleResolver { + protected resolveTxt: typeof resolveTxt + + constructor(options?: DnsHandleResolverOptions) { + this.resolveTxt = options?.nameservers + ? buildResolveTxt(options.nameservers) + : resolveTxt.bind(null) + } + + public async resolve( + handle: string, + _options?: HandleResolveOptions, + ): Promise { + try { + return parseDnsResult(await this.resolveTxt(`${SUBDOMAIN}.${handle}`)) + } catch (err) { + return null + } + } +} + +function buildResolveTxt(nameservers: string[]): typeof resolveTxt { + // Build the resolver asynchronously + const resolverPromise: Promise = Promise.allSettled( + nameservers.map((h) => lookup(h)), + ) + .then((responses) => + responses.flatMap((r) => + r.status === 'fulfilled' ? [r.value.address] : [], + ), + ) + .then((backupIps) => { + if (!backupIps.length) return null + const resolver = new Resolver() + resolver.setServers(backupIps) + return resolver + }) + + resolverPromise.catch(() => { + // Should never happen + }) + + return async (hostname) => { + const resolver = await resolverPromise + return resolver ? resolver.resolveTxt(hostname) : [] + } +} + +function parseDnsResult(chunkedResults: string[][]): ResolvedHandle { + const results = chunkedResults.map((chunks) => chunks.join('')) + const found = results.filter((i) => i.startsWith(PREFIX)) + if (found.length !== 1) { + return null + } + const did = found[0].slice(PREFIX.length) + return isResolvedHandle(did) ? did : null +} diff --git a/packages/handle-resolver-node/src/index.ts b/packages/handle-resolver-node/src/index.ts new file mode 100644 index 00000000000..2403167966a --- /dev/null +++ b/packages/handle-resolver-node/src/index.ts @@ -0,0 +1,6 @@ +// Main export +export * from './node-handle-resolver.js' +export { NodeHandleResolver as default } from './node-handle-resolver.js' + +// Utilities +export * from './dns-handle-resolver.js' diff --git a/packages/handle-resolver-node/src/node-handle-resolver.ts b/packages/handle-resolver-node/src/node-handle-resolver.ts new file mode 100644 index 00000000000..992a7adfcd7 --- /dev/null +++ b/packages/handle-resolver-node/src/node-handle-resolver.ts @@ -0,0 +1,96 @@ +import { Fetch } from '@atproto/fetch' +import { SafeFetchWrapOptions, safeFetchWrap } from '@atproto/fetch-node' +import { + CachedHandleResolver, + CachedHandleResolverOptions, + HandleResolver, + WellKnownHandleResolver, +} from '@atproto/handle-resolver' + +import { + DnsHandleResolver, + DnsHandleResolverOptions, +} from './dns-handle-resolver.js' + +export type NodeHandleResolverOptions = { + /** + * List of domain names that are forbidden to be resolved using the + * well-known/atproto-did method. + */ + wellKnownExclude?: SafeFetchWrapOptions['forbiddenDomainNames'] + + /** + * List of backup nameservers to use for DNS resolution. + */ + fallbackNameservers?: DnsHandleResolverOptions['nameservers'] + + cache?: CachedHandleResolverOptions['cache'] + + /** + * Fetch function to use for HTTP requests. Allows customizing the request + * behavior, e.g. adding headers, setting a timeout, mocking, etc. The + * provided fetch function will be wrapped with a safeFetchWrap function that + * adds SSRF protection. + * + * @default `globalThis.fetch` + */ + fetch?: Fetch +} + +export class NodeHandleResolver + extends CachedHandleResolver + implements HandleResolver +{ + constructor({ + cache, + fetch = globalThis.fetch, + fallbackNameservers, + wellKnownExclude, + }: NodeHandleResolverOptions = {}) { + const safeFetch = safeFetchWrap({ + fetch, + timeout: 3000, // 3 seconds + forbiddenDomainNames: wellKnownExclude, + ssrfProtection: true, + responseMaxSize: 10 * 1048, // DID are max 2048 characters, 10kb for safety + }) + + const httpResolver = new WellKnownHandleResolver({ + fetch: safeFetch, + }) + + const dnsResolver = new DnsHandleResolver() + + const fallbackResolver = new DnsHandleResolver({ + nameservers: fallbackNameservers, + }) + + super({ + cache, + resolver: { + resolve: async (handle) => { + const abortController = new AbortController() + + const dnsPromise = dnsResolver.resolve(handle) + const httpPromise = httpResolver.resolve(handle, { + signal: abortController.signal, + }) + + // Will be awaited later + httpPromise.catch(() => {}) + + const dnsRes = await dnsPromise + if (dnsRes) { + abortController.abort() + return dnsRes + } + + const res = await httpPromise + if (res) return res + + return fallbackResolver.resolve(handle) + }, + }, + }) + } +} diff --git a/packages/handle-resolver-node/tsconfig.build.json b/packages/handle-resolver-node/tsconfig.build.json new file mode 100644 index 00000000000..fafdab3d6f7 --- /dev/null +++ b/packages/handle-resolver-node/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/handle-resolver-node/tsconfig.json b/packages/handle-resolver-node/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/handle-resolver-node/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/handle-resolver/package.json b/packages/handle-resolver/package.json new file mode 100644 index 00000000000..8e1789a01ff --- /dev/null +++ b/packages/handle-resolver/package.json @@ -0,0 +1,34 @@ +{ + "name": "@atproto/handle-resolver", + "version": "0.0.1", + "license": "MIT", + "description": "Isomorphic ATProto handle resolver", + "keywords": [ + "atproto", + "oauth", + "handle", + "identity", + "isomorphic" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/handle-resolver" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/caching": "workspace:*", + "@atproto/did": "workspace:*", + "@atproto/fetch": "workspace:*", + "lru-cache": "^10.2.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/handle-resolver/src/atproto-lexicon-handle-resolver.ts b/packages/handle-resolver/src/atproto-lexicon-handle-resolver.ts new file mode 100644 index 00000000000..2200fddbc4e --- /dev/null +++ b/packages/handle-resolver/src/atproto-lexicon-handle-resolver.ts @@ -0,0 +1,88 @@ +import { Fetch } from '@atproto/fetch' +import z from 'zod' + +import { + HandleResolveOptions, + HandleResolver, + ResolvedHandle, + isResolvedHandle, +} from './handle-resolver.js' + +export const xrpcErrorSchema = z.object({ + error: z.string(), + message: z.string().optional(), +}) + +export type AtprotoLexiconHandleResolverOptions = { + /** + * Fetch function to use for HTTP requests. Allows customizing the request + * behavior, e.g. adding headers, setting a timeout, mocking, etc. + * + * @default globalThis.fetch + */ + fetch?: Fetch + + /** + * URL of the atproto lexicon server. This is the base URL where the + * `com.atproto.identity.resolveHandle` XRPC method is located. + * + * @default 'https://bsky.social' + */ + url?: URL | string +} + +export class AtprotoLexiconHandleResolver implements HandleResolver { + protected readonly url: URL + protected readonly fetch: Fetch + + constructor({ + url = 'https://bsky.social/', + fetch = globalThis.fetch, + }: AtprotoLexiconHandleResolverOptions = {}) { + this.url = new URL(url) + this.fetch = fetch + } + + public async resolve( + handle: string, + options?: HandleResolveOptions, + ): Promise { + const url = new URL('/xrpc/com.atproto.identity.resolveHandle', this.url) + url.searchParams.set('handle', handle) + + const headers = new Headers() + if (options?.noCache) headers.set('cache-control', 'no-cache') + + const request = new Request(url, { headers, signal: options?.signal }) + + const response = await this.fetch.call(null, request) + const payload = await response.json() + + // The response should either be + // - 400 Bad Request with { error: 'InvalidRequest', message: 'Unable to resolve handle' } + // - 200 OK with { did: NonNullable } + // Any other response is considered unexpected behavior an should throw an error. + + if (response.status === 400) { + const data = xrpcErrorSchema.parse(payload) + if ( + data.error === 'InvalidRequest' && + data.message === 'Unable to resolve handle' + ) { + return null + } + } + + if (!response.ok) { + throw new Error('Invalid response from resolveHandle method') + } + + const value: unknown = payload?.did + + if (!value || !isResolvedHandle(value)) { + throw new Error('Invalid DID returned from resolveHandle method') + } + + return value + } +} diff --git a/packages/handle-resolver/src/cached-handle-resolver.ts b/packages/handle-resolver/src/cached-handle-resolver.ts new file mode 100644 index 00000000000..ed788fa0625 --- /dev/null +++ b/packages/handle-resolver/src/cached-handle-resolver.ts @@ -0,0 +1,40 @@ +import { CachedGetter, GenericStore, MemoryStore } from '@atproto/caching' +import { + HandleResolveOptions, + HandleResolver, + ResolvedHandle, +} from './handle-resolver.js' + +export type CachedHandleResolverOptions = { + /** + * The resolver that will be used to resolve handles. + */ + resolver: HandleResolver + + /** + * A store that will be used to cache resolved values. + */ + cache?: GenericStore +} + +export class CachedHandleResolver + extends CachedGetter + implements HandleResolver +{ + constructor({ + resolver, + cache = new MemoryStore({ + max: 1000, + ttl: 10 * 60e3, + }), + }: CachedHandleResolverOptions) { + super((handle, options) => resolver.resolve(handle, options), cache) + } + + async resolve( + handle: string, + options?: HandleResolveOptions, + ): Promise { + return this.get(handle, options) + } +} diff --git a/packages/handle-resolver/src/handle-resolver.ts b/packages/handle-resolver/src/handle-resolver.ts new file mode 100644 index 00000000000..dfa05c9c117 --- /dev/null +++ b/packages/handle-resolver/src/handle-resolver.ts @@ -0,0 +1,27 @@ +import { Did, isDid } from '@atproto/did' + +export type HandleResolveOptions = { + signal?: AbortSignal + noCache?: boolean +} +export type ResolvedHandle = null | Did + +export function isResolvedHandle( + value: T, +): value is T & ResolvedHandle { + return value === null || (typeof value === 'string' && isDid(value)) +} + +export interface HandleResolver { + /** + * @returns the DID that corresponds to the given handle, or `null` if no DID + * is found. `null` should only be returned if no unexpected behavior occurred + * during the resolution process. + * @throws Error if the resolution method fails due to an unexpected error, or + * if the resolution is aborted ({@link HandleResolveOptions.signal}). + */ + resolve( + handle: string, + options?: HandleResolveOptions, + ): Promise +} diff --git a/packages/handle-resolver/src/index.ts b/packages/handle-resolver/src/index.ts new file mode 100644 index 00000000000..ffd297b5063 --- /dev/null +++ b/packages/handle-resolver/src/index.ts @@ -0,0 +1,11 @@ +export * from './handle-resolver.js' + +// Main export +export * from './universal-handle-resolver.js' +export { UniversalHandleResolver as default } from './universal-handle-resolver.js' + +// Utilities +export * from './cached-handle-resolver.js' +export * from './atproto-lexicon-handle-resolver.js' +export * from './serial-handle-resolver.js' +export * from './well-known-handler-resolver.js' diff --git a/packages/handle-resolver/src/serial-handle-resolver.ts b/packages/handle-resolver/src/serial-handle-resolver.ts new file mode 100644 index 00000000000..2a2bf6a5077 --- /dev/null +++ b/packages/handle-resolver/src/serial-handle-resolver.ts @@ -0,0 +1,29 @@ +import { + HandleResolveOptions, + HandleResolver, + ResolvedHandle, +} from './handle-resolver.js' + +export class SerialHandleResolver implements HandleResolver { + constructor(protected readonly resolvers: readonly HandleResolver[]) { + if (!resolvers.length) { + throw new TypeError('At least one resolver is required') + } + } + + public async resolve( + handle: string, + options?: HandleResolveOptions, + ): Promise { + for (const resolver of this.resolvers) { + options?.signal?.throwIfAborted() + + const value = await resolver.resolve(handle, options) + if (value) return value + } + + // If no resolver was able to resolve the handle, assume there is no DID + // corresponding to the handle. + return null + } +} diff --git a/packages/handle-resolver/src/universal-handle-resolver.ts b/packages/handle-resolver/src/universal-handle-resolver.ts new file mode 100644 index 00000000000..325c6a00cba --- /dev/null +++ b/packages/handle-resolver/src/universal-handle-resolver.ts @@ -0,0 +1,58 @@ +import { GenericStore } from '@atproto/caching' +import { Fetch } from '@atproto/fetch' + +import { CachedHandleResolver } from './cached-handle-resolver.js' +import { HandleResolver, ResolvedHandle } from './handle-resolver.js' +import { + AtprotoLexiconHandleResolver, + AtprotoLexiconHandleResolverOptions, +} from './atproto-lexicon-handle-resolver.js' +import { SerialHandleResolver } from './serial-handle-resolver.js' +import { WellKnownHandleResolver } from './well-known-handler-resolver.js' + +export type HandleResolverCache = GenericStore + +export type UniversalHandleResolverOptions = { + cache?: HandleResolverCache + + /** + * Fetch function to use for HTTP requests. Allows customizing the request + * behavior, e.g. adding headers, setting a timeout, mocking, etc. + * + * When using this library from a Node.js environment, you may want to use + * `safeFetchWrap()` from `@atproto/fetch-node` to add SSRF protection. + * + * @default `globalThis.fetch` + */ + fetch?: Fetch + + /** + * @see {@link AtprotoLexiconHandleResolverOptions.url} + */ + atprotoLexiconUrl?: AtprotoLexiconHandleResolverOptions['url'] +} + +/** + * A handle resolver that works in any environment that supports `fetch()`. This + * relies on the a public XRPC implementing "com.atproto.identity.resolveHandle" + * to resolve handles. + */ +export class UniversalHandleResolver + extends CachedHandleResolver + implements HandleResolver +{ + constructor({ + fetch = globalThis.fetch, + cache, + atprotoLexiconUrl, + }: UniversalHandleResolverOptions = {}) { + const resolver = new SerialHandleResolver([ + // Try the well-known method first, allowing to reduce the load on the + // XRPC. + new WellKnownHandleResolver({ fetch }), + new AtprotoLexiconHandleResolver({ fetch, url: atprotoLexiconUrl }), + ]) + + super({ resolver, cache }) + } +} diff --git a/packages/handle-resolver/src/well-known-handler-resolver.ts b/packages/handle-resolver/src/well-known-handler-resolver.ts new file mode 100644 index 00000000000..1278fbf60d7 --- /dev/null +++ b/packages/handle-resolver/src/well-known-handler-resolver.ts @@ -0,0 +1,62 @@ +import { Fetch, FetchResponseError } from '@atproto/fetch' + +import { + HandleResolveOptions, + HandleResolver, + ResolvedHandle, + isResolvedHandle, +} from './handle-resolver.js' + +export type WellKnownHandleResolverOptions = { + fetch?: Fetch +} + +export class WellKnownHandleResolver implements HandleResolver { + protected readonly fetch: Fetch + + constructor({ + fetch = globalThis.fetch, + }: WellKnownHandleResolverOptions = {}) { + this.fetch = fetch + } + + public async resolve( + handle: string, + options?: HandleResolveOptions, + ): Promise { + const url = new URL('/.well-known/atproto-did', `https://${handle}`) + + const headers = new Headers() + if (options?.noCache) headers.set('cache-control', 'no-cache') + const request = new Request(url, { headers, signal: options?.signal }) + + try { + const response = await this.fetch.call(globalThis, request) + const text = await response.text() + const firstLine = text.split('\n')[0]!.trim() + + if (isResolvedHandle(firstLine)) return firstLine + + // If a web server is present at the handle's domain, it could return + // any response. Only payload that start with "did:" but are not a + // valid DID are considered unexpected behavior. + if (firstLine.startsWith('did:')) { + throw new FetchResponseError( + 502, + 'Invalid DID returned from well-known method', + { request, response }, + ) + } + + // Any other response is considered as a positive indication that the + // handle does not resolve to a DID using the well-known method. + return null + } catch { + // The the request failed, assume the handle does not resolve to a DID, + // unless the failure was due to the signal being aborted. + options?.signal?.throwIfAborted() + + return null + } + } +} diff --git a/packages/handle-resolver/tsconfig.build.json b/packages/handle-resolver/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/handle-resolver/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/handle-resolver/tsconfig.json b/packages/handle-resolver/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/handle-resolver/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/tsconfig.json b/tsconfig.json index 35f9c58ac4f..c5107b573c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,8 @@ { "path": "./packages/did-node" }, { "path": "./packages/fetch" }, { "path": "./packages/fetch-node" }, + { "path": "./packages/handle-resolver" }, + { "path": "./packages/handle-resolver-node" }, { "path": "./packages/html" }, { "path": "./packages/http-util" }, { "path": "./packages/identity" }, From d5f1230b1603dbfa7334c953a76f4b74f177ca85 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 22 Mar 2024 08:14:16 +0100 Subject: [PATCH 063/140] docs(oauth-provider): add toto comment --- packages/oauth-provider/src/output/send-authorize-redirect.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oauth-provider/src/output/send-authorize-redirect.ts b/packages/oauth-provider/src/output/send-authorize-redirect.ts index 1a2cfb426c2..a030420f700 100644 --- a/packages/oauth-provider/src/output/send-authorize-redirect.ts +++ b/packages/oauth-provider/src/output/send-authorize-redirect.ts @@ -44,7 +44,7 @@ export async function sendAuthorizeRedirect( const { issuer, parameters, redirect, client } = result const uri = parameters.redirect_uri || client.metadata.redirect_uris[0] - const mode = parameters.response_mode || 'query' + const mode = parameters.response_mode || 'query' // TODO: default depends on response_type const entries: [string, string][] = Object.entries({ iss: issuer, // rfc9207 From a0e82a6f9d983cc665e1851cf5634208a2f6de8b Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sat, 23 Mar 2024 05:11:12 -0700 Subject: [PATCH 064/140] chore(ts): allow fall through in switch --- packages/oauth-provider/src/parameters/claims-requested.ts | 1 - tsconfig/base.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/oauth-provider/src/parameters/claims-requested.ts b/packages/oauth-provider/src/parameters/claims-requested.ts index de2c2e524eb..a26a3db715f 100644 --- a/packages/oauth-provider/src/parameters/claims-requested.ts +++ b/packages/oauth-provider/src/parameters/claims-requested.ts @@ -94,7 +94,6 @@ function compareClaimValue( case 'number': case 'boolean': return expectedValue === value - // @ts-expect-error case 'object': if (expectedValue === null) return value === null // TODO (?): allow for object comparison diff --git a/tsconfig/base.json b/tsconfig/base.json index 0434e9fe5cf..d3fb7169ebb 100644 --- a/tsconfig/base.json +++ b/tsconfig/base.json @@ -7,7 +7,7 @@ "allowUnusedLabels": false, "allowUnreachableCode": false, "exactOptionalPropertyTypes": false, - "noFallthroughCasesInSwitch": true, + "noFallthroughCasesInSwitch": false, "noImplicitAny": false, "noImplicitReturns": false, "noUnusedLocals": true, From 91d0b1eefd08e79a7d398eab549c5baaa8a9e53d Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sat, 23 Mar 2024 05:11:46 -0700 Subject: [PATCH 065/140] feat(b64): add base64-url encoding util --- packages/b64/package.json | 28 ++++++++++++++++++++++++++++ packages/b64/src/index.ts | 34 ++++++++++++++++++++++++++++++++++ packages/b64/tsconfig.json | 8 ++++++++ tsconfig.json | 1 + 4 files changed, 71 insertions(+) create mode 100644 packages/b64/package.json create mode 100644 packages/b64/src/index.ts create mode 100644 packages/b64/tsconfig.json diff --git a/packages/b64/package.json b/packages/b64/package.json new file mode 100644 index 00000000000..65e1b3cb20e --- /dev/null +++ b/packages/b64/package.json @@ -0,0 +1,28 @@ +{ + "name": "@atproto/b64", + "version": "0.0.1", + "license": "MIT", + "description": "A library for encoding/decoding in base64-url", + "keywords": [ + "atproto", + "base64", + "base64-url" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/b64" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "base64-js": "^1.5.1" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/b64/src/index.ts b/packages/b64/src/index.ts new file mode 100644 index 00000000000..eb0214c2ce2 --- /dev/null +++ b/packages/b64/src/index.ts @@ -0,0 +1,34 @@ +import { fromByteArray, toByteArray } from 'base64-js' + +// Old Node implementations do not support "base64url" +const Buffer = ((Buffer) => { + if (typeof Buffer === 'function') { + try { + Buffer.from('', 'base64url') + return Buffer + } catch { + return undefined + } + } + return undefined +})(globalThis.Buffer) + +export const b64uDecode: (b64u: string) => Uint8Array = Buffer + ? (b64u) => Buffer.from(b64u, 'base64url') + : (b64u) => { + // toByteArray requires padding but not to replace '-' and '_' + const pad = b64u.length % 4 + const b64 = b64u.padEnd(b64u.length + (pad > 0 ? 4 - pad : 0), '=') + return toByteArray(b64) + } + +export const b64uEncode = Buffer + ? (bytes: Uint8Array) => { + const buffer = bytes instanceof Buffer ? bytes : Buffer.from(bytes) + return buffer.toString('base64url') + } + : (bytes: Uint8Array): string => + fromByteArray(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, '') diff --git a/packages/b64/tsconfig.json b/packages/b64/tsconfig.json new file mode 100644 index 00000000000..74b7e3462ca --- /dev/null +++ b/packages/b64/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index c5107b573c5..372c47d9706 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./packages/api" }, { "path": "./packages/aws" }, + { "path": "./packages/b64" }, { "path": "./packages/bsky" }, { "path": "./packages/bsync" }, { "path": "./packages/caching" }, From 05c256c4bbd22dce102f95c81148f2286089a6db Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sat, 23 Mar 2024 05:21:06 -0700 Subject: [PATCH 066/140] refactor(jwk)!: extract JOSE specific code in own package --- packages/jwk-jose/package.json | 32 +++++ packages/jwk-jose/src/index.ts | 2 + packages/jwk-jose/src/jose-key.ts | 115 ++++++++++++++++ packages/jwk-jose/src/jose-keyset.ts | 16 +++ packages/jwk-jose/src/util.ts | 9 ++ packages/jwk-jose/tsconfig.json | 8 ++ packages/jwk-node/package.json | 8 +- packages/jwk-node/src/node-key.ts | 117 ++-------------- packages/jwk/package.json | 8 +- packages/jwk/src/index.ts | 3 +- packages/jwk/src/jwt-decode.ts | 32 +++++ packages/jwk/src/jwt-verify.ts | 20 +++ packages/jwk/src/jwt.ts | 2 +- packages/jwk/src/key.ts | 133 ++++++++----------- packages/jwk/src/keyset.ts | 76 +++++------ packages/jwk/src/types.ts | 22 --- packages/jwk/src/util.ts | 24 ++-- packages/oauth-provider/src/signer/signer.ts | 13 +- tsconfig.json | 1 + 19 files changed, 354 insertions(+), 287 deletions(-) create mode 100644 packages/jwk-jose/package.json create mode 100644 packages/jwk-jose/src/index.ts create mode 100644 packages/jwk-jose/src/jose-key.ts create mode 100644 packages/jwk-jose/src/jose-keyset.ts create mode 100644 packages/jwk-jose/src/util.ts create mode 100644 packages/jwk-jose/tsconfig.json create mode 100644 packages/jwk/src/jwt-decode.ts create mode 100644 packages/jwk/src/jwt-verify.ts delete mode 100644 packages/jwk/src/types.ts diff --git a/packages/jwk-jose/package.json b/packages/jwk-jose/package.json new file mode 100644 index 00000000000..b84ace7dba2 --- /dev/null +++ b/packages/jwk-jose/package.json @@ -0,0 +1,32 @@ +{ + "name": "@atproto/jwk-jose", + "version": "0.0.1", + "license": "MIT", + "description": "NodeJS implementation of Keypair from @atproto/jwk", + "keywords": [ + "atproto", + "jwk", + "jose", + "keypair" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/jwk-jose" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@atproto/jwk": "workspace:*", + "jose": "^5.2.0", + "tslib": "^2.6.2" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/jwk-jose/src/index.ts b/packages/jwk-jose/src/index.ts new file mode 100644 index 00000000000..1de544a0a4e --- /dev/null +++ b/packages/jwk-jose/src/index.ts @@ -0,0 +1,2 @@ +export * from './jose-key.js' +export * from './jose-keyset.js' diff --git a/packages/jwk-jose/src/jose-key.ts b/packages/jwk-jose/src/jose-key.ts new file mode 100644 index 00000000000..56460e591e1 --- /dev/null +++ b/packages/jwk-jose/src/jose-key.ts @@ -0,0 +1,115 @@ +import { + JWK, + JWTVerifyOptions, + KeyLike, + SignJWT, + exportJWK, + importJWK, + importPKCS8, + jwtVerify, +} from 'jose' + +import { + Jwk, + Jwt, + JwtHeader, + JwtPayload, + Key, + VerifyOptions, + VerifyPayload, + VerifyResult, + jwkSchema, +} from '@atproto/jwk' +import { either } from './util' + +export type Importable = string | KeyLike | Jwk + +export class JoseKey extends Key { + #keyObj?: KeyLike | Uint8Array + + protected async getKey() { + return (this.#keyObj ||= await importJWK(this.jwk as JWK)) + } + + async createJwt(header: JwtHeader, payload: JwtPayload) { + if (header.kid && header.kid !== this.kid) { + throw new TypeError( + `Invalid "kid" (${header.kid}) used to sign with key "${this.kid}"`, + ) + } + + if (!header.alg || !this.algorithms.includes(header.alg)) { + throw new TypeError( + `Invalid "alg" (${header.alg}) used to sign with key "${this.kid}"`, + ) + } + + const keyObj = await this.getKey() + return new SignJWT(payload) + .setProtectedHeader({ ...header, kid: this.kid }) + .sign(keyObj) as Promise + } + + async verifyJwt< + P extends VerifyPayload = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions): Promise> { + const keyObj = await this.getKey() + const result = await jwtVerify(token, keyObj, { + ...options, + algorithms: this.algorithms, + } as JWTVerifyOptions) + return result as VerifyResult + } + + static async fromImportable( + input: Importable, + kid?: string, + ): Promise { + if (typeof input === 'string') { + // PKCS8 + if (input.startsWith('-----')) { + return this.fromPKCS8(input, kid) + } + + // Jwk (string) + if (input.startsWith('{')) { + return this.fromJWK(input, kid) + } + + throw new TypeError('Invalid input') + } + + if (typeof input === 'object') { + // Jwk + if ('kty' in input || 'alg' in input) { + return this.fromJWK(input, kid) + } + + // KeyLike + return this.fromJWK(await exportJWK(input), kid) + } + + throw new TypeError('Invalid input') + } + + static async fromPKCS8(pem: string, kid?: string): Promise { + const keyLike = await importPKCS8(pem, '', { extractable: true }) + return this.fromJWK(await exportJWK(keyLike), kid) + } + + static async fromJWK( + input: string | Record, + inputKid?: string, + ): Promise { + const jwk = jwkSchema.parse( + typeof input === 'string' ? JSON.parse(input) : input, + ) + + const kid = either(jwk.kid, inputKid) + const alg = jwk.alg + const use = jwk.use || 'sig' + + return new JoseKey({ ...jwk, kid, alg, use }) + } +} diff --git a/packages/jwk-jose/src/jose-keyset.ts b/packages/jwk-jose/src/jose-keyset.ts new file mode 100644 index 00000000000..2d4e0848c28 --- /dev/null +++ b/packages/jwk-jose/src/jose-keyset.ts @@ -0,0 +1,16 @@ +import { Key, Keyset } from '@atproto/jwk' +import { Importable, JoseKey } from './jose-key.js' + +export class JoseKeyset extends Keyset { + static async fromImportables( + input: Record, + ) { + return new JoseKeyset( + await Promise.all( + Object.entries(input).map(([kid, secret]) => + secret instanceof Key ? secret : JoseKey.fromImportable(secret, kid), + ), + ), + ) + } +} diff --git a/packages/jwk-jose/src/util.ts b/packages/jwk-jose/src/util.ts new file mode 100644 index 00000000000..f75cdb66718 --- /dev/null +++ b/packages/jwk-jose/src/util.ts @@ -0,0 +1,9 @@ +export function either( + a?: T, + b?: T, +): T | undefined { + if (a != null && b != null && a !== b) { + throw new TypeError(`Expected "${b}", got "${a}"`) + } + return a ?? b ?? undefined +} diff --git a/packages/jwk-jose/tsconfig.json b/packages/jwk-jose/tsconfig.json new file mode 100644 index 00000000000..6922d4d1578 --- /dev/null +++ b/packages/jwk-jose/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/nodenext.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/jwk-node/package.json b/packages/jwk-node/package.json index ec0283fe194..1e4afdbfafe 100644 --- a/packages/jwk-node/package.json +++ b/packages/jwk-node/package.json @@ -18,15 +18,9 @@ "type": "commonjs", "main": "dist/index.js", "types": "dist/index.d.ts", - "exports": { - ".": { - "default": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, "dependencies": { "@atproto/jwk": "workspace:*", - "jose": "^5.2.0", + "@atproto/jwk-jose": "workspace:*", "tslib": "^2.6.2" }, "devDependencies": { diff --git a/packages/jwk-node/src/node-key.ts b/packages/jwk-node/src/node-key.ts index 96dd314b2f3..efaec45b1a2 100644 --- a/packages/jwk-node/src/node-key.ts +++ b/packages/jwk-node/src/node-key.ts @@ -1,127 +1,28 @@ -import { KeyObject, createPublicKey } from 'node:crypto' +import { KeyObject } from 'node:crypto' -import { - Jwk, - Key, - KeyLike, - either, - jwkPubSchema, - jwkSchema, -} from '@atproto/jwk' -import { exportJWK, importJWK, importPKCS8 } from 'jose' +import { jwkSchema } from '@atproto/jwk' +import { Importable as JoseImportable, JoseKey } from '@atproto/jwk-jose' -export type Importable = string | KeyObject | KeyLike | Jwk +export type Importable = KeyObject | JoseImportable -function asPublicJwk( - { kid, use, alg }: { kid?: string; use?: 'sig' | 'enc'; alg?: string }, - publicKey: KeyObject, -) { - const jwk = publicKey.export({ format: 'jwk' }) - - if (use) jwk['use'] = use - if (kid) jwk['kid'] = kid - if (alg) jwk['alg'] = alg - - return jwkPubSchema.parse(jwk) -} - -export class NodeKey extends Key { +export class NodeKey extends JoseKey { static async fromImportable( input: Importable, kid: string, ): Promise { - if (typeof input === 'string') { - // PKCS8 (string) - if (input.startsWith('-----')) { - return this.fromPKCS8(kid, input) - } - - // Jwk (string) - if (input.startsWith('{')) { - return this.fromJWK(input, kid) - } - - throw new TypeError('Invalid input') - } - - if (typeof input === 'object') { - // KeyObject - if (input instanceof KeyObject) { - return this.fromKeyObject(kid, input) - } - - // Jwk - if ( - !(input instanceof Uint8Array) && - ('kty' in input || 'alg' in input) - ) { - return this.fromJWK(input, kid) - } - - // KeyLike - return this.fromJWK(await exportJWK(input), kid) + if (input instanceof KeyObject) { + return this.fromKeyObject(kid, input) } - throw new TypeError('Invalid input') - } - - static async fromPKCS8( - kid: string, - pem: string, - use?: 'sig' | 'enc', - alg?: string, - ): Promise { - const privateKey = await importPKCS8(pem, '', { - extractable: true, - }) - - return this.fromKeyObject(kid, privateKey, use, alg) + return super.fromImportable(input, kid) } static async fromKeyObject( kid: string, privateKey: KeyObject, - inputUse?: 'sig' | 'enc', - inputAlg?: string, ): Promise { const jwk = jwkSchema.parse(privateKey.export({ format: 'jwk' })) - - const alg = either(jwk.alg, inputAlg) - const use = either(jwk.use, inputUse) || 'sig' - - const privateJwk = { ...jwk, use, kid, alg } - - if (privateKey.asymmetricKeyType) { - const publicKey = createPublicKey(privateKey) - const publicJwk = asPublicJwk(privateJwk, publicKey) - return new NodeKey({ privateJwk, privateKey, publicJwk, publicKey }) - } else { - return new NodeKey({ privateJwk, privateKey }) - } - } - - static async fromJWK( - input: string | Record, - inputKid?: string, - ): Promise { - const jwk = jwkSchema.parse( - typeof input === 'string' ? JSON.parse(input) : input, - ) - - const kid = either(jwk.kid, inputKid) - const alg = jwk.alg const use = jwk.use || 'sig' - - // @ts-expect-error https://github.com/panva/jose/issues/634 - const privateKey = await importJWK(jwk) - if (!(privateKey instanceof KeyObject)) { - throw new TypeError('Expected an asymmetric key') - } - const privateJwk = { ...jwk, kid, alg, use } - - const publicKey = createPublicKey(privateKey) - const publicJwk = asPublicJwk(privateJwk, publicKey) - - return new NodeKey({ privateJwk, privateKey, publicJwk, publicKey }) + return new NodeKey({ ...jwk, use, kid }) } } diff --git a/packages/jwk/package.json b/packages/jwk/package.json index ed1d63babb1..da7b97e30ca 100644 --- a/packages/jwk/package.json +++ b/packages/jwk/package.json @@ -19,14 +19,8 @@ "type": "commonjs", "main": "dist/index.js", "types": "dist/index.d.ts", - "exports": { - ".": { - "default": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, "dependencies": { - "jose": "^5.2.2", + "@atproto/b64": "workspace:*", "tslib": "^2.6.2", "zod": "^3.22.4" }, diff --git a/packages/jwk/src/index.ts b/packages/jwk/src/index.ts index 1e86d1ad21f..3e1c678233c 100644 --- a/packages/jwk/src/index.ts +++ b/packages/jwk/src/index.ts @@ -2,7 +2,8 @@ export * from './alg.js' export * from './jwk.js' export * from './jwks.js' export * from './jwt.js' +export * from './jwt-decode.js' +export * from './jwt-verify.js' export * from './key.js' export * from './keyset.js' -export * from './types.js' export * from './util.js' diff --git a/packages/jwk/src/jwt-decode.ts b/packages/jwk/src/jwt-decode.ts new file mode 100644 index 00000000000..287f8fbc08b --- /dev/null +++ b/packages/jwk/src/jwt-decode.ts @@ -0,0 +1,32 @@ +import { b64uDecode } from '@atproto/b64' + +import { ui8ToString } from './util.js' +import { + JwtHeader, + JwtPayload, + jwtHeaderSchema, + jwtPayloadSchema, +} from './jwt.js' + +export function unsafeDecodeJwt(jwt: string): { + header: JwtHeader + payload: JwtPayload +} { + const { 0: headerEnc, 1: payloadEnc, length } = jwt.split('.') + if (length > 3 || length < 2) { + throw new TypeError('invalid JWT input') + } + + const header = jwtHeaderSchema.parse( + JSON.parse(ui8ToString(b64uDecode(headerEnc!))), + ) + if (length === 2 && header?.alg !== 'none') { + throw new TypeError('invalid JWT input') + } + + const payload = jwtPayloadSchema.parse( + JSON.parse(ui8ToString(b64uDecode(payloadEnc!))), + ) + + return { header, payload } +} diff --git a/packages/jwk/src/jwt-verify.ts b/packages/jwk/src/jwt-verify.ts new file mode 100644 index 00000000000..5eeca81e53e --- /dev/null +++ b/packages/jwk/src/jwt-verify.ts @@ -0,0 +1,20 @@ +import { JwtHeader, JwtPayload } from './jwt.js' +import { RequiredKey } from './util.js' + +export type VerifyOptions = { + audience?: string | readonly string[] + clockTolerance?: string | number + issuer?: string | readonly string[] + maxTokenAge?: string | number + subject?: string + typ?: string + currentDate?: Date + requiredClaims?: readonly C[] +} + +export type VerifyPayload = Record + +export type VerifyResult

= { + payload: RequiredKey

+ protectedHeader: JwtHeader +} diff --git a/packages/jwk/src/jwt.ts b/packages/jwk/src/jwt.ts index 70073eeea9d..c07af8cb735 100644 --- a/packages/jwk/src/jwt.ts +++ b/packages/jwk/src/jwt.ts @@ -61,7 +61,7 @@ export type JwtHeader = z.infer // https://www.iana.org/assignments/jwt/jwt.xhtml export const jwtPayloadSchema = z.object({ - iss: z.string(), + iss: z.string().optional(), aud: z.union([z.string(), z.array(z.string()).nonempty()]).optional(), sub: z.string().optional(), exp: z.number().int().optional(), diff --git a/packages/jwk/src/key.ts b/packages/jwk/src/key.ts index d6b58d7faea..d8af69e0df6 100644 --- a/packages/jwk/src/key.ts +++ b/packages/jwk/src/key.ts @@ -1,56 +1,50 @@ -import { JWK, importJWK } from 'jose' - import { jwkAlgorithms } from './alg.js' import { Jwk, jwkSchema } from './jwk.js' -import { KeyLike } from './types.js' -import { cachedGetter, either } from './util.js' +import { VerifyOptions, VerifyPayload, VerifyResult } from './jwt-verify.js' +import { Jwt, JwtHeader, JwtPayload } from './jwt.js' +import { cachedGetter } from './util.js' + +export abstract class Key { + constructor(protected jwk: Jwk) { + // A key should always be used either for signing or encryption. + if (!jwk.use) throw new TypeError('Missing "use" Parameter value') + } -export class Key { - readonly privateJwk?: Jwk - readonly privateKey?: KeyLike + get isPrivate(): boolean { + const { jwk } = this + if ('d' in jwk && jwk.d !== undefined) return true + return this.isSymetric + } - readonly publicJwk?: Jwk - readonly publicKey?: KeyLike + get isSymetric(): boolean { + const { jwk } = this + if ('k' in jwk && jwk.k !== undefined) return true + return false + } - constructor({ - privateJwk, - privateKey, - publicJwk, - publicKey, - }: - | { - privateJwk: Jwk - privateKey?: KeyLike - publicJwk?: Jwk - publicKey?: KeyLike - } - | { - privateJwk?: Jwk - privateKey?: KeyLike - publicJwk: Jwk - publicKey?: KeyLike - }) { - if (!privateJwk && !publicJwk) - throw new TypeError('At least one of privateJwk or publicJwk is required') - if (privateKey && !privateJwk) - throw new TypeError('privateKey must be used with privateJwk') - if (publicKey && !publicJwk) - throw new TypeError('publicKey must be used with publicJwk') + get privateJwk(): Jwk | undefined { + return this.isPrivate ? this.jwk : undefined + } - this.privateJwk = privateJwk - this.privateKey = privateKey + @cachedGetter + get publicJwk(): Jwk | undefined { + if (this.isSymetric) return undefined + if (this.isPrivate) { + const { d: _, ...jwk } = this.jwk as any + return jwk + } + return this.jwk + } - this.publicJwk = publicJwk - this.publicKey = publicKey + @cachedGetter + get bareJwk(): Jwk | undefined { + if (this.isSymetric) return undefined + const { kty, crv, e, n, x, y } = this.jwk as any + return jwkSchema.parse({ crv, e, kty, n, x, y }) } - /** - * A key should always be used either for signing or encryption. - */ get use() { - const use = either(this.privateJwk?.use, this.publicJwk?.use) - if (!use) throw new TypeError('Missing "use" Parameter value') - return use + return this.jwk.use! } /** @@ -58,23 +52,15 @@ export class Key { * any of the algorithms in {@link algorithms}. */ get alg() { - return either(this.privateJwk?.alg, this.publicJwk?.alg) + return this.jwk.alg } - /** - * The key ID. - */ get kid() { - const kid = either(this.privateJwk?.kid, this.publicJwk?.kid) - if (!kid) throw new TypeError('Missing "kid" Parameter value') - return kid + return this.jwk.kid } get crv() { - return either( - (this.privateJwk as undefined | Extract)?.crv, - (this.publicJwk as undefined | Extract)?.crv, - ) + return (this.jwk as undefined | Extract)?.crv } get canVerify() { @@ -82,17 +68,7 @@ export class Key { } get canSign() { - return this.use === 'sig' && this.privateJwk != null - } - - /** - * The "bare" public jwk (without `kid`, `use` and `alg`), to use inside a - * "cnf" JWT header. - */ - @cachedGetter - get bareJwk(): Jwk { - const { kty, crv, e, n, x, y } = (this.publicJwk || this.privateJwk) as any - return jwkSchema.parse({ crv, e, kty, n, x, y }) + return this.use === 'sig' && this.isPrivate && !this.isSymetric } /** @@ -101,22 +77,19 @@ export class Key { */ @cachedGetter get algorithms(): readonly string[] { - const jwk = this.privateJwk || this.publicJwk - return Array.from(jwk ? jwkAlgorithms(jwk) : []) + return Array.from(jwkAlgorithms(this.jwk)) } - signKeyObject() { - if (!this.privateJwk) throw new TypeError('Not a private key') - return this.privateKey || importJWK(this.privateJwk as JWK) - } + /** + * Create a signed JWT + */ + abstract createJwt(header: JwtHeader, payload: JwtPayload): Promise - verifyKeyObject() { - return ( - // Use the KeyLike object if it's available - this.publicKey || - this.privateKey || - // Fallback to the JWK - importJWK((this.privateJwk || this.publicJwk)! as JWK) - ) - } + /** + * Verify the signature, headers and payload of a JWT + */ + abstract verifyJwt< + P extends VerifyPayload = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions): Promise> } diff --git a/packages/jwk/src/keyset.ts b/packages/jwk/src/keyset.ts index fe81c77764b..9be83677d3b 100644 --- a/packages/jwk/src/keyset.ts +++ b/packages/jwk/src/keyset.ts @@ -1,34 +1,24 @@ -import { JWTVerifyOptions, SignJWT, jwtVerify } from 'jose' - import { Jwk } from './jwk.js' import { Jwks } from './jwks.js' +import { unsafeDecodeJwt } from './jwt-decode.js' +import { VerifyOptions } from './jwt-verify.js' import { Jwt, JwtHeader, JwtPayload } from './jwt.js' import { Key } from './key.js' import { Override, - RequiredKey, - Simplify, cachedGetter, isDefined, matchesAny, preferredOrderCmp, } from './util.js' -export type { JWTVerifyOptions } +export type JwtSignHeader = Override> -export type JwtProtectedHeader = RequiredKey export type JwtPayloadGetter

= ( - protectedHeader: JwtProtectedHeader, + header: JwtHeader, key: Key, ) => P | PromiseLike

-export type JwtSignHeader = Override> - -export type JwtVerifyResult

= { - payload: Simplify

- protectedHeader: JwtProtectedHeader -} - export type KeySearch = { use?: 'sig' | 'enc' kid?: string | string[] @@ -60,9 +50,11 @@ export class Keyset implements Iterable { if (!keys.length) throw new Error('Keyset is empty') const kids = new Set() - for (const key of keys) { - if (kids.has(key.kid)) throw new Error(`Duplicate key id: ${key.kid}`) - else kids.add(key.kid) + for (const { kid } of keys) { + if (!kid) continue + + if (kids.has(kid)) throw new Error(`Duplicate key id: ${kid}`) + else kids.add(kid) } } @@ -117,7 +109,7 @@ export class Keyset implements Iterable { if (search.use && key.use !== search.use) continue if (Array.isArray(search.kid)) { - if (!search.kid.includes(key.kid)) continue + if (!key.kid || !search.kid.includes(key.kid)) continue } else if (search.kid) { if (key.kid !== search.kid) continue } @@ -172,39 +164,37 @@ export class Keyset implements Iterable { return this.keys.values() } - async sign

( + async sign( { alg: searchAlg, kid: searchKid, ...header }: JwtSignHeader, - payload: P | JwtPayloadGetter

, - ): Promise { + payload: JwtPayload | JwtPayloadGetter, + ) { const [key, alg] = this.findSigningKey({ alg: searchAlg, kid: searchKid }) - - const protectedHeader: JwtProtectedHeader = { ...header, alg, kid: key.kid } - - const keyObj = await key.signKeyObject() + const protectedHeader = { ...header, alg, kid: key.kid } if (typeof payload === 'function') { payload = await payload(protectedHeader, key) } - return new SignJWT(payload) - .setProtectedHeader(protectedHeader) - .sign(keyObj) as Promise + return key.createJwt(protectedHeader, payload) } - async verify

( - token: Jwt, - options?: JWTVerifyOptions, - ): Promise> { - return jwtVerify>( - token, - async ({ kid, alg }) => { - // Ensure that the casting to JwtVerifyResult

is actually safe - if (!kid || !alg) throw new TypeError('Missing "kid" or "alg"') - - const key = this.get({ use: 'sig', kid, alg }) - return key.verifyKeyObject() - }, - options, - ) as Promise> + async verify< + P extends Record = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions) { + const { header } = unsafeDecodeJwt(token) + const { kid, alg } = header + + const errors: unknown[] = [] + + for (const key of this.list({ use: 'sig', kid, alg })) { + try { + return await key.verifyJwt(token, options) + } catch (err) { + errors.push(err) + } + } + + throw new AggregateError(errors, 'Unable to verify signature') } } diff --git a/packages/jwk/src/types.ts b/packages/jwk/src/types.ts deleted file mode 100644 index 0adea8a8198..00000000000 --- a/packages/jwk/src/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type JWSAlgorithm = - // HMAC - | 'HS256' - | 'HS384' - | 'HS512' - // RSA - | 'PS256' - | 'PS384' - | 'PS512' - | 'RS256' - | 'RS384' - | 'RS512' - // EC - | 'ES256' - | 'ES256K' - | 'ES384' - | 'ES512' - // OKP - | 'EdDSA' - -// Runtime specific key representation or secret -export type KeyLike = { type: string } | Uint8Array diff --git a/packages/jwk/src/util.ts b/packages/jwk/src/util.ts index 550c10e883a..12b5625cce5 100644 --- a/packages/jwk/src/util.ts +++ b/packages/jwk/src/util.ts @@ -2,9 +2,12 @@ export type Simplify = { [K in keyof T]: T[K] } & {} export type Override = Simplify> -export type RequiredKey = Override< - T, - Required> +export type RequiredKey = Simplify< + string extends K + ? T + : { + [L in K]: Exclude + } & Omit > export const isDefined = (i: T | undefined): i is T => i !== undefined @@ -26,8 +29,8 @@ export function matchesAny( return value == null ? (v): v is T => true : Array.isArray(value) - ? (v): v is T => value.includes(v) - : (v): v is T => v === value + ? (v): v is T => value.includes(v) + : (v): v is T => v === value } /** @@ -48,12 +51,5 @@ export const cachedGetter = ( } } -export function either( - a?: T, - b?: T, -): T | undefined { - if (a != null && b != null && a !== b) { - throw new TypeError(`Expected "${b}", got "${a}"`) - } - return a ?? b ?? undefined -} +export const decoder = new TextDecoder() +export const ui8ToString = (value: Uint8Array) => decoder.decode(value) diff --git a/packages/oauth-provider/src/signer/signer.ts b/packages/oauth-provider/src/signer/signer.ts index 7e6069b7dd3..858f7ce081f 100644 --- a/packages/oauth-provider/src/signer/signer.ts +++ b/packages/oauth-provider/src/signer/signer.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto' import { - JWTVerifyOptions, + VerifyOptions, Jwt, JwtPayload, JwtPayloadGetter, @@ -25,14 +25,19 @@ import { signedTokenPayloadSchema, } from './signed-token-payload.js' -export type VerifyOptions = Omit export type SignPayload = Omit export class Signer { constructor(public readonly issuer: string, public readonly keyset: Keyset) {} - async verify

(token: Jwt, options?: VerifyOptions) { - return this.keyset.verify

(token, { ...options, issuer: [this.issuer] }) + async verify

= JwtPayload>( + token: Jwt, + options?: VerifyOptions, + ) { + return this.keyset.verify

(token, { + ...options, + issuer: [this.issuer], + }) } public async sign( diff --git a/tsconfig.json b/tsconfig.json index 372c47d9706..7f3a76b7431 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ { "path": "./packages/http-util" }, { "path": "./packages/identity" }, { "path": "./packages/jwk" }, + { "path": "./packages/jwk-jose" }, { "path": "./packages/jwk-node" }, { "path": "./packages/lex-cli" }, { "path": "./packages/lexicon" }, From 5b6fcdd5c9960ce5db4bda64cef9391b1a1f230b Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sat, 23 Mar 2024 05:23:22 -0700 Subject: [PATCH 067/140] docs(oauth-provider): order response_types_supported by category --- .../oauth-provider/src/metadata/build-metadata.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/oauth-provider/src/metadata/build-metadata.ts b/packages/oauth-provider/src/metadata/build-metadata.ts index 3c38dce21fc..e82884abb64 100644 --- a/packages/oauth-provider/src/metadata/build-metadata.ts +++ b/packages/oauth-provider/src/metadata/build-metadata.ts @@ -58,15 +58,17 @@ export function buildMetadata( // 'pairwise', // A different "sub" is returned for each client ], response_types_supported: [ - // - 'none', + // OAuth 'code', 'token', - 'id_token', - 'id_token token', + + // OpenID + 'none', + 'code id_token token', 'code id_token', 'code token', - 'code id_token token', + 'id_token token', + 'id_token', ], response_modes_supported: [ // https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#ResponseModes From 20474b578b00e9d83ba74a0fb49a561ed0a59800 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sun, 24 Mar 2024 20:05:23 -0700 Subject: [PATCH 068/140] fix(fetch): cancel timeout on end of response --- packages/fetch/package.json | 1 - packages/fetch/src/fetch-response.ts | 121 +++++++++++---------- packages/fetch/src/fetch-wrap.ts | 108 ++++++++++++------ packages/fetch/src/index.ts | 1 - packages/fetch/src/transformed-response.ts | 33 ++++++ packages/fetch/src/utils.ts | 28 ----- 6 files changed, 172 insertions(+), 120 deletions(-) create mode 100644 packages/fetch/src/transformed-response.ts delete mode 100644 packages/fetch/src/utils.ts diff --git a/packages/fetch/package.json b/packages/fetch/package.json index 45ecb71459c..c13893796ed 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -28,7 +28,6 @@ "zod": "^3.22.4" }, "devDependencies": { - "@types/http-errors": "^2.0.4", "typescript": "^5.3.3" }, "scripts": { diff --git a/packages/fetch/src/fetch-response.ts b/packages/fetch/src/fetch-response.ts index 93239fd266d..55fd9d53441 100644 --- a/packages/fetch/src/fetch-response.ts +++ b/packages/fetch/src/fetch-response.ts @@ -2,7 +2,7 @@ import { Transformer, compose } from '@atproto/transformer' import { z } from 'zod' import { FetchError, FetchErrorOptions } from './fetch-error.js' -import { overrideResponseBody } from './utils.js' +import { TransformedResponse } from './transformed-response.js' export type ResponseTranformer = Transformer @@ -36,20 +36,20 @@ export class FetchResponseError extends FetchError { constructor( statusCode: number, message?: string, - public readonly body?: Blob, options?: FetchErrorOptions, ) { super(statusCode, message, options) } - static async from(response: Response) { - const message = await extractResponseMessage(response) - const body: undefined | Blob = - response.body && !response.bodyUsed - ? await response.clone().blob() - : undefined - - return new FetchResponseError(response.status, message, body, { + static async from( + response: Response, + status = response.status, + message?: string, + cause?: unknown, + ) { + message ??= await extractResponseMessage(response) + return new FetchResponseError(status, message, { + cause, response, }) } @@ -64,44 +64,52 @@ export function fetchOkProcessor(): ResponseTranformer { } export function fetchMaxSizeProcessor(maxBytes: number): ResponseTranformer { - if (!(maxBytes >= 0)) throw new TypeError('maxBytes must be >= 0') if (maxBytes === Infinity) return (response) => response + if (!Number.isFinite(maxBytes) || maxBytes < 0) { + throw new TypeError('maxBytes must be a non-negative number') + } + return async (response) => fetchResponseMaxSize(response, maxBytes) +} - return async (response) => { - if (!response.body) return response - - const contentLength = response.headers.get('content-length') - if (contentLength) { - const length = Number(contentLength) - if (!(length < maxBytes)) { - const err = new FetchError(502, 'Response too large', { response }) - await response.body.cancel(err) - throw err - } +export async function fetchResponseMaxSize( + response: Response, + maxBytes: number, +): Promise { + if (maxBytes === Infinity) return response + if (!response.body) return response + + const contentLength = response.headers.get('content-length') + if (contentLength) { + const length = Number(contentLength) + if (!(length < maxBytes)) { + const err = new FetchResponseError(502, 'Response too large', { + response, + }) + await response.body.cancel(err) + throw err } - - let bytesRead = 0 - - // @ts-ignore - @types/node does not have ReadableStream as global - const newBody: ReadableStream = response.body.pipeThrough( - // @ts-ignore - @types/node does not have TransformStream as global - new TransformStream({ - transform: ( - chunk: Uint8Array, - // @ts-ignore - @types/node does not have TransformStreamDefaultController as global - ctrl: TransformStreamDefaultController, - ) => { - if ((bytesRead += chunk.length) <= maxBytes) { - ctrl.enqueue(chunk) - } else { - ctrl.error(new FetchError(502, 'Response too large', { response })) - } - }, - }), - ) - - return overrideResponseBody(response, newBody) } + + let bytesRead = 0 + + const transform = new TransformStream({ + transform: ( + chunk: Uint8Array, + ctrl: TransformStreamDefaultController, + ) => { + if ((bytesRead += chunk.length) <= maxBytes) { + ctrl.enqueue(chunk) + } else { + ctrl.error( + new FetchResponseError(502, 'Response too large', { + response, + }), + ) + } + }, + }) + + return new TransformedResponse(response, transform) } export type ContentTypeCheckFn = (contentType: string) => boolean @@ -115,8 +123,8 @@ export function fetchTypeProcessor( typeof expectedType === 'string' ? (ct) => ct === expectedType : expectedType instanceof RegExp - ? (ct) => expectedType.test(ct) - : expectedType + ? (ct) => expectedType.test(ct) + : expectedType return async (response) => { const contentType = response.headers @@ -126,18 +134,18 @@ export function fetchTypeProcessor( if (contentType) { if (!isExpected(contentType)) { - throw new FetchError( + throw new FetchResponseError( 502, `Unexpected response Content-Type (${contentType})`, - { - response, - }, + { response }, ) } } else if (contentTypeRequired) { - throw new FetchError(502, 'Missing response Content-Type header', { - response, - }) + throw new FetchResponseError( + 502, + 'Missing response Content-Type header', + { response }, + ) } return response @@ -158,8 +166,11 @@ export async function jsonTranformer( response, json: json as T, })) - .catch((err) => { - throw new FetchError(502, err, { response }) + .catch(async (cause) => { + throw new FetchResponseError(502, 'Unable to parse response JSON', { + response, + cause, + }) }) } diff --git a/packages/fetch/src/fetch-wrap.ts b/packages/fetch/src/fetch-wrap.ts index fb84ef40dfe..43c097a2019 100644 --- a/packages/fetch/src/fetch-wrap.ts +++ b/packages/fetch/src/fetch-wrap.ts @@ -1,33 +1,39 @@ import { Fetch } from './fetch.js' +import { TransformedResponse } from './transformed-response.js' -export const loggedFetchWrap = - ({ fetch = globalThis.fetch as Fetch, prefix = '' } = {}): Fetch => - async (request) => { - await logRequest(request, prefix) - try { - const response = await fetch(request) - await logResponse(response, prefix) - return response - } catch (error) { - await logError(error, prefix) - throw error - } - } - -const logRequest = async (request: Request, prefix = '') => +export const loggedFetchWrap = ({ + fetch = globalThis.fetch as Fetch, +} = {}): Fetch => { + return async function (request) { + return fetchLog.call(this, request, fetch) + } +} + +async function fetchLog( + this: ThisParameterType, + request: Request, + fetch: Fetch = globalThis.fetch, +) { console.info( - `${prefix}> ${request.method} ${request.url}\n` + + `> ${request.method} ${request.url}\n` + stringifyPayload(request.headers, await request.clone().text()), ) -const logResponse = async (response: Response, prefix = '') => - console.info( - `${prefix}< HTTP/1.1 ${response.status} ${response.statusText}\n` + - stringifyPayload(response.headers, await response.clone().text()), - ) + try { + const response = await fetch(request) + + console.info( + `< HTTP/1.1 ${response.status} ${response.statusText}\n` + + stringifyPayload(response.headers, await response.clone().text()), + ) + + return response + } catch (error) { + console.error(`< Error:`, error) -const logError = async (error: unknown, prefix = '') => - console.error(`${prefix} error:`, error) + throw error + } +} const stringifyPayload = (headers: Headers, body: string) => [stringifyHeaders(headers), stringifyBody(body)] @@ -47,17 +53,49 @@ export const timeoutFetchWrap = ({ timeout = 60e3, } = {}): Fetch => { if (timeout === Infinity) return fetch - if (!(timeout > 0)) throw new TypeError('Timeout must be positive') - - return async (request) => { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeout).unref() - const signal = controller.signal - signal.addEventListener('abort', () => clearTimeout(timeoutId)) - request.signal?.addEventListener('abort', () => controller.abort(), { - signal, - }) - - return fetch(new Request(request, { signal })) + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new TypeError('Timeout must be positive') + } + return async function (request) { + return fetchTimeout.call(this, request, timeout, fetch) + } +} + +export async function fetchTimeout( + this: ThisParameterType, + request: Request, + timeout = 30e3, + fetch: Fetch = globalThis.fetch, +): Promise { + if (timeout === Infinity) return fetch(request) + if (!Number.isFinite(timeout) || timeout <= 0) { + throw new TypeError('Timeout must be positive') + } + + const controller = new AbortController() + const signal = controller.signal + + const abort = () => { + controller.abort() + } + const cleanup = () => { + clearTimeout(timeoutId) + request.signal?.removeEventListener('abort', abort) + } + + const timeoutId = setTimeout(abort, timeout).unref() + request.signal?.addEventListener('abort', abort) + + signal.addEventListener('abort', cleanup) + + const response = await fetch(new Request(request, { signal })) + + if (!response.body) { + cleanup() + return response + } else { + // Cleanup the timer & event listeners when the body stream is closed + const transform = new TransformStream({ flush: cleanup }) + return new TransformedResponse(response, transform) } } diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts index 5d6dc2b2f6e..93f31e5493e 100644 --- a/packages/fetch/src/index.ts +++ b/packages/fetch/src/index.ts @@ -3,4 +3,3 @@ export * from './fetch-request.js' export * from './fetch-response.js' export * from './fetch-wrap.js' export * from './fetch.js' -export * from './utils.js' diff --git a/packages/fetch/src/transformed-response.ts b/packages/fetch/src/transformed-response.ts new file mode 100644 index 00000000000..f839d3bcf52 --- /dev/null +++ b/packages/fetch/src/transformed-response.ts @@ -0,0 +1,33 @@ +export class TransformedResponse extends Response { + #response: Response + + constructor(response: Response, transform: TransformStream) { + if (response.body && response.bodyUsed) { + throw new TypeError('Response body is already used') + } + + super(response.body?.pipeThrough(transform) ?? null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + + this.#response = response + } + + /** + * Some props can't be set through ResponseInit, so we need to proxy them + */ + get url() { + return this.#response.url + } + get redirected() { + return this.#response.redirected + } + get type() { + return this.#response.type + } + get statusText() { + return this.#response.statusText + } +} diff --git a/packages/fetch/src/utils.ts b/packages/fetch/src/utils.ts deleted file mode 100644 index 1e074a96c13..00000000000 --- a/packages/fetch/src/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -// BodyInit not made available by @types/node -export type BodyInit = Exclude< - ConstructorParameters[0], - undefined -> - -export function overrideResponseBody( - response: Response, - body: BodyInit, -): Response { - const newResponse = new Response(body, response) - - /** - * Some props do not get copied by the Response constructor (e.g. url) - */ - for (const key of ['url', 'redirected', 'type', 'statusText'] as const) { - const value = response[key] - if (value !== newResponse[key]) { - Object.defineProperty(newResponse, key, { - get: () => value, - enumerable: true, - configurable: true, - }) - } - } - - return newResponse -} From 3335da0458d43ce2805c3620b222809a46dfe51b Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sun, 24 Mar 2024 20:06:46 -0700 Subject: [PATCH 069/140] feat(fetch-dpop): add dpop fetch util --- packages/fetch-dpop/package.json | 32 ++++++ packages/fetch-dpop/src/index.ts | 160 ++++++++++++++++++++++++++++++ packages/fetch-dpop/tsconfig.json | 8 ++ tsconfig.json | 1 + 4 files changed, 201 insertions(+) create mode 100644 packages/fetch-dpop/package.json create mode 100644 packages/fetch-dpop/src/index.ts create mode 100644 packages/fetch-dpop/tsconfig.json diff --git a/packages/fetch-dpop/package.json b/packages/fetch-dpop/package.json new file mode 100644 index 00000000000..e9cc0ca6683 --- /dev/null +++ b/packages/fetch-dpop/package.json @@ -0,0 +1,32 @@ +{ + "name": "@atproto/fetch-dpop", + "version": "0.0.1", + "license": "MIT", + "description": "Utility to perform DPoP authenticated fetch requests", + "keywords": [ + "atproto", + "fetch", + "dpop" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/fetch-dpop" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@atproto/b64": "workspace:*", + "@atproto/caching": "workspace:*", + "@atproto/jwk": "workspace:*", + "@atproto/fetch": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/fetch-dpop/src/index.ts b/packages/fetch-dpop/src/index.ts new file mode 100644 index 00000000000..d94650c25d9 --- /dev/null +++ b/packages/fetch-dpop/src/index.ts @@ -0,0 +1,160 @@ +import { b64uEncode } from '@atproto/b64' +import { GenericStore } from '@atproto/caching' +import { Fetch } from '@atproto/fetch' +import { Key } from '@atproto/jwk' + +export function dpopFetchWrapper({ + key, + iss, + alg, + sha256 = typeof crypto !== 'undefined' && crypto.subtle != null + ? subtleSha256 + : undefined, + nonceCache, +}: { + key: Key + iss: string + alg?: string + sha256?: (input: string) => Promise + nonceCache?: GenericStore + fetch?: Fetch +}): Fetch { + if (!sha256) { + throw new Error( + `crypto.subtle is not available in this environment. Please provide a sha256 function.`, + ) + } + + return async function (request) { + return dpopFetch.call( + this, + request, + key, + iss, + alg, + sha256, + nonceCache, + fetch, + ) + } +} + +export async function dpopFetch( + this: ThisParameterType, + request: Request, + key: Key, + iss: string, + alg: string = key.alg || 'ES256', + sha256: (input: string) => string | PromiseLike = subtleSha256, + nonceCache?: GenericStore, + fetch = globalThis.fetch as Fetch, +): Promise { + const authorizationHeader = request.headers.get('Authorization') + const ath = authorizationHeader?.startsWith('DPoP ') + ? await sha256(authorizationHeader.slice(5)) + : undefined + + const { origin } = new URL(request.url) + + // Clone request for potential retry + const clonedRequest = request.clone() + + // Try with the previously known nonce + const oldNonce = await Promise.resolve() + .then(() => nonceCache?.get(origin)) + .catch(() => undefined) // Ignore cache.get errors + + request.headers.set( + 'DPoP', + await buildProof(key, alg, iss, request.method, request.url, oldNonce, ath), + ) + + const response = await fetch(request) + + const nonce = response.headers.get('DPoP-Nonce') + if (!nonce) return response + + // Store the fresh nonce for future requests + try { + await nonceCache?.set(origin, nonce) + } catch { + // Ignore cache.set errors + } + + if (!(await isUseDpopNonceError(response))) { + return response + } + + clonedRequest.headers.set( + 'DPoP', + await buildProof(key, alg, iss, request.method, request.url, nonce, ath), + ) + + return fetch(clonedRequest) +} + +async function buildProof( + key: Key, + alg: string, + iss: string, + htm: string, + htu: string, + nonce?: string, + ath?: string, +) { + if (!key.bareJwk) { + throw new Error('Only asymetric keys can be used as DPoP proofs') + } + + const now = Math.floor(Date.now() / 1e3) + + return key.createJwt( + { + alg, + typ: 'dpop+jwt', + jwk: key.bareJwk, + }, + { + iss, + iat: now, + exp: now + 10, + // Any collision will cause the request to be rejected by the server. no biggie. + jti: Math.random().toString(36).slice(2), + htm, + htu, + nonce, + ath, + }, + ) +} + +async function isUseDpopNonceError(response: Response): Promise { + if (response.status !== 400) { + return false + } + + const ct = response.headers.get('Content-Type') + const mime = ct?.split(';')[0]?.trim() + if (mime !== 'application/json') { + return false + } + + try { + const body = await response.clone().json() + return body?.error === 'use_dpop_nonce' + } catch { + return false + } +} + +function subtleSha256(input: string): Promise { + if (typeof crypto === 'undefined' || crypto.subtle == null) { + throw new Error( + `crypto.subtle is not available in this environment. Please provide a sha256 function.`, + ) + } + + return crypto.subtle + .digest('SHA-256', new TextEncoder().encode(input)) + .then((digest) => b64uEncode(new Uint8Array(digest))) +} diff --git a/packages/fetch-dpop/tsconfig.json b/packages/fetch-dpop/tsconfig.json new file mode 100644 index 00000000000..74b7e3462ca --- /dev/null +++ b/packages/fetch-dpop/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 7f3a76b7431..fcc6f3f1ff5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ { "path": "./packages/did" }, { "path": "./packages/did-node" }, { "path": "./packages/fetch" }, + { "path": "./packages/fetch-dpop" }, { "path": "./packages/fetch-node" }, { "path": "./packages/handle-resolver" }, { "path": "./packages/handle-resolver-node" }, From ecffdc334acc52090012d5c02369f95063f0fa13 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 28 Mar 2024 09:21:34 -0700 Subject: [PATCH 070/140] feat(identity-resolver): add identity resolver package --- packages/did/src/methods/plc.ts | 9 +- packages/identity-resolver/package.json | 32 +++ .../src/identity-resolver.ts | 35 +++ packages/identity-resolver/src/index.ts | 2 + .../src/universal-identity-resolver.ts | 51 +++++ packages/identity-resolver/tsconfig.json | 8 + pnpm-lock.yaml | 200 +++++++++++++++++- tsconfig.json | 1 + 8 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 packages/identity-resolver/package.json create mode 100644 packages/identity-resolver/src/identity-resolver.ts create mode 100644 packages/identity-resolver/src/index.ts create mode 100644 packages/identity-resolver/src/universal-identity-resolver.ts create mode 100644 packages/identity-resolver/tsconfig.json diff --git a/packages/did/src/methods/plc.ts b/packages/did/src/methods/plc.ts index 1533be24315..ace59beb551 100644 --- a/packages/did/src/methods/plc.ts +++ b/packages/did/src/methods/plc.ts @@ -47,8 +47,15 @@ const didWebDocumentTransformer = compose( ) export type DidPlcMethodOptions = { - plcDirectoryUrl?: string | URL + /** + * @default globalThis.fetch + */ fetch?: Fetch + + /** + * @default 'https://plc.directory/' + */ + plcDirectoryUrl?: string | URL } export class DidPlcMethod implements DidMethod<'plc'> { diff --git a/packages/identity-resolver/package.json b/packages/identity-resolver/package.json new file mode 100644 index 00000000000..cb44dba67d7 --- /dev/null +++ b/packages/identity-resolver/package.json @@ -0,0 +1,32 @@ +{ + "name": "@atproto/identity-resolver", + "version": "0.0.1", + "license": "MIT", + "description": "A library resolving atproto identities", + "keywords": [ + "atproto", + "identity", + "resolver" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/identity-resolver" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "dependencies": { + "@atproto/did": "workspace:*", + "@atproto/fetch": "workspace:*", + "@atproto/handle-resolver": "workspace:*", + "@atproto/syntax": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/identity-resolver/src/identity-resolver.ts b/packages/identity-resolver/src/identity-resolver.ts new file mode 100644 index 00000000000..88e592ff2be --- /dev/null +++ b/packages/identity-resolver/src/identity-resolver.ts @@ -0,0 +1,35 @@ +import { DidResolver } from '@atproto/did' +import { + HandleResolver, + ResolvedHandle, + isResolvedHandle, +} from '@atproto/handle-resolver' +import { normalizeAndEnsureValidHandle } from '@atproto/syntax' + +export type ResolvedIdentity = { + did: NonNullable + url: URL +} + +export class IdentityResolver { + constructor( + readonly handleResolver: HandleResolver, + readonly didResolver: DidResolver<'plc' | 'web'>, + ) {} + + public async resolve( + input: string, + serviceType = 'AtprotoPersonalDataServer', + ): Promise { + const did = isResolvedHandle(input) + ? input // Already a did + : await this.handleResolver.resolve(normalizeAndEnsureValidHandle(input)) + if (!did) throw new Error(`Handle ${input} does not resolve to a DID`) + + const url = await this.didResolver.resolveServiceEndpoint(did, { + type: serviceType, + }) + + return { did, url } + } +} diff --git a/packages/identity-resolver/src/index.ts b/packages/identity-resolver/src/index.ts new file mode 100644 index 00000000000..57008b96e4d --- /dev/null +++ b/packages/identity-resolver/src/index.ts @@ -0,0 +1,2 @@ +export * from './identity-resolver.js' +export * from './universal-identity-resolver.js' diff --git a/packages/identity-resolver/src/universal-identity-resolver.ts b/packages/identity-resolver/src/universal-identity-resolver.ts new file mode 100644 index 00000000000..c205bce433d --- /dev/null +++ b/packages/identity-resolver/src/universal-identity-resolver.ts @@ -0,0 +1,51 @@ +import { + DidCache, + IsomorphicDidResolver, + IsomorphicDidResolverOptions, +} from '@atproto/did' +import { Fetch } from '@atproto/fetch' +import UniversalHandleResolver, { + HandleResolverCache, + UniversalHandleResolverOptions, +} from '@atproto/handle-resolver' +import { IdentityResolver } from './identity-resolver.js' + +export type UniversalIdentityResolverOptions = { + fetch?: Fetch + + didCache?: DidCache + handleCache?: HandleResolverCache + + /** + * @see {@link IsomorphicDidResolverOptions.plcDirectoryUrl} + */ + plcDirectoryUrl?: IsomorphicDidResolverOptions['plcDirectoryUrl'] + + /** + * @see {@link UniversalHandleResolverOptions.atprotoLexiconUrl} + */ + atprotoLexiconUrl?: UniversalHandleResolverOptions['atprotoLexiconUrl'] +} + +export class UniversalIdentityResolver extends IdentityResolver { + static from({ + fetch = globalThis.fetch, + didCache, + handleCache, + plcDirectoryUrl, + atprotoLexiconUrl, + }: UniversalIdentityResolverOptions) { + return new this( + new UniversalHandleResolver({ + fetch, + cache: handleCache, + atprotoLexiconUrl, + }), + new IsomorphicDidResolver({ + fetch, // + cache: didCache, + plcDirectoryUrl, + }), + ) + } +} diff --git a/packages/identity-resolver/tsconfig.json b/packages/identity-resolver/tsconfig.json new file mode 100644 index 00000000000..74b7e3462ca --- /dev/null +++ b/packages/identity-resolver/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1954d5d9fe..65a8a75415a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,16 @@ importers: specifier: 3.0.0 version: 3.0.0 + packages/b64: + dependencies: + base64-js: + specifier: ^1.5.1 + version: 1.5.1 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/bsky: dependencies: '@atproto/api': @@ -327,6 +337,12 @@ importers: specifier: ^10.8.2 version: 10.8.2(@swc/core@1.3.42)(@types/node@18.19.24)(typescript@5.4.4) + packages/caching: + dependencies: + lru-cache: + specifier: ^10.2.0 + version: 10.2.0 + packages/common: dependencies: '@atproto/common-web': @@ -456,6 +472,33 @@ importers: specifier: 3.0.0 version: 3.0.0 + packages/did: + dependencies: + '@atproto/caching': + specifier: workspace:* + version: link:../caching + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/transformer': + specifier: workspace:* + version: link:../transformer + zod: + specifier: ^3.22.4 + version: 3.22.4 + + packages/did-node: + dependencies: + '@atproto/did': + specifier: workspace:* + version: link:../did + '@atproto/fetch-node': + specifier: workspace:* + version: link:../fetch-node + packages/fetch: dependencies: '@atproto/transformer': @@ -468,9 +511,25 @@ importers: specifier: ^3.22.4 version: 3.22.4 devDependencies: - '@types/http-errors': - specifier: ^2.0.4 - version: 2.0.4 + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/fetch-dpop: + dependencies: + '@atproto/b64': + specifier: workspace:* + version: link:../b64 + '@atproto/caching': + specifier: workspace:* + version: link:../caching + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + devDependencies: typescript: specifier: ^5.3.3 version: 5.4.4 @@ -494,6 +553,47 @@ importers: specifier: ^5.3.3 version: 5.4.4 + packages/handle-resolver: + dependencies: + '@atproto/caching': + specifier: workspace:* + version: link:../caching + '@atproto/did': + specifier: workspace:* + version: link:../did + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + lru-cache: + specifier: ^10.2.0 + version: 10.2.0 + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/handle-resolver-node: + dependencies: + '@atproto/did': + specifier: workspace:* + version: link:../did + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/fetch-node': + specifier: workspace:* + version: link:../fetch-node + '@atproto/handle-resolver': + specifier: workspace:* + version: link:../handle-resolver + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/html: dependencies: tslib: @@ -566,11 +666,30 @@ importers: specifier: ^28.1.2 version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) + packages/identity-resolver: + dependencies: + '@atproto/did': + specifier: workspace:* + version: link:../did + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/handle-resolver': + specifier: workspace:* + version: link:../handle-resolver + '@atproto/syntax': + specifier: workspace:* + version: link:../syntax + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/jwk: dependencies: - jose: - specifier: ^5.2.2 - version: 5.2.4 + '@atproto/b64': + specifier: workspace:* + version: link:../b64 tslib: specifier: ^2.6.2 version: 2.6.2 @@ -582,7 +701,7 @@ importers: specifier: ^5.3.3 version: 5.4.4 - packages/jwk-node: + packages/jwk-jose: dependencies: '@atproto/jwk': specifier: workspace:* @@ -598,6 +717,22 @@ importers: specifier: ^5.3.3 version: 5.4.4 + packages/jwk-node: + dependencies: + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/jwk-jose': + specifier: workspace:* + version: link:../jwk-jose + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/lex-cli: dependencies: '@atproto/lexicon': @@ -647,6 +782,19 @@ importers: specifier: ^28.1.2 version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) + packages/oauth-client-metadata: + dependencies: + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/oauth-provider: dependencies: '@atproto/fetch': @@ -667,6 +815,12 @@ importers: '@atproto/jwk-node': specifier: workspace:* version: link:../jwk-node + '@atproto/oauth-client-metadata': + specifier: workspace:* + version: link:../oauth-client-metadata + '@atproto/oauth-server-metadata': + specifier: workspace:* + version: link:../oauth-server-metadata cookie: specifier: ^0.6.0 version: 0.6.0 @@ -762,6 +916,9 @@ importers: packages/oauth-provider-client-uri: dependencies: + '@atproto/caching': + specifier: workspace:* + version: link:../caching '@atproto/fetch': specifier: workspace:* version: link:../fetch @@ -806,6 +963,35 @@ importers: specifier: ^2.6.2 version: 2.6.2 + packages/oauth-server-metadata: + dependencies: + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/oauth-server-metadata-resolver: + dependencies: + '@atproto/caching': + specifier: workspace:* + version: link:../caching + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/oauth-server-metadata': + specifier: workspace:* + version: link:../oauth-server-metadata + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/ozone: dependencies: '@atproto/api': diff --git a/tsconfig.json b/tsconfig.json index fcc6f3f1ff5..79626e3a471 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,7 @@ { "path": "./packages/html" }, { "path": "./packages/http-util" }, { "path": "./packages/identity" }, + { "path": "./packages/identity-resolver" }, { "path": "./packages/jwk" }, { "path": "./packages/jwk-jose" }, { "path": "./packages/jwk-node" }, From 9dda1004be1c6897bf714c531a7028c78497e356 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 28 Mar 2024 09:59:05 -0700 Subject: [PATCH 071/140] fix(oauth-provider): limit dpop validity time --- .../oauth-provider/src/dpop/dpop-manager.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/oauth-provider/src/dpop/dpop-manager.ts b/packages/oauth-provider/src/dpop/dpop-manager.ts index d8e42105bd0..3c50cda6448 100644 --- a/packages/oauth-provider/src/dpop/dpop-manager.ts +++ b/packages/oauth-provider/src/dpop/dpop-manager.ts @@ -5,6 +5,8 @@ import { EmbeddedJWK, calculateJwkThumbprint, jwtVerify } from 'jose' import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' import { UseDpopNonceError } from '../errors/use-dpop-nonce-error.js' import { DpopNonce, DpopNonceInput } from './dpop-nonce.js' +import { DPOP_NONCE_MAX_AGE } from '../constants.js' +import { JOSEError } from 'jose/errors' export { DpopNonce, type DpopNonceInput } export type DpopManagerOptions = { @@ -49,19 +51,29 @@ export class DpopManager { const { protectedHeader, payload } = await jwtVerify<{ iat: number + exp: number jti: string }>(proof, EmbeddedJWK, { typ: 'dpop+jwt', - maxTokenAge: 300, - requiredClaims: ['iat', 'jti'], + maxTokenAge: 10, + clockTolerance: DPOP_NONCE_MAX_AGE / 1e3, + requiredClaims: ['iat', 'exp', 'jti'], }).catch((err) => { - throw new InvalidDpopProofError('DPoP key mismatch', err) + const message = + err instanceof JOSEError + ? `Invalid DPoP proof (${err.message})` + : 'Invalid DPoP proof' + throw new InvalidDpopProofError(message, err) }) if (!payload.jti || typeof payload.jti !== 'string') { throw new InvalidDpopProofError('Invalid or missing jti property') } + if (payload.exp - payload.iat > DPOP_NONCE_MAX_AGE / 3 / 1e3) { + throw new InvalidDpopProofError('DPoP proof validity too long') + } + // Note rfc9110#section-9.1 states that the method name is case-sensitive if (!htm || htm !== payload['htm']) { throw new InvalidDpopProofError('DPoP htm mismatch') From 0c9a98074961a5676bdb434494f4ebbf82ba8b50 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 3 Apr 2024 21:45:23 +0200 Subject: [PATCH 072/140] feat(disposable-polyfill): init --- packages/disposable-polyfill/package.json | 34 ++++++++++++++++++++++ packages/disposable-polyfill/src/index.ts | 10 +++++++ packages/disposable-polyfill/tsconfig.json | 8 +++++ tsconfig.json | 1 + 4 files changed, 53 insertions(+) create mode 100644 packages/disposable-polyfill/package.json create mode 100644 packages/disposable-polyfill/src/index.ts create mode 100644 packages/disposable-polyfill/tsconfig.json diff --git a/packages/disposable-polyfill/package.json b/packages/disposable-polyfill/package.json new file mode 100644 index 00000000000..11bc2752e0d --- /dev/null +++ b/packages/disposable-polyfill/package.json @@ -0,0 +1,34 @@ +{ + "name": "@atproto/disposable-polyfill", + "version": "0.0.1", + "license": "MIT", + "keywords": [ + "atproto", + "disposable", + "polyfill" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/disposable-polyfill" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "tslib": "^2.6.2" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/disposable-polyfill/src/index.ts b/packages/disposable-polyfill/src/index.ts new file mode 100644 index 00000000000..ddb9073b163 --- /dev/null +++ b/packages/disposable-polyfill/src/index.ts @@ -0,0 +1,10 @@ +// Code compiled with tsc supports "using" and "await using" syntax. This +// features is supported by downleveling the code to ES2017. The downleveling +// relies on `Symbol.dispose` and `Symbol.asyncDispose` symbols. These symbols +// might not be available in all environments. This package provides a polyfill +// for these symbols. + +// @ts-expect-error +Symbol.dispose ??= Symbol('@@dispose') +// @ts-expect-error +Symbol.asyncDispose ??= Symbol('@@asyncDispose') diff --git a/packages/disposable-polyfill/tsconfig.json b/packages/disposable-polyfill/tsconfig.json new file mode 100644 index 00000000000..74b7e3462ca --- /dev/null +++ b/packages/disposable-polyfill/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 79626e3a471..5d4848a658c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ { "path": "./packages/dev-env" }, { "path": "./packages/did" }, { "path": "./packages/did-node" }, + { "path": "./packages/disposable-polyfill" }, { "path": "./packages/fetch" }, { "path": "./packages/fetch-dpop" }, { "path": "./packages/fetch-node" }, From 455c026090968f01fc4efb5a27f5efdee1ce0f25 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 3 Apr 2024 21:45:48 +0200 Subject: [PATCH 073/140] feat(jwk-webcrypto): init --- packages/jwk-webcrypto/package.json | 31 +++++ packages/jwk-webcrypto/src/db.ts | 42 ++++++ packages/jwk-webcrypto/src/index.ts | 1 + packages/jwk-webcrypto/src/util.ts | 142 ++++++++++++++++++++ packages/jwk-webcrypto/src/webcrypto-key.ts | 75 +++++++++++ packages/jwk-webcrypto/tsconfig.build.json | 8 ++ packages/jwk-webcrypto/tsconfig.json | 4 + tsconfig.json | 1 + 8 files changed, 304 insertions(+) create mode 100644 packages/jwk-webcrypto/package.json create mode 100644 packages/jwk-webcrypto/src/db.ts create mode 100644 packages/jwk-webcrypto/src/index.ts create mode 100644 packages/jwk-webcrypto/src/util.ts create mode 100644 packages/jwk-webcrypto/src/webcrypto-key.ts create mode 100644 packages/jwk-webcrypto/tsconfig.build.json create mode 100644 packages/jwk-webcrypto/tsconfig.json diff --git a/packages/jwk-webcrypto/package.json b/packages/jwk-webcrypto/package.json new file mode 100644 index 00000000000..b91abd7046d --- /dev/null +++ b/packages/jwk-webcrypto/package.json @@ -0,0 +1,31 @@ +{ + "name": "@atproto/jwk-webcrypto", + "version": "0.0.1", + "license": "MIT", + "description": "Webcrypto based implementation of Keypair from @atproto/jwk", + "keywords": [ + "atproto", + "jwk", + "webcrypto", + "keypair" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/jwk-webcrypto" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/indexed-db": "workspace:*", + "@atproto/jwk": "workspace:*", + "@atproto/jwk-jose": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/jwk-webcrypto/src/db.ts b/packages/jwk-webcrypto/src/db.ts new file mode 100644 index 00000000000..4a59e6315b7 --- /dev/null +++ b/packages/jwk-webcrypto/src/db.ts @@ -0,0 +1,42 @@ +import { DB } from '@atproto/indexed-db' +import { fromSubtleAlgorithm, generateKeypair } from './util.js' + +const INDEXED_DB_NAME = '@@jwk-webcrypto' + +export async function loadCryptoKeyPair( + kid: string, + algs: string[], + extractable = false, +): Promise { + type Schema = { + 'oauth-keypair': CryptoKeyPair + } + + const migrations = [ + (db: IDBDatabase) => { + db.createObjectStore('oauth-keypair') + }, + ] + + // eslint-disable-next-line + await using db = await DB.open(INDEXED_DB_NAME, migrations) + + const current = await db.transaction(['oauth-keypair'], 'readonly', (tx) => + tx.objectStore('oauth-keypair').get(kid), + ) + + try { + const alg = fromSubtleAlgorithm(current.privateKey.algorithm) + if (algs.includes(alg) && current.privateKey.extractable === extractable) { + return current + } else if (current) { + throw new Error('Store contained invalid keypair') + } + } catch { + await db.transaction(['oauth-keypair'], 'readwrite', (tx) => + tx.objectStore('oauth-keypair').delete(kid), + ) + } + + return generateKeypair(algs, extractable) +} diff --git a/packages/jwk-webcrypto/src/index.ts b/packages/jwk-webcrypto/src/index.ts new file mode 100644 index 00000000000..fd2837bd208 --- /dev/null +++ b/packages/jwk-webcrypto/src/index.ts @@ -0,0 +1 @@ +export * from './webcrypto-key.js' diff --git a/packages/jwk-webcrypto/src/util.ts b/packages/jwk-webcrypto/src/util.ts new file mode 100644 index 00000000000..fc7c3a3f900 --- /dev/null +++ b/packages/jwk-webcrypto/src/util.ts @@ -0,0 +1,142 @@ +export type JWSAlgorithm = + // HMAC + | 'HS256' + | 'HS384' + | 'HS512' + // RSA + | 'PS256' + | 'PS384' + | 'PS512' + | 'RS256' + | 'RS384' + | 'RS512' + // EC + | 'ES256' + | 'ES256K' + | 'ES384' + | 'ES512' + // OKP + | 'EdDSA' + +export type SubtleAlgorithm = RsaHashedKeyGenParams | EcKeyGenParams + +export function toSubtleAlgorithm( + alg: string, + crv?: string, + options?: { modulusLength?: number }, +): SubtleAlgorithm { + switch (alg) { + case 'PS256': + case 'PS384': + case 'PS512': + return { + name: 'RSA-PSS', + hash: `SHA-${alg.slice(-3) as '256' | '384' | '512'}`, + modulusLength: options?.modulusLength ?? 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + } + case 'RS256': + case 'RS384': + case 'RS512': + return { + name: 'RSASSA-PKCS1-v1_5', + hash: `SHA-${alg.slice(-3) as '256' | '384' | '512'}`, + modulusLength: options?.modulusLength ?? 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + } + case 'ES256': + case 'ES384': + return { + name: 'ECDSA', + namedCurve: `P-${alg.slice(-3) as '256' | '384'}`, + } + case 'ES512': + return { + name: 'ECDSA', + namedCurve: 'P-521', + } + default: + // https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773 + + throw new TypeError(`Unsupported alg "${alg}"`) + } +} + +export function fromSubtleAlgorithm(algorithm: KeyAlgorithm): JWSAlgorithm { + switch (algorithm.name) { + case 'RSA-PSS': + case 'RSASSA-PKCS1-v1_5': { + const hash = (algorithm).hash.name + switch (hash) { + case 'SHA-256': + case 'SHA-384': + case 'SHA-512': { + const prefix = algorithm.name === 'RSA-PSS' ? 'PS' : 'RS' + return `${prefix}${hash.slice(-3) as '256' | '384' | '512'}` + } + default: + throw new TypeError('unsupported RsaHashedKeyAlgorithm hash') + } + } + case 'ECDSA': { + const namedCurve = (algorithm).namedCurve + switch (namedCurve) { + case 'P-256': + case 'P-384': + case 'P-512': + return `ES${namedCurve.slice(-3) as '256' | '384' | '512'}` + case 'P-521': + return 'ES512' + default: + throw new TypeError('unsupported EcKeyAlgorithm namedCurve') + } + } + case 'Ed448': + case 'Ed25519': + return 'EdDSA' + default: + // https://github.com/w3c/webcrypto/issues/82#issuecomment-849856773 + + throw new TypeError(`Unexpected algorithm "${algorithm.name}"`) + } +} + +export function isSignatureKeyPair( + v: unknown, + extractable?: boolean, +): v is CryptoKeyPair { + return ( + typeof v === 'object' && + v !== null && + 'privateKey' in v && + v.privateKey instanceof CryptoKey && + v.privateKey.type === 'private' && + (extractable == null || v.privateKey.extractable === extractable) && + v.privateKey.usages.includes('sign') && + 'publicKey' in v && + v.publicKey instanceof CryptoKey && + v.publicKey.type === 'public' && + v.publicKey.extractable === true && + v.publicKey.usages.includes('verify') + ) +} + +export async function generateKeypair( + algs: string[], + extractable = false, +): Promise { + const errors: unknown[] = [] + for (const alg of algs) { + try { + return await crypto.subtle.generateKey( + toSubtleAlgorithm(alg), + extractable, + ['sign', 'verify'], + ) + } catch (err) { + errors.push(err) + } + } + + throw new AggregateError(errors, 'Failed to generate keypair') +} diff --git a/packages/jwk-webcrypto/src/webcrypto-key.ts b/packages/jwk-webcrypto/src/webcrypto-key.ts new file mode 100644 index 00000000000..9731050737e --- /dev/null +++ b/packages/jwk-webcrypto/src/webcrypto-key.ts @@ -0,0 +1,75 @@ +import { Jwk, jwkSchema } from '@atproto/jwk' +import { JoseKey } from '@atproto/jwk-jose' +// XXX TODO: remove "./db.ts" file +// import { loadCryptoKeyPair } from './db.js' +import { + generateKeypair, + fromSubtleAlgorithm, + isSignatureKeyPair, +} from './util.js' + +export class WebcryptoKey extends JoseKey { + // static async fromIndexedDB(kid: string, allowedAlgos: string[] = ['ES384']) { + // const cryptoKeyPair = await loadCryptoKeyPair(kid, allowedAlgos) + // return this.fromKeypair(kid, cryptoKeyPair) + // } + + static async generate( + kid: string = crypto.randomUUID(), + allowedAlgos: string[] = ['ES384'], + exportable = false, + ) { + const cryptoKeyPair = await generateKeypair(allowedAlgos, exportable) + return this.fromKeypair(kid, cryptoKeyPair) + } + + static async fromKeypair(kid: string, cryptoKeyPair: CryptoKeyPair) { + if (!isSignatureKeyPair(cryptoKeyPair)) { + throw new TypeError('CryptoKeyPair must be compatible with sign/verify') + } + + // https://datatracker.ietf.org/doc/html/rfc7517 + // > The "use" and "key_ops" JWK members SHOULD NOT be used together; [...] + // > Applications should specify which of these members they use. + + const { key_ops: _, ...jwk } = await crypto.subtle.exportKey( + 'jwk', + cryptoKeyPair.privateKey.extractable + ? cryptoKeyPair.privateKey + : cryptoKeyPair.publicKey, + ) + + const use = jwk.use ?? 'sig' + const alg = + jwk.alg ?? fromSubtleAlgorithm(cryptoKeyPair.privateKey.algorithm) + + if (use !== 'sig') { + throw new TypeError('Unsupported JWK use') + } + + return new WebcryptoKey( + jwkSchema.parse({ ...jwk, use, kid, alg }), + cryptoKeyPair, + ) + } + + constructor( + jwk: Jwk, + readonly cryptoKeyPair: CryptoKeyPair, + ) { + super(jwk) + } + + get isPrivate() { + return true + } + + get privateJwk(): Jwk | undefined { + if (super.isPrivate) return this.jwk + throw new Error('Private Webcrypto Key not exportable') + } + + protected async getKey() { + return this.cryptoKeyPair.privateKey + } +} diff --git a/packages/jwk-webcrypto/tsconfig.build.json b/packages/jwk-webcrypto/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/jwk-webcrypto/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/jwk-webcrypto/tsconfig.json b/packages/jwk-webcrypto/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/jwk-webcrypto/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/tsconfig.json b/tsconfig.json index 5d4848a658c..67c42563551 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,7 @@ { "path": "./packages/jwk" }, { "path": "./packages/jwk-jose" }, { "path": "./packages/jwk-node" }, + { "path": "./packages/jwk-webcrypto" }, { "path": "./packages/lex-cli" }, { "path": "./packages/lexicon" }, { "path": "./packages/oauth-client-metadata" }, From 087fa2f9460f15722c23a632936cf9849ff466fd Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 3 Apr 2024 21:46:10 +0200 Subject: [PATCH 074/140] feat(indexed-db): init --- packages/indexed-db/package.json | 35 +++++++ packages/indexed-db/src/db-index.ts | 44 ++++++++ packages/indexed-db/src/db-object-store.ts | 47 +++++++++ packages/indexed-db/src/db-transaction.ts | 52 ++++++++++ packages/indexed-db/src/db.ts | 114 +++++++++++++++++++++ packages/indexed-db/src/index.ts | 6 ++ packages/indexed-db/src/schema.ts | 2 + packages/indexed-db/src/util.ts | 20 ++++ packages/indexed-db/tsconfig.json | 8 ++ tsconfig.json | 1 + 10 files changed, 329 insertions(+) create mode 100644 packages/indexed-db/package.json create mode 100644 packages/indexed-db/src/db-index.ts create mode 100644 packages/indexed-db/src/db-object-store.ts create mode 100644 packages/indexed-db/src/db-transaction.ts create mode 100644 packages/indexed-db/src/db.ts create mode 100644 packages/indexed-db/src/index.ts create mode 100644 packages/indexed-db/src/schema.ts create mode 100644 packages/indexed-db/src/util.ts create mode 100644 packages/indexed-db/tsconfig.json diff --git a/packages/indexed-db/package.json b/packages/indexed-db/package.json new file mode 100644 index 00000000000..f5383ea0523 --- /dev/null +++ b/packages/indexed-db/package.json @@ -0,0 +1,35 @@ +{ + "name": "@atproto/indexed-db", + "version": "0.0.1", + "license": "MIT", + "description": "Isomorphic wrapper utilities for indexed-db API", + "keywords": [ + "atproto", + "indexed-db" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/indexed-db" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "default": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "dependencies": { + "@atproto/disposable-polyfill": "workspace:*", + "tslib": "^2.6.2" + }, + "devDependencies": { + "typescript": "^5.3.3" + }, + "scripts": { + "build": "tsc --build tsconfig.json" + } +} diff --git a/packages/indexed-db/src/db-index.ts b/packages/indexed-db/src/db-index.ts new file mode 100644 index 00000000000..dc041f024b5 --- /dev/null +++ b/packages/indexed-db/src/db-index.ts @@ -0,0 +1,44 @@ +import { ObjectStoreSchema } from './schema.js' +import { promisify } from './util.js' + +export class DBIndex { + constructor(private idbIndex: IDBIndex) {} + + count(query?: IDBValidKey | IDBKeyRange) { + return promisify(this.idbIndex.count(query)) + } + + get(query: IDBValidKey | IDBKeyRange) { + return promisify(this.idbIndex.get(query)) + } + + getKey(query: IDBValidKey | IDBKeyRange) { + return promisify(this.idbIndex.getKey(query)) + } + + getAll(query?: IDBValidKey | IDBKeyRange | null, count?: number) { + return promisify(this.idbIndex.getAll(query, count)) + } + + getAllKeys(query?: IDBValidKey | IDBKeyRange | null, count?: number) { + return promisify(this.idbIndex.getAllKeys(query, count)) + } + + deleteAll(query?: IDBValidKey | IDBKeyRange | null): Promise { + return new Promise((resolve, reject) => { + const result = this.idbIndex.openCursor(query) + result.onsuccess = function (event) { + const cursor = (event as any).target.result as IDBCursorWithValue + if (cursor) { + cursor.delete() + cursor.continue() + } else { + resolve() + } + } + result.onerror = function (event) { + reject((event.target as any)?.error || new Error('Unexpected error')) + } + }) + } +} diff --git a/packages/indexed-db/src/db-object-store.ts b/packages/indexed-db/src/db-object-store.ts new file mode 100644 index 00000000000..9b15fcad6a9 --- /dev/null +++ b/packages/indexed-db/src/db-object-store.ts @@ -0,0 +1,47 @@ +import { DBIndex } from './db-index.js' +import { ObjectStoreSchema } from './schema.js' +import { promisify } from './util.js' + +export class DBObjectStore { + constructor(private idbObjStore: IDBObjectStore) {} + + get name() { + return this.idbObjStore.name + } + + index(name: string) { + return new DBIndex(this.idbObjStore.index(name)) + } + + get(key: IDBValidKey | IDBKeyRange) { + return promisify(this.idbObjStore.get(key)) + } + + getKey(query: IDBValidKey | IDBKeyRange) { + return promisify(this.idbObjStore.getKey(query)) + } + + getAll(query?: IDBValidKey | IDBKeyRange | null, count?: number) { + return promisify(this.idbObjStore.getAll(query, count)) + } + + getAllKeys(query?: IDBValidKey | IDBKeyRange | null, count?: number) { + return promisify(this.idbObjStore.getAllKeys(query, count)) + } + + add(value: Schema, key?: IDBValidKey) { + return promisify(this.idbObjStore.add(value, key)) + } + + put(value: Schema, key?: IDBValidKey) { + return promisify(this.idbObjStore.put(value, key)) + } + + delete(key: IDBValidKey | IDBKeyRange) { + return promisify(this.idbObjStore.delete(key)) + } + + clear() { + return promisify(this.idbObjStore.clear()) + } +} diff --git a/packages/indexed-db/src/db-transaction.ts b/packages/indexed-db/src/db-transaction.ts new file mode 100644 index 00000000000..79905e796e8 --- /dev/null +++ b/packages/indexed-db/src/db-transaction.ts @@ -0,0 +1,52 @@ +import { DBObjectStore } from './db-object-store.js' +import { DatabaseSchema } from './schema.js' + +export class DBTransaction + implements Disposable +{ + #tx: IDBTransaction | null + + constructor(tx: IDBTransaction) { + this.#tx = tx + + const onAbort = () => { + cleanup() + } + const onComplete = () => { + cleanup() + } + const cleanup = () => { + this.#tx = null + tx.removeEventListener('abort', onAbort) + tx.removeEventListener('complete', onComplete) + } + tx.addEventListener('abort', onAbort) + tx.addEventListener('complete', onComplete) + } + + protected get tx(): IDBTransaction { + if (!this.#tx) throw new Error('Transaction already ended') + return this.#tx + } + + async abort() { + const { tx } = this + this.#tx = null + tx.abort() + } + + async commit() { + const { tx } = this + this.#tx = null + tx.commit?.() + } + + objectStore(name: T) { + const store = this.tx.objectStore(name) + return new DBObjectStore(store) + } + + [Symbol.dispose](): void { + if (this.#tx) this.commit() + } +} diff --git a/packages/indexed-db/src/db.ts b/packages/indexed-db/src/db.ts new file mode 100644 index 00000000000..03ffe61d8c2 --- /dev/null +++ b/packages/indexed-db/src/db.ts @@ -0,0 +1,114 @@ +import { DatabaseSchema } from './schema.js' +import { DBTransaction } from './db-transaction.js' + +export class DB implements Disposable { + static async open( + dbName: string, + migrations: ReadonlyArray<(db: IDBDatabase) => void>, + txOptions?: IDBTransactionOptions, + ) { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, migrations.length) + + request.onerror = () => reject(request.error) + request.onsuccess = () => resolve(request.result) + request.onupgradeneeded = ({ oldVersion, newVersion }) => { + const db = request.result + try { + for ( + let version = oldVersion; + version < (newVersion ?? migrations.length); + ++version + ) { + const migration = migrations[version] + if (migration) migration(db) + else throw new Error(`Missing migration for version ${version}`) + } + } catch (err) { + db.close() + reject(err) + } + } + }) + + return new DB(db, txOptions) + } + + #db: null | IDBDatabase + + constructor( + db: IDBDatabase, + protected readonly txOptions?: IDBTransactionOptions, + ) { + this.#db = db + + const cleanup = () => { + this.#db = null + db.removeEventListener('versionchange', cleanup) + db.removeEventListener('close', cleanup) + db.close() // Can we call close on a "closed" database? + } + + db.addEventListener('versionchange', cleanup) + db.addEventListener('close', cleanup) + } + + protected get db(): IDBDatabase { + if (!this.#db) throw new Error('Database closed') + return this.#db + } + + get name() { + return this.db.name + } + + get objectStoreNames() { + return this.db.objectStoreNames + } + + get version() { + return this.db.version + } + + async transaction( + storeNames: T, + mode: IDBTransactionMode, + run: (tx: DBTransaction>) => R | PromiseLike, + ): Promise { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + try { + const tx = this.db.transaction(storeNames, mode, this.txOptions) + let result: { done: false } | { done: true; value: R } = { done: false } + + tx.oncomplete = () => { + if (result.done) resolve(result.value) + else reject(new Error('Transaction completed without result')) + } + tx.onerror = () => reject(tx.error) + tx.onabort = () => reject(tx.error || new Error('Transaction aborted')) + + try { + const value = await run(new DBTransaction(tx)) + result = { done: true, value } + tx.commit() + } catch (err) { + tx.abort() + throw err + } + } catch (err) { + reject(err) + } + }) + } + + close() { + const { db } = this + this.#db = null + db.close() + } + + [Symbol.dispose]() { + if (this.#db) return this.close() + } +} diff --git a/packages/indexed-db/src/index.ts b/packages/indexed-db/src/index.ts new file mode 100644 index 00000000000..525e98617eb --- /dev/null +++ b/packages/indexed-db/src/index.ts @@ -0,0 +1,6 @@ +import '@atproto/disposable-polyfill' + +export * from './db.js' +export * from './db-index.js' +export * from './db-object-store.js' +export * from './db-transaction.js' diff --git a/packages/indexed-db/src/schema.ts b/packages/indexed-db/src/schema.ts new file mode 100644 index 00000000000..f8736b2a19d --- /dev/null +++ b/packages/indexed-db/src/schema.ts @@ -0,0 +1,2 @@ +export type ObjectStoreSchema = NonNullable +export type DatabaseSchema = Record diff --git a/packages/indexed-db/src/util.ts b/packages/indexed-db/src/util.ts new file mode 100644 index 00000000000..6e52b5919c4 --- /dev/null +++ b/packages/indexed-db/src/util.ts @@ -0,0 +1,20 @@ +export function promisify(request: IDBRequest) { + const promise = new Promise((resolve, reject) => { + const cleanup = () => { + request.removeEventListener('success', success) + request.removeEventListener('error', error) + } + const success = () => { + resolve(request.result) + cleanup() + } + const error = () => { + reject(request.error) + cleanup() + } + request.addEventListener('success', success) + request.addEventListener('error', error) + }) + + return promise +} diff --git a/packages/indexed-db/tsconfig.json b/packages/indexed-db/tsconfig.json new file mode 100644 index 00000000000..9e02c4e952f --- /dev/null +++ b/packages/indexed-db/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/browser.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 67c42563551..a046a1f382b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ { "path": "./packages/http-util" }, { "path": "./packages/identity" }, { "path": "./packages/identity-resolver" }, + { "path": "./packages/indexed-db" }, { "path": "./packages/jwk" }, { "path": "./packages/jwk-jose" }, { "path": "./packages/jwk-node" }, From d3862ed74c034ee1d6b914d77119aa0a8877e0b2 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 3 Apr 2024 21:54:31 +0200 Subject: [PATCH 075/140] style(oauth-provider): lint --- packages/oauth-provider/src/oidc/claims.ts | 2 +- packages/oauth-provider/src/output/customization.ts | 2 +- packages/oauth-provider/src/output/send-web-page.ts | 2 +- packages/oauth-provider/src/signer/signer.ts | 5 ++++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/oauth-provider/src/oidc/claims.ts b/packages/oauth-provider/src/oidc/claims.ts index 3b31a9bf048..cbcbfe2dc8d 100644 --- a/packages/oauth-provider/src/oidc/claims.ts +++ b/packages/oauth-provider/src/oidc/claims.ts @@ -29,7 +29,7 @@ export const OIDC_STANDARD_CLAIMS = Object.freeze( Object.values(OIDC_SCOPE_CLAIMS).flat(), ) -export type OIDCStandardClaim = typeof OIDC_STANDARD_CLAIMS[number] +export type OIDCStandardClaim = (typeof OIDC_STANDARD_CLAIMS)[number] export type OIDCStandardPayload = Partial<{ [K in OIDCStandardClaim]?: JwtPayload[K] }> diff --git a/packages/oauth-provider/src/output/customization.ts b/packages/oauth-provider/src/output/customization.ts index 56c1ca806a4..c69ee81aa1a 100644 --- a/packages/oauth-provider/src/output/customization.ts +++ b/packages/oauth-provider/src/output/customization.ts @@ -1,6 +1,6 @@ // Matches colors defined in tailwind.config.js const colorNames = ['primary', 'error'] as const -type ColorName = typeof colorNames[number] +type ColorName = (typeof colorNames)[number] const isColorName = (name: string): name is ColorName => (colorNames as readonly string[]).includes(name) diff --git a/packages/oauth-provider/src/output/send-web-page.ts b/packages/oauth-provider/src/output/send-web-page.ts index 781ae9a2d31..e1822cfb7de 100644 --- a/packages/oauth-provider/src/output/send-web-page.ts +++ b/packages/oauth-provider/src/output/send-web-page.ts @@ -35,7 +35,7 @@ export function buildWebPage({ } return html` - + diff --git a/packages/oauth-provider/src/signer/signer.ts b/packages/oauth-provider/src/signer/signer.ts index 858f7ce081f..47467326c49 100644 --- a/packages/oauth-provider/src/signer/signer.ts +++ b/packages/oauth-provider/src/signer/signer.ts @@ -28,7 +28,10 @@ import { export type SignPayload = Omit export class Signer { - constructor(public readonly issuer: string, public readonly keyset: Keyset) {} + constructor( + public readonly issuer: string, + public readonly keyset: Keyset, + ) {} async verify

= JwtPayload>( token: Jwt, From 49afd67c4d0ee2917785e85d96646c71c597a321 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 28 Mar 2024 09:59:50 -0700 Subject: [PATCH 076/140] feat(oauth-client): oauth client --- .../oauth-client-browser-example/index.html | 12 + .../oauth-client-browser-example/package.json | 43 ++ .../postcss.config.js | 6 + .../public/.well-known/oauth-client-metadata | 1 + .../oauth-client-browser-example/src/app.tsx | 91 +++ .../src/index.css | 3 + .../src/login-form.tsx | 78 ++ .../oauth-client-browser-example/src/main.tsx | 12 + .../src/oauth-client-metadata.json | 13 + .../oauth-client-browser-example/src/oauth.ts | 119 +++ .../src/vite-env.d.ts | 1 + .../tailwind.config.js | 8 + .../tsconfig.build.json | 8 + .../tsconfig.json | 7 + .../tsconfig.tools.json | 8 + .../vite.config.ts | 26 + .../oauth-client-browser/example/index.ts | 123 ++++ packages/oauth-client-browser/package.json | 43 ++ .../src/browser-oauth-client-factory.ts | 277 +++++++ .../src/browser-oauth-database.ts | 262 +++++++ .../oauth-client-browser/src/crypto-subtle.ts | 50 ++ packages/oauth-client-browser/src/errors.ts | 9 + packages/oauth-client-browser/src/index.ts | 4 + .../src/indexed-db-store.ts | 79 ++ .../oauth-client-browser/tsconfig.build.json | 8 + packages/oauth-client-browser/tsconfig.json | 4 + packages/oauth-client/README.md | 1 + packages/oauth-client/package.json | 39 + packages/oauth-client/src/constants.ts | 4 + .../oauth-client/src/crypto-implementation.ts | 16 + packages/oauth-client/src/crypto-wrapper.ts | 164 +++++ packages/oauth-client/src/index.ts | 8 + .../oauth-client/src/oauth-callback-error.ts | 16 + .../oauth-client/src/oauth-client-factory.ts | 289 ++++++++ packages/oauth-client/src/oauth-client.ts | 89 +++ packages/oauth-client/src/oauth-resolver.ts | 28 + .../oauth-client/src/oauth-server-factory.ts | 80 ++ packages/oauth-client/src/oauth-server.ts | 287 ++++++++ packages/oauth-client/src/oauth-types.ts | 36 + packages/oauth-client/src/session-getter.ts | 140 ++++ .../src/validate-client-metadata.ts | 52 ++ packages/oauth-client/tsconfig.build.json | 8 + packages/oauth-client/tsconfig.json | 4 + packages/oauth-provider/src/oauth-provider.ts | 7 +- .../src/request/request-manager.ts | 80 +- .../oauth-provider/src/token/token-manager.ts | 6 +- pnpm-lock.yaml | 693 +++++++++++++++++- tsconfig.json | 2 + 48 files changed, 3305 insertions(+), 39 deletions(-) create mode 100644 packages/oauth-client-browser-example/index.html create mode 100644 packages/oauth-client-browser-example/package.json create mode 100644 packages/oauth-client-browser-example/postcss.config.js create mode 120000 packages/oauth-client-browser-example/public/.well-known/oauth-client-metadata create mode 100644 packages/oauth-client-browser-example/src/app.tsx create mode 100644 packages/oauth-client-browser-example/src/index.css create mode 100644 packages/oauth-client-browser-example/src/login-form.tsx create mode 100644 packages/oauth-client-browser-example/src/main.tsx create mode 100644 packages/oauth-client-browser-example/src/oauth-client-metadata.json create mode 100644 packages/oauth-client-browser-example/src/oauth.ts create mode 100644 packages/oauth-client-browser-example/src/vite-env.d.ts create mode 100644 packages/oauth-client-browser-example/tailwind.config.js create mode 100644 packages/oauth-client-browser-example/tsconfig.build.json create mode 100644 packages/oauth-client-browser-example/tsconfig.json create mode 100644 packages/oauth-client-browser-example/tsconfig.tools.json create mode 100644 packages/oauth-client-browser-example/vite.config.ts create mode 100644 packages/oauth-client-browser/example/index.ts create mode 100644 packages/oauth-client-browser/package.json create mode 100644 packages/oauth-client-browser/src/browser-oauth-client-factory.ts create mode 100644 packages/oauth-client-browser/src/browser-oauth-database.ts create mode 100644 packages/oauth-client-browser/src/crypto-subtle.ts create mode 100644 packages/oauth-client-browser/src/errors.ts create mode 100644 packages/oauth-client-browser/src/index.ts create mode 100644 packages/oauth-client-browser/src/indexed-db-store.ts create mode 100644 packages/oauth-client-browser/tsconfig.build.json create mode 100644 packages/oauth-client-browser/tsconfig.json create mode 100644 packages/oauth-client/README.md create mode 100644 packages/oauth-client/package.json create mode 100644 packages/oauth-client/src/constants.ts create mode 100644 packages/oauth-client/src/crypto-implementation.ts create mode 100644 packages/oauth-client/src/crypto-wrapper.ts create mode 100644 packages/oauth-client/src/index.ts create mode 100644 packages/oauth-client/src/oauth-callback-error.ts create mode 100644 packages/oauth-client/src/oauth-client-factory.ts create mode 100644 packages/oauth-client/src/oauth-client.ts create mode 100644 packages/oauth-client/src/oauth-resolver.ts create mode 100644 packages/oauth-client/src/oauth-server-factory.ts create mode 100644 packages/oauth-client/src/oauth-server.ts create mode 100644 packages/oauth-client/src/oauth-types.ts create mode 100644 packages/oauth-client/src/session-getter.ts create mode 100644 packages/oauth-client/src/validate-client-metadata.ts create mode 100644 packages/oauth-client/tsconfig.build.json create mode 100644 packages/oauth-client/tsconfig.json diff --git a/packages/oauth-client-browser-example/index.html b/packages/oauth-client-browser-example/index.html new file mode 100644 index 00000000000..2afe8789c8a --- /dev/null +++ b/packages/oauth-client-browser-example/index.html @@ -0,0 +1,12 @@ + + + + + + Example front-end app + + +

+ + + diff --git a/packages/oauth-client-browser-example/package.json b/packages/oauth-client-browser-example/package.json new file mode 100644 index 00000000000..4212b782bb0 --- /dev/null +++ b/packages/oauth-client-browser-example/package.json @@ -0,0 +1,43 @@ +{ + "name": "@atproto/oauth-client-browser-example", + "version": "0.0.1", + "license": "MIT", + "keywords": [ + "atproto", + "oauth", + "client", + "browser", + "example" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-client-browser-example" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "vite --force --host --clearScreen false", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@atproto/oauth-client": "workspace:*", + "@atproto/oauth-client-browser": "workspace:*", + "@atproto/oauth-client-metadata": "workspace:*", + "@atproto/oauth-server-metadata": "workspace:*", + "@babel/plugin-syntax-import-assertions": "^7.23.3", + "@types/react": "^18.2.50", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.33", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.12" + } +} diff --git a/packages/oauth-client-browser-example/postcss.config.js b/packages/oauth-client-browser-example/postcss.config.js new file mode 100644 index 00000000000..2e7af2b7f1a --- /dev/null +++ b/packages/oauth-client-browser-example/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/oauth-client-browser-example/public/.well-known/oauth-client-metadata b/packages/oauth-client-browser-example/public/.well-known/oauth-client-metadata new file mode 120000 index 00000000000..94506bc61cf --- /dev/null +++ b/packages/oauth-client-browser-example/public/.well-known/oauth-client-metadata @@ -0,0 +1 @@ +../../src/oauth-client-metadata.json \ No newline at end of file diff --git a/packages/oauth-client-browser-example/src/app.tsx b/packages/oauth-client-browser-example/src/app.tsx new file mode 100644 index 00000000000..2b4a0536ecb --- /dev/null +++ b/packages/oauth-client-browser-example/src/app.tsx @@ -0,0 +1,91 @@ +import { BrowserOAuthClientFactory } from '@atproto/oauth-client-browser' +import { oauthClientMetadataSchema } from '@atproto/oauth-client-metadata' +import { useCallback, useState } from 'react' + +import LoginForm from './login-form' +import { useOAuth } from './oauth' + +import metadata from './oauth-client-metadata.json' + +export const oauthFactory = new BrowserOAuthClientFactory({ + clientMetadata: oauthClientMetadataSchema.parse(metadata), + responseType: 'code id_token', + responseMode: 'fragment', +}) + +/** + * State data that we want to persist across the OAuth flow, when the user is + * "logging in". + */ +export type AppState = { + foo: string +} + +/** + * The {@link OAuthFrontendXrpcAgent} provides the hostname of the PDS, as + * defined during the authentication flow and credentials (`Authorization` + + * `DPoP`) form the XRPC calls. It will also handle transparently refreshing + * the credentials when they expire. + */ + +function App() { + const { initialized, client, signedIn, signOut, error, loading, signIn } = + useOAuth(oauthFactory) + const [profile, setProfile] = useState<{ + value: { displayName?: string } + } | null>(null) + + const loadProfile = useCallback(async () => { + if (!client) return + + const info = await client.getUserinfo() + console.log('info', info) + + const get = async (method: string, params: Record) => { + const response = await client.request( + `/xrpc/${method}?${new URLSearchParams(params).toString()}`, + ) + return response.json() + } + + // A call that requires to be authenticated + console.log( + await get('com.atproto.server.getServiceAuth', { aud: info.sub }), + ) + + // This call does not require authentication + const profile = await get('com.atproto.repo.getRecord', { + repo: info.sub, + collection: 'app.bsky.actor.profile', + rkey: 'self', + }) + + setProfile(profile) + + console.log(profile) + }, [client]) + + if (!initialized) { + return

{error || 'Loading...'}

+ } + + return signedIn ? ( +
+

Logged in!

+ + +
{profile ? JSON.stringify(profile, undefined, 2) : null}
+
+ + +
+ ) : ( + void signIn(input, { display: 'popup' })} + /> + ) +} + +export default App diff --git a/packages/oauth-client-browser-example/src/index.css b/packages/oauth-client-browser-example/src/index.css new file mode 100644 index 00000000000..b5c61c95671 --- /dev/null +++ b/packages/oauth-client-browser-example/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/oauth-client-browser-example/src/login-form.tsx b/packages/oauth-client-browser-example/src/login-form.tsx new file mode 100644 index 00000000000..4f73c16c7d7 --- /dev/null +++ b/packages/oauth-client-browser-example/src/login-form.tsx @@ -0,0 +1,78 @@ +import { FormEvent, useState } from 'react' + +/** + * @returns Nice tailwind css form asking to enter either a handle or the host + * to use to login. + */ +export default function LoginForm({ + onLogin, + loading, + error, + ...props +}: { + loading?: boolean + error?: null | string + onLogin: (input: string) => void +} & React.HTMLAttributes) { + const [value, setValue] = useState('') + const [loginType, setLoginType] = useState<'handle' | 'host'>('handle') + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + if (loading) return + + onLogin(loginType === 'host' ? `https://${value}` : value) + } + + return ( +
+
+
+ +
+ + {/*
*/} + +
+ + setValue(e.target.value)} + /> + +
+
+ + {error ?
{error}
: null} +
+ ) +} diff --git a/packages/oauth-client-browser-example/src/main.tsx b/packages/oauth-client-browser-example/src/main.tsx new file mode 100644 index 00000000000..d8db51390fa --- /dev/null +++ b/packages/oauth-client-browser-example/src/main.tsx @@ -0,0 +1,12 @@ +import './index.css' + +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './app' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/oauth-client-browser-example/src/oauth-client-metadata.json b/packages/oauth-client-browser-example/src/oauth-client-metadata.json new file mode 100644 index 00000000000..6dfe31f05d4 --- /dev/null +++ b/packages/oauth-client-browser-example/src/oauth-client-metadata.json @@ -0,0 +1,13 @@ +{ + "name": "My App", + "client_id": "https://app.bsky.msbn.xyz/", + "client_uri": "https://app.bsky.msbn.xyz/", + "client_name": "Example Bluesky client app", + "application_type": "web", + "token_endpoint_auth_method": "none", + "redirect_uris": ["https://app.bsky.msbn.xyz/"], + "grant_types": ["authorization_code", "refresh_token", "implicit"], + "response_types": ["code", "code id_token"], + "scope": "openid profile email phone offline_access", + "dpop_bound_access_tokens": true +} diff --git a/packages/oauth-client-browser-example/src/oauth.ts b/packages/oauth-client-browser-example/src/oauth.ts new file mode 100644 index 00000000000..57db09204b6 --- /dev/null +++ b/packages/oauth-client-browser-example/src/oauth.ts @@ -0,0 +1,119 @@ +import { OAuthAuthorizeOptions, OAuthClient } from '@atproto/oauth-client' +import { + BrowserOAuthClientFactory, + LoginContinuedInParentWindowError, +} from '@atproto/oauth-client-browser' +import { useCallback, useEffect, useRef, useState } from 'react' + +const CURRENT_SESSION_ID_KEY = 'CURRENT_SESSION_ID_KEY' + +export function useOAuth(factory: BrowserOAuthClientFactory) { + const [initialized, setInitialized] = useState(false) + const [client, setClient] = useState(void 0) + const [clients, setClients] = useState<{ [_: string]: OAuthClient }>({}) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [state, setState] = useState(undefined) + + const semaphore = useRef(0) + + useEffect(() => { + if (client != null) { + localStorage.setItem(CURRENT_SESSION_ID_KEY, client.sessionId) + } else if (client === null) { + localStorage.removeItem(CURRENT_SESSION_ID_KEY) + } + }, [client]) + + useEffect(() => { + semaphore.current++ + + setInitialized(false) + setClient(undefined) + setClients({}) + setError(null) + setLoading(true) + setState(undefined) + + const sessionId = localStorage.getItem(CURRENT_SESSION_ID_KEY) + factory + .init(sessionId || undefined) + .then(async (r) => { + const clients = await factory.restoreAll().catch((err) => { + console.error('Failed to restore clients:', err) + return {} + }) + setInitialized(true) + setClients(clients) + setClient(r?.client || (sessionId && clients[sessionId]) || null) + setState(r?.state) + }) + .catch((err) => { + localStorage.removeItem(CURRENT_SESSION_ID_KEY) + console.error('Failed to init:', err) + setError(String(err)) + setInitialized(!(err instanceof LoginContinuedInParentWindowError)) + }) + .finally(() => { + setLoading(false) + semaphore.current-- + }) + }, [semaphore, factory]) + + const signOut = useCallback(async () => { + if (!client) return + + if (semaphore.current) return + semaphore.current++ + + setClient(null) + setError(null) + setLoading(true) + setState(undefined) + + try { + await client.signOut() + } catch (err) { + console.error('Failed to clear credentials', err) + if (semaphore.current === 1) setError(String(err)) + } finally { + if (semaphore.current === 1) setLoading(false) + semaphore.current-- + } + }, [semaphore, client]) + + const signIn = useCallback( + async (input: string, options?: OAuthAuthorizeOptions) => { + if (client) return + + if (semaphore.current) return + semaphore.current++ + + setLoading(true) + + try { + const client = await factory.signIn(input, options) + setClient(client) + } catch (err) { + console.error('Failed to login', err) + if (semaphore.current === 1) setError(String(err)) + } finally { + if (semaphore.current === 1) setLoading(false) + semaphore.current-- + } + }, + [semaphore, client, factory], + ) + + return { + initialized, + clients, + client: client ?? null, + state, + loading, + error, + signedIn: client != null, + signIn, + signOut, + } +} diff --git a/packages/oauth-client-browser-example/src/vite-env.d.ts b/packages/oauth-client-browser-example/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/packages/oauth-client-browser-example/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/oauth-client-browser-example/tailwind.config.js b/packages/oauth-client-browser-example/tailwind.config.js new file mode 100644 index 00000000000..7141e4528c6 --- /dev/null +++ b/packages/oauth-client-browser-example/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/packages/oauth-client-browser-example/tsconfig.build.json b/packages/oauth-client-browser-example/tsconfig.build.json new file mode 100644 index 00000000000..4771a072fe5 --- /dev/null +++ b/packages/oauth-client-browser-example/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": ["../../tsconfig/browser.json", "../../tsconfig/bundler.json"], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx", "./src/**/*.json"] +} diff --git a/packages/oauth-client-browser-example/tsconfig.json b/packages/oauth-client-browser-example/tsconfig.json new file mode 100644 index 00000000000..ad9365d269b --- /dev/null +++ b/packages/oauth-client-browser-example/tsconfig.json @@ -0,0 +1,7 @@ +{ + "include": [], + "references": [ + { "path": "./tsconfig.build.json" }, + { "path": "./tsconfig.tools.json" } + ] +} diff --git a/packages/oauth-client-browser-example/tsconfig.tools.json b/packages/oauth-client-browser-example/tsconfig.tools.json new file mode 100644 index 00000000000..8dd0efa0b3a --- /dev/null +++ b/packages/oauth-client-browser-example/tsconfig.tools.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/node.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["./*.js", "./*.ts"] +} diff --git a/packages/oauth-client-browser-example/vite.config.ts b/packages/oauth-client-browser-example/vite.config.ts new file mode 100644 index 00000000000..eb4ca77630f --- /dev/null +++ b/packages/oauth-client-browser-example/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + build: { + commonjsOptions: { + include: [/node_modules/, /packages/], + }, + }, + optimizeDeps: { + include: [ + '@atproto/oauth-client', + '@atproto/oauth-client-browser', + '@atproto/oauth-client-metadata', + '@atproto/oauth-server-metadata', + ], + }, + plugins: [ + react({ + babel: { + plugins: ['@babel/plugin-syntax-import-assertions'], + }, + }), + ], +}) diff --git a/packages/oauth-client-browser/example/index.ts b/packages/oauth-client-browser/example/index.ts new file mode 100644 index 00000000000..c5424f4d3c4 --- /dev/null +++ b/packages/oauth-client-browser/example/index.ts @@ -0,0 +1,123 @@ +import { + BrowserOAuthClientFactory, + LoginContinuedInParentWindowError, +} from '..' + +// It is also possible to fetch clientMetadata from +// '/.well-known/oauth-client-metadata'. This is slower than bundling the client +// metadata with the app, as the app will have to wait for the fetch to complete +// on every load: + +// const oauthFactory = await BrowserOAuthClientFactory.load() + +const oauthFactory = new BrowserOAuthClientFactory({ + clientMetadata: { + client_id: 'https://example.com', + redirect_uris: ['https://example.com/cb'], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code', 'code id_token'], + scope: 'openid profile email phone offline_access', + dpop_bound_access_tokens: true, + application_type: 'web', + }, +}) + +/** + * @param input a did, a handle (without @) or a pds url (starting with https://) + */ +export async function login(input = 'matthieu.bsky.team') { + // Redirect the user to the PDS's login page + return oauthFactory + .signIn(input, { + state: '123', // Use this to restore the state of the app (e.g. navigation, etc.) after signInCallback() (will be ignored in "popup" mode) + display: 'popup', // or 'page' + }) + .then(async (client) => { + // Note: this will only be called in "popup" display mode. In any other + // mode, the current page will be redirected to the PDS's login page. + await client.request('/xrpc/com.atproto.foo.bar', { + method: 'get', + headers: {}, + }) + }) + .catch((err) => { + // May happen if the user navigates back from the login page (because of + // back-forward cache), or in popup mode, if the login resulted in an + // error. + console.error('Failed to sign in:', err) + }) +} + +// If the current url is a redirect url, and contains oauth response params, +// complete the sign in process. +oauthFactory + .signInCallback() + .catch((err) => { + // This page is being used as a popup: Prompt the user to close the popup. + if (err instanceof LoginContinuedInParentWindowError) { + // Prevent the user from loading the app in the popup by reloading the + // page or navigating back. + + // Replace the current history entry so that reloading the page does not load this script + history.replaceState(null, '', '/close-popup.html') + + // Display a "plz close this popup" message to the user + window.open('/close-popup.html', '_self') + + // Prevent back-forward cache from restoring the page and continuing the current promise chain + return new Promise(() => {}) + } + + console.error('OAuth callback error:', err) + throw err + }) + .then(async (result) => { + const currentSessionId = localStorage.getItem('currentSessionId') + + if (currentSessionId) { + if (!result) { + try { + return await oauthFactory.restore(currentSessionId, true) + } catch (err) { + // Session expired ? Network error ? + console.error('Failed to restore session:', err) + + // Only remove the item if the session has expired + // TODO: add example on how to detect this + // localStorage.removeItem('currentSessionId') + + return null + } + } else { + // Make sure to revoke any credentials we no longer need + try { + await oauthFactory.revoke(currentSessionId) + } catch { + // revoke should never throw... + } finally { + localStorage.removeItem('currentSessionId') + } + } + } + + if (!result) return null + + const { client, state } = result + + // Remember sessionId for next app load + localStorage.setItem('currentSessionId', client.sessionId) + + console.log(state) // "123" + + return client + }) + .then(async (client) => { + // User is not signed in... + if (!client) return + + // User is signed in, do something with the client + await client.request('/xrpc/com.atproto.foo.bar', { + method: 'get', + headers: {}, + }) + }) diff --git a/packages/oauth-client-browser/package.json b/packages/oauth-client-browser/package.json new file mode 100644 index 00000000000..91b659feac3 --- /dev/null +++ b/packages/oauth-client-browser/package.json @@ -0,0 +1,43 @@ +{ + "name": "@atproto/oauth-client-browser", + "version": "0.0.1", + "license": "MIT", + "description": "Implementation of OAuth client using SubtleCrypto API for browser and Node.js", + "keywords": [ + "atproto", + "oauth", + "client", + "browser" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-client-browser" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/caching": "workspace:*", + "@atproto/did": "workspace:*", + "@atproto/disposable-polyfill": "workspace:*", + "@atproto/fetch": "workspace:*", + "@atproto/handle-resolver": "workspace:*", + "@atproto/identity-resolver": "workspace:*", + "@atproto/indexed-db": "workspace:*", + "@atproto/jwk": "workspace:*", + "@atproto/jwk-jose": "workspace:*", + "@atproto/jwk-webcrypto": "workspace:*", + "@atproto/oauth-client": "workspace:*", + "@atproto/oauth-client-metadata": "workspace:*", + "@atproto/oauth-server-metadata": "workspace:*", + "@atproto/oauth-server-metadata-resolver": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts new file mode 100644 index 00000000000..72a64fa27ad --- /dev/null +++ b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts @@ -0,0 +1,277 @@ +import { Fetch } from '@atproto/fetch' +import { UniversalIdentityResolver } from '@atproto/identity-resolver' +import { + OAuthAuthorizeOptions, + OAuthClient, + OAuthClientFactory, + OAuthCallbackError, + OAuthResponseMode, + OAuthResponseType, + Session, +} from '@atproto/oauth-client' +import { + OAuthClientMetadata, + oauthClientMetadataSchema, +} from '@atproto/oauth-client-metadata' +import IsomorphicOAuthServerMetadataResolver from '@atproto/oauth-server-metadata-resolver' +import { + BrowserOAuthDatabase, + DatabaseStore, + PopupStateData, +} from './browser-oauth-database.js' +import { CryptoSubtle } from './crypto-subtle.js' +import { LoginContinuedInParentWindowError } from './errors.js' + +export type BrowserOauthClientFactoryOptions = { + responseMode?: OAuthResponseMode + responseType?: OAuthResponseType + clientMetadata: OAuthClientMetadata + fetch?: Fetch + crypto?: Crypto +} + +const POPUP_STATE_PREFIX = '@@oauth-popup-callback:' + +export class BrowserOAuthClientFactory extends OAuthClientFactory { + static async load( + options?: Omit, + ) { + const fetch = options?.fetch ?? globalThis.fetch + const request = new Request('/.well-known/oauth-client-metadata', { + redirect: 'error', + }) + const response = await fetch(request) + const clientMetadata = oauthClientMetadataSchema.parse( + await response.json(), + ) + return new BrowserOAuthClientFactory({ clientMetadata, ...options }) + } + + readonly popupStore: DatabaseStore + readonly sessionStore: DatabaseStore + + private database: BrowserOAuthDatabase + + constructor({ + clientMetadata, + // "fragment" is safer as it is not sent to the server + responseMode = 'fragment', + responseType, + crypto = globalThis.crypto, + fetch = globalThis.fetch, + }: BrowserOauthClientFactoryOptions) { + const database = new BrowserOAuthDatabase() + + super({ + clientMetadata, + responseMode, + responseType, + fetch, + cryptoImplementation: new CryptoSubtle(crypto), + sessionStore: database.getSessionStore(), + stateStore: database.getStateStore(), + metadataResolver: new IsomorphicOAuthServerMetadataResolver({ + fetch, + cache: database.getMetadataCache(), + }), + identityResolver: UniversalIdentityResolver.from({ + fetch, + didCache: database.getDidCache(), + handleCache: database.getHandleCache(), + }), + dpopNonceCache: database.getDpopNonceCache(), + }) + + this.sessionStore = database.getSessionStore() + this.popupStore = database.getPopupStore() + + this.database = database + } + + async restoreAll() { + const sessionIds = await this.sessionStore.getKeys() + return Object.fromEntries( + await Promise.all( + sessionIds.map(async (sessionId) => { + return [sessionId, await this.restore(sessionId, false)] as const + }), + ), + ) + } + + async init(sessionId?: string, refresh?: boolean) { + const signInResult = await this.signInCallback() + if (signInResult) { + return signInResult + } else if (sessionId) { + const client = await this.restore(sessionId, refresh) + return { client } + } else { + // TODO: we could restore any session from the store ? + } + } + + async signIn( + input: string, + options?: OAuthAuthorizeOptions & { signal?: AbortSignal }, + ) { + if (options?.display === 'popup') { + return this.signInPopup(input, options) + } else { + return this.signInRedirect(input, options) + } + } + + async signInRedirect(input: string, options?: OAuthAuthorizeOptions) { + const url = await this.authorize(input, options) + + window.location.href = url.href + + // back-forward cache + return new Promise((resolve, reject) => { + setTimeout(() => reject(new Error('User navigated back')), 5e3) + }) + } + + async signInPopup( + input: string, + options?: Omit & { signal?: AbortSignal }, + ): Promise { + // Open new window asap to prevent popup busting by browsers + const popupFeatures = 'width=600,height=600,menubar=no,toolbar=no' + let popup = window.open('about:blank', '_blank', popupFeatures) + + const stateKey = `${Math.random().toString(36).slice(2)}` + + const url = await this.authorize(input, { + ...options, + state: `${POPUP_STATE_PREFIX}${stateKey}`, + display: options?.display ?? 'popup', + }) + + try { + options?.signal?.throwIfAborted() + + if (popup) { + popup.window.location.href = url.href + } else { + popup = window.open(url.href, '_blank', popupFeatures) + } + + popup?.focus() + + return await new Promise((resolve, reject) => { + const cleanup = () => { + clearInterval(interval) + clearTimeout(timeout) + void this.popupStore.del(stateKey) + options?.signal?.removeEventListener('abort', cancel) + } + + const cancel = () => { + // TODO: Store fact that the request was cancelled, allowing any + // callback to not request credentials (or revoke those obtained) + + reject(new Error(options?.signal?.aborted ? 'Aborted' : 'Timeout')) + cleanup() + } + + options?.signal?.addEventListener('abort', cancel) + + const timeout = setTimeout(cancel, 5 * 60e3) + + const interval = setInterval(async () => { + const result = await this.popupStore.get(stateKey) + if (!result) return + + cleanup() + + if (result.status === 'fulfilled') { + const { sessionId } = result.value + try { + options?.signal?.throwIfAborted() + resolve(await this.restore(sessionId)) + } catch (err) { + reject(err) + void this.revoke(sessionId) + } + } else { + const { message, params } = result.reason + reject(new OAuthCallbackError(new URLSearchParams(params), message)) + } + }, 500) + }) + } finally { + popup?.close() + } + } + + async signInCallback() { + // Only if the current URL is a redirect URI + if ( + this.clientMetadata.redirect_uris.every( + (uri) => new URL(uri).pathname !== location.pathname, + ) + ) { + return null + } + + const params = + this.responseMode === 'fragment' + ? new URLSearchParams(location.hash.slice(1)) + : new URLSearchParams(location.search) + + // Only if the current URL contain oauth response params + if (!params.has('state') || !(params.has('code') || params.has('error'))) { + return null + } + + // Replace the current history entry without the query string (this will + // prevent the following code to run again if the user refreshes the page) + history.replaceState(null, '', location.pathname) + + return this.callback(params) + .then(async (result) => { + if (result.state?.startsWith(POPUP_STATE_PREFIX)) { + const stateKey = result.state.slice(POPUP_STATE_PREFIX.length) + + await this.popupStore.set(stateKey, { + status: 'fulfilled', + value: { + sessionId: result.client.sessionId, + }, + }) + + throw new LoginContinuedInParentWindowError() // signInPopup + } + + return result + }) + .catch(async (err) => { + if ( + err instanceof OAuthCallbackError && + err.state?.startsWith(POPUP_STATE_PREFIX) + ) { + const stateKey = err.state.slice(POPUP_STATE_PREFIX.length) + + await this.popupStore.set(stateKey, { + status: 'rejected', + reason: { + message: err.message, + params: Array.from(err.params.entries()), + }, + }) + + throw new LoginContinuedInParentWindowError() // signInPopup + } + + // Most probable cause at this point is that the "state" parameter is + // invalid. + throw err + }) + } + + async [Symbol.asyncDispose]() { + await this.database[Symbol.asyncDispose]() + } +} diff --git a/packages/oauth-client-browser/src/browser-oauth-database.ts b/packages/oauth-client-browser/src/browser-oauth-database.ts new file mode 100644 index 00000000000..cfc8738376d --- /dev/null +++ b/packages/oauth-client-browser/src/browser-oauth-database.ts @@ -0,0 +1,262 @@ +import { GenericStore, Value } from '@atproto/caching' +import { DidDocument } from '@atproto/did' +import { ResolvedHandle } from '@atproto/handle-resolver' +import { DB, DBObjectStore } from '@atproto/indexed-db' +import { Key } from '@atproto/jwk' +import { WebcryptoKey } from '@atproto/jwk-webcrypto' +import { InternalStateData, Session, TokenSet } from '@atproto/oauth-client' +import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' + +type Item = { + value: V + expiresAt: null | number +} + +type EncodedKey = { + keyId: string + keyPair: CryptoKeyPair +} + +function encodeKey(key: Key): EncodedKey { + if (!(key instanceof WebcryptoKey) || !key.kid) { + throw new Error('Invalid key object') + } + return { + keyId: key.kid, + keyPair: key.cryptoKeyPair, + } +} + +async function decodeKey(encoded: EncodedKey): Promise { + return WebcryptoKey.fromKeypair(encoded.keyId, encoded.keyPair) +} + +export type PopupStateData = + | PromiseRejectedResult + | PromiseFulfilledResult<{ + sessionId: string + }> + +export type Schema = { + popup: Item + state: Item<{ + dpopKey: EncodedKey + + iss: string + nonce: string + verifier?: string + appState?: string + }> + session: Item<{ + dpopKey: EncodedKey + + tokenSet: TokenSet + }> + + didCache: Item + dpopNonceCache: Item + handleCache: Item + metadataCache: Item +} + +export type DatabaseStore = GenericStore & { + getKeys: () => Promise +} + +const STORES = [ + 'popup', + 'state', + 'session', + + 'didCache', + 'dpopNonceCache', + 'handleCache', + 'metadataCache', +] as const + +export type BrowserOAuthDatabaseOptions = { + name?: string + durability?: 'strict' | 'relaxed' + cleanupInterval?: number +} + +export class BrowserOAuthDatabase { + #dbPromise: Promise> + #cleanupInterval?: ReturnType + + constructor(options?: BrowserOAuthDatabaseOptions) { + this.#dbPromise = DB.open( + options?.name ?? '@atproto-oauth-client', + [ + (db) => { + for (const name of STORES) { + const store = db.createObjectStore(name, { autoIncrement: true }) + store.createIndex('expiresAt', 'expiresAt', { unique: false }) + } + }, + ], + { durability: options?.durability ?? 'strict' }, + ) + + this.#cleanupInterval = setInterval(() => { + void this.cleanup() + }, options?.cleanupInterval ?? 30e3) + } + + protected async run( + storeName: N, + mode: 'readonly' | 'readwrite', + fn: (s: DBObjectStore) => R | Promise, + ): Promise { + const db = await this.#dbPromise + return await db.transaction([storeName], mode, (tx) => + fn(tx.objectStore(storeName)), + ) + } + + protected createStore( + name: N, + { + encode, + decode, + expiresAt, + }: { + encode: (value: V) => Schema[N]['value'] | PromiseLike + decode: (encoded: Schema[N]['value']) => V | PromiseLike + expiresAt: (value: V) => null | number + }, + ): DatabaseStore { + return { + get: async (key) => { + // Find item in store + const item = await this.run(name, 'readonly', (store) => store.get(key)) + + // Not found + if (item === undefined) return undefined + + // Too old (delete) + if (item.expiresAt != null && item.expiresAt < Date.now()) { + await this.run(name, 'readwrite', (store) => store.delete(key)) + return undefined + } + + // Item found and valid. Decode + return decode(item.value) + }, + + getKeys: async () => { + const keys = await this.run(name, 'readonly', (store) => + store.getAllKeys(), + ) + return keys.filter((key): key is string => typeof key === 'string') + }, + + set: async (key, value) => { + // Create encoded item record + const item = { + value: await encode(value), + expiresAt: expiresAt(value), + } as Schema[N] + + // Store item record + await this.run(name, 'readwrite', (store) => store.put(item, key)) + }, + + del: async (key) => { + // Delete + await this.run(name, 'readwrite', (store) => store.delete(key)) + }, + } + } + + getSessionStore(): DatabaseStore { + return this.createStore('session', { + expiresAt: ({ tokenSet }) => + tokenSet.refresh_token ? null : tokenSet.expires_at ?? null, + encode: ({ dpopKey, ...session }) => ({ + ...session, + dpopKey: encodeKey(dpopKey), + }), + decode: async ({ dpopKey, ...encoded }) => ({ + ...encoded, + dpopKey: await decodeKey(dpopKey), + }), + }) + } + + getStateStore(): DatabaseStore { + return this.createStore('state', { + expiresAt: (_value) => Date.now() + 10 * 60e3, + encode: ({ dpopKey, ...session }) => ({ + ...session, + dpopKey: encodeKey(dpopKey), + }), + decode: async ({ dpopKey, ...encoded }) => ({ + ...encoded, + dpopKey: await decodeKey(dpopKey), + }), + }) + } + + getPopupStore(): DatabaseStore { + return this.createStore('popup', { + expiresAt: (_value) => Date.now() + 600e3, + encode: (value) => value, + decode: (encoded) => encoded, + }) + } + + getDpopNonceCache(): undefined | DatabaseStore { + return this.createStore('dpopNonceCache', { + expiresAt: (_value) => Date.now() + 600e3, + encode: (value) => value, + decode: (encoded) => encoded, + }) + } + + getDidCache(): undefined | DatabaseStore { + return this.createStore('didCache', { + expiresAt: (_value) => Date.now() + 60e3, + encode: (value) => value, + decode: (encoded) => encoded, + }) + } + + getHandleCache(): undefined | DatabaseStore { + return this.createStore('handleCache', { + expiresAt: (_value) => Date.now() + 60e3, + encode: (value) => value, + decode: (encoded) => encoded, + }) + } + + getMetadataCache(): undefined | DatabaseStore { + return this.createStore('metadataCache', { + expiresAt: (_value) => Date.now() + 60e3, + encode: (value) => value, + decode: (encoded) => encoded, + }) + } + + async cleanup() { + const db = await this.#dbPromise + + for (const name of STORES) { + await db.transaction([name], 'readwrite', (tx) => + tx + .objectStore(name) + .index('expiresAt') + .deleteAll(IDBKeyRange.upperBound(Date.now())), + ) + } + } + + async [Symbol.asyncDispose]() { + clearInterval(this.#cleanupInterval) + const dbPromise = this.#dbPromise + this.#dbPromise = Promise.reject(new Error('Database has been disposed')) + + const db = await dbPromise + await (db[Symbol.asyncDispose] || db[Symbol.dispose]).call(db) + } +} diff --git a/packages/oauth-client-browser/src/crypto-subtle.ts b/packages/oauth-client-browser/src/crypto-subtle.ts new file mode 100644 index 00000000000..28c34adf6a1 --- /dev/null +++ b/packages/oauth-client-browser/src/crypto-subtle.ts @@ -0,0 +1,50 @@ +import { WebcryptoKey } from '@atproto/jwk-webcrypto' +import { + Key, + CryptoImplementation, + DigestAlgorithm, +} from '@atproto/oauth-client' + +export class CryptoSubtle implements CryptoImplementation { + constructor(private crypto: Crypto = globalThis.crypto) { + if (!crypto?.subtle) { + throw new Error( + 'Crypto with CryptoSubtle is required. If running in a browser, make sure the current page is loaded over HTTPS.', + ) + } + } + + async createKey(algs: string[]): Promise { + return WebcryptoKey.generate(undefined, algs) + } + + getRandomValues(byteLength: number): Uint8Array { + const bytes = new Uint8Array(byteLength) + this.crypto.getRandomValues(bytes) + return bytes + } + + async digest( + bytes: Uint8Array, + algorithm: DigestAlgorithm, + ): Promise { + const buffer = await this.crypto.subtle.digest( + digestAlgorithmToSubtle(algorithm), + bytes, + ) + return new Uint8Array(buffer) + } +} + +function digestAlgorithmToSubtle({ + name, +}: DigestAlgorithm): AlgorithmIdentifier { + switch (name) { + case 'sha256': + case 'sha384': + case 'sha512': + return `SHA-${name.slice(-3)}` + default: + throw new Error(`Unknown hash algorithm ${name}`) + } +} diff --git a/packages/oauth-client-browser/src/errors.ts b/packages/oauth-client-browser/src/errors.ts new file mode 100644 index 00000000000..3059781366e --- /dev/null +++ b/packages/oauth-client-browser/src/errors.ts @@ -0,0 +1,9 @@ +/** + * Special error class destined to be thrown when the login process was + * performed in a popup and should be continued in the parent/initiating window. + */ +export class LoginContinuedInParentWindowError extends Error { + constructor() { + super('Login complete, please close the popup window.') + } +} diff --git a/packages/oauth-client-browser/src/index.ts b/packages/oauth-client-browser/src/index.ts new file mode 100644 index 00000000000..9a84e91d06c --- /dev/null +++ b/packages/oauth-client-browser/src/index.ts @@ -0,0 +1,4 @@ +import '@atproto/disposable-polyfill' + +export * from './browser-oauth-client-factory.js' +export * from './errors.js' diff --git a/packages/oauth-client-browser/src/indexed-db-store.ts b/packages/oauth-client-browser/src/indexed-db-store.ts new file mode 100644 index 00000000000..f04dfe1d011 --- /dev/null +++ b/packages/oauth-client-browser/src/indexed-db-store.ts @@ -0,0 +1,79 @@ +import { GenericStore, Key, Value } from '@atproto/caching' +import { DB, DBObjectStore } from '@atproto/indexed-db' + +const storeName = 'store' +type Item = { + value: V + createdAt: Date +} + +export class IndexedDBStore< + K extends Extract, + V extends Value, +> implements GenericStore +{ + constructor( + private dbName: string, + protected maxAge = 600e3, + ) {} + + protected async run( + mode: 'readonly' | 'readwrite', + fn: (s: DBObjectStore>) => R | Promise, + ): Promise { + const db = await DB.open<{ store: Item }>( + this.dbName, + [ + (db) => { + const store = db.createObjectStore(storeName) + store.createIndex('createdAt', 'createdAt', { unique: false }) + }, + ], + { durability: 'strict' }, + ) + try { + return await db.transaction([storeName], mode, (tx) => + fn(tx.objectStore(storeName)), + ) + } finally { + await db[Symbol.dispose]() + } + } + + async get(key: K): Promise { + const item = await this.run('readonly', (store) => store.get(key)) + + if (!item) return undefined + + const age = Date.now() - item.createdAt.getTime() + if (age > this.maxAge) { + await this.del(key) + return undefined + } + + return item?.value + } + + async set(key: K, value: V): Promise { + await this.run('readwrite', (store) => { + store.put({ value, createdAt: new Date() }, key) + }) + } + + async del(key: K): Promise { + await this.run('readwrite', (store) => { + store.delete(key) + }) + } + + async deleteOutdated() { + const upperBound = new Date(Date.now() - this.maxAge) + const query = IDBKeyRange.upperBound(upperBound) + + await this.run('readwrite', async (store) => { + const index = store.index('createdAt') + const keys = await index.getAllKeys(query) + for (const key of keys) store.delete(key) + }) + } +} diff --git a/packages/oauth-client-browser/tsconfig.build.json b/packages/oauth-client-browser/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/oauth-client-browser/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/oauth-client-browser/tsconfig.json b/packages/oauth-client-browser/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/oauth-client-browser/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/oauth-client/README.md b/packages/oauth-client/README.md new file mode 100644 index 00000000000..6fb706cc5c5 --- /dev/null +++ b/packages/oauth-client/README.md @@ -0,0 +1 @@ +# @atproto/oauth-client: atproto flavoured OAuth client diff --git a/packages/oauth-client/package.json b/packages/oauth-client/package.json new file mode 100644 index 00000000000..024247b17e0 --- /dev/null +++ b/packages/oauth-client/package.json @@ -0,0 +1,39 @@ +{ + "name": "@atproto/oauth-client", + "version": "0.0.1", + "license": "MIT", + "description": "Client for com.atproto flavoured OAuth", + "keywords": [ + "atproto", + "oauth", + "client", + "isomorphic" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-client" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/b64": "workspace:*", + "@atproto/caching": "workspace:*", + "@atproto/did": "workspace:*", + "@atproto/fetch": "workspace:*", + "@atproto/fetch-dpop": "workspace:*", + "@atproto/handle-resolver": "workspace:*", + "@atproto/identity-resolver": "workspace:*", + "@atproto/jwk": "workspace:*", + "@atproto/oauth-client-metadata": "workspace:*", + "@atproto/oauth-server-metadata": "workspace:*", + "@atproto/oauth-server-metadata-resolver": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/oauth-client/src/constants.ts b/packages/oauth-client/src/constants.ts new file mode 100644 index 00000000000..2ee644cde0f --- /dev/null +++ b/packages/oauth-client/src/constants.ts @@ -0,0 +1,4 @@ +/** + * Per ATProto spec (OpenID uses RS256) + */ +export const FALLBACK_ALG = 'ES256' diff --git a/packages/oauth-client/src/crypto-implementation.ts b/packages/oauth-client/src/crypto-implementation.ts new file mode 100644 index 00000000000..3812b844bb0 --- /dev/null +++ b/packages/oauth-client/src/crypto-implementation.ts @@ -0,0 +1,16 @@ +import { Key } from '@atproto/jwk' + +export type DigestAlgorithm = { + name: 'sha256' | 'sha384' | 'sha512' +} + +export type { Key } + +export interface CryptoImplementation { + createKey(algs: string[]): Promise + getRandomValues: (length: number) => Uint8Array | PromiseLike + digest: ( + bytes: Uint8Array, + algorithm: DigestAlgorithm, + ) => Uint8Array | PromiseLike +} diff --git a/packages/oauth-client/src/crypto-wrapper.ts b/packages/oauth-client/src/crypto-wrapper.ts new file mode 100644 index 00000000000..deed810c7a3 --- /dev/null +++ b/packages/oauth-client/src/crypto-wrapper.ts @@ -0,0 +1,164 @@ +import { b64uEncode } from '@atproto/b64' +import { JwtHeader, JwtPayload, Key, unsafeDecodeJwt } from '@atproto/jwk' +import { + CryptoImplementation, + DigestAlgorithm, +} from './crypto-implementation.js' + +export class CryptoWrapper { + constructor(protected implementation: CryptoImplementation) {} + + public async generateKey(algs: string[]): Promise { + return this.implementation.createKey(algs) + } + + public async sha256(text: string): Promise { + const bytes = new TextEncoder().encode(text) + const digest = await this.implementation.digest(bytes, { name: 'sha256' }) + return b64uEncode(digest) + } + + public async generateNonce(length = 16): Promise { + const bytes = await this.implementation.getRandomValues(length) + return b64uEncode(bytes) + } + + public async validateIdTokenClaims( + token: string, + state: string, + nonce: string, + code?: string, + accessToken?: string, + ): Promise<{ + header: JwtHeader + payload: JwtPayload + }> { + // It's fine to use unsafeDecodeJwt here because the token was received from + // the server's token endpoint. The following checks are to ensure that the + // oauth flow was indeed initiated by the client. + const { header, payload } = unsafeDecodeJwt(token) + if (!payload.nonce || payload.nonce !== nonce) { + throw new TypeError('Nonce mismatch') + } + if (payload.c_hash) { + await this.validateHashClaim(payload.c_hash, code, header) + } + if (payload.s_hash) { + await this.validateHashClaim(payload.s_hash, state, header) + } + if (payload.at_hash) { + await this.validateHashClaim(payload.at_hash, accessToken, header) + } + return { header, payload } + } + + private async validateHashClaim( + claim: unknown, + source: unknown, + header: { alg: string; crv?: string }, + ): Promise { + if (typeof claim !== 'string' || !claim) { + throw new TypeError(`string "_hash" claim expected`) + } + if (typeof source !== 'string' || !source) { + throw new TypeError(`string value expected`) + } + const expected = await this.generateHashClaim(source, header) + if (expected !== claim) { + throw new TypeError(`"_hash" does not match`) + } + } + + protected async generateHashClaim( + source: string, + header: { alg: string; crv?: string }, + ) { + const algo = getHashAlgo(header) + const bytes = new TextEncoder().encode(source) + const digest = await this.implementation.digest(bytes, algo) + return b64uEncode(digest.slice(0, digest.length / 2)) + } + + public async generatePKCE(byteLength?: number) { + const verifier = await this.generateVerifier(byteLength) + return { + verifier, + challenge: await this.sha256(verifier), + method: 'S256', + } + } + + public async calculateJwkThumbprint(jwk) { + const components = extractJktComponents(jwk) + const data = JSON.stringify(components) + return this.sha256(data) + } + + /** + * @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.1} + * @note It is RECOMMENDED that the output of a suitable random number generator + * be used to create a 32-octet sequence. The octet sequence is then + * base64url-encoded to produce a 43-octet URL safe string to use as the code + * verifier. + */ + protected async generateVerifier(byteLength = 32) { + if (byteLength < 32 || byteLength > 96) { + throw new TypeError('Invalid code_verifier length') + } + const bytes = await this.implementation.getRandomValues(byteLength) + return b64uEncode(bytes) + } +} + +function getHashAlgo(header: { alg: string; crv?: string }): DigestAlgorithm { + switch (header.alg) { + case 'HS256': + case 'RS256': + case 'PS256': + case 'ES256': + case 'ES256K': + return { name: 'sha256' } + case 'HS384': + case 'RS384': + case 'PS384': + case 'ES384': + return { name: 'sha384' } + case 'HS512': + case 'RS512': + case 'PS512': + case 'ES512': + return { name: 'sha512' } + case 'EdDSA': + switch (header.crv) { + case 'Ed25519': + return { name: 'sha512' } + default: + throw new TypeError('unrecognized or invalid EdDSA curve provided') + } + default: + throw new TypeError('unrecognized or invalid JWS algorithm provided') + } +} + +function extractJktComponents(jwk) { + const get = (field) => { + const value = jwk[field] + if (typeof value !== 'string' || !value) { + throw new TypeError(`"${field}" Parameter missing or invalid`) + } + return value + } + + switch (jwk.kty) { + case 'EC': + return { crv: get('crv'), kty: get('kty'), x: get('x'), y: get('y') } + case 'OKP': + return { crv: get('crv'), kty: get('kty'), x: get('x') } + case 'RSA': + return { e: get('e'), kty: get('kty'), n: get('n') } + case 'oct': + return { k: get('k'), kty: get('kty') } + default: + throw new TypeError('"kty" (Key Type) Parameter missing or unsupported') + } +} diff --git a/packages/oauth-client/src/index.ts b/packages/oauth-client/src/index.ts new file mode 100644 index 00000000000..81ec5157983 --- /dev/null +++ b/packages/oauth-client/src/index.ts @@ -0,0 +1,8 @@ +export * from './crypto-implementation.js' +export * from './oauth-client-factory.js' +export * from './oauth-client.js' +export * from './oauth-callback-error.js' +export * from './oauth-server-factory.js' +export * from './oauth-server.js' +export * from './oauth-types.js' +export * from './session-getter.js' diff --git a/packages/oauth-client/src/oauth-callback-error.ts b/packages/oauth-client/src/oauth-callback-error.ts new file mode 100644 index 00000000000..9c9c26d19da --- /dev/null +++ b/packages/oauth-client/src/oauth-callback-error.ts @@ -0,0 +1,16 @@ +export class OAuthCallbackError extends Error { + static from(err: unknown, params: URLSearchParams, state?: string) { + if (err instanceof OAuthCallbackError) return err + const message = err instanceof Error ? err.message : undefined + return new OAuthCallbackError(params, message, state, err) + } + + constructor( + public readonly params: URLSearchParams, + message = params.get('error_description') || 'OAuth callback error', + public readonly state?: string, + cause?: unknown, + ) { + super(message, { cause }) + } +} diff --git a/packages/oauth-client/src/oauth-client-factory.ts b/packages/oauth-client/src/oauth-client-factory.ts new file mode 100644 index 00000000000..1be9f6867b6 --- /dev/null +++ b/packages/oauth-client/src/oauth-client-factory.ts @@ -0,0 +1,289 @@ +import { GenericStore } from '@atproto/caching' +import { Key } from '@atproto/jwk' +import { FALLBACK_ALG } from './constants.js' +import { OAuthClient } from './oauth-client.js' +import { + OAuthServerFactory, + OAuthServerFactoryOptions, +} from './oauth-server-factory.js' +import { OAuthServer } from './oauth-server.js' +import { + OAuthAuthorizeOptions, + OAuthResponseMode, + OAuthResponseType, +} from './oauth-types.js' +import { Session, SessionGetter } from './session-getter.js' +import { OAuthCallbackError } from './oauth-callback-error.js' + +export type InternalStateData = { + iss: string + nonce: string + dpopKey: Key + verifier?: string + + /** + * @note This could be parametrized to be of any type. This wasn't done for + * the sake of simplicity but could be added in a later development. + */ + appState?: string +} + +export type OAuthClientOptions = OAuthServerFactoryOptions & { + stateStore: GenericStore + sessionStore: GenericStore + + /** + * "form_post" will typically be used for server-side applications. + */ + responseMode?: OAuthResponseMode + responseType?: OAuthResponseType +} + +export class OAuthClientFactory { + readonly serverFactory: OAuthServerFactory + + readonly stateStore: GenericStore + readonly sessionGetter: SessionGetter + + readonly responseMode?: OAuthResponseMode + readonly responseType?: OAuthResponseType + + constructor(options: OAuthClientOptions) { + this.responseMode = options?.responseMode + this.responseType = options?.responseType + this.serverFactory = new OAuthServerFactory(options) + this.stateStore = options.stateStore + this.sessionGetter = new SessionGetter( + options.sessionStore, + this.serverFactory, + ) + } + + get clientMetadata() { + return this.serverFactory.clientMetadata + } + + async authorize( + input: string, + options?: OAuthAuthorizeOptions, + ): Promise { + const { did, metadata } = await this.serverFactory.resolver.resolve(input) + + const nonce = await this.serverFactory.crypto.generateNonce() + const pkce = await this.serverFactory.crypto.generatePKCE() + const dpopKey = await this.serverFactory.crypto.generateKey( + metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG], + ) + + const state = await this.serverFactory.crypto.generateNonce() + + await this.stateStore.set(state, { + iss: metadata.issuer, + dpopKey, + nonce, + verifier: pkce?.verifier, + appState: options?.state, + }) + + const parameters = { + client_id: this.clientMetadata.client_id, + redirect_uri: this.clientMetadata.redirect_uris[0], + code_challenge: pkce?.challenge, + code_challenge_method: pkce?.method, + nonce, + state, + login_hint: did || undefined, + response_mode: this.responseMode, + response_type: + this.responseType != null && + metadata['response_types_supported']?.includes(this.responseType) + ? this.responseType + : 'code', + + display: options?.display, + id_token_hint: options?.id_token_hint, + max_age: options?.max_age, // this.clientMetadata.default_max_age + prompt: options?.prompt, + scope: options?.scope + ?.split(' ') + .filter((s) => metadata.scopes_supported?.includes(s)) + .join(' '), + ui_locales: options?.ui_locales, + } + + if (metadata.pushed_authorization_request_endpoint) { + const server = await this.serverFactory.fromMetadata(metadata, dpopKey) + const { json } = await server.request( + 'pushed_authorization_request', + parameters, + ) + + const authorizationUrl = new URL(metadata.authorization_endpoint) + authorizationUrl.searchParams.set( + 'client_id', + this.clientMetadata.client_id, + ) + authorizationUrl.searchParams.set('request_uri', json.request_uri) + return authorizationUrl + } else if (metadata.require_pushed_authorization_requests) { + throw new Error( + 'Server requires pushed authorization requests (PAR) but no PAR endpoint is available', + ) + } else { + const authorizationUrl = new URL(metadata.authorization_endpoint) + for (const [key, value] of Object.entries(parameters)) { + if (value) authorizationUrl.searchParams.set(key, String(value)) + } + + // Length of the URL that will be sent to the server + const urlLength = + authorizationUrl.pathname.length + authorizationUrl.search.length + if (urlLength < 2048) { + return authorizationUrl + } else if (!metadata.pushed_authorization_request_endpoint) { + throw new Error('Login URL too long') + } + } + + throw new Error( + 'Server does not support pushed authorization requests (PAR)', + ) + } + + async callback(params: URLSearchParams): Promise<{ + client: OAuthClient + state?: string + }> { + // TODO: better errors + + const responseJwt = params.get('response') + if (responseJwt != null) { + // https://openid.net/specs/oauth-v2-jarm.html + throw new OAuthCallbackError(params, 'JARM not supported') + } + + const issuerParam = params.get('iss') + const stateParam = params.get('state') + const errorParam = params.get('error') + const codeParam = params.get('code') + + if (!stateParam) { + throw new OAuthCallbackError(params, 'Missing "state" parameter') + } + const stateData = await this.stateStore.get(stateParam) + if (stateData) { + // Prevent any kind of replay + await this.stateStore.del(stateParam) + } else { + throw new OAuthCallbackError(params, 'Invalid state') + } + + try { + if (errorParam != null) { + throw new OAuthCallbackError(params, undefined, stateData.appState) + } + + if (!codeParam) { + throw new OAuthCallbackError( + params, + 'Missing "code" query param', + stateData.appState, + ) + } + + const server = await this.serverFactory.fromIssuer( + stateData.iss, + stateData.dpopKey, + ) + + if (issuerParam != null) { + if (!server.serverMetadata.issuer) { + throw new OAuthCallbackError( + params, + 'Issuer not found in metadata', + stateData.appState, + ) + } + if (server.serverMetadata.issuer !== issuerParam) { + throw new OAuthCallbackError( + params, + 'Issuer mismatch', + stateData.appState, + ) + } + } else if ( + server.serverMetadata.authorization_response_iss_parameter_supported + ) { + throw new OAuthCallbackError( + params, + 'iss missing from the response', + stateData.appState, + ) + } + + const tokenSet = await server.exchangeCode(codeParam, stateData.verifier) + try { + if (tokenSet.id_token) { + await this.serverFactory.crypto.validateIdTokenClaims( + tokenSet.id_token, + stateParam, + stateData.nonce, + codeParam, + tokenSet.access_token, + ) + } + + const sessionId = await this.serverFactory.crypto.generateNonce(4) + + await this.sessionGetter.setStored(sessionId, { + dpopKey: stateData.dpopKey, + tokenSet, + }) + + const client = this.createClient(server, sessionId) + + return { client, state: stateData.appState } + } catch (err) { + await server.revoke(tokenSet.access_token) + + throw err + } + } catch (err) { + // Make sure, whatever the underlying error, that the appState is + // available in the calling code + throw OAuthCallbackError.from(err, params, stateData.appState) + } + } + + /** + * Build a client from a stored session. This will refresh the token only if + * needed (about to expire) by default. + * + * @param refresh See {@link SessionGetter.getSession} + */ + async restore(sessionId: string, refresh?: boolean): Promise { + const { dpopKey, tokenSet } = await this.sessionGetter.getSession( + sessionId, + refresh, + ) + + const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey) + + return this.createClient(server, sessionId) + } + + async revoke(sessionId: string) { + const { dpopKey, tokenSet } = await this.sessionGetter.get(sessionId, { + allowStale: true, + }) + + const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey) + + await server.revoke(tokenSet.access_token) + await this.sessionGetter.delStored(sessionId) + } + + createClient(server: OAuthServer, sessionId: string): OAuthClient { + return new OAuthClient(server, sessionId, this.sessionGetter) + } +} diff --git a/packages/oauth-client/src/oauth-client.ts b/packages/oauth-client/src/oauth-client.ts new file mode 100644 index 00000000000..57a493274a3 --- /dev/null +++ b/packages/oauth-client/src/oauth-client.ts @@ -0,0 +1,89 @@ +import { JwtPayload, unsafeDecodeJwt } from '@atproto/jwk' +import { OAuthServer, TokenSet } from './oauth-server.js' +import { SessionGetter } from './session-getter.js' + +export class OAuthClient { + constructor( + private readonly server: OAuthServer, + public readonly sessionId: string, + private readonly sessionGetter: SessionGetter, + ) {} + + /** + * @param refresh See {@link SessionGetter.getSession} + */ + async getTokenSet(refresh?: boolean): Promise { + const { tokenSet } = await this.sessionGetter.getSession( + this.sessionId, + refresh, + ) + return tokenSet + } + + async getUserinfo(): Promise<{ + userinfo?: JwtPayload + expired?: boolean + scope?: string + iss: string + aud: string + sub: string + }> { + const tokenSet = await this.getTokenSet() + + return { + userinfo: tokenSet.id_token + ? unsafeDecodeJwt(tokenSet.id_token).payload + : undefined, + expired: + tokenSet.expires_at == null + ? undefined + : tokenSet.expires_at < Date.now() - 5e3, + scope: tokenSet.scope, + iss: tokenSet.iss, + aud: tokenSet.aud, + sub: tokenSet.sub, + } + } + + async signOut() { + try { + const tokenSet = await this.getTokenSet(false) + await this.server.revoke(tokenSet.access_token) + } finally { + await this.sessionGetter.delStored(this.sessionId) + } + } + + async request( + pathname: string, + init?: RequestInit, + refreshCredentials?: boolean, + ): Promise { + const tokenSet = await this.getTokenSet(refreshCredentials) + const headers = new Headers(init?.headers) + headers.set( + 'Authorization', + `${tokenSet.token_type} ${tokenSet.access_token}`, + ) + const request = new Request(new URL(pathname, tokenSet.aud), { + ...init, + headers, + }) + + return this.server.dpopFetch(request).catch((err) => { + if (!refreshCredentials && isTokenExpiredError(err)) { + return this.request(pathname, init, true) + } + + throw err + }) + } +} + +/** + * @todo Actually implement this + */ +function isTokenExpiredError(_err: unknown) { + // TODO: Detect access_token expired 401 + return false +} diff --git a/packages/oauth-client/src/oauth-resolver.ts b/packages/oauth-client/src/oauth-resolver.ts new file mode 100644 index 00000000000..b4785e2ae12 --- /dev/null +++ b/packages/oauth-client/src/oauth-resolver.ts @@ -0,0 +1,28 @@ +import { IdentityResolver, ResolvedIdentity } from '@atproto/identity-resolver' +import { + OAuthServerMetadata, + OAuthServerMetadataResolver, +} from '@atproto/oauth-server-metadata-resolver' + +export class OAuthResolver { + constructor( + readonly metadataResolver: OAuthServerMetadataResolver, + readonly identityResolver: IdentityResolver, + ) {} + + public async resolve(input: string): Promise< + Partial & { + url: URL + metadata: OAuthServerMetadata + } + > { + const identity = input.startsWith('https:') + ? // Allow using a PDS url directly as login input (e.g. when the handle does not resolve to a DID) + { url: new URL(input) } + : await this.identityResolver.resolve(input, 'AtprotoPersonalDataServer') + + const metadata = await this.metadataResolver.resolve(identity.url.origin) + + return { ...identity, metadata } + } +} diff --git a/packages/oauth-client/src/oauth-server-factory.ts b/packages/oauth-client/src/oauth-server-factory.ts new file mode 100644 index 00000000000..68799d3ed50 --- /dev/null +++ b/packages/oauth-client/src/oauth-server-factory.ts @@ -0,0 +1,80 @@ +import { GenericStore, MemoryStore } from '@atproto/caching' +import { Fetch } from '@atproto/fetch' +import { IdentityResolver } from '@atproto/identity-resolver' +import { Key, Keyset } from '@atproto/jwk' +import { OAuthClientMetadata } from '@atproto/oauth-client-metadata' +import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' +import { OAuthServerMetadataResolver } from '@atproto/oauth-server-metadata-resolver' +import { CryptoImplementation } from './crypto-implementation.js' +import { CryptoWrapper } from './crypto-wrapper.js' +import { OAuthResolver } from './oauth-resolver.js' +import { OAuthServer } from './oauth-server.js' +import { OAuthClientMetadataId } from './oauth-types.js' +import { validateClientMetadata } from './validate-client-metadata.js' + +export type OAuthServerFactoryOptions = { + clientMetadata: OAuthClientMetadata + metadataResolver: OAuthServerMetadataResolver + cryptoImplementation: CryptoImplementation + identityResolver: IdentityResolver + fetch?: Fetch + keyset?: Keyset + dpopNonceCache?: GenericStore +} + +export class OAuthServerFactory { + readonly clientMetadata: OAuthClientMetadataId + readonly metadataResolver: OAuthServerMetadataResolver + readonly crypto: CryptoWrapper + readonly resolver: OAuthResolver + readonly fetch: Fetch + readonly keyset?: Keyset + readonly dpopNonceCache: GenericStore + + constructor({ + metadataResolver, + identityResolver, + clientMetadata, + cryptoImplementation, + keyset, + fetch = globalThis.fetch, + dpopNonceCache = new MemoryStore({ + ttl: 60e3, + max: 100, + }), + }: OAuthServerFactoryOptions) { + validateClientMetadata(clientMetadata, keyset) + + if (!clientMetadata.client_id) { + throw new TypeError('A client_id property must be specified') + } + + this.clientMetadata = clientMetadata + this.metadataResolver = metadataResolver + this.keyset = keyset + this.fetch = fetch + this.dpopNonceCache = dpopNonceCache + + this.crypto = new CryptoWrapper(cryptoImplementation) + this.resolver = new OAuthResolver(metadataResolver, identityResolver) + } + + async fromIssuer(issuer: string, dpopKey: Key) { + const { origin } = new URL(issuer) + const serverMetadata = await this.metadataResolver.resolve(origin) + return this.fromMetadata(serverMetadata, dpopKey) + } + + async fromMetadata(serverMetadata: OAuthServerMetadata, dpopKey: Key) { + return new OAuthServer( + dpopKey, + serverMetadata, + this.clientMetadata, + this.dpopNonceCache, + this.resolver, + this.crypto, + this.keyset, + this.fetch, + ) + } +} diff --git a/packages/oauth-client/src/oauth-server.ts b/packages/oauth-client/src/oauth-server.ts new file mode 100644 index 00000000000..ebe9a02e24b --- /dev/null +++ b/packages/oauth-client/src/oauth-server.ts @@ -0,0 +1,287 @@ +import { GenericStore } from '@atproto/caching' +import { + Fetch, + fetchFailureHandler, + fetchJsonProcessor, + fetchOkProcessor, +} from '@atproto/fetch' +import { dpopFetchWrapper } from '@atproto/fetch-dpop' +import { Jwt, Key, Keyset } from '@atproto/jwk' +import { OAuthClientMetadata } from '@atproto/oauth-client-metadata' +import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' +import { FALLBACK_ALG } from './constants.js' +import { CryptoWrapper } from './crypto-wrapper.js' +import { OAuthResolver } from './oauth-resolver.js' +import { + OAuthEndpointName, + OAuthTokenResponse, + OAuthTokenType, +} from './oauth-types.js' + +export type TokenSet = { + iss: string + sub: string + aud: string + scope?: string + + id_token?: Jwt + refresh_token?: string + access_token: string + token_type: OAuthTokenType + expires_at?: number +} + +export class OAuthServer { + readonly dpopFetch: (request: Request) => Promise + + constructor( + readonly dpopKey: Key, + readonly serverMetadata: OAuthServerMetadata, + readonly clientMetadata: OAuthClientMetadata & { client_id: string }, + readonly dpopNonceCache: GenericStore, + readonly resolver: OAuthResolver, + readonly crypto: CryptoWrapper, + readonly keyset?: Keyset, + fetch?: Fetch, + ) { + const dpopFetch = dpopFetchWrapper({ + fetch, + iss: this.clientMetadata.client_id, + key: dpopKey, + alg: negotiateAlg( + dpopKey, + serverMetadata.dpop_signing_alg_values_supported, + ), + sha256: async (v) => crypto.sha256(v), + nonceCache: dpopNonceCache, + }) + + this.dpopFetch = (request) => dpopFetch(request).catch(fetchFailureHandler) + } + + async revoke(token: string) { + try { + await this.request('revocation', { token }) + } catch { + // Don't care + } + } + + async exchangeCode(code: string, verifier?: string): Promise { + const { json: tokenResponse } = await this.request('token', { + grant_type: 'authorization_code', + redirect_uri: this.clientMetadata.redirect_uris[0]!, + code, + code_verifier: verifier, + }) + + try { + if (!tokenResponse.sub) { + throw new TypeError(`Missing "sub" in token response`) + } + + // VERY IMPORTANT ! + const resolved = await this.checkSubIssuer(tokenResponse.sub) + + return { + sub: tokenResponse.sub, + aud: resolved.url.href, + iss: resolved.metadata.issuer, + + scope: tokenResponse.scope, + id_token: tokenResponse.id_token, + refresh_token: tokenResponse.refresh_token, + access_token: tokenResponse.access_token, + token_type: tokenResponse.token_type ?? 'Bearer', + expires_at: + typeof tokenResponse.expires_in === 'number' + ? Date.now() + tokenResponse.expires_in * 1000 + : undefined, + } + } catch (err) { + await this.revoke(tokenResponse.access_token) + + throw err + } + } + + async refresh(tokenSet: TokenSet): Promise { + if (!tokenSet.refresh_token) { + throw new Error('No refresh token available') + } + + const { json: tokenResponse } = await this.request('token', { + grant_type: 'refresh_token', + refresh_token: tokenSet.refresh_token, + }) + + try { + if (tokenSet.sub !== tokenResponse.sub) { + throw new TypeError(`Unexpected "sub" in token response`) + } + if (tokenSet.iss !== this.serverMetadata.issuer) { + throw new TypeError('Issuer mismatch') + } + + // VERY IMPORTANT ! + const resolved = await this.checkSubIssuer(tokenResponse.sub) + + return { + sub: tokenResponse.sub, + aud: resolved.url.href, + iss: resolved.metadata.issuer, + + id_token: tokenResponse.id_token, + refresh_token: tokenResponse.refresh_token, + access_token: tokenResponse.access_token, + token_type: tokenResponse.token_type ?? 'Bearer', + expires_at: Date.now() + (tokenResponse.expires_in ?? 60) * 1000, + } + } catch (err) { + await this.revoke(tokenResponse.access_token) + + throw err + } + } + + /** + * Whenever an OAuth token response is received, we **MUST** verify that the + * "sub" is a DID, whose issuer authority is indeed the server we just + * obtained credentials from. This check is a critical step to actually be + * able to use the "sub" (DID) as being the actual user's identifier. + */ + protected async checkSubIssuer(sub: string) { + const resolved = await this.resolver.resolve(sub) + if (resolved.metadata.issuer !== this.serverMetadata.issuer) { + // Maybe the user switched PDS. + throw new TypeError('Issuer mismatch') + } + return resolved + } + + async request( + endpoint: E, + payload: Record, + ) { + const url = this.serverMetadata[`${endpoint}_endpoint`] + if (!url) throw new Error(`No ${endpoint} endpoint available`) + const auth = await this.buildClientAuth(endpoint) + + const request = new Request(url, { + method: 'POST', + headers: { ...auth.headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...payload, ...auth.payload }), + }) + + const response = await this.dpopFetch(request) + .then(fetchOkProcessor()) + .then( + fetchJsonProcessor< + E extends 'pushed_authorization_request' + ? { request_uri: string } + : E extends 'token' + ? OAuthTokenResponse + : unknown + >(), + ) + + // TODO: validate using zod ? + if (endpoint === 'token') { + if (!response.json['access_token']) { + throw new TypeError('No access token in token response') + } + } + + return response + } + + async buildClientAuth(endpoint: OAuthEndpointName): Promise<{ + headers?: Record + payload: + | { + client_id: string + } + | { + client_id: string + client_assertion_type: string + client_assertion: string + } + }> { + const methodSupported = + this.serverMetadata[`${endpoint}_endpoint_auth_methods_supported`] || + this.serverMetadata[`token_endpoint_auth_methods_supported`] + + const method = + this.clientMetadata[`${endpoint}_endpoint_auth_method`] || + this.clientMetadata[`token_endpoint_auth_method`] + + if ( + method === 'private_key_jwt' || + (this.keyset && + !method && + (methodSupported?.includes('private_key_jwt') ?? false)) + ) { + if (!this.keyset) throw new Error('No keyset available') + + try { + const alg = + this.serverMetadata[ + `${endpoint}_endpoint_auth_signing_alg_values_supported` + ] ?? + this.serverMetadata[ + `token_endpoint_auth_signing_alg_values_supported` + ] ?? + FALLBACK_ALG + + // If jwks is defined, make sure to only sign using a key that exists in + // the jwks. If jwks_uri is defined, we can't be sure that the key we're + // looking for is in there so we will just assume it is. + const kid = this.clientMetadata.jwks?.keys + .map(({ kid }) => kid) + .filter((v): v is string => !!v) + + return { + payload: { + client_id: this.clientMetadata.client_id, + client_assertion_type: + 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: await this.keyset.sign( + { alg, kid }, + { + iss: this.clientMetadata.client_id, + sub: this.clientMetadata.client_id, + aud: this.serverMetadata.issuer, + jti: await this.crypto.generateNonce(), + iat: Math.floor(Date.now() / 1000), + }, + ), + }, + } + } catch (err) { + if (method === 'private_key_jwt') throw err + + // Else try next method + } + } + + if ( + method === 'none' || + (!method && (methodSupported?.includes('none') ?? true)) + ) { + return { + payload: { + client_id: this.clientMetadata.client_id, + }, + } + } + + throw new Error(`Unsupported ${endpoint} authentication method`) + } +} + +function negotiateAlg(key: Key, supportedAlgs: string[] | undefined): string { + const alg = key.algorithms.find((a) => supportedAlgs?.includes(a) ?? true) + if (alg) return alg + + throw new Error('Key does not match any alg supported by the server') +} diff --git a/packages/oauth-client/src/oauth-types.ts b/packages/oauth-client/src/oauth-types.ts new file mode 100644 index 00000000000..66162536077 --- /dev/null +++ b/packages/oauth-client/src/oauth-types.ts @@ -0,0 +1,36 @@ +import { Jwt } from '@atproto/jwk' +import { OAuthClientMetadata } from '@atproto/oauth-client-metadata' + +export type OAuthResponseMode = 'query' | 'fragment' | 'form_post' +export type OAuthResponseType = 'code' | 'code id_token' + +export type OAuthEndpointName = + | 'token' + | 'revocation' + | 'introspection' + | 'pushed_authorization_request' + +export type OAuthTokenType = 'Bearer' | 'DPoP' + +export type OAuthAuthorizeOptions = { + display?: 'page' | 'popup' | 'touch' | 'wap' + id_token_hint?: string + max_age?: number + prompt?: 'login' | 'none' | 'consent' | 'select_account' + scope?: string + state?: string + ui_locales?: string +} + +export type OAuthTokenResponse = { + issuer?: string + sub?: string + scope?: string + id_token?: Jwt + refresh_token?: string + access_token: string + token_type?: OAuthTokenType + expires_in?: number +} + +export type OAuthClientMetadataId = OAuthClientMetadata & { client_id: string } diff --git a/packages/oauth-client/src/session-getter.ts b/packages/oauth-client/src/session-getter.ts new file mode 100644 index 00000000000..d136bed5472 --- /dev/null +++ b/packages/oauth-client/src/session-getter.ts @@ -0,0 +1,140 @@ +import { CachedGetter, GenericStore } from '@atproto/caching' +import { FetchResponseError } from '@atproto/fetch' +import { Key } from '@atproto/jwk' +import { OAuthServerFactory } from './oauth-server-factory.js' +import { TokenSet } from './oauth-server.js' + +export type Session = { + dpopKey: Key + tokenSet: TokenSet +} + +/** + * There are several advantages to wrapping the sessionStore in a (single) + * CachedGetter, the main of which is that the cached getter will ensure that at + * most one fresh call is ever being made. Another advantage, is that it + * contains the logic for reading from the cache which, if the cache is based on + * localStorage/indexedDB, will sync across multiple tabs (for a given + * sessionId). + */ +export class SessionGetter extends CachedGetter { + constructor( + sessionStore: GenericStore, + serverFactory: OAuthServerFactory, + ) { + super( + async (sessionId, options, storedSession) => { + // There needs to be a previous session to be able to refresh + if (storedSession === undefined) { + throw new Error('The session was revoked') + } + + // Since refresh tokens can only be used once, we might run into + // concurrency issues if multiple tabs/instances are trying to refresh + // the same token. The chances of this happening when multiple instances + // are started simultaneously is reduced by randomizing the expiry time + // (see isStale() bellow). Even so, There still exist chances that + // multiple tabs will try to refresh the token at the same time. The + // best solution would be to use a mutex/lock to ensure that only one + // instance is refreshing the token at a time. A simpler workaround is + // to check if the value stored in the session store is the same as the + // one in memory. If it isn't, then another instance has already + // refreshed the token. + + const { tokenSet, dpopKey } = storedSession + const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey) + const newTokenSet = await server + .refresh(tokenSet) + .catch(async (err) => { + if (await isRefreshDeniedError(err)) { + // Allow some time for the concurrent request to be stored before + // we try to get it. + await new Promise((r) => setTimeout(r, 500)) + + const stored = await this.getStored(sessionId) + if (stored !== undefined) { + if ( + stored.tokenSet.access_token !== tokenSet.access_token || + stored.tokenSet.refresh_token !== tokenSet.refresh_token + ) { + // A concurrent refresh occurred. Pretend this one succeeded. + return stored.tokenSet + } else { + // The session data will be deleted from the sessionStore by + // the "deleteOnError" callback. + } + } + } + + throw err + }) + return { ...storedSession, tokenSet: newTokenSet } + }, + sessionStore, + { + isStale: (sessionId, { tokenSet }) => { + return ( + tokenSet.expires_at != null && + tokenSet.expires_at < + Date.now() + + // Add some lee way to ensure the token is not expired when it + // reaches the server. + 30e3 + + // Add some randomness to prevent all instances from trying to + // refreshing at the exact same time, when they are started at + // the same time. + 60e3 * Math.random() + ) + }, + onStoreError: async (err, sessionId, { tokenSet, dpopKey }) => { + // If the token data cannot be stored, let's revoke it + const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey) + await server.revoke(tokenSet.access_token) + throw err + }, + deleteOnError: async (err, sessionId, { tokenSet }) => { + // Not possible to refresh without a refresh token + if (!tokenSet.refresh_token) return true + + // If fetching a refresh token fails because they are no longer valid, + // delete the session from the sessionStore. + if (await isRefreshDeniedError(err)) return true + + // Unknown cause, keep the session in the store + return false + }, + }, + ) + } + + /** + * @param refresh When `true`, the credentials will be refreshed even if they + * are not expired. When `false`, the credentials will not be refreshed even + * if they are expired. When `undefined`, the credentials will be refreshed + * if, and only if, they are (about to be) expired. Defaults to `undefined`. + */ + async getSession(sessionId: string, refresh?: boolean) { + return this.get(sessionId, { + noCache: refresh === true, + allowStale: refresh === false, + }) + } +} + +async function isRefreshDeniedError(err: unknown) { + if (err instanceof FetchResponseError && err.statusCode === 400) { + if (err.response?.bodyUsed === false) { + try { + const json = await err.response.clone().json() + return ( + json.error === 'invalid_request' && + json.error_description === 'Invalid refresh token' + ) + } catch { + // falls through + } + } + } + + return false +} diff --git a/packages/oauth-client/src/validate-client-metadata.ts b/packages/oauth-client/src/validate-client-metadata.ts new file mode 100644 index 00000000000..b7bdc138e06 --- /dev/null +++ b/packages/oauth-client/src/validate-client-metadata.ts @@ -0,0 +1,52 @@ +import { Keyset } from '@atproto/jwk' +import { OAuthClientMetadata } from '@atproto/oauth-client-metadata' + +export function validateClientMetadata( + metadata: OAuthClientMetadata, + keyset?: Keyset, +): asserts metadata is OAuthClientMetadata & { client_id: string } { + if (!metadata.client_id) { + throw new TypeError('client_id must be provided') + } + + const url = new URL(metadata.client_id) + if (url.pathname !== '/') { + throw new TypeError('origin must be a URL root') + } + if (url.href !== metadata.client_id) { + throw new TypeError('client_id must be a normalized URL') + } + + if (metadata.client_uri && metadata.client_uri !== metadata.client_id) { + throw new TypeError('client_uri must match client_id') + } + + if (!metadata.redirect_uris.length) { + throw new TypeError('At least one redirect_uri must be provided') + } + for (const u of metadata.redirect_uris) { + const redirectUrl = new URL(u) + if (redirectUrl.origin !== url.origin) { + throw new TypeError('redirect_uris must have the same origin') + } + } + + for (const endpoint of [ + 'token', + 'revocation', + 'introspection', + 'pushed_authorization_request', + ] as const) { + const method = metadata[`${endpoint}_endpoint_auth_method`] + if (method && method !== 'none') { + if (!keyset) { + throw new TypeError(`Keyset is required for ${method} method`) + } + if (!metadata[`${endpoint}_endpoint_auth_signing_alg`]) { + throw new TypeError( + `${endpoint}_endpoint_auth_signing_alg must be provided`, + ) + } + } + } +} diff --git a/packages/oauth-client/tsconfig.build.json b/packages/oauth-client/tsconfig.build.json new file mode 100644 index 00000000000..436d8ecb628 --- /dev/null +++ b/packages/oauth-client/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/isomorphic.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/oauth-client/tsconfig.json b/packages/oauth-client/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/oauth-client/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index cbdbfc8a2bb..5653a7a9ceb 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -224,7 +224,12 @@ export class OAuthProvider extends OAuthVerifier { this.accountManager = new AccountManager(accountStore) this.clientManager = new ClientManager(clientStore, this.keyset, hooks) - this.requestManager = new RequestManager(requestStore, this.signer, hooks) + this.requestManager = new RequestManager( + requestStore, + this.signer, + this.metadata, + hooks, + ) this.tokenManager = new TokenManager( tokenStore, this.signer, diff --git a/packages/oauth-provider/src/request/request-manager.ts b/packages/oauth-provider/src/request/request-manager.ts index 174c590c33e..36fba99f7ff 100644 --- a/packages/oauth-provider/src/request/request-manager.ts +++ b/packages/oauth-provider/src/request/request-manager.ts @@ -1,4 +1,5 @@ import { OAuthClientId } from '@atproto/oauth-client-metadata' +import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' import { DeviceAccountInfo } from '../account/account-store.js' import { Account } from '../account/account.js' @@ -56,6 +57,7 @@ export class RequestManager { constructor( protected readonly store: RequestStore, protected readonly signer: Signer, + protected readonly metadata: OAuthServerMetadata, protected readonly hooks: { onAuthorizationRequest?: AuthorizationRequestHook }, @@ -117,7 +119,49 @@ export class RequestManager { dpopJkt: null | string, pkceRequired = this.pkceRequired, ): Promise { - const scopes = parameters.scope?.split(' ') + // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-10#section-1.4.1 + // > The authorization server MAY fully or partially ignore the scope + // > requested by the client, based on the authorization server policy or + // > the resource owner's instructions. If the issued access token scope is + // > different from the one requested by the client, the authorization + // > server MUST include the scope response parameter in the token response + // > (Section 3.2.3) to inform the client of the actual scope granted. + + const cScopes = client.metadata.scope?.split(' ') + const sScopes = this.metadata.scopes_supported + + const scopes = + (parameters.scope || client.metadata.scope) + ?.split(' ') + .filter((scope) => !!scope && (sScopes?.includes(scope) ?? true)) ?? [] + + for (const scope of scopes) { + if (!cScopes?.includes(scope)) { + throw new InvalidParametersError( + parameters, + `Scope "${scope}" is not registered for this client`, + ) + } + } + + for (const [scope, claims] of Object.entries(OIDC_SCOPE_CLAIMS)) { + for (const claim of claims) { + if ( + parameters?.claims?.id_token?.[claim]?.essential === true || + parameters?.claims?.userinfo?.[claim]?.essential === true + ) { + if (!scopes?.includes(scope)) { + throw new InvalidParametersError( + parameters, + `Essential ${claim} claim requires "${scope}" scope`, + ) + } + } + } + } + + parameters = { ...parameters, scope: scopes.join(' ') } + const responseTypes = parameters.response_type.split(' ') if (parameters.authorization_details) { @@ -225,45 +269,13 @@ export class RequestManager { if (responseTypes.includes('id_token') && !scopes?.includes('openid')) { throw new InvalidParametersError( parameters, - 'id_token response_type requires openid scope', + '"id_token" response_type requires "openid" scope', ) } // TODO Validate parameters against **all** client metadata (are some checks // missing?) !!! - if (!parameters.scope) { - parameters = { ...parameters, scope: client.metadata.scope } - } - - if (scopes) { - const cScopes = client.metadata.scope?.split(' ') - for (const scope of scopes) { - if (!cScopes?.includes(scope)) { - throw new InvalidParametersError( - parameters, - `Scope "${scope}" is not registered for this client`, - ) - } - } - } - - for (const [scope, claims] of Object.entries(OIDC_SCOPE_CLAIMS)) { - for (const claim of claims) { - if ( - parameters?.claims?.id_token?.[claim]?.essential === true || - parameters?.claims?.userinfo?.[claim]?.essential === true - ) { - if (!scopes?.includes(scope)) { - throw new InvalidParametersError( - parameters, - `Essential ${claim} claim requires "${scope}" scope`, - ) - } - } - } - } - // Make "expensive" checks after the "cheaper" checks if (parameters.id_token_hint != null) { diff --git a/packages/oauth-provider/src/token/token-manager.ts b/packages/oauth-provider/src/token/token-manager.ts index cd3fdfe97a8..2069af47274 100644 --- a/packages/oauth-provider/src/token/token-manager.ts +++ b/packages/oauth-provider/src/token/token-manager.ts @@ -58,7 +58,7 @@ export type TokenResponse = { token_type?: TokenType expires_in?: number refresh_token?: RefreshToken - scope?: string + scope: string authorization_details?: AuthorizationDetails // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 @@ -287,7 +287,7 @@ export class TokenManager { token_type: parameters.dpop_jkt ? 'DPoP' : 'Bearer', refresh_token: refreshToken, id_token: idToken, - scope: parameters.scope, + scope: parameters.scope ?? '', authorization_details: authorizationDetails, get expires_in() { return dateToRelativeSeconds(expiresAt) @@ -435,7 +435,7 @@ export class TokenManager { access_token: accessToken, token_type: parameters.dpop_jkt ? 'DPoP' : 'Bearer', refresh_token: nextRefreshToken, - scope: parameters.scope, + scope: parameters.scope ?? '', authorization_details, get expires_in() { return dateToRelativeSeconds(expiresAt) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65a8a75415a..6a89da3281f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -499,6 +499,16 @@ importers: specifier: workspace:* version: link:../fetch-node + packages/disposable-polyfill: + dependencies: + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/fetch: dependencies: '@atproto/transformer': @@ -685,6 +695,19 @@ importers: specifier: ^5.3.3 version: 5.4.4 + packages/indexed-db: + dependencies: + '@atproto/disposable-polyfill': + specifier: workspace:* + version: link:../disposable-polyfill + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/jwk: dependencies: '@atproto/b64': @@ -733,6 +756,22 @@ importers: specifier: ^5.3.3 version: 5.4.4 + packages/jwk-webcrypto: + dependencies: + '@atproto/indexed-db': + specifier: workspace:* + version: link:../indexed-db + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/jwk-jose': + specifier: workspace:* + version: link:../jwk-jose + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/lex-cli: dependencies: '@atproto/lexicon': @@ -782,6 +821,143 @@ importers: specifier: ^28.1.2 version: 28.1.2(@types/node@18.19.24)(ts-node@10.8.2) + packages/oauth-client: + dependencies: + '@atproto/b64': + specifier: workspace:* + version: link:../b64 + '@atproto/caching': + specifier: workspace:* + version: link:../caching + '@atproto/did': + specifier: workspace:* + version: link:../did + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/fetch-dpop': + specifier: workspace:* + version: link:../fetch-dpop + '@atproto/handle-resolver': + specifier: workspace:* + version: link:../handle-resolver + '@atproto/identity-resolver': + specifier: workspace:* + version: link:../identity-resolver + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/oauth-client-metadata': + specifier: workspace:* + version: link:../oauth-client-metadata + '@atproto/oauth-server-metadata': + specifier: workspace:* + version: link:../oauth-server-metadata + '@atproto/oauth-server-metadata-resolver': + specifier: workspace:* + version: link:../oauth-server-metadata-resolver + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/oauth-client-browser: + dependencies: + '@atproto/caching': + specifier: workspace:* + version: link:../caching + '@atproto/did': + specifier: workspace:* + version: link:../did + '@atproto/disposable-polyfill': + specifier: workspace:* + version: link:../disposable-polyfill + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/handle-resolver': + specifier: workspace:* + version: link:../handle-resolver + '@atproto/identity-resolver': + specifier: workspace:* + version: link:../identity-resolver + '@atproto/indexed-db': + specifier: workspace:* + version: link:../indexed-db + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/jwk-jose': + specifier: workspace:* + version: link:../jwk-jose + '@atproto/jwk-webcrypto': + specifier: workspace:* + version: link:../jwk-webcrypto + '@atproto/oauth-client': + specifier: workspace:* + version: link:../oauth-client + '@atproto/oauth-client-metadata': + specifier: workspace:* + version: link:../oauth-client-metadata + '@atproto/oauth-server-metadata': + specifier: workspace:* + version: link:../oauth-server-metadata + '@atproto/oauth-server-metadata-resolver': + specifier: workspace:* + version: link:../oauth-server-metadata-resolver + devDependencies: + typescript: + specifier: ^5.3.3 + version: 5.4.4 + + packages/oauth-client-browser-example: + devDependencies: + '@atproto/oauth-client': + specifier: workspace:* + version: link:../oauth-client + '@atproto/oauth-client-browser': + specifier: workspace:* + version: link:../oauth-client-browser + '@atproto/oauth-client-metadata': + specifier: workspace:* + version: link:../oauth-client-metadata + '@atproto/oauth-server-metadata': + specifier: workspace:* + version: link:../oauth-server-metadata + '@babel/plugin-syntax-import-assertions': + specifier: ^7.23.3 + version: 7.24.1(@babel/core@7.18.6) + '@types/react': + specifier: ^18.2.50 + version: 18.2.75 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.2.24 + '@vitejs/plugin-react': + specifier: ^4.2.1 + version: 4.2.1(vite@5.2.8) + autoprefixer: + specifier: ^10.4.17 + version: 10.4.19(postcss@8.4.38) + postcss: + specifier: ^8.4.33 + version: 8.4.38 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + tailwindcss: + specifier: ^3.4.1 + version: 3.4.3 + typescript: + specifier: ^5.3.3 + version: 5.4.4 + vite: + specifier: ^5.0.12 + version: 5.2.8(@types/node@18.19.24) + packages/oauth-client-metadata: dependencies: '@atproto/jwk': @@ -3902,11 +4078,24 @@ packages: chalk: 2.4.2 dev: true + /@babel/code-frame@7.24.2: + resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.24.2 + picocolors: 1.0.0 + dev: true + /@babel/compat-data@7.22.9: resolution: {integrity: sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==} engines: {node: '>=6.9.0'} dev: true + /@babel/compat-data@7.24.4: + resolution: {integrity: sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/core@7.18.6: resolution: {integrity: sha512-cQbWBpxcbbs/IUredIPkHiAGULLV8iwgNRMFzvbhEXISp4f3rUUXE5+TIw6KwUWUR3DwyI6gmBRnmAtYaWehwQ==} engines: {node: '>=6.9.0'} @@ -3930,6 +4119,29 @@ packages: - supports-color dev: true + /@babel/core@7.24.4: + resolution: {integrity: sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.2.1 + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helpers': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + convert-source-map: 2.0.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/generator@7.22.10: resolution: {integrity: sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==} engines: {node: '>=6.9.0'} @@ -3940,6 +4152,16 @@ packages: jsesc: 2.5.2 dev: true + /@babel/generator@7.24.4: + resolution: {integrity: sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + dev: true + /@babel/helper-compilation-targets@7.22.10: resolution: {integrity: sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==} engines: {node: '>=6.9.0'} @@ -3951,6 +4173,22 @@ packages: semver: 6.3.1 dev: true + /@babel/helper-compilation-targets@7.23.6: + resolution: {integrity: sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/helper-validator-option': 7.23.5 + browserslist: 4.23.0 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true + + /@babel/helper-environment-visitor@7.22.20: + resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-environment-visitor@7.22.5: resolution: {integrity: sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==} engines: {node: '>=6.9.0'} @@ -3964,6 +4202,14 @@ packages: '@babel/types': 7.22.10 dev: true + /@babel/helper-function-name@7.23.0: + resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.0 + '@babel/types': 7.24.0 + dev: true + /@babel/helper-hoist-variables@7.22.5: resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} engines: {node: '>=6.9.0'} @@ -3978,6 +4224,13 @@ packages: '@babel/types': 7.22.10 dev: true + /@babel/helper-module-imports@7.24.3: + resolution: {integrity: sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: true + /@babel/helper-module-transforms@7.22.9(@babel/core@7.18.6): resolution: {integrity: sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==} engines: {node: '>=6.9.0'} @@ -3992,11 +4245,30 @@ packages: '@babel/helper-validator-identifier': 7.22.5 dev: true + /@babel/helper-module-transforms@7.23.3(@babel/core@7.24.4): + resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-simple-access': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/helper-validator-identifier': 7.22.20 + dev: true + /@babel/helper-plugin-utils@7.22.5: resolution: {integrity: sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==} engines: {node: '>=6.9.0'} dev: true + /@babel/helper-plugin-utils@7.24.0: + resolution: {integrity: sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-simple-access@7.22.5: resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} engines: {node: '>=6.9.0'} @@ -4016,6 +4288,16 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-string-parser@7.24.1: + resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helper-validator-identifier@7.22.20: + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-validator-identifier@7.22.5: resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} engines: {node: '>=6.9.0'} @@ -4026,6 +4308,11 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-validator-option@7.23.5: + resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helpers@7.22.10: resolution: {integrity: sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==} engines: {node: '>=6.9.0'} @@ -4037,6 +4324,17 @@ packages: - supports-color dev: true + /@babel/helpers@7.24.4: + resolution: {integrity: sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/highlight@7.22.10: resolution: {integrity: sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==} engines: {node: '>=6.9.0'} @@ -4046,6 +4344,16 @@ packages: js-tokens: 4.0.0 dev: true + /@babel/highlight@7.24.2: + resolution: {integrity: sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.22.20 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.0 + dev: true + /@babel/parser@7.22.10: resolution: {integrity: sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==} engines: {node: '>=6.0.0'} @@ -4054,6 +4362,14 @@ packages: '@babel/types': 7.22.10 dev: true + /@babel/parser@7.24.4: + resolution: {integrity: sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.24.0 + dev: true + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.18.6): resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: @@ -4081,6 +4397,16 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true + /@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.18.6): + resolution: {integrity: sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.18.6): resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} peerDependencies: @@ -4173,6 +4499,26 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true + /@babel/plugin-transform-react-jsx-self@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-react-jsx-source@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + /@babel/runtime@7.22.10: resolution: {integrity: sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==} engines: {node: '>=6.9.0'} @@ -4189,6 +4535,15 @@ packages: '@babel/types': 7.22.10 dev: true + /@babel/template@7.24.0: + resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + dev: true + /@babel/traverse@7.22.10: resolution: {integrity: sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==} engines: {node: '>=6.9.0'} @@ -4207,6 +4562,24 @@ packages: - supports-color dev: true + /@babel/traverse@7.24.1: + resolution: {integrity: sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/types@7.22.10: resolution: {integrity: sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==} engines: {node: '>=6.9.0'} @@ -4216,6 +4589,15 @@ packages: to-fast-properties: 2.0.0 dev: true + /@babel/types@7.24.0: + resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.1 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + dev: true + /@bcoe/v8-coverage@0.2.3: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true @@ -4767,6 +5149,213 @@ packages: - pg-native - supports-color + /@esbuild/aix-ppc64@0.20.2: + resolution: {integrity: sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.20.2: + resolution: {integrity: sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.20.2: + resolution: {integrity: sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.20.2: + resolution: {integrity: sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.20.2: + resolution: {integrity: sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.20.2: + resolution: {integrity: sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.20.2: + resolution: {integrity: sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.20.2: + resolution: {integrity: sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.20.2: + resolution: {integrity: sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.20.2: + resolution: {integrity: sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.20.2: + resolution: {integrity: sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.20.2: + resolution: {integrity: sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.20.2: + resolution: {integrity: sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.20.2: + resolution: {integrity: sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.20.2: + resolution: {integrity: sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.20.2: + resolution: {integrity: sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.20.2: + resolution: {integrity: sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.20.2: + resolution: {integrity: sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.20.2: + resolution: {integrity: sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.20.2: + resolution: {integrity: sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.20.2: + resolution: {integrity: sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.20.2: + resolution: {integrity: sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.20.2: + resolution: {integrity: sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@eslint-community/eslint-utils@4.4.0(eslint@8.57.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5811,6 +6400,16 @@ packages: '@types/babel__traverse': 7.20.1 dev: true + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.22.10 + '@babel/types': 7.22.10 + '@types/babel__generator': 7.6.4 + '@types/babel__template': 7.4.1 + '@types/babel__traverse': 7.20.1 + dev: true + /@types/babel__generator@7.6.4: resolution: {integrity: sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==} dependencies: @@ -6216,6 +6815,22 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /@vitejs/plugin-react@4.2.1(vite@5.2.8): + resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/plugin-transform-react-jsx-self': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-react-jsx-source': 7.24.1(@babel/core@7.24.4) + '@types/babel__core': 7.20.5 + react-refresh: 0.14.0 + vite: 5.2.8(@types/node@18.19.24) + transitivePeerDependencies: + - supports-color + dev: true + /@web/rollup-plugin-import-meta-assets@2.2.1(rollup@4.14.1): resolution: {integrity: sha512-nG7nUQqSJWdl63pBTmnIElJuFi2V1x9eVje19BJuFvfz266jSmZtX3m30ncb7fOJxQt3/ge+FVL8tuNI9+63dQ==} engines: {node: '>=18.0.0'} @@ -7107,6 +7722,10 @@ packages: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} dev: true + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + dev: true + /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -7927,6 +8546,37 @@ packages: esbuild-windows-arm64: 0.14.48 dev: true + /esbuild@0.20.2: + resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.20.2 + '@esbuild/android-arm': 0.20.2 + '@esbuild/android-arm64': 0.20.2 + '@esbuild/android-x64': 0.20.2 + '@esbuild/darwin-arm64': 0.20.2 + '@esbuild/darwin-x64': 0.20.2 + '@esbuild/freebsd-arm64': 0.20.2 + '@esbuild/freebsd-x64': 0.20.2 + '@esbuild/linux-arm': 0.20.2 + '@esbuild/linux-arm64': 0.20.2 + '@esbuild/linux-ia32': 0.20.2 + '@esbuild/linux-loong64': 0.20.2 + '@esbuild/linux-mips64el': 0.20.2 + '@esbuild/linux-ppc64': 0.20.2 + '@esbuild/linux-riscv64': 0.20.2 + '@esbuild/linux-s390x': 0.20.2 + '@esbuild/linux-x64': 0.20.2 + '@esbuild/netbsd-x64': 0.20.2 + '@esbuild/openbsd-x64': 0.20.2 + '@esbuild/sunos-x64': 0.20.2 + '@esbuild/win32-arm64': 0.20.2 + '@esbuild/win32-ia32': 0.20.2 + '@esbuild/win32-x64': 0.20.2 + dev: true + /escalade@3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} @@ -11484,6 +12134,11 @@ packages: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true + /react-refresh@0.14.0: + resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} + engines: {node: '>=0.10.0'} + dev: true + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -12260,7 +12915,7 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true dependencies: - '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/gen-mapping': 0.3.5 commander: 4.1.1 glob: 10.3.10 lines-and-columns: 1.2.4 @@ -12843,6 +13498,42 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + /vite@5.2.8(@types/node@18.19.24): + resolution: {integrity: sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 18.19.24 + esbuild: 0.20.2 + postcss: 8.4.38 + rollup: 4.14.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: diff --git a/tsconfig.json b/tsconfig.json index a046a1f382b..81f72cfb29b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,8 @@ { "path": "./packages/jwk-webcrypto" }, { "path": "./packages/lex-cli" }, { "path": "./packages/lexicon" }, + { "path": "./packages/oauth-client" }, + { "path": "./packages/oauth-client-browser" }, { "path": "./packages/oauth-client-metadata" }, { "path": "./packages/oauth-provider" }, { "path": "./packages/oauth-provider-client-uri" }, From 08814d11a0db9aff5b046b5a0d7be9ceb8ec1944 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 15:28:57 +0200 Subject: [PATCH 077/140] docs(oauth-provider): improve jsdoc --- packages/oauth-provider/src/client/client-manager.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/oauth-provider/src/client/client-manager.ts b/packages/oauth-provider/src/client/client-manager.ts index 75f8ee88637..864f3c842ba 100644 --- a/packages/oauth-provider/src/client/client-manager.ts +++ b/packages/oauth-provider/src/client/client-manager.ts @@ -14,7 +14,11 @@ import { parseRedirectUri } from './client-utils.js' import { Client } from './client.js' /** - * Use this to alter or override client metadata & jwks before they are used. + * Use this to alter, override or validate the client metadata & jwks returned + * by the client store. + * + * @throws {InvalidClientMetadataError} if the metadata is invalid + * @see {@link InvalidClientMetadataError} */ export type ClientDataHook = ( clientId: OAuthClientId, @@ -319,6 +323,9 @@ export class ClientManager { return { metadata, jwks } } catch (err) { if (err instanceof OAuthError) throw err + if ((err as any)?.code === 'DEPTH_ZERO_SELF_SIGNED_CERT') { + throw new InvalidClientMetadataError('Self-signed certificate', err) + } throw new InvalidClientMetadataError('Unable to obtain metadata', err) } } From 885fc51237cc91cdded5b025c713cc607ad45bc6 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 15:30:13 +0200 Subject: [PATCH 078/140] feat(oauth-provider): better define "signIn" API argument --- packages/oauth-provider/src/assets/app/lib/api.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/oauth-provider/src/assets/app/lib/api.ts b/packages/oauth-provider/src/assets/app/lib/api.ts index 7b72475b855..11222aa8580 100644 --- a/packages/oauth-provider/src/assets/app/lib/api.ts +++ b/packages/oauth-provider/src/assets/app/lib/api.ts @@ -4,7 +4,6 @@ import { fetchOkProcessor, } from '@atproto/fetch' -import { SignInFormOutput } from '../components/sign-in-form' import { Account, Info, Session } from '../types' export class Api { @@ -15,7 +14,11 @@ export class Api { private newSessionsRequireConsent: boolean, ) {} - async signIn(credentials: SignInFormOutput): Promise { + async signIn(credentials: { + username: string + password: string + remember?: boolean + }): Promise { const { json } = await fetch('/oauth/authorize/sign-in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, From 2ade64a5b01fe7ae5e140e22ef5ec7db69cc7129 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 15:31:00 +0200 Subject: [PATCH 079/140] refactor(pds): implement BasicProfileGetter using CachedGetter --- packages/pds/package.json | 2 +- packages/pds/src/auth-provider.ts | 58 +++++++++++-------------------- pnpm-lock.yaml | 3 ++ 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/packages/pds/package.json b/packages/pds/package.json index 357c3b54654..c14020d3457 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -31,6 +31,7 @@ "dependencies": { "@atproto/api": "workspace:^", "@atproto/aws": "workspace:^", + "@atproto/caching": "workspace:^", "@atproto/common": "workspace:^", "@atproto/crypto": "workspace:^", "@atproto/fetch": "workspace:*", @@ -64,7 +65,6 @@ "jose": "^5.0.1", "key-encoder": "^2.0.3", "kysely": "^0.22.0", - "lru-cache": "^10.2.0", "multiformats": "^9.9.0", "nodemailer": "^6.8.0", "nodemailer-html-to-text": "^3.2.0", diff --git a/packages/pds/src/auth-provider.ts b/packages/pds/src/auth-provider.ts index 8576dcd854a..14b35a2c705 100644 --- a/packages/pds/src/auth-provider.ts +++ b/packages/pds/src/auth-provider.ts @@ -1,4 +1,3 @@ -import { LRUCache } from 'lru-cache' import { safeFetchWrap } from '@atproto/fetch-node' import { AccessTokenType, @@ -13,14 +12,15 @@ import { } from '@atproto/oauth-provider' import { OAuthReplayStoreMemory } from '@atproto/oauth-provider-replay-memory' import { OAuthReplayStoreRedis } from '@atproto/oauth-provider-replay-redis' - import { Redis } from 'ioredis' + +import { CachedGetter } from '@atproto/caching' import { AccountManager } from './account-manager' +import { ActorStore } from './actor-store' +import { ProfileViewBasic } from './lexicon/types/app/bsky/actor/defs' import { fetchLogger, oauthLogger } from './logger' import { OauthClientStore } from './oauth/oauth-client-store' -import { ActorStore } from './actor-store' import { LocalViewerCreator } from './read-after-write' -import { ProfileViewBasic } from './lexicon/types/app/bsky/actor/defs' export type AuthProviderOptions = { issuer: string @@ -61,7 +61,7 @@ export class AuthProvider extends OAuthProvider { // profile information using the account's repos through the actorStore. accountStore: new DetailedAccountStore( accountManager, - new ActorProfileStoreCached(actorStore, localViewer), + new BasicProfileGetterCached(actorStore, localViewer), ), requestStore: accountManager, sessionStore: accountManager, @@ -133,12 +133,12 @@ export class AuthProvider extends OAuthProvider { /** * This class wraps an AccountStore in order to enrich the accounts it returns - * with profile information through the ActorStore. + * with basic profile data from an ActorStore. */ class DetailedAccountStore implements AccountStore { constructor( private store: AccountStore, - private actorProfileStore: ActorProfileStore, + private basicProfileGetter: BasicProfileGetter, ) {} private async enrichAccountInfo( @@ -146,7 +146,7 @@ class DetailedAccountStore implements AccountStore { ): Promise { const { account } = accountInfo if (!account.picture || !account.name) { - const profile = await this.actorProfileStore.get(account.sub) + const profile = await this.basicProfileGetter.get(account.sub) if (profile) { account.picture ||= profile.avatar account.name ||= profile.displayName @@ -200,51 +200,35 @@ class DetailedAccountStore implements AccountStore { } /** - * Utility class to fetch profile information for a given DID. + * Utility class to fetch basic profile data for a given DID. */ -class ActorProfileStore { +class BasicProfileGetter { constructor( private actorStore: ActorStore, private localViewer: LocalViewerCreator, ) {} public async get(did: string): Promise { - return this.actorStore.read(did, async (store) => { - const localViewer = this.localViewer(store) + return this.actorStore.read(did, async (actorStoreReader) => { + const localViewer = this.localViewer(actorStoreReader) return localViewer.getProfileBasic() }) } } /** - * Drop-in replacement for ActorProfileStore that caches the results of the + * Drop-in replacement for BasicProfileGetter that caches the results of the * get method. */ -class ActorProfileStoreCached - extends ActorProfileStore - implements ActorProfileStore +class BasicProfileGetterCached + extends BasicProfileGetter + implements BasicProfileGetter { - cache = new LRUCache({ - ttl: 10 * 60e3, // 10 minutes - max: 1000, - allowStale: true, - updateAgeOnGet: false, - updateAgeOnHas: false, - allowStaleOnFetchAbort: true, - allowStaleOnFetchRejection: true, - ignoreFetchAbort: true, - noDeleteOnStaleGet: true, - noDeleteOnFetchRejection: true, - fetchMethod: async (did) => (await super.get(did)) ?? 'nullValue', - }) - - public async get(did: string): Promise { - const cached = await this.cache.fetch(did) - if (cached != null) return cached === 'nullValue' ? null : cached + readonly #getter = new CachedGetter((did) => + super.get(did), + ) - // Should never happen when using the fetchMethod option - const result = await super.get(did) - this.cache.set(did, result ?? 'nullValue') - return result + async get(did: string): Promise { + return this.#getter.get(did) } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a89da3281f..743086a96a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1273,6 +1273,9 @@ importers: '@atproto/aws': specifier: workspace:^ version: link:../aws + '@atproto/caching': + specifier: workspace:^ + version: link:../caching '@atproto/common': specifier: workspace:^ version: link:../common From 9e263f78beb58945fba83c829b7b0bb446831a35 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 15:32:00 +0200 Subject: [PATCH 080/140] feat(oauth-client): support working with dev-env --- packages/oauth-client-browser-example/src/app.tsx | 1 + packages/oauth-client-browser-example/src/login-form.tsx | 6 +++++- .../src/browser-oauth-client-factory.ts | 8 +++++++- packages/oauth-client/src/oauth-resolver.ts | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/oauth-client-browser-example/src/app.tsx b/packages/oauth-client-browser-example/src/app.tsx index 2b4a0536ecb..bab398a8b0a 100644 --- a/packages/oauth-client-browser-example/src/app.tsx +++ b/packages/oauth-client-browser-example/src/app.tsx @@ -11,6 +11,7 @@ export const oauthFactory = new BrowserOAuthClientFactory({ clientMetadata: oauthClientMetadataSchema.parse(metadata), responseType: 'code id_token', responseMode: 'fragment', + plcDirectoryUrl: 'http://localhost:2582', // dev-env }) /** diff --git a/packages/oauth-client-browser-example/src/login-form.tsx b/packages/oauth-client-browser-example/src/login-form.tsx index 4f73c16c7d7..fba84c4d573 100644 --- a/packages/oauth-client-browser-example/src/login-form.tsx +++ b/packages/oauth-client-browser-example/src/login-form.tsx @@ -21,7 +21,11 @@ export default function LoginForm({ e.preventDefault() if (loading) return - onLogin(loginType === 'host' ? `https://${value}` : value) + onLogin( + loginType === 'host' && !/^https?:\/\//.test(value) + ? `https://${value}` + : value, + ) } return ( diff --git a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts index 72a64fa27ad..ba19045bb49 100644 --- a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts +++ b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts @@ -1,5 +1,8 @@ import { Fetch } from '@atproto/fetch' -import { UniversalIdentityResolver } from '@atproto/identity-resolver' +import { + UniversalIdentityResolver, + UniversalIdentityResolverOptions, +} from '@atproto/identity-resolver' import { OAuthAuthorizeOptions, OAuthClient, @@ -26,6 +29,7 @@ export type BrowserOauthClientFactoryOptions = { responseMode?: OAuthResponseMode responseType?: OAuthResponseType clientMetadata: OAuthClientMetadata + plcDirectoryUrl?: UniversalIdentityResolverOptions['plcDirectoryUrl'] fetch?: Fetch crypto?: Crypto } @@ -57,6 +61,7 @@ export class BrowserOAuthClientFactory extends OAuthClientFactory { // "fragment" is safer as it is not sent to the server responseMode = 'fragment', responseType, + plcDirectoryUrl, crypto = globalThis.crypto, fetch = globalThis.fetch, }: BrowserOauthClientFactoryOptions) { @@ -76,6 +81,7 @@ export class BrowserOAuthClientFactory extends OAuthClientFactory { }), identityResolver: UniversalIdentityResolver.from({ fetch, + plcDirectoryUrl, didCache: database.getDidCache(), handleCache: database.getHandleCache(), }), diff --git a/packages/oauth-client/src/oauth-resolver.ts b/packages/oauth-client/src/oauth-resolver.ts index b4785e2ae12..a11ec75ecd2 100644 --- a/packages/oauth-client/src/oauth-resolver.ts +++ b/packages/oauth-client/src/oauth-resolver.ts @@ -16,7 +16,7 @@ export class OAuthResolver { metadata: OAuthServerMetadata } > { - const identity = input.startsWith('https:') + const identity = /^https?:\/\//.test(input) ? // Allow using a PDS url directly as login input (e.g. when the handle does not resolve to a DID) { url: new URL(input) } : await this.identityResolver.resolve(input, 'AtprotoPersonalDataServer') From fc612a818aa4db3201ace687a2a2bbc836635f1c Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 15:32:24 +0200 Subject: [PATCH 081/140] feat(dev-env): add oauth config --- packages/dev-env/src/network.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts index 11d18e24224..999fe8e46ca 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -65,6 +65,17 @@ export class TestNetwork extends TestNetworkNoAppView { bskyAppViewDid: bsky.ctx.cfg.serverDid, modServiceUrl: `http://localhost:${ozonePort}`, modServiceDid: ozoneDid, + oauthDisableSsrf: true, + oauthProviderName: 'PDS (dev)', + oauthProviderPrimaryColor: '#ffcb1e', + oauthProviderLogo: + 'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png', + oauthProviderErrorColor: undefined, + oauthProviderHomeLink: 'https://bsky.social/', + oauthProviderTosLink: 'https://bsky.social/about/support/tos', + oauthProviderPrivacyPolicyLink: + 'https://bsky.social/about/support/privacy-policy', + oauthProviderSupportLink: 'https://blueskyweb.zendesk.com/hc/en-us', ...params.pds, }) From fe7e54fc4a50995d0c71b2fe06240d01b69ed6b6 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 15:34:14 +0200 Subject: [PATCH 082/140] feat(dev): run dev-env as part of "pnpm dev" --- packages/dev-env/package.json | 3 ++- services/pds/package.json | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dev-env/package.json b/packages/dev-env/package.json index a740e194955..34eeda20811 100644 --- a/packages/dev-env/package.json +++ b/packages/dev-env/package.json @@ -17,7 +17,8 @@ "bin": "dist/bin.js", "scripts": { "build": "tsc --build tsconfig.build.json", - "start": "../dev-infra/with-test-redis-and-db.sh node dist/bin.js" + "start": "../dev-infra/with-test-redis-and-db.sh node dist/bin.js", + "dev": "../dev-infra/with-test-redis-and-db.sh node --watch dist/bin.js" }, "dependencies": { "@atproto/api": "workspace:^", diff --git a/services/pds/package.json b/services/pds/package.json index efd42d65322..c569b05a3fd 100644 --- a/services/pds/package.json +++ b/services/pds/package.json @@ -11,7 +11,6 @@ "dotenv": "^16.4.5" }, "scripts": { - "start": "node --enable-source-maps --heapsnapshot-signal=SIGUSR2 --require=./tracer.js index.js", - "dev": "node --enable-source-maps --require=dotenv/config --watch index.js" + "start": "node --enable-source-maps --heapsnapshot-signal=SIGUSR2 --require=./tracer.js index.js" } } From 4f9d70e4da87b31672bb3452a24706336f299246 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 21:41:25 +0200 Subject: [PATCH 083/140] fix(oauth-provider): do not require "implicit" grant_type for response_types including "code" --- .../src/client/client-manager.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/oauth-provider/src/client/client-manager.ts b/packages/oauth-provider/src/client/client-manager.ts index 864f3c842ba..c631a0af9ad 100644 --- a/packages/oauth-provider/src/client/client-manager.ts +++ b/packages/oauth-provider/src/client/client-manager.ts @@ -75,12 +75,18 @@ export class ClientManager { ) } - for (const t of ['id_token', 'token'] as const) { - if (rt.includes(t) && !metadata.grant_types.includes('implicit')) { - throw new InvalidClientMetadataError( - `Response type "${responseType}" requires the "implicit" grant type`, - ) - } + // Asking for "code token" or "code id_token" is fine (as long as the + // grant_types includes "authorization_code" and the scope includes + // "openid"). Asking for "token" or "id_token" (without "code") requires + // the "implicit" grant type. + if ( + !rt.includes('code') && + (rt.includes('token') || rt.includes('id_token')) && + !metadata.grant_types.includes('implicit') + ) { + throw new InvalidClientMetadataError( + `Response type "${responseType}" requires the "implicit" grant type`, + ) } } From 7038535c17d230fb827bcead03f7f862326df0c2 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 21:42:14 +0200 Subject: [PATCH 084/140] feat(oauth-provider): expose allowed redirect_uris in "invalid redirect_uri" error description --- packages/oauth-provider/src/request/request-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oauth-provider/src/request/request-manager.ts b/packages/oauth-provider/src/request/request-manager.ts index 36fba99f7ff..500c8f4bc4f 100644 --- a/packages/oauth-provider/src/request/request-manager.ts +++ b/packages/oauth-provider/src/request/request-manager.ts @@ -192,7 +192,7 @@ export class RequestManager { ) { throw new InvalidParametersError( parameters, - `Invalid redirect_uri ${redirect_uri}`, + `Invalid redirect_uri ${redirect_uri} (allowed: ${client.metadata.redirect_uris})`, ) } From 373e15459f7951a4485c53b12bf7a9f7b3214fbb Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 21:43:00 +0200 Subject: [PATCH 085/140] docs(oauth-provider): improve comments in redirect_uri matching algorithm --- .../oauth-provider/src/util/redirect-uri.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/oauth-provider/src/util/redirect-uri.ts b/packages/oauth-provider/src/util/redirect-uri.ts index 67be72ba8b7..88cee5a6569 100644 --- a/packages/oauth-provider/src/util/redirect-uri.ts +++ b/packages/oauth-provider/src/util/redirect-uri.ts @@ -6,6 +6,13 @@ export function matchRedirectUri( allowed_uri: string, request_uri: string, ): boolean { + // https://datatracker.ietf.org/doc/html/rfc8252#section-8.4 + // + // > Authorization servers MUST require clients to register their complete + // > redirect URI (including the path component) and reject authorization + // > requests that specify a redirect URI that doesn't exactly match the + // > one that was registered; the exception is loopback redirects, where + // > an exact match is required except for the port URI component. if (allowed_uri === request_uri) return true const allowed_uri_parsed = new URL(allowed_uri) @@ -21,13 +28,10 @@ export function matchRedirectUri( const request_uri_parsed = new URL(request_uri) - // https://datatracker.ietf.org/doc/html/rfc8252#section-8.4 - // - // > Authorization servers MUST require clients to register their complete - // > redirect URI (including the path component) and reject authorization - // > requests that specify a redirect URI that doesn't exactly match the - // > one that was registered; the exception is loopback redirects, where - // > an exact match is required except for the port URI component. + // > The authorization server MUST allow any port to be specified at the + // > time of the request for loopback IP redirect URIs, to accommodate + // > clients that obtain an available ephemeral port from the operating + // > system at the time of the request. return ( // allowed_uri_parsed.port === request_uri_parsed.port && allowed_uri_parsed.hostname === request_uri_parsed.hostname && From 08810b86d4acf1d76c55251df17c02753f8d2558 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 21:43:59 +0200 Subject: [PATCH 086/140] fix(pds:oauth): add missing "openid" scope for localhost clients --- packages/pds/src/oauth/oauth-client-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/oauth/oauth-client-store.ts b/packages/pds/src/oauth/oauth-client-store.ts index e79b64cab70..118b53e50ff 100644 --- a/packages/pds/src/oauth/oauth-client-store.ts +++ b/packages/pds/src/oauth/oauth-client-store.ts @@ -35,7 +35,7 @@ function loopbackMetadata({ href }: URL): Partial { client_uri: href, response_types: ['code', 'code id_token'], grant_types: ['authorization_code', 'refresh_token'], - scope: 'profile offline_access', + scope: 'openid profile offline_access', redirect_uris: ['127.0.0.1', '[::1]'].map( (ip) => Object.assign(new URL(href), { hostname: ip }).href, ) as [string, string], From eb1bfa87742c8ad5b5caeb8ad1081ed22ed2a527 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 21:44:45 +0200 Subject: [PATCH 087/140] fix(oauth-provider-client-uri): improve validation of client id --- .../src/oauth-client-uri-store.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts index 449ece7b325..b683407e1c4 100644 --- a/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts +++ b/packages/oauth-provider-client-uri/src/oauth-client-uri-store.ts @@ -135,10 +135,12 @@ export class OAuthClientUriStore implements ClientStore { throw new InvalidClientMetadataError(`ClientID must be a normalized URI`) } - if (url.port || url.search || url.hash || url.username || url.password) { - throw new InvalidClientMetadataError( - `ClientID URI must not contain any port, username, password, query or fragment`, - ) + if (url.username || url.password) { + throw new TypeError('ClientID URI must not contain credentials') + } + + if (url.search || url.hash) { + throw new TypeError('ClientID URI must not contain a query or fragment') } if (url.pathname.includes('//')) { @@ -160,13 +162,19 @@ export class OAuthClientUriStore implements ClientStore { if (clientUrl.protocol !== 'http:') { throw new InvalidClientMetadataError( - 'Loopback client must use the "http:" protocol', + 'Loopback ClientID URI must use the "http:" protocol', ) } if (clientUrl.hostname !== 'localhost') { throw new InvalidClientMetadataError( - 'Loopback client must use the "localhost" hostname', + 'Loopback ClientID URI must use the "localhost" hostname', + ) + } + + if (clientUrl.port) { + throw new InvalidClientMetadataError( + 'Loopback ClientID URI must not use a custom port number', ) } From 892a3efea7f3bcf430e94a2a82f105740c1a74f6 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 21:45:32 +0200 Subject: [PATCH 088/140] fix(oauth-client): improve validation of client metadata --- .../src/validate-client-metadata.ts | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/oauth-client/src/validate-client-metadata.ts b/packages/oauth-client/src/validate-client-metadata.ts index b7bdc138e06..b66069d4350 100644 --- a/packages/oauth-client/src/validate-client-metadata.ts +++ b/packages/oauth-client/src/validate-client-metadata.ts @@ -13,8 +13,24 @@ export function validateClientMetadata( if (url.pathname !== '/') { throw new TypeError('origin must be a URL root') } + if (url.username || url.password) { + throw new TypeError('client_id URI must not contain a username or password') + } + if (url.search || url.hash) { + throw new TypeError('client_id URI must not contain a query or fragment') + } if (url.href !== metadata.client_id) { - throw new TypeError('client_id must be a normalized URL') + throw new TypeError('client_id URI must be a normalized URL') + } + + if ( + url.hostname === 'localhost' || + url.hostname === '[::1]' || + url.hostname === '127.0.0.1' + ) { + if (url.protocol !== 'http:' || url.port) { + throw new TypeError('loopback clients must use "http:" and port "80"') + } } if (metadata.client_uri && metadata.client_uri !== metadata.client_id) { @@ -26,8 +42,20 @@ export function validateClientMetadata( } for (const u of metadata.redirect_uris) { const redirectUrl = new URL(u) - if (redirectUrl.origin !== url.origin) { - throw new TypeError('redirect_uris must have the same origin') + // Loopback redirect_uris require special handling + if ( + redirectUrl.hostname === 'localhost' || + redirectUrl.hostname === '[::1]' || + redirectUrl.hostname === '127.0.0.1' + ) { + if (redirectUrl.protocol !== 'http:') { + throw new TypeError('loopback redirect_uris must use "http:"') + } + } else { + // Not a loopback client + if (redirectUrl.origin !== url.origin) { + throw new TypeError('redirect_uris must have the same origin') + } } } From d2e29a70fbe2878f1cc6031bcaadd4b4e4120f12 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 21:46:15 +0200 Subject: [PATCH 089/140] fix(oauth-client-browser): allow customizing the atprotoLexiconUrl --- packages/oauth-client-browser-example/src/app.tsx | 1 + .../oauth-client-browser/src/browser-oauth-client-factory.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/oauth-client-browser-example/src/app.tsx b/packages/oauth-client-browser-example/src/app.tsx index bab398a8b0a..12af46a054f 100644 --- a/packages/oauth-client-browser-example/src/app.tsx +++ b/packages/oauth-client-browser-example/src/app.tsx @@ -12,6 +12,7 @@ export const oauthFactory = new BrowserOAuthClientFactory({ responseType: 'code id_token', responseMode: 'fragment', plcDirectoryUrl: 'http://localhost:2582', // dev-env + atprotoLexiconUrl: 'http://localhost:2584', // dev-env (bsky appview) }) /** diff --git a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts index ba19045bb49..1ea1b857d60 100644 --- a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts +++ b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts @@ -30,6 +30,7 @@ export type BrowserOauthClientFactoryOptions = { responseType?: OAuthResponseType clientMetadata: OAuthClientMetadata plcDirectoryUrl?: UniversalIdentityResolverOptions['plcDirectoryUrl'] + atprotoLexiconUrl?: UniversalIdentityResolverOptions['atprotoLexiconUrl'] fetch?: Fetch crypto?: Crypto } @@ -62,6 +63,7 @@ export class BrowserOAuthClientFactory extends OAuthClientFactory { responseMode = 'fragment', responseType, plcDirectoryUrl, + atprotoLexiconUrl, crypto = globalThis.crypto, fetch = globalThis.fetch, }: BrowserOauthClientFactoryOptions) { @@ -82,6 +84,7 @@ export class BrowserOAuthClientFactory extends OAuthClientFactory { identityResolver: UniversalIdentityResolver.from({ fetch, plcDirectoryUrl, + atprotoLexiconUrl, didCache: database.getDidCache(), handleCache: database.getHandleCache(), }), From 6311aa783c71fa87b3bb746def7df06ed9447741 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 8 Apr 2024 21:46:42 +0200 Subject: [PATCH 090/140] feat(oauth-client-browser-example): use loopback client metadata --- .../public/.well-known/oauth-client-metadata | 1 - packages/oauth-client-browser-example/src/app.tsx | 9 +++++---- .../src/oauth-client-metadata.json | 13 ------------- 3 files changed, 5 insertions(+), 18 deletions(-) delete mode 120000 packages/oauth-client-browser-example/public/.well-known/oauth-client-metadata delete mode 100644 packages/oauth-client-browser-example/src/oauth-client-metadata.json diff --git a/packages/oauth-client-browser-example/public/.well-known/oauth-client-metadata b/packages/oauth-client-browser-example/public/.well-known/oauth-client-metadata deleted file mode 120000 index 94506bc61cf..00000000000 --- a/packages/oauth-client-browser-example/public/.well-known/oauth-client-metadata +++ /dev/null @@ -1 +0,0 @@ -../../src/oauth-client-metadata.json \ No newline at end of file diff --git a/packages/oauth-client-browser-example/src/app.tsx b/packages/oauth-client-browser-example/src/app.tsx index 12af46a054f..8dab3fba573 100644 --- a/packages/oauth-client-browser-example/src/app.tsx +++ b/packages/oauth-client-browser-example/src/app.tsx @@ -5,10 +5,11 @@ import { useCallback, useState } from 'react' import LoginForm from './login-form' import { useOAuth } from './oauth' -import metadata from './oauth-client-metadata.json' - export const oauthFactory = new BrowserOAuthClientFactory({ - clientMetadata: oauthClientMetadataSchema.parse(metadata), + clientMetadata: oauthClientMetadataSchema.parse({ + client_id: 'http://localhost/', + redirect_uris: ['http://127.0.0.1:5173/'], + }), responseType: 'code id_token', responseMode: 'fragment', plcDirectoryUrl: 'http://localhost:2582', // dev-env @@ -85,7 +86,7 @@ function App() { void signIn(input, { display: 'popup' })} + onLogin={(input) => void signIn(input)} /> ) } diff --git a/packages/oauth-client-browser-example/src/oauth-client-metadata.json b/packages/oauth-client-browser-example/src/oauth-client-metadata.json deleted file mode 100644 index 6dfe31f05d4..00000000000 --- a/packages/oauth-client-browser-example/src/oauth-client-metadata.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "My App", - "client_id": "https://app.bsky.msbn.xyz/", - "client_uri": "https://app.bsky.msbn.xyz/", - "client_name": "Example Bluesky client app", - "application_type": "web", - "token_endpoint_auth_method": "none", - "redirect_uris": ["https://app.bsky.msbn.xyz/"], - "grant_types": ["authorization_code", "refresh_token", "implicit"], - "response_types": ["code", "code id_token"], - "scope": "openid profile email phone offline_access", - "dpop_bound_access_tokens": true -} From 975037164c06e4c0b7f0511a44fd91cc738f03de Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Tue, 9 Apr 2024 11:36:57 +0200 Subject: [PATCH 091/140] fix(deps): update pnpm-lock --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 743086a96a7..29563906141 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1375,9 +1375,6 @@ importers: kysely: specifier: ^0.22.0 version: 0.22.0 - lru-cache: - specifier: ^10.2.0 - version: 10.2.0 multiformats: specifier: ^9.9.0 version: 9.9.0 From 14fa2164611929974ff3c0e214a70f628a8cee48 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Tue, 9 Apr 2024 16:44:20 +0200 Subject: [PATCH 092/140] fix(pds:oauth): typings --- packages/pds/src/account-manager/helpers/device-account.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/pds/src/account-manager/helpers/device-account.ts b/packages/pds/src/account-manager/helpers/device-account.ts index 1bcf7d2efb6..2a66b73b881 100644 --- a/packages/pds/src/account-manager/helpers/device-account.ts +++ b/packages/pds/src/account-manager/helpers/device-account.ts @@ -33,9 +33,9 @@ export type InsertableField = { remember: boolean } -function toInsertable( - values: Partial & { [k in K]: unknown }, -): Pick, K & keyof Insertable> +function toInsertable>( + values: V, +): Pick, keyof V & keyof Insertable> function toInsertable( values: Partial, ): Partial> { From a3a0c2be3cac773bbfc0168e5f8795cdf6a6d99a Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 10 Apr 2024 11:10:57 +0200 Subject: [PATCH 093/140] chore(build): use tsc --build mode --- packages/oauth-provider-client-fqdn/package.json | 2 +- packages/oauth-provider-client-uri/package.json | 2 +- packages/oauth-provider-replay-memory/package.json | 2 +- packages/oauth-provider-replay-redis/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/oauth-provider-client-fqdn/package.json b/packages/oauth-provider-client-fqdn/package.json index 7ad62a3878d..b0b47ff8d4b 100644 --- a/packages/oauth-provider-client-fqdn/package.json +++ b/packages/oauth-provider-client-fqdn/package.json @@ -34,6 +34,6 @@ }, "devDependencies": {}, "scripts": { - "build": "tsc" + "build": "tsc --build tsconfig.json" } } diff --git a/packages/oauth-provider-client-uri/package.json b/packages/oauth-provider-client-uri/package.json index f5997689af6..8478b58490a 100644 --- a/packages/oauth-provider-client-uri/package.json +++ b/packages/oauth-provider-client-uri/package.json @@ -40,6 +40,6 @@ "@types/psl": "^1.1.3" }, "scripts": { - "build": "tsc" + "build": "tsc --build tsconfig.json" } } diff --git a/packages/oauth-provider-replay-memory/package.json b/packages/oauth-provider-replay-memory/package.json index f064d1f5f4a..a33cc194275 100644 --- a/packages/oauth-provider-replay-memory/package.json +++ b/packages/oauth-provider-replay-memory/package.json @@ -31,6 +31,6 @@ "tslib": "^2.6.2" }, "scripts": { - "build": "tsc" + "build": "tsc --build tsconfig.json" } } diff --git a/packages/oauth-provider-replay-redis/package.json b/packages/oauth-provider-replay-redis/package.json index b936505132f..ab822486ce7 100644 --- a/packages/oauth-provider-replay-redis/package.json +++ b/packages/oauth-provider-replay-redis/package.json @@ -32,6 +32,6 @@ "tslib": "^2.6.2" }, "scripts": { - "build": "tsc" + "build": "tsc --build tsconfig.json" } } From a2b58638256ee82614f97a31eab488c924932d1f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 10 Apr 2024 13:31:01 +0200 Subject: [PATCH 094/140] fix(oauth-provider): better align errors with spec --- .../src/account/account-manager.ts | 6 +- packages/oauth-provider/src/client/client.ts | 11 ++-- .../invalid-authorization-details-error.ts | 14 ++++- .../src/errors/invalid-client-error.ts | 18 ++++-- .../errors/invalid-client-metadata-error.ts | 12 ++-- .../src/errors/invalid-dpop-key-binding.ts | 20 ++++++- .../src/errors/invalid-dpop-proof-error.ts | 1 - .../src/errors/invalid-grant-error.ts | 16 ++++++ .../src/errors/invalid-redirect-uri-error.ts | 8 ++- .../src/errors/invalid-request-error.ts | 23 ++++++++ .../src/errors/invalid-token-error.ts | 46 +++++++++++++--- .../src/errors/unauthorized-client-error.ts | 15 ++++- .../src/errors/unauthorized-dpop-error.ts | 7 --- .../src/errors/unauthorized-error.ts | 21 ------- .../src/errors/unknown-request-error.ts | 7 --- .../src/errors/use-dpop-nonce-error.ts | 21 ++++++- .../src/errors/www-authenticate-error.ts | 3 +- packages/oauth-provider/src/oauth-errors.ts | 5 +- packages/oauth-provider/src/oauth-provider.ts | 8 ++- packages/oauth-provider/src/oauth-verifier.ts | 36 ++++++++---- .../src/request/request-manager.ts | 16 +++--- .../oauth-provider/src/token/token-manager.ts | 55 +++++++++---------- .../src/token/verify-token-claims.ts | 14 ++--- .../src/util/authorization-header.ts | 18 ++++-- 24 files changed, 260 insertions(+), 141 deletions(-) create mode 100644 packages/oauth-provider/src/errors/invalid-grant-error.ts delete mode 100644 packages/oauth-provider/src/errors/unauthorized-dpop-error.ts delete mode 100644 packages/oauth-provider/src/errors/unauthorized-error.ts delete mode 100644 packages/oauth-provider/src/errors/unknown-request-error.ts diff --git a/packages/oauth-provider/src/account/account-manager.ts b/packages/oauth-provider/src/account/account-manager.ts index dc3e52e8f4d..8c433890da4 100644 --- a/packages/oauth-provider/src/account/account-manager.ts +++ b/packages/oauth-provider/src/account/account-manager.ts @@ -1,6 +1,6 @@ import { OAuthClientId } from '@atproto/oauth-client-metadata' import { DeviceId } from '../device/device-id.js' -import { UnauthorizedError } from '../errors/unauthorized-error.js' +import { InvalidRequestError } from '../oauth-errors.js' import { Sub } from '../oidc/sub.js' import { constantTime } from '../util/time.js' import { AccountInfo, AccountStore, LoginCredentials } from './account-store.js' @@ -18,7 +18,7 @@ export class AccountManager { const result = await this.store.authenticateAccount(credentials, deviceId) if (result) return result - throw new UnauthorizedError('Invalid credentials', {}) + throw new InvalidRequestError('Invalid credentials') }) } @@ -26,7 +26,7 @@ export class AccountManager { const result = await this.store.getDeviceAccount(deviceId, sub) if (result) return result - throw new UnauthorizedError(`Account not found`, {}) + throw new InvalidRequestError(`Account not found`) } public async addAuthorizedClient( diff --git a/packages/oauth-provider/src/client/client.ts b/packages/oauth-provider/src/client/client.ts index 2ddac15bcef..868cd1a9240 100644 --- a/packages/oauth-provider/src/client/client.ts +++ b/packages/oauth-provider/src/client/client.ts @@ -19,6 +19,7 @@ import { import { JOSEError } from 'jose/errors' import { CLIENT_ASSERTION_MAX_AGE, JAR_MAX_AGE } from '../constants.js' +import { InvalidClientError } from '../errors/invalid-client-error.js' import { InvalidClientMetadataError } from '../errors/invalid-client-metadata-error.js' import { InvalidRequestError } from '../errors/invalid-request-error.js' import { ClientAuth, authJwkThumbprint } from './client-auth.js' @@ -145,19 +146,19 @@ export class Client { maxTokenAge: CLIENT_ASSERTION_MAX_AGE / 1000, }).catch((err) => { if (err instanceof JOSEError) { - const msg = `Invalid "client_assertion": ${err.message}` - throw new InvalidRequestError(msg, err) + const msg = `Validation of "client_assertion" failed: ${err.message}` + throw new InvalidClientError(msg, err) } throw err }) if (!result.protectedHeader.kid) { - throw new InvalidRequestError(`"kid" required in client_assertion`) + throw new InvalidClientError(`"kid" required in client_assertion`) } if (!result.payload.jti) { - throw new InvalidRequestError(`"jti" required in client_assertion`) + throw new InvalidClientError(`"jti" required in client_assertion`) } const clientAuth: ClientAuth = { @@ -170,7 +171,7 @@ export class Client { return { clientAuth, nonce: result.payload.jti } } - throw new InvalidRequestError( + throw new InvalidClientError( `Unsupported client_assertion_type "${input.client_assertion_type}"`, ) } diff --git a/packages/oauth-provider/src/errors/invalid-authorization-details-error.ts b/packages/oauth-provider/src/errors/invalid-authorization-details-error.ts index 9a9d04765be..89d9b0732dc 100644 --- a/packages/oauth-provider/src/errors/invalid-authorization-details-error.ts +++ b/packages/oauth-provider/src/errors/invalid-authorization-details-error.ts @@ -1,7 +1,19 @@ import { OAuthError } from './oauth-error.js' /** - * @see {@link https://datatracker.ietf.org/doc/html/rfc9396#section-14.6 | RFC 9396, Section 14.6} + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc9396#section-14.6 | RFC 9396 - OAuth Dynamic Client Registration Metadata Registration Error} + * + * The AS MUST refuse to process any unknown authorization details type or + * authorization details not conforming to the respective type definition. The + * AS MUST abort processing and respond with an error + * invalid_authorization_details to the client if any of the following are true + * of the objects in the authorization_details structure: + * - contains an unknown authorization details type value, + * - is an object of known type but containing unknown fields, + * - contains fields of the wrong type for the authorization details type, + * - contains fields with invalid values for the authorization details type, or + * - is missing required fields for the authorization details type. */ export class InvalidAuthorizationDetailsError extends OAuthError { constructor(error_description: string, cause?: unknown) { diff --git a/packages/oauth-provider/src/errors/invalid-client-error.ts b/packages/oauth-provider/src/errors/invalid-client-error.ts index 2dd191c5b53..328d67b2175 100644 --- a/packages/oauth-provider/src/errors/invalid-client-error.ts +++ b/packages/oauth-provider/src/errors/invalid-client-error.ts @@ -1,10 +1,20 @@ import { OAuthError } from './oauth-error.js' /** - * @see {@link https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2 | RFC7591} + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 | RFC6749 - Issuing an Access Token } + * + * Client authentication failed (e.g., unknown client, no client authentication + * included, or unsupported authentication method). The authorization server MAY + * return an HTTP 401 (Unauthorized) status code to indicate which HTTP + * authentication schemes are supported. If the client attempted to + * authenticate via the "Authorization" request header field, the authorization + * server MUST respond with an HTTP 401 (Unauthorized) status code and include + * the "WWW-Authenticate" response header field matching the authentication + * scheme used by the client. */ -export abstract class InvalidClientError extends OAuthError { - constructor(error: string, error_description: string, cause?: unknown) { - super(error, error_description, 400, cause) +export class InvalidClientError extends OAuthError { + constructor(error_description: string, cause?: unknown) { + super('invalid_client', error_description, 400, cause) } } diff --git a/packages/oauth-provider/src/errors/invalid-client-metadata-error.ts b/packages/oauth-provider/src/errors/invalid-client-metadata-error.ts index 65d841a01b9..0d9d49447bf 100644 --- a/packages/oauth-provider/src/errors/invalid-client-metadata-error.ts +++ b/packages/oauth-provider/src/errors/invalid-client-metadata-error.ts @@ -1,10 +1,14 @@ -import { InvalidClientError } from './invalid-client-error.js' +import { OAuthError } from './oauth-error.js' /** - * @see {@link https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2 | RFC7591} + * @see {@link https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2 | RFC7591 - Client Registration Error Response} + * + * The value of one of the client metadata fields is invalid and the server has + * rejected this request. Note that an authorization server MAY choose to + * substitute a valid value for any requested parameter of a client's metadata. */ -export class InvalidClientMetadataError extends InvalidClientError { +export class InvalidClientMetadataError extends OAuthError { constructor(error_description: string, cause?: unknown) { - super('invalid_client_metadata', error_description, cause) + super('invalid_client_metadata', error_description, 400, cause) } } diff --git a/packages/oauth-provider/src/errors/invalid-dpop-key-binding.ts b/packages/oauth-provider/src/errors/invalid-dpop-key-binding.ts index 8104b0bbd3d..38cd8bac489 100644 --- a/packages/oauth-provider/src/errors/invalid-dpop-key-binding.ts +++ b/packages/oauth-provider/src/errors/invalid-dpop-key-binding.ts @@ -1,7 +1,21 @@ -import { InvalidTokenError } from './invalid-token-error.js' +import { WWWAuthenticateError } from './www-authenticate-error.js' -export class InvalidDpopKeyBindingError extends InvalidTokenError { +/** + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6750#section-3.1 | RFC6750 - The WWW-Authenticate Response Header Field} + * + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc9449#name-the-dpop-authentication-sch | RFC9449 - The DPoP Authentication Scheme} + */ +export class InvalidDpopKeyBindingError extends WWWAuthenticateError { constructor(cause?: unknown) { - super('Invalid DPoP key binding', { DPoP: {} }, cause) + const error = 'invalid_token' + const error_description = 'Invalid DPoP key binding' + super( + error, + error_description, + { DPoP: { error, error_description } }, + cause, + ) } } diff --git a/packages/oauth-provider/src/errors/invalid-dpop-proof-error.ts b/packages/oauth-provider/src/errors/invalid-dpop-proof-error.ts index 47309b5ecfb..d8826fd6a90 100644 --- a/packages/oauth-provider/src/errors/invalid-dpop-proof-error.ts +++ b/packages/oauth-provider/src/errors/invalid-dpop-proof-error.ts @@ -6,7 +6,6 @@ export class InvalidDpopProofError extends WWWAuthenticateError { super( error, error_description, - 400, { DPoP: { error, error_description } }, cause, ) diff --git a/packages/oauth-provider/src/errors/invalid-grant-error.ts b/packages/oauth-provider/src/errors/invalid-grant-error.ts new file mode 100644 index 00000000000..a4e3838db38 --- /dev/null +++ b/packages/oauth-provider/src/errors/invalid-grant-error.ts @@ -0,0 +1,16 @@ +import { OAuthError } from './oauth-error.js' + +/** + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 | RFC6749 - Issuing an Access Token } + * + * The provided authorization grant (e.g., authorization code, resource owner + * credentials) or refresh token is invalid, expired, revoked, does not match + * the redirection URI used in the authorization request, or was issued to + * another client. + */ +export class InvalidGrantError extends OAuthError { + constructor(error_description: string, cause?: unknown) { + super('invalid_grant', error_description, 400, cause) + } +} diff --git a/packages/oauth-provider/src/errors/invalid-redirect-uri-error.ts b/packages/oauth-provider/src/errors/invalid-redirect-uri-error.ts index ca666cbdf72..df390f01ccd 100644 --- a/packages/oauth-provider/src/errors/invalid-redirect-uri-error.ts +++ b/packages/oauth-provider/src/errors/invalid-redirect-uri-error.ts @@ -1,10 +1,12 @@ -import { InvalidClientError } from './invalid-client-error.js' +import { OAuthError } from './oauth-error.js' /** * @see {@link https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.2 | RFC7591} + * + * The value of one or more redirection URIs is invalid. */ -export class InvalidRedirectUriError extends InvalidClientError { +export class InvalidRedirectUriError extends OAuthError { constructor(error_description: string, cause?: unknown) { - super('invalid_redirect_uri', error_description, cause) + super('invalid_redirect_uri', error_description, 400, cause) } } diff --git a/packages/oauth-provider/src/errors/invalid-request-error.ts b/packages/oauth-provider/src/errors/invalid-request-error.ts index 1508835edf3..fd1dabbbf95 100644 --- a/packages/oauth-provider/src/errors/invalid-request-error.ts +++ b/packages/oauth-provider/src/errors/invalid-request-error.ts @@ -1,5 +1,28 @@ import { OAuthError } from './oauth-error.js' +/** + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 | RFC6749 - Issuing an Access Token } + * + * The request is missing a required parameter, includes an unsupported + * parameter value (other than grant type), repeats a parameter, includes + * multiple credentials, utilizes more than one mechanism for authenticating the + * client, or is otherwise malformed. + * + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 | RFC6749 - Authorization Code Grant, Authorization Request} + * + * The request is missing a required parameter, includes an invalid parameter + * value, includes a parameter more than once, or is otherwise malformed. + * + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6750#section-3.1 | RFC6750 - The WWW-Authenticate Response Header Field } + * + * The request is missing a required parameter, includes an unsupported + * parameter or parameter value, repeats the same parameter, uses more than one + * method for including an access token, or is otherwise malformed. The resource + * server SHOULD respond with the HTTP 400 (Bad Request) status code. + */ export class InvalidRequestError extends OAuthError { constructor(error_description: string, cause?: unknown) { super('invalid_request', error_description, 400, cause) diff --git a/packages/oauth-provider/src/errors/invalid-token-error.ts b/packages/oauth-provider/src/errors/invalid-token-error.ts index d201cbc040e..ab7d24f3902 100644 --- a/packages/oauth-provider/src/errors/invalid-token-error.ts +++ b/packages/oauth-provider/src/errors/invalid-token-error.ts @@ -1,30 +1,58 @@ import { JOSEError } from 'jose/errors' import { ZodError } from 'zod' -import { UnauthorizedError, WWWAuthenticate } from './unauthorized-error.js' +import { OAuthError } from './oauth-error.js' +import { WWWAuthenticateError } from './www-authenticate-error.js' -export class InvalidTokenError extends UnauthorizedError { +/** + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6750#section-3.1 | RFC6750 - The WWW-Authenticate Response Header Field } + * + * The access token provided is expired, revoked, malformed, or invalid for + * other reasons. The resource SHOULD respond with the HTTP 401 (Unauthorized) + * status code. The client MAY request a new access token and retry the + * protected resource request. + */ +export class InvalidTokenError extends WWWAuthenticateError { static from( err: unknown, - wwwAuthenticate: WWWAuthenticate, - fallbackMessage = 'Invalid token', + tokenType: string, + fallbackMessage?: string, ): InvalidTokenError { + if (err instanceof InvalidTokenError) { + return err + } + + if (err instanceof OAuthError) { + return new InvalidTokenError(tokenType, err.error_description, err) + } + if (err instanceof JOSEError) { - throw new InvalidTokenError(err.message, wwwAuthenticate, err) + return new InvalidTokenError(tokenType, err.message, err) } if (err instanceof ZodError) { - throw new InvalidTokenError(err.message, wwwAuthenticate, err) + return new InvalidTokenError(tokenType, err.message, err) } - throw new InvalidTokenError(fallbackMessage, wwwAuthenticate, err) + return new InvalidTokenError( + tokenType, + fallbackMessage ?? 'Invalid token', + err, + ) } constructor( + readonly tokenType: string, error_description: string, - wwwAuthenticate: WWWAuthenticate, cause?: unknown, ) { - super(error_description, wwwAuthenticate, cause) + const error = 'invalid_token' + super( + error, + error_description, + { [tokenType]: { error, error_description } }, + cause, + ) } } diff --git a/packages/oauth-provider/src/errors/unauthorized-client-error.ts b/packages/oauth-provider/src/errors/unauthorized-client-error.ts index 29f40a87424..6f28e5870ec 100644 --- a/packages/oauth-provider/src/errors/unauthorized-client-error.ts +++ b/packages/oauth-provider/src/errors/unauthorized-client-error.ts @@ -1,7 +1,20 @@ import { OAuthError } from './oauth-error.js' +/** + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 | RFC6749 - Issuing an Access Token } + * + * The authenticated client is not authorized to use this authorization grant + * type. + * + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 | RFC6749 - Authorization Code Grant, Authorization Request} + * + * The client is not authorized to request an authorization code using this + * method. + */ export class UnauthorizedClientError extends OAuthError { constructor(error_description: string, cause?: unknown) { - super('unauthorized_client', error_description, 401, cause) + super('unauthorized_client', error_description, 400, cause) } } diff --git a/packages/oauth-provider/src/errors/unauthorized-dpop-error.ts b/packages/oauth-provider/src/errors/unauthorized-dpop-error.ts deleted file mode 100644 index a8f05fccfbe..00000000000 --- a/packages/oauth-provider/src/errors/unauthorized-dpop-error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { UnauthorizedError } from './unauthorized-error.js' - -export class UnauthorizedDpopError extends UnauthorizedError { - constructor(cause?: unknown) { - super('DPoP proof required', { DPoP: {} }, cause) - } -} diff --git a/packages/oauth-provider/src/errors/unauthorized-error.ts b/packages/oauth-provider/src/errors/unauthorized-error.ts deleted file mode 100644 index 86034d6e3ed..00000000000 --- a/packages/oauth-provider/src/errors/unauthorized-error.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { - WWWAuthenticate, - WWWAuthenticateParams, - WWWAuthenticateError, -} from './www-authenticate-error.js' - -export type { WWWAuthenticate, WWWAuthenticateParams } - -/** - * @see {@link https://www.rfc-editor.org/rfc/rfc6750.html} - * @see {@link https://www.rfc-editor.org/rfc/rfc6750.html#section-3.1} - */ -export class UnauthorizedError extends WWWAuthenticateError { - constructor( - error_description: string, - wwwAuthenticate: WWWAuthenticate, - cause?: unknown, - ) { - super('unauthorized', error_description, 401, wwwAuthenticate, cause) - } -} diff --git a/packages/oauth-provider/src/errors/unknown-request-error.ts b/packages/oauth-provider/src/errors/unknown-request-error.ts deleted file mode 100644 index 772c04fe3b5..00000000000 --- a/packages/oauth-provider/src/errors/unknown-request-error.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { OAuthError } from './oauth-error.js' - -export class UnknownRequestError extends OAuthError { - constructor(uri: string, cause?: unknown) { - super('invalid_request', `Unknown request_uri "${uri}"`, 400, cause) - } -} diff --git a/packages/oauth-provider/src/errors/use-dpop-nonce-error.ts b/packages/oauth-provider/src/errors/use-dpop-nonce-error.ts index 57f9547f37a..9c1d95566a7 100644 --- a/packages/oauth-provider/src/errors/use-dpop-nonce-error.ts +++ b/packages/oauth-provider/src/errors/use-dpop-nonce-error.ts @@ -1,7 +1,9 @@ import { OAuthError } from './oauth-error.js' +import { WWWAuthenticateError } from './www-authenticate-error.js' /** - * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-8} + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc9449#section-8 | RFC9449 - Authorization Server-Provided Nonce} */ export class UseDpopNonceError extends OAuthError { constructor( @@ -10,4 +12,21 @@ export class UseDpopNonceError extends OAuthError { ) { super('use_dpop_nonce', error_description, 400, cause) } + + /** + * Convert this error into an error meant to be used as "Resource + * Server-Provided Nonce" error. + * + * @see + * {@link https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no | RFC9449 - Resource Server-Provided Nonce} + */ + toWwwAuthenticateError(): WWWAuthenticateError { + const { error, error_description } = this + throw new WWWAuthenticateError( + error, + error_description, + { DPoP: { error, error_description } }, + this, + ) + } } diff --git a/packages/oauth-provider/src/errors/www-authenticate-error.ts b/packages/oauth-provider/src/errors/www-authenticate-error.ts index 950aae9b9fa..a81de30bdbf 100644 --- a/packages/oauth-provider/src/errors/www-authenticate-error.ts +++ b/packages/oauth-provider/src/errors/www-authenticate-error.ts @@ -11,11 +11,10 @@ export class WWWAuthenticateError extends OAuthError { constructor( error: string, error_description: string, - status: number, wwwAuthenticate: WWWAuthenticate, cause?: unknown, ) { - super(error, error_description, status, cause) + super(error, error_description, 401, cause) this.wwwAuthenticate = wwwAuthenticate['DPoP'] != null diff --git a/packages/oauth-provider/src/oauth-errors.ts b/packages/oauth-provider/src/oauth-errors.ts index 7f619a2c091..4b938cd6652 100644 --- a/packages/oauth-provider/src/oauth-errors.ts +++ b/packages/oauth-provider/src/oauth-errors.ts @@ -9,13 +9,12 @@ export { InvalidClientError } from './errors/invalid-client-error.js' export { InvalidClientMetadataError } from './errors/invalid-client-metadata-error.js' export { InvalidDpopKeyBindingError } from './errors/invalid-dpop-key-binding.js' export { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js' +export { InvalidGrantError } from './errors/invalid-grant-error.js' export { InvalidParametersError } from './errors/invalid-parameters-error.js' export { InvalidRedirectUriError } from './errors/invalid-redirect-uri-error.js' export { InvalidRequestError } from './errors/invalid-request-error.js' export { InvalidTokenError } from './errors/invalid-token-error.js' export { LoginRequiredError } from './errors/login-required-error.js' export { UnauthorizedClientError } from './errors/unauthorized-client-error.js' -export { UnauthorizedDpopError } from './errors/unauthorized-dpop-error.js' -export { UnauthorizedError } from './errors/unauthorized-error.js' -export { UnknownRequestError } from './errors/unknown-request-error.js' export { UseDpopNonceError } from './errors/use-dpop-nonce-error.js' +export { WWWAuthenticateError } from './errors/www-authenticate-error.js' diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 5653a7a9ceb..92e59b9f6ba 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -49,6 +49,8 @@ import { DeviceId } from './device/device-id.js' import { AccessDeniedError } from './errors/access-denied-error.js' import { AccountSelectionRequiredError } from './errors/account-selection-required-error.js' import { ConsentRequiredError } from './errors/consent-required-error.js' +import { InvalidClientError } from './errors/invalid-client-error.js' +import { InvalidGrantError } from './errors/invalid-grant-error.js' import { InvalidParametersError } from './errors/invalid-parameters-error.js' import { InvalidRequestError } from './errors/invalid-request-error.js' import { LoginRequiredError } from './errors/login-required-error.js' @@ -276,7 +278,7 @@ export class OAuthProvider extends OAuthVerifier { if (nonce != null) { const unique = await this.replayManager.uniqueAuth(nonce, client.id) if (!unique) { - throw new InvalidRequestError(`${clientAuth.method} jti reused`) + throw new InvalidClientError(`${clientAuth.method} jti reused`) } } @@ -643,7 +645,7 @@ export class OAuthProvider extends OAuthVerifier { const clientAuth = await this.authenticateClient(client, 'token', input) if (!client.metadata.grant_types.includes(input.grant_type)) { - throw new InvalidRequestError( + throw new InvalidGrantError( `"${input.grant_type}" grant type is not allowed for this client`, ) } @@ -656,7 +658,7 @@ export class OAuthProvider extends OAuthVerifier { return this.refreshTokenGrant(client, clientAuth, input, dpopJkt) } - throw new InvalidRequestError( + throw new InvalidGrantError( // @ts-expect-error: fool proof `Grant type "${input.grant_type}" not supported`, ) diff --git a/packages/oauth-provider/src/oauth-verifier.ts b/packages/oauth-provider/src/oauth-verifier.ts index f2b62145a0d..b4d3bf5a05b 100644 --- a/packages/oauth-provider/src/oauth-verifier.ts +++ b/packages/oauth-provider/src/oauth-verifier.ts @@ -5,6 +5,7 @@ import { DpopManager, DpopManagerOptions } from './dpop/dpop-manager.js' import { DpopNonce } from './dpop/dpop-nonce.js' import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js' import { InvalidTokenError } from './errors/invalid-token-error.js' +import { WWWAuthenticateError } from './errors/www-authenticate-error.js' import { ReplayManager } from './replay/replay-manager.js' import { ReplayStore } from './replay/replay-store.js' import { Signer } from './signer/signer.js' @@ -128,7 +129,7 @@ export class OAuthVerifier { this.accessTokenType !== AccessTokenType.auto && this.accessTokenType !== accessTokenType ) { - throw new InvalidTokenError(`Invalid token`, { [tokenType]: {} }) + throw new InvalidTokenError(tokenType, `Invalid token`) } } @@ -140,7 +141,7 @@ export class OAuthVerifier { ): Promise { const jwt = jwtSchema.safeParse(token) if (!jwt.success) { - throw new InvalidTokenError(`Invalid token`, { [tokenType]: {} }) + throw new InvalidTokenError(tokenType, `Invalid token`) } this.assertTokenTypeAllowed(tokenType, AccessTokenType.jwt) @@ -148,11 +149,7 @@ export class OAuthVerifier { const { payload } = await this.signer .verifyAccessToken(jwt.data) .catch((err) => { - throw InvalidTokenError.from( - err, - { [tokenType]: {} }, - 'Unable to verify token', - ) + throw InvalidTokenError.from(err, tokenType) }) return verifyTokenClaims( @@ -175,10 +172,27 @@ export class OAuthVerifier { verifyOptions?: VerifyTokenClaimsOptions, ): Promise { const [tokenType, token] = parseAuthorizationHeader(headers.authorization) - const dpopJkt = await this.checkDpopProof(headers.dpop, method, url, token) - if (tokenType === 'DPoP' && !dpopJkt) { - throw new InvalidDpopProofError(`DPoP proof required`) + try { + const dpopJkt = await this.checkDpopProof( + headers.dpop, + method, + url, + token, + ) + + if (tokenType === 'DPoP' && !dpopJkt) { + throw new InvalidDpopProofError(`DPoP proof required`) + } + + return await this.authenticateToken( + tokenType, + token, + dpopJkt, + verifyOptions, + ) + } catch (err) { + if (err instanceof WWWAuthenticateError) throw err + throw InvalidTokenError.from(err, tokenType) } - return this.authenticateToken(tokenType, token, dpopJkt, verifyOptions) } } diff --git a/packages/oauth-provider/src/request/request-manager.ts b/packages/oauth-provider/src/request/request-manager.ts index 500c8f4bc4f..551c9c630fa 100644 --- a/packages/oauth-provider/src/request/request-manager.ts +++ b/packages/oauth-provider/src/request/request-manager.ts @@ -12,9 +12,9 @@ import { } from '../constants.js' import { DeviceId } from '../device/device-id.js' import { AccessDeniedError } from '../errors/access-denied-error.js' +import { InvalidGrantError } from '../errors/invalid-grant-error.js' import { InvalidParametersError } from '../errors/invalid-parameters-error.js' import { InvalidRequestError } from '../errors/invalid-request-error.js' -import { UnknownRequestError } from '../oauth-errors.js' import { OIDC_SCOPE_CLAIMS } from '../oidc/claims.js' import { Sub } from '../oidc/sub.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' @@ -317,7 +317,7 @@ export class RequestManager { const id = decodeRequestUri(uri) const data = await this.store.readRequest(id) - if (!data) throw new UnknownRequestError(uri) + if (!data) throw new InvalidRequestError(`Unknown request_uri "${uri}"`) const updates: UpdateRequestData = {} @@ -382,7 +382,7 @@ export class RequestManager { const id = decodeRequestUri(uri) const data = await this.store.readRequest(id) - if (!data) throw new UnknownRequestError(uri) + if (!data) throw new InvalidRequestError(`Unknown request_uri "${uri}"`) try { if (data.expiresAt < new Date()) { @@ -450,7 +450,7 @@ export class RequestManager { parameters: AuthorizationParameters }> { const result = await this.store.findRequestByCode(code) - if (!result) throw new InvalidRequestError('Invalid code') + if (!result) throw new InvalidGrantError('Invalid code') try { const { data } = result @@ -461,11 +461,11 @@ export class RequestManager { } if (data.clientId !== client.id) { - throw new InvalidRequestError('This code was issued for another client') + throw new InvalidGrantError('This code was issued for another client') } if (data.expiresAt < new Date()) { - throw new InvalidRequestError('This code has expired') + throw new InvalidGrantError('This code has expired') } if (data.clientAuth.method === 'none') { @@ -476,11 +476,11 @@ export class RequestManager { // method (the token created will be bound to the current clientAuth). } else { if (clientAuth.method !== data.clientAuth.method) { - throw new InvalidRequestError('Invalid client authentication') + throw new InvalidGrantError('Invalid client authentication') } if (!(await client.validateClientAuth(data.clientAuth))) { - throw new InvalidRequestError('Invalid client authentication') + throw new InvalidGrantError('Invalid client authentication') } } diff --git a/packages/oauth-provider/src/token/token-manager.ts b/packages/oauth-provider/src/token/token-manager.ts index 2069af47274..1d82417b5c4 100644 --- a/packages/oauth-provider/src/token/token-manager.ts +++ b/packages/oauth-provider/src/token/token-manager.ts @@ -16,10 +16,10 @@ import { } from '../constants.js' import { DeviceId } from '../device/device-id.js' import { InvalidDpopKeyBindingError } from '../errors/invalid-dpop-key-binding.js' +import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' +import { InvalidGrantError } from '../errors/invalid-grant-error.js' import { InvalidRequestError } from '../errors/invalid-request-error.js' import { InvalidTokenError } from '../errors/invalid-token-error.js' -import { UnauthorizedClientError } from '../errors/unauthorized-client-error.js' -import { UnauthorizedDpopError } from '../errors/unauthorized-dpop-error.js' import { AuthorizationDetails } from '../parameters/authorization-details.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { isCode } from '../request/code.js' @@ -129,13 +129,13 @@ export class TokenManager { dpopJkt: null | string, ): Promise { if (client.metadata.dpop_bound_access_tokens && !dpopJkt) { - throw new UnauthorizedDpopError() + throw new InvalidDpopProofError('DPoP proof required') } if (!parameters.dpop_jkt) { if (dpopJkt) parameters = { ...parameters, dpop_jkt: dpopJkt } } else if (!dpopJkt) { - throw new UnauthorizedDpopError() + throw new InvalidDpopProofError('DPoP proof required') } else if (parameters.dpop_jkt !== dpopJkt) { throw new InvalidDpopKeyBindingError() } @@ -144,6 +144,7 @@ export class TokenManager { clientAuth.method === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' ) { + // Clients **must not** use their private key to sign DPoP proofs. if (parameters.dpop_jkt && clientAuth.jkt === parameters.dpop_jkt) { throw new InvalidRequestError( 'The DPoP proof must be signed with a different key than the client assertion', @@ -152,7 +153,7 @@ export class TokenManager { } if (!client.metadata.grant_types.includes(input.grant_type)) { - throw new InvalidRequestError( + throw new InvalidGrantError( `This client is not allowed to use the "${input.grant_type}" grant type`, ) } @@ -160,7 +161,7 @@ export class TokenManager { switch (input.grant_type) { case 'authorization_code': if (!parameters.code_challenge || !parameters.code_challenge_method) { - throw new InvalidRequestError('PKCE is required') + throw new InvalidGrantError('PKCE is required') } if (!parameters.redirect_uri) { @@ -170,10 +171,10 @@ export class TokenManager { if (redirect_uri) { parameters = { ...parameters, redirect_uri } } else { - throw new InvalidRequestError(`Invalid redirect_uri`) + throw new InvalidGrantError(`Invalid redirect_uri`) } } else if (parameters.redirect_uri !== input.redirect_uri) { - throw new InvalidRequestError( + throw new InvalidGrantError( 'This code was issued for another redirect_uri', ) } @@ -186,13 +187,13 @@ export class TokenManager { if (parameters.code_challenge) { if (!('code_verifier' in input) || !input.code_verifier) { - throw new InvalidRequestError('code_verifier is required') + throw new InvalidGrantError('code_verifier is required') } switch (parameters.code_challenge_method) { case undefined: // Default is "plain" (per spec) case 'plain': { if (parameters.code_challenge !== input.code_verifier) { - throw new InvalidRequestError('Invalid code_verifier') + throw new InvalidGrantError('Invalid code_verifier') } break } @@ -207,7 +208,7 @@ export class TokenManager { .update(input.code_verifier) .digest() if (inputChallenge.compare(computedChallenge) !== 0) { - throw new InvalidRequestError('Invalid code_verifier') + throw new InvalidGrantError('Invalid code_verifier') } break } @@ -224,7 +225,7 @@ export class TokenManager { const tokenInfo = await this.store.findTokenByCode(code) if (tokenInfo) { await this.store.deleteToken(tokenInfo.id) - throw new InvalidRequestError(`Code replayed`) + throw new InvalidGrantError(`Code replayed`) } } @@ -309,19 +310,19 @@ export class TokenManager { tokenInfo: TokenInfo, ) { if (tokenInfo.data.clientId !== client.id) { - throw new InvalidRequestError(`Token was not issued to this client`) + throw new InvalidGrantError(`Token was not issued to this client`) } if (tokenInfo.info?.authorizedClients.includes(client.id) === false) { - throw new InvalidRequestError(`Client no longer trusted by user`) + throw new InvalidGrantError(`Client no longer trusted by user`) } if (tokenInfo.data.clientAuth.method !== clientAuth.method) { - throw new InvalidRequestError(`Client authentication method mismatch`) + throw new InvalidGrantError(`Client authentication method mismatch`) } if (!(await client.validateClientAuth(tokenInfo.data.clientAuth))) { - throw new InvalidRequestError(`Client authentication mismatch`) + throw new InvalidGrantError(`Client authentication mismatch`) } } @@ -335,7 +336,7 @@ export class TokenManager { input.refresh_token, ) if (!tokenInfo?.currentRefreshToken) { - throw new InvalidRequestError(`Invalid refresh token`) + throw new InvalidGrantError(`Invalid refresh token`) } const { account, info, data } = tokenInfo @@ -343,14 +344,14 @@ export class TokenManager { try { if (tokenInfo.currentRefreshToken !== input.refresh_token) { - throw new InvalidRequestError(`refresh token replayed`) + throw new InvalidGrantError(`refresh token replayed`) } await this.validateAccess(client, clientAuth, tokenInfo) if (parameters.dpop_jkt) { if (!dpopJkt) { - throw new UnauthorizedDpopError() + throw new InvalidDpopProofError('DPoP proof required') } else if (parameters.dpop_jkt !== dpopJkt) { throw new InvalidDpopKeyBindingError() } @@ -362,13 +363,11 @@ export class TokenManager { ? UNAUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT : AUTHENTICATED_REFRESH_INACTIVITY_TIMEOUT if (lastActivity.getTime() + inactivityTimeout < Date.now()) { - throw new InvalidRequestError( - `Refresh token exceeded inactivity timeout`, - ) + throw new InvalidGrantError(`Refresh token exceeded inactivity timeout`) } if (data.createdAt.getTime() + TOTAL_REFRESH_LIFETIME < Date.now()) { - throw new InvalidRequestError(`Refresh token expired`) + throw new InvalidGrantError(`Refresh token expired`) } const authorization_details = @@ -460,7 +459,7 @@ export class TokenManager { } /** - * @see {@link https://datatracker.ietf.org/doc/html/rfc7009#section-2.2 rfc7009} + * @see {@link https://datatracker.ietf.org/doc/html/rfc7009#section-2.2 | RFC7009 Section 2.2} */ async revoke(token: string): Promise { switch (true) { @@ -508,7 +507,7 @@ export class TokenManager { ): Promise { const tokenInfo = await this.findTokenInfo(token) if (!tokenInfo) { - throw new UnauthorizedClientError(`Invalid token`) + throw new InvalidGrantError(`Invalid token`) } try { @@ -519,7 +518,7 @@ export class TokenManager { } if (tokenInfo.data.expiresAt.getTime() < Date.now()) { - throw new UnauthorizedClientError(`Token expired`) + throw new InvalidGrantError(`Token expired`) } return tokenInfo @@ -571,11 +570,11 @@ export class TokenManager { const tokenInfo = await this.store.readToken(tokenId) if (!tokenInfo) { - throw new InvalidTokenError(`Invalid token`, { [tokenType]: {} }) + throw new InvalidTokenError(tokenType, `Invalid token`) } if (!(tokenInfo.data.expiresAt.getTime() > Date.now())) { - throw new InvalidTokenError(`Token expired`, { [tokenType]: {} }) + throw new InvalidTokenError(tokenType, `Token expired`) } return tokenInfo diff --git a/packages/oauth-provider/src/token/verify-token-claims.ts b/packages/oauth-provider/src/token/verify-token-claims.ts index a34659333dc..8d47c8528f9 100644 --- a/packages/oauth-provider/src/token/verify-token-claims.ts +++ b/packages/oauth-provider/src/token/verify-token-claims.ts @@ -1,7 +1,7 @@ import { AccessToken } from '../access-token/access-token.js' import { InvalidDpopKeyBindingError } from '../errors/invalid-dpop-key-binding.js' import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' -import { UnauthorizedError } from '../errors/unauthorized-error.js' +import { InvalidTokenError } from '../oauth-errors.js' import { asArray } from '../util/cast.js' import { TokenClaims } from './token-claims.js' import { TokenId } from './token-id.js' @@ -33,9 +33,7 @@ export function verifyTokenClaims( const expectedTokenType: TokenType = claimsJkt ? 'DPoP' : 'Bearer' if (expectedTokenType !== tokenType) { - throw new UnauthorizedError(`Invalid token type`, { - [expectedTokenType]: {}, - }) + throw new InvalidTokenError(expectedTokenType, `Invalid token type`) } if (tokenType === 'DPoP' && !dpopJkt) { throw new InvalidDpopProofError(`jkt is required for DPoP tokens`) @@ -47,18 +45,14 @@ export function verifyTokenClaims( if (options?.audience) { const aud = asArray(claims.aud) if (!options.audience.some((v) => aud.includes(v))) { - throw new UnauthorizedError(`Invalid audience`, { - [tokenType]: {}, - }) + throw new InvalidTokenError(tokenType, `Invalid audience`) } } if (options?.scope) { const scopes = claims.scope?.split(' ') if (!scopes || !options.scope.some((v) => scopes.includes(v))) { - throw new UnauthorizedError(`Invalid scope`, { - [tokenType]: {}, - }) + throw new InvalidTokenError(tokenType, `Invalid scope`) } } diff --git a/packages/oauth-provider/src/util/authorization-header.ts b/packages/oauth-provider/src/util/authorization-header.ts index b9a390fad12..fbf110f2f8b 100644 --- a/packages/oauth-provider/src/util/authorization-header.ts +++ b/packages/oauth-provider/src/util/authorization-header.ts @@ -1,7 +1,8 @@ import { z } from 'zod' import { accessTokenSchema } from '../access-token/access-token.js' -import { UnauthorizedError } from '../errors/unauthorized-error.js' +import { InvalidRequestError } from '../errors/invalid-request-error.js' +import { WWWAuthenticateError } from '../errors/www-authenticate-error.js' import { tokenTypeSchema } from '../token/token-type.js' export const authorizationHeaderSchema = z.tuple([ @@ -10,12 +11,17 @@ export const authorizationHeaderSchema = z.tuple([ ]) export const parseAuthorizationHeader = (header?: string) => { - const parsed = authorizationHeaderSchema.safeParse(header?.split(' ', 2)) + if (header == null) { + throw new WWWAuthenticateError( + 'invalid_request', + 'Authorization header required', + { Bearer: {}, DPoP: {} }, + ) + } + + const parsed = authorizationHeaderSchema.safeParse(header.split(' ', 2)) if (!parsed.success) { - throw new UnauthorizedError('Invalid authorization header', { - Bearer: {}, - DPoP: {}, - }) + throw new InvalidRequestError('Invalid authorization header') } return parsed.data } From ec5201ec1c299cb4638bd6b882e8a5bb0d8083ad Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 10 Apr 2024 14:24:36 +0200 Subject: [PATCH 095/140] fix(fetch): make sure the body is consumed in case of http error --- packages/fetch/src/fetch-response.ts | 129 ++++++++++++++++++--------- packages/fetch/src/index.ts | 1 + packages/fetch/src/util.ts | 49 ++++++++++ 3 files changed, 136 insertions(+), 43 deletions(-) create mode 100644 packages/fetch/src/util.ts diff --git a/packages/fetch/src/fetch-response.ts b/packages/fetch/src/fetch-response.ts index 55fd9d53441..eb3499b7a50 100644 --- a/packages/fetch/src/fetch-response.ts +++ b/packages/fetch/src/fetch-response.ts @@ -2,40 +2,52 @@ import { Transformer, compose } from '@atproto/transformer' import { z } from 'zod' import { FetchError, FetchErrorOptions } from './fetch-error.js' +import { Json, ifObject, ifString } from './util.js' import { TransformedResponse } from './transformed-response.js' export type ResponseTranformer = Transformer -async function extractResponseMessage(response: Response): Promise { +async function extractResponseMessage( + headers: Headers, + body?: Blob | null, +): Promise { + if (!body) return undefined + + const contentType = headers.get('content-type') + if (!contentType) return undefined + + const mimeType = contentType.split(';')[0].trim() + if (!mimeType) return undefined + try { - const contentType = response.headers - .get('content-type') - ?.split(';')[0] - .trim() - if (contentType && response.body && !response.bodyUsed) { - if (contentType === 'text/plain') { - return response.clone().text() - } else if (/^application\/(?:[^+]+\+)?json$/i.test(contentType)) { - const json = await response.clone().json() - if (typeof json?.error_description === 'string') { - return json.error_description - } else if (typeof json?.error === 'string') { - return json.error - } else if (typeof json?.message === 'string') { - return json.message - } - } + if (mimeType === 'text/plain') { + return await body.text() + } else if (/^application\/(?:[^+]+\+)?json$/i.test(mimeType)) { + const json = await body.text().then(JSON.parse) + + if (typeof json === 'string') return json + + const errorDescription = ifString(ifObject(json)?.['error_description']) + if (errorDescription) return errorDescription + + const error = ifString(ifObject(json)?.['error']) + if (error) return error + + const message = ifString(ifObject(json)?.['message']) + if (message) return message } } catch { // noop } - return response.statusText + + return undefined } export class FetchResponseError extends FetchError { constructor( statusCode: number, message?: string, + readonly body?: Blob | null, options?: FetchErrorOptions, ) { super(statusCode, message, options) @@ -44,12 +56,24 @@ export class FetchResponseError extends FetchError { static async from( response: Response, status = response.status, - message?: string, - cause?: unknown, + customMessage?: string, + options?: FetchErrorOptions, ) { - message ??= await extractResponseMessage(response) - return new FetchResponseError(status, message, { - cause, + // Make sure the body gets consumed as, in some environments (Node 👀), the + // response will not be GC'd. + const body = response.body + ? !response.bodyUsed + ? await response.blob() + : undefined + : null + + const message = + customMessage ?? + (await extractResponseMessage(response.headers, body)) ?? + response.statusText + + return new FetchResponseError(status, message, body, { + ...options, response, }) } @@ -82,7 +106,7 @@ export async function fetchResponseMaxSize( if (contentLength) { const length = Number(contentLength) if (!(length < maxBytes)) { - const err = new FetchResponseError(502, 'Response too large', { + const err = new FetchResponseError(502, 'Response too large', undefined, { response, }) await response.body.cancel(err) @@ -101,7 +125,7 @@ export async function fetchResponseMaxSize( ctrl.enqueue(chunk) } else { ctrl.error( - new FetchResponseError(502, 'Response too large', { + new FetchResponseError(502, 'Response too large', undefined, { response, }), ) @@ -134,17 +158,17 @@ export function fetchTypeProcessor( if (contentType) { if (!isExpected(contentType)) { - throw new FetchResponseError( + throw await FetchResponseError.from( + response, 502, `Unexpected response Content-Type (${contentType})`, - { response }, ) } } else if (contentTypeRequired) { - throw new FetchResponseError( + throw await FetchResponseError.from( + response, 502, 'Missing response Content-Type header', - { response }, ) } @@ -152,29 +176,48 @@ export function fetchTypeProcessor( } } -export type ParsedJsonResponse = { +export type ParsedJsonResponse = { response: Response json: T } -export async function jsonTranformer( +export async function jsonTranformer( response: Response, ): Promise> { - return response - .json() - .then((json) => ({ + if (response.body === null) { + throw new FetchResponseError(502, 'No response body', null, { + response, + }) + } + + if (response.bodyUsed) { + throw new FetchResponseError(502, 'Response body already used', undefined, { response, - json: json as T, - })) - .catch(async (cause) => { - throw new FetchResponseError(502, 'Unable to parse response JSON', { - response, - cause, - }) }) + } + + // Read as blob to allow throwing with the body in case on invalid JSON (for debugging/logging purposes mainly) + const body = await response.blob().catch(async (cause) => { + throw new FetchResponseError( + 502, + 'Failed to read response body', + undefined, + { response, cause }, + ) + }) + + try { + const json = (await body.text().then(JSON.parse)) as T + return { response, json } + } catch (cause) { + throw new FetchResponseError(502, 'Unable to parse response JSON', body, { + response, + cause, + }) + } } -export function fetchJsonProcessor( +export function fetchJsonProcessor( contentType: ContentTypeCheck = /^application\/(?:[^+]+\+)?json$/, contentTypeRequired = true, ): Transformer> { diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts index 93f31e5493e..d8fa808f3ec 100644 --- a/packages/fetch/src/index.ts +++ b/packages/fetch/src/index.ts @@ -3,3 +3,4 @@ export * from './fetch-request.js' export * from './fetch-response.js' export * from './fetch-wrap.js' export * from './fetch.js' +export * from './util.js' diff --git a/packages/fetch/src/util.ts b/packages/fetch/src/util.ts new file mode 100644 index 00000000000..313d693543d --- /dev/null +++ b/packages/fetch/src/util.ts @@ -0,0 +1,49 @@ +// TODO: Move to a shared package ? + +export type JsonScalar = string | number | boolean | null +export type Json = JsonScalar | Json[] | { [key: string]: undefined | Json } +export type JsonObject = { [key: string]: Json } +export type JsonArray = Json[] + +declare global { + interface JSON { + parse(text: string, reviver?: (key: any, value: any) => any): Json + } +} + +// TODO: Move to a shared package ? + +const plainObjectProto = Object.prototype +export const ifObject = (v?: V) => { + if (typeof v === 'object' && v != null && !Array.isArray(v)) { + const proto = Object.getPrototypeOf(v) + if (proto === null || proto === plainObjectProto) { + // eslint-disable-next-line @typescript-eslint/ban-types + return v as V extends JsonScalar | JsonArray | Function | symbol + ? never + : V extends Json + ? V + : // Plain object are (mostly) safe to access as Json + { [key: string]: unknown } + } + } + + return undefined +} + +export const ifArray = (v?: V) => (Array.isArray(v) ? v : undefined) +export const ifScalar = (v?: V) => { + switch (typeof v) { + case 'string': + case 'number': + case 'boolean': + return v + default: + if (v === null) return null as V & null + return undefined as V extends JsonScalar ? never : undefined + } +} +export const ifBoolean = (v?: V) => (typeof v === 'boolean' ? v : undefined) +export const ifString = (v?: V) => (typeof v === 'string' ? v : undefined) +export const ifNumber = (v?: V) => (typeof v === 'number' ? v : undefined) +export const ifNull = (v?: V) => (v === null ? v : undefined) From f9bdc6cbf1fbc29adae61d36f99e4b3f4c50125a Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 10 Apr 2024 14:27:07 +0200 Subject: [PATCH 096/140] fix(dpop-fetch): properly parse "use_dpop_nonce" error in response fix(dpop-fetch): never leave a stream unconsumed --- packages/fetch-dpop/src/index.ts | 150 ++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 50 deletions(-) diff --git a/packages/fetch-dpop/src/index.ts b/packages/fetch-dpop/src/index.ts index d94650c25d9..95ce8275b39 100644 --- a/packages/fetch-dpop/src/index.ts +++ b/packages/fetch-dpop/src/index.ts @@ -1,8 +1,26 @@ import { b64uEncode } from '@atproto/b64' import { GenericStore } from '@atproto/caching' -import { Fetch } from '@atproto/fetch' +import { Fetch, Json } from '@atproto/fetch' import { Key } from '@atproto/jwk' +export type DpopFetchWrapperOptions = { + key: Key + iss: string + alg?: string + sha256?: (input: string) => Promise + nonceCache?: GenericStore + + /** + * Is the intended server an authorization server (true) or a resource server + * (false)? Setting this may allow to avoid parsing the response body to + * determine the dpop-nonce. + * + * @default undefined + */ + isAuthServer?: boolean + fetch?: Fetch +} + export function dpopFetchWrapper({ key, iss, @@ -10,15 +28,9 @@ export function dpopFetchWrapper({ sha256 = typeof crypto !== 'undefined' && crypto.subtle != null ? subtleSha256 : undefined, + isAuthServer, nonceCache, -}: { - key: Key - iss: string - alg?: string - sha256?: (input: string) => Promise - nonceCache?: GenericStore - fetch?: Fetch -}): Fetch { +}: DpopFetchWrapperOptions): Fetch { if (!sha256) { throw new Error( `crypto.subtle is not available in this environment. Please provide a sha256 function.`, @@ -34,6 +46,7 @@ export function dpopFetchWrapper({ alg, sha256, nonceCache, + isAuthServer, fetch, ) } @@ -47,6 +60,7 @@ export async function dpopFetch( alg: string = key.alg || 'ES256', sha256: (input: string) => string | PromiseLike = subtleSha256, nonceCache?: GenericStore, + isAuthServer?: boolean, fetch = globalThis.fetch as Fetch, ): Promise { const authorizationHeader = request.headers.get('Authorization') @@ -54,43 +68,63 @@ export async function dpopFetch( ? await sha256(authorizationHeader.slice(5)) : undefined - const { origin } = new URL(request.url) + const { method, url } = request + const { origin } = new URL(url) + let nonce: string | undefined // Clone request for potential retry const clonedRequest = request.clone() - // Try with the previously known nonce - const oldNonce = await Promise.resolve() - .then(() => nonceCache?.get(origin)) - .catch(() => undefined) // Ignore cache.get errors - - request.headers.set( - 'DPoP', - await buildProof(key, alg, iss, request.method, request.url, oldNonce, ath), - ) - - const response = await fetch(request) - - const nonce = response.headers.get('DPoP-Nonce') - if (!nonce) return response - - // Store the fresh nonce for future requests + // Use the cached nonce if available try { - await nonceCache?.set(origin, nonce) + nonce = await nonceCache?.get(origin) } catch { - // Ignore cache.set errors + // Ignore cache.get errors } - if (!(await isUseDpopNonceError(response))) { - return response + // Make sure the clonedRequest body gets (finally) consumed. + try { + const dpopProof = await buildProof(key, alg, iss, method, url, nonce, ath) + request.headers.set('DPoP', dpopProof) + + const response = await fetch(request) + + // Make sure the response body is consumed. Either by the caller (when the + // response is returned), of if an error is thrown (catch block). + try { + nonce = response.headers.get('DPoP-Nonce') || undefined + if (!nonce) { + // We don't have a nonce, so we can't retry + return response + } + + // Store the fresh nonce for future requests + try { + await nonceCache?.set(origin, nonce) + } catch { + // Ignore cache.set errors + } + + if (!(await isUseDpopNonceError(response, isAuthServer))) { + // Not a use_dpop_nonce error, so there is no need to retry + return response + } + + // If the response was not returned to the caller, make sure the body is + // consumed. + await response.body?.cancel() + + const dpopProof = await buildProof(key, alg, iss, method, url, nonce, ath) + clonedRequest.headers.set('DPoP', dpopProof) + + return await fetch(clonedRequest) + } catch (err) { + await response.body?.cancel(err) + throw err + } + } finally { + await clonedRequest.body?.cancel() } - - clonedRequest.headers.set( - 'DPoP', - await buildProof(key, alg, iss, request.method, request.url, nonce, ath), - ) - - return fetch(clonedRequest) } async function buildProof( @@ -128,23 +162,39 @@ async function buildProof( ) } -async function isUseDpopNonceError(response: Response): Promise { - if (response.status !== 400) { - return false +async function isUseDpopNonceError( + response: Response, + isAuthServer?: boolean, +): Promise { + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no + if (isAuthServer == null || isAuthServer === false) { + const wwwAuth = response.headers.get('WWW-Authenticate') + if (wwwAuth?.startsWith('DPoP')) { + return wwwAuth.includes('error="use_dpop_nonce"') + } } - const ct = response.headers.get('Content-Type') - const mime = ct?.split(';')[0]?.trim() - if (mime !== 'application/json') { - return false + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid + if (isAuthServer == null || isAuthServer === true) { + if (response.status === 400) { + const mime = response.headers.get('Content-Type')?.split(';')[0]?.trim() + if (mime === 'application/json') { + try { + const body: Json = await response.clone().json() + return ( + typeof body === 'object' && + !Array.isArray(body) && + body?.['error'] === 'use_dpop_nonce' + ) + } catch { + return false + } + } + } } - try { - const body = await response.clone().json() - return body?.error === 'use_dpop_nonce' - } catch { - return false - } + return false } function subtleSha256(input: string): Promise { From 85ad5b0c35139a1f44e50c513243510fd21e0b6f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 10 Apr 2024 14:28:00 +0200 Subject: [PATCH 097/140] fix(handle-resolver): any invalid payload should be considered as "null" --- .../src/well-known-handler-resolver.ts | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/handle-resolver/src/well-known-handler-resolver.ts b/packages/handle-resolver/src/well-known-handler-resolver.ts index 1278fbf60d7..4dd774b1496 100644 --- a/packages/handle-resolver/src/well-known-handler-resolver.ts +++ b/packages/handle-resolver/src/well-known-handler-resolver.ts @@ -1,4 +1,4 @@ -import { Fetch, FetchResponseError } from '@atproto/fetch' +import { Fetch } from '@atproto/fetch' import { HandleResolveOptions, @@ -31,31 +31,20 @@ export class WellKnownHandleResolver implements HandleResolver { const request = new Request(url, { headers, signal: options?.signal }) try { - const response = await this.fetch.call(globalThis, request) + const response = await (0, this.fetch)(request) const text = await response.text() const firstLine = text.split('\n')[0]!.trim() if (isResolvedHandle(firstLine)) return firstLine - // If a web server is present at the handle's domain, it could return - // any response. Only payload that start with "did:" but are not a - // valid DID are considered unexpected behavior. - if (firstLine.startsWith('did:')) { - throw new FetchResponseError( - 502, - 'Invalid DID returned from well-known method', - { request, response }, - ) - } - - // Any other response is considered as a positive indication that the - // handle does not resolve to a DID using the well-known method. return null - } catch { + } catch (err) { // The the request failed, assume the handle does not resolve to a DID, // unless the failure was due to the signal being aborted. options?.signal?.throwIfAborted() + // TODO: propagate some errors as-is (?) + return null } } From af71160b5d4d30bb183ad36ddbb89e5cc6d8421f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 10 Apr 2024 14:28:20 +0200 Subject: [PATCH 098/140] feat(http-util): expose context utilities --- packages/http-util/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/http-util/src/index.ts b/packages/http-util/src/index.ts index 51d5ec56a84..8e5921a1cef 100644 --- a/packages/http-util/src/index.ts +++ b/packages/http-util/src/index.ts @@ -1,4 +1,5 @@ export * from './accept.js' +export * from './context.js' export * from './middleware.js' export * from './parser.js' export * from './request.js' From 1c3633ede5400b0e336950b643e60b12063d71b5 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 10 Apr 2024 14:31:55 +0200 Subject: [PATCH 099/140] fix(pds): type cast json.parse result --- packages/pds/src/actor-store/preference/reader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds/src/actor-store/preference/reader.ts b/packages/pds/src/actor-store/preference/reader.ts index 2325350ff82..95b8c0263d9 100644 --- a/packages/pds/src/actor-store/preference/reader.ts +++ b/packages/pds/src/actor-store/preference/reader.ts @@ -11,7 +11,7 @@ export class PreferenceReader { .execute() return prefsRes .filter((pref) => !namespace || prefMatchNamespace(namespace, pref.name)) - .map((pref) => JSON.parse(pref.valueJson)) + .map((pref) => JSON.parse(pref.valueJson) as AccountPreference) } } From 9afaa6eadbd5c1de78b75f86d1e138990e6c6125 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Wed, 10 Apr 2024 14:33:58 +0200 Subject: [PATCH 100/140] fix(pds): expose "use_dpop_nonce" error in WWW-Authenticate header --- packages/pds/src/auth-verifier.ts | 37 ++++++++++++++++++------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index cba97c10e6a..c702a8277bf 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -1,7 +1,11 @@ import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto' import { getVerificationMaterial } from '@atproto/common' -import { OAuthError, OAuthVerifier } from '@atproto/oauth-provider' +import { + OAuthError, + OAuthVerifier, + WWWAuthenticateError, +} from '@atproto/oauth-provider' import { AuthRequiredError, ForbiddenError, @@ -130,15 +134,12 @@ export class AuthVerifier { access = async (ctx: ReqCtx): Promise => { this.setAuthHeaders(ctx) - return this.validateAccessToken(ctx.req, [ - AuthScope.Access, - AuthScope.AppPass, - ]) + return this.validateAccessToken(ctx, [AuthScope.Access, AuthScope.AppPass]) } accessCheckTakedown = async (ctx: ReqCtx): Promise => { this.setAuthHeaders(ctx) - const result = await this.validateAccessToken(ctx.req, [ + const result = await this.validateAccessToken(ctx, [ AuthScope.Access, AuthScope.AppPass, ]) @@ -160,12 +161,12 @@ export class AuthVerifier { accessNotAppPassword = async (ctx: ReqCtx): Promise => { this.setAuthHeaders(ctx) - return this.validateAccessToken(ctx.req, [AuthScope.Access]) + return this.validateAccessToken(ctx, [AuthScope.Access]) } accessDeactived = async (ctx: ReqCtx): Promise => { this.setAuthHeaders(ctx) - return this.validateAccessToken(ctx.req, [ + return this.validateAccessToken(ctx, [ AuthScope.Access, AuthScope.AppPass, AuthScope.Deactivated, @@ -362,15 +363,15 @@ export class AuthVerifier { } async validateAccessToken( - req: express.Request, + ctx: ReqCtx, scopes: AuthScope[], ): Promise { - const [type] = parseAuthorizationHeader(req.headers.authorization) + const [type] = parseAuthorizationHeader(ctx.req.headers.authorization) switch (type) { case BEARER: - return this.validateBearerAccessToken(req, scopes) + return this.validateBearerAccessToken(ctx, scopes) case DPOP: - return this.validateDpopAccessToken(req, scopes) + return this.validateDpopAccessToken(ctx, scopes) case null: throw new AuthRequiredError(undefined, 'AuthMissing') default: @@ -382,7 +383,7 @@ export class AuthVerifier { } async validateDpopAccessToken( - req: express.Request, + { req, res }: ReqCtx, scopes: AuthScope[], ): Promise { if (!scopes.includes(AuthScope.Access)) { @@ -416,8 +417,12 @@ export class AuthVerifier { artifacts: result.token, } } catch (err) { - // 'use_dpop_nonce' is expected to be in a particular format. Let's - // also transform any other OAuthError into an XRPCError. + // Make sure to include any WWW-Authenticate header in the response + // (particularly useful for DPoP's "use_dpop_nonce" error) + if (err instanceof WWWAuthenticateError) { + res?.setHeader('WWW-Authenticate', err.wwwAuthenticateHeader) + } + if (err instanceof OAuthError) { throw new XRPCError(err.status, err.error_description, err.error) } @@ -427,7 +432,7 @@ export class AuthVerifier { } async validateBearerAccessToken( - req: express.Request, + { req }: ReqCtx, scopes: AuthScope[], ): Promise { const { did, scope, token, audience } = await this.validateBearerToken( From 95945891befddad47614fe9aca363e2521667819 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 09:31:13 +0200 Subject: [PATCH 101/140] feat(oauth-server-metadata): expose issuer schema --- .../src/oauth-server-metadata.ts | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/oauth-server-metadata/src/oauth-server-metadata.ts b/packages/oauth-server-metadata/src/oauth-server-metadata.ts index 4e6d2ce0607..42be6c05d5d 100644 --- a/packages/oauth-server-metadata/src/oauth-server-metadata.ts +++ b/packages/oauth-server-metadata/src/oauth-server-metadata.ts @@ -1,40 +1,36 @@ import { z } from 'zod' -/** - * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} - */ -export const oauthServerMetadataSchema = z.object({ - issuer: z.string().superRefine((value, ctx) => { - try { - const url = new URL(value) - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Issuer URL must use "https" or "http"', - }) - return false - } - - if (value !== `${url.origin}/`) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - 'Issuer URL must not contain a path, username, password, query, or fragment', - }) - return false - } - - return true - } catch { +export const oauthServerIssuerSchema = z + .string() + .url() + .superRefine((value, ctx) => { + const url = new URL(value) + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Issuer must be a valid URL', + message: 'Issuer must be an HTTP or HTTPS URL', }) + return false + } + if (value !== `${url.origin}/`) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Issuer URL must not contain a path, username, password, query, or fragment', + }) return false } - }), + + return true + }) + +/** + * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} + */ +export const oauthServerMetadataSchema = z.object({ + issuer: oauthServerIssuerSchema, claims_supported: z.array(z.string()).optional(), claims_locales_supported: z.array(z.string()).optional(), From 1574b4d4591e0de1de847589c3e1b03f99015d94 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 09:42:37 +0200 Subject: [PATCH 102/140] fix(oauth-server-metadata-resolver): use private ts fields instead of private js fields --- .../src/isomorphic-oauth-server-metadata-resolver.ts | 12 ++++++------ .../tsconfig.build.json | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/oauth-server-metadata-resolver/src/isomorphic-oauth-server-metadata-resolver.ts b/packages/oauth-server-metadata-resolver/src/isomorphic-oauth-server-metadata-resolver.ts index c6453b278c3..880dc0413c8 100644 --- a/packages/oauth-server-metadata-resolver/src/isomorphic-oauth-server-metadata-resolver.ts +++ b/packages/oauth-server-metadata-resolver/src/isomorphic-oauth-server-metadata-resolver.ts @@ -19,15 +19,15 @@ export type IsomorphicOAuthServerMetadataResolverOptions = { export class IsomorphicOAuthServerMetadataResolver implements OAuthServerMetadataResolver { - readonly #fetch: Fetch - readonly #getter: CachedGetter + private readonly fetch: Fetch + private readonly getter: CachedGetter constructor({ fetch = globalThis.fetch, cache, }: IsomorphicOAuthServerMetadataResolverOptions = {}) { - this.#fetch = fetch - this.#getter = new CachedGetter( + this.fetch = fetch + this.getter = new CachedGetter( async (origin, options) => this.fetchServerMetadata(origin, 'oauth-authorization-server', options), cache, @@ -35,7 +35,7 @@ export class IsomorphicOAuthServerMetadataResolver } async resolve(origin: string): Promise { - return this.#getter.get(origin) + return this.getter.get(origin) } async fetchServerMetadata( @@ -72,7 +72,7 @@ export class IsomorphicOAuthServerMetadataResolver redirect: 'follow', }) - const response = await this.#fetch.call(globalThis, request) + const response = await (0, this.fetch)(request) if (!response.ok) { // Fallback to openid-configuration endpoint diff --git a/packages/oauth-server-metadata-resolver/tsconfig.build.json b/packages/oauth-server-metadata-resolver/tsconfig.build.json index fafdab3d6f7..436d8ecb628 100644 --- a/packages/oauth-server-metadata-resolver/tsconfig.build.json +++ b/packages/oauth-server-metadata-resolver/tsconfig.build.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig/node.json", + "extends": "../../tsconfig/isomorphic.json", "compilerOptions": { "rootDir": "./src", "outDir": "./dist" From f902845d42d91f3c64324111f30097e70fe94eca Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 09:43:22 +0200 Subject: [PATCH 103/140] refactor(oauth-server-metadata-resolver)!: use named export instead of default --- .../oauth-client-browser/src/browser-oauth-client-factory.ts | 2 +- packages/oauth-server-metadata-resolver/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts index 1ea1b857d60..94e16247334 100644 --- a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts +++ b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts @@ -16,7 +16,7 @@ import { OAuthClientMetadata, oauthClientMetadataSchema, } from '@atproto/oauth-client-metadata' -import IsomorphicOAuthServerMetadataResolver from '@atproto/oauth-server-metadata-resolver' +import { IsomorphicOAuthServerMetadataResolver } from '@atproto/oauth-server-metadata-resolver' import { BrowserOAuthDatabase, DatabaseStore, diff --git a/packages/oauth-server-metadata-resolver/src/index.ts b/packages/oauth-server-metadata-resolver/src/index.ts index f13672435ea..a5a11cb0fe4 100644 --- a/packages/oauth-server-metadata-resolver/src/index.ts +++ b/packages/oauth-server-metadata-resolver/src/index.ts @@ -1,4 +1,4 @@ export * from './isomorphic-oauth-server-metadata-resolver.js' export * from './oauth-server-metadata-resolver.js' -export { IsomorphicOAuthServerMetadataResolver as default } from './isomorphic-oauth-server-metadata-resolver.js' +export { IsomorphicOAuthServerMetadataResolver } from './isomorphic-oauth-server-metadata-resolver.js' From 22a4830225ed8ff80ccced696b6d08254e2b2167 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 10:02:45 +0200 Subject: [PATCH 104/140] refactor(oauth-client)!: use ISO Dates to convey session expiry --- .../src/browser-oauth-database.ts | 24 ++++++++++--------- packages/oauth-client/src/oauth-client.ts | 2 +- packages/oauth-client/src/oauth-server.ts | 11 ++++++--- packages/oauth-client/src/session-getter.ts | 2 +- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/oauth-client-browser/src/browser-oauth-database.ts b/packages/oauth-client-browser/src/browser-oauth-database.ts index cfc8738376d..d7a7e01e9ca 100644 --- a/packages/oauth-client-browser/src/browser-oauth-database.ts +++ b/packages/oauth-client-browser/src/browser-oauth-database.ts @@ -9,7 +9,7 @@ import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' type Item = { value: V - expiresAt: null | number + expiresAt?: string // ISO Date } type EncodedKey = { @@ -123,7 +123,7 @@ export class BrowserOAuthDatabase { }: { encode: (value: V) => Schema[N]['value'] | PromiseLike decode: (encoded: Schema[N]['value']) => V | PromiseLike - expiresAt: (value: V) => null | number + expiresAt: (value: V) => null | Date }, ): DatabaseStore { return { @@ -135,7 +135,7 @@ export class BrowserOAuthDatabase { if (item === undefined) return undefined // Too old (delete) - if (item.expiresAt != null && item.expiresAt < Date.now()) { + if (item.expiresAt != null && new Date(item.expiresAt) < new Date()) { await this.run(name, 'readwrite', (store) => store.delete(key)) return undefined } @@ -155,7 +155,7 @@ export class BrowserOAuthDatabase { // Create encoded item record const item = { value: await encode(value), - expiresAt: expiresAt(value), + expiresAt: expiresAt(value)?.toISOString(), } as Schema[N] // Store item record @@ -172,7 +172,9 @@ export class BrowserOAuthDatabase { getSessionStore(): DatabaseStore { return this.createStore('session', { expiresAt: ({ tokenSet }) => - tokenSet.refresh_token ? null : tokenSet.expires_at ?? null, + tokenSet.refresh_token || tokenSet.expires_at == null + ? null + : new Date(tokenSet.expires_at), encode: ({ dpopKey, ...session }) => ({ ...session, dpopKey: encodeKey(dpopKey), @@ -186,7 +188,7 @@ export class BrowserOAuthDatabase { getStateStore(): DatabaseStore { return this.createStore('state', { - expiresAt: (_value) => Date.now() + 10 * 60e3, + expiresAt: (_value) => new Date(Date.now() + 10 * 60e3), encode: ({ dpopKey, ...session }) => ({ ...session, dpopKey: encodeKey(dpopKey), @@ -200,7 +202,7 @@ export class BrowserOAuthDatabase { getPopupStore(): DatabaseStore { return this.createStore('popup', { - expiresAt: (_value) => Date.now() + 600e3, + expiresAt: (_value) => new Date(Date.now() + 600e3), encode: (value) => value, decode: (encoded) => encoded, }) @@ -208,7 +210,7 @@ export class BrowserOAuthDatabase { getDpopNonceCache(): undefined | DatabaseStore { return this.createStore('dpopNonceCache', { - expiresAt: (_value) => Date.now() + 600e3, + expiresAt: (_value) => new Date(Date.now() + 600e3), encode: (value) => value, decode: (encoded) => encoded, }) @@ -216,7 +218,7 @@ export class BrowserOAuthDatabase { getDidCache(): undefined | DatabaseStore { return this.createStore('didCache', { - expiresAt: (_value) => Date.now() + 60e3, + expiresAt: (_value) => new Date(Date.now() + 60e3), encode: (value) => value, decode: (encoded) => encoded, }) @@ -224,7 +226,7 @@ export class BrowserOAuthDatabase { getHandleCache(): undefined | DatabaseStore { return this.createStore('handleCache', { - expiresAt: (_value) => Date.now() + 60e3, + expiresAt: (_value) => new Date(Date.now() + 60e3), encode: (value) => value, decode: (encoded) => encoded, }) @@ -232,7 +234,7 @@ export class BrowserOAuthDatabase { getMetadataCache(): undefined | DatabaseStore { return this.createStore('metadataCache', { - expiresAt: (_value) => Date.now() + 60e3, + expiresAt: (_value) => new Date(Date.now() + 60e3), encode: (value) => value, decode: (encoded) => encoded, }) diff --git a/packages/oauth-client/src/oauth-client.ts b/packages/oauth-client/src/oauth-client.ts index 57a493274a3..0d6400977dc 100644 --- a/packages/oauth-client/src/oauth-client.ts +++ b/packages/oauth-client/src/oauth-client.ts @@ -37,7 +37,7 @@ export class OAuthClient { expired: tokenSet.expires_at == null ? undefined - : tokenSet.expires_at < Date.now() - 5e3, + : new Date(tokenSet.expires_at).getTime() < Date.now() - 5e3, scope: tokenSet.scope, iss: tokenSet.iss, aud: tokenSet.aud, diff --git a/packages/oauth-client/src/oauth-server.ts b/packages/oauth-client/src/oauth-server.ts index ebe9a02e24b..e0f4eb21d37 100644 --- a/packages/oauth-client/src/oauth-server.ts +++ b/packages/oauth-client/src/oauth-server.ts @@ -28,7 +28,8 @@ export type TokenSet = { refresh_token?: string access_token: string token_type: OAuthTokenType - expires_at?: number + /** ISO Date */ + expires_at?: string } export class OAuthServer { @@ -95,7 +96,9 @@ export class OAuthServer { token_type: tokenResponse.token_type ?? 'Bearer', expires_at: typeof tokenResponse.expires_in === 'number' - ? Date.now() + tokenResponse.expires_in * 1000 + ? new Date( + Date.now() + tokenResponse.expires_in * 1000, + ).toISOString() : undefined, } } catch (err) { @@ -135,7 +138,9 @@ export class OAuthServer { refresh_token: tokenResponse.refresh_token, access_token: tokenResponse.access_token, token_type: tokenResponse.token_type ?? 'Bearer', - expires_at: Date.now() + (tokenResponse.expires_in ?? 60) * 1000, + expires_at: tokenResponse.expires_in + ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString() + : undefined, } } catch (err) { await this.revoke(tokenResponse.access_token) diff --git a/packages/oauth-client/src/session-getter.ts b/packages/oauth-client/src/session-getter.ts index d136bed5472..b4a3d2de7db 100644 --- a/packages/oauth-client/src/session-getter.ts +++ b/packages/oauth-client/src/session-getter.ts @@ -75,7 +75,7 @@ export class SessionGetter extends CachedGetter { isStale: (sessionId, { tokenSet }) => { return ( tokenSet.expires_at != null && - tokenSet.expires_at < + new Date(tokenSet.expires_at).getTime() < Date.now() + // Add some lee way to ensure the token is not expired when it // reaches the server. From 3c7c5fb2a2d4a305a251d8efda62414e6e57a537 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 11:25:35 +0200 Subject: [PATCH 105/140] fix(dev-env): move oauth pds config to TestPds --- packages/dev-env/src/network.ts | 11 ----------- packages/dev-env/src/pds.ts | 11 +++++++++++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/dev-env/src/network.ts b/packages/dev-env/src/network.ts index 999fe8e46ca..11d18e24224 100644 --- a/packages/dev-env/src/network.ts +++ b/packages/dev-env/src/network.ts @@ -65,17 +65,6 @@ export class TestNetwork extends TestNetworkNoAppView { bskyAppViewDid: bsky.ctx.cfg.serverDid, modServiceUrl: `http://localhost:${ozonePort}`, modServiceDid: ozoneDid, - oauthDisableSsrf: true, - oauthProviderName: 'PDS (dev)', - oauthProviderPrimaryColor: '#ffcb1e', - oauthProviderLogo: - 'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png', - oauthProviderErrorColor: undefined, - oauthProviderHomeLink: 'https://bsky.social/', - oauthProviderTosLink: 'https://bsky.social/about/support/tos', - oauthProviderPrivacyPolicyLink: - 'https://bsky.social/about/support/privacy-policy', - oauthProviderSupportLink: 'https://blueskyweb.zendesk.com/hc/en-us', ...params.pds, }) diff --git a/packages/dev-env/src/pds.ts b/packages/dev-env/src/pds.ts index 0828f2f3f03..8a8594cf862 100644 --- a/packages/dev-env/src/pds.ts +++ b/packages/dev-env/src/pds.ts @@ -45,6 +45,17 @@ export class TestPds { modServiceDid: 'did:example:invalid', plcRotationKeyK256PrivateKeyHex: plcRotationPriv, inviteRequired: false, + oauthDisableSsrf: true, + oauthProviderName: 'PDS (dev)', + oauthProviderPrimaryColor: '#ffcb1e', + oauthProviderLogo: + 'https://uxwing.com/wp-content/themes/uxwing/download/animals-and-birds/bee-icon.png', + oauthProviderErrorColor: undefined, + oauthProviderHomeLink: 'https://bsky.social/', + oauthProviderTosLink: 'https://bsky.social/about/support/tos', + oauthProviderPrivacyPolicyLink: + 'https://bsky.social/about/support/privacy-policy', + oauthProviderSupportLink: 'https://blueskyweb.zendesk.com/hc/en-us', ...config, } const cfg = pds.envToCfg(env) From b319f0437fdfb2ac2889d7e44dcbd4fd04519c6d Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 15:14:12 +0200 Subject: [PATCH 106/140] feat(oauth-provider): add onAccountAddAuthorizedClient hook --- .../src/account/account-hooks.ts | 26 +++ .../src/account/account-manager.ts | 32 +++- .../src/account/account-store.ts | 2 +- .../oauth-provider/src/client/client-hooks.ts | 22 +++ .../src/client/client-manager.ts | 23 +-- packages/oauth-provider/src/oauth-hooks.ts | 14 +- packages/oauth-provider/src/oauth-provider.ts | 148 +++++++++--------- .../src/request/request-hooks.ts | 23 +++ .../src/request/request-info.ts | 12 ++ .../src/request/request-manager.ts | 32 +--- .../oauth-provider/src/token/token-hooks.ts | 39 +++++ .../oauth-provider/src/token/token-manager.ts | 57 +------ .../src/token/token-response.ts | 21 +++ 13 files changed, 266 insertions(+), 185 deletions(-) create mode 100644 packages/oauth-provider/src/account/account-hooks.ts create mode 100644 packages/oauth-provider/src/client/client-hooks.ts create mode 100644 packages/oauth-provider/src/request/request-hooks.ts create mode 100644 packages/oauth-provider/src/request/request-info.ts create mode 100644 packages/oauth-provider/src/token/token-hooks.ts create mode 100644 packages/oauth-provider/src/token/token-response.ts diff --git a/packages/oauth-provider/src/account/account-hooks.ts b/packages/oauth-provider/src/account/account-hooks.ts new file mode 100644 index 00000000000..c5f45583a61 --- /dev/null +++ b/packages/oauth-provider/src/account/account-hooks.ts @@ -0,0 +1,26 @@ +import { Client } from '../client/client.js' +import { DeviceId } from '../device/device-id.js' +import { ClientAuth } from '../token/token-store.js' +import { Awaitable } from '../util/awaitable.js' +import { Account } from './account-store.js' +// https://github.com/typescript-eslint/typescript-eslint/issues/8902 +// eslint-disable-next-line +import { AccountStore } from './account-store.js' + +/** + * Allows disabling the call to {@link AccountStore.addAuthorizedClient} based + * on the account, client and clientAuth (not all these info are available to + * the store method). + */ +export type AccountAddAuthorizedClient = ( + client: Client, + data: { + deviceId: DeviceId + account: Account + clientAuth: ClientAuth + }, +) => Awaitable + +export type AccountHooks = { + onAccountAddAuthorizedClient?: AccountAddAuthorizedClient +} diff --git a/packages/oauth-provider/src/account/account-manager.ts b/packages/oauth-provider/src/account/account-manager.ts index 8c433890da4..3ad2e159b4a 100644 --- a/packages/oauth-provider/src/account/account-manager.ts +++ b/packages/oauth-provider/src/account/account-manager.ts @@ -1,14 +1,24 @@ -import { OAuthClientId } from '@atproto/oauth-client-metadata' +import { Client } from '../client/client.js' import { DeviceId } from '../device/device-id.js' import { InvalidRequestError } from '../oauth-errors.js' import { Sub } from '../oidc/sub.js' +import { ClientAuth } from '../token/token-store.js' import { constantTime } from '../util/time.js' -import { AccountInfo, AccountStore, LoginCredentials } from './account-store.js' +import { AccountHooks } from './account-hooks.js' +import { + Account, + AccountInfo, + AccountStore, + LoginCredentials, +} from './account-store.js' const TIMING_ATTACK_MITIGATION_DELAY = 400 export class AccountManager { - constructor(protected readonly store: AccountStore) {} + constructor( + protected readonly store: AccountStore, + protected readonly hooks: AccountHooks, + ) {} public async signIn( credentials: LoginCredentials, @@ -31,10 +41,20 @@ export class AccountManager { public async addAuthorizedClient( deviceId: DeviceId, - sub: Sub, - clientId: OAuthClientId, + account: Account, + client: Client, + clientAuth: ClientAuth, ): Promise { - await this.store.addAuthorizedClient(deviceId, sub, clientId) + if (this.hooks.onAccountAddAuthorizedClient) { + const shouldAdd = await this.hooks.onAccountAddAuthorizedClient(client, { + deviceId, + account, + clientAuth, + }) + if (!shouldAdd) return + } + + await this.store.addAuthorizedClient(deviceId, account.sub, client.id) } public async list(deviceId: DeviceId): Promise { diff --git a/packages/oauth-provider/src/account/account-store.ts b/packages/oauth-provider/src/account/account-store.ts index c53f8961a77..f293f9c5ff0 100644 --- a/packages/oauth-provider/src/account/account-store.ts +++ b/packages/oauth-provider/src/account/account-store.ts @@ -24,7 +24,7 @@ export type DeviceAccountInfo = { } // Export all types needed to implement the AccountStore interface -export type { Awaitable, Account, DeviceId, Sub } +export type { Account, DeviceId, Sub } export type AccountInfo = { account: Account diff --git a/packages/oauth-provider/src/client/client-hooks.ts b/packages/oauth-provider/src/client/client-hooks.ts new file mode 100644 index 00000000000..fe5d51ea23e --- /dev/null +++ b/packages/oauth-provider/src/client/client-hooks.ts @@ -0,0 +1,22 @@ +import { Jwks } from '@atproto/jwk' +import { + OAuthClientId, + OAuthClientMetadata, +} from '@atproto/oauth-client-metadata' +import { Awaitable } from '../util/awaitable.js' + +/** + * Use this to alter, override or validate the client metadata & jwks returned + * by the client store. + * + * @throws {InvalidClientMetadataError} if the metadata is invalid + * @see {@link InvalidClientMetadataError} + */ +export type ClientDataHook = ( + clientId: OAuthClientId, + data: { metadata: OAuthClientMetadata; jwks?: Jwks }, +) => Awaitable + +export type ClientHooks = { + onClientData?: ClientDataHook +} diff --git a/packages/oauth-provider/src/client/client-manager.ts b/packages/oauth-provider/src/client/client-manager.ts index c631a0af9ad..e6210aedc7d 100644 --- a/packages/oauth-provider/src/client/client-manager.ts +++ b/packages/oauth-provider/src/client/client-manager.ts @@ -1,35 +1,20 @@ -import { Jwks, Keyset } from '@atproto/jwk' -import { - OAuthClientId, - OAuthClientMetadata, -} from '@atproto/oauth-client-metadata' +import { Keyset } from '@atproto/jwk' +import { OAuthClientId } from '@atproto/oauth-client-metadata' import { InvalidClientMetadataError } from '../errors/invalid-client-metadata-error.js' import { InvalidRedirectUriError } from '../errors/invalid-redirect-uri-error.js' import { OAuthError } from '../errors/oauth-error.js' -import { Awaitable } from '../util/awaitable.js' import { ClientData } from './client-data.js' +import { ClientHooks } from './client-hooks.js' import { ClientStore } from './client-store.js' import { parseRedirectUri } from './client-utils.js' import { Client } from './client.js' -/** - * Use this to alter, override or validate the client metadata & jwks returned - * by the client store. - * - * @throws {InvalidClientMetadataError} if the metadata is invalid - * @see {@link InvalidClientMetadataError} - */ -export type ClientDataHook = ( - clientId: OAuthClientId, - data: { metadata: OAuthClientMetadata; jwks?: Jwks }, -) => Awaitable - export class ClientManager { constructor( protected readonly store: ClientStore, protected readonly keyset: Keyset, - protected readonly hooks: { onClientData?: ClientDataHook }, + protected readonly hooks: ClientHooks, ) {} /** diff --git a/packages/oauth-provider/src/oauth-hooks.ts b/packages/oauth-provider/src/oauth-hooks.ts index 31bb8eef23f..cad256efec4 100644 --- a/packages/oauth-provider/src/oauth-hooks.ts +++ b/packages/oauth-provider/src/oauth-hooks.ts @@ -2,8 +2,14 @@ * This file exposes all the hooks that can be used when instantiating the * OAuthProvider. */ +import type { AccountHooks } from './account/account-hooks.js' +import type { ClientHooks } from './client/client-hooks.js' +import type { RequestHooks } from './request/request-hooks.js' +import type { TokenHooks } from './token/token-hooks.js' -export { type AuthorizationDetailsHook } from './token/token-manager.js' -export { type ClientDataHook } from './client/client-manager.js' -export { type TokenResponseHook } from './token/token-manager.js' -export { type AuthorizationRequestHook } from './request/request-manager.js' +export type * from './account/account-hooks.js' +export type * from './client/client-hooks.js' +export type * from './request/request-hooks.js' +export type * from './token/token-hooks.js' + +export type OAuthHooks = AccountHooks & ClientHooks & RequestHooks & TokenHooks diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 92e59b9f6ba..1a0a8c56124 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -41,7 +41,7 @@ import { CLIENT_ASSERTION_TYPE_JWT_BEARER, ClientIdentification, } from './client/client-credentials.js' -import { ClientDataHook, ClientManager } from './client/client-manager.js' +import { ClientManager } from './client/client-manager.js' import { ClientStore, asClientStore } from './client/client-store.js' import { AuthEndpoint, Client } from './client/client.js' import { AUTH_MAX_AGE, TOKEN_MAX_AGE } from './constants.js' @@ -57,6 +57,7 @@ import { LoginRequiredError } from './errors/login-required-error.js' import { UnauthorizedClientError } from './errors/unauthorized-client-error.js' import { WWWAuthenticateError } from './errors/www-authenticate-error.js' import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js' +import { OAuthHooks } from './oauth-hooks.js' import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js' import { Userinfo } from './oidc/userinfo.js' import { @@ -79,10 +80,7 @@ import { } from './parameters/authorization-parameters.js' import { oidcPayload } from './parameters/oidc-payload.js' import { ReplayStore, asReplayStore } from './replay/replay-store.js' -import { - AuthorizationRequestHook, - RequestManager, -} from './request/request-manager.js' +import { RequestManager } from './request/request-manager.js' import { RequestStoreMemory } from './request/request-store-memory.js' import { RequestStore, isRequestStore } from './request/request-store.js' import { RequestUri, requestUriSchema } from './request/request-uri.js' @@ -96,12 +94,8 @@ import { import { SessionManager } from './session/session-manager.js' import { SessionStore, asSessionStore } from './session/session-store.js' import { isTokenId } from './token/token-id.js' -import { - AuthorizationDetailsHook, - TokenManager, - TokenResponse, - TokenResponseHook, -} from './token/token-manager.js' +import { TokenManager } from './token/token-manager.js' +import { TokenResponse } from './token/token-response.js' import { TokenInfo, TokenStore, asTokenStore } from './token/token-store.js' import { TokenType } from './token/token-type.js' import { @@ -117,6 +111,7 @@ import { } from './token/types.js' import { VerifyTokenClaimsOptions } from './token/verify-token-claims.js' import { dateToEpoch, dateToRelativeSeconds } from './util/date.js' +import { Override } from './util/type.js' export type OAuthProviderStore = Partial< ClientStore & @@ -134,46 +129,44 @@ export { type Handler, type OAuthServerMetadata, } -export type OAuthProviderOptions = OAuthVerifierOptions & { - /** - * Maximum age a device/account session can be before requiring - * re-authentication. This can be overridden on a authorization request basis - * using the `max_age` parameter and on a client basis using the - * `default_max_age` client metadata. - */ - defaultMaxAge?: number - - /** - * Maximum age access & id tokens can be before requiring a refresh. - */ - tokenMaxAge?: number +export type OAuthProviderOptions = Override< + OAuthVerifierOptions & OAuthHooks, + { + /** + * Maximum age a device/account session can be before requiring + * re-authentication. This can be overridden on a authorization request basis + * using the `max_age` parameter and on a client basis using the + * `default_max_age` client metadata. + */ + defaultMaxAge?: number - /** - * Additional metadata to be included in the discovery document. - */ - metadata?: CustomMetadata + /** + * Maximum age access & id tokens can be before requiring a refresh. + */ + tokenMaxAge?: number - onAuthorizationRequest?: AuthorizationRequestHook - onAuthorizationDetails?: AuthorizationDetailsHook - onClientData?: ClientDataHook - onTokenResponse?: TokenResponseHook + /** + * Additional metadata to be included in the discovery document. + */ + metadata?: CustomMetadata - accountStore?: AccountStore - clientStore?: ClientStore - replayStore?: ReplayStore - requestStore?: RequestStore - sessionStore?: SessionStore - tokenStore?: TokenStore + accountStore?: AccountStore + clientStore?: ClientStore + replayStore?: ReplayStore + requestStore?: RequestStore + sessionStore?: SessionStore + tokenStore?: TokenStore - /** - * This will be used as the default store for all the stores. If a store is - * not provided, this store will be used instead. If the `store` does not - * implement a specific store, a runtime error will be thrown. Make sure that - * this store implements all the interfaces not provided in the other - * `Store` options. - */ - store?: OAuthProviderStore -} + /** + * This will be used as the default store for all the stores. If a store is + * not provided, this store will be used instead. If the `store` does not + * implement a specific store, a runtime error will be thrown. Make sure that + * this store implements all the interfaces not provided in the other + * `Store` options. + */ + store?: OAuthProviderStore + } +> export class OAuthProvider extends OAuthVerifier { public readonly metadata: OAuthServerMetadata @@ -194,11 +187,6 @@ export class OAuthProvider extends OAuthVerifier { store, metadata, - onAuthorizationRequest, - onAuthorizationDetails, - onClientData, - onTokenResponse, - accountStore = asAccountStore(store), clientStore = asClientStore(store), replayStore = asReplayStore(store), @@ -208,34 +196,27 @@ export class OAuthProvider extends OAuthVerifier { sessionStore = asSessionStore(store), tokenStore = asTokenStore(store), - ...superOptions + ...rest }: OAuthProviderOptions) { - super({ replayStore, ...superOptions }) - - const hooks = { - onAuthorizationRequest, - onAuthorizationDetails, - onClientData, - onTokenResponse, - } + super({ replayStore, ...rest }) this.defaultMaxAge = defaultMaxAge this.metadata = buildMetadata(this.issuer, this.keyset, metadata) this.sessionStore = sessionStore - this.accountManager = new AccountManager(accountStore) - this.clientManager = new ClientManager(clientStore, this.keyset, hooks) + this.accountManager = new AccountManager(accountStore, rest) + this.clientManager = new ClientManager(clientStore, this.keyset, rest) this.requestManager = new RequestManager( requestStore, this.signer, this.metadata, - hooks, + rest, ) this.tokenManager = new TokenManager( tokenStore, this.signer, - hooks, + rest, this.accessTokenType, tokenMaxAge, ) @@ -454,7 +435,7 @@ export class OAuthProvider extends OAuthVerifier { ) if (parameters.prompt === 'none') { - const ssoSessions = sessions.filter((s) => s.ssoAllowed) + const ssoSessions = sessions.filter((s) => s.matchesHint) if (ssoSessions.length > 1) { throw new AccountSelectionRequiredError(parameters) } @@ -481,6 +462,25 @@ export class OAuthProvider extends OAuthVerifier { return { issuer, client, parameters, redirect } } + // Automatic SSO when a did was provided + if (parameters.prompt == null && parameters.login_hint != null) { + const ssoSessions = sessions.filter((s) => s.matchesHint) + if (ssoSessions.length === 1) { + const ssoSession = ssoSessions[0]! + if (!ssoSession.loginRequired && !ssoSession.consentRequired) { + const redirect = await this.requestManager.setAuthorized( + client, + uri, + deviceId, + ssoSession.account, + ssoSession.info, + ) + + return { issuer, client, parameters, redirect } + } + } + } + return { issuer, client, parameters, authorize: { uri, sessions } } } catch (err) { await this.deleteRequest(uri, parameters) @@ -513,10 +513,11 @@ export class OAuthProvider extends OAuthVerifier { account: Account info: DeviceAccountInfo - ssoAllowed: boolean selected: boolean loginRequired: boolean consentRequired: boolean + + matchesHint: boolean }[] > { const accounts = await this.accountManager.list(deviceId) @@ -536,10 +537,8 @@ export class OAuthProvider extends OAuthVerifier { parameters.prompt === 'consent' || !info.authorizedClients.includes(client.id), - ssoAllowed: - parameters.prompt === 'none' && - (parameters.login_hint === account.sub || - parameters.login_hint == null), + matchesHint: + parameters.login_hint === account.sub || parameters.login_hint == null, })) } @@ -560,7 +559,7 @@ export class OAuthProvider extends OAuthVerifier { const client = await this.clientManager.getClient(clientId) try { - const { parameters } = await this.requestManager.get( + const { parameters, clientAuth } = await this.requestManager.get( uri, clientId, deviceId, @@ -587,8 +586,9 @@ export class OAuthProvider extends OAuthVerifier { await this.accountManager.addAuthorizedClient( deviceId, - account.sub, - client.id, + account, + client, + clientAuth, ) return { issuer, client, parameters, redirect } diff --git a/packages/oauth-provider/src/request/request-hooks.ts b/packages/oauth-provider/src/request/request-hooks.ts new file mode 100644 index 00000000000..7e8d7babdbc --- /dev/null +++ b/packages/oauth-provider/src/request/request-hooks.ts @@ -0,0 +1,23 @@ +import { ClientAuth } from '../client/client-auth.js' +import { Client } from '../client/client.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { Awaitable } from '../util/awaitable.js' + +/** + * Allows validating and modifying the authorization parameters before the + * authorization request is processed. + * + * @throws {InvalidAuthorizationDetailsError} + */ +export type AuthorizationRequestHook = ( + this: null, + parameters: AuthorizationParameters, + data: { + client: Client + clientAuth: ClientAuth + }, +) => Awaitable + +export type RequestHooks = { + onAuthorizationRequest?: AuthorizationRequestHook +} diff --git a/packages/oauth-provider/src/request/request-info.ts b/packages/oauth-provider/src/request/request-info.ts new file mode 100644 index 00000000000..a61ed68b005 --- /dev/null +++ b/packages/oauth-provider/src/request/request-info.ts @@ -0,0 +1,12 @@ +import { ClientAuth } from '../client/client-auth.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { RequestId } from './request-id.js' +import { RequestUri } from './request-uri.js' + +export type RequestInfo = { + id: RequestId + uri: RequestUri + parameters: AuthorizationParameters + expiresAt: Date + clientAuth: ClientAuth +} diff --git a/packages/oauth-provider/src/request/request-manager.ts b/packages/oauth-provider/src/request/request-manager.ts index 551c9c630fa..653b412e107 100644 --- a/packages/oauth-provider/src/request/request-manager.ts +++ b/packages/oauth-provider/src/request/request-manager.ts @@ -19,10 +19,11 @@ import { OIDC_SCOPE_CLAIMS } from '../oidc/claims.js' import { Sub } from '../oidc/sub.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { Signer } from '../signer/signer.js' -import { Awaitable } from '../util/awaitable.js' import { matchRedirectUri } from '../util/redirect-uri.js' import { Code, generateCode } from './code.js' -import { RequestId, generateRequestId } from './request-id.js' +import { RequestHooks } from './request-hooks.js' +import { generateRequestId } from './request-id.js' +import { RequestInfo } from './request-info.js' import { RequestStore, UpdateRequestData } from './request-store.js' import { RequestUri, @@ -30,37 +31,12 @@ import { encodeRequestUri, } from './request-uri.js' -export type RequestInfo = { - id: RequestId - uri: RequestUri - parameters: AuthorizationParameters - expiresAt: Date - clientAuth: ClientAuth -} - -/** - * Allows validating and modifying the authorization parameters before the - * authorization request is processed. - * - * @throws {InvalidAuthorizationDetailsError} - */ -export type AuthorizationRequestHook = ( - this: null, - parameters: AuthorizationParameters, - data: { - client: Client - clientAuth: ClientAuth - }, -) => Awaitable - export class RequestManager { constructor( protected readonly store: RequestStore, protected readonly signer: Signer, protected readonly metadata: OAuthServerMetadata, - protected readonly hooks: { - onAuthorizationRequest?: AuthorizationRequestHook - }, + protected readonly hooks: RequestHooks, protected readonly pkceRequired = true, protected readonly tokenMaxAge = TOKEN_MAX_AGE, ) {} diff --git a/packages/oauth-provider/src/token/token-hooks.ts b/packages/oauth-provider/src/token/token-hooks.ts new file mode 100644 index 00000000000..e06ccb4dc6d --- /dev/null +++ b/packages/oauth-provider/src/token/token-hooks.ts @@ -0,0 +1,39 @@ +import { Account } from '../account/account.js' +import { Client } from '../client/client.js' +import { AuthorizationDetails } from '../parameters/authorization-details.js' +import { AuthorizationParameters } from '../parameters/authorization-parameters.js' +import { Awaitable } from '../util/awaitable.js' +import { TokenResponse } from './token-response.js' + +export type { TokenResponse } + +export type TokenHookData = { + client: Client + parameters: AuthorizationParameters + account: Account +} + +/** + * Allows enriching the authorization details with additional information + * before the tokens are issued. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc9396 | RFC 9396} + */ +export type AuthorizationDetailsHook = ( + this: null, + data: TokenHookData, +) => Awaitable + +/** + * Allows altering the token response before it is sent to the client. + */ +export type TokenResponseHook = ( + this: null, + tokenResponse: TokenResponse, + data: TokenHookData, +) => Awaitable + +export type TokenHooks = { + onAuthorizationDetails?: AuthorizationDetailsHook + onTokenResponse?: TokenResponseHook +} diff --git a/packages/oauth-provider/src/token/token-manager.ts b/packages/oauth-provider/src/token/token-manager.ts index 1d82417b5c4..f660ddef254 100644 --- a/packages/oauth-provider/src/token/token-manager.ts +++ b/packages/oauth-provider/src/token/token-manager.ts @@ -20,20 +20,15 @@ import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' import { InvalidGrantError } from '../errors/invalid-grant-error.js' import { InvalidRequestError } from '../errors/invalid-request-error.js' import { InvalidTokenError } from '../errors/invalid-token-error.js' -import { AuthorizationDetails } from '../parameters/authorization-details.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { isCode } from '../request/code.js' import { Signer } from '../signer/signer.js' -import { Awaitable } from '../util/awaitable.js' import { dateToEpoch, dateToRelativeSeconds } from '../util/date.js' import { matchRedirectUri } from '../util/redirect-uri.js' -import { - RefreshToken, - generateRefreshToken, - isRefreshToken, -} from './refresh-token.js' +import { generateRefreshToken, isRefreshToken } from './refresh-token.js' import { TokenClaims } from './token-claims.js' import { TokenData } from './token-data.js' +import { TokenHooks } from './token-hooks.js' import { TokenId, generateTokenId, @@ -48,61 +43,17 @@ import { VerifyTokenClaimsResult, verifyTokenClaims, } from './verify-token-claims.js' - -/** - * @see {@link https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 | RFC 6749 (OAuth2), Section 5.1} - */ -export type TokenResponse = { - id_token?: string - access_token?: AccessToken - token_type?: TokenType - expires_in?: number - refresh_token?: RefreshToken - scope: string - authorization_details?: AuthorizationDetails - - // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 - // > The client MUST ignore unrecognized value names in the response. - [k: string]: unknown -} +import { TokenResponse } from './token-response.js' export type AuthenticateTokenIdResult = VerifyTokenClaimsResult & { tokenInfo: TokenInfo } -/** - * Allows enriching the authorization details with additional information - * before the tokens are issued. - * - * @see {@link https://datatracker.ietf.org/doc/html/rfc9396 | RFC 9396} - */ -export type AuthorizationDetailsHook = ( - this: null, - data: { - client: Client - parameters: AuthorizationParameters - account: Account - }, -) => Awaitable - -export type TokenResponseHook = ( - this: null, - tokenResponse: TokenResponse, - data: { - client: Client - parameters: AuthorizationParameters - account: Account - }, -) => Awaitable - export class TokenManager { constructor( protected readonly store: TokenStore, protected readonly signer: Signer, - protected readonly hooks: { - onAuthorizationDetails?: AuthorizationDetailsHook - onTokenResponse?: TokenResponseHook - }, + protected readonly hooks: TokenHooks, protected readonly accessTokenType: AccessTokenType, protected readonly tokenMaxAge = TOKEN_MAX_AGE, ) {} diff --git a/packages/oauth-provider/src/token/token-response.ts b/packages/oauth-provider/src/token/token-response.ts new file mode 100644 index 00000000000..0c62dfdcf73 --- /dev/null +++ b/packages/oauth-provider/src/token/token-response.ts @@ -0,0 +1,21 @@ +import { AccessToken } from '../access-token/access-token.js' +import { AuthorizationDetails } from '../parameters/authorization-details.js' +import { RefreshToken } from './refresh-token.js' +import { TokenType } from './token-type.js' + +/** + * @see {@link https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 | RFC 6749 (OAuth2), Section 5.1} + */ +export type TokenResponse = { + id_token?: string + access_token?: AccessToken + token_type?: TokenType + expires_in?: number + refresh_token?: RefreshToken + scope: string + authorization_details?: AuthorizationDetails + + // https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1 + // > The client MUST ignore unrecognized value names in the response. + [k: string]: unknown +} From d44abef58e210dbfa36133a04ff410e3774a4ddc Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 15:14:33 +0200 Subject: [PATCH 107/140] fix(oauth-provider): add missing scopes_supported --- packages/oauth-provider/src/metadata/build-metadata.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/oauth-provider/src/metadata/build-metadata.ts b/packages/oauth-provider/src/metadata/build-metadata.ts index e82884abb64..11b8af38829 100644 --- a/packages/oauth-provider/src/metadata/build-metadata.ts +++ b/packages/oauth-provider/src/metadata/build-metadata.ts @@ -24,6 +24,7 @@ export function buildMetadata( issuer: issuer, scopes_supported: [ + 'offline_access', 'openid', 'email', 'phone', From 549978e6f21917c621ca2e590367b5446a34c964 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 15:24:30 +0200 Subject: [PATCH 108/140] fix(oauth-provider): avoid throwing "resource owner interaction" related errors from PAR endpoint --- packages/oauth-provider/src/oauth-provider.ts | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 1a0a8c56124..7fd12f39eee 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -329,30 +329,42 @@ export class OAuthProvider extends OAuthVerifier { input: PushedAuthorizationRequest, dpopJkt: null | string, ) { - const client = await this.clientManager.getClient(input.client_id) - const clientAuth = await this.authenticateClient( - client, - 'pushed_authorization_request', - input, - ) - - // TODO (?) should we allow using signed JAR for client authentication? - const { payload: parameters } = - 'request' in input // Handle JAR - ? await this.decodeJAR(client, input) - : { payload: input } - - const { uri, expiresAt } = - await this.requestManager.pushedAuthorizationRequest( + try { + const client = await this.clientManager.getClient(input.client_id) + const clientAuth = await this.authenticateClient( client, - clientAuth, - parameters, - dpopJkt, + 'pushed_authorization_request', + input, ) - return { - request_uri: uri, - expires_in: dateToRelativeSeconds(expiresAt), + // TODO (?) should we allow using signed JAR for client authentication? + const { payload: parameters } = + 'request' in input // Handle JAR + ? await this.decodeJAR(client, input) + : { payload: input } + + const { uri, expiresAt } = + await this.requestManager.pushedAuthorizationRequest( + client, + clientAuth, + parameters, + dpopJkt, + ) + + return { + request_uri: uri, + expires_in: dateToRelativeSeconds(expiresAt), + } + } catch (err) { + // https://datatracker.ietf.org/doc/html/rfc9126#section-2.3-1 + // > Since initial processing of the pushed authorization request does not + // > involve resource owner interaction, error codes related to user + // > interaction, such as consent_required defined by [OIDC], are never + // > returned. + if (err instanceof AccessDeniedError) { + throw new InvalidRequestError(err.error_description, err) + } + throw err } } From dfcfb9b352f0a50a51957eb21d88ac6564c65b96 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 15:50:30 +0200 Subject: [PATCH 109/140] docs(oauth-provider): better document AuthorizationRequestHook --- .../oauth-provider/src/request/request-hooks.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/oauth-provider/src/request/request-hooks.ts b/packages/oauth-provider/src/request/request-hooks.ts index 7e8d7babdbc..d6ec84c5503 100644 --- a/packages/oauth-provider/src/request/request-hooks.ts +++ b/packages/oauth-provider/src/request/request-hooks.ts @@ -3,11 +3,25 @@ import { Client } from '../client/client.js' import { AuthorizationParameters } from '../parameters/authorization-parameters.js' import { Awaitable } from '../util/awaitable.js' +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { InvalidAuthorizationDetailsError } from '../errors/invalid-authorization-details-error.js' +import type { AccessDeniedError } from '../errors/access-denied-error.js' +import type { AccountSelectionRequiredError } from '../errors/account-selection-required-error.js' +import type { ConsentRequiredError } from '../errors/consent-required-error.js' +import type { InvalidParametersError } from '../errors/invalid-parameters-error.js' +import type { LoginRequiredError } from '../errors/login-required-error.js' +/* eslint-enable @typescript-eslint/no-unused-vars */ + /** * Allows validating and modifying the authorization parameters before the * authorization request is processed. * - * @throws {InvalidAuthorizationDetailsError} + * @see {@link InvalidAuthorizationDetailsError} + * @see {@link AccessDeniedError} + * @see {@link AccountSelectionRequiredError} + * @see {@link ConsentRequiredError} + * @see {@link InvalidParametersError} + * @see {@link LoginRequiredError} */ export type AuthorizationRequestHook = ( this: null, From d3ff07df8e8eae92ed618bbea356bd74b18a3f25 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 15:50:45 +0200 Subject: [PATCH 110/140] feat(oauth-provider): export Client class --- packages/oauth-provider/src/oauth-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/oauth-provider/src/oauth-client.ts b/packages/oauth-provider/src/oauth-client.ts index ad9ffbc6264..213e4efea03 100644 --- a/packages/oauth-provider/src/oauth-client.ts +++ b/packages/oauth-provider/src/oauth-client.ts @@ -1,2 +1,3 @@ export * from '@atproto/oauth-client-metadata' +export type * from './client/client.js' export * from './client/client-utils.js' From 572a41ef26a965ad466b0a54045feee3f744ca56 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:00:08 +0200 Subject: [PATCH 111/140] fix(pds): prevent loopback clients from obtaining refresh tokens --- packages/pds/src/oauth/oauth-client-store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pds/src/oauth/oauth-client-store.ts b/packages/pds/src/oauth/oauth-client-store.ts index 118b53e50ff..af43635f539 100644 --- a/packages/pds/src/oauth/oauth-client-store.ts +++ b/packages/pds/src/oauth/oauth-client-store.ts @@ -34,8 +34,8 @@ function loopbackMetadata({ href }: URL): Partial { client_name: 'Loopback ATPROTO client', client_uri: href, response_types: ['code', 'code id_token'], - grant_types: ['authorization_code', 'refresh_token'], - scope: 'openid profile offline_access', + grant_types: ['authorization_code'], + scope: 'openid profile', redirect_uris: ['127.0.0.1', '[::1]'].map( (ip) => Object.assign(new URL(href), { hostname: ip }).href, ) as [string, string], From 0ac74eb9c2eb5460ed77729ec64e46f4f326b5bf Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:02:36 +0200 Subject: [PATCH 112/140] fix(pds): prevent un-authenticated clients from obtaining refresh tokens fix(pds): avoid transforming "prompt=none" into anything else --- packages/pds/src/auth-provider.ts | 42 +++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/pds/src/auth-provider.ts b/packages/pds/src/auth-provider.ts index 14b35a2c705..d50aa44fe24 100644 --- a/packages/pds/src/auth-provider.ts +++ b/packages/pds/src/auth-provider.ts @@ -3,9 +3,13 @@ import { AccessTokenType, AccountInfo, AccountStore, + Client, + ClientAuth, + ConsentRequiredError, Customization, DeviceId, DpopManagerOptions, + InvalidParametersError, Keyset, LoginCredentials, OAuthProvider, @@ -48,6 +52,13 @@ export class AuthProvider extends OAuthProvider { customization, disableSsrf = false, }: AuthProviderOptions) { + // TODO: make allow listed client ids configurable + const isAllowListedClient = (clientId) => clientId === 'https://bsky.app/' + + const isTrustableClient = (client: Client, clientAuth: ClientAuth) => { + return clientAuth.method !== 'none' || isAllowListedClient(client.id) + } + super({ issuer, keyset, @@ -94,17 +105,28 @@ export class AuthProvider extends OAuthProvider { accessTokenType: AccessTokenType.id, onAuthorizationRequest: (parameters, { client, clientAuth }) => { - // ATPROTO extension: if the client is not "trustable", force the - // user to consent to the request. We do this to avoid - // unauthenticated clients from being able to silently - // re-authenticate users. - - // TODO: make allow listed client ids configurable - if (clientAuth.method === 'none' && client.id !== 'https://bsky.app/') { - // Prevent sso and require consent by default - if (!parameters.prompt || parameters.prompt === 'none') { - parameters.prompt = 'consent' + // ATPROTO extension: if the client is not trustable, force users to + // consent to authorization requests. We do this to avoid + // unauthenticated clients from being able to silently re-authenticate + // users. + + if (!isTrustableClient(client, clientAuth)) { + if (parameters.prompt === 'none') { + throw new ConsentRequiredError( + parameters, + 'Public clients are not allowed to use silent-sign-on', + ) } + + if (parameters.scope?.includes('offline_access')) { + throw new InvalidParametersError( + parameters, + 'Public clients are not allowed to request offline access', + ) + } + + // force "consent" for unauthenticated, untrusted clients + parameters.prompt = 'consent' } }, From 60295c84e700989f435058b744ed2147f098f19b Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:04:12 +0200 Subject: [PATCH 113/140] fix(oauth-client): use "invalid_grant" oauth error to detect that refresh token was denied --- packages/oauth-client/src/index.ts | 3 +- .../oauth-client/src/oauth-client-factory.ts | 8 +-- packages/oauth-client/src/oauth-client.ts | 20 ++++++- .../oauth-client/src/oauth-response-error.ts | 31 ++++++++++ packages/oauth-client/src/oauth-server.ts | 59 ++++++++++--------- packages/oauth-client/src/session-getter.ts | 22 ++----- 6 files changed, 92 insertions(+), 51 deletions(-) create mode 100644 packages/oauth-client/src/oauth-response-error.ts diff --git a/packages/oauth-client/src/index.ts b/packages/oauth-client/src/index.ts index 81ec5157983..2e955f7e2e4 100644 --- a/packages/oauth-client/src/index.ts +++ b/packages/oauth-client/src/index.ts @@ -1,7 +1,8 @@ export * from './crypto-implementation.js' +export * from './oauth-callback-error.js' export * from './oauth-client-factory.js' export * from './oauth-client.js' -export * from './oauth-callback-error.js' +export * from './oauth-response-error.js' export * from './oauth-server-factory.js' export * from './oauth-server.js' export * from './oauth-types.js' diff --git a/packages/oauth-client/src/oauth-client-factory.ts b/packages/oauth-client/src/oauth-client-factory.ts index 1be9f6867b6..68c4231be98 100644 --- a/packages/oauth-client/src/oauth-client-factory.ts +++ b/packages/oauth-client/src/oauth-client-factory.ts @@ -1,6 +1,7 @@ import { GenericStore } from '@atproto/caching' import { Key } from '@atproto/jwk' import { FALLBACK_ALG } from './constants.js' +import { OAuthCallbackError } from './oauth-callback-error.js' import { OAuthClient } from './oauth-client.js' import { OAuthServerFactory, @@ -13,7 +14,6 @@ import { OAuthResponseType, } from './oauth-types.js' import { Session, SessionGetter } from './session-getter.js' -import { OAuthCallbackError } from './oauth-callback-error.js' export type InternalStateData = { iss: string @@ -113,7 +113,7 @@ export class OAuthClientFactory { if (metadata.pushed_authorization_request_endpoint) { const server = await this.serverFactory.fromMetadata(metadata, dpopKey) - const { json } = await server.request( + const parResponse = await server.request( 'pushed_authorization_request', parameters, ) @@ -123,7 +123,7 @@ export class OAuthClientFactory { 'client_id', this.clientMetadata.client_id, ) - authorizationUrl.searchParams.set('request_uri', json.request_uri) + authorizationUrl.searchParams.set('request_uri', parResponse.request_uri) return authorizationUrl } else if (metadata.require_pushed_authorization_requests) { throw new Error( @@ -154,8 +154,6 @@ export class OAuthClientFactory { client: OAuthClient state?: string }> { - // TODO: better errors - const responseJwt = params.get('response') if (responseJwt != null) { // https://openid.net/specs/oauth-v2-jarm.html diff --git a/packages/oauth-client/src/oauth-client.ts b/packages/oauth-client/src/oauth-client.ts index 0d6400977dc..0346527f3d9 100644 --- a/packages/oauth-client/src/oauth-client.ts +++ b/packages/oauth-client/src/oauth-client.ts @@ -1,13 +1,29 @@ +import { fetchFailureHandler } from '@atproto/fetch' +import { dpopFetchWrapper } from '@atproto/fetch-dpop' import { JwtPayload, unsafeDecodeJwt } from '@atproto/jwk' import { OAuthServer, TokenSet } from './oauth-server.js' import { SessionGetter } from './session-getter.js' export class OAuthClient { + readonly dpopFetch: (request: Request) => Promise + constructor( private readonly server: OAuthServer, public readonly sessionId: string, private readonly sessionGetter: SessionGetter, - ) {} + ) { + const dpopFetch = dpopFetchWrapper({ + fetch, + iss: server.clientMetadata.client_id, + key: server.dpopKey, + alg: server.dpopAlg, + sha256: async (v) => server.crypto.sha256(v), + nonceCache: server.dpopNonceCache, + isAuthServer: false, + }) + + this.dpopFetch = (request) => dpopFetch(request).catch(fetchFailureHandler) + } /** * @param refresh See {@link SessionGetter.getSession} @@ -70,7 +86,7 @@ export class OAuthClient { headers, }) - return this.server.dpopFetch(request).catch((err) => { + return this.dpopFetch(request).catch((err) => { if (!refreshCredentials && isTokenExpiredError(err)) { return this.request(pathname, init, true) } diff --git a/packages/oauth-client/src/oauth-response-error.ts b/packages/oauth-client/src/oauth-response-error.ts new file mode 100644 index 00000000000..adf33b779d5 --- /dev/null +++ b/packages/oauth-client/src/oauth-response-error.ts @@ -0,0 +1,31 @@ +import { Json, ifString, ifObject } from '@atproto/fetch' + +export class OAuthResponseError extends Error { + readonly error?: string + readonly errorDescription?: string + + constructor( + public readonly response: Response, + public readonly payload: Json, + ) { + const error = ifString(ifObject(payload)?.['error']) + const errorDescription = ifString(ifObject(payload)?.['error_description']) + + const messageError = error ? `"${error}"` : 'unknown' + const messageDesc = errorDescription ? `: ${errorDescription}` : '' + const message = `OAuth ${messageError} error${messageDesc}` + + super(message) + + this.error = error + this.errorDescription = errorDescription + } + + get status() { + return this.response.status + } + + get headers() { + return this.response.headers + } +} diff --git a/packages/oauth-client/src/oauth-server.ts b/packages/oauth-client/src/oauth-server.ts index e0f4eb21d37..6e249bfc8d9 100644 --- a/packages/oauth-client/src/oauth-server.ts +++ b/packages/oauth-client/src/oauth-server.ts @@ -1,9 +1,9 @@ import { GenericStore } from '@atproto/caching' import { Fetch, + Json, fetchFailureHandler, fetchJsonProcessor, - fetchOkProcessor, } from '@atproto/fetch' import { dpopFetchWrapper } from '@atproto/fetch-dpop' import { Jwt, Key, Keyset } from '@atproto/jwk' @@ -12,6 +12,7 @@ import { OAuthServerMetadata } from '@atproto/oauth-server-metadata' import { FALLBACK_ALG } from './constants.js' import { CryptoWrapper } from './crypto-wrapper.js' import { OAuthResolver } from './oauth-resolver.js' +import { OAuthResponseError } from './oauth-response-error.js' import { OAuthEndpointName, OAuthTokenResponse, @@ -33,7 +34,7 @@ export type TokenSet = { } export class OAuthServer { - readonly dpopFetch: (request: Request) => Promise + private readonly dpopFetch: (request: Request) => Promise constructor( readonly dpopKey: Key, @@ -49,17 +50,22 @@ export class OAuthServer { fetch, iss: this.clientMetadata.client_id, key: dpopKey, - alg: negotiateAlg( - dpopKey, - serverMetadata.dpop_signing_alg_values_supported, - ), + alg: this.dpopAlg, sha256: async (v) => crypto.sha256(v), nonceCache: dpopNonceCache, + isAuthServer: true, }) this.dpopFetch = (request) => dpopFetch(request).catch(fetchFailureHandler) } + get dpopAlg() { + return negotiateAlg( + this.dpopKey, + this.serverMetadata.dpop_signing_alg_values_supported, + ) + } + async revoke(token: string) { try { await this.request('revocation', { token }) @@ -69,7 +75,7 @@ export class OAuthServer { } async exchangeCode(code: string, verifier?: string): Promise { - const { json: tokenResponse } = await this.request('token', { + const tokenResponse = await this.request('token', { grant_type: 'authorization_code', redirect_uri: this.clientMetadata.redirect_uris[0]!, code, @@ -113,7 +119,7 @@ export class OAuthServer { throw new Error('No refresh token available') } - const { json: tokenResponse } = await this.request('token', { + const tokenResponse = await this.request('token', { grant_type: 'refresh_token', refresh_token: tokenSet.refresh_token, }) @@ -170,34 +176,33 @@ export class OAuthServer { ) { const url = this.serverMetadata[`${endpoint}_endpoint`] if (!url) throw new Error(`No ${endpoint} endpoint available`) - const auth = await this.buildClientAuth(endpoint) + const auth = await this.buildClientAuth(endpoint) const request = new Request(url, { method: 'POST', headers: { ...auth.headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, ...auth.payload }), }) - const response = await this.dpopFetch(request) - .then(fetchOkProcessor()) - .then( - fetchJsonProcessor< - E extends 'pushed_authorization_request' - ? { request_uri: string } - : E extends 'token' - ? OAuthTokenResponse - : unknown - >(), - ) - - // TODO: validate using zod ? - if (endpoint === 'token') { - if (!response.json['access_token']) { - throw new TypeError('No access token in token response') + const { response, json } = + await this.dpopFetch(request).then(fetchJsonProcessor()) + + if (response.ok) { + // TODO: parse using zod + if (endpoint === 'token') { + if (typeof json?.['access_token'] !== 'string') { + throw new TypeError('No access token in token response') + } } - } - return response + return json as E extends 'pushed_authorization_request' + ? { request_uri: string } + : E extends 'token' + ? OAuthTokenResponse + : Json + } else { + throw new OAuthResponseError(response, json) + } } async buildClientAuth(endpoint: OAuthEndpointName): Promise<{ diff --git a/packages/oauth-client/src/session-getter.ts b/packages/oauth-client/src/session-getter.ts index b4a3d2de7db..70c142d7707 100644 --- a/packages/oauth-client/src/session-getter.ts +++ b/packages/oauth-client/src/session-getter.ts @@ -1,6 +1,6 @@ import { CachedGetter, GenericStore } from '@atproto/caching' -import { FetchResponseError } from '@atproto/fetch' import { Key } from '@atproto/jwk' +import { OAuthResponseError } from './oauth-response-error.js' import { OAuthServerFactory } from './oauth-server-factory.js' import { TokenSet } from './oauth-server.js' @@ -122,19 +122,9 @@ export class SessionGetter extends CachedGetter { } async function isRefreshDeniedError(err: unknown) { - if (err instanceof FetchResponseError && err.statusCode === 400) { - if (err.response?.bodyUsed === false) { - try { - const json = await err.response.clone().json() - return ( - json.error === 'invalid_request' && - json.error_description === 'Invalid refresh token' - ) - } catch { - // falls through - } - } - } - - return false + return ( + err instanceof OAuthResponseError && + err.status === 400 && + err.error === 'invalid_grant' + ) } From 9853a7c52cc5993deb2b78b1e7ad3f6de0920fd4 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:28:39 +0200 Subject: [PATCH 114/140] fix(oauth-server-metadata): ensure response_types_supported includes "code" --- .../src/oauth-server-metadata.ts | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/oauth-server-metadata/src/oauth-server-metadata.ts b/packages/oauth-server-metadata/src/oauth-server-metadata.ts index 42be6c05d5d..19b9d17ee40 100644 --- a/packages/oauth-server-metadata/src/oauth-server-metadata.ts +++ b/packages/oauth-server-metadata/src/oauth-server-metadata.ts @@ -4,6 +4,7 @@ export const oauthServerIssuerSchema = z .string() .url() .superRefine((value, ctx) => { + // Validate the issuer (MIX-UP attacks) const url = new URL(value) if (url.protocol !== 'https:' && url.protocol !== 'http:') { @@ -100,23 +101,31 @@ export const oauthServerMetadataSchema = z.object({ export type OAuthServerMetadata = z.infer export const oauthServerMetadataValidator = oauthServerMetadataSchema - .refinement( - (data) => - !data.require_pushed_authorization_requests || - data.pushed_authorization_request_endpoint != null, - { - code: z.ZodIssueCode.custom, - message: - '"pushed_authorization_request_endpoint" required when "require_pushed_authorization_requests" is true', - }, - ) - .refinement( - (data) => { - // Validate the issuer (MIX-UP attacks) - return new URL(data.issuer).pathname === '/' - }, - { - code: z.ZodIssueCode.custom, - message: 'Invalid issuer', - }, - ) + .superRefine((data, ctx) => { + if ( + data.require_pushed_authorization_requests && + !data.pushed_authorization_request_endpoint + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + '"pushed_authorization_request_endpoint" required when "require_pushed_authorization_requests" is true', + }) + return false + } + + return true + }) + .superRefine((data, ctx) => { + if (data.response_types_supported) { + if (!data.response_types_supported.includes('code')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Response type "code" is required', + }) + return false + } + } + + return true + }) From ecd7be3790dc7af284612699e58f96958b6d89a6 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:48:37 +0200 Subject: [PATCH 115/140] feat(oauth-client-metadata): export response type and grant type schemas & types --- .../src/oauth-client-metadata.ts | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/packages/oauth-client-metadata/src/oauth-client-metadata.ts b/packages/oauth-client-metadata/src/oauth-client-metadata.ts index d8dfb452863..766c3f19159 100644 --- a/packages/oauth-client-metadata/src/oauth-client-metadata.ts +++ b/packages/oauth-client-metadata/src/oauth-client-metadata.ts @@ -13,45 +13,51 @@ export const endpointAuthMethod = z.enum([ 'tls_client_auth', ]) +export const oauthResponseTypeSchema = z.enum([ + // OAuth + 'code', + 'token', + + // OpenID + 'none', + 'code id_token token', + 'code id_token', + 'code token', + 'id_token token', + 'id_token', +]) + +export type OAuthResponseType = z.infer + +export const oauthGrantTypeSchema = z.enum([ + 'authorization_code', + 'implicit', + 'refresh_token', + 'password', // Not part of OAuth 2.1 + 'client_credentials', + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'urn:ietf:params:oauth:grant-type:saml2-bearer', +]) + +export type OAuthGrantType = z.infer + // https://openid.net/specs/openid-connect-registration-1_0.html // https://datatracker.ietf.org/doc/html/rfc7591 export const oauthClientMetadataSchema = z .object({ redirect_uris: z.array(z.string().url()).nonempty().readonly(), response_types: z - .array( - z.enum([ - // OAuth - 'code', - 'token', - - // OpenID - 'none', - 'code id_token token', - 'code id_token', - 'code token', - 'id_token token', - 'id_token', - ]), - ) + .array(oauthResponseTypeSchema) .nonempty() + // > If omitted, the default is that the client will use only the "code" + // > response type. .default(['code']) .readonly(), grant_types: z - .array( - z.enum([ - 'authorization_code', - 'implicit', - 'refresh_token', - 'password', - 'client_credentials', - 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'urn:ietf:params:oauth:grant-type:saml2-bearer', - ]), - ) + .array(oauthGrantTypeSchema) .nonempty() - // "If omitted, the default behavior is that the client will use only the - // "authorization_code" Grant Type." [RFC7591] + // > If omitted, the default behavior is that the client will use only the + // > "authorization_code" Grant Type. .default(['authorization_code']) .readonly(), scope: z.string().optional(), From 5b6935bd2aa1c6f3dd3145d91dcb64452ad26ffe Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:49:21 +0200 Subject: [PATCH 116/140] fix(oauth-client): use OAuthResponseType from oauth-client-metadata --- packages/oauth-client/src/oauth-types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/oauth-client/src/oauth-types.ts b/packages/oauth-client/src/oauth-types.ts index 66162536077..bd50671e056 100644 --- a/packages/oauth-client/src/oauth-types.ts +++ b/packages/oauth-client/src/oauth-types.ts @@ -1,8 +1,9 @@ import { Jwt } from '@atproto/jwk' import { OAuthClientMetadata } from '@atproto/oauth-client-metadata' +export type { OAuthResponseType } from '@atproto/oauth-client-metadata' + export type OAuthResponseMode = 'query' | 'fragment' | 'form_post' -export type OAuthResponseType = 'code' | 'code id_token' export type OAuthEndpointName = | 'token' From 8f8f8c65fa56c9c81f0bcc3fba16b2b7d52586a8 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:52:20 +0200 Subject: [PATCH 117/140] fix(oauth-client): negotiate response_type request param with server --- packages/oauth-client-browser/example/index.ts | 2 +- .../src/browser-oauth-client-factory.ts | 4 ---- packages/oauth-client/src/oauth-client-factory.ts | 11 ++++------- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/oauth-client-browser/example/index.ts b/packages/oauth-client-browser/example/index.ts index c5424f4d3c4..dd16e90d6e3 100644 --- a/packages/oauth-client-browser/example/index.ts +++ b/packages/oauth-client-browser/example/index.ts @@ -15,7 +15,7 @@ const oauthFactory = new BrowserOAuthClientFactory({ client_id: 'https://example.com', redirect_uris: ['https://example.com/cb'], grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code', 'code id_token'], + response_types: ['code id_token', 'code'], scope: 'openid profile email phone offline_access', dpop_bound_access_tokens: true, application_type: 'web', diff --git a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts index 94e16247334..880bcd2ee60 100644 --- a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts +++ b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts @@ -9,7 +9,6 @@ import { OAuthClientFactory, OAuthCallbackError, OAuthResponseMode, - OAuthResponseType, Session, } from '@atproto/oauth-client' import { @@ -27,7 +26,6 @@ import { LoginContinuedInParentWindowError } from './errors.js' export type BrowserOauthClientFactoryOptions = { responseMode?: OAuthResponseMode - responseType?: OAuthResponseType clientMetadata: OAuthClientMetadata plcDirectoryUrl?: UniversalIdentityResolverOptions['plcDirectoryUrl'] atprotoLexiconUrl?: UniversalIdentityResolverOptions['atprotoLexiconUrl'] @@ -61,7 +59,6 @@ export class BrowserOAuthClientFactory extends OAuthClientFactory { clientMetadata, // "fragment" is safer as it is not sent to the server responseMode = 'fragment', - responseType, plcDirectoryUrl, atprotoLexiconUrl, crypto = globalThis.crypto, @@ -72,7 +69,6 @@ export class BrowserOAuthClientFactory extends OAuthClientFactory { super({ clientMetadata, responseMode, - responseType, fetch, cryptoImplementation: new CryptoSubtle(crypto), sessionStore: database.getSessionStore(), diff --git a/packages/oauth-client/src/oauth-client-factory.ts b/packages/oauth-client/src/oauth-client-factory.ts index 68c4231be98..e2c50e351bb 100644 --- a/packages/oauth-client/src/oauth-client-factory.ts +++ b/packages/oauth-client/src/oauth-client-factory.ts @@ -36,7 +36,6 @@ export type OAuthClientOptions = OAuthServerFactoryOptions & { * "form_post" will typically be used for server-side applications. */ responseMode?: OAuthResponseMode - responseType?: OAuthResponseType } export class OAuthClientFactory { @@ -46,11 +45,9 @@ export class OAuthClientFactory { readonly sessionGetter: SessionGetter readonly responseMode?: OAuthResponseMode - readonly responseType?: OAuthResponseType constructor(options: OAuthClientOptions) { this.responseMode = options?.responseMode - this.responseType = options?.responseType this.serverFactory = new OAuthServerFactory(options) this.stateStore = options.stateStore this.sessionGetter = new SessionGetter( @@ -95,10 +92,10 @@ export class OAuthClientFactory { login_hint: did || undefined, response_mode: this.responseMode, response_type: - this.responseType != null && - metadata['response_types_supported']?.includes(this.responseType) - ? this.responseType - : 'code', + // Negotiate by using the order in the client metadata + (this.clientMetadata.response_types || ['code id_token'])?.find((t) => + metadata['response_types_supported']?.includes(t), + ) ?? 'code', display: options?.display, id_token_hint: options?.id_token_hint, From cf5f82df9a3d74f498808f7483900c51c52e5f1f Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:52:51 +0200 Subject: [PATCH 118/140] fix(oauth-client): remove unused import --- packages/oauth-client/src/oauth-client-factory.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/oauth-client/src/oauth-client-factory.ts b/packages/oauth-client/src/oauth-client-factory.ts index e2c50e351bb..40c6b64b7b8 100644 --- a/packages/oauth-client/src/oauth-client-factory.ts +++ b/packages/oauth-client/src/oauth-client-factory.ts @@ -8,11 +8,7 @@ import { OAuthServerFactoryOptions, } from './oauth-server-factory.js' import { OAuthServer } from './oauth-server.js' -import { - OAuthAuthorizeOptions, - OAuthResponseMode, - OAuthResponseType, -} from './oauth-types.js' +import { OAuthAuthorizeOptions, OAuthResponseMode } from './oauth-types.js' import { Session, SessionGetter } from './session-getter.js' export type InternalStateData = { From 20d425591ce4582bfa22cdaa3be7125c9e692ad6 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 11 Apr 2024 16:55:36 +0200 Subject: [PATCH 119/140] fix(oauth-client-browser): remove "responseType" from factory config --- packages/oauth-client-browser-example/src/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oauth-client-browser-example/src/app.tsx b/packages/oauth-client-browser-example/src/app.tsx index 8dab3fba573..8b175e4fcd7 100644 --- a/packages/oauth-client-browser-example/src/app.tsx +++ b/packages/oauth-client-browser-example/src/app.tsx @@ -9,8 +9,8 @@ export const oauthFactory = new BrowserOAuthClientFactory({ clientMetadata: oauthClientMetadataSchema.parse({ client_id: 'http://localhost/', redirect_uris: ['http://127.0.0.1:5173/'], + response_types: ['code id_token', 'code'], }), - responseType: 'code id_token', responseMode: 'fragment', plcDirectoryUrl: 'http://localhost:2582', // dev-env atprotoLexiconUrl: 'http://localhost:2584', // dev-env (bsky appview) From d157fdb647ce08c60a31709ca036a6b78a82095a Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 12:14:07 +0200 Subject: [PATCH 120/140] feat(identity-resolver): expose typed used in options --- .../identity-resolver/src/universal-identity-resolver.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/identity-resolver/src/universal-identity-resolver.ts b/packages/identity-resolver/src/universal-identity-resolver.ts index c205bce433d..20e88fceb6a 100644 --- a/packages/identity-resolver/src/universal-identity-resolver.ts +++ b/packages/identity-resolver/src/universal-identity-resolver.ts @@ -10,6 +10,12 @@ import UniversalHandleResolver, { } from '@atproto/handle-resolver' import { IdentityResolver } from './identity-resolver.js' +export type { DidCache, DidDocument } from '@atproto/did' +export type { + HandleResolverCache, + ResolvedHandle, +} from '@atproto/handle-resolver' + export type UniversalIdentityResolverOptions = { fetch?: Fetch From fc35a35ff90ea4410944e7fcd5ea8085b89b159e Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 12:14:25 +0200 Subject: [PATCH 121/140] fix(oauth-client-browser): typo in options type --- .../src/browser-oauth-client-factory.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts index 880bcd2ee60..3cdc0a77c46 100644 --- a/packages/oauth-client-browser/src/browser-oauth-client-factory.ts +++ b/packages/oauth-client-browser/src/browser-oauth-client-factory.ts @@ -24,7 +24,7 @@ import { import { CryptoSubtle } from './crypto-subtle.js' import { LoginContinuedInParentWindowError } from './errors.js' -export type BrowserOauthClientFactoryOptions = { +export type BrowserOAuthClientFactoryOptions = { responseMode?: OAuthResponseMode clientMetadata: OAuthClientMetadata plcDirectoryUrl?: UniversalIdentityResolverOptions['plcDirectoryUrl'] @@ -37,7 +37,7 @@ const POPUP_STATE_PREFIX = '@@oauth-popup-callback:' export class BrowserOAuthClientFactory extends OAuthClientFactory { static async load( - options?: Omit, + options?: Omit, ) { const fetch = options?.fetch ?? globalThis.fetch const request = new Request('/.well-known/oauth-client-metadata', { @@ -63,7 +63,7 @@ export class BrowserOAuthClientFactory extends OAuthClientFactory { atprotoLexiconUrl, crypto = globalThis.crypto, fetch = globalThis.fetch, - }: BrowserOauthClientFactoryOptions) { + }: BrowserOAuthClientFactoryOptions) { const database = new BrowserOAuthDatabase() super({ From a1bd9791c84f95fe5845c99360163b1a03675579 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 12:14:54 +0200 Subject: [PATCH 122/140] feat(oauth-client-react-native): shell --- package.json | 1 + .../android/build.gradle | 94 + .../android/gradle.properties | 5 + .../android/src/main/AndroidManifest.xml | 3 + .../android/src/main/AndroidManifestNew.xml | 2 + .../OauthClientReactNativeModule.kt | 25 + .../OauthClientReactNativePackage.kt | 17 + .../OauthClientReactNative-Bridging-Header.h | 2 + .../ios/OauthClientReactNative.mm | 14 + .../ios/OauthClientReactNative.swift | 8 + .../oauth-client-react-native/package.json | 42 + .../oauth-client-react-native/src/index.ts | 1 + .../src/oauth-client-react-native.ts | 18 + .../src/react-native-crypto-implementation.ts | 31 + .../src/react-native-key.ts | 39 + .../src/react-native-oauth-client-factory.ts | 92 + .../src/react-native-store-with-key.ts | 50 + .../src/react-native-store.ts | 28 + .../tsconfig.build.json | 8 + .../oauth-client-react-native/tsconfig.json | 4 + pnpm-lock.yaml | 3143 ++++++++++++++++- tsconfig.json | 1 + tsconfig/react-native.json | 34 + 23 files changed, 3571 insertions(+), 91 deletions(-) create mode 100644 packages/oauth-client-react-native/android/build.gradle create mode 100644 packages/oauth-client-react-native/android/gradle.properties create mode 100644 packages/oauth-client-react-native/android/src/main/AndroidManifest.xml create mode 100644 packages/oauth-client-react-native/android/src/main/AndroidManifestNew.xml create mode 100644 packages/oauth-client-react-native/android/src/main/java/com/oauthclientreactnative/OauthClientReactNativeModule.kt create mode 100644 packages/oauth-client-react-native/android/src/main/java/com/oauthclientreactnative/OauthClientReactNativePackage.kt create mode 100644 packages/oauth-client-react-native/ios/OauthClientReactNative-Bridging-Header.h create mode 100644 packages/oauth-client-react-native/ios/OauthClientReactNative.mm create mode 100644 packages/oauth-client-react-native/ios/OauthClientReactNative.swift create mode 100644 packages/oauth-client-react-native/package.json create mode 100644 packages/oauth-client-react-native/src/index.ts create mode 100644 packages/oauth-client-react-native/src/oauth-client-react-native.ts create mode 100644 packages/oauth-client-react-native/src/react-native-crypto-implementation.ts create mode 100644 packages/oauth-client-react-native/src/react-native-key.ts create mode 100644 packages/oauth-client-react-native/src/react-native-oauth-client-factory.ts create mode 100644 packages/oauth-client-react-native/src/react-native-store-with-key.ts create mode 100644 packages/oauth-client-react-native/src/react-native-store.ts create mode 100644 packages/oauth-client-react-native/tsconfig.build.json create mode 100644 packages/oauth-client-react-native/tsconfig.json create mode 100644 tsconfig/react-native.json diff --git a/package.json b/package.json index f2a0a1561b2..95ae3fa5c03 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "pino-pretty": "^9.1.0", "prettier": "^3.2.5", "prettier-config-standard": "^7.0.0", + "react-native": "^0.73.6", "typescript": "^5.4.4" }, "workspaces": { diff --git a/packages/oauth-client-react-native/android/build.gradle b/packages/oauth-client-react-native/android/build.gradle new file mode 100644 index 00000000000..dcd6fbb4e7b --- /dev/null +++ b/packages/oauth-client-react-native/android/build.gradle @@ -0,0 +1,94 @@ +buildscript { + // Buildscript is evaluated before everything else so we can't use getExtOrDefault + def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : project.properties["OauthClientReactNative_kotlinVersion"] + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:7.2.1" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +def isNewArchitectureEnabled() { + return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +if (isNewArchitectureEnabled()) { + apply plugin: "com.facebook.react" +} + +def getExtOrDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["OauthClientReactNative_" + name] +} + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["OauthClientReactNative_" + name]).toInteger() +} + +def supportsNamespace() { + def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.') + def major = parsed[0].toInteger() + def minor = parsed[1].toInteger() + + // Namespace support was added in 7.3.0 + return (major == 7 && minor >= 3) || major >= 8 +} + +android { + if (supportsNamespace()) { + namespace "com.oauthclientreactnative" + + sourceSets { + main { + manifest.srcFile "src/main/AndroidManifestNew.xml" + } + } + } + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + // For < 0.71, this will be from the local maven repo + // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin + //noinspection GradleDynamicVersion + implementation "com.facebook.react:react-native:+" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} + diff --git a/packages/oauth-client-react-native/android/gradle.properties b/packages/oauth-client-react-native/android/gradle.properties new file mode 100644 index 00000000000..f1a05979111 --- /dev/null +++ b/packages/oauth-client-react-native/android/gradle.properties @@ -0,0 +1,5 @@ +OauthClientReactNative_kotlinVersion=1.7.0 +OauthClientReactNative_minSdkVersion=21 +OauthClientReactNative_targetSdkVersion=31 +OauthClientReactNative_compileSdkVersion=31 +OauthClientReactNative_ndkversion=21.4.7075529 diff --git a/packages/oauth-client-react-native/android/src/main/AndroidManifest.xml b/packages/oauth-client-react-native/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..166700ad015 --- /dev/null +++ b/packages/oauth-client-react-native/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/oauth-client-react-native/android/src/main/AndroidManifestNew.xml b/packages/oauth-client-react-native/android/src/main/AndroidManifestNew.xml new file mode 100644 index 00000000000..a2f47b6057d --- /dev/null +++ b/packages/oauth-client-react-native/android/src/main/AndroidManifestNew.xml @@ -0,0 +1,2 @@ + + diff --git a/packages/oauth-client-react-native/android/src/main/java/com/oauthclientreactnative/OauthClientReactNativeModule.kt b/packages/oauth-client-react-native/android/src/main/java/com/oauthclientreactnative/OauthClientReactNativeModule.kt new file mode 100644 index 00000000000..94320ac715e --- /dev/null +++ b/packages/oauth-client-react-native/android/src/main/java/com/oauthclientreactnative/OauthClientReactNativeModule.kt @@ -0,0 +1,25 @@ +package com.oauthclientreactnative + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.Promise + +class OauthClientReactNativeModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String { + return NAME + } + + // Example method + // See https://reactnative.dev/docs/native-modules-android + @ReactMethod + fun multiply(a: Double, b: Double, promise: Promise) { + promise.resolve(a * b) + } + + companion object { + const val NAME = "OauthClientReactNative" + } +} diff --git a/packages/oauth-client-react-native/android/src/main/java/com/oauthclientreactnative/OauthClientReactNativePackage.kt b/packages/oauth-client-react-native/android/src/main/java/com/oauthclientreactnative/OauthClientReactNativePackage.kt new file mode 100644 index 00000000000..faa30d9036e --- /dev/null +++ b/packages/oauth-client-react-native/android/src/main/java/com/oauthclientreactnative/OauthClientReactNativePackage.kt @@ -0,0 +1,17 @@ +package com.oauthclientreactnative + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + + +class OauthClientReactNativePackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(OauthClientReactNativeModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} diff --git a/packages/oauth-client-react-native/ios/OauthClientReactNative-Bridging-Header.h b/packages/oauth-client-react-native/ios/OauthClientReactNative-Bridging-Header.h new file mode 100644 index 00000000000..dea7ff6bf0c --- /dev/null +++ b/packages/oauth-client-react-native/ios/OauthClientReactNative-Bridging-Header.h @@ -0,0 +1,2 @@ +#import +#import diff --git a/packages/oauth-client-react-native/ios/OauthClientReactNative.mm b/packages/oauth-client-react-native/ios/OauthClientReactNative.mm new file mode 100644 index 00000000000..089360a5219 --- /dev/null +++ b/packages/oauth-client-react-native/ios/OauthClientReactNative.mm @@ -0,0 +1,14 @@ +#import + +@interface RCT_EXTERN_MODULE(OauthClientReactNative, NSObject) + +RCT_EXTERN_METHOD(multiply:(float)a withB:(float)b + withResolver:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject) + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +@end diff --git a/packages/oauth-client-react-native/ios/OauthClientReactNative.swift b/packages/oauth-client-react-native/ios/OauthClientReactNative.swift new file mode 100644 index 00000000000..1c8fe483e80 --- /dev/null +++ b/packages/oauth-client-react-native/ios/OauthClientReactNative.swift @@ -0,0 +1,8 @@ +@objc(OauthClientReactNative) +class OauthClientReactNative: NSObject { + + @objc(multiply:withB:withResolver:withRejecter:) + func multiply(a: Float, b: Float, resolve:RCTPromiseResolveBlock,reject:RCTPromiseRejectBlock) -> Void { + resolve(a*b) + } +} diff --git a/packages/oauth-client-react-native/package.json b/packages/oauth-client-react-native/package.json new file mode 100644 index 00000000000..f545a7398bf --- /dev/null +++ b/packages/oauth-client-react-native/package.json @@ -0,0 +1,42 @@ +{ + "name": "@atproto/oauth-client-react-native", + "version": "0.0.1", + "license": "MIT", + "description": "Implementation of ATPROTO OAuth client for react-native", + "keywords": [ + "atproto", + "oauth", + "client", + "react-native" + ], + "homepage": "https://atproto.com", + "repository": { + "type": "git", + "url": "https://github.com/bluesky-social/atproto", + "directory": "packages/oauth-client-react-native" + }, + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc --build tsconfig.build.json" + }, + "dependencies": { + "@atproto/caching": "workspace:*", + "@atproto/fetch": "workspace:*", + "@atproto/handle-resolver": "workspace:*", + "@atproto/identity-resolver": "workspace:*", + "@atproto/jwk": "workspace:*", + "@atproto/oauth-client": "workspace:*", + "@atproto/oauth-client-metadata": "workspace:*", + "@atproto/oauth-server-metadata-resolver": "workspace:*" + }, + "devDependencies": { + "react-native": "0.73.6", + "typescript": "^5.3.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } +} diff --git a/packages/oauth-client-react-native/src/index.ts b/packages/oauth-client-react-native/src/index.ts new file mode 100644 index 00000000000..4b33dff2570 --- /dev/null +++ b/packages/oauth-client-react-native/src/index.ts @@ -0,0 +1 @@ +export * from './react-native-oauth-client-factory.js' diff --git a/packages/oauth-client-react-native/src/oauth-client-react-native.ts b/packages/oauth-client-react-native/src/oauth-client-react-native.ts new file mode 100644 index 00000000000..7f5e03c9246 --- /dev/null +++ b/packages/oauth-client-react-native/src/oauth-client-react-native.ts @@ -0,0 +1,18 @@ +import { NativeModules, Platform } from 'react-native' + +const LINKING_ERROR = + `The package 'oauth-client-react-native' doesn't seem to be linked. Make sure: \n\n` + + Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + + '- You rebuilt the app after installing the package\n' + + '- You are not using Expo Go\n' + +export const OauthClientReactNative = NativeModules.OauthClientReactNative + ? NativeModules.OauthClientReactNative + : new Proxy( + {}, + { + get() { + throw new Error(LINKING_ERROR) + }, + }, + ) diff --git a/packages/oauth-client-react-native/src/react-native-crypto-implementation.ts b/packages/oauth-client-react-native/src/react-native-crypto-implementation.ts new file mode 100644 index 00000000000..202ac2c318a --- /dev/null +++ b/packages/oauth-client-react-native/src/react-native-crypto-implementation.ts @@ -0,0 +1,31 @@ +import { + CryptoImplementation, + DigestAlgorithm, + Key, +} from '@atproto/oauth-client' + +import { OauthClientReactNative } from './oauth-client-react-native.js' +import { ReactNativeKey } from './react-native-key.js' + +export class ReactNativeCryptoImplementation implements CryptoImplementation { + async createKey(algs: string[]): Promise { + const bytes = await this.getRandomValues(12) + const kid = Array.from(bytes, byteToHex).join('') + return ReactNativeKey.generate(kid, algs) + } + + async getRandomValues(length: number): Promise { + return OauthClientReactNative.getRandomValues(length) + } + + async digest( + bytes: Uint8Array, + algorithm: DigestAlgorithm, + ): Promise { + return OauthClientReactNative.digest(bytes, algorithm) + } +} + +function byteToHex(b: number): string { + return b.toString(16).padStart(2, '0') +} diff --git a/packages/oauth-client-react-native/src/react-native-key.ts b/packages/oauth-client-react-native/src/react-native-key.ts new file mode 100644 index 00000000000..094ebe3fe87 --- /dev/null +++ b/packages/oauth-client-react-native/src/react-native-key.ts @@ -0,0 +1,39 @@ +import { + Jwk, + Jwt, + JwtHeader, + JwtPayload, + Key, + VerifyOptions, + VerifyPayload, + VerifyResult, +} from '@atproto/jwk' + +import { OauthClientReactNative } from './oauth-client-react-native.js' + +export class ReactNativeKey extends Key { + static async generate( + kid: string, + allowedAlgos: string[] = ['ES256'], + ): Promise { + if (!allowedAlgos.includes('ES256')) { + throw new Error( + `None of the allowed algorithms (${allowedAlgos}) are supported (only ES256)`, + ) + } + + const privateJwk: Jwk = await OauthClientReactNative.createES256Jwk() + return new ReactNativeKey({ ...privateJwk, kid }) + } + + async createJwt(header: JwtHeader, payload: JwtPayload): Promise { + return OauthClientReactNative.createJwt(header, payload, this.jwk) + } + + async verifyJwt< + P extends VerifyPayload = JwtPayload, + C extends string = string, + >(token: Jwt, options?: VerifyOptions): Promise> { + return OauthClientReactNative.verifyJwt(token, options, this.jwk) + } +} diff --git a/packages/oauth-client-react-native/src/react-native-oauth-client-factory.ts b/packages/oauth-client-react-native/src/react-native-oauth-client-factory.ts new file mode 100644 index 00000000000..a187e427c64 --- /dev/null +++ b/packages/oauth-client-react-native/src/react-native-oauth-client-factory.ts @@ -0,0 +1,92 @@ +import { Fetch } from '@atproto/fetch' +import { + DidDocument, + ResolvedHandle, + UniversalIdentityResolver, + UniversalIdentityResolverOptions, +} from '@atproto/identity-resolver' +import { + InternalStateData, + OAuthAuthorizeOptions, + OAuthClientFactory, + Session, +} from '@atproto/oauth-client' +import { OAuthClientMetadata } from '@atproto/oauth-client-metadata' +import { + IsomorphicOAuthServerMetadataResolver, + OAuthServerMetadata, +} from '@atproto/oauth-server-metadata-resolver' + +import { ReactNativeCryptoImplementation } from './react-native-crypto-implementation.js' +import { ReactNativeStoreWithKey } from './react-native-store-with-key.js' +import { ReactNativeStore } from './react-native-store.js' + +export type ReactNativeOAuthClientFactoryOptions = { + clientMetadata: OAuthClientMetadata + plcDirectoryUrl?: UniversalIdentityResolverOptions['plcDirectoryUrl'] + atprotoLexiconUrl?: UniversalIdentityResolverOptions['atprotoLexiconUrl'] + fetch?: Fetch +} + +export class ReactNativeOAuthClientFactory extends OAuthClientFactory { + constructor({ + clientMetadata, + plcDirectoryUrl, + atprotoLexiconUrl, + }: ReactNativeOAuthClientFactoryOptions) { + super({ + clientMetadata, + responseMode: 'query', + fetch, + cryptoImplementation: new ReactNativeCryptoImplementation(), + sessionStore: new ReactNativeStoreWithKey(({ tokenSet }) => + tokenSet.refresh_token || !tokenSet.expires_at + ? null + : new Date(tokenSet.expires_at), + ), + stateStore: new ReactNativeStoreWithKey( + () => new Date(Date.now() + 600e3), + ), + metadataResolver: new IsomorphicOAuthServerMetadataResolver({ + fetch, + cache: new ReactNativeStore( + () => new Date(Date.now() + 60e3), + ), + }), + identityResolver: UniversalIdentityResolver.from({ + fetch, + plcDirectoryUrl, + atprotoLexiconUrl, + didCache: new ReactNativeStore( + () => new Date(Date.now() + 60e3), + ), + handleCache: new ReactNativeStore( + () => new Date(Date.now() + 60e3), + ), + }), + dpopNonceCache: new ReactNativeStore( + () => new Date(Date.now() + 600e3), + ), + }) + } + + async signIn( + input: string, + options?: OAuthAuthorizeOptions & { signal?: AbortSignal }, + ) { + const url = await this.authorize(input, options) + const params = await this.openNativeLoginUi(url) + const { client } = await this.callback(params) + return client + } + + async openNativeLoginUi(url: URL): Promise { + // TODO: implement this + return new URLSearchParams({ + error: 'invalid_request', + error_description: 'Not implemented', + state: url.searchParams.get('state') ?? '', + issuer: url.searchParams.get('iss') ?? '', + }) + } +} diff --git a/packages/oauth-client-react-native/src/react-native-store-with-key.ts b/packages/oauth-client-react-native/src/react-native-store-with-key.ts new file mode 100644 index 00000000000..4fdc91d6aff --- /dev/null +++ b/packages/oauth-client-react-native/src/react-native-store-with-key.ts @@ -0,0 +1,50 @@ +import { GenericStore, Value } from '@atproto/caching' +import { Jwk } from '@atproto/jwk' +import { ReactNativeKey } from './react-native-key.js' +import { ReactNativeStore } from './react-native-store.js' + +type ExposedValue = Value & { dpopKey: ReactNativeKey } +type StoredValue = Omit & { + dpopKey: Jwk +} + +/** + * Uses a {@link ReactNativeStore} to store values that contain a + * {@link ReactNativeKey} as `dpopKey` property. This works by serializing the + * {@link Key} to a JWK before storing it, and deserializing it back to a + * {@link ReactNativeKey} when retrieving the value. + */ +export class ReactNativeStoreWithKey + implements GenericStore +{ + internalStore: ReactNativeStore> + + constructor( + protected valueExpiresAt: (value: StoredValue) => null | Date, + ) { + this.internalStore = new ReactNativeStore(valueExpiresAt) + } + + async set(key: string, value: V): Promise { + const { dpopKey, ...rest } = value + if (!dpopKey.privateJwk) throw new Error('dpopKey.privateJwk is required') + await this.internalStore.set(key, { + ...rest, + dpopKey: dpopKey.privateJwk, + }) + } + + async get(key: string): Promise { + const value = await this.internalStore.get(key) + if (!value) return undefined + + return { + ...value, + dpopKey: new ReactNativeKey(value.dpopKey), + } as V + } + + async del(key: string): Promise { + await this.internalStore.del(key) + } +} diff --git a/packages/oauth-client-react-native/src/react-native-store.ts b/packages/oauth-client-react-native/src/react-native-store.ts new file mode 100644 index 00000000000..e0e162b2f5a --- /dev/null +++ b/packages/oauth-client-react-native/src/react-native-store.ts @@ -0,0 +1,28 @@ +import { GenericStore, Value } from '@atproto/caching' + +// TODO: implement this using the app's safe storage +export class ReactNativeStore + implements GenericStore +{ + constructor( + /** + * Allows defining, at storage time, when the value should expire. This + * allows the store to automatically delete the values when they expire. + */ + protected valueExpiresAt: (value: V) => null | Date, + ) { + throw new Error('Not implemented') + } + + async get(key: string): Promise { + throw new Error('Not implemented') + } + + async set(key: string, value: V): Promise { + throw new Error('Not implemented') + } + + async del(key: string): Promise { + throw new Error('Not implemented') + } +} diff --git a/packages/oauth-client-react-native/tsconfig.build.json b/packages/oauth-client-react-native/tsconfig.build.json new file mode 100644 index 00000000000..beebf98ef1d --- /dev/null +++ b/packages/oauth-client-react-native/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig/react-native.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src"] +} diff --git a/packages/oauth-client-react-native/tsconfig.json b/packages/oauth-client-react-native/tsconfig.json new file mode 100644 index 00000000000..e84b8178b47 --- /dev/null +++ b/packages/oauth-client-react-native/tsconfig.json @@ -0,0 +1,4 @@ +{ + "include": [], + "references": [{ "path": "./tsconfig.build.json" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29563906141..fa9c8db3682 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: prettier-config-standard: specifier: ^7.0.0 version: 7.0.0(prettier@3.2.5) + react-native: + specifier: ^0.73.6 + version: 0.73.6(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0) typescript: specifier: ^5.4.4 version: 5.4.4 @@ -926,7 +929,7 @@ importers: version: link:../oauth-server-metadata '@babel/plugin-syntax-import-assertions': specifier: ^7.23.3 - version: 7.24.1(@babel/core@7.18.6) + version: 7.24.1(@babel/core@7.24.4) '@types/react': specifier: ^18.2.50 version: 18.2.75 @@ -971,6 +974,43 @@ importers: specifier: ^5.3.3 version: 5.4.4 + packages/oauth-client-react-native: + dependencies: + '@atproto/caching': + specifier: workspace:* + version: link:../caching + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/handle-resolver': + specifier: workspace:* + version: link:../handle-resolver + '@atproto/identity-resolver': + specifier: workspace:* + version: link:../identity-resolver + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/oauth-client': + specifier: workspace:* + version: link:../oauth-client + '@atproto/oauth-client-metadata': + specifier: workspace:* + version: link:../oauth-client-metadata + '@atproto/oauth-server-metadata-resolver': + specifier: workspace:* + version: link:../oauth-server-metadata-resolver + react: + specifier: '*' + version: 18.2.0 + devDependencies: + react-native: + specifier: 0.73.6 + version: 0.73.6(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0) + typescript: + specifier: ^5.3.3 + version: 5.4.4 + packages/oauth-provider: dependencies: '@atproto/fetch': @@ -4162,6 +4202,20 @@ packages: jsesc: 2.5.2 dev: true + /@babel/helper-annotate-as-pure@7.22.5: + resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: true + + /@babel/helper-builder-binary-assignment-operator-visitor@7.22.15: + resolution: {integrity: sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: true + /@babel/helper-compilation-targets@7.22.10: resolution: {integrity: sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==} engines: {node: '>=6.9.0'} @@ -4184,6 +4238,51 @@ packages: semver: 6.3.1 dev: true + /@babel/helper-create-class-features-plugin@7.24.4(@babel/core@7.24.4): + resolution: {integrity: sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + '@babel/helper-replace-supers': 7.24.1(@babel/core@7.24.4) + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + semver: 6.3.1 + dev: true + + /@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.24.4): + resolution: {integrity: sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + regexpu-core: 5.3.2 + semver: 6.3.1 + dev: true + + /@babel/helper-define-polyfill-provider@0.6.1(@babel/core@7.24.4): + resolution: {integrity: sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + debug: 4.3.4 + lodash.debounce: 4.0.8 + resolve: 1.22.4 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/helper-environment-visitor@7.22.20: resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} engines: {node: '>=6.9.0'} @@ -4217,6 +4316,13 @@ packages: '@babel/types': 7.22.10 dev: true + /@babel/helper-member-expression-to-functions@7.23.0: + resolution: {integrity: sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: true + /@babel/helper-module-imports@7.22.5: resolution: {integrity: sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==} engines: {node: '>=6.9.0'} @@ -4259,6 +4365,13 @@ packages: '@babel/helper-validator-identifier': 7.22.20 dev: true + /@babel/helper-optimise-call-expression@7.22.5: + resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: true + /@babel/helper-plugin-utils@7.22.5: resolution: {integrity: sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==} engines: {node: '>=6.9.0'} @@ -4269,6 +4382,30 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-remap-async-to-generator@7.22.20(@babel/core@7.24.4): + resolution: {integrity: sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-wrap-function': 7.22.20 + dev: true + + /@babel/helper-replace-supers@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + dev: true + /@babel/helper-simple-access@7.22.5: resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} engines: {node: '>=6.9.0'} @@ -4276,6 +4413,13 @@ packages: '@babel/types': 7.22.10 dev: true + /@babel/helper-skip-transparent-expression-wrappers@7.22.5: + resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: true + /@babel/helper-split-export-declaration@7.22.6: resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} engines: {node: '>=6.9.0'} @@ -4313,6 +4457,15 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-wrap-function@7.22.20: + resolution: {integrity: sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-function-name': 7.23.0 + '@babel/template': 7.24.0 + '@babel/types': 7.24.0 + dev: true + /@babel/helpers@7.22.10: resolution: {integrity: sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==} engines: {node: '>=6.9.0'} @@ -4370,90 +4523,162 @@ packages: '@babel/types': 7.24.0 dev: true - /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.18.6): - resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + /@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.4(@babel/core@7.24.4): + resolution: {integrity: sha512-qpl6vOOEEzTLLcsuqYYo8yDtrTocmu2xkGvgNebvPjT9DTtfFYGmgDqY+rBYXNlqL4s9qLDn6xkrJv4RxAPiTA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-y4HqEnkelJIOQGd+3g1bTeKsA5c6qM7eOn7VggGVbBc0y8MLSKHacwcIE2PplNlQSj0PqS9rrXL/nkPVK+kUNg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-Hj791Ii4ci8HqnaKHAlLNs+zaLXb0EzSDhiAWp5VNlyvCNymYfacs64pxTxbH1znW/NcArSmwpmG9IKE/TUVVQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-transform-optional-chaining': 7.24.1(@babel/core@7.24.4) + dev: true + + /@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-m9m/fXsXLiHfwdgydIFnpk+7jlVbnvlK5B2EKiPdLUb6WX654ZaaEWJUjk8TftRbZpK0XibovlLWX4KIZhV6jw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-proposal-async-generator-functions@7.20.7(@babel/core@7.24.4): + resolution: {integrity: sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.24.4) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.4) dev: true - /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.18.6): - resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + /@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.24.4): + resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/core': 7.24.4 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 dev: true - /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.18.6): - resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + /@babel/plugin-proposal-export-default-from@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-+0hrgGGV3xyYIjOrD/bUZk/iUwOIGuoANfRfVg1cPhYBxF+TIXSEcc42DqzBICmWsnAQ+SfKedY0bj8QD+LuMg==} + engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-export-default-from': 7.24.1(@babel/core@7.24.4) dev: true - /@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.18.6): - resolution: {integrity: sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==} + /@babel/plugin-proposal-nullish-coalescing-operator@7.18.6(@babel/core@7.24.4): + resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==} engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 + '@babel/core': 7.24.4 '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.4) dev: true - /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.18.6): - resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + /@babel/plugin-proposal-numeric-separator@7.18.6(@babel/core@7.24.4): + resolution: {integrity: sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.4) dev: true - /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.18.6): - resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + /@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.24.4): + resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/compat-data': 7.24.4 + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-transform-parameters': 7.24.1(@babel/core@7.24.4) dev: true - /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.18.6): - resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + /@babel/plugin-proposal-optional-catch-binding@7.18.6(@babel/core@7.24.4): + resolution: {integrity: sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-catch-binding instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.4) dev: true - /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.18.6): - resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + /@babel/plugin-proposal-optional-chaining@7.21.0(@babel/core@7.24.4): + resolution: {integrity: sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead. peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.4) dev: true - /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.18.6): - resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + /@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.24.4): + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 - '@babel/helper-plugin-utils': 7.22.5 + '@babel/core': 7.24.4 dev: true - /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.18.6): - resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.18.6): + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -4461,17 +4686,17 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.18.6): - resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.24.4): + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 + '@babel/core': 7.24.4 '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.18.6): - resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + /@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.18.6): + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -4479,9 +4704,8 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.18.6): - resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} - engines: {node: '>=6.9.0'} + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.18.6): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -4489,18 +4713,17 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-syntax-typescript@7.22.5(@babel/core@7.18.6): - resolution: {integrity: sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==} - engines: {node: '>=6.9.0'} + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.24.4): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.6 + '@babel/core': 7.24.4 '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-transform-react-jsx-self@7.24.1(@babel/core@7.24.4): - resolution: {integrity: sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==} + /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.24.4): + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -4509,9 +4732,8 @@ packages: '@babel/helper-plugin-utils': 7.24.0 dev: true - /@babel/plugin-transform-react-jsx-source@7.24.1(@babel/core@7.24.4): - resolution: {integrity: sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==} - engines: {node: '>=6.9.0'} + /@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.24.4): + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: @@ -4519,28 +4741,1055 @@ packages: '@babel/helper-plugin-utils': 7.24.0 dev: true - /@babel/runtime@7.22.10: - resolution: {integrity: sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==} + /@babel/plugin-syntax-export-default-from@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-cNXSxv9eTkGUtd0PsNMK8Yx5xeScxfpWOUAxE+ZPAXXEcAMOC3fk7LRdXq5fvpra2pLx2p1YtkAhpUbB2SwaRA==} engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 dependencies: - regenerator-runtime: 0.14.0 + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 dev: true - /@babel/template@7.22.5: - resolution: {integrity: sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==} - engines: {node: '>=6.9.0'} + /@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.24.4): + resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 dependencies: - '@babel/code-frame': 7.22.10 - '@babel/parser': 7.22.10 - '@babel/types': 7.22.10 + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 dev: true - /@babel/template@7.24.0: - resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==} + /@babel/plugin-syntax-flow@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-sxi2kLTI5DeW5vDtMUsk4mTPwvlUDbjOnoWayhynCwrw4QXRld4QEYwqzY8JmQXaJUtgUuCIurtSRH5sn4c7mA==} engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 dependencies: - '@babel/code-frame': 7.24.2 - '@babel/parser': 7.24.4 + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-syntax-import-assertions@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-syntax-import-attributes@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-zhQTMH0X2nVLnb04tz+s7AMuasX8U0FnpE+nHTOhSOINjWMnopoZTxtIKsd45n4GQ/HIZLyfIpoul8e2m0DnRA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.18.6): + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.24.4): + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.18.6): + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.24.4): + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-jsx@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.18.6): + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.4): + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.18.6): + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.24.4): + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.18.6): + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.24.4): + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.18.6): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.24.4): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.18.6): + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.24.4): + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.18.6): + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.24.4): + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.24.4): + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.18.6): + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.24.4): + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-typescript@7.22.5(@babel/core@7.18.6): + resolution: {integrity: sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.18.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-syntax-typescript@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.24.4): + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-arrow-functions@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-ngT/3NkRhsaep9ck9uj2Xhv9+xB1zShY3tM3g6om4xxCELwCDN4g4Aq5dRn48+0hasAql7s2hdBOysCfNpr4fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-async-generator-functions@7.24.3(@babel/core@7.24.4): + resolution: {integrity: sha512-Qe26CMYVjpQxJ8zxM1340JFNjZaF+ISWpr1Kt/jGo+ZTUzKkfw/pphEWbRCb+lmSM6k/TOgfYLvmbHkUQ0asIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.24.4) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-async-to-generator@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-AawPptitRXp1y0n4ilKcGbRYWfbbzFWz2NqNu7dacYDtFtz0CMjG64b3LQsb3KIgnf4/obcUL78hfaOS7iCUfw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-block-scoped-functions@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-TWWC18OShZutrv9C6mye1xwtam+uNi2bnTOCBUd5sZxyHOiWbU6ztSROofIMrK84uweEZC219POICK/sTYwfgg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-block-scoping@7.24.4(@babel/core@7.24.4): + resolution: {integrity: sha512-nIFUZIpGKDf9O9ttyRXpHFpKC+X3Y5mtshZONuEUYBomAKoM4y029Jr+uB1bHGPhNmK8YXHevDtKDOLmtRrp6g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-class-properties@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-OMLCXi0NqvJfORTaPQBwqLXHhb93wkBKZ4aNwMl6WtehO7ar+cmp+89iPEQPqxAnxsOKTaMcs3POz3rKayJ72g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-class-static-block@7.24.4(@babel/core@7.24.4): + resolution: {integrity: sha512-B8q7Pz870Hz/q9UgP8InNpY01CSLDSCyqX7zcRuv3FcPl87A2G17lASroHWaCtbdIcbYzOZ7kWmXFKbijMSmFg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-classes@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-replace-supers': 7.24.1(@babel/core@7.24.4) + '@babel/helper-split-export-declaration': 7.22.6 + globals: 11.12.0 + dev: true + + /@babel/plugin-transform-computed-properties@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-5pJGVIUfJpOS+pAqBQd+QMaTD2vCL/HcePooON6pDpHgRp4gNRmzyHTPIkXntwKsq3ayUFVfJaIKPw2pOkOcTw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/template': 7.24.0 + dev: true + + /@babel/plugin-transform-destructuring@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-dotall-regex@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-p7uUxgSoZwZ2lPNMzUkqCts3xlp8n+o05ikjy7gbtFJSt9gdU88jAmtfmOxHM14noQXBxfgzf2yRWECiNVhTCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-duplicate-keys@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-msyzuUnvsjsaSaocV6L7ErfNsa5nDWL1XKNnDePLgmz+WdU4w/J8+AxBMrWfi9m4IxfL5sZQKUPQKDQeeAT6lA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-dynamic-import@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-av2gdSTyXcJVdI+8aFZsCAtR29xJt0S5tas+Ef8NvBNmD1a+N/3ecMLeMBgfcK+xzsjdLDT6oHt+DFPyeqUbDA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-exponentiation-operator@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-U1yX13dVBSwS23DEAqU+Z/PkwE9/m7QQy8Y9/+Tdb8UWYaGNDYwTLi19wqIAiROr8sXVum9A/rtiH5H0boUcTw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-builder-binary-assignment-operator-visitor': 7.22.15 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-export-namespace-from@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-Ft38m/KFOyzKw2UaJFkWG9QnHPG/Q/2SkOrRk4pNBPg5IPZ+dOxcmkK5IyuBcxiNPyyYowPGUReyBvrvZs7IlQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-flow-strip-types@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-iIYPIWt3dUmUKKE10s3W+jsQ3icFkw0JyRVyY1B7G4yK/nngAOHLVx8xlhA6b/Jzl/Y0nis8gjqhqKtRDQqHWQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-flow': 7.24.1(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-for-of@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-OxBdcnF04bpdQdR3i4giHZNZQn7cm8RQKcSwA17wAAqEELo1ZOwp5FFgeptWUQXFyT9kwHo10aqqauYkRZPCAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + dev: true + + /@babel/plugin-transform-function-name@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-BXmDZpPlh7jwicKArQASrj8n22/w6iymRnvHYYd2zO30DbE277JO20/7yXJT3QxDPtiQiOxQBbZH4TpivNXIxA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-json-strings@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-U7RMFmRvoasscrIFy5xA4gIp8iWnWubnKkKuUGJjsuOH7GfbMkB+XZzeslx2kLdEGdOJDamEmCqOks6e8nv8DQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-literals@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-zn9pwz8U7nCqOYIiBaOxoQOtYmMODXTJnkxG4AtX8fPmnCRYWBOHD0qcpwS9e2VDSp1zNJYpdnFMIKb8jmwu6g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-logical-assignment-operators@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-OhN6J4Bpz+hIBqItTeWJujDOfNP+unqv/NJgyhlpSqgBTPm37KkMmZV6SYcOj+pnDbdcl1qRGV/ZiIjX9Iy34w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-member-expression-literals@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-4ojai0KysTWXzHseJKa1XPNXKRbuUrhkOPY4rEGeR+7ChlJVKxFa3H3Bz+7tWaGKgJAXUWKOGmltN+u9B3+CVg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-modules-amd@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-lAxNHi4HVtjnHd5Rxg3D5t99Xm6H7b04hUS7EHIXcUl2EV4yl1gWdqZrNzXnSrHveL9qMdbODlLF55mvgjAfaQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-modules-commonjs@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-simple-access': 7.22.5 + dev: true + + /@babel/plugin-transform-modules-systemjs@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-mqQ3Zh9vFO1Tpmlt8QPnbwGHzNz3lpNEMxQb1kAemn/erstyqw1r9KeOlOfo3y6xAnFEcOv2tSyrXfmMk+/YZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-validator-identifier': 7.22.20 + dev: true + + /@babel/plugin-transform-modules-umd@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-tuA3lpPj+5ITfcCluy6nWonSL7RvaG0AOTeAuvXqEKS34lnLzXpDb0dcP6K8jD0zWZFNDVly90AGFJPnm4fOYg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-named-capturing-groups-regex@7.22.5(@babel/core@7.24.4): + resolution: {integrity: sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-new-target@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-/rurytBM34hYy0HKZQyA0nHbQgQNFm4Q/BOc9Hflxi2X3twRof7NaE5W46j4kQitm7SvACVRXsa6N/tSZxvPug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-nullish-coalescing-operator@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-iQ+caew8wRrhCikO5DrUYx0mrmdhkaELgFa+7baMcVuhxIkN7oxt06CZ51D65ugIb1UWRQ8oQe+HXAVM6qHFjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-numeric-separator@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-7GAsGlK4cNL2OExJH1DzmDeKnRv/LXq0eLUSvudrehVA5Rgg4bIrqEUW29FbKMBRT0ztSqisv7kjP+XIC4ZMNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-object-rest-spread@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-XjD5f0YqOtebto4HGISLNfiNMTTs6tbkFf2TOqJlYKYmbo+mN9Dnpl4SRoofiziuOWMIyq3sZEUqLo3hLITFEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-transform-parameters': 7.24.1(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-object-super@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-oKJqR3TeI5hSLRxudMjFQ9re9fBVUU0GICqM3J1mi8MqlhVr6hC/ZN4ttAyMuQR6EZZIY6h/exe5swqGNNIkWQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-replace-supers': 7.24.1(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-optional-catch-binding@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-oBTH7oURV4Y+3EUrf6cWn1OHio3qG/PVwO5J03iSJmBg6m2EhKjkAu/xuaXaYwWW9miYtvbWv4LNf0AmR43LUA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-optional-chaining@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-n03wmDt+987qXwAgcBlnUUivrZBPZ8z1plL0YvgQalLm+ZE5BMhGm94jhxXtA1wzv1Cu2aaOv1BM9vbVttrzSg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-parameters@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-private-methods@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-tGvisebwBO5em4PaYNqt4fkw56K2VALsAbAakY0FjTYqJp7gfdrgr7YX76Or8/cpik0W6+tj3rZ0uHU9Oil4tw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-private-property-in-object@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-property-literals@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-LetvD7CrHmEx0G442gOomRr66d7q8HzzGGr4PMHGr+5YIm6++Yke+jxj246rpvsbyhJwCLxcTn6zW1P1BSenqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-react-display-name@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-mvoQg2f9p2qlpDQRBC7M3c3XTr0k7cp/0+kFKKO/7Gtu0LSw16eKB+Fabe2bDT/UpsyasTBBkAnbdsLrkD5XMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-react-jsx-self@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-react-jsx-source@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-react-jsx@7.23.4(@babel/core@7.24.4): + resolution: {integrity: sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-jsx': 7.24.1(@babel/core@7.24.4) + '@babel/types': 7.24.0 + dev: true + + /@babel/plugin-transform-regenerator@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-sJwZBCzIBE4t+5Q4IGLaaun5ExVMRY0lYwos/jNecjMrVCygCdph3IKv0tkP5Fc87e/1+bebAmEAGBfnRD+cnw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + regenerator-transform: 0.15.2 + dev: true + + /@babel/plugin-transform-reserved-words@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-JAclqStUfIwKN15HrsQADFgeZt+wexNQ0uLhuqvqAUFoqPMjEcFCYZBhq0LUdz6dZK/mD+rErhW71fbx8RYElg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-runtime@7.24.3(@babel/core@7.24.4): + resolution: {integrity: sha512-J0BuRPNlNqlMTRJ72eVptpt9VcInbxO6iP3jaxr+1NPhC0UkKL+6oeX6VXMEYdADnuqmMmsBspt4d5w8Y/TCbQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + babel-plugin-polyfill-corejs2: 0.4.10(@babel/core@7.24.4) + babel-plugin-polyfill-corejs3: 0.10.4(@babel/core@7.24.4) + babel-plugin-polyfill-regenerator: 0.6.1(@babel/core@7.24.4) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/plugin-transform-shorthand-properties@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-LyjVB1nsJ6gTTUKRjRWx9C1s9hE7dLfP/knKdrfeH9UPtAGjYGgxIbFfx7xyLIEWs7Xe1Gnf8EWiUqfjLhInZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-spread@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-KjmcIM+fxgY+KxPVbjelJC6hrH1CgtPmTvdXAfn3/a9CnWGSTY7nH4zm5+cjmWJybdcPSsD0++QssDsjcpe47g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + dev: true + + /@babel/plugin-transform-sticky-regex@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-9v0f1bRXgPVcPrngOQvLXeGNNVLc8UjMVfebo9ka0WF3/7+aVUHmaJVT3sa0XCzEFioPfPHZiOcYG9qOsH63cw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-template-literals@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-WRkhROsNzriarqECASCNu/nojeXCDTE/F2HmRgOzi7NGvyfYGq1NEjKBK3ckLfRgGc6/lPAqP0vDOSw3YtG34g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-typeof-symbol@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-CBfU4l/A+KruSUoW+vTQthwcAdwuqbpRNB8HQKlZABwHRhsdHZ9fezp4Sn18PeAlYxTNiLMlx4xUBV3AWfg1BA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-typescript@7.24.4(@babel/core@7.24.4): + resolution: {integrity: sha512-79t3CQ8+oBGk/80SQ8MN3Bs3obf83zJ0YZjDmDaEZN8MqhMI760apl5z6a20kFeMXBwJX99VpKT8CKxEBp5H1g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-typescript': 7.24.1(@babel/core@7.24.4) + dev: true + + /@babel/plugin-transform-unicode-escapes@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-RlkVIcWT4TLI96zM660S877E7beKlQw7Ig+wqkKBiWfj0zH5Q4h50q6er4wzZKRNSYpfo6ILJ+hrJAGSX2qcNw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-unicode-property-regex@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-Ss4VvlfYV5huWApFsF8/Sq0oXnGO+jB+rijFEFugTd3cwSObUSnUi88djgR5528Csl0uKlrI331kRqe56Ov2Ng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-unicode-regex@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-2A/94wgZgxfTsiLaQ2E36XAOdcZmGAaEEgVmxQWwZXWkGhvoHbaqXcKnU8zny4ycpu3vNqg0L/PcCiYtHtA13g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/plugin-transform-unicode-sets-regex@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-fqj4WuzzS+ukpgerpAoOnMfQXwUHFxXUZUE84oL2Kao2N8uSlvcpnAidKASgsNgzZHBsHWvcm8s9FPWUhAb8fA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.24.4) + '@babel/helper-plugin-utils': 7.24.0 + dev: true + + /@babel/preset-env@7.24.4(@babel/core@7.24.4): + resolution: {integrity: sha512-7Kl6cSmYkak0FK/FXjSEnLJ1N9T/WA2RkMhu17gZ/dsxKJUuTYNIylahPTzqpLyJN4WhDif8X0XK1R8Wsguo/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/core': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-validator-option': 7.23.5 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.24.4(@babel/core@7.24.4) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.24.4) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.24.4) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.24.4) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.24.4) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-import-assertions': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-syntax-import-attributes': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.24.4) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.24.4) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.24.4) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.24.4) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.4) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.24.4) + '@babel/plugin-transform-arrow-functions': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-async-generator-functions': 7.24.3(@babel/core@7.24.4) + '@babel/plugin-transform-async-to-generator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-block-scoped-functions': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-block-scoping': 7.24.4(@babel/core@7.24.4) + '@babel/plugin-transform-class-properties': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-class-static-block': 7.24.4(@babel/core@7.24.4) + '@babel/plugin-transform-classes': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-computed-properties': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-destructuring': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-dotall-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-duplicate-keys': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-dynamic-import': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-exponentiation-operator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-export-namespace-from': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-for-of': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-function-name': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-json-strings': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-logical-assignment-operators': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-member-expression-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-amd': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-systemjs': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-umd': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.24.4) + '@babel/plugin-transform-new-target': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-nullish-coalescing-operator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-numeric-separator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-object-rest-spread': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-object-super': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-optional-catch-binding': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-optional-chaining': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-parameters': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-private-methods': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-private-property-in-object': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-property-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-regenerator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-reserved-words': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-shorthand-properties': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-spread': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-sticky-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-template-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-typeof-symbol': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-escapes': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-property-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-sets-regex': 7.24.1(@babel/core@7.24.4) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.24.4) + babel-plugin-polyfill-corejs2: 0.4.10(@babel/core@7.24.4) + babel-plugin-polyfill-corejs3: 0.10.4(@babel/core@7.24.4) + babel-plugin-polyfill-regenerator: 0.6.1(@babel/core@7.24.4) + core-js-compat: 3.36.1 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/preset-flow@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-sWCV2G9pcqZf+JHyv/RyqEIpFypxdCSxWIxQjpdaQxenNog7cN1pr76hg8u0Fz8Qgg0H4ETkGcJnXL8d4j0PPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-validator-option': 7.23.5 + '@babel/plugin-transform-flow-strip-types': 7.24.1(@babel/core@7.24.4) + dev: true + + /@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.24.4): + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/types': 7.24.0 + esutils: 2.0.3 + dev: true + + /@babel/preset-typescript@7.24.1(@babel/core@7.24.4): + resolution: {integrity: sha512-1DBaMmRDpuYQBPWD8Pf/WEwCrtgRHxsZnP4mIy9G/X+hFfbI47Q2G4t1Paakld84+qsk2fSsUPMKg71jkoOOaQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-validator-option': 7.23.5 + '@babel/plugin-syntax-jsx': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-typescript': 7.24.4(@babel/core@7.24.4) + dev: true + + /@babel/register@7.23.7(@babel/core@7.24.4): + resolution: {integrity: sha512-EjJeB6+kvpk+Y5DAkEAmbOBEFkh9OASx0huoEkqYTFxAZHzOAX2Oh5uwAUuL2rUddqfM0SA+KPXV2TbzoZ2kvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + clone-deep: 4.0.1 + find-cache-dir: 2.1.0 + make-dir: 2.1.0 + pirates: 4.0.6 + source-map-support: 0.5.21 + dev: true + + /@babel/regjsgen@0.8.0: + resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==} + dev: true + + /@babel/runtime@7.22.10: + resolution: {integrity: sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.0 + dev: true + + /@babel/template@7.22.5: + resolution: {integrity: sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.22.10 + '@babel/parser': 7.22.10 + '@babel/types': 7.22.10 + dev: true + + /@babel/template@7.24.0: + resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/parser': 7.24.4 '@babel/types': 7.24.0 dev: true @@ -5425,6 +6674,16 @@ packages: resolution: {integrity: sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==} dev: false + /@hapi/hoek@9.3.0: + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + dev: true + + /@hapi/topo@5.1.0: + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + dependencies: + '@hapi/hoek': 9.3.0 + dev: true + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -5473,6 +6732,11 @@ packages: wrap-ansi: 8.1.0 wrap-ansi-cjs: /wrap-ansi@7.0.0 + /@isaacs/ttlcache@1.4.1: + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + dev: true + /@istanbuljs/load-nyc-config@1.1.0: resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -5551,6 +6815,13 @@ packages: '@jest/types': 27.5.1 dev: true + /@jest/create-cache-key-function@29.7.0: + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + dev: true + /@jest/environment@28.1.3: resolution: {integrity: sha512-1bf40cMFTEkKyEf585R9Iz1WayDjHoHqvts0XFYEqyKM3cFWDpeMoqKKTAF9LSYQModPUlh8FKptoM2YcMWAXA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -5561,6 +6832,16 @@ packages: jest-mock: 28.1.3 dev: true + /@jest/environment@29.7.0: + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 18.19.24 + jest-mock: 29.7.0 + dev: true + /@jest/expect-utils@28.1.3: resolution: {integrity: sha512-wvbi9LUrHJLn3NlDW6wF2hvIMtd4JUl2QNVrjq+IBSHirgfrR3o9RnVtxzdEGO2n9JyIWwHnLfby5KzqBGg2YA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -5590,6 +6871,18 @@ packages: jest-util: 28.1.3 dev: true + /@jest/fake-timers@29.7.0: + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 18.19.24 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + dev: true + /@jest/globals@28.1.3: resolution: {integrity: sha512-XFU4P4phyryCXu1pbcqMO0GSQcYe1IsalYCDzRNyhetyeyxMcIxa11qPNDpVNLeretItNqEmYYQn1UYz/5x1NA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -5646,6 +6939,13 @@ packages: '@sinclair/typebox': 0.24.51 dev: true + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.8 + dev: true + /@jest/source-map@28.1.2: resolution: {integrity: sha512-cV8Lx3BeStJb8ipPHnqVw/IM2VCMWO3crWZzYodSIkxXnRcXJipCdx1JCK0K5MsJJouZQTH73mzf4vgxRaH9ww==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -5698,6 +6998,17 @@ packages: - supports-color dev: true + /@jest/types@26.6.2: + resolution: {integrity: sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==} + engines: {node: '>= 10.14.2'} + dependencies: + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 18.19.24 + '@types/yargs': 15.0.19 + chalk: 4.1.2 + dev: true + /@jest/types@27.5.1: resolution: {integrity: sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -5721,6 +7032,18 @@ packages: chalk: 4.1.2 dev: true + /@jest/types@29.6.3: + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 18.19.24 + '@types/yargs': 17.0.24 + chalk: 4.1.2 + dev: true + /@jridgewell/gen-mapping@0.3.3: resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} engines: {node: '>=6.0.0'} @@ -5946,25 +7269,369 @@ packages: '@protobufjs/inquire': 1.1.0 dev: false - /@protobufjs/float@1.0.2: - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - dev: false + /@protobufjs/float@1.0.2: + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + dev: false + + /@protobufjs/inquire@1.1.0: + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + dev: false + + /@protobufjs/path@1.1.2: + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + dev: false + + /@protobufjs/pool@1.1.0: + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + dev: false + + /@protobufjs/utf8@1.1.0: + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + dev: false + + /@react-native-community/cli-clean@12.3.6: + resolution: {integrity: sha512-gUU29ep8xM0BbnZjwz9MyID74KKwutq9x5iv4BCr2im6nly4UMf1B1D+V225wR7VcDGzbgWjaezsJShLLhC5ig==} + dependencies: + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + execa: 5.1.1 + transitivePeerDependencies: + - encoding + dev: true + + /@react-native-community/cli-config@12.3.6: + resolution: {integrity: sha512-JGWSYQ9EAK6m2v0abXwFLEfsqJ1zkhzZ4CV261QZF9MoUNB6h57a274h1MLQR9mG6Tsh38wBUuNfEPUvS1vYew==} + dependencies: + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + cosmiconfig: 5.2.1 + deepmerge: 4.3.1 + glob: 7.2.3 + joi: 17.12.3 + transitivePeerDependencies: + - encoding + dev: true + + /@react-native-community/cli-debugger-ui@12.3.6: + resolution: {integrity: sha512-SjUKKsx5FmcK9G6Pb6UBFT0s9JexVStK5WInmANw75Hm7YokVvHEgtprQDz2Uvy5znX5g2ujzrkIU//T15KQzA==} + dependencies: + serve-static: 1.15.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@react-native-community/cli-doctor@12.3.6: + resolution: {integrity: sha512-fvBDv2lTthfw4WOQKkdTop2PlE9GtfrlNnpjB818MhcdEnPjfQw5YaTUcnNEGsvGomdCs1MVRMgYXXwPSN6OvQ==} + dependencies: + '@react-native-community/cli-config': 12.3.6 + '@react-native-community/cli-platform-android': 12.3.6 + '@react-native-community/cli-platform-ios': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + command-exists: 1.2.9 + deepmerge: 4.3.1 + envinfo: 7.12.0 + execa: 5.1.1 + hermes-profile-transformer: 0.0.6 + node-stream-zip: 1.15.0 + ora: 5.4.1 + semver: 7.6.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + yaml: 2.4.1 + transitivePeerDependencies: + - encoding + dev: true + + /@react-native-community/cli-hermes@12.3.6: + resolution: {integrity: sha512-sNGwfOCl8OAIjWCkwuLpP8NZbuO0dhDI/2W7NeOGDzIBsf4/c4MptTrULWtGIH9okVPLSPX0NnRyGQ+mSwWyuQ==} + dependencies: + '@react-native-community/cli-platform-android': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + hermes-profile-transformer: 0.0.6 + transitivePeerDependencies: + - encoding + dev: true + + /@react-native-community/cli-platform-android@12.3.6: + resolution: {integrity: sha512-DeDDAB8lHpuGIAPXeeD9Qu2+/wDTFPo99c8uSW49L0hkmZJixzvvvffbGQAYk32H0TmaI7rzvzH+qzu7z3891g==} + dependencies: + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + execa: 5.1.1 + fast-xml-parser: 4.3.6 + glob: 7.2.3 + logkitty: 0.7.1 + transitivePeerDependencies: + - encoding + dev: true + + /@react-native-community/cli-platform-ios@12.3.6: + resolution: {integrity: sha512-3eZ0jMCkKUO58wzPWlvAPRqezVKm9EPZyaPyHbRPWU8qw7JqkvnRlWIaYDGpjCJgVW4k2hKsEursLtYKb188tg==} + dependencies: + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + execa: 5.1.1 + fast-xml-parser: 4.3.6 + glob: 7.2.3 + ora: 5.4.1 + transitivePeerDependencies: + - encoding + dev: true + + /@react-native-community/cli-plugin-metro@12.3.6: + resolution: {integrity: sha512-3jxSBQt4fkS+KtHCPSyB5auIT+KKIrPCv9Dk14FbvOaEh9erUWEm/5PZWmtboW1z7CYeNbFMeXm9fM2xwtVOpg==} + dev: true + + /@react-native-community/cli-server-api@12.3.6: + resolution: {integrity: sha512-80NIMzo8b2W+PL0Jd7NjiJW9mgaT8Y8wsIT/lh6mAvYH7mK0ecDJUYUTAAv79Tbo1iCGPAr3T295DlVtS8s4yQ==} + dependencies: + '@react-native-community/cli-debugger-ui': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + compression: 1.7.4 + connect: 3.7.0 + errorhandler: 1.5.1 + nocache: 3.0.4 + pretty-format: 26.6.2 + serve-static: 1.15.0 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /@react-native-community/cli-tools@12.3.6: + resolution: {integrity: sha512-FPEvZn19UTMMXUp/piwKZSh8cMEfO8G3KDtOwo53O347GTcwNrKjgZGtLSPELBX2gr+YlzEft3CoRv2Qmo83fQ==} + dependencies: + appdirsjs: 1.2.7 + chalk: 4.1.2 + find-up: 5.0.0 + mime: 2.6.0 + node-fetch: 2.7.0 + open: 6.4.0 + ora: 5.4.1 + semver: 7.6.0 + shell-quote: 1.8.1 + sudo-prompt: 9.2.1 + transitivePeerDependencies: + - encoding + dev: true + + /@react-native-community/cli-types@12.3.6: + resolution: {integrity: sha512-xPqTgcUtZowQ8WKOkI9TLGBwH2bGggOC4d2FFaIRST3gTcjrEeGRNeR5aXCzJFIgItIft8sd7p2oKEdy90+01Q==} + dependencies: + joi: 17.12.3 + dev: true + + /@react-native-community/cli@12.3.6: + resolution: {integrity: sha512-647OSi6xBb8FbwFqX9zsJxOzu685AWtrOUWHfOkbKD+5LOpGORw+GQo0F9rWZnB68rLQyfKUZWJeaD00pGv5fw==} + engines: {node: '>=18'} + hasBin: true + dependencies: + '@react-native-community/cli-clean': 12.3.6 + '@react-native-community/cli-config': 12.3.6 + '@react-native-community/cli-debugger-ui': 12.3.6 + '@react-native-community/cli-doctor': 12.3.6 + '@react-native-community/cli-hermes': 12.3.6 + '@react-native-community/cli-plugin-metro': 12.3.6 + '@react-native-community/cli-server-api': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + '@react-native-community/cli-types': 12.3.6 + chalk: 4.1.2 + commander: 9.5.0 + deepmerge: 4.3.1 + execa: 5.1.1 + find-up: 4.1.0 + fs-extra: 8.1.0 + graceful-fs: 4.2.11 + prompts: 2.4.2 + semver: 7.6.0 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /@react-native/assets-registry@0.73.1: + resolution: {integrity: sha512-2FgAbU7uKM5SbbW9QptPPZx8N9Ke2L7bsHb+EhAanZjFZunA9PaYtyjUQ1s7HD+zDVqOQIvjkpXSv7Kejd2tqg==} + engines: {node: '>=18'} + dev: true + + /@react-native/babel-plugin-codegen@0.73.4(@babel/preset-env@7.24.4): + resolution: {integrity: sha512-XzRd8MJGo4Zc5KsphDHBYJzS1ryOHg8I2gOZDAUCGcwLFhdyGu1zBNDJYH2GFyDrInn9TzAbRIf3d4O+eltXQQ==} + engines: {node: '>=18'} + dependencies: + '@react-native/codegen': 0.73.3(@babel/preset-env@7.24.4) + transitivePeerDependencies: + - '@babel/preset-env' + - supports-color + dev: true + + /@react-native/babel-preset@0.73.21(@babel/core@7.24.4)(@babel/preset-env@7.24.4): + resolution: {integrity: sha512-WlFttNnySKQMeujN09fRmrdWqh46QyJluM5jdtDNrkl/2Hx6N4XeDUGhABvConeK95OidVO7sFFf7sNebVXogA==} + engines: {node: '>=18'} + peerDependencies: + '@babel/core': '*' + dependencies: + '@babel/core': 7.24.4 + '@babel/plugin-proposal-async-generator-functions': 7.20.7(@babel/core@7.24.4) + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.24.4) + '@babel/plugin-proposal-export-default-from': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.24.4) + '@babel/plugin-proposal-numeric-separator': 7.18.6(@babel/core@7.24.4) + '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.24.4) + '@babel/plugin-proposal-optional-catch-binding': 7.18.6(@babel/core@7.24.4) + '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.24.4) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-export-default-from': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-syntax-flow': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.4) + '@babel/plugin-transform-arrow-functions': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-async-to-generator': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-block-scoping': 7.24.4(@babel/core@7.24.4) + '@babel/plugin-transform-classes': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-computed-properties': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-destructuring': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-flow-strip-types': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-function-name': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-literals': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.24.4) + '@babel/plugin-transform-parameters': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-private-methods': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-private-property-in-object': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-react-display-name': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-react-jsx': 7.23.4(@babel/core@7.24.4) + '@babel/plugin-transform-react-jsx-self': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-react-jsx-source': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-runtime': 7.24.3(@babel/core@7.24.4) + '@babel/plugin-transform-shorthand-properties': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-spread': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-sticky-regex': 7.24.1(@babel/core@7.24.4) + '@babel/plugin-transform-typescript': 7.24.4(@babel/core@7.24.4) + '@babel/plugin-transform-unicode-regex': 7.24.1(@babel/core@7.24.4) + '@babel/template': 7.24.0 + '@react-native/babel-plugin-codegen': 0.73.4(@babel/preset-env@7.24.4) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.24.4) + react-refresh: 0.14.0 + transitivePeerDependencies: + - '@babel/preset-env' + - supports-color + dev: true + + /@react-native/codegen@0.73.3(@babel/preset-env@7.24.4): + resolution: {integrity: sha512-sxslCAAb8kM06vGy9Jyh4TtvjhcP36k/rvj2QE2Jdhdm61KvfafCATSIsOfc0QvnduWFcpXUPvAVyYwuv7PYDg==} + engines: {node: '>=18'} + peerDependencies: + '@babel/preset-env': ^7.1.6 + dependencies: + '@babel/parser': 7.24.4 + '@babel/preset-env': 7.24.4(@babel/core@7.24.4) + flow-parser: 0.206.0 + glob: 7.2.3 + invariant: 2.2.4 + jscodeshift: 0.14.0(@babel/preset-env@7.24.4) + mkdirp: 0.5.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@react-native/community-cli-plugin@0.73.17(@babel/core@7.24.4)(@babel/preset-env@7.24.4): + resolution: {integrity: sha512-F3PXZkcHg+1ARIr6FRQCQiB7ZAA+MQXGmq051metRscoLvgYJwj7dgC8pvgy0kexzUkHu5BNKrZeySzUft3xuQ==} + engines: {node: '>=18'} + dependencies: + '@react-native-community/cli-server-api': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + '@react-native/dev-middleware': 0.73.8 + '@react-native/metro-babel-transformer': 0.73.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4) + chalk: 4.1.2 + execa: 5.1.1 + metro: 0.80.8 + metro-config: 0.80.8 + metro-core: 0.80.8 + node-fetch: 2.7.0 + readline: 1.3.0 + transitivePeerDependencies: + - '@babel/core' + - '@babel/preset-env' + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /@react-native/debugger-frontend@0.73.3: + resolution: {integrity: sha512-RgEKnWuoo54dh7gQhV7kvzKhXZEhpF9LlMdZolyhGxHsBqZ2gXdibfDlfcARFFifPIiaZ3lXuOVVa4ei+uPgTw==} + engines: {node: '>=18'} + dev: true + + /@react-native/dev-middleware@0.73.8: + resolution: {integrity: sha512-oph4NamCIxkMfUL/fYtSsE+JbGOnrlawfQ0kKtDQ5xbOjPKotKoXqrs1eGwozNKv7FfQ393stk1by9a6DyASSg==} + engines: {node: '>=18'} + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.73.3 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 1.0.0 + connect: 3.7.0 + debug: 2.6.9 + node-fetch: 2.7.0 + open: 7.4.2 + serve-static: 1.15.0 + temp-dir: 2.0.0 + ws: 6.2.2 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true - /@protobufjs/inquire@1.1.0: - resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} - dev: false + /@react-native/gradle-plugin@0.73.4: + resolution: {integrity: sha512-PMDnbsZa+tD55Ug+W8CfqXiGoGneSSyrBZCMb5JfiB3AFST3Uj5e6lw8SgI/B6SKZF7lG0BhZ6YHZsRZ5MlXmg==} + engines: {node: '>=18'} + dev: true - /@protobufjs/path@1.1.2: - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - dev: false + /@react-native/js-polyfills@0.73.1: + resolution: {integrity: sha512-ewMwGcumrilnF87H4jjrnvGZEaPFCAC4ebraEK+CurDDmwST/bIicI4hrOAv+0Z0F7DEK4O4H7r8q9vH7IbN4g==} + engines: {node: '>=18'} + dev: true - /@protobufjs/pool@1.1.0: - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - dev: false + /@react-native/metro-babel-transformer@0.73.15(@babel/core@7.24.4)(@babel/preset-env@7.24.4): + resolution: {integrity: sha512-LlkSGaXCz+xdxc9819plmpsl4P4gZndoFtpjN3GMBIu6f7TBV0GVbyJAU4GE8fuAWPVSVL5ArOcdkWKSbI1klw==} + engines: {node: '>=18'} + peerDependencies: + '@babel/core': '*' + dependencies: + '@babel/core': 7.24.4 + '@react-native/babel-preset': 0.73.21(@babel/core@7.24.4)(@babel/preset-env@7.24.4) + hermes-parser: 0.15.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@babel/preset-env' + - supports-color + dev: true - /@protobufjs/utf8@1.1.0: - resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} - dev: false + /@react-native/normalize-colors@0.73.2: + resolution: {integrity: sha512-bRBcb2T+I88aG74LMVHaKms2p/T8aQd8+BZ7LuuzXlRfog1bMWWn/C5i0HVuvW4RPtXQYgIlGiXVDy9Ir1So/w==} + dev: true + + /@react-native/virtualized-lists@0.73.4(react-native@0.73.6): + resolution: {integrity: sha512-HpmLg1FrEiDtrtAbXiwCgXFYyloK/dOIPIuWW3fsqukwJEWAiTzm1nXGJ7xPU5XTHiWZ4sKup5Ebaj8z7iyWog==} + engines: {node: '>=18'} + peerDependencies: + react-native: '*' + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react-native: 0.73.6(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0) + dev: true /@rollup/plugin-commonjs@25.0.7(rollup@4.14.1): resolution: {integrity: sha512-nEvcR+LRjEjsaSsc4x3XZfCCvZIaSMenZu/OiwOKGN2UhQpAYI7ru7czFvyWbErlpoGjnSX3D5Ch5FcMA3kRWQ==} @@ -6203,16 +7870,46 @@ packages: dev: true optional: true + /@sideway/address@4.1.5: + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + dependencies: + '@hapi/hoek': 9.3.0 + dev: true + + /@sideway/formula@3.0.1: + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + dev: true + + /@sideway/pinpoint@2.0.0: + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + dev: true + /@sinclair/typebox@0.24.51: resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} dev: true + /@sinclair/typebox@0.27.8: + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + dev: true + /@sinonjs/commons@1.8.6: resolution: {integrity: sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==} dependencies: type-detect: 4.0.8 dev: true + /@sinonjs/commons@3.0.1: + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + dependencies: + type-detect: 4.0.8 + dev: true + + /@sinonjs/fake-timers@10.3.0: + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + dependencies: + '@sinonjs/commons': 3.0.1 + dev: true + /@sinonjs/fake-timers@9.1.2: resolution: {integrity: sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==} dependencies: @@ -6659,6 +8356,12 @@ packages: resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} dev: true + /@types/yargs@15.0.19: + resolution: {integrity: sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==} + dependencies: + '@types/yargs-parser': 21.0.0 + dev: true + /@types/yargs@16.0.5: resolution: {integrity: sha512-AxO/ADJOBFJScHbWhq2xAhlWP24rY4aCEG/NFaMvbT3X2MgRsLjhjQwsn0Zi5zn0LG9jUhCCZMeX9Dkuw6k+vQ==} dependencies: @@ -6938,6 +8641,10 @@ packages: require-from-string: 2.0.2 uri-js: 4.4.1 + /anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + dev: true + /ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -6950,6 +8657,19 @@ packages: type-fest: 0.21.3 dev: true + /ansi-fragments@0.2.1: + resolution: {integrity: sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==} + dependencies: + colorette: 1.4.0 + slice-ansi: 2.1.0 + strip-ansi: 5.2.0 + dev: true + + /ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + dev: true + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -6992,6 +8712,10 @@ packages: picomatch: 2.3.1 dev: true + /appdirsjs@1.2.7: + resolution: {integrity: sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==} + dev: true + /aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} dev: true @@ -7064,6 +8788,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + dev: true + /asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} dependencies: @@ -7072,11 +8800,27 @@ packages: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + /ast-types@0.15.2: + resolution: {integrity: sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==} + engines: {node: '>=4'} + dependencies: + tslib: 2.6.2 + dev: true + + /astral-regex@1.0.0: + resolution: {integrity: sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==} + engines: {node: '>=4'} + dev: true + /astring@1.8.6: resolution: {integrity: sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==} hasBin: true dev: true + /async-limiter@1.0.1: + resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} + dev: true + /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -7143,6 +8887,14 @@ packages: /b4a@1.6.4: resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==} + /babel-core@7.0.0-bridge.0(@babel/core@7.24.4): + resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.4 + dev: true + /babel-jest@28.1.3(@babel/core@7.18.6): resolution: {integrity: sha512-epUaPOEWMk3cWX0M/sPvCHHCe9fMFAa/9hXEgKP8nFfNl/jlGkE9ucq9NqkZGXLDduCJYS0UvSlPUwC0S+rH6Q==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -7184,6 +8936,50 @@ packages: '@types/babel__traverse': 7.20.1 dev: true + /babel-plugin-polyfill-corejs2@0.4.10(@babel/core@7.24.4): + resolution: {integrity: sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/core': 7.24.4 + '@babel/helper-define-polyfill-provider': 0.6.1(@babel/core@7.24.4) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-polyfill-corejs3@0.10.4(@babel/core@7.24.4): + resolution: {integrity: sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-define-polyfill-provider': 0.6.1(@babel/core@7.24.4) + core-js-compat: 3.36.1 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-polyfill-regenerator@0.6.1(@babel/core@7.24.4): + resolution: {integrity: sha512-JfTApdE++cgcTWjsiCQlLyFBMbTUft9ja17saCc93lgV33h4tuCVj7tlvu//qpLwaG+3yEz7/KhahGrUMkVq9g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-define-polyfill-provider': 0.6.1(@babel/core@7.24.4) + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.24.4): + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + dependencies: + '@babel/plugin-syntax-flow': 7.24.1(@babel/core@7.24.4) + transitivePeerDependencies: + - '@babel/core' + dev: true + /babel-preset-current-node-syntax@1.0.1(@babel/core@7.18.6): resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==} peerDependencies: @@ -7428,6 +9224,25 @@ packages: function-bind: 1.1.1 get-intrinsic: 1.2.1 + /caller-callsite@2.0.0: + resolution: {integrity: sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==} + engines: {node: '>=4'} + dependencies: + callsites: 2.0.0 + dev: true + + /caller-path@2.0.0: + resolution: {integrity: sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A==} + engines: {node: '>=4'} + dependencies: + caller-callsite: 2.0.0 + dev: true + + /callsites@2.0.0: + resolution: {integrity: sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==} + engines: {node: '>=4'} + dev: true + /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -7548,6 +9363,36 @@ packages: engines: {node: '>=10'} dev: true + /chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + dependencies: + '@types/node': 18.19.24 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + dev: true + + /chromium-edge-launcher@1.0.0: + resolution: {integrity: sha512-pgtgjNKZ7i5U++1g1PWv75umkHvhVTDOQIZ+sjeUX9483S7Y6MUvO0lrd7ShGlQlFHMN4SwKTCq/X8hWrbv2KA==} + dependencies: + '@types/node': 18.19.24 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + rimraf: 3.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + dev: true + /ci-info@3.8.0: resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} engines: {node: '>=8'} @@ -7561,6 +9406,18 @@ packages: engines: {node: '>=6'} dev: true + /cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + dependencies: + restore-cursor: 3.1.0 + dev: true + + /cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + dev: true + /cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} dependencies: @@ -7578,6 +9435,15 @@ packages: wrap-ansi: 7.0.0 dev: true + /clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + dev: true + /clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -7641,6 +9507,10 @@ packages: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} dev: true + /colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + dev: true + /colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} dev: true @@ -7651,6 +9521,10 @@ packages: dependencies: delayed-stream: 1.0.0 + /command-exists@1.2.9: + resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==} + dev: true + /commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} dev: true @@ -7670,6 +9544,11 @@ packages: engines: {node: ^12.20.0 || >=14} dev: false + /commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + dev: true + /commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} dev: true @@ -7704,6 +9583,18 @@ packages: source-map: 0.6.1 dev: true + /connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: true + /console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} dev: true @@ -7738,6 +9629,16 @@ packages: engines: {node: '>= 0.6'} dev: false + /core-js-compat@3.36.1: + resolution: {integrity: sha512-Dk997v9ZCt3X/npqzyGdTlq6t7lDBhZwGvV94PKzDArjp7BTRm7WlDAXYd/OWdeFHO8OChQYRJNJvUCqCbrtKA==} + dependencies: + browserslist: 4.23.0 + dev: true + + /core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + dev: true + /cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -7745,6 +9646,16 @@ packages: object-assign: 4.1.1 vary: 1.1.2 + /cosmiconfig@5.2.1: + resolution: {integrity: sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==} + engines: {node: '>=4'} + dependencies: + import-fresh: 2.0.0 + is-directory: 0.3.1 + js-yaml: 3.14.1 + parse-json: 4.0.0 + dev: true + /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true @@ -8071,6 +9982,10 @@ packages: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} dev: true + /denodeify@1.2.1: + resolution: {integrity: sha512-KNTihKNmQENUZeKu5fzfpzRqR5S2VMp4gl9RFHiWzj9DfvYQPMJ6XHKNaQxaGCXwPk6y9yme3aUoaiAe+KX+vg==} + dev: true + /denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -8079,6 +9994,15 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + /deprecated-react-native-prop-types@5.0.0: + resolution: {integrity: sha512-cIK8KYiiGVOFsKdPMmm1L3tA/Gl+JopXL6F5+C7x39MyPsQYnP57Im/D6bNUzcborD7fcMwiwZqcBdBXXZucYQ==} + engines: {node: '>=18'} + dependencies: + '@react-native/normalize-colors': 0.73.2 + invariant: 2.2.4 + prop-types: 15.8.1 + dev: true + /destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -8253,6 +10177,12 @@ packages: engines: {node: '>=6'} dev: true + /envinfo@7.12.0: + resolution: {integrity: sha512-Iw9rQJBGpJRd3rwXm9ft/JiGoAZmLxxJZELYDQoPRZ4USVhkKtIcNBPw6U+/K2mBpaqM25JSV6Yl4Az9vO2wJg==} + engines: {node: '>=4'} + hasBin: true + dev: true + /err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} dev: true @@ -8263,6 +10193,20 @@ packages: is-arrayish: 0.2.1 dev: true + /error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + dependencies: + stackframe: 1.3.4 + dev: true + + /errorhandler@1.5.1: + resolution: {integrity: sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A==} + engines: {node: '>= 0.8'} + dependencies: + accepts: 1.3.8 + escape-html: 1.0.3 + dev: true + /es-abstract@1.22.1: resolution: {integrity: sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==} engines: {node: '>= 0.4'} @@ -8921,6 +10865,13 @@ packages: strnum: 1.0.5 dev: false + /fast-xml-parser@4.3.6: + resolution: {integrity: sha512-M2SovcRxD4+vC493Uc2GZVcZaj66CCJhWurC4viynVSTvrpErCShNcDz1lAho6n9REQKvL/ll4A4/fw6Y9z8nw==} + hasBin: true + dependencies: + strnum: 1.0.5 + dev: true + /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: @@ -8956,6 +10907,21 @@ packages: dependencies: to-regex-range: 5.0.1 + /finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: true + /finalhandler@1.2.0: resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} engines: {node: '>= 0.8'} @@ -8970,6 +10936,22 @@ packages: transitivePeerDependencies: - supports-color + /find-cache-dir@2.1.0: + resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} + engines: {node: '>=6'} + dependencies: + commondir: 1.0.1 + make-dir: 2.1.0 + pkg-dir: 3.0.0 + dev: true + + /find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + dependencies: + locate-path: 3.0.0 + dev: true + /find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -9010,6 +10992,15 @@ packages: resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} dev: true + /flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + dev: true + + /flow-parser@0.206.0: + resolution: {integrity: sha512-HVzoK3r6Vsg+lKvlIZzaWNBVai+FXTX1wdYhz/wVlH13tb/gOdLXmlTqy6odmTBhT5UoWUbq0k8263Qhr9d88w==} + engines: {node: '>=0.4.0'} + dev: true + /follow-redirects@1.15.2: resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} engines: {node: '>=4.0'} @@ -9389,6 +11380,33 @@ packages: readable-stream: 3.6.2 dev: true + /hermes-estree@0.15.0: + resolution: {integrity: sha512-lLYvAd+6BnOqWdnNbP/Q8xfl8LOGw4wVjfrNd9Gt8eoFzhNBRVD95n4l2ksfMVOoxuVyegs85g83KS9QOsxbVQ==} + dev: true + + /hermes-estree@0.20.1: + resolution: {integrity: sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==} + dev: true + + /hermes-parser@0.15.0: + resolution: {integrity: sha512-Q1uks5rjZlE9RjMMjSUCkGrEIPI5pKJILeCtK1VmTj7U4pf3wVPoo+cxfu+s4cBAPy2JzikIIdCZgBoR6x7U1Q==} + dependencies: + hermes-estree: 0.15.0 + dev: true + + /hermes-parser@0.20.1: + resolution: {integrity: sha512-BL5P83cwCogI8D7rrDCgsFY0tdYUtmFP9XaXtl2IQjC+2Xo+4okjfXintlTxcIwl4qeGddEl28Z11kbVIw0aNA==} + dependencies: + hermes-estree: 0.20.1 + dev: true + + /hermes-profile-transformer@0.0.6: + resolution: {integrity: sha512-cnN7bQUm65UWOy6cbGcCcZ3rpwW8Q/j4OP5aWRhEry4Z2t2aR1cjrbp0BS+KiBN0smvP1caBgAuxutvyvJILzQ==} + engines: {node: '>=8'} + dependencies: + source-map: 0.7.4 + dev: true + /hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} dependencies: @@ -9516,6 +11534,14 @@ packages: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} + /image-size@1.1.1: + resolution: {integrity: sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==} + engines: {node: '>=16.x'} + hasBin: true + dependencies: + queue: 6.0.2 + dev: true + /import-cwd@3.0.0: resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==} engines: {node: '>=8'} @@ -9523,6 +11549,14 @@ packages: import-from: 3.0.0 dev: true + /import-fresh@2.0.0: + resolution: {integrity: sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==} + engines: {node: '>=4'} + dependencies: + caller-path: 2.0.0 + resolve-from: 3.0.0 + dev: true + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -9596,6 +11630,12 @@ packages: side-channel: 1.0.4 dev: true + /invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + dev: true + /ioredis@5.3.2: resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} engines: {node: '>=12.22.0'} @@ -9692,10 +11732,26 @@ packages: has-tostringtag: 1.0.0 dev: true + /is-directory@0.3.1: + resolution: {integrity: sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==} + engines: {node: '>=0.10.0'} + dev: true + + /is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + dev: true + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + /is-fullwidth-code-point@2.0.0: + resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} + engines: {node: '>=4'} + dev: true + /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -9711,6 +11767,11 @@ packages: dependencies: is-extglob: 2.1.1 + /is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + dev: true + /is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} dev: true @@ -9745,6 +11806,13 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + dependencies: + isobject: 3.0.1 + dev: true + /is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} dependencies: @@ -9798,6 +11866,11 @@ packages: which-typed-array: 1.1.11 dev: true + /is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + dev: true + /is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} dependencies: @@ -9809,9 +11882,20 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-wsl@1.1.0: + resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==} + engines: {node: '>=4'} + dev: true + + /is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + dev: true + /isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - dev: false /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -9824,6 +11908,11 @@ packages: resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} dev: false + /isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + dev: true + /istanbul-lib-coverage@3.2.0: resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} engines: {node: '>=8'} @@ -10027,11 +12116,28 @@ packages: jest-util: 28.1.3 dev: true + /jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 18.19.24 + jest-mock: 29.7.0 + jest-util: 29.7.0 + dev: true + /jest-get-type@28.0.2: resolution: {integrity: sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} dev: true + /jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + /jest-haste-map@28.1.3: resolution: {integrity: sha512-3S+RQWDXccXDKSWnkHa/dPwt+2qwA8CJzR61w3FoYCvoo3Pn8tvGcysmMF0Bj0EX5RYvAI2EIvC57OmotfdtKA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -10084,6 +12190,21 @@ packages: stack-utils: 2.0.6 dev: true + /jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.24.2 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + dev: true + /jest-mock@28.1.3: resolution: {integrity: sha512-o3J2jr6dMMWYVH4Lh/NKmDXdosrsJgi4AviS8oXLujcjpCMBb1FMsblDnOXKZKfSiHLxYub1eS0IHuRXsio9eA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -10092,6 +12213,15 @@ packages: '@types/node': 18.19.24 dev: true + /jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 18.19.24 + jest-util: 29.7.0 + dev: true + /jest-pnp-resolver@1.2.3(jest-resolve@28.1.3): resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} engines: {node: '>=6'} @@ -10236,6 +12366,18 @@ packages: picomatch: 2.3.1 dev: true + /jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 18.19.24 + chalk: 4.1.2 + ci-info: 3.8.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + dev: true + /jest-validate@28.1.3: resolution: {integrity: sha512-SZbOGBWEsaTxBGCOpsRWlXlvNkvTkY0XxRfh7zYmvd8uL5Qzyg0CHAXiXKROflh801quA6+/DsT4ODDthOC/OA==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -10248,6 +12390,18 @@ packages: pretty-format: 28.1.3 dev: true + /jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + dev: true + /jest-watcher@28.1.3: resolution: {integrity: sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -10271,6 +12425,16 @@ packages: supports-color: 8.1.1 dev: true + /jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/node': 18.19.24 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + /jest@28.1.2(@types/node@18.19.24)(ts-node@10.8.2): resolution: {integrity: sha512-Tuf05DwLeCh2cfWCQbcz9UxldoDyiR1E9Igaei5khjonKncYdc6LDfynKCEWozK0oLE3GD+xKAo2u8x/0s6GOg==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -10296,6 +12460,16 @@ packages: hasBin: true dev: true + /joi@17.12.3: + resolution: {integrity: sha512-2RRziagf555owrm9IRVtdKynOBeITiDpuZqIpgwqXShPncPKNiRQoiGsl/T8SQdq+8ugRzH2LqY67irr2y/d+g==} + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + dev: true + /jose@4.15.4: resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} dev: true @@ -10315,7 +12489,6 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: true /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} @@ -10332,12 +12505,59 @@ packages: argparse: 2.0.1 dev: true + /jsc-android@250231.0.0: + resolution: {integrity: sha512-rS46PvsjYmdmuz1OAWXY/1kCYG7pnf1TBqeTiOJr1iDz7s5DLxxC9n/ZMknLDxzYzNVfI7R95MH10emSSG1Wuw==} + dev: true + + /jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + dev: true + + /jscodeshift@0.14.0(@babel/preset-env@7.24.4): + resolution: {integrity: sha512-7eCC1knD7bLUPuSCwXsMZUH51O8jIcoVyKtI6P0XM0IVzlGjckPy3FIwQlorzbN0Sg79oK+RlohN32Mqf/lrYA==} + hasBin: true + peerDependencies: + '@babel/preset-env': ^7.1.6 + dependencies: + '@babel/core': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.24.4) + '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.24.4) + '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.24.4) + '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.24.4) + '@babel/preset-env': 7.24.4(@babel/core@7.24.4) + '@babel/preset-flow': 7.24.1(@babel/core@7.24.4) + '@babel/preset-typescript': 7.24.1(@babel/core@7.24.4) + '@babel/register': 7.23.7(@babel/core@7.24.4) + babel-core: 7.0.0-bridge.0(@babel/core@7.24.4) + chalk: 4.1.2 + flow-parser: 0.206.0 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + neo-async: 2.6.2 + node-dir: 0.1.17 + recast: 0.21.5 + temp: 0.8.4 + write-file-atomic: 2.4.3 + transitivePeerDependencies: + - supports-color + dev: true + + /jsesc@0.5.0: + resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} + hasBin: true + dev: true + /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} engines: {node: '>=4'} hasBin: true dev: true + /json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + dev: true + /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true @@ -10458,6 +12678,15 @@ packages: type-check: 0.4.0 dev: true + /lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + dependencies: + debug: 2.6.9 + marky: 1.2.5 + transitivePeerDependencies: + - supports-color + dev: true + /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -10491,6 +12720,14 @@ packages: engines: {node: '>= 12.13.0'} dev: true + /locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + dev: true + /locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -10509,6 +12746,10 @@ packages: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} dev: true + /lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + dev: true + /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -10567,9 +12808,30 @@ packages: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} dev: true + /lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + dev: true + /lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + /log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + dev: true + + /logkitty@0.7.1: + resolution: {integrity: sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==} + hasBin: true + dependencies: + ansi-fragments: 0.2.1 + dayjs: 1.11.10 + yargs: 15.4.1 + dev: true + /long@5.2.3: resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} dev: false @@ -10579,7 +12841,6 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 - dev: true /lru-cache@10.2.0: resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} @@ -10615,6 +12876,14 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + dependencies: + pify: 4.0.1 + semver: 5.7.2 + dev: true + /make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -10667,6 +12936,10 @@ packages: engines: {node: '>=8'} dev: true + /marky@1.2.5: + resolution: {integrity: sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==} + dev: true + /mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} dev: true @@ -10675,6 +12948,10 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: true + /meow@6.1.1: resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==} engines: {node: '>=8'} @@ -10707,6 +12984,217 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + /metro-babel-transformer@0.80.8: + resolution: {integrity: sha512-TTzNwRZb2xxyv4J/+yqgtDAP2qVqH3sahsnFu6Xv4SkLqzrivtlnyUbaeTdJ9JjtADJUEjCbgbFgUVafrXdR9Q==} + engines: {node: '>=18'} + dependencies: + '@babel/core': 7.24.4 + hermes-parser: 0.20.1 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /metro-cache-key@0.80.8: + resolution: {integrity: sha512-qWKzxrLsRQK5m3oH8ePecqCc+7PEhR03cJE6Z6AxAj0idi99dHOSitTmY0dclXVB9vP2tQIAE8uTd8xkYGk8fA==} + engines: {node: '>=18'} + dev: true + + /metro-cache@0.80.8: + resolution: {integrity: sha512-5svz+89wSyLo7BxdiPDlwDTgcB9kwhNMfNhiBZPNQQs1vLFXxOkILwQiV5F2EwYT9DEr6OPZ0hnJkZfRQ8lDYQ==} + engines: {node: '>=18'} + dependencies: + metro-core: 0.80.8 + rimraf: 3.0.2 + dev: true + + /metro-config@0.80.8: + resolution: {integrity: sha512-VGQJpfJawtwRzGzGXVUoohpIkB0iPom4DmSbAppKfumdhtLA8uVeEPp2GM61kL9hRvdbMhdWA7T+hZFDlo4mJA==} + engines: {node: '>=18'} + dependencies: + connect: 3.7.0 + cosmiconfig: 5.2.1 + jest-validate: 29.7.0 + metro: 0.80.8 + metro-cache: 0.80.8 + metro-core: 0.80.8 + metro-runtime: 0.80.8 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /metro-core@0.80.8: + resolution: {integrity: sha512-g6lud55TXeISRTleW6SHuPFZHtYrpwNqbyFIVd9j9Ofrb5IReiHp9Zl8xkAfZQp8v6ZVgyXD7c130QTsCz+vBw==} + engines: {node: '>=18'} + dependencies: + lodash.throttle: 4.1.1 + metro-resolver: 0.80.8 + dev: true + + /metro-file-map@0.80.8: + resolution: {integrity: sha512-eQXMFM9ogTfDs2POq7DT2dnG7rayZcoEgRbHPXvhUWkVwiKkro2ngcBE++ck/7A36Cj5Ljo79SOkYwHaWUDYDw==} + engines: {node: '>=18'} + dependencies: + anymatch: 3.1.3 + debug: 2.6.9 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.5 + node-abort-controller: 3.1.1 + nullthrows: 1.1.1 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - supports-color + dev: true + + /metro-minify-terser@0.80.8: + resolution: {integrity: sha512-y8sUFjVvdeUIINDuW1sejnIjkZfEF+7SmQo0EIpYbWmwh+kq/WMj74yVaBWuqNjirmUp1YNfi3alT67wlbBWBQ==} + engines: {node: '>=18'} + dependencies: + terser: 5.30.3 + dev: true + + /metro-resolver@0.80.8: + resolution: {integrity: sha512-JdtoJkP27GGoZ2HJlEsxs+zO7jnDUCRrmwXJozTlIuzLHMRrxgIRRby9fTCbMhaxq+iA9c+wzm3iFb4NhPmLbQ==} + engines: {node: '>=18'} + dev: true + + /metro-runtime@0.80.8: + resolution: {integrity: sha512-2oScjfv6Yb79PelU1+p8SVrCMW9ZjgEiipxq7jMRn8mbbtWzyv3g8Mkwr+KwOoDFI/61hYPUbY8cUnu278+x1g==} + engines: {node: '>=18'} + dependencies: + '@babel/runtime': 7.22.10 + dev: true + + /metro-source-map@0.80.8: + resolution: {integrity: sha512-+OVISBkPNxjD4eEKhblRpBf463nTMk3KMEeYS8Z4xM/z3qujGJGSsWUGRtH27+c6zElaSGtZFiDMshEb8mMKQg==} + engines: {node: '>=18'} + dependencies: + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + invariant: 2.2.4 + metro-symbolicate: 0.80.8 + nullthrows: 1.1.1 + ob1: 0.80.8 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: true + + /metro-symbolicate@0.80.8: + resolution: {integrity: sha512-nwhYySk79jQhwjL9QmOUo4wS+/0Au9joEryDWw7uj4kz2yvw1uBjwmlql3BprQCBzRdB3fcqOP8kO8Es+vE31g==} + engines: {node: '>=18'} + hasBin: true + dependencies: + invariant: 2.2.4 + metro-source-map: 0.80.8 + nullthrows: 1.1.1 + source-map: 0.5.7 + through2: 2.0.5 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: true + + /metro-transform-plugins@0.80.8: + resolution: {integrity: sha512-sSu8VPL9Od7w98MftCOkQ1UDeySWbsIAS5I54rW22BVpPnI3fQ42srvqMLaJUQPjLehUanq8St6OMBCBgH/UWw==} + engines: {node: '>=18'} + dependencies: + '@babel/core': 7.24.4 + '@babel/generator': 7.24.4 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /metro-transform-worker@0.80.8: + resolution: {integrity: sha512-+4FG3TQk3BTbNqGkFb2uCaxYTfsbuFOCKMMURbwu0ehCP8ZJuTUramkaNZoATS49NSAkRgUltgmBa4YaKZ5mqw==} + engines: {node: '>=18'} + dependencies: + '@babel/core': 7.24.4 + '@babel/generator': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + metro: 0.80.8 + metro-babel-transformer: 0.80.8 + metro-cache: 0.80.8 + metro-cache-key: 0.80.8 + metro-minify-terser: 0.80.8 + metro-source-map: 0.80.8 + metro-transform-plugins: 0.80.8 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /metro@0.80.8: + resolution: {integrity: sha512-in7S0W11mg+RNmcXw+2d9S3zBGmCARDxIwoXJAmLUQOQoYsRP3cpGzyJtc7WOw8+FXfpgXvceD0u+PZIHXEL7g==} + engines: {node: '>=18'} + hasBin: true + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/core': 7.24.4 + '@babel/generator': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + accepts: 1.3.8 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 2.6.9 + denodeify: 1.2.1 + error-stack-parser: 2.1.4 + graceful-fs: 4.2.11 + hermes-parser: 0.20.1 + image-size: 1.1.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.80.8 + metro-cache: 0.80.8 + metro-cache-key: 0.80.8 + metro-config: 0.80.8 + metro-core: 0.80.8 + metro-file-map: 0.80.8 + metro-resolver: 0.80.8 + metro-runtime: 0.80.8 + metro-source-map: 0.80.8 + metro-symbolicate: 0.80.8 + metro-transform-plugins: 0.80.8 + metro-transform-worker: 0.80.8 + mime-types: 2.1.35 + node-fetch: 2.7.0 + nullthrows: 1.1.1 + rimraf: 3.0.2 + serialize-error: 2.1.0 + source-map: 0.5.7 + strip-ansi: 6.0.1 + throat: 5.0.0 + ws: 7.5.9 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} engines: {node: '>=8.6'} @@ -10729,6 +13217,12 @@ packages: engines: {node: '>=4'} hasBin: true + /mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + dev: true + /mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -10851,6 +13345,13 @@ packages: /mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + /mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: true + /mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -10910,6 +13411,11 @@ packages: /neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + /nocache@3.0.4: + resolution: {integrity: sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==} + engines: {node: '>=12.0.0'} + dev: true + /node-abi@3.47.0: resolution: {integrity: sha512-2s6B2CWZM//kPgwnuI0KrYwNjfdByE25zvAaEpq9IH4zcNsarH8Ihu/UuX6XMPEogDAxkuUFeZn60pXNHAqn3A==} engines: {node: '>=10'} @@ -10925,11 +13431,17 @@ packages: /node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - dev: false /node-addon-api@6.1.0: resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + /node-dir@0.1.17: + resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==} + engines: {node: '>= 0.10.5'} + dependencies: + minimatch: 3.1.2 + dev: true + /node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -10993,6 +13505,11 @@ packages: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} dev: true + /node-stream-zip@1.15.0: + resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} + engines: {node: '>=0.12.0'} + dev: true + /nodemailer-html-to-text@3.2.0: resolution: {integrity: sha512-RJUC6640QV1PzTHHapOrc6IzrAJUZtk2BdVdINZ9VTLm+mcQNyBO9LYyhrnufkzqiD9l8hPLJ97rSyK4WanPNg==} engines: {node: '>= 10.23.0'} @@ -11058,6 +13575,15 @@ packages: boolbase: 1.0.0 dev: true + /nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + dev: true + + /ob1@0.80.8: + resolution: {integrity: sha512-QHJQk/lXMmAW8I7AIM3in1MSlwe1umR72Chhi8B7Xnq6mzjhBKkA6Fy/zAhQnGkA4S912EPCEvTij5yh+EQTAA==} + engines: {node: '>=18'} + dev: true + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -11092,6 +13618,13 @@ packages: /on-exit-leak-free@2.1.0: resolution: {integrity: sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==} + /on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: true + /on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -11117,6 +13650,21 @@ packages: mimic-fn: 2.1.0 dev: true + /open@6.4.0: + resolution: {integrity: sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==} + engines: {node: '>=8'} + dependencies: + is-wsl: 1.1.0 + dev: true + + /open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + dev: true + /opentelemetry-plugin-better-sqlite3@1.1.0(better-sqlite3@9.4.5): resolution: {integrity: sha512-yd+mgaB5W5JxzcQt9TvX1VIrusqtbbeuxSoZ6KQe4Ra0J/Kqkp6kz7dg0VQUU5+cenOWkza6xtvsT0KGXI03HA==} peerDependencies: @@ -11148,6 +13696,21 @@ packages: type-check: 0.4.0 dev: true + /ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + dev: true + /os-tmpdir@1.0.2: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} @@ -11181,6 +13744,13 @@ packages: dependencies: yocto-queue: 0.1.0 + /p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + dependencies: + p-limit: 2.3.0 + dev: true + /p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -11241,6 +13811,14 @@ packages: callsites: 3.1.0 dev: true + /parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + dependencies: + error-ex: 1.3.2 + json-parse-better-errors: 1.0.2 + dev: true + /parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -11259,6 +13837,11 @@ packages: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} dev: false + /path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + dev: true + /path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -11434,6 +14017,13 @@ packages: engines: {node: '>= 6'} dev: true + /pkg-dir@3.0.0: + resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} + engines: {node: '>=6'} + dependencies: + find-up: 3.0.0 + dev: true + /pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} @@ -11967,6 +14557,16 @@ packages: engines: {node: '>=14'} hasBin: true + /pretty-format@26.6.2: + resolution: {integrity: sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==} + engines: {node: '>= 10'} + dependencies: + '@jest/types': 26.6.2 + ansi-regex: 5.0.1 + ansi-styles: 4.3.0 + react-is: 17.0.2 + dev: true + /pretty-format@28.1.3: resolution: {integrity: sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==} engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} @@ -11977,6 +14577,19 @@ packages: react-is: 18.2.0 dev: true + /pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.2.0 + dev: true + + /process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + dev: true + /process-warning@2.2.0: resolution: {integrity: sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg==} @@ -12006,6 +14619,12 @@ packages: engines: {node: '>=0.12'} dev: true + /promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + dependencies: + asap: 2.0.6 + dev: true + /prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -12014,6 +14633,14 @@ packages: sisteransi: 1.0.5 dev: true + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + dev: true + /protobufjs@7.2.5: resolution: {integrity: sha512-gGXRSXvxQ7UiPgfw8gevrfRWcTlSbOFg+p/N+JVJEK5VhueL2miT6qTymqAmjr1Q5WbOCyJbyrk6JfWKwlFn6A==} engines: {node: '>=12.0.0'} @@ -12081,6 +14708,12 @@ packages: /queue-tick@1.0.1: resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + /queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + dependencies: + inherits: 2.0.4 + dev: true + /quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -12120,6 +14753,16 @@ packages: minimist: 1.2.8 strip-json-comments: 2.0.1 + /react-devtools-core@4.28.5: + resolution: {integrity: sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==} + dependencies: + shell-quote: 1.8.1 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -12130,21 +14773,93 @@ packages: scheduler: 0.23.0 dev: true + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dev: true + + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} dev: true + /react-native@0.73.6(@babel/core@7.24.4)(@babel/preset-env@7.24.4)(react@18.2.0): + resolution: {integrity: sha512-oqmZe8D2/VolIzSPZw+oUd6j/bEmeRHwsLn1xLA5wllEYsZ5zNuMsDus235ONOnCRwexqof/J3aztyQswSmiaA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + react: 18.2.0 + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native-community/cli': 12.3.6 + '@react-native-community/cli-platform-android': 12.3.6 + '@react-native-community/cli-platform-ios': 12.3.6 + '@react-native/assets-registry': 0.73.1 + '@react-native/codegen': 0.73.3(@babel/preset-env@7.24.4) + '@react-native/community-cli-plugin': 0.73.17(@babel/core@7.24.4)(@babel/preset-env@7.24.4) + '@react-native/gradle-plugin': 0.73.4 + '@react-native/js-polyfills': 0.73.1 + '@react-native/normalize-colors': 0.73.2 + '@react-native/virtualized-lists': 0.73.4(react-native@0.73.6) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + base64-js: 1.5.1 + chalk: 4.1.2 + deprecated-react-native-prop-types: 5.0.0 + event-target-shim: 5.0.1 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + jsc-android: 250231.0.0 + memoize-one: 5.2.1 + metro-runtime: 0.80.8 + metro-source-map: 0.80.8 + mkdirp: 0.5.6 + nullthrows: 1.1.1 + pretty-format: 26.6.2 + promise: 8.3.0 + react: 18.2.0 + react-devtools-core: 4.28.5 + react-refresh: 0.14.0 + react-shallow-renderer: 16.15.0(react@18.2.0) + regenerator-runtime: 0.13.11 + scheduler: 0.24.0-canary-efb381bbf-20230505 + stacktrace-parser: 0.1.10 + whatwg-fetch: 3.6.20 + ws: 6.2.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@babel/core' + - '@babel/preset-env' + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + /react-refresh@0.14.0: resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} engines: {node: '>=0.10.0'} dev: true + /react-shallow-renderer@16.15.0(react@18.2.0): + resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + object-assign: 4.1.1 + react: 18.2.0 + react-is: 18.2.0 + dev: true + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 - dev: true /read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -12181,6 +14896,18 @@ packages: strip-bom: 3.0.0 dev: true + /readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + dev: true + /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -12212,10 +14939,24 @@ packages: picomatch: 2.3.1 dev: true + /readline@1.3.0: + resolution: {integrity: sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==} + dev: true + /real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + /recast@0.21.5: + resolution: {integrity: sha512-hjMmLaUXAm1hIuTqOdeYObMslq/q+Xff6QE3Y2P+uoHAg2nmVlLBps2hzh1UJDdMtDTMXOFewK6ky51JQIeECg==} + engines: {node: '>= 4'} + dependencies: + ast-types: 0.15.2 + esprima: 4.0.1 + source-map: 0.6.1 + tslib: 2.6.2 + dev: true + /redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -12234,10 +14975,31 @@ packages: dependencies: redis-errors: 1.2.0 + /regenerate-unicode-properties@10.1.1: + resolution: {integrity: sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==} + engines: {node: '>=4'} + dependencies: + regenerate: 1.4.2 + dev: true + + /regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + dev: true + + /regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + dev: true + /regenerator-runtime@0.14.0: resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} dev: true + /regenerator-transform@0.15.2: + resolution: {integrity: sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==} + dependencies: + '@babel/runtime': 7.22.10 + dev: true + /regexp.prototype.flags@1.5.0: resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==} engines: {node: '>= 0.4'} @@ -12247,6 +15009,25 @@ packages: functions-have-names: 1.2.3 dev: true + /regexpu-core@5.3.2: + resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==} + engines: {node: '>=4'} + dependencies: + '@babel/regjsgen': 0.8.0 + regenerate: 1.4.2 + regenerate-unicode-properties: 10.1.1 + regjsparser: 0.9.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.1.0 + dev: true + + /regjsparser@0.9.1: + resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==} + hasBin: true + dependencies: + jsesc: 0.5.0 + dev: true + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -12282,6 +15063,11 @@ packages: resolve-from: 5.0.0 dev: true + /resolve-from@3.0.0: + resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} + engines: {node: '>=4'} + dev: true + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -12305,6 +15091,14 @@ packages: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + /restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + /retry@0.10.1: resolution: {integrity: sha512-ZXUSQYTHdl3uS7IuCehYfMzKyIDBNoAuUblvy5oGO5UJSUTmStUUVPXbA9Qxd173Bgre53yCQczQuHgRWAdvJQ==} dev: false @@ -12326,6 +15120,13 @@ packages: /rfdc@1.3.0: resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} + /rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} hasBin: true @@ -12453,6 +15254,12 @@ packages: loose-envify: 1.4.0 dev: true + /scheduler@0.24.0-canary-efb381bbf-20230505: + resolution: {integrity: sha512-ABvovCDe/k9IluqSh4/ISoq8tIJnW8euVAWYt5j/bg6dRnqwQwiGO1F/V4AyK96NGF/FB04FhOUDuWj8IKfABA==} + dependencies: + loose-envify: 1.4.0 + dev: true + /scmp@2.1.0: resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==} dev: true @@ -12486,7 +15293,6 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: false /send@0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} @@ -12508,6 +15314,11 @@ packages: transitivePeerDependencies: - supports-color + /serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + dev: true + /serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} dependencies: @@ -12532,6 +15343,13 @@ packages: /setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + /shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + dependencies: + kind-of: 6.0.3 + dev: true + /sharp@0.32.6: resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} engines: {node: '>=14.15.0'} @@ -12568,6 +15386,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + /shell-quote@1.8.1: + resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + dev: true + /shimmer@1.2.1: resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} dev: false @@ -12616,6 +15438,15 @@ packages: engines: {node: '>=12'} dev: true + /slice-ansi@2.1.0: + resolution: {integrity: sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==} + engines: {node: '>=6'} + dependencies: + ansi-styles: 3.2.1 + astral-regex: 1.0.0 + is-fullwidth-code-point: 2.0.0 + dev: true + /smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -12681,6 +15512,11 @@ packages: source-map: 0.6.1 dev: true + /source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + dev: true + /source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -12688,7 +15524,6 @@ packages: /source-map@0.7.4: resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} engines: {node: '>= 8'} - dev: false /spawndamnit@2.0.0: resolution: {integrity: sha512-j4JKEcncSjFlqIwU5L/rp2N5SIPsdxaRsIv678+TZxZ0SRDJTm8JrxJMjE/XuiEZNEir3S8l0Fa3Ke339WI4qA==} @@ -12752,9 +15587,25 @@ packages: escape-string-regexp: 2.0.0 dev: true + /stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + dev: true + + /stacktrace-parser@0.1.10: + resolution: {integrity: sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==} + engines: {node: '>=6'} + dependencies: + type-fest: 0.7.1 + dev: true + /standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + /statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + dev: true + /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -12831,11 +15682,24 @@ packages: es-abstract: 1.22.1 dev: true + /string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + dependencies: + safe-buffer: 5.1.2 + dev: true + /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: safe-buffer: 5.2.1 + /strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + dependencies: + ansi-regex: 4.1.1 + dev: true + /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -12881,7 +15745,6 @@ packages: /strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} - dev: false /strtok3@6.3.0: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} @@ -12924,6 +15787,10 @@ packages: ts-interface-checker: 0.1.13 dev: true + /sudo-prompt@9.2.1: + resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} + dev: true + /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -13053,6 +15920,18 @@ packages: yallist: 4.0.0 dev: true + /temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + dev: true + + /temp@0.8.4: + resolution: {integrity: sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==} + engines: {node: '>=6.0.0'} + dependencies: + rimraf: 2.6.3 + dev: true + /term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -13108,6 +15987,17 @@ packages: dependencies: real-require: 0.2.0 + /throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + dev: true + + /through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + dev: true + /through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: false @@ -13294,6 +16184,11 @@ packages: engines: {node: '>=8'} dev: true + /type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + dev: true + /type-fest@0.8.1: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} @@ -13401,6 +16296,29 @@ packages: dependencies: '@fastify/busboy': 2.1.0 + /unicode-canonical-property-names-ecmascript@2.0.0: + resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} + engines: {node: '>=4'} + dev: true + + /unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.0 + unicode-property-aliases-ecmascript: 2.1.0 + dev: true + + /unicode-match-property-value-ecmascript@2.1.0: + resolution: {integrity: sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==} + engines: {node: '>=4'} + dev: true + + /unicode-property-aliases-ecmascript@2.1.0: + resolution: {integrity: sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==} + engines: {node: '>=4'} + dev: true + /unique-filename@2.0.1: resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -13534,6 +16452,10 @@ packages: fsevents: 2.3.3 dev: true + /vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + dev: true + /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: @@ -13550,6 +16472,10 @@ packages: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: true + /whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + dev: true + /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: @@ -13641,6 +16567,14 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + /write-file-atomic@2.4.3: + resolution: {integrity: sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==} + dependencies: + graceful-fs: 4.2.11 + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + dev: true + /write-file-atomic@4.0.2: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -13649,6 +16583,33 @@ packages: signal-exit: 3.0.7 dev: true + /ws@6.2.2: + resolution: {integrity: sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dependencies: + async-limiter: 1.0.1 + dev: true + + /ws@7.5.9: + resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + /ws@8.12.0: resolution: {integrity: sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==} engines: {node: '>=10.0.0'} diff --git a/tsconfig.json b/tsconfig.json index 81f72cfb29b..a16e8577273 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,7 @@ { "path": "./packages/oauth-client" }, { "path": "./packages/oauth-client-browser" }, { "path": "./packages/oauth-client-metadata" }, + { "path": "./packages/oauth-client-react-native" }, { "path": "./packages/oauth-provider" }, { "path": "./packages/oauth-provider-client-uri" }, { "path": "./packages/oauth-provider-client-fqdn" }, diff --git a/tsconfig/react-native.json b/tsconfig/react-native.json new file mode 100644 index 00000000000..2069f1fff2f --- /dev/null +++ b/tsconfig/react-native.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "types": ["react-native", "jest"], + "lib": [ + "es2019", + "es2020.bigint", + "es2020.date", + "es2020.number", + "es2020.promise", + "es2020.string", + "es2020.symbol.wellknown", + "es2021.promise", + "es2021.string", + "es2021.weakref", + "es2022.array", + "es2022.object", + "es2022.string" + ], + "allowJs": true, + "jsx": "react-native", + "noEmit": true, + "isolatedModules": true, + "strict": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} From 812b60f18d8b1b24df956a9ac9df2427eb1d5aa7 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 13:29:34 +0200 Subject: [PATCH 123/140] fix(fetch): avoid relying on .blob() --- packages/fetch/src/fetch-response.ts | 100 ++++++++++----------------- 1 file changed, 38 insertions(+), 62 deletions(-) diff --git a/packages/fetch/src/fetch-response.ts b/packages/fetch/src/fetch-response.ts index eb3499b7a50..0dd18177367 100644 --- a/packages/fetch/src/fetch-response.ts +++ b/packages/fetch/src/fetch-response.ts @@ -6,14 +6,12 @@ import { Json, ifObject, ifString } from './util.js' import { TransformedResponse } from './transformed-response.js' export type ResponseTranformer = Transformer +export type ResponseMessageGetter = Transformer -async function extractResponseMessage( - headers: Headers, - body?: Blob | null, -): Promise { - if (!body) return undefined +const extractResponseMessage: ResponseMessageGetter = async (response) => { + if (!response.body) return undefined - const contentType = headers.get('content-type') + const contentType = response.headers.get('content-type') if (!contentType) return undefined const mimeType = contentType.split(';')[0].trim() @@ -21,9 +19,9 @@ async function extractResponseMessage( try { if (mimeType === 'text/plain') { - return await body.text() + return await response.text() } else if (/^application\/(?:[^+]+\+)?json$/i.test(mimeType)) { - const json = await body.text().then(JSON.parse) + const json = await response.json() if (typeof json === 'string') return json @@ -45,45 +43,41 @@ async function extractResponseMessage( export class FetchResponseError extends FetchError { constructor( - statusCode: number, - message?: string, - readonly body?: Blob | null, - options?: FetchErrorOptions, + response: Response, + statusCode: number = response.status, + message: string = response.statusText, + options?: Omit, ) { - super(statusCode, message, options) + super(statusCode, message, { response, ...options }) } static async from( response: Response, - status = response.status, - customMessage?: string, - options?: FetchErrorOptions, + statusCode = response.status, + customMessage: string | ResponseMessageGetter = extractResponseMessage, + options?: Omit, ) { - // Make sure the body gets consumed as, in some environments (Node 👀), the - // response will not be GC'd. - const body = response.body - ? !response.bodyUsed - ? await response.blob() - : undefined - : null - const message = - customMessage ?? - (await extractResponseMessage(response.headers, body)) ?? - response.statusText + typeof customMessage === 'string' + ? customMessage + : typeof customMessage === 'function' + ? await customMessage(response) + : undefined - return new FetchResponseError(status, message, body, { - ...options, - response, - }) + // Make sure the body gets consumed as, in some environments (Node 👀), the + // response will not automatically be GC'd. + await response.body?.cancel() + + return new FetchResponseError(response, statusCode, message, options) } } -export function fetchOkProcessor(): ResponseTranformer { +export function fetchOkProcessor( + customMessage?: string | ResponseMessageGetter, +): ResponseTranformer { return async (response) => { if (response.ok) return response - - throw await FetchResponseError.from(response) + throw await FetchResponseError.from(response, undefined, customMessage) } } @@ -106,9 +100,7 @@ export async function fetchResponseMaxSize( if (contentLength) { const length = Number(contentLength) if (!(length < maxBytes)) { - const err = new FetchResponseError(502, 'Response too large', undefined, { - response, - }) + const err = new FetchResponseError(response, 502, 'Response too large') await response.body.cancel(err) throw err } @@ -124,11 +116,7 @@ export async function fetchResponseMaxSize( if ((bytesRead += chunk.length) <= maxBytes) { ctrl.enqueue(chunk) } else { - ctrl.error( - new FetchResponseError(502, 'Response too large', undefined, { - response, - }), - ) + ctrl.error(new FetchResponseError(response, 502, 'Response too large')) } }, }) @@ -185,35 +173,23 @@ export async function jsonTranformer( response: Response, ): Promise> { if (response.body === null) { - throw new FetchResponseError(502, 'No response body', null, { - response, - }) + throw new FetchResponseError(response, 502, 'No response body') } if (response.bodyUsed) { - throw new FetchResponseError(502, 'Response body already used', undefined, { - response, - }) + throw new FetchResponseError(response, 500, 'Response body already used') } - // Read as blob to allow throwing with the body in case on invalid JSON (for debugging/logging purposes mainly) - const body = await response.blob().catch(async (cause) => { - throw new FetchResponseError( - 502, - 'Failed to read response body', - undefined, - { response, cause }, - ) - }) - try { - const json = (await body.text().then(JSON.parse)) as T + const json = (await response.json()) as T return { response, json } } catch (cause) { - throw new FetchResponseError(502, 'Unable to parse response JSON', body, { + throw new FetchResponseError( response, - cause, - }) + 502, + 'Unable to parse response as JSON', + { cause }, + ) } } From 56aa24339375bc7287859f7dea86f0b7df72cced Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 13:58:50 +0200 Subject: [PATCH 124/140] docs(oauth-client-react-native): add implementation details --- .../src/react-native-key.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/oauth-client-react-native/src/react-native-key.ts b/packages/oauth-client-react-native/src/react-native-key.ts index 094ebe3fe87..3ce5e72115c 100644 --- a/packages/oauth-client-react-native/src/react-native-key.ts +++ b/packages/oauth-client-react-native/src/react-native-key.ts @@ -16,13 +16,9 @@ export class ReactNativeKey extends Key { kid: string, allowedAlgos: string[] = ['ES256'], ): Promise { - if (!allowedAlgos.includes('ES256')) { - throw new Error( - `None of the allowed algorithms (${allowedAlgos}) are supported (only ES256)`, - ) - } - - const privateJwk: Jwk = await OauthClientReactNative.createES256Jwk() + // Note: OauthClientReactNative.createJwk should throw if it supports none + // of the allowed algorithms + const privateJwk: Jwk = await OauthClientReactNative.createJwk(allowedAlgos) return new ReactNativeKey({ ...privateJwk, kid }) } @@ -34,6 +30,23 @@ export class ReactNativeKey extends Key { P extends VerifyPayload = JwtPayload, C extends string = string, >(token: Jwt, options?: VerifyOptions): Promise> { - return OauthClientReactNative.verifyJwt(token, options, this.jwk) + const result = await OauthClientReactNative.verifyJwt( + token, + options, + this.jwk, + ) + + // TODO (?): add any check not performed by the native module + // - result.payload.aud - must match options?.audience + // - result.payload.iss - must match options?.issuer + // - result.payload.sub - must match options?.subject + // - result.header.typ - must match options?.typ + // - result.payload - must contain all of options?.requiredClaims as keys + // - result.payload.iat - must be present + // - result.payload.iat - must not older than (options?.currentDate - options?.maxTokenAge +- options?.clockTolerance) + // - result.payload.nbf - if present (options?.currentDate +- options?.clockTolerance) + // - result.payload.exp - if present (options?.currentDate +- options?.clockTolerance) + + return result as VerifyResult } } From 718b387e1a4cb4c4173b4f92435b3dfd0dfbcfa9 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 13:59:42 +0200 Subject: [PATCH 125/140] fix(jwk-webcrypto): remove unused code --- packages/jwk-webcrypto/package.json | 1 - packages/jwk-webcrypto/src/db.ts | 42 --------------------- packages/jwk-webcrypto/src/webcrypto-key.ts | 10 +---- pnpm-lock.yaml | 3 -- 4 files changed, 2 insertions(+), 54 deletions(-) delete mode 100644 packages/jwk-webcrypto/src/db.ts diff --git a/packages/jwk-webcrypto/package.json b/packages/jwk-webcrypto/package.json index b91abd7046d..99160716113 100644 --- a/packages/jwk-webcrypto/package.json +++ b/packages/jwk-webcrypto/package.json @@ -21,7 +21,6 @@ "build": "tsc --build tsconfig.build.json" }, "dependencies": { - "@atproto/indexed-db": "workspace:*", "@atproto/jwk": "workspace:*", "@atproto/jwk-jose": "workspace:*" }, diff --git a/packages/jwk-webcrypto/src/db.ts b/packages/jwk-webcrypto/src/db.ts deleted file mode 100644 index 4a59e6315b7..00000000000 --- a/packages/jwk-webcrypto/src/db.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { DB } from '@atproto/indexed-db' -import { fromSubtleAlgorithm, generateKeypair } from './util.js' - -const INDEXED_DB_NAME = '@@jwk-webcrypto' - -export async function loadCryptoKeyPair( - kid: string, - algs: string[], - extractable = false, -): Promise { - type Schema = { - 'oauth-keypair': CryptoKeyPair - } - - const migrations = [ - (db: IDBDatabase) => { - db.createObjectStore('oauth-keypair') - }, - ] - - // eslint-disable-next-line - await using db = await DB.open(INDEXED_DB_NAME, migrations) - - const current = await db.transaction(['oauth-keypair'], 'readonly', (tx) => - tx.objectStore('oauth-keypair').get(kid), - ) - - try { - const alg = fromSubtleAlgorithm(current.privateKey.algorithm) - if (algs.includes(alg) && current.privateKey.extractable === extractable) { - return current - } else if (current) { - throw new Error('Store contained invalid keypair') - } - } catch { - await db.transaction(['oauth-keypair'], 'readwrite', (tx) => - tx.objectStore('oauth-keypair').delete(kid), - ) - } - - return generateKeypair(algs, extractable) -} diff --git a/packages/jwk-webcrypto/src/webcrypto-key.ts b/packages/jwk-webcrypto/src/webcrypto-key.ts index 9731050737e..0b701d24cf2 100644 --- a/packages/jwk-webcrypto/src/webcrypto-key.ts +++ b/packages/jwk-webcrypto/src/webcrypto-key.ts @@ -1,7 +1,6 @@ import { Jwk, jwkSchema } from '@atproto/jwk' import { JoseKey } from '@atproto/jwk-jose' -// XXX TODO: remove "./db.ts" file -// import { loadCryptoKeyPair } from './db.js' + import { generateKeypair, fromSubtleAlgorithm, @@ -9,14 +8,9 @@ import { } from './util.js' export class WebcryptoKey extends JoseKey { - // static async fromIndexedDB(kid: string, allowedAlgos: string[] = ['ES384']) { - // const cryptoKeyPair = await loadCryptoKeyPair(kid, allowedAlgos) - // return this.fromKeypair(kid, cryptoKeyPair) - // } - static async generate( kid: string = crypto.randomUUID(), - allowedAlgos: string[] = ['ES384'], + allowedAlgos: string[] = ['ES256'], exportable = false, ) { const cryptoKeyPair = await generateKeypair(allowedAlgos, exportable) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa9c8db3682..a060f085ebb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -761,9 +761,6 @@ importers: packages/jwk-webcrypto: dependencies: - '@atproto/indexed-db': - specifier: workspace:* - version: link:../indexed-db '@atproto/jwk': specifier: workspace:* version: link:../jwk From 5241de05730af38806dbe69159086ed01b793feb Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 15:22:57 +0200 Subject: [PATCH 126/140] fix(jwk-webcrypto): prefer jwk algorithms that yield smaller signatures --- packages/jwk-webcrypto/src/util.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/jwk-webcrypto/src/util.ts b/packages/jwk-webcrypto/src/util.ts index fc7c3a3f900..9bd35b7c85f 100644 --- a/packages/jwk-webcrypto/src/util.ts +++ b/packages/jwk-webcrypto/src/util.ts @@ -126,7 +126,8 @@ export async function generateKeypair( extractable = false, ): Promise { const errors: unknown[] = [] - for (const alg of algs) { + const algsSorted = Array.from(algs).sort(compareAlgos) + for (const alg of algsSorted) { try { return await crypto.subtle.generateKey( toSubtleAlgorithm(alg), @@ -140,3 +141,29 @@ export async function generateKeypair( throw new AggregateError(errors, 'Failed to generate keypair') } + +/** + * 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order) + */ +function compareAlgos(a: string, b: string): number { + if (a === 'ES256K') return -1 + if (b === 'ES256K') return 1 + + for (const prefix of ['ES', 'PS', 'RS']) { + if (a.startsWith(prefix)) { + if (b.startsWith(prefix)) { + const aLen = parseInt(a.slice(2, 5)) + const bLen = parseInt(b.slice(2, 5)) + + // Prefer shorter key lengths + return aLen - bLen + } + return -1 + } else if (b.startsWith(prefix)) { + return 1 + } + } + + // Don't know how to compare, keep original order + return 0 +} From 4cc3257fa1eaa133414c0abb51223b64740892b0 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 15:35:58 +0200 Subject: [PATCH 127/140] fix(oauth-client): prefer jwk algorithms that yield smaller signatures --- packages/jwk-webcrypto/src/util.ts | 29 +-------------------- packages/oauth-client/src/crypto-wrapper.ts | 29 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/jwk-webcrypto/src/util.ts b/packages/jwk-webcrypto/src/util.ts index 9bd35b7c85f..fc7c3a3f900 100644 --- a/packages/jwk-webcrypto/src/util.ts +++ b/packages/jwk-webcrypto/src/util.ts @@ -126,8 +126,7 @@ export async function generateKeypair( extractable = false, ): Promise { const errors: unknown[] = [] - const algsSorted = Array.from(algs).sort(compareAlgos) - for (const alg of algsSorted) { + for (const alg of algs) { try { return await crypto.subtle.generateKey( toSubtleAlgorithm(alg), @@ -141,29 +140,3 @@ export async function generateKeypair( throw new AggregateError(errors, 'Failed to generate keypair') } - -/** - * 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order) - */ -function compareAlgos(a: string, b: string): number { - if (a === 'ES256K') return -1 - if (b === 'ES256K') return 1 - - for (const prefix of ['ES', 'PS', 'RS']) { - if (a.startsWith(prefix)) { - if (b.startsWith(prefix)) { - const aLen = parseInt(a.slice(2, 5)) - const bLen = parseInt(b.slice(2, 5)) - - // Prefer shorter key lengths - return aLen - bLen - } - return -1 - } else if (b.startsWith(prefix)) { - return 1 - } - } - - // Don't know how to compare, keep original order - return 0 -} diff --git a/packages/oauth-client/src/crypto-wrapper.ts b/packages/oauth-client/src/crypto-wrapper.ts index deed810c7a3..b6ce9f15f3d 100644 --- a/packages/oauth-client/src/crypto-wrapper.ts +++ b/packages/oauth-client/src/crypto-wrapper.ts @@ -9,7 +9,8 @@ export class CryptoWrapper { constructor(protected implementation: CryptoImplementation) {} public async generateKey(algs: string[]): Promise { - return this.implementation.createKey(algs) + const algsSorted = Array.from(algs).sort(compareAlgos) + return this.implementation.createKey(algsSorted) } public async sha256(text: string): Promise { @@ -162,3 +163,29 @@ function extractJktComponents(jwk) { throw new TypeError('"kty" (Key Type) Parameter missing or unsupported') } } + +/** + * 256K > ES (256 > 384 > 512) > PS (256 > 384 > 512) > RS (256 > 384 > 512) > other (in original order) + */ +function compareAlgos(a: string, b: string): number { + if (a === 'ES256K') return -1 + if (b === 'ES256K') return 1 + + for (const prefix of ['ES', 'PS', 'RS']) { + if (a.startsWith(prefix)) { + if (b.startsWith(prefix)) { + const aLen = parseInt(a.slice(2, 5)) + const bLen = parseInt(b.slice(2, 5)) + + // Prefer shorter key lengths + return aLen - bLen + } + return -1 + } else if (b.startsWith(prefix)) { + return 1 + } + } + + // Don't know how to compare, keep original order + return 0 +} From 316b5f697d600b27f752497a755e6494c7274eca Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 15:45:00 +0200 Subject: [PATCH 128/140] feat(jwk): export jwkValidator that validates "use" and "key_ops" claims --- packages/jwk/src/jwk.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/jwk/src/jwk.ts b/packages/jwk/src/jwk.ts index f74a2ef3775..e6548564400 100644 --- a/packages/jwk/src/jwk.ts +++ b/packages/jwk/src/jwk.ts @@ -136,8 +136,7 @@ export const jwkSchema = z.union([ export type Jwk = z.infer -export const jwkPubSchema = jwkSchema - .refine((k) => k.kid != null, 'kid is required') +export const jwkValidator = jwkSchema .refine((k) => k.use != null || k.key_ops != null, 'use or key_ops required') .refine( (k) => @@ -150,4 +149,7 @@ export const jwkPubSchema = jwkSchema ), 'use and key_ops must be consistent', ) + +export const jwkPubSchema = jwkValidator + .refine((k) => k.kid != null, 'kid is required') .refine((k) => !('k' in k) && !('d' in k), 'private key not allowed') From d8748be3fa592d95f1a981987176615d83011591 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 15:47:08 +0200 Subject: [PATCH 129/140] feat(oauth-client-react-native): validate native jwk --- .../src/react-native-key.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/oauth-client-react-native/src/react-native-key.ts b/packages/oauth-client-react-native/src/react-native-key.ts index 3ce5e72115c..00c3d2ac919 100644 --- a/packages/oauth-client-react-native/src/react-native-key.ts +++ b/packages/oauth-client-react-native/src/react-native-key.ts @@ -1,5 +1,4 @@ import { - Jwk, Jwt, JwtHeader, JwtPayload, @@ -7,6 +6,7 @@ import { VerifyOptions, VerifyPayload, VerifyResult, + jwkValidator, } from '@atproto/jwk' import { OauthClientReactNative } from './oauth-client-react-native.js' @@ -14,12 +14,21 @@ import { OauthClientReactNative } from './oauth-client-react-native.js' export class ReactNativeKey extends Key { static async generate( kid: string, - allowedAlgos: string[] = ['ES256'], + allowedAlgos: string[], ): Promise { - // Note: OauthClientReactNative.createJwk should throw if it supports none - // of the allowed algorithms - const privateJwk: Jwk = await OauthClientReactNative.createJwk(allowedAlgos) - return new ReactNativeKey({ ...privateJwk, kid }) + for (const algo of allowedAlgos) { + try { + // Note: OauthClientReactNative.generatePrivateJwk should throw if it + // doesn't support the algorithm. + const jwk = await OauthClientReactNative.generatePrivateJwk(algo) + const use = jwk.use || 'sig' + return new ReactNativeKey(jwkValidator.parse({ ...jwk, use, kid })) + } catch { + // Ignore, try next one + } + } + + throw new Error('No supported algorithms') } async createJwt(header: JwtHeader, payload: JwtPayload): Promise { From 065e0b6cc7b89df62f33b10a239735fe837a1636 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 15:49:40 +0200 Subject: [PATCH 130/140] fix(jose-key): remove un-necessary code --- packages/jwk-jose/src/jose-key.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/jwk-jose/src/jose-key.ts b/packages/jwk-jose/src/jose-key.ts index 56460e591e1..cb9a7fcc13e 100644 --- a/packages/jwk-jose/src/jose-key.ts +++ b/packages/jwk-jose/src/jose-key.ts @@ -107,9 +107,8 @@ export class JoseKey extends Key { ) const kid = either(jwk.kid, inputKid) - const alg = jwk.alg const use = jwk.use || 'sig' - return new JoseKey({ ...jwk, kid, alg, use }) + return new JoseKey({ ...jwk, kid, use }) } } From 8d14b7808a0498b513f71dd96f98897719151cc0 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 15:54:20 +0200 Subject: [PATCH 131/140] fix(fetch): do not attempts to cancel the body if it was used --- packages/fetch-dpop/src/index.ts | 6 +++--- packages/fetch/src/fetch-response.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/fetch-dpop/src/index.ts b/packages/fetch-dpop/src/index.ts index 95ce8275b39..b3890f74a92 100644 --- a/packages/fetch-dpop/src/index.ts +++ b/packages/fetch-dpop/src/index.ts @@ -112,18 +112,18 @@ export async function dpopFetch( // If the response was not returned to the caller, make sure the body is // consumed. - await response.body?.cancel() + if (!response.bodyUsed) await response.body?.cancel() const dpopProof = await buildProof(key, alg, iss, method, url, nonce, ath) clonedRequest.headers.set('DPoP', dpopProof) return await fetch(clonedRequest) } catch (err) { - await response.body?.cancel(err) + if (!response.bodyUsed) await response.body?.cancel(err) throw err } } finally { - await clonedRequest.body?.cancel() + if (!clonedRequest.bodyUsed) await clonedRequest.body?.cancel() } } diff --git a/packages/fetch/src/fetch-response.ts b/packages/fetch/src/fetch-response.ts index 0dd18177367..cd7c3497e47 100644 --- a/packages/fetch/src/fetch-response.ts +++ b/packages/fetch/src/fetch-response.ts @@ -66,7 +66,7 @@ export class FetchResponseError extends FetchError { // Make sure the body gets consumed as, in some environments (Node 👀), the // response will not automatically be GC'd. - await response.body?.cancel() + if (!response.bodyUsed) await response.body?.cancel() return new FetchResponseError(response, statusCode, message, options) } From 7e9b72e286b3783c6b265d79482da8757cfae80b Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 16:54:17 +0200 Subject: [PATCH 132/140] fix(oauth-provider): do not expose account info in browser --- packages/oauth-provider/src/assets/app/lib/api.ts | 14 ++++++++------ packages/oauth-provider/src/assets/app/types.ts | 7 ------- packages/oauth-provider/src/oauth-provider.ts | 7 +++++-- .../src/output/send-authorize-page.ts | 8 +++++++- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/oauth-provider/src/assets/app/lib/api.ts b/packages/oauth-provider/src/assets/app/lib/api.ts index 11222aa8580..d20221a7c48 100644 --- a/packages/oauth-provider/src/assets/app/lib/api.ts +++ b/packages/oauth-provider/src/assets/app/lib/api.ts @@ -4,7 +4,7 @@ import { fetchOkProcessor, } from '@atproto/fetch' -import { Account, Info, Session } from '../types' +import { Account, Session } from '../types' export class Api { constructor( @@ -31,17 +31,19 @@ export class Api { }), }) .then(fetchOkProcessor(), fetchFailureHandler) - .then(fetchJsonProcessor<{ account: Account; info: Info }>()) + .then( + fetchJsonProcessor<{ + account: Account + consentRequired: boolean + }>(), + ) return { account: json.account, - info: json.info, selected: true, - consentRequired: - this.newSessionsRequireConsent || - !json.info.authorizedClients.includes(this.clientId), loginRequired: false, + consentRequired: this.newSessionsRequireConsent || json.consentRequired, } } diff --git a/packages/oauth-provider/src/assets/app/types.ts b/packages/oauth-provider/src/assets/app/types.ts index 36512d5c2f1..2d9cc36e9e0 100644 --- a/packages/oauth-provider/src/assets/app/types.ts +++ b/packages/oauth-provider/src/assets/app/types.ts @@ -46,15 +46,8 @@ export type Account = { updated_at?: number } -export type Info = { - remembered: boolean - authenticatedAt: string - authorizedClients: readonly string[] -} - export type Session = { account: Account - info: Info selected: boolean loginRequired: boolean diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 7fd12f39eee..97e2da2ed21 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -1139,7 +1139,7 @@ export class OAuthProvider extends OAuthVerifier { const signInPayloadSchema = z.object({ csrf_token: z.string(), request_uri: requestUriSchema, - // client_id: clientIdSchema, + client_id: oauthClientIdSchema, credentials: z.object({ username: z.string(), password: z.string(), @@ -1171,7 +1171,10 @@ export class OAuthProvider extends OAuthVerifier { // Prevent fixation attacks await sessionManager.rotate(req, res, deviceId) - return writeJson(res, { account, info }) + return writeJson(res, { + account, + consentRequired: !info.authorizedClients.includes(input.client_id), + }) }) const acceptQuerySchema = z.object({ diff --git a/packages/oauth-provider/src/output/send-authorize-page.ts b/packages/oauth-provider/src/output/send-authorize-page.ts index ff1058e7ec0..69f1719d020 100644 --- a/packages/oauth-provider/src/output/send-authorize-page.ts +++ b/packages/oauth-provider/src/output/send-authorize-page.ts @@ -42,7 +42,13 @@ function buildAuthorizeData(data: AuthorizationResultAuthorize) { newSessionsRequireConsent: data.parameters.prompt === 'login' || data.parameters.prompt === 'consent', - sessions: data.authorize.sessions, + sessions: data.authorize.sessions.map((session) => ({ + account: session.account, + + selected: session.selected, + loginRequired: session.loginRequired, + consentRequired: session.consentRequired, + })), } } From e31611774dba120bbb726b0ec65f26462504bb8d Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 20:17:54 +0200 Subject: [PATCH 133/140] feat(jwk): simplify VerifyOptions interface --- packages/jwk/src/jwt-verify.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/jwk/src/jwt-verify.ts b/packages/jwk/src/jwt-verify.ts index 5eeca81e53e..8c80c510d81 100644 --- a/packages/jwk/src/jwt-verify.ts +++ b/packages/jwk/src/jwt-verify.ts @@ -3,9 +3,11 @@ import { RequiredKey } from './util.js' export type VerifyOptions = { audience?: string | readonly string[] - clockTolerance?: string | number + /** in seconds */ + clockTolerance?: number issuer?: string | readonly string[] - maxTokenAge?: string | number + /** in seconds */ + maxTokenAge?: number subject?: string typ?: string currentDate?: Date From f55c1bd327978fbdf55c25ac2a626d94fbaf560a Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 20:27:57 +0200 Subject: [PATCH 134/140] feat(oauth-client-react-native): improve typings --- .../src/oauth-client-react-native.ts | 47 +++++++--- .../src/react-native-crypto-implementation.ts | 2 +- .../src/react-native-key.ts | 88 +++++++++++++++---- 3 files changed, 108 insertions(+), 29 deletions(-) diff --git a/packages/oauth-client-react-native/src/oauth-client-react-native.ts b/packages/oauth-client-react-native/src/oauth-client-react-native.ts index 7f5e03c9246..51d8ec7caad 100644 --- a/packages/oauth-client-react-native/src/oauth-client-react-native.ts +++ b/packages/oauth-client-react-native/src/oauth-client-react-native.ts @@ -1,3 +1,4 @@ +import { Jwk, Jwt } from '@atproto/jwk' import { NativeModules, Platform } from 'react-native' const LINKING_ERROR = @@ -6,13 +7,39 @@ const LINKING_ERROR = '- You rebuilt the app after installing the package\n' + '- You are not using Expo Go\n' -export const OauthClientReactNative = NativeModules.OauthClientReactNative - ? NativeModules.OauthClientReactNative - : new Proxy( - {}, - { - get() { - throw new Error(LINKING_ERROR) - }, - }, - ) +type Awaitable = T | Promise + +// This is a stub for the native module. It is used when the module is not +// linked AND to provide types. +export const OauthClientReactNative = + (NativeModules.OauthClientReactNative as null) || { + getRandomValues(_length: number): Awaitable { + throw new Error(LINKING_ERROR) + }, + + digest(_bytes: Uint8Array, _algorithm: string): Awaitable { + throw new Error(LINKING_ERROR) + }, + + generatePrivateJwk(_algo: string): Awaitable { + throw new Error(LINKING_ERROR) + }, + + createJwt( + _header: unknown, + _payload: unknown, + _jwk: unknown, + ): Awaitable { + throw new Error(LINKING_ERROR) + }, + + verifyJwt( + _token: Jwt, + _jwk: Jwk, + ): Awaitable<{ + payload: Record + protectedHeader: Record + }> { + throw new Error(LINKING_ERROR) + }, + } diff --git a/packages/oauth-client-react-native/src/react-native-crypto-implementation.ts b/packages/oauth-client-react-native/src/react-native-crypto-implementation.ts index 202ac2c318a..863304dd244 100644 --- a/packages/oauth-client-react-native/src/react-native-crypto-implementation.ts +++ b/packages/oauth-client-react-native/src/react-native-crypto-implementation.ts @@ -22,7 +22,7 @@ export class ReactNativeCryptoImplementation implements CryptoImplementation { bytes: Uint8Array, algorithm: DigestAlgorithm, ): Promise { - return OauthClientReactNative.digest(bytes, algorithm) + return OauthClientReactNative.digest(bytes, algorithm.name) } } diff --git a/packages/oauth-client-react-native/src/react-native-key.ts b/packages/oauth-client-react-native/src/react-native-key.ts index 00c3d2ac919..12e37d7eaa8 100644 --- a/packages/oauth-client-react-native/src/react-native-key.ts +++ b/packages/oauth-client-react-native/src/react-native-key.ts @@ -7,6 +7,8 @@ import { VerifyPayload, VerifyResult, jwkValidator, + jwtHeaderSchema, + jwtPayloadSchema, } from '@atproto/jwk' import { OauthClientReactNative } from './oauth-client-react-native.js' @@ -39,23 +41,73 @@ export class ReactNativeKey extends Key { P extends VerifyPayload = JwtPayload, C extends string = string, >(token: Jwt, options?: VerifyOptions): Promise> { - const result = await OauthClientReactNative.verifyJwt( - token, - options, - this.jwk, - ) - - // TODO (?): add any check not performed by the native module - // - result.payload.aud - must match options?.audience - // - result.payload.iss - must match options?.issuer - // - result.payload.sub - must match options?.subject - // - result.header.typ - must match options?.typ - // - result.payload - must contain all of options?.requiredClaims as keys - // - result.payload.iat - must be present - // - result.payload.iat - must not older than (options?.currentDate - options?.maxTokenAge +- options?.clockTolerance) - // - result.payload.nbf - if present (options?.currentDate +- options?.clockTolerance) - // - result.payload.exp - if present (options?.currentDate +- options?.clockTolerance) - - return result as VerifyResult + const result = await OauthClientReactNative.verifyJwt(token, this.jwk) + + const payload = jwtPayloadSchema.parse(result.payload) + const protectedHeader = jwtHeaderSchema.parse(result.protectedHeader) + + if (options?.audience != null) { + const audience = Array.isArray(options.audience) + ? options.audience + : [options.audience] + if (!audience.includes(payload.aud)) { + throw new Error('Invalid audience') + } + } + + if (options?.issuer != null) { + const issuer = Array.isArray(options.issuer) + ? options.issuer + : [options.issuer] + if (!issuer.includes(payload.iss)) { + throw new Error('Invalid issuer') + } + } + + if (options?.subject != null && payload.sub !== options.subject) { + throw new Error('Invalid subject') + } + + if (options?.typ != null && protectedHeader.typ !== options.typ) { + throw new Error('Invalid type') + } + + if (options?.requiredClaims != null) { + for (const key of options.requiredClaims) { + if ( + !Object.hasOwn(payload, key) || + (payload as Record)[key] === undefined + ) { + throw new Error(`Missing claim: ${key}`) + } + } + } + + if (payload.iat == null) { + throw new Error('Missing issued at') + } + + const now = (options?.currentDate?.getTime() ?? Date.now()) / 1e3 + const clockTolerance = options?.clockTolerance ?? 0 + + if (options?.maxTokenAge != null) { + if (payload.iat < now - options.maxTokenAge + clockTolerance) { + throw new Error('Invalid issued at') + } + } + + if (payload.nbf != null) { + if (payload.nbf > now - clockTolerance) { + throw new Error('Invalid not before') + } + } + + if (payload.exp != null) { + if (payload.exp < now + clockTolerance) { + throw new Error('Invalid expiration') + } + } + + return { payload, protectedHeader } as VerifyResult } } From 3eefb79b34897209ff80307efbc04f65626c9427 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Fri, 12 Apr 2024 20:31:00 +0200 Subject: [PATCH 135/140] docs(oauth-client-react-native): add jsdoc --- .../src/oauth-client-react-native.ts | 11 ++++++++++- .../oauth-client-react-native/src/react-native-key.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/oauth-client-react-native/src/oauth-client-react-native.ts b/packages/oauth-client-react-native/src/oauth-client-react-native.ts index 51d8ec7caad..2955c465264 100644 --- a/packages/oauth-client-react-native/src/oauth-client-react-native.ts +++ b/packages/oauth-client-react-native/src/oauth-client-react-native.ts @@ -17,11 +17,20 @@ export const OauthClientReactNative = throw new Error(LINKING_ERROR) }, + /** + * @throws if the algorithm is not supported ("sha256" must be supported) + */ digest(_bytes: Uint8Array, _algorithm: string): Awaitable { throw new Error(LINKING_ERROR) }, - generatePrivateJwk(_algo: string): Awaitable { + /** + * Create a private JWK for the given algorithm. The JWK should have a "use" + * an does not need a "kid" property. + * + * @throws if the algorithm is not supported ("ES256" must be supported) + */ + generateJwk(_algo: string): Awaitable { throw new Error(LINKING_ERROR) }, diff --git a/packages/oauth-client-react-native/src/react-native-key.ts b/packages/oauth-client-react-native/src/react-native-key.ts index 12e37d7eaa8..426f63331ae 100644 --- a/packages/oauth-client-react-native/src/react-native-key.ts +++ b/packages/oauth-client-react-native/src/react-native-key.ts @@ -22,7 +22,7 @@ export class ReactNativeKey extends Key { try { // Note: OauthClientReactNative.generatePrivateJwk should throw if it // doesn't support the algorithm. - const jwk = await OauthClientReactNative.generatePrivateJwk(algo) + const jwk = await OauthClientReactNative.generateJwk(algo) const use = jwk.use || 'sig' return new ReactNativeKey(jwkValidator.parse({ ...jwk, use, kid })) } catch { From fdfe4388b741fb067e8f996c019367f26b0d65ee Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 15 Apr 2024 13:15:25 +0200 Subject: [PATCH 136/140] refactor(oauth-provider): merge session and device concepts --- .../session-data.ts => device/device-data.ts} | 6 ++-- .../oauth-provider/src/device/device-store.ts | 33 +++++++++++++++++ .../{session => device}/session-manager.ts | 36 +++++++++---------- packages/oauth-provider/src/oauth-provider.ts | 27 +++++++------- packages/oauth-provider/src/oauth-store.ts | 2 +- .../src/session/session-store.ts | 36 ------------------- 6 files changed, 66 insertions(+), 74 deletions(-) rename packages/oauth-provider/src/{session/session-data.ts => device/device-data.ts} (78%) create mode 100644 packages/oauth-provider/src/device/device-store.ts rename packages/oauth-provider/src/{session => device}/session-manager.ts (88%) delete mode 100644 packages/oauth-provider/src/session/session-store.ts diff --git a/packages/oauth-provider/src/session/session-data.ts b/packages/oauth-provider/src/device/device-data.ts similarity index 78% rename from packages/oauth-provider/src/session/session-data.ts rename to packages/oauth-provider/src/device/device-data.ts index 9e7be4ae6fc..782f80f8e13 100644 --- a/packages/oauth-provider/src/session/session-data.ts +++ b/packages/oauth-provider/src/device/device-data.ts @@ -1,8 +1,8 @@ import { z } from 'zod' import { SESSION_ID_BYTES_LENGTH, SESSION_ID_PREFIX } from '../constants.js' -import { devideDetailsSchema } from '../device/device-details.js' import { randomHexId } from '../util/crypto.js' +import { devideDetailsSchema } from './device-details.js' export const sessionIdSchema = z .string() @@ -21,9 +21,9 @@ export const generateSessionId = async (): Promise => { return `${SESSION_ID_PREFIX}${await randomHexId(SESSION_ID_BYTES_LENGTH)}` } -export const deviceSessionDataSchema = devideDetailsSchema.extend({ +export const deviceDataSchema = devideDetailsSchema.extend({ sessionId: sessionIdSchema, lastSeenAt: z.date(), }) -export type SessionData = z.infer +export type DeviceData = z.infer diff --git a/packages/oauth-provider/src/device/device-store.ts b/packages/oauth-provider/src/device/device-store.ts new file mode 100644 index 00000000000..23c69353eed --- /dev/null +++ b/packages/oauth-provider/src/device/device-store.ts @@ -0,0 +1,33 @@ +import { Awaitable } from '../util/awaitable.js' +import { DeviceData, SessionId } from './device-data.js' +import { DeviceId } from './device-id.js' + +// Export all types needed to implement the DeviceStore interface +export type { DeviceData, DeviceId, SessionId } + +export interface DeviceStore { + createDevice(deviceId: DeviceId, data: DeviceData): Awaitable + readDevice(deviceId: DeviceId): Awaitable + updateDevice(deviceId: DeviceId, data: Partial): Awaitable + deleteDevice(deviceId: DeviceId): Awaitable +} + +export function isDeviceStore( + implementation: Record & Partial, +): implementation is Record & DeviceStore { + return ( + typeof implementation.createDevice === 'function' && + typeof implementation.readDevice === 'function' && + typeof implementation.updateDevice === 'function' && + typeof implementation.deleteDevice === 'function' + ) +} + +export function asDeviceStore( + implementation?: Record & Partial, +): DeviceStore { + if (!implementation || !isDeviceStore(implementation)) { + throw new Error('Invalid DeviceStore implementation') + } + return implementation +} diff --git a/packages/oauth-provider/src/session/session-manager.ts b/packages/oauth-provider/src/device/session-manager.ts similarity index 88% rename from packages/oauth-provider/src/session/session-manager.ts rename to packages/oauth-provider/src/device/session-manager.ts index 5abd2f8539c..4b347a706e9 100644 --- a/packages/oauth-provider/src/session/session-manager.ts +++ b/packages/oauth-provider/src/device/session-manager.ts @@ -6,18 +6,14 @@ import type Keygrip from 'keygrip' import { z } from 'zod' import { SESSION_FIXATION_MAX_AGE } from '../constants.js' -import { extractDeviceDetails } from '../device/device-details.js' import { - DeviceId, - deviceIdSchema, - generateDeviceId, -} from '../device/device-id.js' -import { - SessionData, + DeviceData, generateSessionId, sessionIdSchema, -} from './session-data.js' -import { SessionStore } from './session-store.js' +} from './device-data.js' +import { extractDeviceDetails } from './device-details.js' +import { DeviceId, deviceIdSchema, generateDeviceId } from './device-id.js' +import { DeviceStore } from './device-store.js' export const DEFAULT_OPTIONS = { /** @@ -87,20 +83,20 @@ export const DEFAULT_OPTIONS = { }, } -export type DeviceSessionManagerOptions = typeof DEFAULT_OPTIONS +export type DeviceDeviceManagerOptions = typeof DEFAULT_OPTIONS const cookieValueSchema = z.tuple([deviceIdSchema, sessionIdSchema]) type CookieValue = z.infer /** * This class provides an abstraction for keeping track of DEVICE sessions. It - * relies on a {@link SessionStore} to persist session data and a cookie to + * relies on a {@link DeviceStore} to persist session data and a cookie to * identify the session. */ -export class SessionManager { +export class DeviceManager { constructor( - private readonly store: SessionStore, - private readonly options: DeviceSessionManagerOptions = DEFAULT_OPTIONS, + private readonly store: DeviceStore, + private readonly options: DeviceDeviceManagerOptions = DEFAULT_OPTIONS, ) {} public async load( @@ -126,7 +122,7 @@ export class SessionManager { generateSessionId(), ] as const) - await this.store.createDeviceSession(deviceId, { + await this.store.createDevice(deviceId, { sessionId, lastSeenAt: new Date(), userAgent, @@ -144,7 +140,7 @@ export class SessionManager { [deviceId, sessionId]: CookieValue, forceRotate = false, ): Promise<{ deviceId: DeviceId }> { - const data = await this.store.readDeviceSession(deviceId) + const data = await this.store.readDevice(deviceId) if (!data) return this.create(req, res) const lastSeenAt = new Date(data.lastSeenAt) @@ -157,7 +153,7 @@ export class SessionManager { forceRotate = true } else { // Something's wrong. Let's create a new session. - await this.store.deleteDeviceSession(deviceId) + await this.store.deleteDevice(deviceId) return this.create(req, res) } } @@ -183,11 +179,11 @@ export class SessionManager { req: IncomingMessage, res: ServerResponse, deviceId: DeviceId, - data?: Partial>, + data?: Partial>, ): Promise { const sessionId = await generateSessionId() - await this.store.updateDeviceSession(deviceId, { + await this.store.updateDevice(deviceId, { ...data, sessionId, lastSeenAt: new Date(), @@ -216,7 +212,7 @@ export class SessionManager { // Silently ignore invalid cookies if (!device || !session) { // If the device cookie is valid, let's cleanup the DB - if (device) await this.store.deleteDeviceSession(device.value) + if (device) await this.store.deleteDevice(device.value) return null } diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index 97e2da2ed21..e4e81cc8edb 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -46,6 +46,8 @@ import { ClientStore, asClientStore } from './client/client-store.js' import { AuthEndpoint, Client } from './client/client.js' import { AUTH_MAX_AGE, TOKEN_MAX_AGE } from './constants.js' import { DeviceId } from './device/device-id.js' +import { DeviceStore, asDeviceStore } from './device/device-store.js' +import { DeviceManager } from './device/session-manager.js' import { AccessDeniedError } from './errors/access-denied-error.js' import { AccountSelectionRequiredError } from './errors/account-selection-required-error.js' import { ConsentRequiredError } from './errors/consent-required-error.js' @@ -91,8 +93,6 @@ import { authorizationRequestQuerySchema, pushedAuthorizationRequestSchema, } from './request/types.js' -import { SessionManager } from './session/session-manager.js' -import { SessionStore, asSessionStore } from './session/session-store.js' import { isTokenId } from './token/token-id.js' import { TokenManager } from './token/token-manager.js' import { TokenResponse } from './token/token-response.js' @@ -116,7 +116,7 @@ import { Override } from './util/type.js' export type OAuthProviderStore = Partial< ClientStore & AccountStore & - SessionStore & + DeviceStore & TokenStore & RequestStore & ReplayStore @@ -151,10 +151,10 @@ export type OAuthProviderOptions = Override< metadata?: CustomMetadata accountStore?: AccountStore + deviceStore?: DeviceStore clientStore?: ClientStore replayStore?: ReplayStore requestStore?: RequestStore - sessionStore?: SessionStore tokenStore?: TokenStore /** @@ -173,9 +173,8 @@ export class OAuthProvider extends OAuthVerifier { public readonly defaultMaxAge: number - public readonly sessionStore: SessionStore - public readonly accountManager: AccountManager + public readonly deviceStore: DeviceStore public readonly clientManager: ClientManager public readonly requestManager: RequestManager public readonly tokenManager: TokenManager @@ -193,7 +192,7 @@ export class OAuthProvider extends OAuthVerifier { requestStore = store && isRequestStore(store) ? store : new RequestStoreMemory(), - sessionStore = asSessionStore(store), + deviceStore = asDeviceStore(store), tokenStore = asTokenStore(store), ...rest @@ -203,7 +202,7 @@ export class OAuthProvider extends OAuthVerifier { this.defaultMaxAge = defaultMaxAge this.metadata = buildMetadata(this.issuer, this.keyset, metadata) - this.sessionStore = sessionStore + this.deviceStore = deviceStore this.accountManager = new AccountManager(accountStore, rest) this.clientManager = new ClientManager(clientStore, this.keyset, rest) @@ -852,7 +851,7 @@ export class OAuthProvider extends OAuthVerifier { customization?: Customization onError?: (req: Req, res: Res, err: unknown) => void }): Handler { - const sessionManager = new SessionManager(this.sessionStore) + const deviceManager = new DeviceManager(this.deviceStore) // eslint-disable-next-line @typescript-eslint/no-this-alias const server = this @@ -1111,7 +1110,7 @@ export class OAuthProvider extends OAuthVerifier { path: ['query'], }) - const { deviceId } = await sessionManager.load(req, res) + const { deviceId } = await deviceManager.load(req, res) const data = await server.authorize(deviceId, input) switch (true) { @@ -1164,12 +1163,12 @@ export class OAuthProvider extends OAuthVerifier { csrfCookie(input.request_uri), ) - const { deviceId } = await sessionManager.load(req, res) + const { deviceId } = await deviceManager.load(req, res) const { account, info } = await server.signIn(deviceId, input.credentials) // Prevent fixation attacks - await sessionManager.rotate(req, res, deviceId) + await deviceManager.rotate(req, res, deviceId) return writeJson(res, { account, @@ -1212,7 +1211,7 @@ export class OAuthProvider extends OAuthVerifier { true, ) - const { deviceId } = await sessionManager.load(req, res) + const { deviceId } = await deviceManager.load(req, res) const data = await server.acceptRequest( deviceId, @@ -1265,7 +1264,7 @@ export class OAuthProvider extends OAuthVerifier { true, ) - const { deviceId } = await sessionManager.load(req, res) + const { deviceId } = await deviceManager.load(req, res) const data = await server.rejectRequest( deviceId, diff --git a/packages/oauth-provider/src/oauth-store.ts b/packages/oauth-provider/src/oauth-store.ts index b0e1caa8de8..a397d27e49e 100644 --- a/packages/oauth-provider/src/oauth-store.ts +++ b/packages/oauth-provider/src/oauth-store.ts @@ -5,7 +5,7 @@ export type * from './account/account-store.js' export type * from './client/client-store.js' +export type * from './device/device-store.js' export type * from './replay/replay-store.js' export type * from './request/request-store.js' -export type * from './session/session-store.js' export type * from './token/token-store.js' diff --git a/packages/oauth-provider/src/session/session-store.ts b/packages/oauth-provider/src/session/session-store.ts deleted file mode 100644 index 7fc474935bd..00000000000 --- a/packages/oauth-provider/src/session/session-store.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DeviceId } from '../device/device-id.js' -import { Awaitable } from '../util/awaitable.js' -import { SessionId, SessionData } from './session-data.js' - -// Export all types needed to implement the SessionStore interface -export type { Awaitable, SessionId, DeviceId, SessionData } - -export interface SessionStore { - createDeviceSession(deviceId: DeviceId, data: SessionData): Awaitable - readDeviceSession(deviceId: DeviceId): Awaitable - updateDeviceSession( - deviceId: DeviceId, - data: Partial, - ): Awaitable - deleteDeviceSession(deviceId: DeviceId): Awaitable -} - -export function isSessionStore( - implementation: Record & Partial, -): implementation is Record & SessionStore { - return ( - typeof implementation.createDeviceSession === 'function' && - typeof implementation.readDeviceSession === 'function' && - typeof implementation.updateDeviceSession === 'function' && - typeof implementation.deleteDeviceSession === 'function' - ) -} - -export function asSessionStore( - implementation?: Record & Partial, -): SessionStore { - if (!implementation || !isSessionStore(implementation)) { - throw new Error('Invalid SessionStore implementation') - } - return implementation -} From 9c794e0252ac75b08a6d7a1483b4e4b6432a2d2d Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 15 Apr 2024 13:15:49 +0200 Subject: [PATCH 137/140] fix(pds): adapt to latest oauth-provider refactor --- .../pds/src/account-manager/helpers/device.ts | 14 ++++++------ packages/pds/src/account-manager/index.ts | 22 ++++++++----------- packages/pds/src/auth-provider.ts | 2 +- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/packages/pds/src/account-manager/helpers/device.ts b/packages/pds/src/account-manager/helpers/device.ts index 0463537c1ec..7c540dd6da4 100644 --- a/packages/pds/src/account-manager/helpers/device.ts +++ b/packages/pds/src/account-manager/helpers/device.ts @@ -1,9 +1,9 @@ -import { DeviceId, SessionData } from '@atproto/oauth-provider' +import { DeviceId, DeviceData } from '@atproto/oauth-provider' import { AccountDb, Device } from '../db' import { fromDateISO, toDateISO } from '../../db' import { Selectable } from 'kysely' -const rowToSessionData = (row: Selectable): SessionData => ({ +const rowToDeviceData = (row: Selectable): DeviceData => ({ sessionId: row.sessionId, userAgent: row.userAgent, ipAddress: row.ipAddress, @@ -13,9 +13,9 @@ const rowToSessionData = (row: Selectable): SessionData => ({ /** * Future-proofs the session data by ensuring that only the expected fields are * present. If the @atproto/oauth-provider package adds new fields to the - * SessionData type, this function will throw an error. + * DeviceData type, this function will throw an error. */ -const futureProof = >(data: T): T => { +const futureProof = >(data: T): T => { const { sessionId, userAgent, ipAddress, lastSeenAt, ...rest } = data if (Object.keys(rest).length > 0) throw new Error('Unexpected fields') return { sessionId, userAgent, ipAddress, lastSeenAt } as T @@ -24,7 +24,7 @@ const futureProof = >(data: T): T => { export const create = async ( db: AccountDb, deviceId: DeviceId, - data: SessionData, + data: DeviceData, ) => { const { sessionId, userAgent, ipAddress, lastSeenAt } = futureProof(data) @@ -49,13 +49,13 @@ export const getById = async (db: AccountDb, deviceId: DeviceId) => { if (row == null) return null - return rowToSessionData(row) + return rowToDeviceData(row) } export const update = async ( db: AccountDb, deviceId: DeviceId, - data: Partial, + data: Partial, ) => { const { sessionId, userAgent, ipAddress, lastSeenAt } = futureProof(data) diff --git a/packages/pds/src/account-manager/index.ts b/packages/pds/src/account-manager/index.ts index 35bf44696c2..5abf05faa0b 100644 --- a/packages/pds/src/account-manager/index.ts +++ b/packages/pds/src/account-manager/index.ts @@ -1,10 +1,10 @@ import { KeyObject } from 'node:crypto' import { HOUR, wait } from '@atproto/common' import { - Account, AccountInfo, AccountStore, Code, + DeviceData, DeviceId, FoundRequestResult, LoginCredentials, @@ -13,8 +13,7 @@ import { RequestData, RequestId, RequestStore, - SessionData, - SessionStore, + DeviceStore, TokenData, TokenId, TokenInfo, @@ -43,7 +42,7 @@ import * as token from './helpers/token.js' import * as usedRefreshToken from './helpers/used-refresh-token.js' export class AccountManager - implements AccountStore, RequestStore, SessionStore, TokenStore + implements AccountStore, RequestStore, DeviceStore, TokenStore { db: AccountDb @@ -553,27 +552,24 @@ export class AccountManager return authorizationRequest.findByCode(this.db, code) } - // SessionStore + // DeviceStore - async createDeviceSession( - deviceId: DeviceId, - data: SessionData, - ): Promise { + async createDevice(deviceId: DeviceId, data: DeviceData): Promise { await device.create(this.db, deviceId, data) } - async readDeviceSession(deviceId: DeviceId): Promise { + async readDevice(deviceId: DeviceId): Promise { return device.getById(this.db, deviceId) } - async updateDeviceSession( + async updateDevice( deviceId: DeviceId, - data: Partial, + data: Partial, ): Promise { await device.update(this.db, deviceId, data) } - async deleteDeviceSession(deviceId: DeviceId): Promise { + async deleteDevice(deviceId: DeviceId): Promise { await device.remove(this.db, deviceId) // TODO: can use use foreign key constraint to delete this row ? diff --git a/packages/pds/src/auth-provider.ts b/packages/pds/src/auth-provider.ts index d50aa44fe24..4a7ceba9db9 100644 --- a/packages/pds/src/auth-provider.ts +++ b/packages/pds/src/auth-provider.ts @@ -75,7 +75,7 @@ export class AuthProvider extends OAuthProvider { new BasicProfileGetterCached(actorStore, localViewer), ), requestStore: accountManager, - sessionStore: accountManager, + deviceStore: accountManager, tokenStore: accountManager, replayStore: redis ? new OAuthReplayStoreRedis(redis) From 17fc0fc5a85426675a4032f8bd2fc64a887587b4 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 15 Apr 2024 13:18:25 +0200 Subject: [PATCH 138/140] fixup! refactor(oauth-provider): merge session and device concepts --- .../src/device/{session-manager.ts => device-manager.ts} | 0 packages/oauth-provider/src/oauth-provider.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/oauth-provider/src/device/{session-manager.ts => device-manager.ts} (100%) diff --git a/packages/oauth-provider/src/device/session-manager.ts b/packages/oauth-provider/src/device/device-manager.ts similarity index 100% rename from packages/oauth-provider/src/device/session-manager.ts rename to packages/oauth-provider/src/device/device-manager.ts diff --git a/packages/oauth-provider/src/oauth-provider.ts b/packages/oauth-provider/src/oauth-provider.ts index e4e81cc8edb..179d04057d5 100644 --- a/packages/oauth-provider/src/oauth-provider.ts +++ b/packages/oauth-provider/src/oauth-provider.ts @@ -46,8 +46,8 @@ import { ClientStore, asClientStore } from './client/client-store.js' import { AuthEndpoint, Client } from './client/client.js' import { AUTH_MAX_AGE, TOKEN_MAX_AGE } from './constants.js' import { DeviceId } from './device/device-id.js' +import { DeviceManager } from './device/device-manager.js' import { DeviceStore, asDeviceStore } from './device/device-store.js' -import { DeviceManager } from './device/session-manager.js' import { AccessDeniedError } from './errors/access-denied-error.js' import { AccountSelectionRequiredError } from './errors/account-selection-required-error.js' import { ConsentRequiredError } from './errors/consent-required-error.js' From b01e28966777919460b34990447b9b869af0bf70 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 15 Apr 2024 13:20:15 +0200 Subject: [PATCH 139/140] fixup! refactor(oauth-provider): merge session and device concepts --- packages/oauth-provider/src/device/device-data.ts | 4 ++-- packages/oauth-provider/src/device/device-details.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/oauth-provider/src/device/device-data.ts b/packages/oauth-provider/src/device/device-data.ts index 782f80f8e13..cea7ac08fbf 100644 --- a/packages/oauth-provider/src/device/device-data.ts +++ b/packages/oauth-provider/src/device/device-data.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { SESSION_ID_BYTES_LENGTH, SESSION_ID_PREFIX } from '../constants.js' import { randomHexId } from '../util/crypto.js' -import { devideDetailsSchema } from './device-details.js' +import { deviceDetailsSchema } from './device-details.js' export const sessionIdSchema = z .string() @@ -21,7 +21,7 @@ export const generateSessionId = async (): Promise => { return `${SESSION_ID_PREFIX}${await randomHexId(SESSION_ID_BYTES_LENGTH)}` } -export const deviceDataSchema = devideDetailsSchema.extend({ +export const deviceDataSchema = deviceDetailsSchema.extend({ sessionId: sessionIdSchema, lastSeenAt: z.date(), }) diff --git a/packages/oauth-provider/src/device/device-details.ts b/packages/oauth-provider/src/device/device-details.ts index 9d0767ccbe9..8673736146b 100644 --- a/packages/oauth-provider/src/device/device-details.ts +++ b/packages/oauth-provider/src/device/device-details.ts @@ -2,11 +2,11 @@ import { IncomingMessage } from 'node:http' import { z } from 'zod' -export const devideDetailsSchema = z.object({ +export const deviceDetailsSchema = z.object({ userAgent: z.string().nullable(), ipAddress: z.string(), }) -export type DeviceDetails = z.infer +export type DeviceDetails = z.infer export function extractDeviceDetails( req: IncomingMessage, From 6aa38d7d20d369e2c75928f3f60769db7bba37f5 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Mon, 15 Apr 2024 14:08:58 +0200 Subject: [PATCH 140/140] feat(oauth-client-browser): simplify login ux & allow popup mode --- .../oauth-client-browser-example/src/app.tsx | 6 +- .../src/login-form.tsx | 78 ++++++++++++------- 2 files changed, 53 insertions(+), 31 deletions(-) diff --git a/packages/oauth-client-browser-example/src/app.tsx b/packages/oauth-client-browser-example/src/app.tsx index 8b175e4fcd7..d3dc8a67cac 100644 --- a/packages/oauth-client-browser-example/src/app.tsx +++ b/packages/oauth-client-browser-example/src/app.tsx @@ -83,11 +83,7 @@ function App() {
) : ( - void signIn(input)} - /> + ) } diff --git a/packages/oauth-client-browser-example/src/login-form.tsx b/packages/oauth-client-browser-example/src/login-form.tsx index fba84c4d573..6e8b325e560 100644 --- a/packages/oauth-client-browser-example/src/login-form.tsx +++ b/packages/oauth-client-browser-example/src/login-form.tsx @@ -1,4 +1,4 @@ -import { FormEvent, useState } from 'react' +import { FormEvent, useEffect, useState } from 'react' /** * @returns Nice tailwind css form asking to enter either a handle or the host @@ -7,25 +7,59 @@ import { FormEvent, useState } from 'react' export default function LoginForm({ onLogin, loading, - error, + error = null, ...props }: { loading?: boolean error?: null | string - onLogin: (input: string) => void + onLogin: (input: string, options?: { display?: 'popup' | 'page' }) => void } & React.HTMLAttributes) { const [value, setValue] = useState('') - const [loginType, setLoginType] = useState<'handle' | 'host'>('handle') + const [display, setDisplay] = useState<'popup' | 'page'>('popup') + const [localError, setLocalError] = useState(error) + + useEffect(() => { + setLocalError(null) + }, [value]) + + useEffect(() => { + setLocalError(error) + }, [error]) const onSubmit = (e: FormEvent) => { e.preventDefault() if (loading) return - onLogin( - loginType === 'host' && !/^https?:\/\//.test(value) - ? `https://${value}` - : value, - ) + if (value.startsWith('did:')) { + if (value.length > 5) onLogin(value, { display }) + else setLocalError('DID must be at least 6 characters') + return + } + + if (value.startsWith('https://')) { + try { + const url = new URL(value) + if (value !== url.origin) throw new Error('PDS URL must be a origin') + onLogin(value, { display }) + } catch (err) { + setLocalError((err as any)?.message || String(err)) + } + return + } + + if (value.startsWith('http://')) { + setLocalError('PDS URL must be a secure origin') + return + } + + if (value.includes('.') && value.length > 3) { + const handle = value.startsWith('@') ? value.slice(1) : value + if (handle.length > 3) onLogin(handle, { display }) + else setLocalError('Handle must be at least 4 characters') + return + } + + setLocalError('Please provide a valid handle, DID or PDS URL') } return ( @@ -33,35 +67,25 @@ export default function LoginForm({
{/*
*/}
- setValue(e.target.value)} @@ -76,7 +100,9 @@ export default function LoginForm({
- {error ?
{error}
: null} + {localError ? ( +
{localError}
+ ) : null} ) }