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:
commit
5c6af3823c
7 changed files with 179 additions and 15 deletions
|
@ -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",
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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
17
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue