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:
parent
23d40ed4de
commit
5872172cbb
22 changed files with 913 additions and 529 deletions
8
.changeset/itchy-eels-remain.md
Normal file
8
.changeset/itchy-eels-remain.md
Normal 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).
|
|
@ -130,7 +130,6 @@ export const useSidebarMenuItems = (): {
|
||||||
{
|
{
|
||||||
Icon: JwtClaims,
|
Icon: JwtClaims,
|
||||||
title: 'customize_jwt',
|
title: 'customize_jwt',
|
||||||
isHidden: !isCloud,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Icon: Hook,
|
Icon: Hook,
|
||||||
|
|
|
@ -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]
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
@ -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}.${
|
||||||
|
|
|
@ -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.
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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`.
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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';
|
||||||
|
|
17
packages/core/src/utils/custom-jwt/local-vm-error.ts
Normal file
17
packages/core/src/utils/custom-jwt/local-vm-error.ts
Normal 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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 };
|
||||||
|
}`;
|
||||||
|
|
|
@ -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>();
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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": [
|
||||||
|
|
10
packages/toolkit/core-kit/src/custom-jwt/error-handling.ts
Normal file
10
packages/toolkit/core-kit/src/custom-jwt/error-handling.ts
Normal 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) };
|
2
packages/toolkit/core-kit/src/custom-jwt/index.ts
Normal file
2
packages/toolkit/core-kit/src/custom-jwt/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './script-execution.js';
|
||||||
|
export * from './error-handling.js';
|
41
packages/toolkit/core-kit/src/custom-jwt/script-execution.ts
Normal file
41
packages/toolkit/core-kit/src/custom-jwt/script-execution.ts
Normal 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
962
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue