0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

Merge pull request #5525 from logto-io/yemq-log-8444-add-jwt-customizer-test-api

feat(core): add POST /configs/jwt-customizer/test API
This commit is contained in:
Darcy Ye 2024-03-21 13:04:19 +08:00 committed by GitHub
commit 5c6af3823c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 179 additions and 15 deletions

View file

@ -91,7 +91,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@logto/cloud": "0.2.5-4ef0b45",
"@logto/cloud": "0.2.5-ceb63ed",
"@silverhand/eslint-config": "5.0.0",
"@silverhand/ts-config": "5.0.0",
"@types/debug": "^4.1.7",

View file

@ -188,6 +188,56 @@
}
}
}
},
"/api/configs/jwt-customizer/test": {
"post": {
"tags": ["Cloud only"],
"summary": "Test JWT customizer",
"description": "Test the JWT customizer script with the given sample context and sample token payload.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"tokenType": {
"description": "The token type to test the JWT customizer for."
},
"payload": {
"properties": {
"script": {
"description": "The code snippet of the JWT customizer."
},
"envVars": {
"description": "The environment variables for the JWT customizer."
},
"contextSample": {
"description": "The sample context for the JWT customizer script testing purpose."
},
"tokenSample": {
"description": "The sample token payload for the JWT customizer script testing purpose."
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "The result of the JWT customizer script testing."
},
"400": {
"description": "Zod errors in cloud service (data type does not match expectation, can be either request body or response body)."
},
"403": {
"description": "Cloud connection does not have enough permission to perform the action."
},
"422": {
"description": "Syntax errors in cloud service."
}
}
}
}
}
}

View file

@ -16,9 +16,11 @@ import {
clientCredentialsJwtCustomizerGuard,
LogtoJwtTokenKey,
LogtoJwtTokenPath,
jsonObjectGuard,
} from '@logto/schemas';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard, { parse } from '#src/middleware/koa-guard.js';
import { exportJWK } from '#src/utils/jwks.js';
@ -75,7 +77,7 @@ const getRedactedOidcKeyResponse = async (
);
export default function logtoConfigRoutes<T extends AuthedRouter>(
...[router, { queries, logtoConfigs, invalidateCache }]: RouterInitArgs<T>
...[router, { queries, logtoConfigs, invalidateCache, cloudConnection }]: RouterInitArgs<T>
) {
const {
getAdminConsoleConfig,
@ -287,4 +289,55 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
return next();
}
);
if (!EnvSet.values.isCloud) {
return;
}
router.post(
'/configs/jwt-customizer/test',
koaGuard({
/**
* Early throws when:
* 1. no `script` provided.
* 2. no `tokenSample` provided.
*/
body: z.discriminatedUnion('tokenType', [
z.object({
tokenType: z.literal(LogtoJwtTokenKey.AccessToken),
payload: accessTokenJwtCustomizerGuard.required({
script: true,
tokenSample: true,
}),
}),
z.object({
tokenType: z.literal(LogtoJwtTokenKey.ClientCredentials),
payload: clientCredentialsJwtCustomizerGuard.required({
script: true,
tokenSample: true,
}),
}),
]),
response: jsonObjectGuard,
status: [200, 400, 403, 422],
}),
async (ctx, next) => {
const {
body: {
payload: { tokenSample, contextSample, ...rest },
},
} = ctx.guard;
const client = await cloudConnection.getClient();
ctx.body = await client.post(`/api/services/custom-jwt`, {
body: {
...rest,
token: tokenSample,
context: contextSample,
},
});
return next();
}
);
}

View file

@ -7,13 +7,14 @@ import deepmerge from 'deepmerge';
import { findUp } from 'find-up';
import type { IMiddleware } from 'koa-router';
import type Router from 'koa-router';
import type { OpenAPIV3 } from 'openapi-types';
import { type OpenAPIV3 } from 'openapi-types';
import { EnvSet } from '#src/env-set/index.js';
import { isKoaAuthMiddleware } from '#src/middleware/koa-auth/index.js';
import type { WithGuardConfig } from '#src/middleware/koa-guard.js';
import { isGuardMiddleware } from '#src/middleware/koa-guard.js';
import { isPaginationMiddleware } from '#src/middleware/koa-pagination.js';
import { type DeepPartial } from '#src/test-utils/tenant.js';
import assertThat from '#src/utils/assert-that.js';
import { consoleLog } from '#src/utils/console.js';
import { translationSchemas, zodTypeToSwagger } from '#src/utils/zod.js';
@ -24,6 +25,7 @@ import {
buildTag,
findSupplementFiles,
normalizePath,
removeCloudOnlyOperations,
validateSupplement,
validateSwaggerDocument,
} from './utils/general.js';
@ -193,9 +195,11 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
const supplementPaths = await findSupplementFiles(routesDirectory);
const supplementDocuments = await Promise.all(
supplementPaths.map(
// eslint-disable-next-line no-restricted-syntax
async (path) => JSON.parse(await fs.readFile(path, 'utf8')) as Record<string, unknown>
supplementPaths.map(async (path) =>
removeCloudOnlyOperations(
// eslint-disable-next-line no-restricted-syntax -- trust the type here as we'll validate it later
JSON.parse(await fs.readFile(path, 'utf8')) as DeepPartial<OpenAPIV3.Document>
)
)
);
@ -228,8 +232,11 @@ export default function swaggerRoutes<T extends AnonymousRouter, R extends Route
tags: [...tags].map((tag) => ({ name: tag })),
};
const data = supplementDocuments.reduce(
(document, supplement) => deepmerge(document, supplement, { arrayMerge: mergeParameters }),
const data = supplementDocuments.reduce<OpenAPIV3.Document>(
(document, supplement) =>
deepmerge<OpenAPIV3.Document, DeepPartial<OpenAPIV3.Document>>(document, supplement, {
arrayMerge: mergeParameters,
}),
baseDocument
);

View file

@ -6,10 +6,15 @@ import { isKeyInObject, type Optional } from '@silverhand/essentials';
import { OpenAPIV3 } from 'openapi-types';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import { type DeepPartial } from '#src/test-utils/tenant.js';
import { consoleLog } from '#src/utils/console.js';
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
/** The tag name used in the supplement document to indicate that the operation is cloud only. */
const cloudOnlyTag = 'Cloud only';
/**
* Get the root component name from the given absolute path.
* @example '/organizations/:id' -> 'organizations'
@ -105,9 +110,14 @@ const validateSupplementPaths = (
);
}
if (isKeyInObject(operations[method], 'tags')) {
const operation = operations[method];
if (
isKeyInObject(operation, 'tags') &&
Array.isArray(operation.tags) &&
(operation.tags.length > 1 || operation.tags[0] !== cloudOnlyTag)
) {
throw new TypeError(
`Cannot use \`tags\` in supplement document on path \`${path}\` and operation \`${method}\`. Define tags in the document root instead.`
`Cannot use \`tags\` in supplement document on path \`${path}\` and operation \`${method}\` except for tag \`${cloudOnlyTag}\`. Define tags in the document root instead.`
);
}
}
@ -124,7 +134,7 @@ const validateSupplementPaths = (
*/
export const validateSupplement = (
original: OpenAPIV3.Document,
supplement: Record<string, unknown>
supplement: DeepPartial<OpenAPIV3.Document>
) => {
if (supplement.tags) {
const supplementTags = z.array(z.object({ name: z.string() })).parse(supplement.tags);
@ -201,3 +211,34 @@ export const validateSwaggerDocument = (document: OpenAPIV3.Document) => {
}
}
};
/**
* **CAUTION**: This function mutates the input document.
*
* Remove operations (path + method) that are tagged with `Cloud only` if the application is not
* running in the cloud. This will prevent the swagger validation from failing in the OSS
* environment.
*/
export const removeCloudOnlyOperations = (
document: DeepPartial<OpenAPIV3.Document>
): DeepPartial<OpenAPIV3.Document> => {
if (EnvSet.values.isCloud || !document.paths) {
return document;
}
for (const [path, pathItem] of Object.entries(document.paths)) {
for (const method of Object.values(OpenAPIV3.HttpMethods)) {
if (pathItem?.[method]?.tags?.includes(cloudOnlyTag)) {
// eslint-disable-next-line @silverhand/fp/no-delete, @typescript-eslint/no-dynamic-delete -- intended
delete pathItem[method];
}
}
if (Object.keys(pathItem ?? {}).length === 0) {
// eslint-disable-next-line @silverhand/fp/no-delete, @typescript-eslint/no-dynamic-delete -- intended
delete document.paths[path];
}
}
return document;
};

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
import { Organizations, Roles, UserSsoIdentities } from '../db-entries/index.js';
import { mfaFactorsGuard, jsonObjectGuard } from '../foundations/index.js';
import { Roles, UserSsoIdentities, Organizations } from '../db-entries/index.js';
import { jsonObjectGuard, mfaFactorsGuard } from '../foundations/index.js';
import { jwtCustomizerGuard } from './logto-config/index.js';
import { scopeResponseGuard } from './scope.js';

17
pnpm-lock.yaml generated
View file

@ -3400,8 +3400,8 @@ importers:
version: 3.22.4
devDependencies:
'@logto/cloud':
specifier: 0.2.5-4ef0b45
version: 0.2.5-4ef0b45(zod@3.22.4)
specifier: 0.2.5-ceb63ed
version: 0.2.5-ceb63ed(zod@3.22.4)
'@silverhand/eslint-config':
specifier: 5.0.0
version: 5.0.0(eslint@8.44.0)(prettier@3.0.0)(typescript@5.3.3)
@ -7670,6 +7670,16 @@ packages:
- zod
dev: true
/@logto/cloud@0.2.5-ceb63ed(zod@3.22.4):
resolution: {integrity: sha512-mF4ce41qc4nnmLjlyvTxiy3i1K88xC91yjWcmyrJ66Zujr+w7AaR/Ye0g8TZ1B93qlDt5EJBFuBW/gqYDuwEEQ==}
engines: {node: ^20.9.0}
dependencies:
'@silverhand/essentials': 2.9.0
'@withtyped/server': 0.13.3(zod@3.22.4)
transitivePeerDependencies:
- zod
dev: true
/@logto/js@4.0.0:
resolution: {integrity: sha512-eKLS0HqFjQyf7imKTf2a7FUmMuNeebxFZ2b5A/1qUWSyO+BIxSu0XYI4JZ9qjtWNPvlGmL+jPOkIUuWQ9DZYZw==}
dependencies:
@ -17849,6 +17859,9 @@ packages:
resolution: {integrity: sha512-2GTVocFkwblV/TIg9AmT7TI2fO4xdWkyN8aFUEVtiVNWt96GTR3FgQyHFValfCbcj1k9Xf962Ws2hYXYUr9k1Q==}
engines: {node: '>= 12.0.0'}
hasBin: true
peerDependenciesMeta:
'@parcel/core':
optional: true
dependencies:
'@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.31)
'@parcel/core': 2.9.3