0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

Merge pull request #4854 from logto-io/gao-use-refresh-token-grant

refactor(core): reuse `refresh_token` grant for org tokens
This commit is contained in:
Gao Sun 2023-11-13 18:45:55 +08:00 committed by GitHub
commit a53fcfc654
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 534 additions and 326 deletions

View file

@ -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 => {

View file

@ -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.

View 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;
}

View 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;
}

View 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]>;
}

View 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>>;
}

View 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 }>>;
}

View file

@ -0,0 +1,27 @@
import { GrantType } from '@logto/schemas';
import type Provider from 'oidc-provider';
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 refreshToken from './refresh-token.js';
export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries) => {
const {
features: { resourceIndicators },
} = instance(oidc).configuration();
// 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], []];
// Override the default `refresh_token` grant
oidc.registerGrantType(
GrantType.RefreshToken,
refreshToken.buildHandler(envSet, queries.organizations),
...parameterConfig
);
};

View file

@ -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 */

View file

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

View file

@ -0,0 +1,399 @@
/**
* @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, isKeyInObject, cond } 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
* 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);
/**
* 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 === */
// The value type is `unknown`, which will swallow other type inferences. So we have to cast it
// to `Boolean` first.
const organizationId = cond(Boolean(params.organization_id) && String(params.organization_id));
if (organizationId) {
// 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
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 */

View file

@ -7,6 +7,6 @@ describe('oidc provider init', () => {
it('init should not throw', async () => {
const { queries, libraries } = new MockTenant();
expect(() => initOidc('mock_id', mockEnvSet, queries, libraries)).not.toThrow();
expect(() => initOidc(mockEnvSet, queries, libraries)).not.toThrow();
});
});

View file

@ -8,7 +8,6 @@ import {
customClientMetadataDefault,
CustomClientMetadataKey,
demoAppApplicationId,
GrantType,
inSeconds,
logtoCookieKey,
type LogtoUiCookie,
@ -19,7 +18,7 @@ import koaBody from 'koa-body';
import Provider, { errors } from 'oidc-provider';
import snakecaseKeys from 'snakecase-keys';
import type { EnvSet } from '#src/env-set/index.js';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { addOidcEventListeners } from '#src/event-listeners/index.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
@ -31,7 +30,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 { registerGrants } from './grants/index.js';
import { findResource, findResourceScopes, getSharedResourceServerData } from './resource.js';
import { getUserClaimData, getUserClaims } from './scope.js';
import { OIDCExtraParametersKey, InteractionMode } from './type.js';
@ -39,12 +38,7 @@ import { OIDCExtraParametersKey, InteractionMode } from './type.js';
// Temporarily removed 'EdDSA' since it's not supported by browser yet
const supportedSigningAlgs = Object.freeze(['RS256', 'PS256', 'ES256', 'ES384', 'ES512'] as const);
export default function initOidc(
tenantId: string,
envSet: EnvSet,
queries: Queries,
libraries: Libraries
): Provider {
export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Libraries): Provider {
const {
resources: { findDefaultResource },
users: { findUserById },
@ -288,12 +282,10 @@ export default function initOidc(
addOidcEventListeners(oidc, queries);
// Register custom grant types
oidc.registerGrantType(
GrantType.OrganizationToken,
organizationToken.buildHandler(envSet, organizations),
[...organizationToken.parameters]
);
// DEV: Customized `refresh_token` grant
if (EnvSet.values.isDevFeaturesEnabled) {
registerGrants(oidc, envSet, queries);
}
// Provide audit log context for event listeners
oidc.use(koaAuditLog(queries));

View file

@ -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({

View file

@ -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

View file

@ -83,7 +83,7 @@ export default class Tenant implements TenantContext {
app.use(koaSecurityHeaders(mountedApps, id));
// Mount OIDC
const provider = initOidc(id, envSet, queries, libraries);
const provider = initOidc(envSet, queries, libraries);
app.use(mount('/oidc', provider.app));
const tenantContext: TenantContext = {

View file

@ -102,7 +102,7 @@ const App = () => {
endpoint: window.location.origin,
appId: demoAppApplicationId,
prompt: Prompt.Login,
// Use enum values once JS SDK is updated
// TODO: Use enum values once JS SDK is updated
scopes: ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'],
resources: ['urn:logto:resource:organizations'],
}}

View file

@ -46,16 +46,17 @@ const issuer = defaultConfig.endpoint + '/oidc';
class MockOrganizationClient extends MockClient {
/** Perform the organization token grant. It may be replaced once our SDK supports it. */
async fetchOrganizationToken(organizationId?: string) {
async fetchOrganizationToken(organizationId?: string, scopes?: string[]) {
const refreshToken = await this.getRefreshToken();
try {
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,
scope: scopes?.join(' '),
}),
})
.json();
@ -76,7 +77,7 @@ class MockOrganizationClient extends MockClient {
const isObject = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';
describe('OIDC organization token grant', () => {
describe('`refresh_token` grant (for organization tokens)', () => {
const organizationApi = new OrganizationApiTest();
const userApi = new UserApiTest();
const username = generateUsername();
@ -114,9 +115,10 @@ describe('OIDC organization token grant', () => {
expectation: {
organizationId: string;
scopes: string[];
idToken?: boolean;
}
) => {
const { scopes, organizationId } = expectation;
const { scopes, organizationId, idToken = true } = expectation;
// Expect response
assert(isObject(response), new Error('response is not an object'));
@ -128,7 +130,13 @@ describe('OIDC organization token grant', () => {
refresh_token: expect.any(String),
token_type: 'Bearer',
});
expect(response).not.toHaveProperty('id_token');
if (idToken) {
expect(response.id_token).toEqual(expect.any(String));
} else {
expect(response).not.toHaveProperty('id_token');
}
expect(String(response.scope).split(' ').filter(Boolean).slice().sort()).toStrictEqual(
scopes.slice().sort()
);
@ -209,11 +217,23 @@ describe('OIDC organization token grant', () => {
]);
});
it('should return error when organization id is not provided', async () => {
it('should perform the normal grant when organization id is not provided', async () => {
const client = await initClient();
await expect(client.fetchOrganizationToken()).rejects.toMatchError(
grantErrorContaining('oidc.invalid_request', "missing required parameter 'organization_id'")
);
const response = await client.fetchOrganizationToken();
assert(isObject(response), new Error('response is not an object'));
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
expect(response).toMatchObject({
access_token: expect.any(String),
refresh_token: expect.any(String),
id_token: expect.any(String),
expires_in: expect.any(Number),
token_type: 'Bearer',
});
/* eslint-enable @typescript-eslint/no-unsafe-assignment */
// The access token should not be a JWT
expect(response.access_token).not.toContain('.');
});
it('should return error when organizations scope is not requested', async () => {
@ -323,7 +343,7 @@ describe('OIDC organization token grant', () => {
});
});
it('should down-scope according to the refresh token', async () => {
it('should down-scope according to the refresh token and token request', async () => {
const { orgs } = context;
const client = await initClient({
scopes: ['urn:logto:scope:organizations', 'scope1', 'scope2'],
@ -336,6 +356,11 @@ describe('OIDC organization token grant', () => {
organizationId: orgs[1].id,
scopes: ['scope1', 'scope2'],
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[1].id, ['scope1']), {
organizationId: orgs[1].id,
scopes: ['scope1'],
idToken: false, // No ID token since no `openid` scope
});
expectGrantResponse(await client.fetchOrganizationToken(orgs[2].id), {
organizationId: orgs[2].id,
scopes: [],

View file

@ -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',
}