web: Normalize client-side error handling (#13595)
web: Clean up error handling. Prep for permission checks. - Add clearer reporting for API and network errors. - Tidy error checking. - Partial type safety for events.
This commit is contained in:
@ -1,36 +0,0 @@
|
||||
import {
|
||||
GenericError,
|
||||
GenericErrorFromJSON,
|
||||
ResponseError,
|
||||
ValidationError,
|
||||
ValidationErrorFromJSON,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
export class SentryIgnoredError extends Error {}
|
||||
export class NotFoundError extends Error {}
|
||||
export class RequestError extends Error {}
|
||||
|
||||
export type APIErrorTypes = ValidationError | GenericError;
|
||||
|
||||
export const HTTP_BAD_REQUEST = 400;
|
||||
export const HTTP_INTERNAL_SERVICE_ERROR = 500;
|
||||
|
||||
export async function parseAPIError(error: Error): Promise<APIErrorTypes> {
|
||||
if (!(error instanceof ResponseError)) {
|
||||
return error;
|
||||
}
|
||||
if (
|
||||
error.response.status < HTTP_BAD_REQUEST ||
|
||||
error.response.status >= HTTP_INTERNAL_SERVICE_ERROR
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
const body = await error.response.json();
|
||||
if (error.response.status === 400) {
|
||||
return ValidationErrorFromJSON(body);
|
||||
}
|
||||
if (error.response.status === 403) {
|
||||
return GenericErrorFromJSON(body);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
184
web/src/common/errors/network.ts
Normal file
184
web/src/common/errors/network.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import {
|
||||
GenericError,
|
||||
GenericErrorFromJSON,
|
||||
ResponseError,
|
||||
ValidationError,
|
||||
ValidationErrorFromJSON,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
//#region HTTP
|
||||
|
||||
/**
|
||||
* Common HTTP status names used in the API and their corresponding codes.
|
||||
*/
|
||||
export const HTTPStatusCode = {
|
||||
BadRequest: 400,
|
||||
Forbidden: 403,
|
||||
InternalServiceError: 500,
|
||||
} as const satisfies Record<string, number>;
|
||||
|
||||
export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode];
|
||||
|
||||
export type HTTPErrorJSONTransformer<T = unknown> = (json: T) => APIError;
|
||||
|
||||
export const HTTPStatusCodeTransformer: Record<number, HTTPErrorJSONTransformer> = {
|
||||
[HTTPStatusCode.BadRequest]: ValidationErrorFromJSON,
|
||||
[HTTPStatusCode.Forbidden]: GenericErrorFromJSON,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Type guard to check if a response contains a JSON body.
|
||||
*
|
||||
* This is useful to guard against parsing errors when attempting to read the response body.
|
||||
*/
|
||||
export function isJSONResponse(response: Response): boolean {
|
||||
return Boolean(response.headers.get("content-type")?.includes("application/json"));
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region API
|
||||
|
||||
/**
|
||||
* An API response error, typically derived from a {@linkcode Response} body.
|
||||
*
|
||||
* @see {@linkcode parseAPIResponseError}
|
||||
*/
|
||||
export type APIError = ValidationError | GenericError;
|
||||
|
||||
/**
|
||||
* Given an error-like object, attempts to normalize it into a {@linkcode GenericError}
|
||||
* suitable for display to the user.
|
||||
*/
|
||||
export function createSyntheticGenericError(detail?: string): GenericError {
|
||||
const syntheticGenericError: GenericError = {
|
||||
detail: detail || ResponseErrorMessages[HTTPStatusCode.InternalServiceError].reason,
|
||||
};
|
||||
|
||||
return syntheticGenericError;
|
||||
}
|
||||
|
||||
/**
|
||||
* An error that contains a native response object.
|
||||
*
|
||||
* @see {@linkcode isResponseErrorLike} to determine if an error contains a response object.
|
||||
*/
|
||||
export type APIErrorWithResponse = Pick<ResponseError, "response" | "message">;
|
||||
|
||||
/**
|
||||
* Type guard to check if an error contains a HTTP {@linkcode Response} object.
|
||||
*
|
||||
* @see {@linkcode parseAPIResponseError} to parse the response body into a {@linkcode APIError}.
|
||||
*/
|
||||
export function isResponseErrorLike(errorLike: unknown): errorLike is APIErrorWithResponse {
|
||||
if (!errorLike || typeof errorLike !== "object") return false;
|
||||
|
||||
return "response" in errorLike && errorLike.response instanceof Response;
|
||||
}
|
||||
|
||||
/**
|
||||
* A descriptor to provide a human readable error message for a given HTTP status code.
|
||||
*
|
||||
* @see {@linkcode ResponseErrorMessages} for a list of fallback error messages.
|
||||
*/
|
||||
interface ResponseErrorDescriptor {
|
||||
headline: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback error messages for HTTP status codes used when a more specific error message is not available in the response.
|
||||
*/
|
||||
export const ResponseErrorMessages: Record<number, ResponseErrorDescriptor> = {
|
||||
[HTTPStatusCode.BadRequest]: {
|
||||
headline: "Bad request",
|
||||
reason: "The server did not understand the request",
|
||||
},
|
||||
[HTTPStatusCode.InternalServiceError]: {
|
||||
headline: "Internal server error",
|
||||
reason: "An unexpected error occurred",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Composes a human readable error message from a {@linkcode ResponseErrorDescriptor}.
|
||||
*
|
||||
* Note that this is kept separate from localization to lower the complexity of the error handling code.
|
||||
*/
|
||||
export function composeResponseErrorDescriptor(descriptor: ResponseErrorDescriptor): string {
|
||||
return `${descriptor.headline}: ${descriptor.reason}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to pluck a human readable error message from a {@linkcode ValidationError}.
|
||||
*/
|
||||
export function pluckErrorDetail(validationError: ValidationError, fallback?: string): string;
|
||||
/**
|
||||
* Attempts to pluck a human readable error message from a {@linkcode GenericError}.
|
||||
*/
|
||||
export function pluckErrorDetail(genericError: GenericError, fallback?: string): string;
|
||||
/**
|
||||
* Attempts to pluck a human readable error message from an `Error` object.
|
||||
*/
|
||||
export function pluckErrorDetail(error: Error, fallback?: string): string;
|
||||
/**
|
||||
* Attempts to pluck a human readable error message from an error-like object.
|
||||
*
|
||||
* Prioritizes the `detail` key, then the `message` key.
|
||||
*
|
||||
*/
|
||||
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string;
|
||||
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string {
|
||||
fallback ||= composeResponseErrorDescriptor(
|
||||
ResponseErrorMessages[HTTPStatusCode.InternalServiceError],
|
||||
);
|
||||
|
||||
if (!errorLike || typeof errorLike !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if ("detail" in errorLike && typeof errorLike.detail === "string") {
|
||||
return errorLike.detail;
|
||||
}
|
||||
|
||||
if ("message" in errorLike && typeof errorLike.message === "string") {
|
||||
return errorLike.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given API error, parses the response body and transforms it into a {@linkcode APIError}.
|
||||
*/
|
||||
export async function parseAPIResponseError<T extends APIError = APIError>(
|
||||
error: unknown,
|
||||
): Promise<T> {
|
||||
if (!isResponseErrorLike(error)) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return createSyntheticGenericError(message) as T;
|
||||
}
|
||||
|
||||
const { response, message } = error;
|
||||
|
||||
if (!isJSONResponse(response)) {
|
||||
return createSyntheticGenericError(message || response.statusText) as T;
|
||||
}
|
||||
|
||||
return response
|
||||
.json()
|
||||
.then((body) => {
|
||||
const transformer = HTTPStatusCodeTransformer[response.status];
|
||||
const transformedBody = transformer ? transformer(body) : body;
|
||||
|
||||
return transformedBody as unknown as T;
|
||||
})
|
||||
.catch((transformerError: unknown) => {
|
||||
console.error("Failed to parse response error body", transformerError);
|
||||
|
||||
return createSyntheticGenericError(message || response.statusText) as T;
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
@ -8,13 +8,10 @@ export interface EventUser {
|
||||
is_anonymous?: boolean;
|
||||
}
|
||||
|
||||
export interface EventContext {
|
||||
[key: string]: EventContext | EventModel | string | number | string[];
|
||||
}
|
||||
|
||||
export interface EventWithContext extends Event {
|
||||
user: EventUser;
|
||||
context: EventContext;
|
||||
export interface EventGeo {
|
||||
city?: string;
|
||||
country?: string;
|
||||
continent?: string;
|
||||
}
|
||||
|
||||
export interface EventModel {
|
||||
@ -28,3 +25,16 @@ export interface EventRequest {
|
||||
path: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
export type EventContextProperty = EventModel | EventGeo | string | number | string[] | undefined;
|
||||
|
||||
// TODO: Events should have more specific types.
|
||||
export interface EventContext {
|
||||
[key: string]: EventContext | EventContextProperty;
|
||||
geo?: EventGeo;
|
||||
}
|
||||
|
||||
export interface EventWithContext extends Event {
|
||||
user: EventUser;
|
||||
context: EventContext;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||
|
||||
export interface PlexPinResponse {
|
||||
// Only has the fields we care about
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { config } from "@goauthentik/common/api/config";
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import {
|
||||
ErrorEvent,
|
||||
@ -13,6 +12,11 @@ import {
|
||||
|
||||
import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api";
|
||||
|
||||
/**
|
||||
* A generic error that can be thrown without triggering Sentry's reporting.
|
||||
*/
|
||||
export class SentryIgnoredError extends Error {}
|
||||
|
||||
export const TAG_SENTRY_COMPONENT = "authentik.component";
|
||||
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";
|
||||
|
||||
|
||||
@ -1,63 +1,96 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
|
||||
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
|
||||
|
||||
import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
|
||||
import { CoreApi, SessionUser } from "@goauthentik/api";
|
||||
|
||||
let globalMePromise: Promise<SessionUser> | undefined;
|
||||
/**
|
||||
* Create a guest session for unauthenticated users.
|
||||
*
|
||||
* @see {@linkcode me} for the actual session retrieval.
|
||||
*/
|
||||
function createGuestSession(): SessionUser {
|
||||
const guest: SessionUser = {
|
||||
user: {
|
||||
pk: -1,
|
||||
isSuperuser: false,
|
||||
isActive: true,
|
||||
groups: [],
|
||||
avatar: "",
|
||||
uid: "",
|
||||
username: "",
|
||||
name: "",
|
||||
settings: {},
|
||||
systemPermissions: [],
|
||||
},
|
||||
};
|
||||
|
||||
return guest;
|
||||
}
|
||||
|
||||
let memoizedSession: SessionUser | null = null;
|
||||
|
||||
/**
|
||||
* Refresh the current user session.
|
||||
*/
|
||||
export function refreshMe(): Promise<SessionUser> {
|
||||
globalMePromise = undefined;
|
||||
memoizedSession = null;
|
||||
return me();
|
||||
}
|
||||
|
||||
export function me(): Promise<SessionUser> {
|
||||
if (!globalMePromise) {
|
||||
globalMePromise = new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersMeRetrieve()
|
||||
.then((user) => {
|
||||
if (!user.user.settings || !("locale" in user.user.settings)) {
|
||||
return user;
|
||||
}
|
||||
const locale: string | undefined = user.user.settings.locale;
|
||||
if (locale && locale !== "") {
|
||||
console.debug(
|
||||
`authentik/locale: Activating user's configured locale '${locale}'`,
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EVENT_LOCALE_REQUEST, {
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
detail: { locale },
|
||||
}),
|
||||
/**
|
||||
* Retrieve the current user session.
|
||||
*
|
||||
* This is a memoized function, so it will only make one request per page load.
|
||||
*
|
||||
* @see {@linkcode refreshMe} to force a refresh.
|
||||
*/
|
||||
export async function me(): Promise<SessionUser> {
|
||||
if (memoizedSession) return memoizedSession;
|
||||
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersMeRetrieve()
|
||||
.then((nextSession) => {
|
||||
const locale: string | undefined = nextSession.user.settings.locale;
|
||||
|
||||
if (locale) {
|
||||
console.debug(`authentik/locale: Activating user's configured locale '${locale}'`);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(EVENT_LOCALE_REQUEST, {
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
detail: { locale },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return nextSession;
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
if (isResponseErrorLike(error)) {
|
||||
const { response } = error;
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const { pathname, search, hash } = window.location;
|
||||
|
||||
const authFlowRedirectURL = new URL(
|
||||
`/flows/-/default/authentication/`,
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
authFlowRedirectURL.searchParams.set("next", `${pathname}${search}${hash}`);
|
||||
|
||||
window.location.assign(authFlowRedirectURL);
|
||||
}
|
||||
return user;
|
||||
})
|
||||
.catch((ex: ResponseError) => {
|
||||
const defaultUser: SessionUser = {
|
||||
user: {
|
||||
pk: -1,
|
||||
isSuperuser: false,
|
||||
isActive: true,
|
||||
groups: [],
|
||||
avatar: "",
|
||||
uid: "",
|
||||
username: "",
|
||||
name: "",
|
||||
settings: {},
|
||||
systemPermissions: [],
|
||||
},
|
||||
};
|
||||
if (ex.response?.status === 401 || ex.response?.status === 403) {
|
||||
const relativeUrl = window.location
|
||||
.toString()
|
||||
.substring(window.location.origin.length);
|
||||
window.location.assign(
|
||||
`/flows/-/default/authentication/?next=${encodeURIComponent(relativeUrl)}`,
|
||||
);
|
||||
}
|
||||
return defaultUser;
|
||||
});
|
||||
}
|
||||
return globalMePromise;
|
||||
}
|
||||
|
||||
console.debug("authentik/users: Failed to retrieve user session", error);
|
||||
|
||||
return createGuestSession();
|
||||
})
|
||||
.then((nextSession) => {
|
||||
memoizedSession = nextSession;
|
||||
return nextSession;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||
|
||||
import { CSSResult, css } from "lit";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user