mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(core): reuse refresh_token
grant for org tokens
This commit is contained in:
parent
73f348af89
commit
d3e7cff0bd
16 changed files with 469 additions and 308 deletions
|
@ -69,11 +69,6 @@ const grantTypeToExchangeByType: Record<GrantType, token.ExchangeByType> = {
|
|||
[GrantType.AuthorizationCode]: token.ExchangeByType.AuthorizationCode,
|
||||
[GrantType.RefreshToken]: token.ExchangeByType.RefreshToken,
|
||||
[GrantType.ClientCredentials]: token.ExchangeByType.ClientCredentials,
|
||||
/**
|
||||
* The organization token grant also uses refresh token to exchange for access token.
|
||||
* See [RFC 0001](https://github.com/logto-io/rfcs) for more details.
|
||||
*/
|
||||
[GrantType.OrganizationToken]: token.ExchangeByType.RefreshToken,
|
||||
};
|
||||
|
||||
const getExchangeByType = (grantType: unknown): token.ExchangeByType => {
|
||||
|
|
|
@ -1 +1 @@
|
|||
The original folder named `lib` which will be ignored by our build system. Change the name to `lib.keep` to avoid confusion.
|
||||
The original folder named `lib` which will be ignored by our build system. Change the name to `lib-keep` to avoid confusion.
|
||||
|
|
6
packages/core/src/include.d/oidc-provider/lib-keep/helpers/certificate_thumbprint.d.ts
vendored
Normal file
6
packages/core/src/include.d/oidc-provider/lib-keep/helpers/certificate_thumbprint.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
// https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/certificate_thumbprint.js
|
||||
declare module 'oidc-provider/lib/helpers/certificate_thumbprint.js' {
|
||||
import { type X509Certificate } from 'node:crypto';
|
||||
|
||||
export default function certificateThumbprint(cert: string | X509Certificate): string;
|
||||
}
|
4
packages/core/src/include.d/oidc-provider/lib-keep/helpers/epoch_time.d.ts
vendored
Normal file
4
packages/core/src/include.d/oidc-provider/lib-keep/helpers/epoch_time.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
// https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/epoch_time.js
|
||||
declare module 'oidc-provider/lib/helpers/epoch_time.js' {
|
||||
export default function epochTime(): number;
|
||||
}
|
11
packages/core/src/include.d/oidc-provider/lib-keep/helpers/filter_claims.d.ts
vendored
Normal file
11
packages/core/src/include.d/oidc-provider/lib-keep/helpers/filter_claims.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
// https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/filter_claims.js
|
||||
declare module 'oidc-provider/lib/helpers/filter_claims.js' {
|
||||
import { type ClaimsParameter } from 'oidc-provider';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
export default function filterClaims(
|
||||
source: ClaimsParameter | undefined,
|
||||
target: keyof ClaimsParameter,
|
||||
grant: InstanceType<Provider['Grant']>
|
||||
): NonNullable<ClaimsParameter[keyof ClaimsParameter]>;
|
||||
}
|
12
packages/core/src/include.d/oidc-provider/lib-keep/helpers/resolve_resource.d.ts
vendored
Normal file
12
packages/core/src/include.d/oidc-provider/lib-keep/helpers/resolve_resource.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
|||
// https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/resolve_resource.js
|
||||
declare module 'oidc-provider/lib/helpers/resolve_resource.js' {
|
||||
import { type Optional } from '@silverhand/essentials';
|
||||
import { type KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
export default function resolveResource(
|
||||
ctx: KoaContextWithOIDC,
|
||||
model: unknown,
|
||||
config: unknown,
|
||||
scopes?: Set<string>
|
||||
): Promise<Optional<string>>;
|
||||
}
|
9
packages/core/src/include.d/oidc-provider/lib-keep/helpers/validate_dpop.d.ts
vendored
Normal file
9
packages/core/src/include.d/oidc-provider/lib-keep/helpers/validate_dpop.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
// https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/validate_dpop.js
|
||||
declare module 'oidc-provider/lib/helpers/validate_dpop.js' {
|
||||
import { type Optional } from '@silverhand/essentials';
|
||||
import type { KoaContextWithOIDC } from 'oidc-provider';
|
||||
|
||||
export default function dpopValidate(
|
||||
ctx: KoaContextWithOIDC
|
||||
): Promise<Optional<{ thumbprint: string; jti?: string; iat?: string }>>;
|
||||
}
|
|
@ -1,276 +0,0 @@
|
|||
/**
|
||||
* @overview This file implements the custom grant type for organization token, which is defined
|
||||
* in RFC 0001.
|
||||
*
|
||||
* Note the code is edited from the `refresh_token` grant type from [oidc-provider](https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/actions/grants/refresh_token.js).
|
||||
* 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.
|
||||
*
|
||||
* @remarks
|
||||
* The original implementation supports DPoP and mutual TLS client authentication, which are not
|
||||
* enabled in Logto. So we removed related code to simplify the implementation. They can be added
|
||||
* back if needed.
|
||||
*
|
||||
* The original implementation also supports issuing ID tokens. But we don't support it for now
|
||||
* due to the lack of development type definitions in the `IdToken` class.
|
||||
*/
|
||||
|
||||
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
|
||||
import { GrantType } from '@logto/schemas';
|
||||
import { isKeyInObject } from '@silverhand/essentials';
|
||||
import type Provider from 'oidc-provider';
|
||||
import { errors } from 'oidc-provider';
|
||||
import difference from 'oidc-provider/lib/helpers/_/difference.js';
|
||||
import revoke from 'oidc-provider/lib/helpers/revoke.js';
|
||||
import validatePresence from 'oidc-provider/lib/helpers/validate_presence.js';
|
||||
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
||||
|
||||
import { type EnvSet } from '#src/env-set/index.js';
|
||||
import type OrganizationQueries from '#src/queries/organizations.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js';
|
||||
|
||||
const {
|
||||
InvalidClient,
|
||||
InvalidRequest,
|
||||
InvalidGrant,
|
||||
InvalidScope,
|
||||
InsufficientScope,
|
||||
AccessDenied,
|
||||
} = errors;
|
||||
|
||||
const grantType = GrantType.OrganizationToken;
|
||||
|
||||
/** The valid parameters for the `organization_token` grant type. */
|
||||
export const parameters = Object.freeze(['refresh_token', 'organization_id', 'scope'] as const);
|
||||
|
||||
/**
|
||||
* The required parameters for the `organization_token` grant type.
|
||||
*
|
||||
* @see {@link parameters} for the full list of valid parameters.
|
||||
*/
|
||||
const requiredParameters = Object.freeze([
|
||||
'refresh_token',
|
||||
'organization_id',
|
||||
] as const) satisfies ReadonlyArray<(typeof parameters)[number]>;
|
||||
|
||||
/**
|
||||
* The required scope for the `urn:logto:grant-type:organization_token` grant type.
|
||||
*
|
||||
* @see {@link GrantType.OrganizationToken}
|
||||
*/
|
||||
const requiredScope = UserScope.Organizations;
|
||||
|
||||
// We have to disable the rules because the original implementation is written in JavaScript and
|
||||
// uses mutable variables.
|
||||
/* eslint-disable @silverhand/fp/no-let, @typescript-eslint/no-non-null-assertion, @silverhand/fp/no-mutation, unicorn/no-array-method-this-argument */
|
||||
export const buildHandler: (
|
||||
envSet: EnvSet,
|
||||
queries: OrganizationQueries
|
||||
// eslint-disable-next-line complexity
|
||||
) => Parameters<Provider['registerGrantType']>['1'] = (envSet, queries) => async (ctx, next) => {
|
||||
const providerInstance = instance(ctx.oidc.provider);
|
||||
const { rotateRefreshToken } = providerInstance.configuration();
|
||||
const { client, params, requestParamScopes, provider } = ctx.oidc;
|
||||
const { RefreshToken, Account, AccessToken, Grant } = provider;
|
||||
|
||||
assertThat(params, new InvalidGrant('parameters must be available'));
|
||||
validatePresence(ctx, ...requiredParameters);
|
||||
|
||||
assertThat(client, new InvalidClient('client must be available'));
|
||||
|
||||
// @gao: I believe the presence of the param is validated by required parameters of this grant.
|
||||
// Add `String` to make TS happy.
|
||||
let refreshTokenValue = String(params.refresh_token);
|
||||
let refreshToken = await RefreshToken.find(refreshTokenValue, { ignoreExpiration: true });
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new InvalidGrant('refresh token not found');
|
||||
}
|
||||
|
||||
if (refreshToken.clientId !== client.clientId) {
|
||||
throw new InvalidGrant('client mismatch');
|
||||
}
|
||||
|
||||
if (refreshToken.isExpired) {
|
||||
throw new InvalidGrant('refresh token is expired');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- code from oidc-provider
|
||||
if (client.tlsClientCertificateBoundAccessTokens || refreshToken['x5t#S256']) {
|
||||
throw new InvalidRequest(
|
||||
'mutual TLS client authentication is not supported for this grant type'
|
||||
);
|
||||
}
|
||||
|
||||
/* === RFC 0001 === */
|
||||
// Validate if the refresh token has the required scope from RFC 0001.
|
||||
if (!refreshToken.scopes.has(requiredScope)) {
|
||||
throw new InsufficientScope('refresh token missing required scope', requiredScope);
|
||||
}
|
||||
/* === End RFC 0001 === */
|
||||
|
||||
if (!refreshToken.grantId) {
|
||||
throw new InvalidGrant('grantId not found');
|
||||
}
|
||||
|
||||
const grant = await Grant.find(refreshToken.grantId, {
|
||||
ignoreExpiration: true,
|
||||
});
|
||||
|
||||
if (!grant) {
|
||||
throw new InvalidGrant('grant not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* It's actually available on the `BaseModel` class - but missing from the typings.
|
||||
*
|
||||
* @see {@link https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/models/base_model.js#L128 | oidc-provider/lib/models/base_model.js#L128}
|
||||
*/
|
||||
if (isKeyInObject(grant, 'isExpired') && grant.isExpired) {
|
||||
throw new InvalidGrant('grant is expired');
|
||||
}
|
||||
|
||||
if (grant.clientId !== client.clientId) {
|
||||
throw new InvalidGrant('client mismatch');
|
||||
}
|
||||
|
||||
if (params.scope) {
|
||||
const missing = difference([...requestParamScopes], [...refreshToken.scopes]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new InvalidScope(
|
||||
`refresh token missing requested ${missing.length > 1 ? 'scopes' : 'scope'}`,
|
||||
missing.join(' ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshToken.jkt) {
|
||||
throw new InvalidRequest('DPoP is not supported for this grant type');
|
||||
}
|
||||
|
||||
ctx.oidc.entity('RefreshToken', refreshToken);
|
||||
ctx.oidc.entity('Grant', grant);
|
||||
|
||||
// @ts-expect-error -- code from oidc-provider. the original type definition does not include
|
||||
// `RefreshToken` but it's actually available.
|
||||
const account = await Account.findAccount(ctx, refreshToken.accountId, refreshToken);
|
||||
|
||||
if (!account) {
|
||||
throw new InvalidGrant('refresh token invalid (referenced account not found)');
|
||||
}
|
||||
|
||||
if (refreshToken.accountId !== grant.accountId) {
|
||||
throw new InvalidGrant('accountId mismatch');
|
||||
}
|
||||
|
||||
ctx.oidc.entity('Account', account);
|
||||
|
||||
if (refreshToken.consumed) {
|
||||
await Promise.all([refreshToken.destroy(), revoke(ctx, refreshToken.grantId)]);
|
||||
throw new InvalidGrant('refresh token already used');
|
||||
}
|
||||
|
||||
/* === RFC 0001 === */
|
||||
// Check membership
|
||||
const organizationId = String(params.organization_id);
|
||||
if (!(await queries.relations.users.exists(organizationId, account.accountId))) {
|
||||
throw new AccessDenied('user is not a member of the organization');
|
||||
}
|
||||
/* === End RFC 0001 === */
|
||||
|
||||
if (
|
||||
rotateRefreshToken === true ||
|
||||
(typeof rotateRefreshToken === 'function' && (await rotateRefreshToken(ctx)))
|
||||
) {
|
||||
await refreshToken.consume();
|
||||
ctx.oidc.entity('RotatedRefreshToken', refreshToken);
|
||||
|
||||
refreshToken = new RefreshToken({
|
||||
accountId: refreshToken.accountId,
|
||||
acr: refreshToken.acr,
|
||||
amr: refreshToken.amr,
|
||||
authTime: refreshToken.authTime,
|
||||
claims: refreshToken.claims,
|
||||
client,
|
||||
expiresWithSession: refreshToken.expiresWithSession,
|
||||
iiat: refreshToken.iiat,
|
||||
grantId: refreshToken.grantId,
|
||||
gty: refreshToken.gty!,
|
||||
nonce: refreshToken.nonce,
|
||||
resource: refreshToken.resource,
|
||||
rotations: typeof refreshToken.rotations === 'number' ? refreshToken.rotations + 1 : 1,
|
||||
scope: refreshToken.scope!,
|
||||
sessionUid: refreshToken.sessionUid,
|
||||
sid: refreshToken.sid,
|
||||
'x5t#S256': refreshToken['x5t#S256'],
|
||||
jkt: refreshToken.jkt,
|
||||
});
|
||||
|
||||
if (refreshToken.gty && !refreshToken.gty.endsWith(grantType)) {
|
||||
refreshToken.gty = `${refreshToken.gty} ${grantType}`;
|
||||
}
|
||||
|
||||
ctx.oidc.entity('RefreshToken', refreshToken);
|
||||
refreshTokenValue = await refreshToken.save();
|
||||
}
|
||||
|
||||
const at = new AccessToken({
|
||||
accountId: account.accountId,
|
||||
client,
|
||||
expiresWithSession: refreshToken.expiresWithSession,
|
||||
grantId: refreshToken.grantId!,
|
||||
gty: refreshToken.gty!,
|
||||
sessionUid: refreshToken.sessionUid,
|
||||
sid: refreshToken.sid,
|
||||
scope: undefined!,
|
||||
});
|
||||
|
||||
if (at.gty && !at.gty.endsWith(grantType)) {
|
||||
at.gty = `${at.gty} ${grantType}`;
|
||||
}
|
||||
|
||||
/* === RFC 0001 === */
|
||||
const audience = buildOrganizationUrn(organizationId);
|
||||
/** All available scopes for the user in the organization. */
|
||||
const availableScopes = await queries.relations.rolesUsers
|
||||
.getUserScopes(organizationId, account.accountId)
|
||||
.then((scopes) => scopes.map(({ name }) => name));
|
||||
/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
|
||||
const scope = params.scope ? requestParamScopes : refreshToken.scopes;
|
||||
/** The intersection of the available scopes and the requested scopes. */
|
||||
const issuedScopes = availableScopes.filter((name) => scope.has(name)).join(' ');
|
||||
|
||||
at.aud = audience;
|
||||
// Note: the original implementation uses `new provider.ResourceServer` to create the resource
|
||||
// server. But it's not available in the typings. The class is actually very simple and holds
|
||||
// no provider-specific context. So we just create the object manually.
|
||||
// See https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/resource_server.js
|
||||
at.resourceServer = {
|
||||
...getSharedResourceServerData(envSet),
|
||||
accessTokenTTL: reversedResourceAccessTokenTtl,
|
||||
audience,
|
||||
scope: availableScopes.join(' '),
|
||||
};
|
||||
at.scope = issuedScopes;
|
||||
/* === End RFC 0001 === */
|
||||
|
||||
ctx.oidc.entity('AccessToken', at);
|
||||
const accessToken = await at.save();
|
||||
|
||||
ctx.body = {
|
||||
access_token: accessToken,
|
||||
expires_in: at.expiration,
|
||||
// `id_token: idToken` -- see the comment at the beginning of this file.
|
||||
refresh_token: refreshTokenValue,
|
||||
scope: at.scope,
|
||||
token_type: at.tokenType,
|
||||
};
|
||||
|
||||
await next();
|
||||
};
|
||||
/* eslint-enable @silverhand/fp/no-let, @typescript-eslint/no-non-null-assertion, @silverhand/fp/no-mutation, unicorn/no-array-method-this-argument */
|
|
@ -5,7 +5,7 @@ import Sinon from 'sinon';
|
|||
import { createOidcContext } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
||||
import { buildHandler } from './organization-token.js';
|
||||
import { buildHandler } from './refresh-token.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
|
@ -147,11 +147,6 @@ afterAll(() => {
|
|||
});
|
||||
|
||||
describe('organization token grant', () => {
|
||||
it('should throw when required parameters are missing', async () => {
|
||||
const ctx = createOidcContext();
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidRequest);
|
||||
});
|
||||
|
||||
it('should throw when client is not available', async () => {
|
||||
const ctx = createOidcContext({ ...validOidcContext, client: undefined });
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidClient);
|
||||
|
@ -242,6 +237,21 @@ describe('organization token grant', () => {
|
|||
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidScope);
|
||||
});
|
||||
|
||||
it('should throw when both `resource` and `organization_id` are present in request', async () => {
|
||||
const ctx = createOidcContext({
|
||||
...validOidcContext,
|
||||
params: {
|
||||
...validOidcContext.params,
|
||||
resource: 'some_resource',
|
||||
},
|
||||
});
|
||||
stubRefreshToken(ctx);
|
||||
stubGrant(ctx);
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
|
||||
new errors.InvalidRequest('resource is not allowed when requesting organization token')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when account cannot be found or account id mismatch', async () => {
|
||||
const ctx = createOidcContext(validOidcContext);
|
||||
stubRefreshToken(ctx);
|
397
packages/core/src/oidc/grants/refresh-token.ts
Normal file
397
packages/core/src/oidc/grants/refresh-token.ts
Normal file
|
@ -0,0 +1,397 @@
|
|||
/**
|
||||
* @overview This file implements the custom `refresh_token` grant which extends the original
|
||||
* `refresh_token` grant with the issuing of organization tokens (RFC 0001).
|
||||
*
|
||||
* 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/cf2069cbb31a6a855876e95157372d25dde2511c/lib/actions/grants/refresh_token.js | oidc-provider/lib/actions/grants/refresh_token.js} for the original code.
|
||||
*
|
||||
* @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 `cf2069cbb31a6a855876e95157372d25dde2511c`.
|
||||
*/
|
||||
|
||||
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
|
||||
import { type Optional, cond, isKeyInObject } from '@silverhand/essentials';
|
||||
import type Provider from 'oidc-provider';
|
||||
import { errors } from 'oidc-provider';
|
||||
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 resolveResource from 'oidc-provider/lib/helpers/resolve_resource.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 instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
||||
|
||||
import { type EnvSet } from '#src/env-set/index.js';
|
||||
import type OrganizationQueries from '#src/queries/organizations.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js';
|
||||
|
||||
const {
|
||||
InvalidClient,
|
||||
InvalidGrant,
|
||||
InvalidScope,
|
||||
InsufficientScope,
|
||||
AccessDenied,
|
||||
InvalidRequest,
|
||||
} = 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
|
||||
* handled by oidc-provider so we don't need to include it here.
|
||||
*/
|
||||
export const parameters = Object.freeze(['refresh_token', 'organization_id', 'scope'] as const);
|
||||
|
||||
/**
|
||||
* 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<
|
||||
(typeof parameters)[number]
|
||||
>;
|
||||
|
||||
// We have to disable the rules because the original implementation is written in JavaScript and
|
||||
// uses mutable variables.
|
||||
/* eslint-disable @silverhand/fp/no-let, @typescript-eslint/no-non-null-assertion, @silverhand/fp/no-mutation, unicorn/no-array-method-this-argument */
|
||||
export const buildHandler: (
|
||||
envSet: EnvSet,
|
||||
queries: OrganizationQueries
|
||||
// eslint-disable-next-line complexity
|
||||
) => Parameters<Provider['registerGrantType']>['1'] = (envSet, queries) => async (ctx, next) => {
|
||||
const { client, params, requestParamScopes, provider } = ctx.oidc;
|
||||
const { RefreshToken, Account, AccessToken, Grant, ReplayDetection, IdToken } = provider;
|
||||
|
||||
assertThat(params, new InvalidGrant('parameters must be available'));
|
||||
assertThat(client, new InvalidClient('client must be available'));
|
||||
|
||||
validatePresence(ctx, ...requiredParameters);
|
||||
|
||||
const providerInstance = instance(provider);
|
||||
const {
|
||||
rotateRefreshToken,
|
||||
conformIdTokenClaims,
|
||||
features: {
|
||||
mTLS: { getCertificate },
|
||||
userinfo,
|
||||
resourceIndicators,
|
||||
},
|
||||
} = providerInstance.configuration();
|
||||
|
||||
const dPoP = await dpopValidate(ctx);
|
||||
|
||||
// @gao: I believe the presence of the param is validated by required parameters of this grant.
|
||||
// Add `String` to make TS happy.
|
||||
let refreshTokenValue = String(params.refresh_token);
|
||||
let refreshToken = await RefreshToken.find(refreshTokenValue, { ignoreExpiration: true });
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new InvalidGrant('refresh token not found');
|
||||
}
|
||||
|
||||
if (refreshToken.clientId !== client.clientId) {
|
||||
throw new InvalidGrant('client mismatch');
|
||||
}
|
||||
|
||||
if (refreshToken.isExpired) {
|
||||
throw new InvalidGrant('refresh token is expired');
|
||||
}
|
||||
|
||||
let cert: Optional<string>;
|
||||
// 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 === */
|
||||
if (params.organization_id) {
|
||||
// Validate if the refresh token has the required scope from RFC 0001.
|
||||
if (!refreshToken.scopes.has(UserScope.Organizations)) {
|
||||
throw new InsufficientScope('refresh token missing required scope', UserScope.Organizations);
|
||||
}
|
||||
// Does not allow requesting resource token when requesting organization token (yet).
|
||||
if (params.resource) {
|
||||
throw new InvalidRequest('resource is not allowed when requesting organization token');
|
||||
}
|
||||
}
|
||||
/* === End RFC 0001 === */
|
||||
|
||||
if (!refreshToken.grantId) {
|
||||
throw new InvalidGrant('grantId not found');
|
||||
}
|
||||
|
||||
const grant = await Grant.find(refreshToken.grantId, {
|
||||
ignoreExpiration: true,
|
||||
});
|
||||
|
||||
if (!grant) {
|
||||
throw new InvalidGrant('grant not found');
|
||||
}
|
||||
|
||||
/**
|
||||
* It's actually available on the `BaseModel` class - but missing from the typings.
|
||||
*
|
||||
* @see {@link https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/models/base_model.js#L128 | oidc-provider/lib/models/base_model.js#L128}
|
||||
*/
|
||||
if (isKeyInObject(grant, 'isExpired') && grant.isExpired) {
|
||||
throw new InvalidGrant('grant is expired');
|
||||
}
|
||||
|
||||
if (grant.clientId !== client.clientId) {
|
||||
throw new InvalidGrant('client mismatch');
|
||||
}
|
||||
|
||||
if (params.scope) {
|
||||
const missing = difference([...requestParamScopes], [...refreshToken.scopes]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new InvalidScope(
|
||||
`refresh token missing requested ${missing.length > 1 ? 'scopes' : 'scope'}`,
|
||||
missing.join(' ')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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('Grant', grant);
|
||||
|
||||
// @ts-expect-error -- code from oidc-provider. the original type definition does not include
|
||||
// `RefreshToken` but it's actually available.
|
||||
const account = await Account.findAccount(ctx, refreshToken.accountId, refreshToken);
|
||||
|
||||
if (!account) {
|
||||
throw new InvalidGrant('refresh token invalid (referenced account not found)');
|
||||
}
|
||||
|
||||
if (refreshToken.accountId !== grant.accountId) {
|
||||
throw new InvalidGrant('accountId mismatch');
|
||||
}
|
||||
|
||||
ctx.oidc.entity('Account', account);
|
||||
|
||||
if (refreshToken.consumed) {
|
||||
await Promise.all([refreshToken.destroy(), revoke(ctx, refreshToken.grantId)]);
|
||||
throw new InvalidGrant('refresh token already used');
|
||||
}
|
||||
|
||||
/* === RFC 0001 === */
|
||||
// Check membership
|
||||
const organizationId = cond(Boolean(params.organization_id) && String(params.organization_id));
|
||||
if (
|
||||
organizationId &&
|
||||
!(await queries.relations.users.exists(organizationId, account.accountId))
|
||||
) {
|
||||
throw new AccessDenied('user is not a member of the organization');
|
||||
}
|
||||
/* === End RFC 0001 === */
|
||||
|
||||
if (
|
||||
rotateRefreshToken === true ||
|
||||
(typeof rotateRefreshToken === 'function' && (await rotateRefreshToken(ctx)))
|
||||
) {
|
||||
await refreshToken.consume();
|
||||
ctx.oidc.entity('RotatedRefreshToken', refreshToken);
|
||||
|
||||
refreshToken = new RefreshToken({
|
||||
accountId: refreshToken.accountId,
|
||||
acr: refreshToken.acr,
|
||||
amr: refreshToken.amr,
|
||||
authTime: refreshToken.authTime,
|
||||
claims: refreshToken.claims,
|
||||
client,
|
||||
expiresWithSession: refreshToken.expiresWithSession,
|
||||
iiat: refreshToken.iiat,
|
||||
grantId: refreshToken.grantId,
|
||||
gty: refreshToken.gty!,
|
||||
nonce: refreshToken.nonce,
|
||||
resource: refreshToken.resource,
|
||||
rotations: typeof refreshToken.rotations === 'number' ? refreshToken.rotations + 1 : 1,
|
||||
scope: refreshToken.scope!,
|
||||
sessionUid: refreshToken.sessionUid,
|
||||
sid: refreshToken.sid,
|
||||
'x5t#S256': refreshToken['x5t#S256'],
|
||||
jkt: refreshToken.jkt,
|
||||
});
|
||||
|
||||
if (refreshToken.gty && !refreshToken.gty.endsWith(gty)) {
|
||||
refreshToken.gty = `${refreshToken.gty} ${gty}`;
|
||||
}
|
||||
|
||||
ctx.oidc.entity('RefreshToken', refreshToken);
|
||||
refreshTokenValue = await refreshToken.save();
|
||||
}
|
||||
|
||||
const at = new AccessToken({
|
||||
accountId: account.accountId,
|
||||
client,
|
||||
expiresWithSession: refreshToken.expiresWithSession,
|
||||
grantId: refreshToken.grantId!,
|
||||
gty: refreshToken.gty!,
|
||||
sessionUid: refreshToken.sessionUid,
|
||||
sid: refreshToken.sid,
|
||||
scope: undefined!,
|
||||
});
|
||||
|
||||
if (client.tlsClientCertificateBoundAccessTokens) {
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
// 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)) {
|
||||
at.gty = `${at.gty} ${gty}`;
|
||||
}
|
||||
|
||||
/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
|
||||
const scope = params.scope ? requestParamScopes : refreshToken.scopes;
|
||||
|
||||
/* === RFC 0001 === */
|
||||
if (organizationId) {
|
||||
const audience = buildOrganizationUrn(organizationId);
|
||||
/** All available scopes for the user in the organization. */
|
||||
const availableScopes = await queries.relations.rolesUsers
|
||||
.getUserScopes(organizationId, account.accountId)
|
||||
.then((scopes) => scopes.map(({ name }) => name));
|
||||
/** The intersection of the available scopes and the requested scopes. */
|
||||
const issuedScopes = availableScopes.filter((name) => scope.has(name)).join(' ');
|
||||
|
||||
at.aud = audience;
|
||||
// Note: the original implementation uses `new provider.ResourceServer` to create the resource
|
||||
// server. But it's not available in the typings. The class is actually very simple and holds
|
||||
// no provider-specific context. So we just create the object manually.
|
||||
// See https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/resource_server.js
|
||||
at.resourceServer = {
|
||||
...getSharedResourceServerData(envSet),
|
||||
accessTokenTTL: reversedResourceAccessTokenTtl,
|
||||
audience,
|
||||
scope: availableScopes.join(' '),
|
||||
};
|
||||
at.scope = issuedScopes;
|
||||
/* === End RFC 0001 === */
|
||||
} else {
|
||||
const resource = await resolveResource(
|
||||
ctx,
|
||||
refreshToken,
|
||||
{ userinfo, resourceIndicators },
|
||||
scope
|
||||
);
|
||||
|
||||
if (resource) {
|
||||
const resourceServerInfo = await resourceIndicators.getResourceServerInfo(
|
||||
ctx,
|
||||
resource,
|
||||
client
|
||||
);
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
|
||||
at.resourceServer = new provider.ResourceServer(resource, resourceServerInfo);
|
||||
at.scope = grant.getResourceScopeFiltered(
|
||||
resource,
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
[...scope].filter(Set.prototype.has.bind(at.resourceServer.scopes))
|
||||
);
|
||||
} else {
|
||||
at.claims = refreshToken.claims;
|
||||
at.scope = grant.getOIDCScopeFiltered(scope);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.oidc.entity('AccessToken', at);
|
||||
const accessToken = await at.save();
|
||||
|
||||
let idToken;
|
||||
if (scope.has('openid')) {
|
||||
const claims = filterClaims(refreshToken.claims, 'id_token', grant);
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
|
||||
const rejected: string[] = grant.getRejectedOIDCClaims();
|
||||
const token = new IdToken(
|
||||
{
|
||||
...(await account.claims('id_token', [...scope].join(' '), claims, rejected)),
|
||||
acr: refreshToken.acr,
|
||||
amr: refreshToken.amr,
|
||||
auth_time: refreshToken.authTime,
|
||||
},
|
||||
{ ctx }
|
||||
);
|
||||
|
||||
// eslint-disable-next-line unicorn/prefer-ternary
|
||||
if (conformIdTokenClaims && userinfo.enabled && !at.aud) {
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
token.scope = 'openid';
|
||||
} else {
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
token.scope = grant.getOIDCScopeFiltered(scope);
|
||||
}
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
token.mask = claims;
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
token.rejected = rejected;
|
||||
|
||||
token.set('nonce', refreshToken.nonce);
|
||||
token.set('at_hash', accessToken);
|
||||
token.set('sid', refreshToken.sid);
|
||||
|
||||
idToken = await token.issue({ use: 'idtoken' });
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
access_token: accessToken,
|
||||
expires_in: at.expiration,
|
||||
id_token: idToken,
|
||||
refresh_token: refreshTokenValue,
|
||||
scope: at.scope,
|
||||
token_type: at.tokenType,
|
||||
};
|
||||
|
||||
await next();
|
||||
};
|
||||
/* eslint-enable @silverhand/fp/no-let, @typescript-eslint/no-non-null-assertion, @silverhand/fp/no-mutation, unicorn/no-array-method-this-argument */
|
|
@ -31,7 +31,7 @@ import type Libraries from '#src/tenants/Libraries.js';
|
|||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
||||
import defaults from './defaults.js';
|
||||
import * as organizationToken from './grants/organization-token.js';
|
||||
import * as refreshToken from './grants/refresh-token.js';
|
||||
import { findResource, findResourceScopes, getSharedResourceServerData } from './resource.js';
|
||||
import { getUserClaimData, getUserClaims } from './scope.js';
|
||||
import { OIDCExtraParametersKey, InteractionMode } from './type.js';
|
||||
|
@ -288,12 +288,10 @@ export default function initOidc(
|
|||
|
||||
addOidcEventListeners(oidc, queries);
|
||||
|
||||
// Register custom grant types
|
||||
oidc.registerGrantType(
|
||||
GrantType.OrganizationToken,
|
||||
organizationToken.buildHandler(envSet, organizations),
|
||||
[...organizationToken.parameters]
|
||||
);
|
||||
// Override the default `refresh_token` grant
|
||||
oidc.registerGrantType(GrantType.RefreshToken, refreshToken.buildHandler(envSet, organizations), [
|
||||
...refreshToken.parameters,
|
||||
]);
|
||||
|
||||
// Provide audit log context for event listeners
|
||||
oidc.use(koaAuditLog(queries));
|
||||
|
|
|
@ -12,17 +12,17 @@ import {
|
|||
describe('getConstantClientMetadata()', () => {
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.SPA)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.OrganizationToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Native)).toEqual({
|
||||
application_type: 'native',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.OrganizationToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Traditional)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.OrganizationToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.MachineToMachine)).toEqual({
|
||||
|
|
|
@ -29,7 +29,7 @@ export const getConstantClientMetadata = (
|
|||
grant_types:
|
||||
type === ApplicationType.MachineToMachine
|
||||
? [GrantType.ClientCredentials]
|
||||
: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.OrganizationToken],
|
||||
: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
token_endpoint_auth_method: getTokenEndpointAuthMethod(),
|
||||
response_types: conditional(type === ApplicationType.MachineToMachine && []),
|
||||
// https://www.scottbrady91.com/jose/jwts-which-signing-algorithm-should-i-use
|
||||
|
|
|
@ -101,7 +101,7 @@ const App = () => {
|
|||
config={{
|
||||
endpoint: window.location.origin,
|
||||
appId: demoAppApplicationId,
|
||||
prompt: Prompt.Login,
|
||||
prompt: Prompt.Consent,
|
||||
// Use enum values once JS SDK is updated
|
||||
scopes: ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'],
|
||||
resources: ['urn:logto:resource:organizations'],
|
||||
|
|
|
@ -52,7 +52,7 @@ class MockOrganizationClient extends MockClient {
|
|||
const json = await got
|
||||
.post(`${this.config.endpoint}/oidc/token`, {
|
||||
form: removeUndefinedKeys({
|
||||
grant_type: GrantType.OrganizationToken,
|
||||
grant_type: GrantType.RefreshToken,
|
||||
client_id: this.config.appId,
|
||||
refresh_token: refreshToken,
|
||||
organization_id: organizationId,
|
||||
|
@ -76,7 +76,8 @@ class MockOrganizationClient extends MockClient {
|
|||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object';
|
||||
|
||||
describe('OIDC organization token grant', () => {
|
||||
// Next PR will update this test accordingly
|
||||
describe.skip('`refresh_token` grant (for organization tokens)', () => {
|
||||
const organizationApi = new OrganizationApiTest();
|
||||
const userApi = new UserApiTest();
|
||||
const username = generateUsername();
|
|
@ -12,10 +12,4 @@ export enum GrantType {
|
|||
AuthorizationCode = 'authorization_code',
|
||||
RefreshToken = 'refresh_token',
|
||||
ClientCredentials = 'client_credentials',
|
||||
/**
|
||||
* The grant type for using refresh token to get organization access token.
|
||||
*
|
||||
* @see {@link https://github.com/logto-io/rfcs | RFC 0001} for more details.
|
||||
*/
|
||||
OrganizationToken = 'urn:logto:grant-type:organization_token',
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue