Sign in again through a front-door proxy when its session ends #52

Merged
jcoffey-dev merged 1 commits from fix/proxy-reauth into main 2026-10-10 01:41:15 +00:00
3 changed files with 123 additions and 5 deletions

No files matched your search

+17 -5
View File
@@ -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<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(withBase(path), {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string> | undefined) },
credentials: "same-origin",
});
let res: Response;
try {
res = await fetch(withBase(path), {
...init,
headers: { ...HEADERS, ...(init.headers as Record<string, string> | 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.");
+57
View File
@@ -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<Response>) =>
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();
});
});
+49
View File
@@ -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<boolean> | null = null;
/** Whether a sign-in proxy in front of the app wants the reader to sign in. */
export async function frontDoorWantsSignIn(): Promise<boolean> {
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<boolean> {
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;
}