Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/kind-eels-march.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@logto/core": minor
---

support JWT access token exchange for service-to-service delegation

Added a new `subject_token_type` value `urn:ietf:params:oauth:token-type:jwt` to enable JWT access token exchange. This allows services to exchange JWT tokens issued by trusted issuers for Logto access tokens, enabling service-to-service delegation scenarios. The JWT tokens are verified using the issuer's JWK set and can be reused multiple times.
62 changes: 53 additions & 9 deletions packages/core/src/oidc/grants/token-exchange/account.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
import { trySafe } from '@silverhand/essentials';
import { type JWSHeaderParameters, type FlattenedJWSInput, type KeyLike, jwtVerify } from 'jose';
import { errors } from 'oidc-provider';

import type Queries from '../../../tenants/Queries.js';
import assertThat from '../../../utils/assert-that.js';
import { EnvSet } from '#src/env-set/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

import { TokenExchangeTokenType } from './types.js';

const { InvalidGrant } = errors;

export const validateSubjectToken = async (
queries: Queries,
subjectToken: string,
type: string
): Promise<{ userId: string; subjectTokenId?: string }> => {
type ValidateSubjectTokenParams = {
queries: Queries;
subjectToken: string;
subjectTokenType: string;
/** For JWT access token verification. Required when dev features are enabled. */
jwtVerificationOptions?: {
localJWKSet: (
protectedHeader: JWSHeaderParameters,
token: FlattenedJWSInput
) => Promise<KeyLike | Uint8Array>;
issuer: string;
};
};

export const validateSubjectToken = async ({
queries,
subjectToken,
subjectTokenType,
jwtVerificationOptions,
}: ValidateSubjectTokenParams): Promise<{ userId: string; subjectTokenId?: string }> => {
const {
subjectTokens: { findSubjectToken },
personalAccessTokens: { findByValue },
} = queries;

if (type === TokenExchangeTokenType.AccessToken) {
if (subjectTokenType === TokenExchangeTokenType.AccessToken) {
const token = await trySafe(async () => findSubjectToken(subjectToken));
assertThat(token, new InvalidGrant('subject token not found'));
assertThat(token.expiresAt > Date.now(), new InvalidGrant('subject token is expired'));
Expand All @@ -29,7 +46,7 @@
subjectTokenId: token.id,
};
}
if (type === TokenExchangeTokenType.PersonalAccessToken) {
if (subjectTokenType === TokenExchangeTokenType.PersonalAccessToken) {
const token = await findByValue(subjectToken);
assertThat(token, new InvalidGrant('subject token not found'));
assertThat(
Expand All @@ -39,5 +56,32 @@

return { userId: token.userId };
}

// TODO: Remove dev feature guard when JWT access token exchange is ready for production

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

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/oidc/grants/token-exchange/account.ts#L60

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Remove dev feature guard when JWT...'.
if (
EnvSet.values.isDevFeaturesEnabled &&
subjectTokenType === TokenExchangeTokenType.JwtAccessToken
) {
assertThat(jwtVerificationOptions, new InvalidGrant('JWT verification options are required'));
const { localJWKSet, issuer } = jwtVerificationOptions;

try {
const { payload } = await jwtVerify(subjectToken, localJWKSet, { issuer });
assertThat(
payload.sub,
new InvalidGrant('subject token does not contain a valid `sub` claim')
);

// JWT access tokens are not consumption-tracked, so no subjectTokenId is returned.
// This allows the same token to be exchanged multiple times (e.g., by different services).
return { userId: payload.sub };
} catch (error) {
if (error instanceof errors.OIDCProviderError) {
throw error;
}
throw new InvalidGrant('invalid subject token');
}
}

throw new InvalidGrant('unsupported subject token type');
};
86 changes: 86 additions & 0 deletions packages/core/src/oidc/grants/token-exchange/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@ import { type KoaContextWithOIDC, errors } from 'oidc-provider';
import Sinon from 'sinon';

import { mockApplication } from '#src/__mocks__/index.js';
import { EnvSet } from '#src/env-set/index.js';
import { createOidcContext } from '#src/test-utils/oidc-provider.js';
import { MockTenant } from '#src/test-utils/tenant.js';

import { TokenExchangeTokenType } from './types.js';

const { jest } = import.meta;

const mockJwtVerify = jest.fn();

jest.unstable_mockModule('jose', () => ({
jwtVerify: mockJwtVerify,
}));

const { buildHandler } = await import('./index.js');

// eslint-disable-next-line @typescript-eslint/no-empty-function
Expand Down Expand Up @@ -99,6 +106,7 @@ afterAll(() => {
describe('token exchange', () => {
afterEach(() => {
findApplicationById.mockClear();
updateSubjectTokenById.mockClear();
});

it('should throw when client is not available', async () => {
Expand Down Expand Up @@ -241,4 +249,82 @@ describe('token exchange', () => {
});
});
});

describe('JWT access token exchange', () => {
// Stub EnvSet.values to enable dev features for JWT access token exchange
const stub = Sinon.stub(EnvSet, 'values').value({
...EnvSet.values,
isDevFeaturesEnabled: true,
});

afterAll(() => {
stub.restore();
});

const jwtOidcContext: Partial<KoaContextWithOIDC['oidc']> = {
params: {
subject_token: 'some_jwt_token',
subject_token_type: TokenExchangeTokenType.JwtAccessToken,
},
entities: {
Client: validClient,
},
client: validClient,
};

const createPreparedJwtContext = () => {
const ctx = createOidcContext(jwtOidcContext);
return ctx;
};

afterEach(() => {
mockJwtVerify.mockClear();
});

it('should throw when JWT verification fails', async () => {
const ctx = createPreparedJwtContext();
mockJwtVerify.mockRejectedValueOnce(new Error('invalid signature'));
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
new errors.InvalidGrant('invalid subject token')
);
});

it('should throw when JWT does not contain sub claim', async () => {
const ctx = createPreparedJwtContext();
mockJwtVerify.mockResolvedValueOnce({ payload: {} });
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
new errors.InvalidGrant('subject token does not contain a valid `sub` claim')
);
});

it('should throw when account cannot be found', async () => {
const ctx = createPreparedJwtContext();
mockJwtVerify.mockResolvedValueOnce({ payload: { sub: accountId } });
Sinon.stub(ctx.oidc.provider.Account, 'findAccount').resolves();
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidGrant);
});

it('should not consume the token (allow multiple exchanges)', async () => {
const ctx = createPreparedJwtContext();
mockJwtVerify.mockResolvedValueOnce({ payload: { sub: accountId } });
Sinon.stub(ctx.oidc.provider.Account, 'findAccount').resolves({ accountId });

const entityStub = Sinon.stub(ctx.oidc, 'entity');
const noopStub = Sinon.stub().resolves();

await expect(mockHandler(mockTenant)(ctx, noopStub)).resolves.toBeUndefined();
expect(noopStub.callCount).toBe(1);
// JWT tokens should NOT be consumption-tracked
expect(updateSubjectTokenById).not.toHaveBeenCalled();

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const [key, value] = entityStub.lastCall.args;
expect(key).toBe('AccessToken');
expect(value).toMatchObject({
accountId,
clientId,
gty: 'urn:ietf:params:oauth:grant-type:token-exchange',
});
});
});
});
12 changes: 8 additions & 4 deletions packages/core/src/oidc/grants/token-exchange/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,15 @@ export const buildHandler: (
scopes: oidcScopes,
} = providerInstance.configuration();

const { userId, subjectTokenId } = await validateSubjectToken(
const { userId, subjectTokenId } = await validateSubjectToken({
queries,
String(params.subject_token),
String(params.subject_token_type)
);
subjectToken: String(params.subject_token),
subjectTokenType: String(params.subject_token_type),
jwtVerificationOptions: {
localJWKSet: envSet.oidc.localJWKSet,
issuer: envSet.oidc.issuer,
},
});

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

Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/oidc/grants/token-exchange/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,10 @@ export type TokenExchangeAct = z.infer<typeof tokenExchangeActGuard>;
export enum TokenExchangeTokenType {
AccessToken = 'urn:ietf:params:oauth:token-type:access_token',
PersonalAccessToken = 'urn:logto:token-type:personal_access_token',
/**
* JWT access token type for service-to-service delegation.
* Allows servers to exchange received JWT access tokens for tokens with different audiences.
* @see {@link https://datatracker.ietf.org/doc/html/rfc8693#section-3 | RFC 8693 Section 3}
*/
JwtAccessToken = 'urn:ietf:params:oauth:token-type:jwt',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { ApplicationType, GrantType, InteractionEvent, type Resource } 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 { 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 { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generatePassword, generateUsername, getAccessTokenPayload } from '#src/utils.js';

describe('Token Exchange (Actor Token)', () => {
const username = generateUsername();
const password = generatePassword();

const testApiResourceInfo: Pick<Resource, 'name' | 'indicator'> = {
name: 'test-actor-token-resource',
indicator: 'https://actor-token.logto.io/api',
};

/* eslint-disable @silverhand/fp/no-let */
let testApiResourceId: string;
let testApplicationId: 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 applicationName = 'test-actor-token-app';
const applicationType = ApplicationType.SPA;
const application = await createApplication(applicationName, applicationType, {
oidcClientMetadata: { redirectUris: ['http://localhost:3000'], postLogoutRedirectUris: [] },
});
testApplicationId = application.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);
await deleteApplication(testApplicationId);
});

it('should exchange an access token with `act` claim', async () => {
const { subjectToken } = await createSubjectToken(testUserId);

const { access_token } = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: testApplicationId,
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(testUserId);

await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: testApplicationId,
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(testUserId);

await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: testApplicationId,
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(testUserId);
// 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: testApplicationId,
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();
});
});
Loading
Loading