From b9f821a6921ffa29379484c5231ffb45e5b8ba9a Mon Sep 17 00:00:00 2001 From: wangsijie Date: Wed, 3 Jul 2024 16:44:23 +0800 Subject: [PATCH] feat(core): actor token --- packages/core/src/oidc/extra-token-claims.ts | 33 ++++ packages/core/src/oidc/grants/index.ts | 2 +- .../grants/token-exchange/actor-token.test.ts | 65 ++++++++ .../oidc/grants/token-exchange/actor-token.ts | 39 +++++ .../index.test.ts} | 10 +- .../index.ts} | 23 ++- .../src/oidc/grants/token-exchange/types.ts | 9 ++ packages/core/src/oidc/init.ts | 6 +- .../src/tests/api/oidc/token-exchange.test.ts | 149 +++++++++++++++++- 9 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/oidc/grants/token-exchange/actor-token.test.ts create mode 100644 packages/core/src/oidc/grants/token-exchange/actor-token.ts rename packages/core/src/oidc/grants/{token-exchange.test.ts => token-exchange/index.test.ts} (96%) rename packages/core/src/oidc/grants/{token-exchange.ts => token-exchange/index.ts} (89%) create mode 100644 packages/core/src/oidc/grants/token-exchange/types.ts diff --git a/packages/core/src/oidc/extra-token-claims.ts b/packages/core/src/oidc/extra-token-claims.ts index 5ff95e73dd9..c333fa0b776 100644 --- a/packages/core/src/oidc/extra-token-claims.ts +++ b/packages/core/src/oidc/extra-token-claims.ts @@ -5,6 +5,7 @@ import { LogResult, jwtCustomizer as jwtCustomizerLog, type CustomJwtFetcher, + GrantType, } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { conditional, trySafe } from '@silverhand/essentials'; @@ -18,6 +19,8 @@ import { LogEntry } from '#src/middleware/koa-audit-log.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; +import { tokenExchangeActGuard } from './grants/token-exchange/types.js'; + /** * For organization API resource feature, add extra token claim `organization_id` to the * access token. @@ -46,6 +49,36 @@ export const getExtraTokenClaimsForOrganizationApiResource = async ( return { organization_id: organizationId }; }; +/** + * The field `extra` in the access token will be overidden by the return value of `extraTokenClaims` function, + * previously in token exchange grant, this field is used to save `act` data temporarily, + * here we validate the data and return them again to prevent data loss. + */ +export const getExtraTokenClaimsForTokenExchange = async ( + ctx: KoaContextWithOIDC, + token: unknown +): Promise => { + const isAccessToken = token instanceof ctx.oidc.provider.AccessToken; + + // Only handle access tokens + if (!isAccessToken) { + return; + } + + // Only handle token exchange grant type + if (token.gty !== GrantType.TokenExchange) { + return; + } + + const result = tokenExchangeActGuard.safeParse(token.extra); + + if (!result.success) { + return; + } + + return result.data; +}; + /* eslint-disable complexity */ export const getExtraTokenClaimsForJwtCustomization = async ( ctx: KoaContextWithOIDC, diff --git a/packages/core/src/oidc/grants/index.ts b/packages/core/src/oidc/grants/index.ts index ad00ef07eaa..0b641495ed1 100644 --- a/packages/core/src/oidc/grants/index.ts +++ b/packages/core/src/oidc/grants/index.ts @@ -7,7 +7,7 @@ import type Queries from '#src/tenants/Queries.js'; import * as clientCredentials from './client-credentials.js'; import * as refreshToken from './refresh-token.js'; -import * as tokenExchange from './token-exchange.js'; +import * as tokenExchange from './token-exchange/index.js'; export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries) => { const { diff --git a/packages/core/src/oidc/grants/token-exchange/actor-token.test.ts b/packages/core/src/oidc/grants/token-exchange/actor-token.test.ts new file mode 100644 index 00000000000..6431e29f3d5 --- /dev/null +++ b/packages/core/src/oidc/grants/token-exchange/actor-token.test.ts @@ -0,0 +1,65 @@ +import { errors, type KoaContextWithOIDC } from 'oidc-provider'; +import Sinon from 'sinon'; + +import { createOidcContext } from '#src/test-utils/oidc-provider.js'; + +import { handleActorToken } from './actor-token.js'; + +const { InvalidGrant } = errors; + +const accountId = 'some_account_id'; + +const validOidcContext: Partial = { + params: { + actor_token: 'some_actor_token', + actor_token_type: 'urn:ietf:params:oauth:token-type:access_token', + }, +}; + +beforeAll(() => { + // `oidc-provider` will warn for dev interactions + Sinon.stub(console, 'warn'); +}); + +afterAll(() => { + Sinon.restore(); +}); + +describe('handleActorToken', () => { + it('should return accountId', async () => { + const ctx = createOidcContext(validOidcContext); + Sinon.stub(ctx.oidc.provider.AccessToken, 'find').resolves({ accountId, scope: 'openid' }); + + await expect(handleActorToken(ctx)).resolves.toStrictEqual({ + accountId, + }); + }); + + it('should return empty accountId when params are not present', async () => { + const ctx = createOidcContext({ params: {} }); + + await expect(handleActorToken(ctx)).resolves.toStrictEqual({ + accountId: undefined, + }); + }); + + it('should throw if actor_token_type is invalid', async () => { + const ctx = createOidcContext({ + params: { + actor_token: 'some_actor_token', + actor_token_type: 'invalid', + }, + }); + + await expect(handleActorToken(ctx)).rejects.toThrow( + new InvalidGrant('unsupported actor token type') + ); + }); + + it('should throw if actor_token is invalid', async () => { + const ctx = createOidcContext(validOidcContext); + Sinon.stub(ctx.oidc.provider.AccessToken, 'find').rejects(); + + await expect(handleActorToken(ctx)).rejects.toThrow(new InvalidGrant('invalid actor token')); + }); +}); diff --git a/packages/core/src/oidc/grants/token-exchange/actor-token.ts b/packages/core/src/oidc/grants/token-exchange/actor-token.ts new file mode 100644 index 00000000000..9c369199a95 --- /dev/null +++ b/packages/core/src/oidc/grants/token-exchange/actor-token.ts @@ -0,0 +1,39 @@ +import { trySafe } from '@silverhand/essentials'; +import { type KoaContextWithOIDC, errors } from 'oidc-provider'; + +import assertThat from '#src/utils/assert-that.js'; + +const { InvalidGrant } = errors; + +/** + * Handles the `actor_token` and `actor_token_type` parameters, + * if both are present and valid, the `accountId` of the actor token is returned. + */ +export const handleActorToken = async ( + ctx: KoaContextWithOIDC +): Promise<{ accountId?: string }> => { + const { params, provider } = ctx.oidc; + const { AccessToken } = provider; + + assertThat(params, new InvalidGrant('parameters must be available')); + assertThat( + !params.actor_token || + params.actor_token_type === 'urn:ietf:params:oauth:token-type:access_token', + new InvalidGrant('unsupported actor token type') + ); + + if (!params.actor_token) { + return { accountId: undefined }; + } + + // The actor token should have `openid` scope (RFC 0005), and a token with this scope is an opaque token. + // We can use `AccessToken.find` to handle the token, no need to handle JWT tokens. + const actorToken = await trySafe(async () => AccessToken.find(String(params.actor_token))); + assertThat(actorToken?.accountId, new InvalidGrant('invalid actor token')); + assertThat( + actorToken.scope?.includes('openid'), + new InvalidGrant('actor token must have openid scope') + ); + + return { accountId: actorToken.accountId }; +}; diff --git a/packages/core/src/oidc/grants/token-exchange.test.ts b/packages/core/src/oidc/grants/token-exchange/index.test.ts similarity index 96% rename from packages/core/src/oidc/grants/token-exchange.test.ts rename to packages/core/src/oidc/grants/token-exchange/index.test.ts index 183d11fb375..b0adcd52b26 100644 --- a/packages/core/src/oidc/grants/token-exchange.test.ts +++ b/packages/core/src/oidc/grants/token-exchange/index.test.ts @@ -1,4 +1,5 @@ import { type SubjectToken } from '@logto/schemas'; +import { createMockUtils } from '@logto/shared/esm'; import { type KoaContextWithOIDC, errors } from 'oidc-provider'; import Sinon from 'sinon'; @@ -6,9 +7,14 @@ import { mockApplication } from '#src/__mocks__/index.js'; import { createOidcContext } from '#src/test-utils/oidc-provider.js'; import { MockTenant } from '#src/test-utils/tenant.js'; -import { buildHandler } from './token-exchange.js'; - const { jest } = import.meta; +const { mockEsm } = createMockUtils(jest); + +const { handleActorToken } = mockEsm('./actor-token.js', () => ({ + handleActorToken: jest.fn().mockResolvedValue({ accountId: undefined }), +})); + +const { buildHandler } = await import('./index.js'); // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = async () => {}; diff --git a/packages/core/src/oidc/grants/token-exchange.ts b/packages/core/src/oidc/grants/token-exchange/index.ts similarity index 89% rename from packages/core/src/oidc/grants/token-exchange.ts rename to packages/core/src/oidc/grants/token-exchange/index.ts index 0b263995def..ad8286b0aa3 100644 --- a/packages/core/src/oidc/grants/token-exchange.ts +++ b/packages/core/src/oidc/grants/token-exchange/index.ts @@ -21,7 +21,10 @@ import { isThirdPartyApplication, getSharedResourceServerData, reversedResourceAccessTokenTtl, -} from '../resource.js'; +} from '../../resource.js'; + +import { handleActorToken } from './actor-token.js'; +import { type TokenExchangeAct } from './types.js'; const { InvalidClient, InvalidGrant, AccessDenied } = errors; @@ -32,6 +35,8 @@ const { InvalidClient, InvalidGrant, AccessDenied } = errors; export const parameters = Object.freeze([ 'subject_token', 'subject_token_type', + 'actor_token', + 'actor_token_type', 'organization_id', 'scope', ] as const); @@ -46,6 +51,7 @@ const requiredParameters = Object.freeze([ 'subject_token_type', ] as const) satisfies ReadonlyArray<(typeof parameters)[number]>; +/* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */ export const buildHandler: ( envSet: EnvSet, queries: Queries @@ -90,8 +96,6 @@ export const buildHandler: ( // TODO: (LOG-9501) Implement general security checks like dPop ctx.oidc.entity('Account', account); - /* eslint-disable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */ - /* === RFC 0001 === */ // The value type is `unknown`, which will swallow other type inferences. So we have to cast it // to `Boolean` first. @@ -197,7 +201,17 @@ export const buildHandler: ( .filter((name) => new Set(oidcScopes).has(name)) .join(' '); } - /* eslint-enable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */ + + // Handle the actor token + const { accountId: actorId } = await handleActorToken(ctx); + if (actorId) { + // The JWT generator in node-oidc-provider only recognizes a fixed list of claims, + // to add other claims to JWT, the only way is to return them in `extraTokenClaims` function. + // @see https://github.com/panva/node-oidc-provider/blob/main/lib/models/formats/jwt.js#L118 + // We save the `act` data in the `extra` field temporarily, + // so that we can get this context it in the `extraTokenClaims` function and add it to the JWT. + accessToken.extra = { act: { sub: actorId } } satisfies TokenExchangeAct; + } ctx.oidc.entity('AccessToken', accessToken); const accessTokenString = await accessToken.save(); @@ -216,3 +230,4 @@ export const buildHandler: ( await next(); }; +/* eslint-enable @silverhand/fp/no-mutation, @typescript-eslint/no-unsafe-assignment */ diff --git a/packages/core/src/oidc/grants/token-exchange/types.ts b/packages/core/src/oidc/grants/token-exchange/types.ts new file mode 100644 index 00000000000..6614ecac412 --- /dev/null +++ b/packages/core/src/oidc/grants/token-exchange/types.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const tokenExchangeActGuard = z.object({ + act: z.object({ + sub: z.string(), + }), +}); + +export type TokenExchangeAct = z.infer; diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 9e6a12f6c94..99e9ffbd86e 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -42,6 +42,7 @@ import defaults from './defaults.js'; import { getExtraTokenClaimsForJwtCustomization, getExtraTokenClaimsForOrganizationApiResource, + getExtraTokenClaimsForTokenExchange, } from './extra-token-claims.js'; import { registerGrants } from './grants/index.js'; import { @@ -224,6 +225,8 @@ export default function initOidc( }, extraParams: Object.values(ExtraParamsKey), extraTokenClaims: async (ctx, token) => { + const tokenExchangeClaims = await getExtraTokenClaimsForTokenExchange(ctx, token); + const organizationApiResourceClaims = await getExtraTokenClaimsForOrganizationApiResource( ctx, token @@ -237,11 +240,12 @@ export default function initOidc( cloudConnection, }); - if (!organizationApiResourceClaims && !jwtCustomizedClaims) { + if (!organizationApiResourceClaims && !jwtCustomizedClaims && !tokenExchangeClaims) { return; } return { + ...tokenExchangeClaims, ...organizationApiResourceClaims, ...jwtCustomizedClaims, }; diff --git a/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts b/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts index b19d1e3039a..e2b26a0676c 100644 --- a/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts @@ -1,15 +1,33 @@ import { UserScope, buildOrganizationUrn } from '@logto/core-kit'; import { decodeAccessToken } from '@logto/js'; -import { ApplicationType, GrantType, MfaFactor } from '@logto/schemas'; +import { + ApplicationType, + GrantType, + InteractionEvent, + MfaFactor, + type Resource, +} from '@logto/schemas'; import { formUrlEncodedHeaders } from '@logto/shared'; import { createUserMfaVerification, deleteUser } from '#src/api/admin-user.js'; import { oidcApi } from '#src/api/api.js'; import { createApplication, deleteApplication } from '#src/api/application.js'; +import { putInteraction } from '#src/api/interaction.js'; +import { createResource, deleteResource } from '#src/api/resource.js'; import { createSubjectToken } from '#src/api/subject-token.js'; +import type MockClient from '#src/client/index.js'; +import { initClient, processSession } from '#src/helpers/client.js'; import { createUserByAdmin } from '#src/helpers/index.js'; import { OrganizationApiTest } from '#src/helpers/organization.js'; -import { devFeatureTest, getAccessTokenPayload, randomString, generateName } from '#src/utils.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { + devFeatureTest, + getAccessTokenPayload, + randomString, + generateName, + generatePassword, + generateUsername, +} from '#src/utils.js'; const { describe, it } = devFeatureTest; @@ -252,4 +270,131 @@ describe('Token Exchange', () => { }); }); }); + + describe('with actor token', () => { + const username = generateUsername(); + const password = generatePassword(); + // Add test resource to ensure that the access token is JWT, + // make it easy to check claims. + const testApiResourceInfo: Pick = { + name: 'test-api-resource', + indicator: 'https://foo.logto.io/api', + }; + + /* eslint-disable @silverhand/fp/no-let */ + let testApiResourceId: string; + let testUserId: string; + let testAccessToken: string; + let client: MockClient; + /* eslint-enable @silverhand/fp/no-let */ + + beforeAll(async () => { + await enableAllPasswordSignInMethods(); + + /* eslint-disable @silverhand/fp/no-mutation */ + const resource = await createResource( + testApiResourceInfo.name, + testApiResourceInfo.indicator + ); + testApiResourceId = resource.id; + const { id } = await createUserByAdmin({ username, password }); + testUserId = id; + client = await initClient({ + resources: [testApiResourceInfo.indicator], + }); + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { username, password }, + }); + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + testAccessToken = await client.getAccessToken(); + /* eslint-enable @silverhand/fp/no-mutation */ + }); + + afterAll(async () => { + await deleteUser(testUserId); + await deleteResource(testApiResourceId); + }); + + it('should exchange an access token with `act` claim', async () => { + const { subjectToken } = await createSubjectToken(userId); + + const { access_token } = 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', + actor_token: testAccessToken, + actor_token_type: 'urn:ietf:params:oauth:token-type:access_token', + resource: testApiResourceInfo.indicator, + }), + }) + .json<{ access_token: string }>(); + + expect(getAccessTokenPayload(access_token)).toHaveProperty('act', { sub: testUserId }); + }); + + it('should fail with invalid actor_token_type', async () => { + const { subjectToken } = await createSubjectToken(userId); + + 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', + actor_token: testAccessToken, + actor_token_type: 'invalid_actor_token_type', + resource: testApiResourceInfo.indicator, + }), + }) + ).rejects.toThrow(); + }); + + it('should fail with invalid actor_token', async () => { + const { subjectToken } = await createSubjectToken(userId); + + 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', + actor_token: 'invalid_actor_token', + actor_token_type: 'urn:ietf:params:oauth:token-type:access_token', + resource: testApiResourceInfo.indicator, + }), + }) + ).rejects.toThrow(); + }); + + it('should fail when the actor token do not have `openid` scope', async () => { + const { subjectToken } = await createSubjectToken(userId); + // Set `resource` to ensure that the access token is JWT, and then it won't have `openid` scope. + const accessToken = await client.getAccessToken(testApiResourceInfo.indicator); + + 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', + actor_token: accessToken, + actor_token_type: 'urn:ietf:params:oauth:token-type:access_token', + resource: testApiResourceInfo.indicator, + }), + }) + ).rejects.toThrow(); + }); + }); });