Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add POST /configs/jwt-customizer/test API #5525

Merged
merged 11 commits into from
Mar 21, 2024
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
44 changes: 44 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,50 @@
}
}
}
},
"/api/configs/jwt-customizer/test": {
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
"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": "The request body is invalid."
}
}
}
}
}
}
61 changes: 60 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,61 @@ export default function logtoConfigRoutes<T extends AuthedRouter>(
return next();
}
);

if (!EnvSet.values.isCloud) {
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
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,
/**
* TODO (LOG-8450): Add this path to openapi.json file, since now the swagger.json can not automatically adapt the path validation for OSS version.
*
* Code 400 indicates Zod errors in cloud service (data type does not match expectation, can be either request body or response body).
* Code 422 indicates syntax errors in cloud service.
*/
status: [200, 400, 422],
}),
async (ctx, next) => {
const {
body: {
payload: { tokenSample, contextSample, ...rest },
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
},
} = 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 \`${cloudOnlyTag}\`. Define tags in the document root instead.`
darcyYe marked this conversation as resolved.
Show resolved Hide resolved
);
}
}
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.

Loading