0
Fork 0
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:
Gao Sun 2023-11-10 14:06:47 +08:00
parent 73f348af89
commit d3e7cff0bd
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
16 changed files with 469 additions and 308 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

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

View file

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

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

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

View file

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

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