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:
commit
778407ea74
20 changed files with 1112 additions and 107 deletions
|
@ -84,6 +84,7 @@
|
||||||
"pg-protocol": "^1.6.0",
|
"pg-protocol": "^1.6.0",
|
||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
|
"raw-body": "^2.5.2",
|
||||||
"redis": "^4.6.14",
|
"redis": "^4.6.14",
|
||||||
"roarr": "^7.11.0",
|
"roarr": "^7.11.0",
|
||||||
"samlify": "2.8.11",
|
"samlify": "2.8.11",
|
||||||
|
|
|
@ -1,15 +1,12 @@
|
||||||
import { ConsoleLog } from '@logto/shared';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
|
import { getConsoleLogFromContext } from '#src/utils/console.js';
|
||||||
|
|
||||||
import { grantListener, grantRevocationListener } from './grant.js';
|
import { grantListener, grantRevocationListener } from './grant.js';
|
||||||
import { interactionEndedListener, interactionStartedListener } from './interaction.js';
|
import { interactionEndedListener, interactionStartedListener } from './interaction.js';
|
||||||
import { recordActiveUsers } from './record-active-users.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/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}
|
* @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.started', interactionStartedListener);
|
||||||
provider.addListener('interaction.ended', interactionEndedListener);
|
provider.addListener('interaction.ended', interactionEndedListener);
|
||||||
provider.addListener('server_error', (_, error) => {
|
provider.addListener('server_error', (ctx, error) => {
|
||||||
consoleLog.error('server_error:', error);
|
getConsoleLogFromContext(ctx).error('server_error:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Record token usage.
|
// Record token usage.
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
132
packages/core/src/middleware/koa-app-secret-transpilation.ts
Normal file
132
packages/core/src/middleware/koa-app-secret-transpilation.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
|
@ -90,17 +90,10 @@ export default function koaOidcErrorHandler<StateT, ContextT>(): Middleware<Stat
|
||||||
throw error;
|
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.
|
// 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
|
// See https://github.com/panva/node-oidc-provider/blob/37d0a6cfb3c618141a44cbb904ce45659438f821/lib/shared/error_handler.js
|
||||||
ctx.status = error.statusCode || 500;
|
ctx.status = error.statusCode || 500;
|
||||||
ctx.body = errorOut(error);
|
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
|
// 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) {
|
if (parsed.success) {
|
||||||
const { data } = parsed;
|
const { data } = parsed;
|
||||||
|
|
||||||
const code = isSessionNotFound(data.error_description)
|
const code = isSessionNotFound(data.error_description)
|
||||||
? 'session.not_found'
|
? 'session.not_found'
|
||||||
: `oidc.${data.error}`;
|
: `oidc.${data.error}`;
|
||||||
|
@ -126,6 +120,12 @@ export default function koaOidcErrorHandler<StateT, ContextT>(): Middleware<Stat
|
||||||
error_uri: uri,
|
error_uri: uri,
|
||||||
...ctx.body,
|
...ctx.body,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!EnvSet.values.isUnitTest && (!EnvSet.values.isProduction || ctx.status >= 500)) {
|
||||||
|
getConsoleLogFromContext(ctx).error(ctx.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
void appInsights.trackException(ctx.body, buildAppInsightsTelemetry(ctx));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
InvalidInputError,
|
InvalidInputError,
|
||||||
CheckIntegrityConstraintViolationError,
|
CheckIntegrityConstraintViolationError,
|
||||||
UniqueIntegrityConstraintViolationError,
|
UniqueIntegrityConstraintViolationError,
|
||||||
|
ForeignKeyIntegrityConstraintViolationError,
|
||||||
} from '@silverhand/slonik';
|
} from '@silverhand/slonik';
|
||||||
import type { Middleware } from 'koa';
|
import type { Middleware } from 'koa';
|
||||||
|
|
||||||
|
@ -42,6 +43,13 @@ export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<St
|
||||||
status: 422,
|
status: 422,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error.constraint === 'application_secrets_pkey') {
|
||||||
|
throw new RequestError({
|
||||||
|
code: 'application.secret_name_exists',
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof CheckIntegrityConstraintViolationError) {
|
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) {
|
if (error instanceof InsertionError) {
|
||||||
throw new RequestError({
|
throw new RequestError({
|
||||||
code: 'entity.create_failed',
|
code: 'entity.create_failed',
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
/* istanbul ignore file */
|
/* istanbul ignore file */
|
||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import querystring from 'node:querystring';
|
||||||
|
|
||||||
import { userClaims } from '@logto/core-kit';
|
import { userClaims } from '@logto/core-kit';
|
||||||
import type { I18nKey } from '@logto/phrases';
|
import type { I18nKey } from '@logto/phrases';
|
||||||
|
@ -17,15 +18,15 @@ import {
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { removeUndefinedKeys, trySafe, tryThat } from '@silverhand/essentials';
|
import { removeUndefinedKeys, trySafe, tryThat } from '@silverhand/essentials';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import { koaBody } from 'koa-body';
|
|
||||||
import Provider, { errors } from 'oidc-provider';
|
import Provider, { errors } from 'oidc-provider';
|
||||||
|
import getRawBody from 'raw-body';
|
||||||
import snakecaseKeys from 'snakecase-keys';
|
import snakecaseKeys from 'snakecase-keys';
|
||||||
|
|
||||||
import { type EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { addOidcEventListeners } from '#src/event-listeners/index.js';
|
import { addOidcEventListeners } from '#src/event-listeners/index.js';
|
||||||
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js';
|
||||||
import { type LogtoConfigLibrary } from '#src/libraries/logto-config.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 koaAuditLog from '#src/middleware/koa-audit-log.js';
|
||||||
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
|
import koaBodyEtag from '#src/middleware/koa-body-etag.js';
|
||||||
import koaResourceParam from '#src/middleware/koa-resource-param.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
|
// Provide audit log context for event listeners
|
||||||
oidc.use(koaAuditLog(queries));
|
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
|
* 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.
|
* 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)
|
* `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.
|
* 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)
|
* 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 as we uses `koaBody()` above.
|
* 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.
|
* However, this is not recommended for other routes rather since it causes a header-body format mismatch.
|
||||||
*/
|
*/
|
||||||
oidc.use(async (ctx, next) => {
|
oidc.use(async (ctx, next) => {
|
||||||
// WARNING: [Registration actions](https://github.com/panva/node-oidc-provider/blob/6a0bcbcd35ed3e6179e81f0ab97a45f5e4e58f48/lib/actions/registration.js#L4) are using
|
const jsonContentType = 'application/json';
|
||||||
// 'application/json' for body parsing. Update relatively when we enable that feature.
|
const formUrlEncodedContentType = 'application/x-www-form-urlencoded';
|
||||||
if (ctx.headers['content-type'] === 'application/json') {
|
|
||||||
ctx.headers['content-type'] = '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();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||||
|
oidc.use(koaAppSecretTranspilation(queries));
|
||||||
|
}
|
||||||
|
|
||||||
oidc.use(koaBodyEtag());
|
oidc.use(koaBodyEtag());
|
||||||
|
|
||||||
return oidc;
|
return oidc;
|
||||||
|
|
55
packages/core/src/queries/application-secrets.ts
Normal file
55
packages/core/src/queries/application-secrets.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
107
packages/core/src/routes/applications/application-secret.ts
Normal file
107
packages/core/src/routes/applications/application-secret.ts
Normal 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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -109,7 +109,6 @@ describe('application route', () => {
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({
|
||||||
...mockApplication,
|
...mockApplication,
|
||||||
id: mockId,
|
id: mockId,
|
||||||
secret: mockId,
|
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
type,
|
type,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { generateStandardId, generateStandardSecret } from '@logto/shared';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
import { boolean, object, string, z } from 'zod';
|
import { boolean, object, string, z } from 'zod';
|
||||||
|
|
||||||
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import koaPagination from '#src/middleware/koa-pagination.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 type { ManagementApiRouter, RouterInitArgs } from '../types.js';
|
||||||
|
|
||||||
|
import { generateInternalSecret } from './application-secret.js';
|
||||||
import { applicationCreateGuard, applicationPatchGuard } from './types.js';
|
import { applicationCreateGuard, applicationPatchGuard } from './types.js';
|
||||||
|
|
||||||
const includesInternalAdminRole = (roles: Readonly<Array<{ role: Role }>>) =>
|
const includesInternalAdminRole = (roles: Readonly<Array<{ role: Role }>>) =>
|
||||||
|
@ -38,7 +40,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
||||||
...[
|
...[
|
||||||
router,
|
router,
|
||||||
{
|
{
|
||||||
queries,
|
queries: { applications, applicationsRoles, roles },
|
||||||
id: tenantId,
|
id: tenantId,
|
||||||
libraries: { quota, protectedApps },
|
libraries: { quota, protectedApps },
|
||||||
},
|
},
|
||||||
|
@ -51,16 +53,14 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
||||||
updateApplicationById,
|
updateApplicationById,
|
||||||
countApplications,
|
countApplications,
|
||||||
findApplications,
|
findApplications,
|
||||||
} = queries.applications;
|
} = applications;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
findApplicationsRolesByApplicationId,
|
findApplicationsRolesByApplicationId,
|
||||||
insertApplicationsRoles,
|
insertApplicationsRoles,
|
||||||
deleteApplicationRole,
|
deleteApplicationRole,
|
||||||
findApplicationsRolesByRoleId,
|
findApplicationsRolesByRoleId,
|
||||||
} = queries.applicationsRoles;
|
} = applicationsRoles;
|
||||||
|
|
||||||
const { findRoleByRoleName } = queries.roles;
|
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/applications',
|
'/applications',
|
||||||
|
@ -191,7 +191,9 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
||||||
|
|
||||||
const application = await insertApplication({
|
const application = await insertApplication({
|
||||||
id: generateStandardId(),
|
id: generateStandardId(),
|
||||||
secret: generateStandardSecret(),
|
secret: EnvSet.values.isDevFeaturesEnabled
|
||||||
|
? generateStandardSecret()
|
||||||
|
: generateInternalSecret(),
|
||||||
oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata),
|
oidcClientMetadata: buildOidcClientMetadata(oidcClientMetadata),
|
||||||
...conditional(
|
...conditional(
|
||||||
rest.type === ApplicationType.Protected &&
|
rest.type === ApplicationType.Protected &&
|
||||||
|
@ -275,7 +277,7 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
|
||||||
if (isAdmin !== undefined) {
|
if (isAdmin !== undefined) {
|
||||||
const [applicationsRoles, internalAdminRole] = await Promise.all([
|
const [applicationsRoles, internalAdminRole] = await Promise.all([
|
||||||
findApplicationsRolesByApplicationId(id),
|
findApplicationsRolesByApplicationId(id),
|
||||||
findRoleByRoleName(InternalRole.Admin),
|
roles.findRoleByRoleName(InternalRole.Admin),
|
||||||
]);
|
]);
|
||||||
const usedToBeAdmin = includesInternalAdminRole(applicationsRoles);
|
const usedToBeAdmin = includesInternalAdminRole(applicationsRoles);
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ import adminUserRoutes from './admin-user/index.js';
|
||||||
import applicationOrganizationRoutes from './applications/application-organization.js';
|
import applicationOrganizationRoutes from './applications/application-organization.js';
|
||||||
import applicationProtectedAppMetadataRoutes from './applications/application-protected-app-metadata.js';
|
import applicationProtectedAppMetadataRoutes from './applications/application-protected-app-metadata.js';
|
||||||
import applicationRoleRoutes from './applications/application-role.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 applicationSignInExperienceRoutes from './applications/application-sign-in-experience.js';
|
||||||
import applicationUserConsentOrganizationRoutes from './applications/application-user-consent-organization.js';
|
import applicationUserConsentOrganizationRoutes from './applications/application-user-consent-organization.js';
|
||||||
import applicationUserConsentScopeRoutes from './applications/application-user-consent-scope.js';
|
import applicationUserConsentScopeRoutes from './applications/application-user-consent-scope.js';
|
||||||
|
@ -65,6 +66,9 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
applicationRoleRoutes(managementRouter, tenant);
|
applicationRoleRoutes(managementRouter, tenant);
|
||||||
applicationProtectedAppMetadataRoutes(managementRouter, tenant);
|
applicationProtectedAppMetadataRoutes(managementRouter, tenant);
|
||||||
applicationOrganizationRoutes(managementRouter, tenant);
|
applicationOrganizationRoutes(managementRouter, tenant);
|
||||||
|
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||||
|
applicationSecretRoutes(managementRouter, tenant);
|
||||||
|
}
|
||||||
|
|
||||||
// Third-party application related routes
|
// Third-party application related routes
|
||||||
applicationUserConsentScopeRoutes(managementRouter, tenant);
|
applicationUserConsentScopeRoutes(managementRouter, tenant);
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import type { CommonQueryMethods } from '@silverhand/slonik';
|
import type { CommonQueryMethods } from '@silverhand/slonik';
|
||||||
|
|
||||||
import { type WellKnownCache } from '#src/caches/well-known.js';
|
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 createApplicationSignInExperienceQueries from '#src/queries/application-sign-in-experience.js';
|
||||||
import { createApplicationQueries } from '#src/queries/application.js';
|
import { createApplicationQueries } from '#src/queries/application.js';
|
||||||
import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js';
|
import { createApplicationsRolesQueries } from '#src/queries/applications-roles.js';
|
||||||
|
@ -30,6 +31,7 @@ import { createVerificationStatusQueries } from '#src/queries/verification-statu
|
||||||
|
|
||||||
export default class Queries {
|
export default class Queries {
|
||||||
applications = createApplicationQueries(this.pool);
|
applications = createApplicationQueries(this.pool);
|
||||||
|
applicationSecrets = new ApplicationSecretQueries(this.pool);
|
||||||
applicationSignInExperiences = createApplicationSignInExperienceQueries(this.pool);
|
applicationSignInExperiences = createApplicationSignInExperienceQueries(this.pool);
|
||||||
connectors = createConnectorQueries(this.pool, this.wellKnownCache);
|
connectors = createConnectorQueries(this.pool, this.wellKnownCache);
|
||||||
customPhrases = createCustomPhraseQueries(this.pool, this.wellKnownCache);
|
customPhrases = createCustomPhraseQueries(this.pool, this.wellKnownCache);
|
||||||
|
|
|
@ -6,6 +6,8 @@ import {
|
||||||
type Role,
|
type Role,
|
||||||
type ProtectedAppMetadata,
|
type ProtectedAppMetadata,
|
||||||
type OrganizationWithRoles,
|
type OrganizationWithRoles,
|
||||||
|
type CreateApplicationSecret,
|
||||||
|
type ApplicationSecret,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
|
|
||||||
|
@ -14,7 +16,12 @@ import { authedAdminApi, oidcApi } from './api.js';
|
||||||
export const createApplication = async (
|
export const createApplication = async (
|
||||||
name: string,
|
name: string,
|
||||||
type: ApplicationType,
|
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
|
authedAdminApi
|
||||||
.post('applications', {
|
.post('applications', {
|
||||||
|
@ -126,3 +133,20 @@ export const getOrganizations = async (applicationId: string, page?: number, pag
|
||||||
})
|
})
|
||||||
.json<OrganizationWithRoles[]>();
|
.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`);
|
||||||
|
|
|
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -56,7 +56,6 @@ describe('application APIs', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const application = await createApplication(applicationName, ApplicationType.Protected, {
|
const application = await createApplication(applicationName, ApplicationType.Protected, {
|
||||||
// @ts-expect-error the create guard has been modified
|
|
||||||
protectedAppMetadata: metadata,
|
protectedAppMetadata: metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -76,12 +75,10 @@ describe('application APIs', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const application = await createApplication(applicationName, ApplicationType.Protected, {
|
const application = await createApplication(applicationName, ApplicationType.Protected, {
|
||||||
// @ts-expect-error the create guard has been modified
|
|
||||||
protectedAppMetadata: metadata,
|
protectedAppMetadata: metadata,
|
||||||
});
|
});
|
||||||
await expectRejects(
|
await expectRejects(
|
||||||
createApplication('test-create-app', ApplicationType.Protected, {
|
createApplication('test-create-app', ApplicationType.Protected, {
|
||||||
// @ts-expect-error the create guard has been modified
|
|
||||||
protectedAppMetadata: metadata,
|
protectedAppMetadata: metadata,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
@ -133,7 +130,6 @@ describe('application APIs', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const application = await createApplication('test-update-app', ApplicationType.Protected, {
|
const application = await createApplication('test-update-app', ApplicationType.Protected, {
|
||||||
// @ts-expect-error the create guard has been modified
|
|
||||||
protectedAppMetadata: metadata,
|
protectedAppMetadata: metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -18,6 +18,8 @@ const application = {
|
||||||
invalid_subdomain: 'Invalid subdomain.',
|
invalid_subdomain: 'Invalid subdomain.',
|
||||||
custom_domain_not_found: 'Custom domain not found.',
|
custom_domain_not_found: 'Custom domain not found.',
|
||||||
should_delete_custom_domains_first: 'Should delete custom domains first.',
|
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);
|
export default Object.freeze(application);
|
||||||
|
|
176
pnpm-lock.yaml
176
pnpm-lock.yaml
|
@ -3337,6 +3337,9 @@ importers:
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.3
|
specifier: ^1.5.3
|
||||||
version: 1.5.3
|
version: 1.5.3
|
||||||
|
raw-body:
|
||||||
|
specifier: ^2.5.2
|
||||||
|
version: 2.5.2
|
||||||
redis:
|
redis:
|
||||||
specifier: ^4.6.14
|
specifier: ^4.6.14
|
||||||
version: 4.6.14
|
version: 4.6.14
|
||||||
|
@ -3430,7 +3433,7 @@ importers:
|
||||||
version: 8.57.0
|
version: 8.57.0
|
||||||
jest:
|
jest:
|
||||||
specifier: ^29.7.0
|
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:
|
jest-matcher-specific-error:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
@ -3734,7 +3737,7 @@ importers:
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
jest:
|
jest:
|
||||||
specifier: ^29.7.0
|
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:
|
jest-environment-jsdom:
|
||||||
specifier: ^29.7.0
|
specifier: ^29.7.0
|
||||||
version: 29.7.0
|
version: 29.7.0
|
||||||
|
@ -3743,7 +3746,7 @@ importers:
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
jest-transformer-svg:
|
jest-transformer-svg:
|
||||||
specifier: ^2.0.0
|
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:
|
js-base64:
|
||||||
specifier: ^3.7.5
|
specifier: ^3.7.5
|
||||||
version: 3.7.5
|
version: 3.7.5
|
||||||
|
@ -3882,7 +3885,7 @@ importers:
|
||||||
version: 10.0.0
|
version: 10.0.0
|
||||||
jest:
|
jest:
|
||||||
specifier: ^29.7.0
|
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:
|
jest-matcher-specific-error:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
|
@ -15230,6 +15233,41 @@ snapshots:
|
||||||
- supports-color
|
- supports-color
|
||||||
- ts-node
|
- 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':
|
'@jest/create-cache-key-function@27.5.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 27.5.1
|
'@jest/types': 27.5.1
|
||||||
|
@ -18720,13 +18758,13 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
lodash.get: 4.4.2
|
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:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
exit: 0.1.2
|
exit: 0.1.2
|
||||||
graceful-fs: 4.2.11
|
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
|
jest-util: 29.7.0
|
||||||
prompts: 2.4.2
|
prompts: 2.4.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
@ -21120,35 +21158,16 @@ snapshots:
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
- supports-color
|
- 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:
|
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/test-result': 29.7.0
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
chalk: 4.1.2
|
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
|
exit: 0.1.2
|
||||||
import-local: 3.1.0
|
import-local: 3.1.0
|
||||||
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
|
|
||||||
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-util: 29.7.0
|
jest-util: 29.7.0
|
||||||
jest-validate: 29.7.0
|
jest-validate: 29.7.0
|
||||||
yargs: 17.7.2
|
yargs: 17.7.2
|
||||||
|
@ -21177,7 +21196,7 @@ snapshots:
|
||||||
- supports-color
|
- supports-color
|
||||||
- ts-node
|
- 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:
|
dependencies:
|
||||||
'@babel/core': 7.24.4
|
'@babel/core': 7.24.4
|
||||||
'@jest/test-sequencer': 29.7.0
|
'@jest/test-sequencer': 29.7.0
|
||||||
|
@ -21203,6 +21222,7 @@ snapshots:
|
||||||
strip-json-comments: 3.1.1
|
strip-json-comments: 3.1.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.10.4
|
'@types/node': 20.10.4
|
||||||
|
ts-node: 10.9.2(@types/node@20.10.4)(typescript@5.5.3)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
- supports-color
|
- supports-color
|
||||||
|
@ -21233,7 +21253,38 @@ snapshots:
|
||||||
strip-json-comments: 3.1.1
|
strip-json-comments: 3.1.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.12.7
|
'@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:
|
transitivePeerDependencies:
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
- supports-color
|
- 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))
|
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
|
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:
|
jest-util@29.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jest/types': 29.6.3
|
'@jest/types': 29.6.3
|
||||||
|
@ -21551,24 +21597,12 @@ snapshots:
|
||||||
merge-stream: 2.0.0
|
merge-stream: 2.0.0
|
||||||
supports-color: 8.1.1
|
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:
|
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
|
'@jest/types': 29.6.3
|
||||||
import-local: 3.1.0
|
import-local: 3.1.0
|
||||||
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))
|
||||||
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)
|
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
@ -25189,6 +25223,27 @@ snapshots:
|
||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
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):
|
ts-node@10.9.2(@swc/core@1.3.52)(@types/node@20.12.7)(typescript@5.5.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@cspotcode/source-map-support': 0.8.1
|
'@cspotcode/source-map-support': 0.8.1
|
||||||
|
@ -25209,6 +25264,25 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@swc/core': 1.3.52(@swc/helpers@0.5.1)
|
'@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:
|
tsconfig-paths@3.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/json5': 0.0.29
|
'@types/json5': 0.0.29
|
||||||
|
|
Loading…
Reference in a new issue