Skip to content

Commit

Permalink
refactor(core): fork client credentials grant
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun committed Jun 23, 2024
1 parent a43434c commit 480e5c4
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -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>>;
}
139 changes: 139 additions & 0 deletions packages/core/src/oidc/grants/client-credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* @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
) => 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);

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

Check warning on line 138 in packages/core/src/oidc/grants/client-credentials.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/grants/client-credentials.ts#L50-L138

Added lines #L50 - L138 were not covered by tests
/* eslint-enable @silverhand/fp/no-mutation, @typescript-eslint/no-non-null-assertion */
19 changes: 14 additions & 5 deletions packages/core/src/oidc/grants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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)
);
};
17 changes: 5 additions & 12 deletions packages/core/src/oidc/grants/refresh-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,23 +46,16 @@ 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'] as const);

/**
* The required parameters for the grant type.
Expand All @@ -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;

Expand Down

0 comments on commit 480e5c4

Please sign in to comment.