0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

feat(core,console): enable custom JWT for OSS and can run script in local vm (#5794)

This commit is contained in:
Darcy Ye 2024-05-11 22:22:14 +08:00 committed by GitHub
parent 23d40ed4de
commit 5872172cbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 913 additions and 529 deletions

View file

@ -0,0 +1,8 @@
---
"@logto/console": minor
"@logto/core": minor
---
enable custom JWT feature for OSS version
OSS version users can now use custom JWT feature to add custom claims to JWT access tokens payload (previously, this feature was only available to Logto Cloud).

View file

@ -130,7 +130,6 @@ export const useSidebarMenuItems = (): {
{ {
Icon: JwtClaims, Icon: JwtClaims,
title: 'customize_jwt', title: 'customize_jwt',
isHidden: !isCloud,
}, },
{ {
Icon: Hook, Icon: Hook,

View file

@ -63,7 +63,7 @@ export const useConsoleRoutes = () => {
}, },
{ path: 'signing-keys', element: <SigningKeys /> }, { path: 'signing-keys', element: <SigningKeys /> },
isCloud && tenantSettings, isCloud && tenantSettings,
isCloud && customizeJwt customizeJwt
), ),
[tenantSettings] [tenantSettings]
); );

View file

@ -1,14 +1,19 @@
import { runScriptFunctionInLocalVm, buildErrorResponse } from '@logto/core-kit/custom-jwt';
import { import {
userInfoSelectFields, userInfoSelectFields,
jwtCustomizerUserContextGuard, jwtCustomizerUserContextGuard,
type LogtoJwtTokenKey, type LogtoJwtTokenKey,
type JwtCustomizerType, type JwtCustomizerType,
type JwtCustomizerUserContext, type JwtCustomizerUserContext,
type CustomJwtFetcher,
LogtoJwtTokenKeyType,
} from '@logto/schemas'; } from '@logto/schemas';
import { type ConsoleLog } from '@logto/shared'; import { type ConsoleLog } from '@logto/shared';
import { deduplicate, pick, pickState, assert } from '@silverhand/essentials'; import { deduplicate, pick, pickState, assert } from '@silverhand/essentials';
import deepmerge from 'deepmerge'; import deepmerge from 'deepmerge';
import { z, ZodError } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import type { LogtoConfigLibrary } from '#src/libraries/logto-config.js'; import type { LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { type ScopeLibrary } from '#src/libraries/scope.js'; import { type ScopeLibrary } from '#src/libraries/scope.js';
@ -18,26 +23,49 @@ import {
getJwtCustomizerScripts, getJwtCustomizerScripts,
type CustomJwtDeployRequestBody, type CustomJwtDeployRequestBody,
} from '#src/utils/custom-jwt/index.js'; } from '#src/utils/custom-jwt/index.js';
import { LocalVmError } from '#src/utils/custom-jwt/index.js';
import { type CloudConnectionLibrary } from './cloud-connection.js'; import { type CloudConnectionLibrary } from './cloud-connection.js';
export const createJwtCustomizerLibrary = ( export class JwtCustomizerLibrary {
queries: Queries, // Convert errors to WithTyped client response error to share the error handling logic.
logtoConfigs: LogtoConfigLibrary, static async runScriptInLocalVm(data: CustomJwtFetcher) {
cloudConnection: CloudConnectionLibrary, try {
userLibrary: UserLibrary, const payload =
scopeLibrary: ScopeLibrary data.tokenType === LogtoJwtTokenKeyType.AccessToken
) => { ? pick(data, 'token', 'context', 'environmentVariables')
const { : pick(data, 'token', 'environmentVariables');
users: { findUserById }, const result = await runScriptFunctionInLocalVm(data.script, 'getCustomJwtClaims', payload);
rolesScopes: { findRolesScopesByRoleIds },
scopes: { findScopesByIds }, // If the `result` is not a record, we cannot merge it to the existing token payload.
userSsoIdentities, return z.record(z.unknown()).parse(result);
organizations: { relations }, } catch (error: unknown) {
} = queries; // Assuming we only use zod for request body validation
const { findUserRoles } = userLibrary; if (error instanceof ZodError) {
const { attachResourceToScopes } = scopeLibrary; const { errors } = error;
const { getJwtCustomizers } = logtoConfigs; throw new LocalVmError(
{
message: 'Invalid input',
errors,
},
400
);
}
throw new LocalVmError(
buildErrorResponse(error),
error instanceof SyntaxError || error instanceof TypeError ? 422 : 500
);
}
}
constructor(
private readonly queries: Queries,
private readonly logtoConfigs: LogtoConfigLibrary,
private readonly cloudConnection: CloudConnectionLibrary,
private readonly userLibrary: UserLibrary,
private readonly scopeLibrary: ScopeLibrary
) {}
/** /**
* We does not include org roles' scopes for the following reason: * We does not include org roles' scopes for the following reason:
@ -45,15 +73,20 @@ export const createJwtCustomizerLibrary = (
* these APIs from console setup while this library method is a backend used method. * these APIs from console setup while this library method is a backend used method.
* 2. Logto developers can get the org roles' id from this user context and hence query the org roles' scopes via management API. * 2. Logto developers can get the org roles' id from this user context and hence query the org roles' scopes via management API.
*/ */
const getUserContext = async (userId: string): Promise<JwtCustomizerUserContext> => { async getUserContext(userId: string): Promise<JwtCustomizerUserContext> {
const user = await findUserById(userId); const user = await this.queries.users.findUserById(userId);
const fullSsoIdentities = await userSsoIdentities.findUserSsoIdentitiesByUserId(userId); const fullSsoIdentities = await this.queries.userSsoIdentities.findUserSsoIdentitiesByUserId(
const roles = await findUserRoles(userId); userId
const rolesScopes = await findRolesScopesByRoleIds(roles.map(({ id }) => id)); );
const roles = await this.userLibrary.findUserRoles(userId);
const rolesScopes = await this.queries.rolesScopes.findRolesScopesByRoleIds(
roles.map(({ id }) => id)
);
const scopeIds = rolesScopes.map(({ scopeId }) => scopeId); const scopeIds = rolesScopes.map(({ scopeId }) => scopeId);
const scopes = await findScopesByIds(scopeIds); const scopes = await this.queries.scopes.findScopesByIds(scopeIds);
const scopesWithResources = await attachResourceToScopes(scopes); const scopesWithResources = await this.scopeLibrary.attachResourceToScopes(scopes);
const organizationsWithRoles = await relations.users.getOrganizationsByUserId(userId); const organizationsWithRoles =
await this.queries.organizations.relations.users.getOrganizationsByUserId(userId);
const userContext = { const userContext = {
...pick(user, ...userInfoSelectFields), ...pick(user, ...userInfoSelectFields),
ssoIdentities: fullSsoIdentities.map(pickState('issuer', 'identityId', 'detail')), ssoIdentities: fullSsoIdentities.map(pickState('issuer', 'identityId', 'detail')),
@ -81,7 +114,7 @@ export const createJwtCustomizerLibrary = (
}; };
return jwtCustomizerUserContextGuard.parse(userContext); return jwtCustomizerUserContextGuard.parse(userContext);
}; }
/** /**
* This method is used to deploy the give JWT customizer scripts to the cloud worker service. * This method is used to deploy the give JWT customizer scripts to the cloud worker service.
@ -95,17 +128,24 @@ export const createJwtCustomizerLibrary = (
* @params payload.value - JWT customizer value * @params payload.value - JWT customizer value
* @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`. * @params payload.useCase - The use case of JWT customizer script, can be either `test` or `production`.
*/ */
const deployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>( async deployJwtCustomizerScript<T extends LogtoJwtTokenKey>(
consoleLog: ConsoleLog, consoleLog: ConsoleLog,
payload: { payload: {
key: T; key: T;
value: JwtCustomizerType[T]; value: JwtCustomizerType[T];
useCase: 'test' | 'production'; useCase: 'test' | 'production';
} }
) => { ) {
if (!EnvSet.values.isCloud) {
consoleLog.warn(
'Early terminate `deployJwtCustomizerScript` since we do not provide dedicated computing resource for OSS version.'
);
return;
}
const [client, jwtCustomizers] = await Promise.all([ const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(), this.cloudConnection.getClient(),
getJwtCustomizers(consoleLog), this.logtoConfigs.getJwtCustomizers(consoleLog),
]); ]);
const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers); const customizerScriptsFromDatabase = getJwtCustomizerScripts(jwtCustomizers);
@ -129,15 +169,19 @@ export const createJwtCustomizerLibrary = (
await client.put(`/api/services/custom-jwt/worker`, { await client.put(`/api/services/custom-jwt/worker`, {
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts), body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts),
}); });
}; }
async undeployJwtCustomizerScript<T extends LogtoJwtTokenKey>(consoleLog: ConsoleLog, key: T) {
if (!EnvSet.values.isCloud) {
consoleLog.warn(
'Early terminate `undeployJwtCustomizerScript` since we do not deploy the script to dedicated computing resource for OSS version.'
);
return;
}
const undeployJwtCustomizerScript = async <T extends LogtoJwtTokenKey>(
consoleLog: ConsoleLog,
key: T
) => {
const [client, jwtCustomizers] = await Promise.all([ const [client, jwtCustomizers] = await Promise.all([
cloudConnection.getClient(), this.cloudConnection.getClient(),
getJwtCustomizers(consoleLog), this.logtoConfigs.getJwtCustomizers(consoleLog),
]); ]);
assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key })); assert(jwtCustomizers[key], new RequestError({ code: 'entity.not_exists', key }));
@ -160,10 +204,5 @@ export const createJwtCustomizerLibrary = (
await client.put(`/api/services/custom-jwt/worker`, { await client.put(`/api/services/custom-jwt/worker`, {
body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts), body: deepmerge(customizerScriptsFromDatabase, newCustomizerScripts),
}); });
}; }
return { }
getUserContext,
deployJwtCustomizerScript,
undeployJwtCustomizerScript,
};
};

View file

@ -4,6 +4,7 @@ import {
LogtoJwtTokenKeyType, LogtoJwtTokenKeyType,
LogResult, LogResult,
jwtCustomizer as jwtCustomizerLog, jwtCustomizer as jwtCustomizerLog,
type CustomJwtFetcher,
} from '@logto/schemas'; } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
import { conditional, trySafe } from '@silverhand/essentials'; import { conditional, trySafe } from '@silverhand/essentials';
@ -11,6 +12,7 @@ import { type KoaContextWithOIDC, type UnknownObject } from 'oidc-provider';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'; import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js';
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js'; import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { LogEntry } from '#src/middleware/koa-audit-log.js'; import { LogEntry } from '#src/middleware/koa-audit-log.js';
import type Libraries from '#src/tenants/Libraries.js'; import type Libraries from '#src/tenants/Libraries.js';
@ -66,12 +68,6 @@ export const getExtraTokenClaimsForJwtCustomization = async (
cloudConnection: CloudConnectionLibrary; cloudConnection: CloudConnectionLibrary;
} }
): Promise<UnknownObject | undefined> => { ): Promise<UnknownObject | undefined> => {
const { isCloud } = EnvSet.values;
// No cloud connection for OSS version, skip.
if (!isCloud) {
return;
}
// Narrow down the token type to `AccessToken` and `ClientCredentials`. // Narrow down the token type to `AccessToken` and `ClientCredentials`.
if ( if (
!(token instanceof ctx.oidc.provider.AccessToken) && !(token instanceof ctx.oidc.provider.AccessToken) &&
@ -110,14 +106,6 @@ export const getExtraTokenClaimsForJwtCustomization = async (
.map((field) => [field, Reflect.get(token, field)]) .map((field) => [field, Reflect.get(token, field)])
); );
const client = await cloudConnection.getClient();
const commonPayload = {
script,
environmentVariables,
token: readOnlyToken,
};
// We pass context to the cloud API only when it is a user's access token. // We pass context to the cloud API only when it is a user's access token.
const logtoUserInfo = conditional( const logtoUserInfo = conditional(
!isTokenClientCredentials && !isTokenClientCredentials &&
@ -125,22 +113,29 @@ export const getExtraTokenClaimsForJwtCustomization = async (
(await libraries.jwtCustomizers.getUserContext(token.accountId)) (await libraries.jwtCustomizers.getUserContext(token.accountId))
); );
// `context` parameter is only eligible for user's access token for now. const payload: CustomJwtFetcher = {
return await client.post(`/api/services/custom-jwt`, { script,
body: isTokenClientCredentials environmentVariables,
? { token: readOnlyToken,
...commonPayload, ...(isTokenClientCredentials
tokenType: LogtoJwtTokenKeyType.ClientCredentials, ? { tokenType: LogtoJwtTokenKeyType.ClientCredentials }
}
: { : {
...commonPayload,
tokenType: LogtoJwtTokenKeyType.AccessToken, 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. // 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 // eslint-disable-next-line no-restricted-syntax
context: { user: logtoUserInfo as Record<string, Json> }, context: { user: logtoUserInfo as Record<string, Json> },
}, }),
};
if (EnvSet.values.isCloud) {
const client = await cloudConnection.getClient();
return await client.post(`/api/services/custom-jwt`, {
body: payload,
search: {}, search: {},
}); });
}
return await JwtCustomizerLibrary.runScriptInLocalVm(payload);
} catch (error: unknown) { } catch (error: unknown) {
const entry = new LogEntry( const entry = new LogEntry(
`${jwtCustomizerLog.prefix}.${ `${jwtCustomizerLog.prefix}.${

View file

@ -157,10 +157,9 @@ describe('configs JWT customizer routes', () => {
expect(response.status).toEqual(204); expect(response.status).toEqual(204);
}); });
it('POST /configs/jwt-customizer/test should return 200', async () => { it('POST /configs/jwt-customizer/test should not call cloud connection client post', async () => {
const cloudConnectionResponse = { success: true };
jest.spyOn(tenantContext.cloudConnection, 'getClient').mockResolvedValue(mockCloudClient); jest.spyOn(tenantContext.cloudConnection, 'getClient').mockResolvedValue(mockCloudClient);
jest.spyOn(mockCloudClient, 'post').mockResolvedValue(cloudConnectionResponse); const clientPostSpy = jest.spyOn(mockCloudClient, 'post');
const payload: JwtCustomizerTestRequestBody = { const payload: JwtCustomizerTestRequestBody = {
tokenType: LogtoJwtTokenKeyType.ClientCredentials, tokenType: LogtoJwtTokenKeyType.ClientCredentials,
@ -169,7 +168,7 @@ describe('configs JWT customizer routes', () => {
token: {}, token: {},
}; };
const response = await routeRequester.post('/configs/jwt-customizer/test').send(payload); await routeRequester.post('/configs/jwt-customizer/test').send(payload);
expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith( expect(tenantContext.libraries.jwtCustomizers.deployJwtCustomizerScript).toHaveBeenCalledWith(
expect.any(ConsoleLog), expect.any(ConsoleLog),
@ -180,13 +179,8 @@ describe('configs JWT customizer routes', () => {
} }
); );
expect(mockCloudClient.post).toHaveBeenCalledWith('/api/services/custom-jwt', { expect(clientPostSpy).toHaveBeenCalledTimes(0);
body: payload,
search: {
isTest: 'true',
},
});
expect(response.status).toEqual(200); // TODO: Add the test on nested class static method.
}); });
}); });

View file

@ -13,6 +13,7 @@ import { ZodError, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js'; import { EnvSet } from '#src/env-set/index.js';
import RequestError, { formatZodError } from '#src/errors/RequestError/index.js'; import RequestError, { formatZodError } from '#src/errors/RequestError/index.js';
import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js';
import koaGuard, { parse } from '#src/middleware/koa-guard.js'; import koaGuard, { parse } from '#src/middleware/koa-guard.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js'; import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
import { getConsoleLogFromContext } from '#src/utils/console.js'; import { getConsoleLogFromContext } from '#src/utils/console.js';
@ -41,7 +42,6 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
const { getRowsByKeys, deleteJwtCustomizer } = queries.logtoConfigs; const { getRowsByKeys, deleteJwtCustomizer } = queries.logtoConfigs;
const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers, updateJwtCustomizer } = const { upsertJwtCustomizer, getJwtCustomizer, getJwtCustomizers, updateJwtCustomizer } =
logtoConfigs; logtoConfigs;
const { deployJwtCustomizerScript, undeployJwtCustomizerScript } = libraries.jwtCustomizers;
router.put( router.put(
'/configs/jwt-customizer/:tokenTypePath', '/configs/jwt-customizer/:tokenTypePath',
@ -79,7 +79,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully. // Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
if (!isIntegrationTest) { if (!isIntegrationTest) {
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), { await libraries.jwtCustomizers.deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key, key,
value: body, value: body,
useCase: 'production', useCase: 'production',
@ -123,7 +123,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
// Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully. // Deploy first to avoid the case where the JWT customizer was saved to DB but not deployed successfully.
if (!isIntegrationTest) { if (!isIntegrationTest) {
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), { await libraries.jwtCustomizers.deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key, key,
value: body, value: body,
useCase: 'production', useCase: 'production',
@ -195,7 +195,10 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
// Undeploy the script first to avoid the case where the JWT customizer was deleted from DB but worker script not updated successfully. // Undeploy the script first to avoid the case where the JWT customizer was deleted from DB but worker script not updated successfully.
if (!isIntegrationTest) { if (!isIntegrationTest) {
await undeployJwtCustomizerScript(getConsoleLogFromContext(ctx), tokenKey); await libraries.jwtCustomizers.undeployJwtCustomizerScript(
getConsoleLogFromContext(ctx),
tokenKey
);
} }
await deleteJwtCustomizer(tokenKey); await deleteJwtCustomizer(tokenKey);
@ -204,10 +207,6 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
} }
); );
if (!EnvSet.values.isCloud && !EnvSet.values.isUnitTest) {
return;
}
router.post( router.post(
'/configs/jwt-customizer/test', '/configs/jwt-customizer/test',
koaGuard({ koaGuard({
@ -220,7 +219,7 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
const { body } = ctx.guard; const { body } = ctx.guard;
// Deploy the test script // Deploy the test script
await deployJwtCustomizerScript(getConsoleLogFromContext(ctx), { await libraries.jwtCustomizers.deployJwtCustomizerScript(getConsoleLogFromContext(ctx), {
key: key:
body.tokenType === LogtoJwtTokenKeyType.AccessToken body.tokenType === LogtoJwtTokenKeyType.AccessToken
? LogtoJwtTokenKey.AccessToken ? LogtoJwtTokenKey.AccessToken
@ -229,13 +228,16 @@ export default function logtoConfigJwtCustomizerRoutes<T extends ManagementApiRo
useCase: 'test', useCase: 'test',
}); });
const client = await cloudConnection.getClient();
try { try {
if (EnvSet.values.isCloud) {
const client = await cloudConnection.getClient();
ctx.body = await client.post(`/api/services/custom-jwt`, { ctx.body = await client.post(`/api/services/custom-jwt`, {
body, body,
search: { isTest: 'true' }, search: { isTest: 'true' },
}); });
} else {
ctx.body = await JwtCustomizerLibrary.runScriptInLocalVm(body);
}
} catch (error: unknown) { } catch (error: unknown) {
/** /**
* All APIs should throw `RequestError` instead of `Error`. * All APIs should throw `RequestError` instead of `Error`.

View file

@ -246,7 +246,6 @@
}, },
"/api/configs/jwt-customizer/test": { "/api/configs/jwt-customizer/test": {
"post": { "post": {
"tags": ["Cloud only"],
"summary": "Test JWT customizer", "summary": "Test JWT customizer",
"description": "Test the JWT customizer script with the given sample context and sample token payload.", "description": "Test the JWT customizer script with the given sample context and sample token payload.",
"requestBody": { "requestBody": {

View file

@ -3,7 +3,7 @@ import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js'
import type { ConnectorLibrary } from '#src/libraries/connector.js'; import type { ConnectorLibrary } from '#src/libraries/connector.js';
import { createDomainLibrary } from '#src/libraries/domain.js'; import { createDomainLibrary } from '#src/libraries/domain.js';
import { createHookLibrary } from '#src/libraries/hook/index.js'; import { createHookLibrary } from '#src/libraries/hook/index.js';
import { createJwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js'; import { JwtCustomizerLibrary } from '#src/libraries/jwt-customizer.js';
import type { LogtoConfigLibrary } from '#src/libraries/logto-config.js'; import type { LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { OrganizationInvitationLibrary } from '#src/libraries/organization-invitation.js'; import { OrganizationInvitationLibrary } from '#src/libraries/organization-invitation.js';
import { createPasscodeLibrary } from '#src/libraries/passcode.js'; import { createPasscodeLibrary } from '#src/libraries/passcode.js';
@ -26,7 +26,7 @@ export default class Libraries {
hooks = createHookLibrary(this.queries); hooks = createHookLibrary(this.queries);
scopes = createScopeLibrary(this.queries); scopes = createScopeLibrary(this.queries);
socials = createSocialLibrary(this.queries, this.connectors); socials = createSocialLibrary(this.queries, this.connectors);
jwtCustomizers = createJwtCustomizerLibrary( jwtCustomizers = new JwtCustomizerLibrary(
this.queries, this.queries,
this.logtoConfigs, this.logtoConfigs,
this.cloudConnection, this.cloudConnection,

View file

@ -1,2 +1,3 @@
export * from './custom-jwt.js'; export * from './custom-jwt.js';
export * from './types.js'; export * from './types.js';
export * from './local-vm-error.js';

View file

@ -0,0 +1,17 @@
import { ResponseError } from '@withtyped/client';
// Extend the ResponseError from @withtyped/client, so that we can unify the error handling and display logic for both OSS version and Cloud version.
export class LocalVmError extends ResponseError {
constructor(errorBody: Record<string, unknown>, statusCode: number) {
super(
new Response(
new Blob([JSON.stringify(errorBody)], {
type: 'application/json',
}),
{
status: statusCode,
}
)
);
}
}

View file

@ -1,11 +1,38 @@
import type { AccessTokenPayload, ClientCredentialsPayload } from '@logto/schemas';
const standardTokenPayloadData = {
jti: 'f1d3d2d1-1f2d-3d4e-5d6f-7d8a9d0e1d2',
aud: 'http://localhost:3000/api/test',
scope: 'read write',
clientId: 'my_app',
};
export const accessTokenSample: AccessTokenPayload = {
...standardTokenPayloadData,
accountId: 'uid_123',
grantId: 'grant_123',
gty: 'authorization_code',
kind: 'AccessToken',
};
export const clientCredentialsTokenSample: ClientCredentialsPayload = {
...standardTokenPayloadData,
kind: 'ClientCredentials',
};
export const clientCredentialsJwtCustomizerPayload = { export const clientCredentialsJwtCustomizerPayload = {
script: '', script: '',
environmentVariables: {}, environmentVariables: {
foo: 'bar',
API_KEY: '12345',
},
contextSample: {}, contextSample: {},
tokenSample: clientCredentialsTokenSample,
}; };
export const accessTokenJwtCustomizerPayload = { export const accessTokenJwtCustomizerPayload = {
...clientCredentialsJwtCustomizerPayload, ...clientCredentialsJwtCustomizerPayload,
tokenSample: accessTokenSample,
contextSample: { contextSample: {
user: { user: {
id: '123', id: '123',
@ -26,3 +53,11 @@ export const accessTokenJwtCustomizerPayload = {
}, },
}, },
}; };
export const accessTokenSampleScript = `const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
return { user_id: context?.user?.id ?? 'unknown' };
}`;
export const clientCredentialsSampleScript = `const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
return { ...environmentVariables };
}`;

View file

@ -6,6 +6,8 @@ import {
type AccessTokenJwtCustomizer, type AccessTokenJwtCustomizer,
type ClientCredentialsJwtCustomizer, type ClientCredentialsJwtCustomizer,
type JwtCustomizerConfigs, type JwtCustomizerConfigs,
type JwtCustomizerTestRequestBody,
type Json,
} from '@logto/schemas'; } from '@logto/schemas';
import { authedAdminApi } from './api.js'; import { authedAdminApi } from './api.js';
@ -60,3 +62,10 @@ export const updateJwtCustomizer = async (
authedAdminApi authedAdminApi
.patch(`configs/jwt-customizer/${keyTypePath}`, { json: value }) .patch(`configs/jwt-customizer/${keyTypePath}`, { json: value })
.json<AccessTokenJwtCustomizer | ClientCredentialsJwtCustomizer>(); .json<AccessTokenJwtCustomizer | ClientCredentialsJwtCustomizer>();
export const testJwtCustomizer = async (payload: JwtCustomizerTestRequestBody) =>
authedAdminApi
.post(`configs/jwt-customizer/test`, {
json: payload,
})
.json<Json>();

View file

@ -1,7 +1,12 @@
import { ApplicationType, RoleType } from '@logto/schemas'; import { ApplicationType, RoleType } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { generateStandardId, formUrlEncodedHeaders } from '@logto/shared';
import { HTTPError } from 'ky'; import { HTTPError } from 'ky';
import {
clientCredentialsJwtCustomizerPayload,
clientCredentialsSampleScript,
} from '#src/__mocks__/jwt-customizer.js';
import { oidcApi } from '#src/api/api.js';
import { import {
createApplication, createApplication,
getApplicationRoles, getApplicationRoles,
@ -9,9 +14,14 @@ import {
deleteRoleFromApplication, deleteRoleFromApplication,
putRolesToApplication, putRolesToApplication,
getApplications, getApplications,
createResource,
upsertJwtCustomizer,
deleteJwtCustomizer,
} from '#src/api/index.js'; } from '#src/api/index.js';
import { createRole, assignApplicationsToRole } from '#src/api/role.js'; import { createRole, assignApplicationsToRole } from '#src/api/role.js';
import { createScope } from '#src/api/scope.js';
import { expectRejects } from '#src/helpers/index.js'; import { expectRejects } from '#src/helpers/index.js';
import { getAccessTokenPayload } from '#src/utils.js';
describe('admin console application management (roles)', () => { describe('admin console application management (roles)', () => {
it('should get empty list successfully', async () => { it('should get empty list successfully', async () => {
@ -146,4 +156,40 @@ describe('admin console application management (roles)', () => {
expect(applications.find(({ name }) => name === 'test-m2m-app-002')).toBeFalsy(); expect(applications.find(({ name }) => name === 'test-m2m-app-002')).toBeFalsy();
expect(applications.find(({ name }) => name === 'test-spa-app-002')).toBeTruthy(); expect(applications.find(({ name }) => name === 'test-spa-app-002')).toBeTruthy();
}); });
it('test m2m application client credentials grant type with custom JWT', async () => {
await upsertJwtCustomizer('client-credentials', {
...clientCredentialsJwtCustomizerPayload,
script: clientCredentialsSampleScript,
});
const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine);
const resource = await createResource();
const createdScope = await createScope(resource.id);
const createdScope2 = await createScope(resource.id);
const role = await createRole({
type: RoleType.MachineToMachine,
scopeIds: [createdScope.id, createdScope2.id],
});
await assignApplicationsToRole([m2mApp.id], role.id);
const { access_token: accessToken } = await oidcApi
.post('token', {
headers: formUrlEncodedHeaders,
body: new URLSearchParams({
client_id: m2mApp.id,
client_secret: m2mApp.secret,
grant_type: 'client_credentials',
resource: resource.indicator,
scope: [createdScope.name, createdScope2.name].join(' '),
}),
})
.json<{ access_token: string }>();
const payload = getAccessTokenPayload(accessToken);
expect(payload).toHaveProperty('foo', 'bar');
expect(payload).toHaveProperty('API_KEY', '12345');
await deleteJwtCustomizer('client-credentials');
});
}); });

View file

@ -3,11 +3,14 @@ import {
type AdminConsoleData, type AdminConsoleData,
LogtoOidcConfigKeyType, LogtoOidcConfigKeyType,
LogtoJwtTokenKey, LogtoJwtTokenKey,
LogtoJwtTokenKeyType,
} from '@logto/schemas'; } from '@logto/schemas';
import { import {
accessTokenJwtCustomizerPayload, accessTokenJwtCustomizerPayload,
clientCredentialsJwtCustomizerPayload, clientCredentialsJwtCustomizerPayload,
accessTokenSampleScript,
clientCredentialsSampleScript,
} from '#src/__mocks__/jwt-customizer.js'; } from '#src/__mocks__/jwt-customizer.js';
import { import {
deleteOidcKey, deleteOidcKey,
@ -20,6 +23,7 @@ import {
getJwtCustomizer, getJwtCustomizer,
getJwtCustomizers, getJwtCustomizers,
deleteJwtCustomizer, deleteJwtCustomizer,
testJwtCustomizer,
} from '#src/api/index.js'; } from '#src/api/index.js';
import { expectRejects } from '#src/helpers/index.js'; import { expectRejects } from '#src/helpers/index.js';
@ -241,4 +245,27 @@ describe('admin console sign-in experience', () => {
await deleteJwtCustomizer('client-credentials'); await deleteJwtCustomizer('client-credentials');
await expect(getJwtCustomizers()).resolves.toEqual([]); await expect(getJwtCustomizers()).resolves.toEqual([]);
}); });
it('should successfully test an access token JWT customizer', async () => {
const testResult = await testJwtCustomizer({
tokenType: LogtoJwtTokenKeyType.AccessToken,
token: accessTokenJwtCustomizerPayload.tokenSample,
context: accessTokenJwtCustomizerPayload.contextSample,
script: accessTokenSampleScript,
environmentVariables: accessTokenJwtCustomizerPayload.environmentVariables,
});
expect(testResult).toMatchObject({
user_id: accessTokenJwtCustomizerPayload.contextSample.user.id,
});
});
it('should successfully test a client credentials JWT customizer', async () => {
const testResult = await testJwtCustomizer({
tokenType: LogtoJwtTokenKeyType.ClientCredentials,
token: clientCredentialsJwtCustomizerPayload.tokenSample,
script: clientCredentialsSampleScript,
environmentVariables: clientCredentialsJwtCustomizerPayload.environmentVariables,
});
expect(testResult).toMatchObject(clientCredentialsJwtCustomizerPayload.environmentVariables);
});
}); });

View file

@ -5,7 +5,18 @@ import { InteractionEvent, type Resource, RoleType } from '@logto/schemas';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import { createRemoteJWKSet, jwtVerify } from 'jose'; import { createRemoteJWKSet, jwtVerify } from 'jose';
import { createResource, deleteResource, deleteUser, putInteraction } from '#src/api/index.js'; import {
accessTokenJwtCustomizerPayload,
accessTokenSampleScript,
} from '#src/__mocks__/jwt-customizer.js';
import {
createResource,
deleteJwtCustomizer,
deleteResource,
deleteUser,
putInteraction,
upsertJwtCustomizer,
} from '#src/api/index.js';
import { assignUsersToRole, createRole, deleteRole } from '#src/api/role.js'; import { assignUsersToRole, createRole, deleteRole } from '#src/api/role.js';
import { createScope, deleteScope } from '#src/api/scope.js'; import { createScope, deleteScope } from '#src/api/scope.js';
import MockClient, { defaultConfig } from '#src/client/index.js'; import MockClient, { defaultConfig } from '#src/client/index.js';
@ -91,6 +102,11 @@ describe('get access token', () => {
}); });
it('can sign in and getAccessToken with guest user', async () => { it('can sign in and getAccessToken with guest user', async () => {
await upsertJwtCustomizer('access-token', {
...accessTokenJwtCustomizerPayload,
script: accessTokenSampleScript,
});
const client = new MockClient({ const client = new MockClient({
resources: [testApiResourceInfo.indicator], resources: [testApiResourceInfo.indicator],
scopes: testApiScopeNames, scopes: testApiScopeNames,
@ -108,6 +124,9 @@ describe('get access token', () => {
'scope', 'scope',
testApiScopeNames.join(' ') testApiScopeNames.join(' ')
); );
expect(getAccessTokenPayload(accessToken)).toHaveProperty('user_id', guestUserId);
await deleteJwtCustomizer('access-token');
}); });
it('sign in and verify jwt', async () => { it('sign in and verify jwt', async () => {

View file

@ -1,12 +1,24 @@
import { jsonObjectGuard } from '@logto/connector-kit';
import { z } from 'zod'; import { z } from 'zod';
import { Organizations, Roles, UserSsoIdentities } from '../../db-entries/index.js'; import { Organizations, Roles, UserSsoIdentities } from '../../db-entries/index.js';
import { jsonObjectGuard, mfaFactorsGuard } from '../../foundations/index.js'; import { mfaFactorsGuard } from '../../foundations/index.js';
import { scopeResponseGuard } from '../scope.js'; import { scopeResponseGuard } from '../scope.js';
import { userInfoGuard } from '../user.js'; import { userInfoGuard } from '../user.js';
import { accessTokenPayloadGuard, clientCredentialsPayloadGuard } from './oidc-provider.js'; import { accessTokenPayloadGuard, clientCredentialsPayloadGuard } from './oidc-provider.js';
export const jwtCustomizerGuard = z.object({
script: z.string(),
environmentVariables: z.record(z.string()).optional(),
contextSample: jsonObjectGuard.optional(),
});
export enum LogtoJwtTokenKeyType {
AccessToken = 'access-token',
ClientCredentials = 'client-credentials',
}
export const jwtCustomizerUserContextGuard = userInfoGuard.extend({ export const jwtCustomizerUserContextGuard = userInfoGuard.extend({
ssoIdentities: UserSsoIdentities.guard ssoIdentities: UserSsoIdentities.guard
.pick({ issuer: true, identityId: true, detail: true }) .pick({ issuer: true, identityId: true, detail: true })
@ -32,12 +44,6 @@ export const jwtCustomizerUserContextGuard = userInfoGuard.extend({
export type JwtCustomizerUserContext = z.infer<typeof jwtCustomizerUserContextGuard>; export type JwtCustomizerUserContext = z.infer<typeof jwtCustomizerUserContextGuard>;
export const jwtCustomizerGuard = z.object({
script: z.string(),
environmentVariables: z.record(z.string()).optional(),
contextSample: jsonObjectGuard.optional(),
});
export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard export const accessTokenJwtCustomizerGuard = jwtCustomizerGuard
.extend({ .extend({
// Use partial token guard since users customization may not rely on all fields. // Use partial token guard since users customization may not rely on all fields.
@ -57,11 +63,6 @@ export const clientCredentialsJwtCustomizerGuard = jwtCustomizerGuard
export type ClientCredentialsJwtCustomizer = z.infer<typeof clientCredentialsJwtCustomizerGuard>; export type ClientCredentialsJwtCustomizer = z.infer<typeof clientCredentialsJwtCustomizerGuard>;
export enum LogtoJwtTokenKeyType {
AccessToken = 'access-token',
ClientCredentials = 'client-credentials',
}
/** /**
* This guard is for the core JWT customizer testing API request body guard. * This guard is for the core JWT customizer testing API request body guard.
* Unlike the DB guard * Unlike the DB guard

View file

@ -17,7 +17,11 @@
"import": "./lib/index.js" "import": "./lib/index.js"
}, },
"./declaration": "./declaration/index.ts", "./declaration": "./declaration/index.ts",
"./scss/*": "./scss/*.scss" "./scss/*": "./scss/*.scss",
"./custom-jwt": {
"node": "./lib/custom-jwt/index.js",
"types": "./lib/custom-jwt/index.d.ts"
}
}, },
"types": "./lib/index.d.ts", "types": "./lib/index.d.ts",
"files": [ "files": [

View file

@ -0,0 +1,10 @@
import { types } from 'node:util';
export const buildErrorResponse = (error: unknown) =>
/**
* Use `isNativeError` to check if the error is an instance of `Error`.
* If the error comes from `node:vm` module, then it will not be an instance of `Error` but can be captured by `isNativeError`.
*/
types.isNativeError(error)
? { message: error.message, stack: error.stack }
: { message: String(error) };

View file

@ -0,0 +1,2 @@
export * from './script-execution.js';
export * from './error-handling.js';

View file

@ -0,0 +1,41 @@
import { runInNewContext } from 'node:vm';
/**
* This function is used to execute a named function in a customized code script in a local
* virtual machine with the given payload as input.
*
* @param script Custom code snippet.
* @param functionName The name of the function to be executed.
* @param payload The input payload for the function.
* @returns The result of the function execution.
*/
export const runScriptFunctionInLocalVm = async (
script: string,
functionName: string,
payload: unknown
) => {
const globalContext = Object.freeze({
fetch: async (...args: Parameters<typeof fetch>) => fetch(...args),
});
const customFunction: unknown = runInNewContext(script + `;${functionName};`, globalContext);
if (typeof customFunction !== 'function') {
throw new TypeError(`The script does not have a function named \`${functionName}\``);
}
/**
* We can not use top-level await in `vm`, use the following implementation instead.
*
* Ref:
* 1. https://github.com/nodejs/node/issues/40898
* 2. https://github.com/n-riesco/ijavascript/issues/173#issuecomment-693924098
*/
const result: unknown = await runInNewContext(
'(async () => customFunction(payload))();',
Object.freeze({ customFunction, payload }),
// Limit the execution time to 3 seconds, throws error if the script takes too long to execute.
{ timeout: 3000 }
);
return result;
};

962
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff