Matthieu Sieben cf4117966c
Fix call to onDecodeToken oauth verifier hook (#4191)
* Make `DpopProof` readonly

* Improve token verification error details

* Always log warnings when DPOP proof `htu` contains # or ?.

* Add missing initialization of `onDecodeToken` hook

* Add logging around scope dereferencing operations
2025-09-09 15:56:32 +02:00

1098 lines
36 KiB
TypeScript

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
* `<name>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<string, Jwks>
/**
* 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<string, OAuthClientMetadata>
/**
* 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<OAuthAuthorizationRequestParameters> {
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<OAuthParResponse> {
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<AuthorizationResultRedirect | AuthorizationResultAuthorizePage> {
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<OAuthTokenResponse> {
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<void> {
// 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<OAuthTokenResponse> {
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<void> {
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<OAuthTokenResponse> {
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<void> {
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<AccessTokenPayload> {
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
}
}