0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(core): fork client credentials grant

This commit is contained in:
Gao Sun 2024-06-23 22:33:06 +08:00
parent a43434c42f
commit 88ee906b75
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
4 changed files with 168 additions and 18 deletions

View file

@ -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<T, R>(
...args: Parameters<KoaMiddleware<T, R>>
): ReturnType<KoaMiddleware<T, R>>;
}

View file

@ -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<Provider['registerGrantType']>[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 */

View file

@ -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)
);
};

View file

@ -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<Provider['registerGrantType']>['1'] = (envSet, queries) => async (ctx, next) => {
) => Parameters<Provider['registerGrantType']>[1] = (envSet, queries) => async (ctx, next) => {
const { client, params, requestParamScopes, provider } = ctx.oidc;
const { RefreshToken, Account, AccessToken, Grant, ReplayDetection, IdToken } = provider;