diff --git a/web/src/jmap/client.ts b/web/src/jmap/client.ts index 2649a8f..2529449 100644 --- a/web/src/jmap/client.ts +++ b/web/src/jmap/client.ts @@ -1,5 +1,6 @@ import type { Id, Invocation, JmapResponse, JmapSession, MethodError, UploadResponse } from "./types"; import { withBase } from "@/lib/basePath"; +import { reauthAtFrontDoor } from "@/lib/frontDoor"; export const CAP = { core: "urn:ietf:params:jmap:core", @@ -75,11 +76,22 @@ const HEADERS = { "content-type": "application/json", accept: "application/json" * whatever the deployment happens to be called. */ export async function apiFetch(path: string, init: RequestInit = {}): Promise { - const res = await fetch(withBase(path), { - ...init, - headers: { ...HEADERS, ...(init.headers as Record | undefined) }, - credentials: "same-origin", - }); + let res: Response; + try { + res = await fetch(withBase(path), { + ...init, + headers: { ...HEADERS, ...(init.headers as Record | undefined) }, + credentials: "same-origin", + }); + } catch (err) { + // A sign-in proxy redirecting to its login looks exactly like this (#47). + void reauthAtFrontDoor(); + throw err; + } + if (res.redirected && !(res.headers.get("content-type") ?? "").includes("json")) { + // Followed to somebody else's page on this origin: the same door. + void reauthAtFrontDoor(); + } if (res.status === 401 && !path.startsWith("/api/auth/login")) { client.handleUnauthenticated(); throw new ApiError(401, "unauthenticated", "Your session has expired. Please sign in again."); diff --git a/web/src/lib/__tests__/front-door.test.ts b/web/src/lib/__tests__/front-door.test.ts new file mode 100644 index 0000000..ee5c834 --- /dev/null +++ b/web/src/lib/__tests__/front-door.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { frontDoorWantsSignIn, reauthAtFrontDoor } from "@/lib/frontDoor"; + +/** + * #47: behind a sign-in proxy, an expired proxy session must send the app to + * a real navigation instead of failing every request as a network error. + */ +const answer = (res: Partial) => + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 0, type: "basic", headers: new Headers(), ...res }) as Response)); + +beforeEach(() => sessionStorage.clear()); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("frontDoorWantsSignIn", () => { + it("sees a redirect it was told not to follow", async () => { + answer({ type: "opaqueredirect" }); + expect(await frontDoorWantsSignIn()).toBe(true); + }); + it("sees a page where the health check should be JSON", async () => { + answer({ ok: true, status: 200, headers: new Headers({ "content-type": "text/html" }) }); + expect(await frontDoorWantsSignIn()).toBe(true); + }); + it("leaves our own healthy answer alone", async () => { + answer({ ok: true, status: 200, headers: new Headers({ "content-type": "application/json" }) }); + expect(await frontDoorWantsSignIn()).toBe(false); + }); + it("calls an unreachable server the network, not the door", async () => { + vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("Failed to fetch"); })); + expect(await frontDoorWantsSignIn()).toBe(false); + }); +}); + +describe("reauthAtFrontDoor", () => { + it("opens the root for real when the door asks", async () => { + answer({ type: "opaqueredirect" }); + const go = vi.fn(); + expect(await reauthAtFrontDoor(go)).toBe(true); + expect(go).toHaveBeenCalledTimes(1); + expect(go.mock.calls[0]![0]).toMatch(/\/$/); + }); + it("goes only once a minute, so a proxy that keeps redirecting can't loop the app", async () => { + answer({ type: "opaqueredirect" }); + const go = vi.fn(); + await reauthAtFrontDoor(go); + await reauthAtFrontDoor(go); + expect(go).toHaveBeenCalledTimes(1); + }); + it("does nothing when the door isn't asking", async () => { + answer({ ok: true, status: 200, headers: new Headers({ "content-type": "application/json" }) }); + const go = vi.fn(); + expect(await reauthAtFrontDoor(go)).toBe(false); + expect(go).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/frontDoor.ts b/web/src/lib/frontDoor.ts new file mode 100644 index 0000000..1718be5 --- /dev/null +++ b/web/src/lib/frontDoor.ts @@ -0,0 +1,49 @@ +import { withBase } from "@/lib/basePath"; + +/* + * A sign-in proxy in front of the app (Cloudflare Access, Authentik's outpost + * and the like) answers once its own session ends with a redirect to its login + * page, usually on another origin. A page fetch can't follow that: it fails as + * a network error. An installed app never recovers by itself, because it opens + * on an app route the service worker answers from its kept copy, so nothing + * ever navigates to the network where the proxy could show its login (#47). + * + * So when a call to our own API fails that way, ask the server something cheap + * without following redirects. A redirect, or a page where JSON was expected, + * means the door is asking, and the root -- which the service worker always + * sends to the network -- is opened for real. Once a minute at most, so a + * proxy that keeps redirecting can't put the app in a loop. + */ + +const GUARD = "ihasmail:frontDoorAt"; +const ONCE_PER_MS = 60_000; + +let checking: Promise | null = null; + +/** Whether a sign-in proxy in front of the app wants the reader to sign in. */ +export async function frontDoorWantsSignIn(): Promise { + try { + const res = await fetch(withBase("/api/health"), { redirect: "manual", cache: "no-store", credentials: "same-origin" }); + if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) return true; + return res.ok && !(res.headers.get("content-type") ?? "").includes("json"); + } catch { + // Unreachable as well: that is the network, not the door. + return false; + } +} + +/** + * Called when an API request failed at the network or came back redirected to + * something that isn't ours. Opens the root for the proxy if it is asking, and + * says whether it did. + */ +export async function reauthAtFrontDoor(go: (url: string) => void = (url) => window.location.replace(url)): Promise { + if (typeof navigator !== "undefined" && navigator.onLine === false) return false; + const last = Number(sessionStorage.getItem(GUARD) ?? 0); + if (Date.now() - last < ONCE_PER_MS) return false; + checking ??= frontDoorWantsSignIn().finally(() => { checking = null; }); + if (!(await checking)) return false; + sessionStorage.setItem(GUARD, String(Date.now())); + go(withBase("/")); + return true; +}