import { createHash } from 'node:crypto' import type { Redis, RedisOptions } from 'ioredis' import { Jwks, Keyset } from '@atproto/jwk' import { LexiconResolver } from '@atproto/lexicon-resolver' import type { Account } from '@atproto/oauth-provider-api' import { CLIENT_ASSERTION_TYPE_JWT_BEARER, OAuthAccessToken, OAuthAuthorizationCodeGrantTokenRequest, OAuthAuthorizationRequestJar, OAuthAuthorizationRequestPar, OAuthAuthorizationRequestParameters, OAuthAuthorizationRequestQuery, OAuthAuthorizationServerMetadata, OAuthClientCredentials, OAuthClientCredentialsNone, OAuthClientMetadata, OAuthParResponse, OAuthRefreshTokenGrantTokenRequest, OAuthTokenIdentification, OAuthTokenRequest, OAuthTokenResponse, OAuthTokenType, atprotoLoopbackClientMetadata, oauthAuthorizationRequestParametersSchema, } from '@atproto/oauth-types' import { safeFetchWrap } from '@atproto-labs/fetch-node' import { SimpleStore } from '@atproto-labs/simple-store' import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory' import { AccessTokenMode } from './access-token/access-token-mode.js' import { AccountManager } from './account/account-manager.js' import { AccountStore, AuthorizedClientData, DeviceAccount, asAccountStore, } from './account/account-store.js' import { ClientAuth, ClientAuthLegacy } from './client/client-auth.js' import { ClientId } from './client/client-id.js' import { ClientManager, LoopbackMetadataGetter, } from './client/client-manager.js' import { ClientStore, ifClientStore } from './client/client-store.js' import { Client } from './client/client.js' import { AUTHENTICATION_MAX_AGE, CONFIDENTIAL_CLIENT_REFRESH_LIFETIME, CONFIDENTIAL_CLIENT_SESSION_LIFETIME, PUBLIC_CLIENT_REFRESH_LIFETIME, PUBLIC_CLIENT_SESSION_LIFETIME, TOKEN_MAX_AGE, } from './constants.js' import { Branding, BrandingInput } from './customization/branding.js' import { Customization, CustomizationInput, customizationSchema, } from './customization/customization.js' import { DeviceId } from './device/device-id.js' import { DeviceManager, DeviceManagerOptions, deviceManagerOptionsSchema, } from './device/device-manager.js' import { DeviceStore, asDeviceStore } from './device/device-store.js' import { AccountSelectionRequiredError } from './errors/account-selection-required-error.js' import { AuthorizationError } from './errors/authorization-error.js' import { ConsentRequiredError } from './errors/consent-required-error.js' import { InvalidDpopKeyBindingError } from './errors/invalid-dpop-key-binding-error.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 { LoginRequiredError } from './errors/login-required-error.js' import { LexiconManager } from './lexicon/lexicon-manager.js' import { LexiconStore, asLexiconStore } from './lexicon/lexicon-store.js' import { HcaptchaConfig } from './lib/hcaptcha.js' import { RequestMetadata } from './lib/http/request.js' import { dateToRelativeSeconds } from './lib/util/date.js' import { formatError } from './lib/util/error.js' import { MultiLangString } from './lib/util/locale.js' import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js' import { OAuthHooks } from './oauth-hooks.js' import { DpopProof, OAuthVerifier, OAuthVerifierOptions, VerifyTokenPayloadOptions, } from './oauth-verifier.js' import { ReplayStore, ifReplayStore } from './replay/replay-store.js' import { codeSchema } from './request/code.js' import { RequestManager } from './request/request-manager.js' import { RequestStore, asRequestStore } from './request/request-store.js' import { requestUriSchema } from './request/request-uri.js' import { AuthorizationRedirectParameters } from './result/authorization-redirect-parameters.js' import { AuthorizationResultAuthorizePage } from './result/authorization-result-authorize-page.js' import { AuthorizationResultRedirect } from './result/authorization-result-redirect.js' import { ErrorHandler } from './router/error-handler.js' import { AccessTokenPayload } from './signer/access-token-payload.js' import { TokenData } from './token/token-data.js' import { TokenManager } from './token/token-manager.js' import { TokenStore, asTokenStore, refreshTokenSchema, } from './token/token-store.js' import { isPARResponseError } from './types/par-response-error.js' export { AccessTokenMode, Keyset } export type { AccessTokenPayload, AuthorizationRedirectParameters, AuthorizationResultAuthorizePage as AuthorizationResultAuthorize, AuthorizationResultRedirect, Branding, BrandingInput, CustomMetadata, Customization, CustomizationInput, ErrorHandler, HcaptchaConfig, LexiconResolver, MultiLangString, OAuthAuthorizationServerMetadata, VerifyTokenPayloadOptions, } type OAuthProviderConfig = { /** * Maximum age a device/account session can be before requiring * re-authentication. */ authenticationMaxAge?: number /** * Maximum age access & id tokens can be before requiring a refresh. */ tokenMaxAge?: number /** * If set to {@link AccessTokenMode.stateless}, the generated access tokens * will contain all the necessary information to validate the token without * needing to query the database. This is useful for cases where the Resource * Server is on a different host/server than the Authorization Server. * * When set to {@link AccessTokenMode.light}, the access tokens will contain * only the necessary information to validate the token, but the token id * will need to be queried from the database to retrieve the full token * information (scope, audience, etc.) * * @see {@link AccessTokenMode} * @default {AccessTokenMode.stateless} */ accessTokenMode?: AccessTokenMode /** * Additional metadata to be included in the discovery document. */ metadata?: CustomMetadata /** * A custom fetch function that can be used to fetch the client metadata from * the internet. By default, the fetch function is a safeFetchWrap() function * that protects against SSRF attacks, large responses & known bad domains. If * you want to disable all protections, you can provide `globalThis.fetch` as * fetch function. */ safeFetch?: typeof globalThis.fetch /** * A custom ATProto lexicon resolver */ lexiconResolver?: LexiconResolver /** * A redis instance to use for replay protection. If not provided, replay * protection will use memory storage. */ redis?: Redis | RedisOptions | string /** * 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?: Partial< AccountStore & ClientStore & DeviceStore & LexiconStore & ReplayStore & RequestStore & TokenStore > accountStore?: AccountStore clientStore?: ClientStore deviceStore?: DeviceStore lexiconStore?: LexiconStore replayStore?: ReplayStore requestStore?: RequestStore tokenStore?: TokenStore /** * In order to speed up the client fetching process, you can provide a cache * to store HTTP responses. * * @note the cached entries should automatically expire after a certain time (typically 10 minutes) */ clientJwksCache?: SimpleStore /** * In order to speed up the client fetching process, you can provide a cache * to store HTTP responses. * * @note the cached entries should automatically expire after a certain time (typically 10 minutes) */ clientMetadataCache?: SimpleStore /** * 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. * * @default is as specified by ATPROTO */ loopbackMetadata?: null | false | LoopbackMetadataGetter } export type OAuthProviderOptions = OAuthProviderConfig & OAuthVerifierOptions & OAuthHooks & DeviceManagerOptions & CustomizationInput export class OAuthProvider extends OAuthVerifier { protected readonly accessTokenMode: AccessTokenMode protected readonly hooks: OAuthHooks public readonly metadata: OAuthAuthorizationServerMetadata public readonly customization: Customization public readonly authenticationMaxAge: number public readonly accountManager: AccountManager public readonly deviceManager: DeviceManager public readonly clientManager: ClientManager public readonly lexiconManager: LexiconManager public readonly requestManager: RequestManager public readonly tokenManager: TokenManager public constructor({ // OAuthProviderConfig authenticationMaxAge = AUTHENTICATION_MAX_AGE, tokenMaxAge = TOKEN_MAX_AGE, accessTokenMode = AccessTokenMode.stateless, metadata, lexiconResolver, safeFetch = safeFetchWrap(), store, // compound store implementation // Required stores accountStore = asAccountStore(store), deviceStore = asDeviceStore(store), lexiconStore = asLexiconStore(store), tokenStore = asTokenStore(store), requestStore = asRequestStore(store), // Optional stores clientStore = ifClientStore(store), replayStore = ifReplayStore(store), clientJwksCache = new SimpleStoreMemory({ maxSize: 50_000_000, ttl: 600e3, }), clientMetadataCache = new SimpleStoreMemory({ maxSize: 50_000_000, ttl: 600e3, }), loopbackMetadata = atprotoLoopbackClientMetadata, // OAuthHooks & // OAuthVerifierOptions & // DeviceManagerOptions & // Customization ...rest }: OAuthProviderOptions) { const deviceManagerOptions: DeviceManagerOptions = deviceManagerOptionsSchema.parse(rest) super({ replayStore, ...rest }) // @NOTE: hooks don't really need a type parser, as all zod can actually // check at runtime is the fact that the values are functions. The only way // we would benefit from zod here would be to wrap the functions with a // validator for the provided function's return types, which we don't // really need if types are respected. this.hooks = rest this.accessTokenMode = accessTokenMode this.authenticationMaxAge = authenticationMaxAge this.metadata = buildMetadata(this.issuer, this.keyset, metadata) this.customization = customizationSchema.parse(rest) this.deviceManager = new DeviceManager(deviceStore, deviceManagerOptions) this.accountManager = new AccountManager( this.issuer, accountStore, this.hooks, this.customization, ) this.clientManager = new ClientManager( this.metadata, this.keyset, this.hooks, clientStore || null, loopbackMetadata || null, safeFetch, clientJwksCache, clientMetadataCache, ) this.lexiconManager = new LexiconManager(lexiconStore, lexiconResolver) this.requestManager = new RequestManager( requestStore, this.lexiconManager, this.signer, this.metadata, this.hooks, ) this.tokenManager = new TokenManager( tokenStore, this.lexiconManager, this.signer, this.hooks, this.accessTokenMode, tokenMaxAge, ) } get jwks() { return this.keyset.publicJwks } /** * @returns true if the user's consent is required for the requested scopes */ public checkConsentRequired( parameters: OAuthAuthorizationRequestParameters, clientData?: AuthorizedClientData, ) { // Client was never authorized before if (!clientData) return true // Client explicitly asked for consent if (parameters.prompt === 'consent') return true // No scope requested, and client is known by user, no consent required const requestedScopes = parameters.scope?.split(' ') if (requestedScopes == null) return false // Ensure that all requested scopes were previously authorized by the user const { authorizedScopes } = clientData return !requestedScopes.every((scope) => authorizedScopes.includes(scope)) } public checkLoginRequired(deviceAccount: DeviceAccount) { const authAge = Date.now() - deviceAccount.updatedAt.getTime() return authAge > this.authenticationMaxAge } protected async authenticateClient( clientCredentials: OAuthClientCredentials, dpopProof: null | DpopProof, options?: { allowMissingDpopProof?: boolean }, ): Promise<{ client: Client clientAuth: ClientAuth }> { const client = await this.clientManager.getClient( clientCredentials.client_id, ) if ( client.metadata.dpop_bound_access_tokens && !dpopProof && !options?.allowMissingDpopProof ) { throw new InvalidDpopProofError('DPoP proof required') } if (dpopProof && !client.metadata.dpop_bound_access_tokens) { throw new InvalidDpopProofError('DPoP proof not allowed for this client') } const clientAuth = await client.authenticate(clientCredentials, { authorizationServerIdentifier: this.issuer, }) if (clientAuth.method === 'private_key_jwt') { // Clients MUST NOT use their client assertion key to sign DPoP proofs if (dpopProof && clientAuth.jkt === dpopProof.jkt) { throw new InvalidRequestError( 'The DPoP proof must be signed with a different key than the client assertion', ) } // https://www.rfc-editor.org/rfc/rfc7523.html#section-3 // > 7. [...] The authorization server MAY ensure that JWTs are not // > replayed by maintaining the set of used "jti" values for the // > length of time for which the JWT would be considered valid based // > on the applicable "exp" instant. const unique = await this.replayManager.uniqueAuth( clientAuth.jti, client.id, clientAuth.exp, ) if (!unique) { throw new InvalidGrantError(`${clientAuth.method} jti reused`) } } return { client, clientAuth } } protected async decodeJAR( client: Client, input: OAuthAuthorizationRequestJar, ): Promise { const { payload } = await client.decodeRequestObject( input.request, this.issuer, ) const { jti } = payload if (!jti) { throw new InvalidRequestError( 'Request object payload must contain a "jti" claim', ) } if (!(await this.replayManager.uniqueJar(jti, client.id))) { throw new InvalidRequestError('Request object was replayed') } const parameters = await oauthAuthorizationRequestParametersSchema .parseAsync(payload) .catch((err) => { const msg = formatError(err, 'Invalid parameters in JAR') throw new InvalidRequestError(msg, err) }) return parameters } /** * @see {@link https://datatracker.ietf.org/doc/html/rfc9126} */ public async pushedAuthorizationRequest( credentials: OAuthClientCredentials, authorizationRequest: OAuthAuthorizationRequestPar, dpopProof: null | DpopProof, ): Promise { try { const { client, clientAuth } = await this.authenticateClient( credentials, dpopProof, // Allow missing DPoP header for PAR requests as rfc9449 allows it // (though the dpop_jkt parameter must be present in that case, see // check bellow). { allowMissingDpopProof: true }, ) const parameters = 'request' in authorizationRequest // Handle JAR ? await this.decodeJAR(client, authorizationRequest) : authorizationRequest if (!parameters.dpop_jkt) { if (client.metadata.dpop_bound_access_tokens) { if (dpopProof) parameters.dpop_jkt = dpopProof.jkt else { // @NOTE When both PAR and DPoP are used, either the DPoP header, or // the dpop_jkt parameter must be present. We do not enforce this // for legacy reasons. // https://datatracker.ietf.org/doc/html/rfc9449#section-10.1 } } } else { if (!client.metadata.dpop_bound_access_tokens) { throw new InvalidRequestError( 'DPoP bound access tokens are not enabled for this client', ) } // Proof is optional if the dpop_jkt is provided, but if it is provided, // it must match the DPoP proof JKT. if (dpopProof && dpopProof.jkt !== parameters.dpop_jkt) { throw new InvalidDpopKeyBindingError() } } const { requestUri, expiresAt } = await this.requestManager.createAuthorizationRequest( client, clientAuth, parameters, null, ) return { request_uri: requestUri, 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 "access_denied", are never returned. if (err instanceof AuthorizationError && !isPARResponseError(err.error)) { throw new InvalidRequestError(err.error_description, err) } throw err } } private async processAuthorizationRequest( client: Client, deviceId: DeviceId, query: OAuthAuthorizationRequestQuery, ) { // PAR if ('request_uri' in query) { const requestUri = await requestUriSchema .parseAsync(query.request_uri, { path: ['query', 'request_uri'] }) .catch((err) => { const msg = formatError(err, 'Invalid "request_uri" query parameter') throw new InvalidRequestError(msg, err) }) return this.requestManager.get(requestUri, deviceId, client.id) } // JAR if ('request' in query) { // @NOTE Since JAR are signed with the client's private key, a JAR *could* // technically be used to authenticate the client when requests are // created without PAR (i.e. created on the fly by the authorize // endpoint). This implementation actually used to support this // (un-spec'd) behavior. That support was removed: // - Because it was not actually used // - Because it was not part of any standard // - Because it makes extending the client authentication mechanism more // complex since any extension would not only need to affect the // "private_key_jwt" auth method but also the JAR "request" object. const parameters = await this.decodeJAR(client, query) return this.requestManager.createAuthorizationRequest( client, null, parameters, deviceId, ) } // "Regular" authorization request (created on the fly by directing the user // to the authorization endpoint with all the parameters in the url). return this.requestManager.createAuthorizationRequest( client, null, query, deviceId, ) } /** * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-4.1.1} */ public async authorize( clientCredentials: OAuthClientCredentialsNone, query: OAuthAuthorizationRequestQuery, deviceId: DeviceId, deviceMetadata: RequestMetadata, ): Promise { const { issuer } = this // If there is a chance to redirect the user to the client, let's do // it by wrapping the error in an AuthorizationError. const throwAuthorizationError = 'redirect_uri' in query ? (err: unknown): never => { // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-11#section-4.1.2.1 throw AuthorizationError.from(query, err) } : null const client = await this.clientManager .getClient(clientCredentials.client_id) .catch(throwAuthorizationError) const { parameters, requestUri } = await this.processAuthorizationRequest( client, deviceId, query, ).catch(throwAuthorizationError) try { const sessions = await this.getSessions(client.id, deviceId, parameters) if (parameters.prompt === 'none') { const ssoSessions = sessions.filter((s) => s.matchesHint) if (ssoSessions.length > 1) { throw new AccountSelectionRequiredError(parameters) } if (ssoSessions.length < 1) { throw new LoginRequiredError(parameters) } const ssoSession = ssoSessions[0]! if (ssoSession.loginRequired) { throw new LoginRequiredError(parameters) } if (ssoSession.consentRequired) { throw new ConsentRequiredError(parameters) } const code = await this.requestManager.setAuthorized( requestUri, client, ssoSession.account, deviceId, deviceMetadata, ) return { issuer, parameters, redirect: { code } } } // 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 code = await this.requestManager.setAuthorized( requestUri, client, ssoSession.account, deviceId, deviceMetadata, ) return { issuer, parameters, redirect: { code } } } } } return { issuer, client, parameters, requestUri, sessions: sessions.map((session) => ({ // Map to avoid leaking other data that might be present in the session account: session.account, selected: session.selected, loginRequired: session.loginRequired, consentRequired: session.consentRequired, })), permissionSets: await this.lexiconManager .getPermissionSetsFromScope(parameters.scope) .catch((cause) => { throw new AuthorizationError( parameters, 'Unable to retrieve permission sets', 'invalid_scope', cause, ) }), } } catch (err) { try { await this.requestManager.delete(requestUri) } catch { // There are two error here. Better keep the outer one. // // @TODO Maybe move this entire code to the /authorize endpoint // (allowing to log this error) } throw AuthorizationError.from(parameters, err) } } protected async getSessions( clientId: ClientId, deviceId: DeviceId, parameters: OAuthAuthorizationRequestParameters, ): Promise< { account: Account selected: boolean loginRequired: boolean consentRequired: boolean matchesHint: boolean }[] > { const deviceAccounts = await this.accountManager.listDeviceAccounts(deviceId) const hint = parameters.login_hint const matchesHint = (account: Account): boolean => (!!account.sub && account.sub === hint) || (!!account.preferred_username && account.preferred_username === hint) return deviceAccounts.map((deviceAccount) => ({ account: deviceAccount.account, selected: parameters.prompt !== 'select_account' && matchesHint(deviceAccount.account), // @TODO Return the session expiration date instead of a boolean to // avoid having to rely on a leeway when "accepting" the request. loginRequired: parameters.prompt === 'login' || this.checkLoginRequired(deviceAccount), consentRequired: this.checkConsentRequired( parameters, deviceAccount.authorizedClients.get(clientId), ), matchesHint: hint == null || matchesHint(deviceAccount.account), })) } public async token( clientCredentials: OAuthClientCredentials, clientMetadata: RequestMetadata, request: OAuthTokenRequest, dpopProof: null | DpopProof, ): Promise { const { client, clientAuth } = await this.authenticateClient( clientCredentials, dpopProof, ) if (!this.metadata.grant_types_supported?.includes(request.grant_type)) { throw new InvalidGrantError( `Grant type "${request.grant_type}" is not supported by the server`, ) } if (!client.metadata.grant_types.includes(request.grant_type)) { throw new InvalidGrantError( `"${request.grant_type}" grant type is not allowed for this client`, ) } if (request.grant_type === 'authorization_code') { return this.authorizationCodeGrant( client, clientAuth, clientMetadata, request, dpopProof, ) } if (request.grant_type === 'refresh_token') { return this.refreshTokenGrant( client, clientAuth, clientMetadata, request, dpopProof, ) } throw new InvalidGrantError( `Grant type "${request.grant_type}" not supported`, ) } protected async compareClientAuth( client: Client, clientAuth: ClientAuth, dpopProof: null | DpopProof, initial: { parameters: OAuthAuthorizationRequestParameters clientId: ClientId clientAuth: null | ClientAuth | ClientAuthLegacy }, ): Promise { // Fool proofing, ensure that the client is authenticating using the right method if (clientAuth.method !== client.metadata.token_endpoint_auth_method) { throw new InvalidGrantError( `Client authentication method mismatch (expected ${client.metadata.token_endpoint_auth_method}, got ${clientAuth.method})`, ) } if (initial.clientId !== client.id) { throw new InvalidGrantError(`Token was not issued to this client`) } const { parameters } = initial if (parameters.dpop_jkt) { if (!dpopProof) { throw new InvalidGrantError(`DPoP proof is required for this request`) } else if (parameters.dpop_jkt !== dpopProof.jkt) { throw new InvalidGrantError( `DPoP proof does not match the expected JKT`, ) } } if (!initial.clientAuth) { // If the client did not use PAR, it was not authenticated when the request // was initially created (see authorize() method in OAuthProvider). 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). return } switch (initial.clientAuth.method) { case CLIENT_ASSERTION_TYPE_JWT_BEARER: // LEGACY case 'private_key_jwt': if (clientAuth.method !== 'private_key_jwt') { throw new InvalidGrantError( `Client authentication method mismatch (expected ${initial.clientAuth.method})`, ) } if ( clientAuth.kid !== initial.clientAuth.kid || clientAuth.alg !== initial.clientAuth.alg || clientAuth.jkt !== initial.clientAuth.jkt ) { throw new InvalidGrantError( `The session was initiated with a different key than the client assertion currently used`, ) } break case 'none': // @NOTE We allow the client to "upgrade" to a confidential client if // the session was initially created without client authentication. break default: throw new InvalidGrantError( // @ts-expect-error (future proof, backwards compatibility) `Invalid method "${initial.clientAuth.method}"`, ) } } protected async authorizationCodeGrant( client: Client, clientAuth: ClientAuth, clientMetadata: RequestMetadata, input: OAuthAuthorizationCodeGrantTokenRequest, dpopProof: null | DpopProof, ): Promise { const code = await codeSchema .parseAsync(input.code, { path: ['code'] }) .catch((err) => { const msg = formatError(err, 'Invalid code') throw new InvalidGrantError(msg, err) }) const data = await this.requestManager .consumeCode(code) .catch(async (err) => { // Code not found in request manager: check for replays const tokenInfo = await this.tokenManager.findByCode(code) if (tokenInfo) { // try/finally to ensure that both code path get executed (sequentially) try { // "code" was replayed, delete existing session await this.tokenManager.deleteToken(tokenInfo.id) } finally { // As an additional security measure, we also sign the device out, // so that the device cannot be used to access the account anymore // without a new authentication. const { deviceId, sub } = tokenInfo.data if (deviceId) { await this.accountManager.removeDeviceAccount(deviceId, sub) } } } throw InvalidGrantError.from(err, `Invalid code`) }) // @NOTE at this point, the request data was removed from the store and only // exists in memory here (in the "data" variable). Because of this, any // error thrown after this point will permanently cause the request data to // be lost. await this.compareClientAuth(client, clientAuth, dpopProof, data) // If the DPoP proof was not provided earlier (PAR / authorize), let's add // it now. const parameters = dpopProof && client.metadata.dpop_bound_access_tokens && !data.parameters.dpop_jkt ? { ...data.parameters, dpop_jkt: dpopProof.jkt } : data.parameters await this.validateCodeGrant(parameters, input) const { account } = await this.accountManager.getAccount(data.sub) return this.tokenManager.createToken( client, clientAuth, clientMetadata, account, data.deviceId, parameters, code, ) } protected async validateCodeGrant( parameters: OAuthAuthorizationRequestParameters, input: OAuthAuthorizationCodeGrantTokenRequest, ): Promise { if (parameters.redirect_uri !== input.redirect_uri) { throw new InvalidGrantError( 'The redirect_uri parameter must match the one used in the authorization request', ) } if (parameters.code_challenge) { if (!input.code_verifier) { throw new InvalidGrantError('code_verifier is required') } if (input.code_verifier.length < 43) { throw new InvalidGrantError('code_verifier too short') } switch (parameters.code_challenge_method) { case undefined: // default is "plain" case 'plain': if (parameters.code_challenge !== input.code_verifier) { throw new InvalidGrantError('Invalid code_verifier') } break case 'S256': { const inputChallenge = Buffer.from( parameters.code_challenge, 'base64', ) const computedChallenge = createHash('sha256') .update(input.code_verifier) .digest() if (inputChallenge.compare(computedChallenge) !== 0) { throw new InvalidGrantError('Invalid code_verifier') } break } default: // Should never happen (because request validation should catch this) throw new Error(`Unsupported code_challenge_method`) } const unique = await this.replayManager.uniqueCodeChallenge( parameters.code_challenge, ) if (!unique) { throw new InvalidGrantError('Code challenge already used') } } else if (input.code_verifier !== undefined) { throw new InvalidRequestError("code_challenge parameter wasn't provided") } } protected async refreshTokenGrant( client: Client, clientAuth: ClientAuth, clientMetadata: RequestMetadata, input: OAuthRefreshTokenGrantTokenRequest, dpopProof: null | DpopProof, ): Promise { const refreshToken = await refreshTokenSchema .parseAsync(input.refresh_token, { path: ['refresh_token'] }) .catch((err) => { const msg = formatError(err, 'Invalid refresh token') throw new InvalidGrantError(msg, err) }) const tokenInfo = await this.tokenManager.consumeRefreshToken(refreshToken) try { const { data } = tokenInfo await this.compareClientAuth(client, clientAuth, dpopProof, data) await this.validateRefreshGrant(client, clientAuth, data) return await this.tokenManager.rotateToken( client, clientAuth, clientMetadata, tokenInfo, ) } catch (err) { await this.tokenManager.deleteToken(tokenInfo.id) throw err } } protected async validateRefreshGrant( client: Client, clientAuth: ClientAuth, data: TokenData, ): Promise { const [sessionLifetime, refreshLifetime] = clientAuth.method !== 'none' || client.info.isFirstParty ? [ CONFIDENTIAL_CLIENT_SESSION_LIFETIME, CONFIDENTIAL_CLIENT_REFRESH_LIFETIME, ] : [PUBLIC_CLIENT_SESSION_LIFETIME, PUBLIC_CLIENT_REFRESH_LIFETIME] const sessionAge = Date.now() - data.createdAt.getTime() if (sessionAge > sessionLifetime) { throw new InvalidGrantError(`Session expired`) } const refreshAge = Date.now() - data.updatedAt.getTime() if (refreshAge > refreshLifetime) { throw new InvalidGrantError(`Refresh token expired`) } } /** * @see {@link https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 rfc7009} */ public async revoke( clientCredentials: OAuthClientCredentials, { token }: OAuthTokenIdentification, dpopProof: null | DpopProof, ) { // > The authorization server first validates the client credentials (in // > case of a confidential client) const { client, clientAuth } = await this.authenticateClient( clientCredentials, dpopProof, ) const tokenInfo = await this.tokenManager.findToken(token) if (tokenInfo) { // > [...] and then verifies whether the token was issued to the client // > making the revocation request. const { data } = tokenInfo await this.compareClientAuth(client, clientAuth, dpopProof, data) // > In the next step, the authorization server invalidates the token. The // > invalidation takes place immediately, and the token cannot be used // > again after the revocation. await this.tokenManager.deleteToken(tokenInfo.id) } } protected override async decodeToken( tokenType: OAuthTokenType, token: OAuthAccessToken, dpopProof: null | DpopProof, ): Promise { const tokenPayload = await super.decodeToken(tokenType, token, dpopProof) if (this.accessTokenMode !== AccessTokenMode.stateless) { // @NOTE in non stateless mode, some claims can be omitted (most notably // "scope"). We load the token claims here (allowing to ensure that the // token is still valid, and to retrieve a (potentially updated) set of // claims). const tokenClaims = await this.tokenManager.loadTokenClaims( tokenType, tokenPayload, ) Object.assign(tokenPayload, tokenClaims) } return tokenPayload } }