mirror of
https://github.com/logto-io/logto.git
synced 2025-01-20 21:32:31 -05:00
refactor(core): guard status from request errors (#5343)
* refactor(core): guard status from request errors * refactor(core): update routes * refactor: update tests and routes * refactor(core): update protected app response code
This commit is contained in:
parent
a3db1f7177
commit
c7353f68e9
22 changed files with 127 additions and 51 deletions
|
@ -31,14 +31,14 @@ export type ProtectedAppLibrary = ReturnType<typeof createProtectedAppLibrary>;
|
|||
|
||||
const getProviderConfig = async () => {
|
||||
const { protectedAppConfigProviderConfig } = SystemContext.shared;
|
||||
assertThat(protectedAppConfigProviderConfig, 'application.protected_app_not_configured');
|
||||
assertThat(protectedAppConfigProviderConfig, 'application.protected_app_not_configured', 501);
|
||||
|
||||
return protectedAppConfigProviderConfig;
|
||||
};
|
||||
|
||||
const getHostnameProviderConfig = async () => {
|
||||
const { protectedAppHostnameProviderConfig } = SystemContext.shared;
|
||||
assertThat(protectedAppHostnameProviderConfig, 'application.protected_app_not_configured');
|
||||
assertThat(protectedAppHostnameProviderConfig, 'application.protected_app_not_configured', 501);
|
||||
|
||||
return protectedAppHostnameProviderConfig;
|
||||
};
|
||||
|
@ -114,7 +114,8 @@ const addDomainToRemote = async (
|
|||
!(blockedDomains ?? []).some(
|
||||
(domain) => hostname === domain || isSubdomainOf(hostname, domain)
|
||||
),
|
||||
'domain.domain_is_not_allowed'
|
||||
'domain.domain_is_not_allowed',
|
||||
422
|
||||
);
|
||||
|
||||
const [fallbackOrigin, cloudflareData] = await Promise.all([
|
||||
|
@ -205,11 +206,11 @@ export const createProtectedAppLibrary = (queries: Queries) => {
|
|||
}
|
||||
> => {
|
||||
const { protectedAppHostnameProviderConfig } = SystemContext.shared;
|
||||
assertThat(protectedAppHostnameProviderConfig, 'domain.not_configured');
|
||||
assertThat(protectedAppHostnameProviderConfig, 'domain.not_configured', 501);
|
||||
|
||||
const application = await findApplicationById(applicationId);
|
||||
const { protectedAppMetadata } = application;
|
||||
assertThat(protectedAppMetadata, 'application.protected_app_not_configured');
|
||||
assertThat(protectedAppMetadata, 'application.protected_app_not_configured', 501);
|
||||
|
||||
if (!protectedAppMetadata.customDomains || protectedAppMetadata.customDomains.length === 0) {
|
||||
return {
|
||||
|
@ -220,7 +221,7 @@ export const createProtectedAppLibrary = (queries: Queries) => {
|
|||
|
||||
const customDomains: CustomDomain[] = await Promise.all(
|
||||
protectedAppMetadata.customDomains.map(async (domain) => {
|
||||
assertThat(domain.cloudflareData, 'domain.cloudflare_data_missing');
|
||||
assertThat(domain.cloudflareData, 'domain.cloudflare_data_missing', 501);
|
||||
|
||||
const cloudflareData = await getCustomHostname(
|
||||
protectedAppHostnameProviderConfig,
|
||||
|
@ -259,7 +260,7 @@ export const createProtectedAppLibrary = (queries: Queries) => {
|
|||
}
|
||||
);
|
||||
// Not expected to happen, just to make TS happy
|
||||
assertThat(updatedProtectedAppMetadata, 'application.protected_app_not_configured');
|
||||
assertThat(updatedProtectedAppMetadata, 'application.protected_app_not_configured', 501);
|
||||
|
||||
return {
|
||||
...application,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import ServerError from '#src/errors/ServerError/index.js';
|
||||
import { emptyMiddleware, createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
|
@ -134,6 +135,36 @@ describe('koaGuardMiddleware', () => {
|
|||
await expect(koaGuard({ status: [200, 204] })(ctx, next)).rejects.toThrow(ServerError);
|
||||
});
|
||||
|
||||
it('should throw when inner middleware throws invalid status', async () => {
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
params: {},
|
||||
body: {},
|
||||
guard: {},
|
||||
response: {},
|
||||
};
|
||||
|
||||
next.mockRejectedValueOnce(new RequestError({ code: 'request.general', status: 400 }));
|
||||
|
||||
// @ts-expect-error
|
||||
await expect(koaGuard({ status: 200 })(ctx, next)).rejects.toThrow(ServerError);
|
||||
});
|
||||
|
||||
it('should pass when inner middleware throws valid status', async () => {
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
params: {},
|
||||
body: {},
|
||||
guard: {},
|
||||
response: {},
|
||||
};
|
||||
|
||||
next.mockRejectedValueOnce(new RequestError({ code: 'request.general', status: 400 }));
|
||||
|
||||
// @ts-expect-error
|
||||
await expect(koaGuard({ status: [200, 400] })(ctx, next)).rejects.toThrow(RequestError);
|
||||
});
|
||||
|
||||
it('should throw when params type is invalid', async () => {
|
||||
const ctx = {
|
||||
...baseCtx,
|
||||
|
|
|
@ -49,8 +49,11 @@ export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT, FilesT> = {
|
|||
*/
|
||||
response?: ZodType<ResponseT, ZodTypeDef, unknown>;
|
||||
/**
|
||||
* Guard response status code. It produces a `ServerError` (500)
|
||||
* if the response does not satisfy any of the given value(s).
|
||||
* Guard response status code. It produces a `ServerError` (500) if the response does not satisfy
|
||||
* any of the given value(s).
|
||||
*
|
||||
* Note: It will also guard the status code from `RequestError` that is thrown by inner
|
||||
* middleware.
|
||||
*/
|
||||
status?: number | number[];
|
||||
files?: ZodType<FilesT, ZodTypeDef, unknown>;
|
||||
|
@ -150,19 +153,43 @@ export default function koaGuard<
|
|||
GuardResponseT
|
||||
>
|
||||
> = async function (ctx, next) {
|
||||
await (body ?? files
|
||||
? koaBody<StateT, ContextT>({ multipart: Boolean(files) })(ctx, async () => guard(ctx, next))
|
||||
: guard(ctx, next));
|
||||
/**
|
||||
* Assert the status code matches the value(s) in the config. If the config does not
|
||||
* specify a status code, it will not assert anything.
|
||||
*
|
||||
* @param value The status code to assert.
|
||||
* @throws {StatusCodeError} If the status code does not match the value(s) in the config.
|
||||
*/
|
||||
const assertStatusCode = (value: number) => {
|
||||
if (status === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status !== undefined) {
|
||||
assertThat(
|
||||
Array.isArray(status)
|
||||
? status.includes(ctx.response.status)
|
||||
: status === ctx.response.status,
|
||||
new StatusCodeError(status, ctx.response.status)
|
||||
Array.isArray(status) ? status.includes(value) : status === value,
|
||||
new StatusCodeError(status, value)
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
await (body ?? files
|
||||
? koaBody<StateT, ContextT>({ multipart: Boolean(files) })(ctx, async () =>
|
||||
guard(ctx, next)
|
||||
)
|
||||
: guard(ctx, next));
|
||||
} catch (error: unknown) {
|
||||
// Assert the status code from `RequestError` that is thrown by inner middleware.
|
||||
// Ignore guard errors since they will be always 400 and can be automatically documented
|
||||
// in the OpenAPI route.
|
||||
if (error instanceof RequestError && !error.code.startsWith('guard.')) {
|
||||
assertStatusCode(error.status);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
assertStatusCode(ctx.response.status);
|
||||
|
||||
if (response !== undefined) {
|
||||
const result = response.safeParse(ctx.body);
|
||||
|
||||
|
|
|
@ -126,7 +126,7 @@ describe('application protected app metadata routes', () => {
|
|||
.send({
|
||||
domain: mockDomain,
|
||||
});
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.status).toEqual(422);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -45,7 +45,7 @@ export default function applicationProtectedAppMetadataRoutes<T extends AuthedRo
|
|||
customDomainsPathname,
|
||||
koaGuard({
|
||||
params: z.object(params),
|
||||
status: [200, 400, 404],
|
||||
status: [200, 400, 404, 501],
|
||||
response: customDomainsGuard,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
|
@ -65,20 +65,21 @@ export default function applicationProtectedAppMetadataRoutes<T extends AuthedRo
|
|||
koaGuard({
|
||||
params: z.object(params),
|
||||
body: z.object({ domain: z.string() }),
|
||||
status: [201, 404, 422],
|
||||
status: [201, 400, 404, 422, 501],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
const { domain } = ctx.guard.body;
|
||||
|
||||
const { protectedAppMetadata, oidcClientMetadata } = await findApplicationById(id);
|
||||
assertThat(protectedAppMetadata, 'application.protected_app_not_configured');
|
||||
assertThat(protectedAppMetadata, 'application.protected_app_not_configured', 501);
|
||||
|
||||
// Only allow one domain, be careful when changing this, the unique index on the database
|
||||
// is based on this assumption
|
||||
assertThat(
|
||||
!protectedAppMetadata.customDomains || protectedAppMetadata.customDomains.length === 0,
|
||||
'domain.limit_to_one_domain'
|
||||
'domain.limit_to_one_domain',
|
||||
422
|
||||
);
|
||||
|
||||
const customDomain = await addDomainToRemote(domain);
|
||||
|
@ -116,7 +117,7 @@ export default function applicationProtectedAppMetadataRoutes<T extends AuthedRo
|
|||
...params,
|
||||
domain: z.string(),
|
||||
}),
|
||||
status: [204, 404],
|
||||
status: [204, 404, 501],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id, domain } = ctx.guard.params;
|
||||
|
|
|
@ -43,7 +43,7 @@ function applicationSignInExperienceRoutes<T extends AuthedRouter>(
|
|||
}),
|
||||
body: applicationSignInExperienceCreateGuard,
|
||||
response: ApplicationSignInExperiences.guard,
|
||||
status: [200, 201, 404],
|
||||
status: [200, 201, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
|
|
|
@ -354,7 +354,7 @@ describe('application route', () => {
|
|||
});
|
||||
await expect(applicationRequest.delete('/applications/foo')).resolves.toHaveProperty(
|
||||
'status',
|
||||
400
|
||||
422
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -314,7 +314,7 @@ export default function applicationRoutes<T extends AuthedRouter>(
|
|||
koaGuard({
|
||||
params: object({ id: string().min(1) }),
|
||||
response: z.undefined(),
|
||||
status: [204, 404],
|
||||
status: [204, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
|
@ -322,7 +322,8 @@ export default function applicationRoutes<T extends AuthedRouter>(
|
|||
if (type === ApplicationType.Protected && protectedAppMetadata) {
|
||||
assertThat(
|
||||
!protectedAppMetadata.customDomains || protectedAppMetadata.customDomains.length === 0,
|
||||
'application.should_delete_custom_domains_first'
|
||||
'application.should_delete_custom_domains_first',
|
||||
422
|
||||
);
|
||||
await protectedApps.deleteRemoteAppConfigs(protectedAppMetadata.host);
|
||||
}
|
||||
|
|
|
@ -110,7 +110,7 @@ export default function authnRoutes<T extends AnonymousRouter>(
|
|||
koaGuard({
|
||||
body: jsonObjectGuard,
|
||||
params: z.object({ connectorId: z.string().min(1) }),
|
||||
status: 302,
|
||||
status: [302, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
|
|
|
@ -68,7 +68,7 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
*/
|
||||
.merge(Connectors.createGuard.pick({ id: true }).partial()),
|
||||
response: connectorResponseGuard,
|
||||
status: [200, 422],
|
||||
status: [200, 400, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
|
|
|
@ -91,7 +91,7 @@ export default function additionalRoutes<T extends IRouterParamContext>(
|
|||
`${interactionPrefix}/${verificationPath}/verification-code`,
|
||||
koaGuard({
|
||||
body: requestVerificationCodePayloadGuard,
|
||||
status: [204, 400, 404],
|
||||
status: [204, 400, 404, 501],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { interactionDetails, guard, createLog } = ctx;
|
||||
|
@ -263,7 +263,7 @@ export default function additionalRoutes<T extends IRouterParamContext>(
|
|||
router.post(
|
||||
`${interactionPrefix}/${verificationPath}/webauthn-authentication`,
|
||||
koaGuard({
|
||||
status: [200],
|
||||
status: [200, 400],
|
||||
response: webAuthnAuthenticationOptionsGuard,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
|
|
|
@ -324,7 +324,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
|||
router.post(
|
||||
`${interactionPrefix}/submit`,
|
||||
koaGuard({
|
||||
status: [200, 204, 400, 401, 404, 422],
|
||||
status: [200, 204, 400, 401, 403, 404, 422],
|
||||
response: z
|
||||
.object({
|
||||
redirectTo: z.string(),
|
||||
|
|
|
@ -54,6 +54,9 @@
|
|||
"201": {
|
||||
"description": "The organization invitation was created successfully."
|
||||
},
|
||||
"400": {
|
||||
"description": "The organization invitation could not be created. This can happen if the input is invalid or if the expiration date is in the past."
|
||||
},
|
||||
"501": {
|
||||
"description": "No email connector is configured for the tenant."
|
||||
}
|
||||
|
@ -105,6 +108,9 @@
|
|||
"responses": {
|
||||
"200": {
|
||||
"description": "The organization invitation status was updated successfully."
|
||||
},
|
||||
"422": {
|
||||
"description": "The organization invitation status could not be updated. This can happen if the current status is not \"Pending\" or if the status is \"Accepted\" and the accepted user ID is not provided."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
|
|||
messagePayload: sendMessagePayloadGuard.or(z.literal(false)).default(false),
|
||||
}),
|
||||
response: organizationInvitationEntityGuard,
|
||||
status: [201],
|
||||
status: [201, 400, 501],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
|
@ -89,7 +89,7 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
|
|||
]),
|
||||
}),
|
||||
response: organizationInvitationEntityGuard,
|
||||
status: [200],
|
||||
status: [200, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id } = ctx.guard.params;
|
||||
|
|
|
@ -291,7 +291,7 @@ export default function resourceRoutes<T extends AuthedRouter>(
|
|||
params: object({ resourceId: string().min(1), scopeId: string().min(1) }),
|
||||
body: Scopes.createGuard.pick({ name: true, description: true }).partial(),
|
||||
response: Scopes.guard,
|
||||
status: [200, 404, 422],
|
||||
status: [200, 400, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
|
|
|
@ -72,7 +72,7 @@ export default function singleSignOnConnectorsRoutes<T extends AuthedRouter>(
|
|||
koaGuard({
|
||||
body: ssoConnectorCreateGuard,
|
||||
response: SsoConnectors.guard,
|
||||
status: [200, 422],
|
||||
status: [200, 409, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { body } = ctx.guard;
|
||||
|
@ -98,7 +98,7 @@ export default function singleSignOnConnectorsRoutes<T extends AuthedRouter>(
|
|||
// Validate the connector name is unique
|
||||
if (connectorName) {
|
||||
const duplicateConnector = await ssoConnectors.findByConnectorName(connectorName);
|
||||
assertThat(!duplicateConnector, 'single_sign_on.duplicate_connector_name');
|
||||
assertThat(!duplicateConnector, 'single_sign_on.duplicate_connector_name', 409);
|
||||
}
|
||||
|
||||
const connectorId = generateStandardShortId();
|
||||
|
@ -212,7 +212,7 @@ export default function singleSignOnConnectorsRoutes<T extends AuthedRouter>(
|
|||
params: z.object({ id: z.string().min(1) }),
|
||||
body: ssoConnectorPatchGuard,
|
||||
response: ssoConnectorWithProviderConfigGuard,
|
||||
status: [200, 404, 422],
|
||||
status: [200, 404, 409, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
|
@ -238,7 +238,8 @@ export default function singleSignOnConnectorsRoutes<T extends AuthedRouter>(
|
|||
// Should not block the update of the current connector.
|
||||
assertThat(
|
||||
!duplicateConnector || duplicateConnector.id === id,
|
||||
'single_sign_on.duplicate_connector_name'
|
||||
'single_sign_on.duplicate_connector_name',
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ export default function systemRoutes<T extends AuthedRouter>(
|
|||
'/systems/application',
|
||||
koaGuard({
|
||||
response: object({ protectedApps: object({ defaultDomain: string() }) }),
|
||||
status: [200],
|
||||
status: [200, 501],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const defaultDomain = await protectedApps.getDefaultDomain();
|
||||
|
|
|
@ -21,7 +21,7 @@ export default function verificationCodeRoutes<T extends AuthedRouter>(
|
|||
'/verification-codes',
|
||||
koaGuard({
|
||||
body: requestVerificationCodePayloadGuard,
|
||||
status: [204, 400],
|
||||
status: [204, 400, 501],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const code = await createPasscode(undefined, codeType, ctx.guard.body);
|
||||
|
|
|
@ -3,13 +3,17 @@ import { assert } from '@silverhand/essentials';
|
|||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
||||
type AssertThatFunction = <E extends Error>(
|
||||
value: unknown,
|
||||
error: E | LogtoErrorCode
|
||||
) => asserts value;
|
||||
type AssertThatFunction = {
|
||||
<E extends Error>(value: unknown, error: E): asserts value;
|
||||
(value: unknown, error: LogtoErrorCode, status?: number): asserts value;
|
||||
};
|
||||
|
||||
const assertThat: AssertThatFunction = (value, error): asserts value => {
|
||||
assert(value, error instanceof Error ? error : new RequestError({ code: error }));
|
||||
const assertThat: AssertThatFunction = <E extends Error>(
|
||||
value: unknown,
|
||||
error: E | LogtoErrorCode,
|
||||
status?: number
|
||||
): asserts value => {
|
||||
assert(value, error instanceof Error ? error : new RequestError({ code: error, status }));
|
||||
};
|
||||
|
||||
export default assertThat;
|
||||
|
|
|
@ -56,7 +56,7 @@ describe('application sign in experience', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should throw 400 if application is not third-party', async () => {
|
||||
it('should throw if application is not third-party', async () => {
|
||||
await expectRejects(
|
||||
setApplicationSignInExperience(
|
||||
applications.get('firstPartyApp')!.id,
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import assert from 'node:assert';
|
||||
|
||||
import { defaultManagementApi } from '@logto/schemas';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
|
@ -89,7 +91,9 @@ describe('scopes', () => {
|
|||
const response = await updateScope(resource.id, scope.id, {
|
||||
name: 'scope name',
|
||||
}).catch((error: unknown) => error);
|
||||
expect(response instanceof HTTPError && response.response.statusCode === 400).toBe(true);
|
||||
|
||||
assert(response instanceof HTTPError);
|
||||
expect(response.response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('should return 404 when update scope with invalid resource id', async () => {
|
||||
|
|
|
@ -64,7 +64,7 @@ describe('post sso-connectors', () => {
|
|||
providerName: 'OIDC',
|
||||
connectorName: 'test connector name',
|
||||
}),
|
||||
{ code: 'single_sign_on.duplicate_connector_name', statusCode: 400 }
|
||||
{ code: 'single_sign_on.duplicate_connector_name', statusCode: 409 }
|
||||
);
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
|
@ -202,7 +202,7 @@ describe('patch sso-connector by id', () => {
|
|||
patchSsoConnectorById(id2, {
|
||||
connectorName: 'test connector name',
|
||||
}),
|
||||
{ code: 'single_sign_on.duplicate_connector_name', statusCode: 400 }
|
||||
{ code: 'single_sign_on.duplicate_connector_name', statusCode: 409 }
|
||||
);
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
|
|
Loading…
Add table
Reference in a new issue