From 3a9a69381de5852bf010611ad19b994903e02927 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 21 Jul 2024 23:50:36 +0800 Subject: [PATCH 1/2] feat(core): multiple app secrets --- packages/core/package.json | 1 + packages/core/src/event-listeners/index.ts | 9 +- .../koa-app-secret-transpilation.test.ts | 154 +++++++++++++ .../koa-app-secret-transpilation.ts | 132 +++++++++++ .../src/middleware/koa-oidc-error-handler.ts | 14 +- .../middleware/koa-slonik-error-handler.ts | 15 ++ packages/core/src/oidc/init.ts | 67 +++--- .../core/src/queries/application-secrets.ts | 55 +++++ .../application-secret.openapi.json | 80 +++++++ .../routes/applications/application-secret.ts | 106 +++++++++ .../routes/applications/application.test.ts | 1 - .../src/routes/applications/application.ts | 16 +- packages/core/src/routes/init.ts | 4 + packages/core/src/tenants/Queries.ts | 2 + .../integration-tests/src/api/application.ts | 26 ++- .../application/application.secrets.test.ts | 144 ++++++++++++ .../tests/api/application/application.test.ts | 4 - .../api/oidc/client-authentication.test.ts | 210 ++++++++++++++++++ .../src/locales/en/errors/application.ts | 2 + pnpm-lock.yaml | 176 ++++++++++----- 20 files changed, 1111 insertions(+), 107 deletions(-) create mode 100644 packages/core/src/middleware/koa-app-secret-transpilation.test.ts create mode 100644 packages/core/src/middleware/koa-app-secret-transpilation.ts create mode 100644 packages/core/src/queries/application-secrets.ts create mode 100644 packages/core/src/routes/applications/application-secret.openapi.json create mode 100644 packages/core/src/routes/applications/application-secret.ts create mode 100644 packages/integration-tests/src/tests/api/application/application.secrets.test.ts create mode 100644 packages/integration-tests/src/tests/api/oidc/client-authentication.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index 9321a779b..92b45f9d6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -84,6 +84,7 @@ "pg-protocol": "^1.6.0", "pluralize": "^8.0.0", "qrcode": "^1.5.3", + "raw-body": "^2.5.2", "redis": "^4.6.14", "roarr": "^7.11.0", "samlify": "2.8.11", diff --git a/packages/core/src/event-listeners/index.ts b/packages/core/src/event-listeners/index.ts index 402d1c929..39df74322 100644 --- a/packages/core/src/event-listeners/index.ts +++ b/packages/core/src/event-listeners/index.ts @@ -1,15 +1,12 @@ -import { ConsoleLog } from '@logto/shared'; -import chalk from 'chalk'; import type Provider from 'oidc-provider'; import type Queries from '#src/tenants/Queries.js'; +import { getConsoleLogFromContext } from '#src/utils/console.js'; import { grantListener, grantRevocationListener } from './grant.js'; import { interactionEndedListener, interactionStartedListener } from './interaction.js'; import { recordActiveUsers } from './record-active-users.js'; -const consoleLog = new ConsoleLog(chalk.magenta('oidc')); - /** * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/README.md#im-getting-a-client-authentication-failed-error-with-no-details Getting auth error with no details?} * @see {@link https://github.com/panva/node-oidc-provider/blob/v7.x/docs/events.md OIDC Provider events} @@ -29,8 +26,8 @@ export const addOidcEventListeners = (provider: Provider, queries: Queries) => { }); provider.addListener('interaction.started', interactionStartedListener); provider.addListener('interaction.ended', interactionEndedListener); - provider.addListener('server_error', (_, error) => { - consoleLog.error('server_error:', error); + provider.addListener('server_error', (ctx, error) => { + getConsoleLogFromContext(ctx).error('server_error:', error); }); // Record token usage. diff --git a/packages/core/src/middleware/koa-app-secret-transpilation.test.ts b/packages/core/src/middleware/koa-app-secret-transpilation.test.ts new file mode 100644 index 000000000..ed2289e56 --- /dev/null +++ b/packages/core/src/middleware/koa-app-secret-transpilation.test.ts @@ -0,0 +1,154 @@ +import { type Context } from 'koa'; +import { type IRouterParamContext } from 'koa-router'; + +import { MockQueries } from '#src/test-utils/tenant.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import koaAppSecretTranspilation from './koa-app-secret-transpilation.js'; + +const { jest } = import.meta; + +describe('koaAppSecretTranspilation middleware', () => { + const next = jest.fn(); + const findByCredentials = jest.fn(); + const queries = new MockQueries({ applicationSecrets: { findByCredentials } }); + + type Values = { + authorization?: string; + body?: Record; + query?: Record; + }; + + const expectNothingChanged = ( + ctx: Context & IRouterParamContext, + { authorization, body, query }: Values = {}, + calledCount = 0 + ) => { + expect(ctx.headers.authorization).toBe(authorization); + expect(ctx.request.body).toStrictEqual(body); + expect(ctx.query).toStrictEqual(query); + expect(findByCredentials).toHaveBeenCalledTimes(calledCount); + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should do nothing if no credentials are found', async () => { + const ctx = createContextWithRouteParameters(); + await koaAppSecretTranspilation(queries)(ctx, next); + expectNothingChanged(ctx); + }); + + it('should do nothing if Authorization header is not valid', async () => { + const ctx = createContextWithRouteParameters(); + + for (const authorization of [ + 'Bearer', + 'Bearer invalid', + 'Basic invalid', + `Basic ${Buffer.from('\u0019:1').toString('base64')}`, + ]) { + ctx.headers.authorization = authorization; + // eslint-disable-next-line no-await-in-loop + await koaAppSecretTranspilation(queries)(ctx, next); + expectNothingChanged(ctx, { authorization }); + } + }); + + it('should do nothing if params are not valid', async () => { + const ctx = createContextWithRouteParameters(); + + ctx.method = 'POST'; + for (const body of [ + {}, + { client_id: 1 }, + { client_secret: 1 }, + { client_id: '1', client_secret: 1 }, + ]) { + ctx.request.body = body; + // eslint-disable-next-line no-await-in-loop + await koaAppSecretTranspilation(queries)(ctx, next); + expectNothingChanged(ctx, { body }); + } + + ctx.method = 'GET'; + for (const query of [ + {}, + { client_id: 1 }, + { client_secret: 1 }, + { client_id: '1', client_secret: 1 }, + ]) { + ctx.request.body = undefined; + // @ts-expect-error + ctx.query = query; + // eslint-disable-next-line no-await-in-loop + await koaAppSecretTranspilation(queries)(ctx, next); + expectNothingChanged(ctx, { query }); + } + }); + + it('should do nothing if client credentials have the wrong combination', async () => { + const ctx = createContextWithRouteParameters(); + + for (const [authorization, body] of [ + ['Basic ' + Buffer.from('1:').toString('base64'), { client_id: '2', client_secret: '1' }], + ['Basic ' + Buffer.from('1:1').toString('base64'), { client_id: '1', client_secret: '1' }], + ] as const) { + ctx.headers.authorization = authorization; + ctx.method = 'POST'; + ctx.request.body = body; + // eslint-disable-next-line no-await-in-loop + await koaAppSecretTranspilation(queries)(ctx, next); + expectNothingChanged(ctx, { authorization, body }); + } + }); + + it('should do nothing if client credentials cannot be found', async () => { + const ctx = createContextWithRouteParameters(); + const authorization = 'Basic ' + Buffer.from('1:1').toString('base64'); + ctx.headers.authorization = authorization; + await koaAppSecretTranspilation(queries)(ctx, next); + expectNothingChanged(ctx, { authorization }, 1); + }); + + it('should throw an error if client credentials are expired', async () => { + const ctx = createContextWithRouteParameters(); + const authorization = 'Basic ' + Buffer.from('1:1').toString('base64'); + ctx.headers.authorization = authorization; + findByCredentials.mockResolvedValueOnce({ expiresAt: new Date(Date.now() - 1000) }); + await expect(koaAppSecretTranspilation(queries)(ctx, next)).rejects.toThrowError( + 'invalid_request' + ); + expectNothingChanged(ctx, { authorization }, 1); + }); + + it('should rewrite the client secret in the header', async () => { + const ctx = createContextWithRouteParameters(); + const authorization = 'Basic ' + Buffer.from('1:1').toString('base64'); + ctx.headers.authorization = authorization; + findByCredentials.mockResolvedValueOnce({ originalSecret: '2' }); + await koaAppSecretTranspilation(queries)(ctx, next); + expect(ctx.headers.authorization).toBe('Basic ' + Buffer.from('1:2').toString('base64')); + }); + + it('should rewrite the client secret in the body', async () => { + const ctx = createContextWithRouteParameters(); + const body = { client_id: '1', client_secret: '1' }; + ctx.method = 'POST'; + ctx.request.body = body; + findByCredentials.mockResolvedValueOnce({ originalSecret: '2' }); + await koaAppSecretTranspilation(queries)(ctx, next); + expect(ctx.request.body.client_secret).toBe('2'); + }); + + it('should rewrite the client secret in the query', async () => { + const ctx = createContextWithRouteParameters(); + const query = { client_id: '1', client_secret: '1' }; + ctx.method = 'GET'; + ctx.query = query; + findByCredentials.mockResolvedValueOnce({ originalSecret: '2' }); + await koaAppSecretTranspilation(queries)(ctx, next); + expect(ctx.query.client_secret).toBe('2'); + }); +}); diff --git a/packages/core/src/middleware/koa-app-secret-transpilation.ts b/packages/core/src/middleware/koa-app-secret-transpilation.ts new file mode 100644 index 000000000..19201383a --- /dev/null +++ b/packages/core/src/middleware/koa-app-secret-transpilation.ts @@ -0,0 +1,132 @@ +import type { Nullable } from '@silverhand/essentials'; +import type { MiddlewareType } from 'koa'; +import { errors } from 'oidc-provider'; + +import type Queries from '#src/tenants/Queries.js'; + +const noVSCHAR = /[^\u0020-\u007E]/; + +function decodeAuthToken(token: string) { + const authToken = decodeURIComponent(token.replaceAll('+', '%20')); + if (noVSCHAR.test(authToken)) { + return; + } + return authToken; +} + +/** @import { getConstantClientMetadata } from '#src/oidc/utils.js'; */ +/** + * Create a middleware function that reads the app secret from the request and check if it matches + * the app secret in the `application_secrets` table. If it matches, the secret will be transpiled + * to the one in the `applications` table in order to be recognized by `oidc-provider`. + * + * If the app secret is not found in the `application_secrets` table, the middleware will keep + * everything as is and let the `oidc-provider` handle it. + * + * @remarks + * We have to transpile the app secret because the `oidc-provider` only accepts one secret per + * client. + * + * @remarks + * The way to read the app secret from the request is duplicated from the original `oidc-provider` + * implementation as the client should not be aware of this process. If we change the way to + * authenticate the client in the future, we should update this middleware accordingly. + * + * @see {@link getConstantClientMetadata} for client authentication method based on application + * type. + * @see {@link https://github.com/panva/node-oidc-provider/blob/v8.4.6/lib/shared/token_auth.js#L74 | oidc-provider} for + * the original implementation. + */ +export default function koaAppSecretTranspilation( + queries: Queries +): MiddlewareType> { + return async (ctx, next) => { + type ClientCredentials = Partial<{ + clientId: string; + clientSecret: string; + }>; + const getCredentialsFromHeader = (): ClientCredentials => { + const parts = ctx.headers.authorization?.split(' '); + + if (parts?.length !== 2 || parts[0]?.toLowerCase() !== 'basic' || !parts[1]) { + return {}; + } + + const [part0, part1] = Buffer.from(parts[1], 'base64').toString().split(':'); + + return { + clientId: part0 && decodeAuthToken(part0), + clientSecret: part1 && decodeAuthToken(part1), + }; + }; + + const getCredentialsFromParams = (): ClientCredentials => { + const params: unknown = ctx.method === 'POST' ? ctx.request.body : ctx.query; + + if (typeof params !== 'object' || params === null) { + return {}; + } + + const clientId = + 'client_id' in params && typeof params.client_id === 'string' + ? params.client_id + : undefined; + const clientSecret = + 'client_secret' in params && typeof params.client_secret === 'string' + ? params.client_secret + : undefined; + return { clientId, clientSecret }; + }; + + const getCredentials = ( + header: ClientCredentials, + params: ClientCredentials + ): ClientCredentials => { + if (header.clientId && params.clientId && header.clientId !== params.clientId) { + return {}; + } + + // Only authorization header is allowed to be used for client authentication if the client ID + // is provided in the header. + if (header.clientId && (!header.clientSecret || params.clientSecret)) { + return {}; + } + + const clientId = header.clientId ?? params.clientId; + const clientSecret = header.clientSecret ?? params.clientSecret; + + return { clientId, clientSecret }; + }; + + const header = getCredentialsFromHeader(); + const params = getCredentialsFromParams(); + const { clientId, clientSecret } = getCredentials(header, params); + if (!clientId || !clientSecret) { + return next(); + } + + const result = await queries.applicationSecrets.findByCredentials(clientId, clientSecret); + + // Fall back to the original client secret logic to provide backward compatibility. + if (!result) { + return next(); + } + + if (result.expiresAt && result.expiresAt < Date.now()) { + throw new errors.InvalidRequest('client secret has expired'); + } + + // All checks passed. Rewrite the client secret to the one in the `applications` table. + if (header.clientSecret) { + ctx.headers.authorization = `Basic ${Buffer.from( + `${clientId}:${result.originalSecret}` + ).toString('base64')}`; + } else if (ctx.method === 'POST') { + ctx.request.body.client_secret = result.originalSecret; + } else { + ctx.query.client_secret = result.originalSecret; + } + + return next(); + }; +} diff --git a/packages/core/src/middleware/koa-oidc-error-handler.ts b/packages/core/src/middleware/koa-oidc-error-handler.ts index 529630264..898c946cd 100644 --- a/packages/core/src/middleware/koa-oidc-error-handler.ts +++ b/packages/core/src/middleware/koa-oidc-error-handler.ts @@ -90,17 +90,10 @@ export default function koaOidcErrorHandler(): Middleware= 500)) { - getConsoleLogFromContext(ctx).error(error); - } } // This is the only way we can check if the error is handled by the oidc-provider, because @@ -115,6 +108,7 @@ export default function koaOidcErrorHandler(): Middleware(): Middleware= 500)) { + getConsoleLogFromContext(ctx).error(ctx.body); + } + + void appInsights.trackException(ctx.body, buildAppInsightsTelemetry(ctx)); } } }; diff --git a/packages/core/src/middleware/koa-slonik-error-handler.ts b/packages/core/src/middleware/koa-slonik-error-handler.ts index 8d8dc5f1b..520e8a1bc 100644 --- a/packages/core/src/middleware/koa-slonik-error-handler.ts +++ b/packages/core/src/middleware/koa-slonik-error-handler.ts @@ -5,6 +5,7 @@ import { InvalidInputError, CheckIntegrityConstraintViolationError, UniqueIntegrityConstraintViolationError, + ForeignKeyIntegrityConstraintViolationError, } from '@silverhand/slonik'; import type { Middleware } from 'koa'; @@ -42,6 +43,13 @@ export default function koaSlonikErrorHandler(): Middleware(): Middleware { - // `koa-body` will throw `SyntaxError` if the request body is not a valid JSON - // By default any untracked server error will throw a `500` internal error. Instead of throwing 500 error - // we should throw a `400` RequestError for all the invalid request body input. - - try { - await koaBody({ urlencoded: false, text: false })(ctx, next); - } catch (error: unknown) { - if (error instanceof SyntaxError) { - throw new RequestError({ code: 'guard.invalid_input', type: 'body' }, error); - } - - throw error; - } - }); /** * Check if the request URL contains comma separated `resource` query parameter. If yes, split the values and * reconstruct the URL with multiple `resource` query parameters. @@ -394,19 +374,46 @@ export default function initOidc( * `oidc-provider` [strictly checks](https://github.com/panva/node-oidc-provider/blob/6a0bcbcd35ed3e6179e81f0ab97a45f5e4e58f48/lib/shared/selective_body.js#L11) * the `content-type` header for further processing. * - * It will [directly use the `ctx.req.body` for parsing](https://github.com/panva/node-oidc-provider/blob/6a0bcbcd35ed3e6179e81f0ab97a45f5e4e58f48/lib/shared/selective_body.js#L39) - * so there's no need to change the raw request body as we uses `koaBody()` above. + * It will [directly use the `ctx.req.body` or `ctx.request.body` for parsing](https://github.com/panva/node-oidc-provider/blob/6a0bcbcd35ed3e6179e81f0ab97a45f5e4e58f48/lib/shared/selective_body.js#L39) + * so there's no need to change the raw request body if we parse the JSON here. * * However, this is not recommended for other routes rather since it causes a header-body format mismatch. */ oidc.use(async (ctx, next) => { - // WARNING: [Registration actions](https://github.com/panva/node-oidc-provider/blob/6a0bcbcd35ed3e6179e81f0ab97a45f5e4e58f48/lib/actions/registration.js#L4) are using - // 'application/json' for body parsing. Update relatively when we enable that feature. - if (ctx.headers['content-type'] === 'application/json') { - ctx.headers['content-type'] = 'application/x-www-form-urlencoded'; + const jsonContentType = 'application/json'; + const formUrlEncodedContentType = 'application/x-www-form-urlencoded'; + + // Replicate the behavior of `oidc-provider` for parsing the request body + if (ctx.req.readable) { + const charset = ctx.headers['content-type'] + ?.split(';') + .map((part) => part.trim().split('=')) + .find(([key]) => key?.trim() === 'charset')?.[1]; + + const body = await getRawBody(ctx.req, { + length: ctx.request.length, + limit: '56kb', + encoding: charset ?? 'utf8', + }); + + // WARNING: [Registration actions](https://github.com/panva/node-oidc-provider/blob/6a0bcbcd35ed3e6179e81f0ab97a45f5e4e58f48/lib/actions/registration.js#L4) are using + // 'application/json' for body parsing. Update relatively when we enable that feature. + if (ctx.is(jsonContentType)) { + ctx.headers['content-type'] = formUrlEncodedContentType; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + ctx.request.body = JSON.parse(body); + } else if (ctx.is(formUrlEncodedContentType)) { + ctx.request.body = querystring.parse(body); + } } + return next(); }); + + if (EnvSet.values.isDevFeaturesEnabled) { + oidc.use(koaAppSecretTranspilation(queries)); + } + oidc.use(koaBodyEtag()); return oidc; diff --git a/packages/core/src/queries/application-secrets.ts b/packages/core/src/queries/application-secrets.ts new file mode 100644 index 000000000..8124ee221 --- /dev/null +++ b/packages/core/src/queries/application-secrets.ts @@ -0,0 +1,55 @@ +import { Applications, type ApplicationSecret, ApplicationSecrets } from '@logto/schemas'; +import { type CommonQueryMethods, sql } from '@silverhand/slonik'; + +import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; +import { convertToIdentifiers } from '#src/utils/sql.js'; + +type ApplicationCredentials = ApplicationSecret & { + /** The original application secret that stored in the `applications` table. */ + originalSecret: string; +}; + +const { table, fields } = convertToIdentifiers(ApplicationSecrets); + +export class ApplicationSecretQueries { + public readonly insert = buildInsertIntoWithPool(this.pool)(ApplicationSecrets, { + returning: true, + }); + + constructor(public readonly pool: CommonQueryMethods) {} + + async findByCredentials(appId: string, appSecret: string) { + const applications = convertToIdentifiers(Applications, true); + const { table, fields } = convertToIdentifiers(ApplicationSecrets, true); + + return this.pool.maybeOne(sql` + select ${sql.join(Object.values(fields), sql`, `)}, ${ + applications.fields.secret + } as "originalSecret" + from ${table} + join ${applications.table} on ${fields.applicationId} = ${applications.fields.id} + where ${fields.applicationId} = ${appId} + and ${fields.value}=${appSecret} + `); + } + + async getSecretsByApplicationId(appId: string) { + return this.pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.applicationId} = ${appId} + `); + } + + async deleteByName(appId: string, name: string) { + const { rowCount } = await this.pool.query(sql` + delete from ${table} + where ${fields.applicationId} = ${appId} + and ${fields.name} = ${name} + `); + if (rowCount < 1) { + throw new DeletionError(ApplicationSecrets.table, name); + } + } +} diff --git a/packages/core/src/routes/applications/application-secret.openapi.json b/packages/core/src/routes/applications/application-secret.openapi.json new file mode 100644 index 000000000..7b57dd71e --- /dev/null +++ b/packages/core/src/routes/applications/application-secret.openapi.json @@ -0,0 +1,80 @@ +{ + "tags": [ + { + "name": "Dev feature" + } + ], + "paths": { + "/api/applications/{id}/legacy-secret": { + "delete": { + "summary": "Delete application legacy secret", + "description": "Delete the legacy secret for the application and replace it with a new internal secret.\n\nNote: This operation does not \"really\" delete the legacy secret because it is still needed for internal validation. We may remove the display of the legacy secret (the `secret` field in the application response) in the future.", + "responses": { + "204": { + "description": "The legacy secret was deleted successfully." + }, + "400": { + "description": "The application does not have a legacy secret." + } + } + } + }, + "/api/applications/{id}/secrets": { + "get": { + "summary": "Get application secrets", + "description": "Get all the secrets for the application.", + "responses": { + "200": { + "description": "A list of secrets." + } + } + }, + "post": { + "summary": "Add application secret", + "description": "Add a new secret for the application.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "description": "The secret name. Must be unique within the application." + }, + "expiresAt": { + "description": "The epoch time in milliseconds when the secret will expire. If not provided, the secret will never expire." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The secret was added successfully." + }, + "422": { + "description": "The secret name is already in use." + } + } + } + }, + "/api/applications/{id}/secrets/{name}": { + "delete": { + "summary": "Delete application secret", + "description": "Delete a secret for the application by name.", + "parameters": [ + { + "name": "name", + "in": "path", + "description": "The name of the secret." + } + ], + "responses": { + "204": { + "description": "The secret was deleted successfully." + } + } + } + } + } +} diff --git a/packages/core/src/routes/applications/application-secret.ts b/packages/core/src/routes/applications/application-secret.ts new file mode 100644 index 000000000..720afd7f5 --- /dev/null +++ b/packages/core/src/routes/applications/application-secret.ts @@ -0,0 +1,106 @@ +import { Applications, ApplicationSecrets, internalPrefix } from '@logto/schemas'; +import { generateStandardSecret } from '@logto/shared'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { type ManagementApiRouter, type RouterInitArgs } from '../types.js'; + +export const generateInternalSecret = () => internalPrefix + generateStandardSecret(); + +export default function applicationSecretRoutes( + ...[router, { queries }]: RouterInitArgs +) { + router.delete( + '/applications/:id/legacy-secret', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + response: Applications.guard, + status: [200, 400, 404], + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + + const application = await queries.applications.findApplicationById(id); + + if (!application.secret || application.secret.startsWith(internalPrefix)) { + throw new RequestError('application.no_legacy_secret_found'); + } + + const secret = generateInternalSecret(); + ctx.body = await queries.applications.updateApplicationById(id, { secret }); + return next(); + } + ); + + router.get( + '/applications/:id/secrets', + koaGuard({ + params: z.object({ id: z.string() }), + response: ApplicationSecrets.guard.array(), + status: [200, 404], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + // Ensure that the application exists. + await queries.applications.findApplicationById(id); + ctx.body = await queries.applicationSecrets.getSecretsByApplicationId(id); + return next(); + } + ); + + router.post( + '/applications/:id/secrets', + koaGuard({ + params: z.object({ id: z.string() }), + body: ApplicationSecrets.createGuard.pick({ name: true, expiresAt: true }), + response: ApplicationSecrets.guard, + status: [201, 400], + }), + async (ctx, next) => { + const { + params: { id: appId }, + body, + } = ctx.guard; + + assertThat( + !body.expiresAt || body.expiresAt > Date.now(), + new RequestError({ + code: 'request.invalid_input', + details: 'The value of `expiresAt` must be in the future.', + }) + ); + + ctx.body = await queries.applicationSecrets.insert({ + ...body, + applicationId: appId, + value: generateStandardSecret(), + }); + ctx.status = 201; + + return next(); + } + ); + + router.delete( + '/applications/:id/secrets/:name', + koaGuard({ + params: z.object({ id: z.string(), name: z.string() }), + status: [204, 404], + }), + async (ctx, next) => { + const { + params: { id: appId, name }, + } = ctx.guard; + + await queries.applicationSecrets.deleteByName(appId, name); + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/applications/application.test.ts b/packages/core/src/routes/applications/application.test.ts index f02353db2..a6c65d40b 100644 --- a/packages/core/src/routes/applications/application.test.ts +++ b/packages/core/src/routes/applications/application.test.ts @@ -109,7 +109,6 @@ describe('application route', () => { expect(response.body).toEqual({ ...mockApplication, id: mockId, - secret: mockId, name, description, type, diff --git a/packages/core/src/routes/applications/application.ts b/packages/core/src/routes/applications/application.ts index 15328665e..1b9ff571f 100644 --- a/packages/core/src/routes/applications/application.ts +++ b/packages/core/src/routes/applications/application.ts @@ -10,6 +10,7 @@ import { generateStandardId, generateStandardSecret } from '@logto/shared'; import { conditional } from '@silverhand/essentials'; import { boolean, object, string, z } from 'zod'; +import { EnvSet } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; @@ -19,6 +20,7 @@ import { parseSearchParamsForSearch } from '#src/utils/search.js'; import type { ManagementApiRouter, RouterInitArgs } from '../types.js'; +import { generateInternalSecret } from './application-secret.js'; import { applicationCreateGuard, applicationPatchGuard } from './types.js'; const includesInternalAdminRole = (roles: Readonly>) => @@ -38,7 +40,7 @@ export default function applicationRoutes( ...[ router, { - queries, + queries: { applications, applicationsRoles, roles }, id: tenantId, libraries: { quota, protectedApps }, }, @@ -51,16 +53,14 @@ export default function applicationRoutes( updateApplicationById, countApplications, findApplications, - } = queries.applications; + } = applications; const { findApplicationsRolesByApplicationId, insertApplicationsRoles, deleteApplicationRole, findApplicationsRolesByRoleId, - } = queries.applicationsRoles; - - const { findRoleByRoleName } = queries.roles; + } = applicationsRoles; router.get( '/applications', @@ -191,7 +191,9 @@ export default function applicationRoutes( const application = await insertApplication({ id: generateStandardId(), - secret: generateStandardSecret(), + secret: EnvSet.values.isDevFeaturesEnabled + ? generateStandardSecret() + : generateInternalSecret(), oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata), ...conditional( rest.type === ApplicationType.Protected && @@ -275,7 +277,7 @@ export default function applicationRoutes( if (isAdmin !== undefined) { const [applicationsRoles, internalAdminRole] = await Promise.all([ findApplicationsRolesByApplicationId(id), - findRoleByRoleName(InternalRole.Admin), + roles.findRoleByRoleName(InternalRole.Admin), ]); const usedToBeAdmin = includesInternalAdminRole(applicationsRoles); diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index f166af765..868e6b30c 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -15,6 +15,7 @@ import adminUserRoutes from './admin-user/index.js'; import applicationOrganizationRoutes from './applications/application-organization.js'; import applicationProtectedAppMetadataRoutes from './applications/application-protected-app-metadata.js'; import applicationRoleRoutes from './applications/application-role.js'; +import applicationSecretRoutes from './applications/application-secret.js'; import applicationSignInExperienceRoutes from './applications/application-sign-in-experience.js'; import applicationUserConsentOrganizationRoutes from './applications/application-user-consent-organization.js'; import applicationUserConsentScopeRoutes from './applications/application-user-consent-scope.js'; @@ -65,6 +66,9 @@ const createRouters = (tenant: TenantContext) => { applicationRoleRoutes(managementRouter, tenant); applicationProtectedAppMetadataRoutes(managementRouter, tenant); applicationOrganizationRoutes(managementRouter, tenant); + if (EnvSet.values.isDevFeaturesEnabled) { + applicationSecretRoutes(managementRouter, tenant); + } // Third-party application related routes applicationUserConsentScopeRoutes(managementRouter, tenant); diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 70048b97c..f7982b014 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -1,6 +1,7 @@ import type { CommonQueryMethods } from '@silverhand/slonik'; import { type WellKnownCache } from '#src/caches/well-known.js'; +import { ApplicationSecretQueries } from '#src/queries/application-secrets.js'; import createApplicationSignInExperienceQueries from '#src/queries/application-sign-in-experience.js'; import { createApplicationQueries } from '#src/queries/application.js'; import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js'; @@ -30,6 +31,7 @@ import { createVerificationStatusQueries } from '#src/queries/verification-statu export default class Queries { applications = createApplicationQueries(this.pool); + applicationSecrets = new ApplicationSecretQueries(this.pool); applicationSignInExperiences = createApplicationSignInExperienceQueries(this.pool); connectors = createConnectorQueries(this.pool, this.wellKnownCache); customPhrases = createCustomPhraseQueries(this.pool, this.wellKnownCache); diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index 4a2a96125..522c71765 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -6,6 +6,8 @@ import { type Role, type ProtectedAppMetadata, type OrganizationWithRoles, + type CreateApplicationSecret, + type ApplicationSecret, } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; @@ -14,7 +16,12 @@ import { authedAdminApi, oidcApi } from './api.js'; export const createApplication = async ( name: string, type: ApplicationType, - rest?: Partial + rest?: Partial< + // Synced from packages/core/src/routes/applications/types.ts + Omit & { + protectedAppMetadata: { origin: string; subDomain: string }; + } + > ) => authedAdminApi .post('applications', { @@ -126,3 +133,20 @@ export const getOrganizations = async (applicationId: string, page?: number, pag }) .json(); }; + +export const createApplicationSecret = async ({ + applicationId, + ...body +}: Omit) => + authedAdminApi + .post(`applications/${applicationId}/secrets`, { json: body }) + .json(); + +export const getApplicationSecrets = async (applicationId: string) => + authedAdminApi.get(`applications/${applicationId}/secrets`).json(); + +export const deleteApplicationSecret = async (applicationId: string, secretName: string) => + authedAdminApi.delete(`applications/${applicationId}/secrets/${secretName}`); + +export const deleteLegacyApplicationSecret = async (applicationId: string) => + authedAdminApi.delete(`applications/${applicationId}/legacy-secret`); diff --git a/packages/integration-tests/src/tests/api/application/application.secrets.test.ts b/packages/integration-tests/src/tests/api/application/application.secrets.test.ts new file mode 100644 index 000000000..0d5e77f6e --- /dev/null +++ b/packages/integration-tests/src/tests/api/application/application.secrets.test.ts @@ -0,0 +1,144 @@ +import { ApplicationType, type Application } from '@logto/schemas'; +import { cond, noop } from '@silverhand/essentials'; +import { HTTPError } from 'ky'; + +import { + createApplication as createApplicationApi, + createApplicationSecret, + deleteApplication, + deleteApplicationSecret, + getApplicationSecrets, +} from '#src/api/application.js'; +import { devFeatureTest, randomString } from '#src/utils.js'; + +devFeatureTest.describe('application secrets', () => { + const applications: Application[] = []; + const createApplication = async (...args: Parameters) => { + const created = await createApplicationApi(...args); + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + applications.push(created); + return created; + }; + + afterAll(async () => { + await Promise.all(applications.map(async ({ id }) => deleteApplication(id).catch(noop))); + }); + + it.each(Object.values(ApplicationType))( + 'should or not to create application secret for %s applications per type', + async (type) => { + const application = await createApplication( + 'application', + type, + cond( + type === ApplicationType.Protected && { + protectedAppMetadata: { + origin: 'https://example.com', + subDomain: randomString(), + }, + } + ) + ); + const secretName = randomString(); + const secretPromise = createApplicationSecret({ + applicationId: application.id, + name: secretName, + }); + + if ( + [ + ApplicationType.MachineToMachine, + ApplicationType.Protected, + ApplicationType.Traditional, + ].includes(type) + ) { + expect(await secretPromise).toEqual( + expect.objectContaining({ applicationId: application.id, name: secretName }) + ); + } else { + const response = await secretPromise.catch((error: unknown) => error); + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 422); + expect(await (response as HTTPError).response.json()).toHaveProperty( + 'code', + 'entity.db_constraint_violated' + ); + } + } + ); + + it('should throw error when creating application secret with existing name', async () => { + const application = await createApplication('application', ApplicationType.MachineToMachine); + const secretName = randomString(); + await createApplicationSecret({ applicationId: application.id, name: secretName }); + + const response = await createApplicationSecret({ + applicationId: application.id, + name: secretName, + }).catch((error: unknown) => error); + + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 422); + expect(await (response as HTTPError).response.json()).toHaveProperty( + 'code', + 'application.secret_name_exists' + ); + }); + + it('should throw error when creating application secret with invalid application id', async () => { + const secretName = randomString(); + const response = await createApplicationSecret({ + applicationId: 'invalid', + name: secretName, + }).catch((error: unknown) => error); + + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 404); + }); + + it('should throw error when creating application secret with empty name', async () => { + const application = await createApplication('application', ApplicationType.MachineToMachine); + const response = await createApplicationSecret({ + applicationId: application.id, + name: '', + }).catch((error: unknown) => error); + + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 400); + }); + + it('should throw error when creating application secret with invalid expiresAt', async () => { + const application = await createApplication('application', ApplicationType.MachineToMachine); + const secretName = randomString(); + const response = await createApplicationSecret({ + applicationId: application.id, + name: secretName, + expiresAt: Date.now() - 1000, + }).catch((error: unknown) => error); + + expect(response).toBeInstanceOf(HTTPError); + expect(response).toHaveProperty('response.status', 400); + }); + + it('should be able to create, get, and delete multiple application secrets', async () => { + const application = await createApplication('application', ApplicationType.MachineToMachine); + const secretName1 = randomString(); + const secretName2 = randomString(); + const secret1 = await createApplicationSecret({ + applicationId: application.id, + name: secretName1, + expiresAt: Date.now() + 1000, + }); + const secret2 = await createApplicationSecret({ + applicationId: application.id, + name: secretName2, + }); + + expect(await getApplicationSecrets(application.id)).toEqual( + expect.arrayContaining([secret1, secret2]) + ); + await deleteApplicationSecret(application.id, secretName1); + await deleteApplicationSecret(application.id, secretName2); + expect(await getApplicationSecrets(application.id)).toEqual([]); + }); +}); diff --git a/packages/integration-tests/src/tests/api/application/application.test.ts b/packages/integration-tests/src/tests/api/application/application.test.ts index 9508355dd..346d9ba10 100644 --- a/packages/integration-tests/src/tests/api/application/application.test.ts +++ b/packages/integration-tests/src/tests/api/application/application.test.ts @@ -56,7 +56,6 @@ describe('application APIs', () => { }; const application = await createApplication(applicationName, ApplicationType.Protected, { - // @ts-expect-error the create guard has been modified protectedAppMetadata: metadata, }); @@ -76,12 +75,10 @@ describe('application APIs', () => { }; const application = await createApplication(applicationName, ApplicationType.Protected, { - // @ts-expect-error the create guard has been modified protectedAppMetadata: metadata, }); await expectRejects( createApplication('test-create-app', ApplicationType.Protected, { - // @ts-expect-error the create guard has been modified protectedAppMetadata: metadata, }), { @@ -133,7 +130,6 @@ describe('application APIs', () => { }; const application = await createApplication('test-update-app', ApplicationType.Protected, { - // @ts-expect-error the create guard has been modified protectedAppMetadata: metadata, }); diff --git a/packages/integration-tests/src/tests/api/oidc/client-authentication.test.ts b/packages/integration-tests/src/tests/api/oidc/client-authentication.test.ts new file mode 100644 index 000000000..4b55473ea --- /dev/null +++ b/packages/integration-tests/src/tests/api/oidc/client-authentication.test.ts @@ -0,0 +1,210 @@ +/** + * @fileoverview Integration tests for handling client authentication with multiple client secrets. + * This test suite will test both the client authentication middleware and the inner workings of the + * `oidc-provider` when handling client authentication. + * + * @see {@link file://./../../../../../core/src/middleware/koa-app-secret-transpilation.ts} for the middleware implementation. + */ + +import assert from 'node:assert'; + +import { ApplicationType } from '@logto/schemas'; +import { noop, removeUndefinedKeys } from '@silverhand/essentials'; +import { HTTPError } from 'ky'; + +import { oidcApi } from '#src/api/api.js'; +import { + createApplication, + createApplicationSecret, + deleteApplication, +} from '#src/api/application.js'; +import { createResource } from '#src/api/resource.js'; +import { devFeatureTest, randomString, waitFor } from '#src/utils.js'; + +type TokenResponse = { + access_token: string; + token_type: string; + expires_in: number; + scope: string; +}; + +const [application, resource] = await Promise.all([ + createApplication('application', ApplicationType.MachineToMachine), + createResource(), +]); + +afterAll(async () => { + await deleteApplication(application.id).catch(noop); +}); + +devFeatureTest.describe('client authentication', () => { + type RequestOptions = { + authorization?: string; + body?: Record; + charset?: BufferEncoding; + }; + + const post = async ({ authorization, body, charset }: RequestOptions) => { + const searchParams = new URLSearchParams({ + grant_type: 'client_credentials', + ...body, + }); + return oidcApi + .post('token', { + headers: removeUndefinedKeys({ + Authorization: authorization, + 'Content-Type': charset + ? `application/x-www-form-urlencoded; charset=${charset}` + : undefined, + }), + body: charset ? Buffer.from(searchParams.toString(), charset) : searchParams, + }) + .json(); + }; + + const expectError = async ( + options: RequestOptions, + status: number, + json?: Record + ) => { + const error = await post(options).catch((error: unknown) => error); + assert(error instanceof HTTPError); + expect(error.response.status).toBe(status); + + if (json) { + expect(await error.response.json()).toMatchObject(json); + } + }; + + it('should respond with error when no client authentication is provided', async () => { + await expectError({}, 400, { + error: 'invalid_request', + error_description: 'no client authentication mechanism provided', + }); + }); + + it('should respond with error when malformed client authentication is provided', async () => { + await expectError({ authorization: 'Basic invalid' }, 400, { + error: 'invalid_request', + error_description: 'invalid authorization header value format', + }); + }); + + it('should respond with error when client ids do not match', async () => { + await expectError( + { + authorization: `Basic ${Buffer.from(`${application.id}:invalid`).toString('base64')}`, + body: { client_id: 'invalid' }, + }, + 400, + { + error: 'invalid_request', + error_description: 'mismatch in body and authorization client ids', + } + ); + }); + + it('should respond with error when the client secret is expired', async () => { + const secret = await createApplicationSecret({ + applicationId: application.id, + name: randomString(), + expiresAt: Date.now() + 50, + }); + + await waitFor(50); + await expectError( + { + authorization: `Basic ${Buffer.from(`${application.id}:${secret.value}`).toString( + 'base64' + )}`, + body: { resource: resource.indicator }, + }, + 400, + { + error: 'invalid_request', + error_description: 'client secret has expired', + } + ); + }); + + it('should pass when client credentials are valid in authorization header (legacy)', async () => { + await expect( + post({ + authorization: `Basic ${Buffer.from(`${application.id}:${application.secret}`).toString( + 'base64' + )}`, + body: { resource: resource.indicator }, + }) + ).resolves.toMatchObject({ + token_type: 'Bearer', + }); + }); + + it('should pass when client credentials are valid in body (legacy)', async () => { + await expect( + post({ + body: { + client_id: application.id, + client_secret: application.secret, + resource: resource.indicator, + }, + }) + ).resolves.toMatchObject({ + token_type: 'Bearer', + }); + }); + + it('should pass when client credentials are valid in authorization header', async () => { + const secret = await createApplicationSecret({ + applicationId: application.id, + name: randomString(), + }); + + await expect( + post({ + authorization: `Basic ${Buffer.from(`${application.id}:${secret.value}`).toString( + 'base64' + )}`, + body: { resource: resource.indicator }, + }) + ).resolves.toMatchObject({ + token_type: 'Bearer', + }); + }); + + it('should pass when client credentials are valid in body', async () => { + const secret = await createApplicationSecret({ + applicationId: application.id, + name: randomString(), + }); + + await expect( + post({ + body: { + client_id: application.id, + client_secret: secret.value, + resource: resource.indicator, + }, + }) + ).resolves.toMatchObject({ + token_type: 'Bearer', + }); + + // Set another charset + for (const charset of ['utf16le', 'latin1'] as const) { + // eslint-disable-next-line no-await-in-loop + await expect( + post({ + body: { + client_id: application.id, + client_secret: secret.value, + resource: resource.indicator, + }, + charset, + }) + ).resolves.toMatchObject({ + token_type: 'Bearer', + }); + } + }); +}); diff --git a/packages/phrases/src/locales/en/errors/application.ts b/packages/phrases/src/locales/en/errors/application.ts index feabd176b..40a67768e 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -18,6 +18,8 @@ const application = { invalid_subdomain: 'Invalid subdomain.', custom_domain_not_found: 'Custom domain not found.', should_delete_custom_domains_first: 'Should delete custom domains first.', + no_legacy_secret_found: 'The application does not have a legacy secret.', + secret_name_exists: 'Secret name already exists.', }; export default Object.freeze(application); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e3f8cd94..843d1550a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3337,6 +3337,9 @@ importers: qrcode: specifier: ^1.5.3 version: 1.5.3 + raw-body: + specifier: ^2.5.2 + version: 2.5.2 redis: specifier: ^4.6.14 version: 4.6.14 @@ -3430,7 +3433,7 @@ importers: version: 8.57.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.10.4) + version: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) jest-matcher-specific-error: specifier: ^1.0.0 version: 1.0.0 @@ -3728,7 +3731,7 @@ importers: version: 3.0.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.12.7) + version: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 @@ -3737,7 +3740,7 @@ importers: version: 2.0.0 jest-transformer-svg: specifier: ^2.0.0 - version: 2.0.0(jest@29.7.0(@types/node@20.12.7))(react@18.3.1) + version: 2.0.0(jest@29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)))(react@18.3.1) js-base64: specifier: ^3.7.5 version: 3.7.5 @@ -3876,7 +3879,7 @@ importers: version: 10.0.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.10.4) + version: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) jest-matcher-specific-error: specifier: ^1.0.0 version: 1.0.0 @@ -15192,6 +15195,41 @@ snapshots: - supports-color - ts-node + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.12.7 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.8.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.5 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + '@jest/create-cache-key-function@27.5.1': dependencies: '@jest/types': 27.5.1 @@ -18685,13 +18723,13 @@ snapshots: dependencies: lodash.get: 4.4.2 - create-jest@29.7.0(@types/node@20.10.4): + create-jest@29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.10.4) + jest-config: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -21085,35 +21123,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.10.4): + jest-cli@29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.10.4) + create-jest: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) exit: 0.1.2 import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.10.4) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest-cli@29.7.0(@types/node@20.12.7): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)) - exit: 0.1.2 - import-local: 3.1.0 - jest-config: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)) + jest-config: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -21142,7 +21161,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.10.4): + jest-config@29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)): dependencies: '@babel/core': 7.24.4 '@jest/test-sequencer': 29.7.0 @@ -21168,6 +21187,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.10.4 + ts-node: 10.9.2(@types/node@20.10.4)(typescript@5.5.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -21198,7 +21218,38 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.12.7 - ts-node: 10.9.2(@swc/core@1.3.52)(@types/node@20.12.7)(typescript@5.5.3) + ts-node: 10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)): + dependencies: + '@babel/core': 7.24.4 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.24.4) + chalk: 4.1.2 + ci-info: 3.8.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.12.7 + ts-node: 10.9.2(@types/node@20.10.4)(typescript@5.5.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -21459,11 +21510,6 @@ snapshots: jest: 29.7.0(@types/node@20.12.7)(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)) react: 18.3.1 - jest-transformer-svg@2.0.0(jest@29.7.0(@types/node@20.12.7))(react@18.3.1): - dependencies: - jest: 29.7.0(@types/node@20.12.7) - react: 18.3.1 - jest-util@29.5.0: dependencies: '@jest/types': 29.6.3 @@ -21516,24 +21562,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.10.4): + jest@29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) '@jest/types': 29.6.3 import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@20.10.4) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - - jest@29.7.0(@types/node@20.12.7): - dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3)) - '@jest/types': 29.6.3 - import-local: 3.1.0 - jest-cli: 29.7.0(@types/node@20.12.7) + jest-cli: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -25147,6 +25181,27 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-node@10.9.2(@swc/core@1.3.52(@swc/helpers@0.5.1))(@types/node@20.12.7)(typescript@5.5.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.12.7 + acorn: 8.10.0 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.5.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.3.52(@swc/helpers@0.5.1) + optional: true + ts-node@10.9.2(@swc/core@1.3.52)(@types/node@20.12.7)(typescript@5.5.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -25167,6 +25222,25 @@ snapshots: optionalDependencies: '@swc/core': 1.3.52(@swc/helpers@0.5.1) + ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.10.4 + acorn: 8.11.3 + acorn-walk: 8.3.2 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.5.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optional: true + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 From 94296acc7a18ce5a6859a23132fd65b0de068156 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Wed, 24 Jul 2024 15:55:37 +0800 Subject: [PATCH 2/2] chore: add comment --- packages/core/src/routes/applications/application-secret.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/routes/applications/application-secret.ts b/packages/core/src/routes/applications/application-secret.ts index 720afd7f5..1728e6cbc 100644 --- a/packages/core/src/routes/applications/application-secret.ts +++ b/packages/core/src/routes/applications/application-secret.ts @@ -13,6 +13,7 @@ export const generateInternalSecret = () => internalPrefix + generateStandardSec export default function applicationSecretRoutes( ...[router, { queries }]: RouterInitArgs ) { + // See OpenAPI description for the rationale of this endpoint. router.delete( '/applications/:id/legacy-secret', koaGuard({