From 0e31d237e2b7712e1cf9d4cf9bcbd1bd423cb316 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Mon, 8 Jul 2024 17:04:07 +0800 Subject: [PATCH] feat(core): handle dpop and client certificate for token exchange --- .../src/oidc/grants/client-credentials.ts | 27 ++----- .../core/src/oidc/grants/refresh-token.ts | 57 ++----------- .../src/oidc/grants/token-exchange/index.ts | 5 +- packages/core/src/oidc/grants/utils.ts | 81 +++++++++++++++++++ 4 files changed, 95 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/oidc/grants/utils.ts diff --git a/packages/core/src/oidc/grants/client-credentials.ts b/packages/core/src/oidc/grants/client-credentials.ts index 5bf06f889ac..8aafdeec2ed 100644 --- a/packages/core/src/oidc/grants/client-credentials.ts +++ b/packages/core/src/oidc/grants/client-credentials.ts @@ -23,8 +23,6 @@ import { buildOrganizationUrn } from '@logto/core-kit'; import { cond } from '@silverhand/essentials'; import type Provider from 'oidc-provider'; import { errors } from 'oidc-provider'; -import epochTime from 'oidc-provider/lib/helpers/epoch_time.js'; -import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js'; import instance from 'oidc-provider/lib/helpers/weak_cache.js'; import checkResource from 'oidc-provider/lib/shared/check_resource.js'; @@ -34,6 +32,8 @@ import assertThat from '#src/utils/assert-that.js'; import { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js'; +import { handleClientCertificate, handleDPoP } from './utils.js'; + const { AccessDenied, InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors; /** @@ -51,7 +51,7 @@ export const buildHandler: ( // eslint-disable-next-line complexity ) => Parameters[1] = (envSet, queries) => async (ctx, next) => { const { client, params } = ctx.oidc; - const { ClientCredentials, ReplayDetection } = ctx.oidc.provider; + const { ClientCredentials } = ctx.oidc.provider; assertThat(client, new InvalidClient('client must be available')); @@ -62,8 +62,6 @@ export const buildHandler: ( scopes: statics, } = instance(ctx.oidc.provider).configuration(); - const dPoP = await dpopValidate(ctx); - /* === RFC 0006 === */ // The value type is `unknown`, which will swallow other type inferences. So we have to cast it // to `Boolean` first. @@ -166,23 +164,8 @@ export const buildHandler: ( token.setThumbprint('x5t', cert); } - if (dPoP) { - // @ts-expect-error -- code from oidc-provider - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const unique: unknown = await ReplayDetection.unique( - client.clientId, - dPoP.jti, - epochTime() + 300 - ); - - assertThat(unique, new InvalidGrant('DPoP proof JWT Replay detected')); - - // @ts-expect-error -- code from oidc-provider - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - token.setThumbprint('jkt', dPoP.thumbprint); - } else if (ctx.oidc.client?.dpopBoundAccessTokens) { - throw new InvalidGrant('DPoP proof JWT not provided'); - } + await handleDPoP(ctx, token); + await handleClientCertificate(ctx, token); ctx.oidc.entity('ClientCredentials', token); const value = await token.save(); diff --git a/packages/core/src/oidc/grants/refresh-token.ts b/packages/core/src/oidc/grants/refresh-token.ts index 87677f4f034..f3fa05fff5b 100644 --- a/packages/core/src/oidc/grants/refresh-token.ts +++ b/packages/core/src/oidc/grants/refresh-token.ts @@ -19,19 +19,14 @@ * The commit hash of the original file is `cf2069cbb31a6a855876e95157372d25dde2511c`. */ -import { type X509Certificate } from 'node:crypto'; - import { UserScope, buildOrganizationUrn } from '@logto/core-kit'; -import { type Optional, isKeyInObject, cond } from '@silverhand/essentials'; +import { isKeyInObject, cond } from '@silverhand/essentials'; import type Provider from 'oidc-provider'; import { errors } from 'oidc-provider'; import difference from 'oidc-provider/lib/helpers/_/difference.js'; -import certificateThumbprint from 'oidc-provider/lib/helpers/certificate_thumbprint.js'; -import epochTime from 'oidc-provider/lib/helpers/epoch_time.js'; import filterClaims from 'oidc-provider/lib/helpers/filter_claims.js'; import resolveResource from 'oidc-provider/lib/helpers/resolve_resource.js'; import revoke from 'oidc-provider/lib/helpers/revoke.js'; -import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js'; import validatePresence from 'oidc-provider/lib/helpers/validate_presence.js'; import instance from 'oidc-provider/lib/helpers/weak_cache.js'; @@ -46,6 +41,8 @@ import { isOrganizationConsentedToApplication, } from '../resource.js'; +import { handleClientCertificate, handleDPoP } from './utils.js'; + const { InvalidClient, InvalidGrant, InvalidScope, InsufficientScope, AccessDenied } = errors; /** The grant type name. `gty` follows the name in oidc-provider. */ @@ -93,8 +90,6 @@ export const buildHandler: ( }, } = providerInstance.configuration(); - const dPoP = await dpopValidate(ctx); - // @gao: I believe the presence of the param is validated by required parameters of this grant. // Add `String` to make TS happy. let refreshTokenValue = String(params.refresh_token); @@ -112,23 +107,6 @@ export const buildHandler: ( throw new InvalidGrant('refresh token is expired'); } - let cert: Optional; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- the original code uses `||` - if (client.tlsClientCertificateBoundAccessTokens || refreshToken['x5t#S256']) { - cert = getCertificate(ctx); - if (!cert) { - throw new InvalidGrant('mutual TLS client certificate not provided'); - } - } - - if (!dPoP && client.dpopBoundAccessTokens) { - throw new InvalidGrant('DPoP proof JWT not provided'); - } - - if (refreshToken['x5t#S256'] && refreshToken['x5t#S256'] !== certificateThumbprint(cert!)) { - throw new InvalidGrant('failed x5t#S256 verification'); - } - /* === RFC 0001 === */ // The value type is `unknown`, which will swallow other type inferences. So we have to cast it // to `Boolean` first. @@ -177,22 +155,6 @@ export const buildHandler: ( } } - if (dPoP) { - // @ts-expect-error -- code from oidc-provider - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - const unique: unknown = await ReplayDetection.unique( - client.clientId, - dPoP.jti, - epochTime() + 300 - ); - - assertThat(unique, new errors.InvalidGrant('DPoP proof JWT Replay detected')); - } - - if (refreshToken.jkt && (!dPoP || refreshToken.jkt !== dPoP.thumbprint)) { - throw new InvalidGrant('failed jkt verification'); - } - ctx.oidc.entity('RefreshToken', refreshToken); ctx.oidc.entity('Grant', grant); @@ -304,17 +266,8 @@ export const buildHandler: ( scope: undefined!, }); - if (client.tlsClientCertificateBoundAccessTokens) { - // @ts-expect-error -- code from oidc-provider - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - at.setThumbprint('x5t', cert); - } - - if (dPoP) { - // @ts-expect-error -- code from oidc-provider - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - at.setThumbprint('jkt', dPoP.thumbprint); - } + await handleDPoP(ctx, at, refreshToken); + await handleClientCertificate(ctx, at, refreshToken); if (at.gty && !at.gty.endsWith(gty)) { at.gty = `${at.gty} ${gty}`; diff --git a/packages/core/src/oidc/grants/token-exchange/index.ts b/packages/core/src/oidc/grants/token-exchange/index.ts index f672bad78e6..b4758e7537e 100644 --- a/packages/core/src/oidc/grants/token-exchange/index.ts +++ b/packages/core/src/oidc/grants/token-exchange/index.ts @@ -22,6 +22,7 @@ import { getSharedResourceServerData, reversedResourceAccessTokenTtl, } from '../../resource.js'; +import { handleClientCertificate, handleDPoP } from '../utils.js'; import { handleActorToken } from './actor-token.js'; import { TokenExchangeTokenType, type TokenExchangeAct } from './types.js'; @@ -93,7 +94,6 @@ export const buildHandler: ( throw new InvalidGrant('refresh token invalid (referenced account not found)'); } - // TODO: (LOG-9501) Implement general security checks like dPop ctx.oidc.entity('Account', account); /* === RFC 0001 === */ @@ -137,6 +137,9 @@ export const buildHandler: ( scope: undefined!, }); + await handleDPoP(ctx, accessToken); + await handleClientCertificate(ctx, accessToken); + /** The scopes requested by the client. If not provided, use the scopes from the refresh token. */ const scope = requestParamScopes; const resource = await resolveResource( diff --git a/packages/core/src/oidc/grants/utils.ts b/packages/core/src/oidc/grants/utils.ts new file mode 100644 index 00000000000..7720c67ef93 --- /dev/null +++ b/packages/core/src/oidc/grants/utils.ts @@ -0,0 +1,81 @@ +import type Provider from 'oidc-provider'; +import { errors, type KoaContextWithOIDC } from 'oidc-provider'; +import certificateThumbprint from 'oidc-provider/lib/helpers/certificate_thumbprint.js'; +import epochTime from 'oidc-provider/lib/helpers/epoch_time.js'; +import dpopValidate from 'oidc-provider/lib/helpers/validate_dpop.js'; +import instance from 'oidc-provider/lib/helpers/weak_cache.js'; + +import assertThat from '#src/utils/assert-that.js'; + +const { InvalidGrant, InvalidClient } = errors; + +/** + * Handle DPoP bound access tokens. + */ +export const handleDPoP = async ( + ctx: KoaContextWithOIDC, + token: InstanceType | InstanceType, + originalToken?: InstanceType +) => { + const { client } = ctx.oidc; + assertThat(client, new InvalidClient('client must be available')); + + const dPoP = await dpopValidate(ctx); + + if (dPoP) { + // @ts-expect-error -- code from oidc-provider + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const unique: unknown = await ReplayDetection.unique( + client.clientId, + dPoP.jti, + epochTime() + 300 + ); + + assertThat(unique, new InvalidGrant('DPoP proof JWT Replay detected')); + + // @ts-expect-error -- code from oidc-provider + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + token.setThumbprint('jkt', dPoP.thumbprint); + } else if (client.dpopBoundAccessTokens) { + throw new InvalidGrant('DPoP proof JWT not provided'); + } + + if (originalToken?.jkt && (!dPoP || originalToken.jkt !== dPoP.thumbprint)) { + throw new InvalidGrant('failed jkt verification'); + } +}; + +/** + * Handle client certificate bound access tokens. + */ +export const handleClientCertificate = async ( + ctx: KoaContextWithOIDC, + token: InstanceType | InstanceType, + originalToken?: InstanceType +) => { + const { client, provider } = ctx.oidc; + assertThat(client, new InvalidClient('client must be available')); + + const providerInstance = instance(provider); + const { + features: { + mTLS: { getCertificate }, + }, + } = providerInstance.configuration(); + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (client.tlsClientCertificateBoundAccessTokens || originalToken?.['x5t#S256']) { + const cert = getCertificate(ctx); + + if (!cert) { + throw new InvalidGrant('mutual TLS client certificate not provided'); + } + // @ts-expect-error -- code from oidc-provider + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + token.setThumbprint('x5t', cert); + + if (originalToken?.['x5t#S256'] && originalToken['x5t#S256'] !== certificateThumbprint(cert)) { + throw new InvalidGrant('failed x5t#S256 verification'); + } + } +};