0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

Merge pull request #6292 from logto-io/gao-core-app-secrets

feat(core): multiple app secrets
This commit is contained in:
Gao Sun 2024-07-24 16:11:08 +08:00 committed by GitHub
commit 778407ea74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1112 additions and 107 deletions

View file

@ -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",

View file

@ -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.

View file

@ -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<string, unknown>;
query?: Record<string, unknown>;
};
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');
});
});

View file

@ -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<StateT, ContextT, ResponseBodyT>(
queries: Queries
): MiddlewareType<StateT, ContextT, Nullable<ResponseBodyT>> {
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();
};
}

View file

@ -90,17 +90,10 @@ export default function koaOidcErrorHandler<StateT, ContextT>(): Middleware<Stat
throw error;
}
// Report oidc exceptions to ApplicationInsights
void appInsights.trackException(error, buildAppInsightsTelemetry(ctx));
// Mimic oidc-provider's error handling, thus we can use the unified logic below.
// See https://github.com/panva/node-oidc-provider/blob/37d0a6cfb3c618141a44cbb904ce45659438f821/lib/shared/error_handler.js
ctx.status = error.statusCode || 500;
ctx.body = errorOut(error);
if (!EnvSet.values.isUnitTest && (!EnvSet.values.isProduction || ctx.status >= 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<StateT, ContextT>(): Middleware<Stat
if (parsed.success) {
const { data } = parsed;
const code = isSessionNotFound(data.error_description)
? 'session.not_found'
: `oidc.${data.error}`;
@ -126,6 +120,12 @@ export default function koaOidcErrorHandler<StateT, ContextT>(): Middleware<Stat
error_uri: uri,
...ctx.body,
};
if (!EnvSet.values.isUnitTest && (!EnvSet.values.isProduction || ctx.status >= 500)) {
getConsoleLogFromContext(ctx).error(ctx.body);
}
void appInsights.trackException(ctx.body, buildAppInsightsTelemetry(ctx));
}
}
};

View file

@ -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<StateT, ContextT>(): Middleware<St
status: 422,
});
}
if (error.constraint === 'application_secrets_pkey') {
throw new RequestError({
code: 'application.secret_name_exists',
status: 422,
});
}
}
if (error instanceof CheckIntegrityConstraintViolationError) {
@ -51,6 +59,13 @@ export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<St
});
}
if (error instanceof ForeignKeyIntegrityConstraintViolationError) {
throw new RequestError({
code: 'entity.relation_foreign_key_not_found',
status: 404,
});
}
if (error instanceof InsertionError) {
throw new RequestError({
code: 'entity.create_failed',

View file

@ -2,6 +2,7 @@
/* istanbul ignore file */
import assert from 'node:assert';
import { readFileSync } from 'node:fs';
import querystring from 'node:querystring';
import { userClaims } from '@logto/core-kit';
import type { I18nKey } from '@logto/phrases';
@ -17,15 +18,15 @@ import {
} from '@logto/schemas';
import { removeUndefinedKeys, trySafe, tryThat } from '@silverhand/essentials';
import i18next from 'i18next';
import { koaBody } from 'koa-body';
import Provider, { errors } from 'oidc-provider';
import getRawBody from 'raw-body';
import snakecaseKeys from 'snakecase-keys';
import { type EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { EnvSet } from '#src/env-set/index.js';
import { addOidcEventListeners } from '#src/event-listeners/index.js';
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.js';
import koaAppSecretTranspilation from '#src/middleware/koa-app-secret-transpilation.js';
import koaAuditLog from '#src/middleware/koa-audit-log.js';
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
import koaResourceParam from '#src/middleware/koa-resource-param.js';
@ -363,27 +364,6 @@ export default function initOidc(
// Provide audit log context for event listeners
oidc.use(koaAuditLog(queries));
/**
* Create a middleware function that transpile requests with content type `application/json`
* since `oidc-provider` only accepts `application/x-www-form-urlencoded` for most of routes.
*
* Other parsers are explicitly disabled to keep it neat.
*/
oidc.use(async (ctx, next) => {
// `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;

View file

@ -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<ApplicationCredentials>(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<ApplicationSecret>(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);
}
}
}

View file

@ -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."
}
}
}
}
}
}

View file

@ -0,0 +1,107 @@
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<T extends ManagementApiRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
// See OpenAPI description for the rationale of this endpoint.
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();
}
);
}

View file

@ -109,7 +109,6 @@ describe('application route', () => {
expect(response.body).toEqual({
...mockApplication,
id: mockId,
secret: mockId,
name,
description,
type,

View file

@ -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<Array<{ role: Role }>>) =>
@ -38,7 +40,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
...[
router,
{
queries,
queries: { applications, applicationsRoles, roles },
id: tenantId,
libraries: { quota, protectedApps },
},
@ -51,16 +53,14 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
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<T extends ManagementApiRouter>(
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<T extends ManagementApiRouter>(
if (isAdmin !== undefined) {
const [applicationsRoles, internalAdminRole] = await Promise.all([
findApplicationsRolesByApplicationId(id),
findRoleByRoleName(InternalRole.Admin),
roles.findRoleByRoleName(InternalRole.Admin),
]);
const usedToBeAdmin = includesInternalAdminRole(applicationsRoles);

View file

@ -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);

View file

@ -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);

View file

@ -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<CreateApplication>
rest?: Partial<
// Synced from packages/core/src/routes/applications/types.ts
Omit<CreateApplication, 'protectedAppMetadata'> & {
protectedAppMetadata: { origin: string; subDomain: string };
}
>
) =>
authedAdminApi
.post('applications', {
@ -126,3 +133,20 @@ export const getOrganizations = async (applicationId: string, page?: number, pag
})
.json<OrganizationWithRoles[]>();
};
export const createApplicationSecret = async ({
applicationId,
...body
}: Omit<CreateApplicationSecret, 'value'>) =>
authedAdminApi
.post(`applications/${applicationId}/secrets`, { json: body })
.json<ApplicationSecret>();
export const getApplicationSecrets = async (applicationId: string) =>
authedAdminApi.get(`applications/${applicationId}/secrets`).json<ApplicationSecret[]>();
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`);

View file

@ -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<typeof createApplicationApi>) => {
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([]);
});
});

View file

@ -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,
});

View file

@ -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<string, string>;
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<TokenResponse>();
};
const expectError = async (
options: RequestOptions,
status: number,
json?: Record<string, unknown>
) => {
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',
});
}
});
});

View file

@ -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);

View file

@ -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
@ -3734,7 +3737,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
@ -3743,7 +3746,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
@ -3882,7 +3885,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
@ -15230,6 +15233,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
@ -18720,13 +18758,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:
@ -21120,35 +21158,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
@ -21177,7 +21196,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
@ -21203,6 +21222,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
@ -21233,7 +21253,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
@ -21494,11 +21545,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
@ -21551,24 +21597,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
@ -25189,6 +25223,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
@ -25209,6 +25264,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