0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core): add subject token context to jwt customizer (#6185)

This commit is contained in:
wangsijie 2024-07-08 15:35:00 +08:00 committed by GitHub
parent c1a01d6925
commit 1557c34134
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 120 additions and 89 deletions

View file

@ -5,6 +5,7 @@ import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { isDevFeaturesEnabled } from '@/consts/env';
import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type';
import {
environmentVariablesCodeExample,
@ -78,7 +79,7 @@ function InstructionTab({ isActive }: Props) {
/>
</GuideCard>
)}
{tokenType === LogtoJwtTokenKeyType.AccessToken && (
{isDevFeaturesEnabled && tokenType === LogtoJwtTokenKeyType.AccessToken && (
<GuideCard
name={CardType.GrantData}
isExpanded={expendCard === CardType.GrantData}

View file

@ -10,6 +10,7 @@ import { type EditorProps } from '@monaco-editor/react';
import TokenFileIcon from '@/assets/icons/token-file-icon.svg';
import UserFileIcon from '@/assets/icons/user-file-icon.svg';
import { isDevFeaturesEnabled } from '../../../consts/env.js';
import type { ModelSettings } from '../MainContent/MonacoCodeEditor/type.js';
import {
@ -209,10 +210,14 @@ const defaultGrantContext: Partial<JwtCustomizerGrantContext> = {
},
};
export const defaultUserTokenContextData = {
user: defaultUserContext,
grant: defaultGrantContext,
};
export const defaultUserTokenContextData = isDevFeaturesEnabled
? {
user: defaultUserContext,
grant: defaultGrantContext,
}
: {
user: defaultUserContext,
};
export const accessTokenPayloadTestModel: ModelSettings = {
language: 'json',

View file

@ -142,6 +142,11 @@ export const getExtraTokenClaimsForJwtCustomization = async (
(await libraries.jwtCustomizers.getUserContext(token.accountId))
);
const subjectToken =
isTokenClientCredentials || token.gty !== GrantType.TokenExchange
? undefined
: await trySafe(async () => queries.subjectTokens.findSubjectToken(token.grantId));
const payload: CustomJwtFetcher = {
script,
environmentVariables,
@ -152,8 +157,18 @@ export const getExtraTokenClaimsForJwtCustomization = async (
tokenType: LogtoJwtTokenKeyType.AccessToken,
// TODO (LOG-8555): the newly added `UserProfile` type includes undefined fields and can not be directly assigned to `Json` type. And the `undefined` fields should be removed by zod guard.
// `context` parameter is only eligible for user's access token for now.
// eslint-disable-next-line no-restricted-syntax
context: { user: logtoUserInfo as Record<string, Json> },
context: {
// eslint-disable-next-line no-restricted-syntax
user: logtoUserInfo as Record<string, Json>,
...conditional(
subjectToken && {
grant: {
type: GrantType.TokenExchange,
subjectTokenContext: subjectToken.context,
},
}
),
},
}),
};

View file

@ -1,4 +1,4 @@
import type { AccessTokenPayload, ClientCredentialsPayload } from '@logto/schemas';
import { type AccessTokenPayload, type ClientCredentialsPayload } from '@logto/schemas';
const standardTokenPayloadData = {
jti: 'f1d3d2d1-1f2d-3d4e-5d6f-7d8a9d0e1d2',

View file

@ -13,6 +13,7 @@ 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 { deleteJwtCustomizer, upsertJwtCustomizer } from '#src/api/logto-config.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';
@ -32,38 +33,65 @@ import {
const { describe, it } = devFeatureTest;
describe('Token Exchange', () => {
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<Resource, 'name' | 'indicator'> = {
name: 'test-api-resource',
indicator: 'https://foo.logto.io/api',
};
/* eslint-disable @silverhand/fp/no-let */
let userId: string;
let applicationId: string;
let testApiResourceId: string;
let testApplicationId: string;
let testUserId: string;
let testAccessToken: string;
let client: MockClient;
/* eslint-enable @silverhand/fp/no-let */
/* eslint-disable @silverhand/fp/no-mutation */
beforeAll(async () => {
const user = await createUserByAdmin();
userId = user.id;
await enableAllPasswordSignInMethods();
/* eslint-disable @silverhand/fp/no-mutation */
const resource = await createResource(testApiResourceInfo.name, testApiResourceInfo.indicator);
testApiResourceId = resource.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;
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 */
});
/* eslint-enable @silverhand/fp/no-mutation */
afterAll(async () => {
await deleteUser(userId);
await deleteApplication(applicationId);
await deleteUser(testUserId);
await deleteResource(testApiResourceId);
await deleteApplication(testApplicationId);
});
describe('Basic flow', () => {
it('should exchange an access token by a subject token', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
const body = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -78,7 +106,7 @@ describe('Token Exchange', () => {
});
it('should fail without valid client_id', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
await expect(
oidcApi.post('token', {
@ -97,7 +125,7 @@ describe('Token Exchange', () => {
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: 'invalid_subject_token',
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -107,12 +135,12 @@ describe('Token Exchange', () => {
});
it('should failed with consumed subject token', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
await oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -122,7 +150,7 @@ describe('Token Exchange', () => {
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -132,7 +160,7 @@ describe('Token Exchange', () => {
});
it('should fail with a third-party application', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
const thirdPartyApplication = await createApplication(
generateName(),
ApplicationType.Traditional,
@ -157,13 +185,13 @@ describe('Token Exchange', () => {
});
it('should filter out non-oidc scopes', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
const body = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -193,14 +221,14 @@ describe('Token Exchange', () => {
beforeAll(async () => {
const organization = await organizationApi.create({ name: 'org1' });
testOrganizationId = organization.id;
await organizationApi.addUsers(testOrganizationId, [userId]);
await organizationApi.addUsers(testOrganizationId, [testUserId]);
const scope = await organizationApi.scopeApi.create({ name: scopeName });
testApiScopeId = scope.id;
const role = await organizationApi.roleApi.create({ name: `role1:${randomString()}` });
await organizationApi.roleApi.addScopes(role.id, [scope.id]);
await organizationApi.addUserRoles(testOrganizationId, userId, [role.id]);
await organizationApi.addUserRoles(testOrganizationId, testUserId, [role.id]);
});
/* eslint-enable @silverhand/fp/no-mutation */
@ -209,13 +237,13 @@ describe('Token Exchange', () => {
});
it('should be able to get access token for organization with correct scopes', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
const { access_token } = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -232,14 +260,14 @@ describe('Token Exchange', () => {
});
it('should throw when organization requires mfa but user has not configured', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
await organizationApi.update(testOrganizationId, { isMfaRequired: true });
await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -250,13 +278,13 @@ describe('Token Exchange', () => {
});
it('should be able to get access token for organization when user has mfa configured', async () => {
const { subjectToken } = await createSubjectToken(userId);
await createUserMfaVerification(userId, MfaFactor.TOTP);
const { subjectToken } = await createSubjectToken(testUserId);
await createUserMfaVerification(testUserId, MfaFactor.TOTP);
const { access_token } = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -272,59 +300,14 @@ 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<Resource, 'name' | 'indicator'> = {
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 { subjectToken } = await createSubjectToken(testUserId);
const { access_token } = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -339,13 +322,13 @@ describe('Token Exchange', () => {
});
it('should fail with invalid actor_token_type', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -358,13 +341,13 @@ describe('Token Exchange', () => {
});
it('should fail with invalid actor_token', async () => {
const { subjectToken } = await createSubjectToken(userId);
const { subjectToken } = await createSubjectToken(testUserId);
await expect(
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -377,7 +360,7 @@ describe('Token Exchange', () => {
});
it('should fail when the actor token do not have `openid` scope', async () => {
const { subjectToken } = await createSubjectToken(userId);
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);
@ -385,7 +368,7 @@ describe('Token Exchange', () => {
oidcApi.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: applicationId,
client_id: testApplicationId,
grant_type: GrantType.TokenExchange,
subject_token: subjectToken,
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
@ -397,4 +380,31 @@ describe('Token Exchange', () => {
).rejects.toThrow();
});
});
describe('custom jwt', () => {
it('should get context from subject token', async () => {
const { subjectToken } = await createSubjectToken(testUserId, { foo: 'bar' });
await upsertJwtCustomizer('access-token', {
script: `const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
return { foo: context?.grant?.subjectTokenContext?.foo };
};`,
});
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',
resource: testApiResourceInfo.indicator,
}),
})
.json<{ access_token: string }>();
expect(getAccessTokenPayload(access_token)).toHaveProperty('foo', 'bar');
await deleteJwtCustomizer('access-token');
});
});
});