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",
|
||||
"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",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
|
|
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({
|
||||
...mockApplication,
|
||||
id: mockId,
|
||||
secret: mockId,
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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`);
|
||||
|
|
|
@ -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, {
|
||||
// @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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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.',
|
||||
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);
|
||||
|
|
176
pnpm-lock.yaml
176
pnpm-lock.yaml
|
@ -3337,6 +3337,9 @@ importers:
|
|||
qrcode:
|
||||
specifier: ^1.5.3
|
||||
version: 1.5.3
|
||||
raw-body:
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2
|
||||
redis:
|
||||
specifier: ^4.6.14
|
||||
version: 4.6.14
|
||||
|
@ -3430,7 +3433,7 @@ importers:
|
|||
version: 8.57.0
|
||||
jest:
|
||||
specifier: ^29.7.0
|
||||
version: 29.7.0(@types/node@20.10.4)
|
||||
version: 29.7.0(@types/node@20.10.4)(ts-node@10.9.2(@types/node@20.10.4)(typescript@5.5.3))
|
||||
jest-matcher-specific-error:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue