Sign in again through a front-door proxy when its session ends #52
No files matched your search
+17
-5
@@ -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.");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user