From 88ee906b756f9e08583b6896016c2106b3aaabd6 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 23 Jun 2024 22:33:06 +0800 Subject: [PATCH] refactor(core): fork client credentials grant --- .../lib-keep/shared/check_resource.d.ts | 8 + .../src/oidc/grants/client-credentials.ts | 140 ++++++++++++++++++ packages/core/src/oidc/grants/index.ts | 19 ++- .../core/src/oidc/grants/refresh-token.ts | 19 +-- 4 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/include.d/oidc-provider/lib-keep/shared/check_resource.d.ts create mode 100644 packages/core/src/oidc/grants/client-credentials.ts diff --git a/packages/core/src/include.d/oidc-provider/lib-keep/shared/check_resource.d.ts b/packages/core/src/include.d/oidc-provider/lib-keep/shared/check_resource.d.ts new file mode 100644 index 000000000..b3e301391 --- /dev/null +++ b/packages/core/src/include.d/oidc-provider/lib-keep/shared/check_resource.d.ts @@ -0,0 +1,8 @@ +// https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/shared/check_resource.js +declare module 'oidc-provider/lib/shared/check_resource.js' { + import { type KoaMiddleware } from 'koa'; + + export default async function checkResource( + ...args: Parameters> + ): ReturnType>; +} diff --git a/packages/core/src/oidc/grants/client-credentials.ts b/packages/core/src/oidc/grants/client-credentials.ts new file mode 100644 index 000000000..8e6346d86 --- /dev/null +++ b/packages/core/src/oidc/grants/client-credentials.ts @@ -0,0 +1,140 @@ +/** + * @overview This file implements the custom `client_credentials` grant which extends the original + * `client_credentials` grant with the issuing of organization tokens (based on RFC 0001, but for + * machine-to-machine apps). + * + * Note the code is edited from oidc-provider, most parts are kept the same unless it requires + * changes for TypeScript or RFC 0001. + * + * For "RFC 0001"-related edited parts, we added comments with `=== RFC 0001 ===` and + * `=== End RFC 0001 ===` to indicate the changes. + * + * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0001. + * @see {@link https://github.com/panva/node-oidc-provider/blob/0c52469f08b0a4a1854d90a96546a3f7aa090e5e/lib/actions/grants/client_credentials.js | Original file}. + * + * @remarks + * Since the original code is not exported, we have to copy the code here. This file should be + * treated as a fork of the original file, which means, we should keep the code in sync with the + * original file as much as possible. + * + * The commit hash of the original file is `0c52469f08b0a4a1854d90a96546a3f7aa090e5e`. + */ + +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'; + +import { type EnvSet } from '#src/env-set/index.js'; +import type Queries from '#src/tenants/Queries.js'; +import assertThat from '#src/utils/assert-that.js'; + +const { InvalidClient, InvalidGrant, InvalidScope, InvalidTarget } = errors; + +/** + * The valid parameters for the `client_credentials` grant type. Note the `resource` parameter is + * not included here since it should be handled per configuration when registering the grant type. + */ +export const parameters = Object.freeze(['scope']); + +// We have to disable the rules because the original implementation is written in JavaScript and +// uses mutable variables. +/* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-non-null-assertion */ +export const buildHandler: ( + envSet: EnvSet, + queries: Queries + // eslint-disable-next-line complexity, unicorn/consistent-function-scoping +) => Parameters[1] = (_envSet, _queries) => async (ctx, next) => { + const { client } = ctx.oidc; + const { ClientCredentials, ReplayDetection } = ctx.oidc.provider; + + assertThat(client, new InvalidClient('client must be available')); + + const { + features: { + mTLS: { getCertificate }, + }, + scopes: statics, + } = instance(ctx.oidc.provider).configuration(); + + const dPoP = await dpopValidate(ctx); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + await checkResource(ctx, async () => {}); + + const scopes = ctx.oidc.params?.scope + ? [...new Set(String(ctx.oidc.params.scope).split(' '))] + : []; + + if (client.scope) { + const allowList = new Set(client.scope.split(' ')); + + for (const scope of scopes.filter(Set.prototype.has.bind(statics))) { + if (!allowList.has(scope)) { + throw new InvalidScope('requested scope is not allowed', scope); + } + } + } + + const token = new ClientCredentials({ + client, + scope: scopes.join(' ') || undefined!, + }); + + const { 0: resourceServer, length } = Object.values(ctx.oidc.resourceServers ?? {}); + if (resourceServer) { + if (length !== 1) { + throw new InvalidTarget( + 'only a single resource indicator value is supported for this grant type' + ); + } + token.resourceServer = resourceServer; + token.scope = + scopes.filter(Set.prototype.has.bind(new Set(resourceServer.scope.split(' ')))).join(' ') || + undefined; + } + + if (client.tlsClientCertificateBoundAccessTokens) { + 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 (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'); + } + + ctx.oidc.entity('ClientCredentials', token); + const value = await token.save(); + + ctx.body = { + access_token: value, + expires_in: token.expiration, + token_type: token.tokenType, + scope: token.scope, + }; + + await next(); +}; +/* eslint-enable @silverhand/fp/no-mutation, @typescript-eslint/no-non-null-assertion */ diff --git a/packages/core/src/oidc/grants/index.ts b/packages/core/src/oidc/grants/index.ts index 25d0afd60..3aaa7dc8a 100644 --- a/packages/core/src/oidc/grants/index.ts +++ b/packages/core/src/oidc/grants/index.ts @@ -5,6 +5,7 @@ import instance from 'oidc-provider/lib/helpers/weak_cache.js'; import { type EnvSet } from '#src/env-set/index.js'; import type Queries from '#src/tenants/Queries.js'; +import * as clientCredentials from './client-credentials.js'; import * as refreshToken from './refresh-token.js'; export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries) => { @@ -14,14 +15,22 @@ export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries) // If resource indicators are enabled, append `resource` to the parameters and allow it to // be duplicated - const parameterConfig: [parameters: string[], duplicates: string[]] = resourceIndicators.enabled - ? [[...refreshToken.parameters, 'resource'], ['resource']] - : [[...refreshToken.parameters], []]; + const getParameterConfig = ( + parameters: readonly string[] + ): [parameters: string[], duplicates: string[]] => + resourceIndicators.enabled + ? [[...parameters, 'resource'], ['resource']] + : [[...parameters], []]; - // Override the default `refresh_token` grant + // Override the default grants oidc.registerGrantType( GrantType.RefreshToken, refreshToken.buildHandler(envSet, queries), - ...parameterConfig + ...getParameterConfig(refreshToken.parameters) + ); + oidc.registerGrantType( + GrantType.ClientCredentials, + clientCredentials.buildHandler(envSet, queries), + ...getParameterConfig(clientCredentials.parameters) ); }; diff --git a/packages/core/src/oidc/grants/refresh-token.ts b/packages/core/src/oidc/grants/refresh-token.ts index 9bb2d232c..479e10da5 100644 --- a/packages/core/src/oidc/grants/refresh-token.ts +++ b/packages/core/src/oidc/grants/refresh-token.ts @@ -9,7 +9,7 @@ * `=== End RFC 0001 ===` to indicate the changes. * * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0001. - * @see {@link https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/actions/grants/refresh_token.js | oidc-provider/lib/actions/grants/refresh_token.js} for the original code. + * @see {@link https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/actions/grants/refresh_token.js | Original file}. * * @remarks * Since the original code is not exported, we have to copy the code here. This file should be @@ -46,30 +46,23 @@ import { isOrganizationConsentedToApplication, } from '../resource.js'; -const { - InvalidClient, - InvalidGrant, - InvalidScope, - InsufficientScope, - AccessDenied, - InvalidRequest, -} = errors; +const { InvalidClient, InvalidGrant, InvalidScope, InsufficientScope, AccessDenied } = errors; /** The grant type name. `gty` follows the name in oidc-provider. */ const gty = 'refresh_token'; /** - * The valid parameters for the `organization_token` grant type. Note the `resource` parameter is + * The valid parameters for the `refresh_token` grant type. Note the `resource` parameter is * not included here since it should be handled per configuration when registering the grant type. */ -export const parameters = Object.freeze(['refresh_token', 'organization_id', 'scope'] as const); +export const parameters = Object.freeze(['refresh_token', 'scope', 'organization_id']); /** * The required parameters for the grant type. * * @see {@link parameters} for the full list of valid parameters. */ -const requiredParameters = Object.freeze(['refresh_token'] as const) satisfies ReadonlyArray< +const requiredParameters = Object.freeze(['refresh_token']) satisfies ReadonlyArray< (typeof parameters)[number] >; @@ -80,7 +73,7 @@ export const buildHandler: ( envSet: EnvSet, queries: Queries // eslint-disable-next-line complexity -) => Parameters['1'] = (envSet, queries) => async (ctx, next) => { +) => Parameters[1] = (envSet, queries) => async (ctx, next) => { const { client, params, requestParamScopes, provider } = ctx.oidc; const { RefreshToken, Account, AccessToken, Grant, ReplayDetection, IdToken } = provider;