mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
refactor(core): refactor error handler logic (#220)
* refactor(core): refactor error handler logic add oidc and slonik custom error handler * fix(core): fix typo fix type * refactor: align core errors align some old core error definitions
This commit is contained in:
parent
78cc86ec77
commit
cdcadb968f
17 changed files with 165 additions and 100 deletions
|
@ -7,6 +7,8 @@ import koaLogger from 'koa-logger';
|
|||
import { port } from '@/env/consts';
|
||||
import koaErrorHandler from '@/middleware/koa-error-handler';
|
||||
import koaI18next from '@/middleware/koa-i18next';
|
||||
import koaOIDCErrorHandler from '@/middleware/koa-oidc-error-handler';
|
||||
import koaSlonikErrorHandler from '@/middleware/koa-slonik-error-handler';
|
||||
import koaUIProxy from '@/middleware/koa-ui-proxy';
|
||||
import koaUserLog from '@/middleware/koa-user-log';
|
||||
import initOidc from '@/oidc/init';
|
||||
|
@ -14,6 +16,9 @@ import initRouter from '@/routes/init';
|
|||
|
||||
export default async function initApp(app: Koa): Promise<void> {
|
||||
app.use(koaErrorHandler());
|
||||
app.use(koaOIDCErrorHandler());
|
||||
app.use(koaSlonikErrorHandler());
|
||||
|
||||
// TODO move to specific router (LOG-454)
|
||||
app.use(koaUserLog());
|
||||
app.use(koaLogger());
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
import { LogtoErrorCode, LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import { RequestErrorBody } from '@logto/schemas';
|
||||
import decamelize from 'decamelize';
|
||||
import i18next from 'i18next';
|
||||
import pick from 'lodash.pick';
|
||||
import { errors } from 'oidc-provider';
|
||||
|
||||
export default class OIDCRequestError extends Error {
|
||||
code: LogtoErrorCode;
|
||||
status: number;
|
||||
expose: boolean;
|
||||
data: unknown;
|
||||
|
||||
constructor(error: errors.OIDCProviderError) {
|
||||
const {
|
||||
status = 400,
|
||||
message,
|
||||
error_description,
|
||||
error_detail,
|
||||
name,
|
||||
expose = true,
|
||||
constructor,
|
||||
...interpolation
|
||||
} = error;
|
||||
|
||||
super(message);
|
||||
|
||||
switch (constructor) {
|
||||
case errors.InvalidScope:
|
||||
case errors.InvalidTarget:
|
||||
case errors.InvalidToken:
|
||||
case errors.InvalidClientMetadata:
|
||||
case errors.InvalidGrant:
|
||||
this.code = `oidc.${decamelize(name)}` as LogtoErrorCode;
|
||||
this.message = i18next.t<string, LogtoErrorI18nKey>(`errors:${this.code}`, interpolation);
|
||||
break;
|
||||
case errors.SessionNotFound:
|
||||
this.code = 'session.not_found';
|
||||
this.message = i18next.t<string, LogtoErrorI18nKey>(`errors:${this.code}`, interpolation);
|
||||
break;
|
||||
case errors.InsufficientScope:
|
||||
this.code = 'oidc.insufficient_scope';
|
||||
this.message = i18next.t<string, LogtoErrorI18nKey>(`errors:${this.code}`, {
|
||||
scopes: error_detail,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
this.code = 'oidc.provider_error';
|
||||
this.message = i18next.t<string, LogtoErrorI18nKey>(`errors:${this.code}`, {
|
||||
message: this.message,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
this.status = status;
|
||||
this.expose = expose;
|
||||
|
||||
// Original OIDCProvider Error description and details are provided in the data field
|
||||
this.data = { error_description, error_detail };
|
||||
}
|
||||
|
||||
get body(): RequestErrorBody {
|
||||
return pick(this, 'message', 'code', 'data');
|
||||
}
|
||||
}
|
|
@ -13,13 +13,14 @@ export default class RequestError extends Error {
|
|||
const {
|
||||
code,
|
||||
status = 400,
|
||||
expose = true,
|
||||
...interpolation
|
||||
} = typeof input === 'string' ? { code: input } : input;
|
||||
const message = i18next.t<string, LogtoErrorI18nKey>(`errors:${code}`, interpolation);
|
||||
|
||||
super(message);
|
||||
|
||||
this.expose = true;
|
||||
this.expose = expose;
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { SlonikError } from 'slonik';
|
||||
|
||||
export class DeletionError extends SlonikError {
|
||||
public constructor() {
|
||||
table?: string;
|
||||
id?: string;
|
||||
|
||||
public constructor(table?: string, id?: string) {
|
||||
super('Resource not found.');
|
||||
|
||||
this.table = table;
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) =
|
|||
assertThat(
|
||||
authorization.startsWith(bearerTokenIdentifier),
|
||||
new RequestError(
|
||||
{ code: 'auth.authorization_type_not_supported', status: 401 },
|
||||
{ code: 'auth.authorization_token_type_not_supported', status: 401 },
|
||||
{ supportedTypes: [bearerTokenIdentifier] }
|
||||
)
|
||||
);
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
import { RequestErrorBody } from '@logto/schemas';
|
||||
import { Middleware } from 'koa';
|
||||
import { errors } from 'oidc-provider';
|
||||
import { NotFoundError } from 'slonik';
|
||||
|
||||
import OIDCRequestError from '@/errors/OIDCRequestError';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
|
||||
export default function koaErrorHandler<StateT, ContextT>(): Middleware<
|
||||
|
@ -22,25 +19,6 @@ export default function koaErrorHandler<StateT, ContextT>(): Middleware<
|
|||
return;
|
||||
}
|
||||
|
||||
if (error instanceof errors.OIDCProviderError) {
|
||||
const oidcError = new OIDCRequestError(error);
|
||||
ctx.status = oidcError.status;
|
||||
ctx.body = oidcError.body;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Slonik Error
|
||||
if (error instanceof NotFoundError) {
|
||||
const error = new RequestError({ code: 'entity.not_found', status: 404 });
|
||||
ctx.status = error.status;
|
||||
ctx.body = error.body;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Zod Error
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
88
packages/core/src/middleware/koa-oidc-error-handler.ts
Normal file
88
packages/core/src/middleware/koa-oidc-error-handler.ts
Normal file
|
@ -0,0 +1,88 @@
|
|||
import { LogtoErrorCode } from '@logto/phrases';
|
||||
import decamelize from 'decamelize';
|
||||
import { Middleware } from 'koa';
|
||||
import { errors } from 'oidc-provider';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
|
||||
/**
|
||||
* OIDC Provider Error Definiation: https://github.com/panva/node-oidc-provider/blob/main/lib/helpers/errors.js
|
||||
*/
|
||||
|
||||
export default function koaOIDCErrorHandler<StateT, ContextT>(): Middleware<StateT, ContextT> {
|
||||
return async (ctx, next) => {
|
||||
try {
|
||||
await next();
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof errors.OIDCProviderError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const {
|
||||
status = 400,
|
||||
message,
|
||||
error_description,
|
||||
error_detail,
|
||||
name,
|
||||
expose,
|
||||
constructor,
|
||||
...interpolation
|
||||
} = error;
|
||||
|
||||
// Original OIDCProvider Error description and details are provided in the data field
|
||||
const data = { error_description, error_detail };
|
||||
|
||||
switch (constructor) {
|
||||
case errors.InvalidScope:
|
||||
case errors.InvalidTarget:
|
||||
case errors.InvalidToken:
|
||||
case errors.InvalidClientMetadata:
|
||||
case errors.InvalidRedirectUri:
|
||||
case errors.AccessDenied:
|
||||
case errors.UnsupportedGrantType:
|
||||
case errors.UnsupportedResponseMode:
|
||||
case errors.UnsupportedResponseType:
|
||||
case errors.InvalidGrant:
|
||||
throw new RequestError(
|
||||
{
|
||||
code: `oidc.${decamelize(name)}` as LogtoErrorCode,
|
||||
status,
|
||||
expose,
|
||||
...interpolation,
|
||||
},
|
||||
data
|
||||
);
|
||||
case errors.SessionNotFound:
|
||||
throw new RequestError(
|
||||
{
|
||||
code: 'session.not_found',
|
||||
status,
|
||||
expose,
|
||||
...interpolation,
|
||||
},
|
||||
data
|
||||
);
|
||||
case errors.InsufficientScope:
|
||||
throw new RequestError(
|
||||
{
|
||||
code: 'oidc.insufficient_scope',
|
||||
status,
|
||||
expose,
|
||||
scopes: error_detail,
|
||||
},
|
||||
data
|
||||
);
|
||||
default:
|
||||
throw new RequestError(
|
||||
{
|
||||
code: 'oidc.provider_error',
|
||||
status,
|
||||
expose,
|
||||
message,
|
||||
},
|
||||
data
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
50
packages/core/src/middleware/koa-slonik-error-handler.ts
Normal file
50
packages/core/src/middleware/koa-slonik-error-handler.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Slonik Error Types:
|
||||
*
|
||||
* BackendTerminatedError,
|
||||
* CheckIntegrityConstraintViolationError,
|
||||
* ConnectionError,
|
||||
* DataIntegrityError,
|
||||
* ForeignKeyIntegrityConstraintViolationError,
|
||||
* IntegrityConstraintViolationError,
|
||||
* InvalidConfigurationError,
|
||||
* InvalidInputError,
|
||||
* NotFoundError,
|
||||
* NotNullIntegrityConstraintViolationError,
|
||||
* StatementCancelledError,
|
||||
* StatementTimeoutError,
|
||||
* UnexpectedStateError,
|
||||
* UniqueIntegrityConstraintViolationError,
|
||||
* TupleMovedToAnotherPartitionError
|
||||
*
|
||||
* (reference)[https://github.com/gajus/slonik#error-handling]
|
||||
*/
|
||||
|
||||
import { Middleware } from 'koa';
|
||||
import { SlonikError, NotFoundError } from 'slonik';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { DeletionError } from '@/errors/SlonikError';
|
||||
|
||||
export default function koaSlonikErrorHandler<StateT, ContextT>(): Middleware<StateT, ContextT> {
|
||||
return async (ctx, next) => {
|
||||
try {
|
||||
await next();
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof SlonikError)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
switch (error.constructor) {
|
||||
case DeletionError:
|
||||
case NotFoundError:
|
||||
throw new RequestError({
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
});
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
|
@ -50,6 +50,6 @@ export const deleteApplicationById = async (id: string) => {
|
|||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError();
|
||||
throw new DeletionError(Applications.table, id);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -36,7 +36,7 @@ export const deletePasscodeById = async (id: string) => {
|
|||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError();
|
||||
throw new DeletionError(Passcodes.table, id);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -47,6 +47,7 @@ export const deletePasscodesByIds = async (ids: string[]) => {
|
|||
`);
|
||||
|
||||
if (rowCount !== ids.length) {
|
||||
throw new DeletionError();
|
||||
// TODO: need to track the failed ids
|
||||
throw new DeletionError(Passcodes.table, `${ids.join(',')}`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -49,6 +49,6 @@ export const deleteResourceById = async (id: string) => {
|
|||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError();
|
||||
throw new DeletionError(Resources.table, id);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -30,6 +30,6 @@ export const deleteScopeById = async (id: string) => {
|
|||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError();
|
||||
throw new DeletionError(ResourceScopes.table, id);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -103,6 +103,6 @@ export const deleteUserById = async (id: string) => {
|
|||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError();
|
||||
throw new DeletionError(Users.table, id);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { LogtoErrorCode } from '@logto/phrases';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { Provider } from 'oidc-provider';
|
||||
import { Provider, errors } from 'oidc-provider';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
import RequestError from '@/errors/RequestError';
|
||||
|
@ -80,7 +80,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
return next();
|
||||
}
|
||||
|
||||
throw new Error(`Prompt not supported: ${name}`);
|
||||
throw new errors.InvalidRequest(`Prompt not supported: ${name}`);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ const translation = {
|
|||
const errors = {
|
||||
auth: {
|
||||
authorization_header_missing: 'Authorization header is missing.',
|
||||
authorization_type_not_supported: 'Authorization type is not supported.',
|
||||
authorization_token_type_not_supported: 'Authorization type is not supported.',
|
||||
unauthorized: 'Unauthorized. Please check credentils and its scope.',
|
||||
jwt_sub_missing: 'Missing `sub` in JWT.',
|
||||
},
|
||||
|
|
|
@ -19,7 +19,7 @@ const translation = {
|
|||
const errors = {
|
||||
auth: {
|
||||
authorization_header_missing: 'Authorization 请求 header 遗漏。',
|
||||
authorization_type_not_supported: '不支持的 authorization 类型。',
|
||||
authorization_token_type_not_supported: '不支持的 authorization 类型。',
|
||||
unauthorized: '未授权。请检查相关 credentials 和 scope。',
|
||||
jwt_sub_missing: 'JWT 中找不到 `sub`。',
|
||||
},
|
||||
|
|
|
@ -3,6 +3,7 @@ import { LogtoErrorCode } from '@logto/phrases';
|
|||
export type RequestErrorMetadata = Record<string, unknown> & {
|
||||
code: LogtoErrorCode;
|
||||
status?: number;
|
||||
expose?: boolean;
|
||||
};
|
||||
|
||||
export type RequestErrorBody = { message: string; data: unknown; code: LogtoErrorCode };
|
||||
|
|
Loading…
Add table
Reference in a new issue