Skip to content

Commit

Permalink
Merge pull request #5525 from logto-io/yemq-log-8444-add-jwt-customiz…
Browse files Browse the repository at this point in the history
…er-test-api

feat(core): add POST /configs/jwt-customizer/test API
  • Loading branch information
darcyYe committed Mar 21, 2024
2 parents d913c8d + 88f759c commit 5c6af38
Show file tree
Hide file tree
Showing 7 changed files with 179 additions and 15 deletions.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions packages/core/src/routes/logto-config.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
}
}
}
55 changes: 54 additions & 1 deletion packages/core/src/routes/logto-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
);
}
19 changes: 13 additions & 6 deletions packages/core/src/routes/swagger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +25,7 @@ import {
buildTag,
findSupplementFiles,
normalizePath,
removeCloudOnlyOperations,
validateSupplement,
validateSwaggerDocument,
} from './utils/general.js';
Expand Down Expand Up @@ -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>
)
)
);

Expand Down Expand Up @@ -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
);

Expand Down
47 changes: 44 additions & 3 deletions packages/core/src/routes/swagger/utils/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.`
);
}
}
Expand All @@ -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);
Expand Down Expand Up @@ -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;
};
4 changes: 2 additions & 2 deletions packages/schemas/src/types/jwt-customizer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
17 changes: 15 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 5c6af38

Please sign in to comment.