diff --git a/frontend/src/hooks.client.ts b/frontend/src/hooks.client.ts index 30e638bd0c..5496fc5e59 100644 --- a/frontend/src/hooks.client.ts +++ b/frontend/src/hooks.client.ts @@ -1,13 +1,13 @@ import { ensureErrorIsTraced, traceFetch } from '$lib/otel/otel.client'; +import { getErrorMessage, validateFetchResponse } from './hooks.shared'; -import { redirect, type HandleClientError } from '@sveltejs/kit'; -import { getErrorMessage } from './hooks.shared'; -import { loadI18n } from '$lib/i18n'; +import { APP_VERSION } from '$lib/util/version'; +import type { HandleClientError } from '@sveltejs/kit'; +import { USER_LOAD_KEY } from '$lib/user'; import { handleFetch } from '$lib/util/fetch-proxy'; -import {invalidate} from '$app/navigation'; -import {USER_LOAD_KEY} from '$lib/user'; +import { invalidate } from '$app/navigation'; +import { loadI18n } from '$lib/i18n'; import { updated } from '$app/stores'; -import { APP_VERSION } from '$lib/util/version'; await loadI18n(); @@ -64,13 +64,9 @@ function shouldTryAutoReload(updateDetected: boolean): boolean { handleFetch(async ({ fetch, args }) => { const response = await traceFetch(() => fetch(...args)); - if (response.status === 401 && location.pathname !== '/login') { - throw redirect(307, '/logout'); - } - - if (response.status >= 500) { - throw new Error(`Unexpected response: ${response.statusText} (${response.status}). URL: ${response.url}.`); - } + validateFetchResponse(response, + location.pathname === '/login', + location.pathname === '/' || location.pathname === '/home' || location.pathname === '/admin'); if (response.headers.get('lexbox-refresh-jwt') == 'true') { await invalidate(USER_LOAD_KEY); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 76bc70a14b..0be69ee15f 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -4,10 +4,13 @@ import { redirect, type Handle, type HandleFetch, type HandleServerError, type R import { loadI18n } from '$lib/i18n'; import { ensureErrorIsTraced, traceRequest, traceFetch } from '$lib/otel/otel.server' import { env } from '$env/dynamic/private'; -import { getErrorMessage } from './hooks.shared'; +import { getErrorMessage, validateFetchResponse } from './hooks.shared'; + +const UNAUTHENTICATED_ROOT = '(unauthenticated)'; +const AUTHENTICATED_ROOT = '(authenticated)'; const PUBLIC_ROUTE_ROOTS = [ - '(unauthenticated)', + UNAUTHENTICATED_ROOT, 'email', 'healthz', ]; @@ -55,6 +58,12 @@ export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { if (response.headers.has('lexbox-version')) { apiVersion.value = response.headers.get('lexbox-version'); } + + const routeId = event.route.id ?? ''; + validateFetchResponse(response, + routeId.endsWith('/login'), + routeId.endsWith(AUTHENTICATED_ROOT) || routeId.endsWith('/home') || routeId.endsWith('/admin')); + return response; }; diff --git a/frontend/src/hooks.shared.ts b/frontend/src/hooks.shared.ts index b01dac84d2..dce16ebb2a 100644 --- a/frontend/src/hooks.shared.ts +++ b/frontend/src/hooks.shared.ts @@ -1,3 +1,5 @@ +import { redirect } from '@sveltejs/kit'; + const sayWuuuuuuut = 'We\'re not sure what happened.'; export function getErrorMessage(error: unknown): string { @@ -17,3 +19,23 @@ export function getErrorMessage(error: unknown): string { sayWuuuuuuut ); } + +export function validateFetchResponse(response: Response, isAtLogin: boolean, isHome: boolean): void { + if (response.status === 401 && !isAtLogin) { + throw redirect(307, '/logout'); + } + + if (response.status === 403) { + if (isHome) { + // the user's JWT appears to be invalid + throw redirect(307, '/logout'); + } else { + // the user tried to access something they don't have permission for + throw redirect(307, '/home'); + } + } + + if (response.status >= 500) { + throw new Error(`Unexpected response: ${response.statusText} (${response.status}). URL: ${response.url}.`); + } +} diff --git a/frontend/src/lib/gql/gql-client.ts b/frontend/src/lib/gql/gql-client.ts index 2a9222e8b4..a2a9d24e76 100644 --- a/frontend/src/lib/gql/gql-client.ts +++ b/frontend/src/lib/gql/gql-client.ts @@ -1,4 +1,3 @@ -import {redirect} from '@sveltejs/kit'; import { type Client, type AnyVariables, @@ -6,7 +5,6 @@ import { type OperationContext, type OperationResult, fetchExchange, - type CombinedError, queryStore, type OperationResultSource, type OperationResultStore @@ -167,15 +165,12 @@ class GqlClient { private throwAnyUnexpectedErrors>(result: T): void { const error = result.error; if (!error) return; - if (this.is401(error)) throw redirect(307, '/logout'); - if (error.networkError) throw error.networkError; // e.g. SvelteKit redirects + // unexpected status codes are handled in the fetch hooks + // throws there (e.g. SvelteKit redirects) turn into networkErrors that we rethrow here + if (error.networkError) throw error.networkError; throw error; } - private is401(error: CombinedError): boolean { - return (error.response as Response | undefined)?.status === 401; - } - private findInputErrors({data}: OperationResult): LexGqlError> | undefined { const errors: GqlInputError>[] = []; if (isObject(data)) {