Skip to content
File

Blob: src/client/lib/session-bootstrap.ts

typescript91 lines
1import { OIDC_RETURN_MARKER, SESSION_HINT_COOKIE } from "@/shared/auth";
2 
3const PUBLIC_BOOTSTRAP_PATH_PREFIXES = ["/login", "/s/"] as const;
4export type SessionBootstrapStrategy = "skip" | "background" | "block";
5 
6export function hasSessionRefreshHint(cookieHeader = typeof document === "undefined" ? "" : document.cookie): boolean {
7 return cookieHeader.split(";").some((pair) => {
8 const [name, value] = pair.trim().split("=");
9 return name === SESSION_HINT_COOKIE && value === "1";
10 });
11}
12 
13function isPublicBootstrapPath(pathname: string): boolean {
14 if (pathname === "/") return true;
15 if (pathname === "/login") return true;
16 return PUBLIC_BOOTSTRAP_PATH_PREFIXES.some((prefix) =>
17 prefix === "/login" ? pathname === prefix : pathname.startsWith(prefix),
18 );
19}
20 
21function isInvitePath(pathname: string): boolean {
22 return pathname.startsWith("/invite/");
23}
24 
25export function hasOidcMarker(search?: string): boolean {
26 if (search === undefined) {
27 if (typeof window === "undefined") return false;
28 search = window.location.search;
29 }
30 if (!search) return false;
31 return new URLSearchParams(search.startsWith("?") ? search.slice(1) : search).has(OIDC_RETURN_MARKER);
32}
33 
34export function stripOidcMarker(): void {
35 if (typeof window === "undefined") return;
36 const params = new URLSearchParams(window.location.search);
37 if (!params.has(OIDC_RETURN_MARKER)) return;
38 params.delete(OIDC_RETURN_MARKER);
39 const next = params.toString();
40 const url = `${window.location.pathname}${next ? `?${next}` : ""}${window.location.hash}`;
41 window.history.replaceState(window.history.state, "", url);
42}
43 
44export interface PostOidcBootstrapDeps {
45 refreshSession: () => Promise<{ ok: boolean }>;
46 clearAuth: () => void;
47 navigate: (url: string) => void;
48}
49 
50// ADR: post-OIDC refresh failure must fail closed. The cached user may belong
51// to a prior identity; rendering before the fresh bland JWT is confirmed would
52// leak stale workspace/Dexie data for the old user.
53export async function performBootstrapRefresh(
54 postOidcReturn: boolean,
55 deps: PostOidcBootstrapDeps,
56): Promise<"continue" | "redirected"> {
57 const result = await deps.refreshSession();
58 if (postOidcReturn && !result.ok) {
59 deps.clearAuth();
60 stripOidcMarker();
61 deps.navigate("/login?error=oidc_post_callback_refresh_failed");
62 return "redirected";
63 }
64 stripOidcMarker();
65 return "continue";
66}
67 
68export function getSessionBootstrapStrategy(
69 pathname: string,
70 hasStoredUser: boolean,
71 cookieHeader?: string,
72 search?: string,
73): SessionBootstrapStrategy {
74 // ADR: post-OIDC redirects carry the marker so the SPA blocks on refresh +
75 // owner validation. A prior cached user from a different identity must never
76 // render before the freshly-minted bland JWT is exchanged.
77 if (hasOidcMarker(search)) return "block";
78 
79 if (hasStoredUser) return "background";
80 
81 if (!hasSessionRefreshHint(cookieHeader)) {
82 return "skip";
83 }
84 
85 // Invite acceptance triggers a write the moment the SPA mounts; refresh
86 // must complete before the auto-accept fires.
87 if (isInvitePath(pathname)) return "block";
88 
89 return isPublicBootstrapPath(pathname) ? "background" : "block";
90}