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:
Teffen Ellis
2025-04-07 19:50:41 +02:00
committed by GitHub
parent e93b2a1a75
commit 363d655378
53 changed files with 901 additions and 493 deletions

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { CSSResult, css } from "lit";