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:
parent
a43434c42f
commit
88ee906b75
4 changed files with 168 additions and 18 deletions
8
packages/core/src/include.d/oidc-provider/lib-keep/shared/check_resource.d.ts
vendored
Normal file
8
packages/core/src/include.d/oidc-provider/lib-keep/shared/check_resource.d.ts
vendored
Normal 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>>;
|
||||
}
|
140
packages/core/src/oidc/grants/client-credentials.ts
Normal file
140
packages/core/src/oidc/grants/client-credentials.ts
Normal 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 */
|
|
@ -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)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
Loading…
Reference in a new issue