mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): handle dpop and client certificate for token exchange (#6199)
This commit is contained in:
parent
ba415c92ed
commit
f9d6137048
4 changed files with 95 additions and 75 deletions
|
@ -23,8 +23,6 @@ import { buildOrganizationUrn } from '@logto/core-kit';
|
||||||
import { cond } from '@silverhand/essentials';
|
import { cond } from '@silverhand/essentials';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
import { errors } 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 instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
||||||
import checkResource from 'oidc-provider/lib/shared/check_resource.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 { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js';
|
||||||
|
|
||||||
|
import { handleClientCertificate, handleDPoP } from './utils.js';
|
||||||
|
|
||||||
const { AccessDenied, InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors;
|
const { AccessDenied, InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -51,7 +51,7 @@ export const buildHandler: (
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
) => Parameters<Provider['registerGrantType']>[1] = (envSet, queries) => async (ctx, next) => {
|
) => Parameters<Provider['registerGrantType']>[1] = (envSet, queries) => async (ctx, next) => {
|
||||||
const { client, params } = ctx.oidc;
|
const { client, params } = ctx.oidc;
|
||||||
const { ClientCredentials, ReplayDetection } = ctx.oidc.provider;
|
const { ClientCredentials } = ctx.oidc.provider;
|
||||||
|
|
||||||
assertThat(client, new InvalidClient('client must be available'));
|
assertThat(client, new InvalidClient('client must be available'));
|
||||||
|
|
||||||
|
@ -62,8 +62,6 @@ export const buildHandler: (
|
||||||
scopes: statics,
|
scopes: statics,
|
||||||
} = instance(ctx.oidc.provider).configuration();
|
} = instance(ctx.oidc.provider).configuration();
|
||||||
|
|
||||||
const dPoP = await dpopValidate(ctx);
|
|
||||||
|
|
||||||
/* === RFC 0006 === */
|
/* === RFC 0006 === */
|
||||||
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
|
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
|
||||||
// to `Boolean` first.
|
// to `Boolean` first.
|
||||||
|
@ -166,23 +164,8 @@ export const buildHandler: (
|
||||||
token.setThumbprint('x5t', cert);
|
token.setThumbprint('x5t', cert);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dPoP) {
|
await handleDPoP(ctx, token);
|
||||||
// @ts-expect-error -- code from oidc-provider
|
await handleClientCertificate(ctx, token);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.oidc.entity('ClientCredentials', token);
|
ctx.oidc.entity('ClientCredentials', token);
|
||||||
const value = await token.save();
|
const value = await token.save();
|
||||||
|
|
|
@ -19,19 +19,14 @@
|
||||||
* The commit hash of the original file is `cf2069cbb31a6a855876e95157372d25dde2511c`.
|
* The commit hash of the original file is `cf2069cbb31a6a855876e95157372d25dde2511c`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type X509Certificate } from 'node:crypto';
|
|
||||||
|
|
||||||
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
|
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 type Provider from 'oidc-provider';
|
||||||
import { errors } from 'oidc-provider';
|
import { errors } from 'oidc-provider';
|
||||||
import difference from 'oidc-provider/lib/helpers/_/difference.js';
|
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 filterClaims from 'oidc-provider/lib/helpers/filter_claims.js';
|
||||||
import resolveResource from 'oidc-provider/lib/helpers/resolve_resource.js';
|
import resolveResource from 'oidc-provider/lib/helpers/resolve_resource.js';
|
||||||
import revoke from 'oidc-provider/lib/helpers/revoke.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 validatePresence from 'oidc-provider/lib/helpers/validate_presence.js';
|
||||||
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
||||||
|
|
||||||
|
@ -46,6 +41,8 @@ import {
|
||||||
isOrganizationConsentedToApplication,
|
isOrganizationConsentedToApplication,
|
||||||
} from '../resource.js';
|
} from '../resource.js';
|
||||||
|
|
||||||
|
import { handleClientCertificate, handleDPoP } from './utils.js';
|
||||||
|
|
||||||
const { InvalidClient, InvalidGrant, InvalidScope, InsufficientScope, AccessDenied } = errors;
|
const { InvalidClient, InvalidGrant, InvalidScope, InsufficientScope, AccessDenied } = errors;
|
||||||
|
|
||||||
/** The grant type name. `gty` follows the name in oidc-provider. */
|
/** The grant type name. `gty` follows the name in oidc-provider. */
|
||||||
|
@ -93,8 +90,6 @@ export const buildHandler: (
|
||||||
},
|
},
|
||||||
} = providerInstance.configuration();
|
} = providerInstance.configuration();
|
||||||
|
|
||||||
const dPoP = await dpopValidate(ctx);
|
|
||||||
|
|
||||||
// @gao: I believe the presence of the param is validated by required parameters of this grant.
|
// @gao: I believe the presence of the param is validated by required parameters of this grant.
|
||||||
// Add `String` to make TS happy.
|
// Add `String` to make TS happy.
|
||||||
let refreshTokenValue = String(params.refresh_token);
|
let refreshTokenValue = String(params.refresh_token);
|
||||||
|
@ -112,23 +107,6 @@ export const buildHandler: (
|
||||||
throw new InvalidGrant('refresh token is expired');
|
throw new InvalidGrant('refresh token is expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
let cert: Optional<string | X509Certificate>;
|
|
||||||
// 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 === */
|
/* === RFC 0001 === */
|
||||||
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
|
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
|
||||||
// to `Boolean` first.
|
// 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('RefreshToken', refreshToken);
|
||||||
ctx.oidc.entity('Grant', grant);
|
ctx.oidc.entity('Grant', grant);
|
||||||
|
|
||||||
|
@ -304,17 +266,8 @@ export const buildHandler: (
|
||||||
scope: undefined!,
|
scope: undefined!,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (client.tlsClientCertificateBoundAccessTokens) {
|
await handleDPoP(ctx, at, refreshToken);
|
||||||
// @ts-expect-error -- code from oidc-provider
|
await handleClientCertificate(ctx, at, refreshToken);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (at.gty && !at.gty.endsWith(gty)) {
|
if (at.gty && !at.gty.endsWith(gty)) {
|
||||||
at.gty = `${at.gty} ${gty}`;
|
at.gty = `${at.gty} ${gty}`;
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
getSharedResourceServerData,
|
getSharedResourceServerData,
|
||||||
reversedResourceAccessTokenTtl,
|
reversedResourceAccessTokenTtl,
|
||||||
} from '../../resource.js';
|
} from '../../resource.js';
|
||||||
|
import { handleClientCertificate, handleDPoP } from '../utils.js';
|
||||||
|
|
||||||
import { handleActorToken } from './actor-token.js';
|
import { handleActorToken } from './actor-token.js';
|
||||||
import { TokenExchangeTokenType, type TokenExchangeAct } from './types.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)');
|
throw new InvalidGrant('refresh token invalid (referenced account not found)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: (LOG-9501) Implement general security checks like dPop
|
|
||||||
ctx.oidc.entity('Account', account);
|
ctx.oidc.entity('Account', account);
|
||||||
|
|
||||||
/* === RFC 0001 === */
|
/* === RFC 0001 === */
|
||||||
|
@ -137,6 +137,9 @@ export const buildHandler: (
|
||||||
scope: undefined!,
|
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. */
|
/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
|
||||||
const scope = requestParamScopes;
|
const scope = requestParamScopes;
|
||||||
const resource = await resolveResource(
|
const resource = await resolveResource(
|
||||||
|
|
81
packages/core/src/oidc/grants/utils.ts
Normal file
81
packages/core/src/oidc/grants/utils.ts
Normal file
|
@ -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<Provider['AccessToken']> | InstanceType<Provider['ClientCredentials']>,
|
||||||
|
originalToken?: InstanceType<Provider['RefreshToken']>
|
||||||
|
) => {
|
||||||
|
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<Provider['AccessToken']> | InstanceType<Provider['ClientCredentials']>,
|
||||||
|
originalToken?: InstanceType<Provider['RefreshToken']>
|
||||||
|
) => {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in a new issue