0
Fork 0
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:
simeng-li 2022-02-14 11:50:47 +08:00 committed by GitHub
parent 78cc86ec77
commit cdcadb968f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 165 additions and 100 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
);
}
}
};
}

View 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;
}
}
};
}

View file

@ -50,6 +50,6 @@ export const deleteApplicationById = async (id: string) => {
`);
if (rowCount < 1) {
throw new DeletionError();
throw new DeletionError(Applications.table, id);
}
};

View file

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

View file

@ -49,6 +49,6 @@ export const deleteResourceById = async (id: string) => {
`);
if (rowCount < 1) {
throw new DeletionError();
throw new DeletionError(Resources.table, id);
}
};

View file

@ -30,6 +30,6 @@ export const deleteScopeById = async (id: string) => {
`);
if (rowCount < 1) {
throw new DeletionError();
throw new DeletionError(ResourceScopes.table, id);
}
};

View file

@ -103,6 +103,6 @@ export const deleteUserById = async (id: string) => {
`);
if (rowCount < 1) {
throw new DeletionError();
throw new DeletionError(Users.table, id);
}
};

View file

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

View file

@ -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.',
},

View file

@ -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`。',
},

View file

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