Skip to content

Commit

Permalink
feat(core,schemas): token exchange grant
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Jun 20, 2024
1 parent f825a8a commit e9d0836
Show file tree
Hide file tree
Showing 10 changed files with 287 additions and 6 deletions.
1 change: 1 addition & 0 deletions packages/core/src/event-listeners/grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const grantTypeToExchangeByType: Record<GrantType, token.ExchangeByType> = {
[GrantType.AuthorizationCode]: token.ExchangeByType.AuthorizationCode,
[GrantType.RefreshToken]: token.ExchangeByType.RefreshToken,
[GrantType.ClientCredentials]: token.ExchangeByType.ClientCredentials,
[GrantType.TokenExchange]: token.ExchangeByType.TokenExchange,
};

const getExchangeByType = (grantType: unknown): token.ExchangeByType => {
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/oidc/grants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { type EnvSet } from '#src/env-set/index.js';
import type Queries from '#src/tenants/Queries.js';

import * as refreshToken from './refresh-token.js';
import * as tokenExchange from './token-exchange.js';

export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries) => {
const {
Expand All @@ -24,4 +25,15 @@ export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries)
refreshToken.buildHandler(envSet, queries),
...parameterConfig
);

// Token exchange grant
const tokenExchangeParameterConfig: [parameters: string[], duplicates: string[]] =
resourceIndicators.enabled
? [[...tokenExchange.parameters, 'resource'], ['resource']]
: [[...tokenExchange.parameters], []];
oidc.registerGrantType(
GrantType.TokenExchange,
tokenExchange.buildHandler(envSet, queries),
...tokenExchangeParameterConfig
);
};
157 changes: 157 additions & 0 deletions packages/core/src/oidc/grants/token-exchange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* @overview This file implements the `token_exchange` grant type. The grant type is used to impersonate
*
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0005.
*/

import { GrantType } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import type Provider from 'oidc-provider';
import { errors } from 'oidc-provider';
import resolveResource from 'oidc-provider/lib/helpers/resolve_resource.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 Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

const {
InvalidClient,
InvalidGrant,
InvalidScope,
InsufficientScope,
AccessDenied,
InvalidRequest,
} = errors;

/**
* The valid parameters for the `urn:ietf:params:oauth:grant-type:token-exchange` 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([
'subject_token',
'subject_token_type',
'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([
'subject_token',
'subject_token_type',
] 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.

export const buildHandler: (
envSet: EnvSet,
queries: Queries
) => Parameters<Provider['registerGrantType']>['1'] = (envSet, queries) => async (ctx, next) => {
const { client, params, requestParamScopes, provider } = ctx.oidc;
const { Account, AccessToken } = provider;
const {
subjectTokens: { findSubjectToken, updateSubjectTokenById },
} = queries;

assertThat(params, new InvalidGrant('parameters must be available'));
assertThat(client, new InvalidClient('client must be available'));
assertThat(
params.subject_token_type === 'urn:ietf:params:oauth:token-type:access_token',
new InvalidGrant('unsupported subject token type')
);

validatePresence(ctx, ...requiredParameters);

const providerInstance = instance(provider);
const {
features: { userinfo, resourceIndicators },
} = providerInstance.configuration();

const subjectToken = await trySafe(async () => findSubjectToken(String(params.subject_token)));
assertThat(subjectToken, new InvalidGrant('subject token not found'));
assertThat(subjectToken.expiresAt > Date.now(), new InvalidGrant('subject token expired'));
assertThat(!subjectToken.consumedAt, new InvalidGrant('subject token already consumed'));

const account = await Account.findAccount(ctx, subjectToken.userId);

if (!account) {
throw new InvalidGrant('refresh token invalid (referenced account not found)');
}

ctx.oidc.entity('Account', account);

// TODO: (LOG-9140) Check organization permissions

const accessToken = new AccessToken({
accountId: account.accountId,
clientId: client.clientId,
gty: GrantType.TokenExchange,
client,
grantId: subjectToken.id, // There is no actual grant, so we use the subject token ID
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scope: undefined!,
});

/* eslint-disable @silverhand/fp/no-mutation */

/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
const scope = requestParamScopes;
const resource = await resolveResource(
ctx,
{
// We don't restrict the resource indicators to the requested resource,
// because the subject token does not have a resource indicator.
// Use the params.resource to bypass the resource indicator check.
resourceIndicators: new Set([params.resource]),
},
{ 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
accessToken.resourceServer = new provider.ResourceServer(resource, resourceServerInfo);
// For access token scopes, there is no "grant" to check,
// filter the scopes based on the resource server's scopes
accessToken.scope = [...scope]
// @ts-expect-error -- code from oidc-provider
.filter(Set.prototype.has.bind(accessToken.resourceServer.scopes))
.join(' ');
} else {
// TODO: (LOG-9166) Check claims and scopes
accessToken.claims = ctx.oidc.claims;
accessToken.scope = Array.from(scope).join(' ');
}
// TODO: (LOG-9140) Handle organization token

/* eslint-enable @silverhand/fp/no-mutation */

ctx.oidc.entity('AccessToken', accessToken);
const accessTokenString = await accessToken.save();

// Consume the subject token
await updateSubjectTokenById(subjectToken.id, {
consumedAt: Date.now(),
});

ctx.body = {
access_token: accessTokenString,
expires_in: accessToken.expiration,
scope: accessToken.scope,
token_type: accessToken.tokenType,
};

await next();
};

Check warning on line 157 in packages/core/src/oidc/grants/token-exchange.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/oidc/grants/token-exchange.ts#L56-L157

Added lines #L56 - L157 were not covered by tests
6 changes: 3 additions & 3 deletions packages/core/src/oidc/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@ import {
describe('getConstantClientMetadata()', () => {
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.SPA)).toEqual({
application_type: 'web',
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.TokenExchange],
token_endpoint_auth_method: 'none',
});
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Native)).toEqual({
application_type: 'native',
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.TokenExchange],
token_endpoint_auth_method: 'none',
});
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Traditional)).toEqual({
application_type: 'web',
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.TokenExchange],
token_endpoint_auth_method: 'client_secret_basic',
});
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.MachineToMachine)).toEqual({
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/oidc/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const getConstantClientMetadata = (
grant_types:
type === ApplicationType.MachineToMachine
? [GrantType.ClientCredentials]
: [GrantType.AuthorizationCode, GrantType.RefreshToken],
: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.TokenExchange],
token_endpoint_auth_method: getTokenEndpointAuthMethod(),
response_types: conditional(type === ApplicationType.MachineToMachine && []),
// https://www.scottbrady91.com/jose/jwts-which-signing-algorithm-should-i-use
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/queries/subject-token.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { SubjectTokens } from '@logto/schemas';
import { type CreateSubjectToken, SubjectTokens } from '@logto/schemas';
import type { CommonQueryMethods } from '@silverhand/slonik';

import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
import { type OmitAutoSetFields } from '#src/utils/sql.js';

export const createSubjectTokenQueries = (pool: CommonQueryMethods) => {
const insertSubjectToken = buildInsertIntoWithPool(pool)(SubjectTokens, {
returning: true,
});

const findSubjectToken = buildFindEntityByIdWithPool(pool)(SubjectTokens);

const updateSubjectToken = buildUpdateWhereWithPool(pool)(SubjectTokens, true);

const updateSubjectTokenById = async (
id: string,
set: Partial<OmitAutoSetFields<CreateSubjectToken>>

Check warning on line 20 in packages/core/src/queries/subject-token.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/subject-token.ts#L19-L20

Added lines #L19 - L20 were not covered by tests
) => updateSubjectToken({ set, where: { id }, jsonbMode: 'merge' });

return {
insertSubjectToken,
findSubjectToken,
updateSubjectTokenById,
};
};
2 changes: 1 addition & 1 deletion packages/core/src/routes/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function securityRoutes<T extends ManagementApiRouter>(...args: R
const {
auth: { id },
guard: {
body: { userId, context },
body: { userId, context = {} },
},
} = ctx;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { ApplicationType, GrantType } from '@logto/schemas';
import { formUrlEncodedHeaders } from '@logto/shared';

import { deleteUser } from '#src/api/admin-user.js';
import { oidcApi } from '#src/api/api.js';
import { createApplication, deleteApplication } from '#src/api/application.js';
import { createSubjectToken } from '#src/api/subject-token.js';
import { createUserByAdmin } from '#src/helpers/index.js';

describe('Token Exchange', () => {
/* eslint-disable @silverhand/fp/no-let */
let userId: string;
let applicationId: string;
/* eslint-enable @silverhand/fp/no-let */

/* eslint-disable @silverhand/fp/no-mutation */
beforeAll(async () => {
const user = await createUserByAdmin();
userId = user.id;
const applicationName = 'test-token-exchange-app';
const applicationType = ApplicationType.SPA;
const application = await createApplication(applicationName, applicationType, {
oidcClientMetadata: { redirectUris: ['http://localhost:3000'], postLogoutRedirectUris: [] },
});
applicationId = application.id;
});
/* eslint-enable @silverhand/fp/no-mutation */

afterAll(async () => {
await deleteUser(userId);
await deleteApplication(applicationId);
});

describe('Basic flow', () => {
it('should exchange an access token by a subject token', async () => {
const { subjectToken } = await createSubjectToken(userId);

const body = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
}),
})
.json();

expect(body).toHaveProperty('access_token');
expect(body).toHaveProperty('token_type', 'Bearer');
expect(body).toHaveProperty('expires_in');
expect(body).toHaveProperty('scope', '');
});

it('should failed with invalid subject token', async () => {
await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
grant_type: GrantType.TokenExchange,
subject_token: 'invalid_subject_token',
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
}),
})
).rejects.toThrow();
});

it('should failed with consumed subject token', async () => {
const { subjectToken } = await createSubjectToken(userId);

await oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
}),
});
await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
}),
})
).rejects.toThrow();
});
});
});
1 change: 1 addition & 0 deletions packages/schemas/src/types/log/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum ExchangeByType {
AuthorizationCode = 'AuthorizationCode',
RefreshToken = 'RefreshToken',
ClientCredentials = 'ClientCredentials',
TokenExchange = 'TokenExchange',
}

export type LogKey = `${Type.ExchangeTokenBy}.${ExchangeByType}` | `${Type.RevokeToken}`;
1 change: 1 addition & 0 deletions packages/schemas/src/types/oidc-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export enum GrantType {
AuthorizationCode = 'authorization_code',
RefreshToken = 'refresh_token',
ClientCredentials = 'client_credentials',
TokenExchange = 'urn:ietf:params:oauth:grant-type:token-exchange',
}

0 comments on commit e9d0836

Please sign in to comment.