@ttt-productions/auth-core 1.6.10 → 1.6.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"AuthProvider.d.ts","sourceRoot":"","sources":["../../src/react/AuthProvider.tsx"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAGf,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAGvE,eAAO,MAAM,WAAW,uDAAoD,CAAC;AAE7E,UAAU,iBAAiB,CAAC,OAAO;IACjC,MAAM,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACpC,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,wBAAgB,YAAY,CAAC,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5D,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,iBAAiB,CAAC,OAAO,CAAC,+BAgGjD"}
1
+ {"version":3,"file":"AuthProvider.d.ts","sourceRoot":"","sources":["../../src/react/AuthProvider.tsx"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAIf,OAAO,KAAK,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAavE,eAAO,MAAM,WAAW,uDAAoD,CAAC;AAE7E,UAAU,iBAAiB,CAAC,OAAO;IACjC,MAAM,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC;IACpC,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,wBAAgB,YAAY,CAAC,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5D,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,iBAAiB,CAAC,OAAO,CAAC,+BAoHjD"}
@@ -6,11 +6,40 @@ import { getIdTokenClaims } from "../claims.js";
6
6
  export const AuthContext = createContext(null);
7
7
  export function AuthProvider({ config, children }) {
8
8
  const { user, loading: authLoading } = useAuthState(config.auth);
9
- const [claims, setClaims] = useState(config.defaultClaims);
10
- const [claimsLoading, setClaimsLoading] = useState(true);
9
+ // Claims are held with the session they were read for and derived at render (below), so a
10
+ // render can never pair one account's `user` with another account's claims, and a read that
11
+ // settles after the session changed (a late refresh, a slow initial read) is dropped.
12
+ const [resolved, setResolved] = useState(null);
13
+ const generationRef = useRef(0);
14
+ const appliedGenerationRef = useRef(0);
15
+ const currentUserRef = useRef(user);
16
+ currentUserRef.current = user;
11
17
  // Stable config ref so effects don't re-fire on config object identity changes
12
18
  const configRef = useRef(config);
13
19
  configRef.current = config;
20
+ /**
21
+ * Read `forUser`'s claims under a new generation and apply them only if `forUser` is still
22
+ * the signed-in session and no newer read has applied. A failed read leaves the session's
23
+ * claims as they are, or settles an unread session on the default claims.
24
+ */
25
+ const readClaims = useCallback(async (forUser, read, context) => {
26
+ const generation = ++generationRef.current;
27
+ try {
28
+ const raw = await read();
29
+ if (currentUserRef.current !== forUser || generation <= appliedGenerationRef.current)
30
+ return;
31
+ appliedGenerationRef.current = generation;
32
+ setResolved({ user: forUser, generation, claims: configRef.current.parseClaims(raw ?? {}) });
33
+ }
34
+ catch (err) {
35
+ if (currentUserRef.current !== forUser)
36
+ return;
37
+ configRef.current.onError?.(err, context);
38
+ setResolved((prev) => prev && prev.user === forUser
39
+ ? prev
40
+ : { user: forUser, generation, claims: configRef.current.defaultClaims });
41
+ }
42
+ }, []);
14
43
  // --- Optional readiness gate (see AuthProviderConfig.readyGate) ---
15
44
  // Open immediately when no gate is configured; otherwise held closed until the
16
45
  // gate settles. Fail-open: a rejected gate is reported and then treated as open.
@@ -36,44 +65,23 @@ export function AuthProvider({ config, children }) {
36
65
  if (authLoading)
37
66
  return;
38
67
  if (!user) {
39
- setClaims(configRef.current.defaultClaims);
40
- setClaimsLoading(false);
68
+ setResolved(null);
41
69
  configRef.current.onAuthStateChange?.(null);
42
70
  return;
43
71
  }
44
- let cancelled = false;
45
- setClaimsLoading(true);
46
72
  configRef.current.onAuthStateChange?.(user);
47
- getIdTokenClaims(user)
48
- .then((raw) => {
49
- if (cancelled)
50
- return;
51
- setClaims(configRef.current.parseClaims((raw ?? {})));
52
- })
53
- .catch((err) => {
54
- if (cancelled)
55
- return;
56
- configRef.current.onError?.(err, "getIdTokenClaims");
57
- setClaims(configRef.current.defaultClaims);
58
- })
59
- .finally(() => {
60
- if (!cancelled)
61
- setClaimsLoading(false);
62
- });
63
- return () => { cancelled = true; };
64
- }, [user, authLoading]);
73
+ void readClaims(user, async () => ((await getIdTokenClaims(user)) ?? {}), "getIdTokenClaims");
74
+ }, [user, authLoading, readClaims]);
65
75
  // --- refreshClaims ---
66
76
  const refreshClaims = useCallback(async () => {
67
77
  if (!user)
68
78
  return;
69
- try {
70
- const result = await user.getIdTokenResult(true);
71
- setClaims(configRef.current.parseClaims((result.claims ?? {})));
72
- }
73
- catch (err) {
74
- configRef.current.onError?.(err, "refreshClaims");
75
- }
76
- }, [user]);
79
+ await readClaims(user, async () => ((await user.getIdTokenResult(true)).claims ?? {}), "refreshClaims");
80
+ }, [user, readClaims]);
81
+ // --- Derived at render: only the current session's claims are ever exposed ---
82
+ const sessionClaims = user && resolved?.user === user ? resolved : null;
83
+ const claims = sessionClaims ? sessionClaims.claims : config.defaultClaims;
84
+ const claimsLoading = authLoading || (user !== null && sessionClaims === null);
77
85
  // --- Context value ---
78
86
  const loading = authLoading || claimsLoading || !gateOpen;
79
87
  const value = useMemo(() => ({
@@ -1 +1 @@
1
- {"version":3,"file":"AuthProvider.js","sourceRoot":"","sources":["../../src/react/AuthProvider.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useEffect,\n useRef,\n useState,\n useCallback,\n useMemo,\n type ReactNode,\n} from \"react\";\nimport { useAuthState } from \"./useAuthState.js\";\nimport { getIdTokenClaims } from \"../claims.js\";\nimport type { AuthProviderConfig, AuthContextValue } from \"./types.js\";\n\n \nexport const AuthContext = createContext<AuthContextValue<any> | null>(null);\n\ninterface AuthProviderProps<TClaims> {\n config: AuthProviderConfig<TClaims>;\n children: ReactNode;\n}\n\nexport function AuthProvider<TClaims = Record<string, unknown>>(\n { config, children }: AuthProviderProps<TClaims>,\n) {\n const { user, loading: authLoading } = useAuthState(config.auth);\n\n const [claims, setClaims] = useState<TClaims>(config.defaultClaims);\n const [claimsLoading, setClaimsLoading] = useState(true);\n\n // Stable config ref so effects don't re-fire on config object identity changes\n const configRef = useRef(config);\n configRef.current = config;\n\n // --- Optional readiness gate (see AuthProviderConfig.readyGate) ---\n // Open immediately when no gate is configured; otherwise held closed until the\n // gate settles. Fail-open: a rejected gate is reported and then treated as open.\n const [gateOpen, setGateOpen] = useState(() => !config.readyGate);\n useEffect(() => {\n const gate = configRef.current.readyGate;\n if (!gate) return;\n let cancelled = false;\n Promise.resolve()\n .then(() => gate())\n .catch((err) => configRef.current.onError?.(err, \"readyGate\"))\n .finally(() => {\n if (!cancelled) setGateOpen(true);\n });\n return () => { cancelled = true; };\n // Mount-only by design (reads only the stable configRef): the gate is a\n // one-time client readiness signal.\n }, []);\n\n // --- Claims fetching ---\n useEffect(() => {\n if (authLoading) return;\n\n if (!user) {\n setClaims(configRef.current.defaultClaims);\n setClaimsLoading(false);\n configRef.current.onAuthStateChange?.(null);\n return;\n }\n\n let cancelled = false;\n setClaimsLoading(true);\n configRef.current.onAuthStateChange?.(user);\n\n getIdTokenClaims(user)\n .then((raw) => {\n if (cancelled) return;\n setClaims(configRef.current.parseClaims((raw ?? {}) as Record<string, unknown>));\n })\n .catch((err) => {\n if (cancelled) return;\n configRef.current.onError?.(err, \"getIdTokenClaims\");\n setClaims(configRef.current.defaultClaims);\n })\n .finally(() => {\n if (!cancelled) setClaimsLoading(false);\n });\n\n return () => { cancelled = true; };\n }, [user, authLoading]);\n\n // --- refreshClaims ---\n const refreshClaims = useCallback(async () => {\n if (!user) return;\n try {\n const result = await user.getIdTokenResult(true);\n setClaims(\n configRef.current.parseClaims((result.claims ?? {}) as Record<string, unknown>),\n );\n } catch (err) {\n configRef.current.onError?.(err, \"refreshClaims\");\n }\n }, [user]);\n\n // --- Context value ---\n const loading = authLoading || claimsLoading || !gateOpen;\n\n const value = useMemo<AuthContextValue<TClaims>>(\n () => ({\n user,\n claims,\n loading,\n authLoading,\n claimsLoading,\n isAuthenticated: !!user,\n refreshClaims,\n }),\n [user, claims, loading, authLoading, claimsLoading, refreshClaims],\n );\n\n return (\n <AuthContext.Provider value={value}>\n {children}\n </AuthContext.Provider>\n );\n}\n"]}
1
+ {"version":3,"file":"AuthProvider.js","sourceRoot":"","sources":["../../src/react/AuthProvider.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useEffect,\n useRef,\n useState,\n useCallback,\n useMemo,\n type ReactNode,\n} from \"react\";\nimport type { User } from \"firebase/auth\";\nimport { useAuthState } from \"./useAuthState.js\";\nimport { getIdTokenClaims } from \"../claims.js\";\nimport type { AuthProviderConfig, AuthContextValue } from \"./types.js\";\n\n/**\n * Claims resolved for one signed-in session. `user` is the Firebase `User` instance (it carries\n * the uid and is replaced on every sign-in), and `generation` orders the reads that produced it.\n */\ninterface ResolvedClaims<TClaims> {\n user: User;\n generation: number;\n claims: TClaims;\n}\n\n \nexport const AuthContext = createContext<AuthContextValue<any> | null>(null);\n\ninterface AuthProviderProps<TClaims> {\n config: AuthProviderConfig<TClaims>;\n children: ReactNode;\n}\n\nexport function AuthProvider<TClaims = Record<string, unknown>>(\n { config, children }: AuthProviderProps<TClaims>,\n) {\n const { user, loading: authLoading } = useAuthState(config.auth);\n\n // Claims are held with the session they were read for and derived at render (below), so a\n // render can never pair one account's `user` with another account's claims, and a read that\n // settles after the session changed (a late refresh, a slow initial read) is dropped.\n const [resolved, setResolved] = useState<ResolvedClaims<TClaims> | null>(null);\n const generationRef = useRef(0);\n const appliedGenerationRef = useRef(0);\n const currentUserRef = useRef<User | null>(user);\n currentUserRef.current = user;\n\n // Stable config ref so effects don't re-fire on config object identity changes\n const configRef = useRef(config);\n configRef.current = config;\n\n /**\n * Read `forUser`'s claims under a new generation and apply them only if `forUser` is still\n * the signed-in session and no newer read has applied. A failed read leaves the session's\n * claims as they are, or settles an unread session on the default claims.\n */\n const readClaims = useCallback(\n async (forUser: User, read: () => Promise<Record<string, unknown> | null>, context: string) => {\n const generation = ++generationRef.current;\n try {\n const raw = await read();\n if (currentUserRef.current !== forUser || generation <= appliedGenerationRef.current) return;\n appliedGenerationRef.current = generation;\n setResolved({ user: forUser, generation, claims: configRef.current.parseClaims(raw ?? {}) });\n } catch (err) {\n if (currentUserRef.current !== forUser) return;\n configRef.current.onError?.(err, context);\n setResolved((prev) =>\n prev && prev.user === forUser\n ? prev\n : { user: forUser, generation, claims: configRef.current.defaultClaims },\n );\n }\n },\n [],\n );\n\n // --- Optional readiness gate (see AuthProviderConfig.readyGate) ---\n // Open immediately when no gate is configured; otherwise held closed until the\n // gate settles. Fail-open: a rejected gate is reported and then treated as open.\n const [gateOpen, setGateOpen] = useState(() => !config.readyGate);\n useEffect(() => {\n const gate = configRef.current.readyGate;\n if (!gate) return;\n let cancelled = false;\n Promise.resolve()\n .then(() => gate())\n .catch((err) => configRef.current.onError?.(err, \"readyGate\"))\n .finally(() => {\n if (!cancelled) setGateOpen(true);\n });\n return () => { cancelled = true; };\n // Mount-only by design (reads only the stable configRef): the gate is a\n // one-time client readiness signal.\n }, []);\n\n // --- Claims fetching ---\n useEffect(() => {\n if (authLoading) return;\n\n if (!user) {\n setResolved(null);\n configRef.current.onAuthStateChange?.(null);\n return;\n }\n\n configRef.current.onAuthStateChange?.(user);\n void readClaims(\n user,\n async () => ((await getIdTokenClaims(user)) ?? {}) as Record<string, unknown>,\n \"getIdTokenClaims\",\n );\n }, [user, authLoading, readClaims]);\n\n // --- refreshClaims ---\n const refreshClaims = useCallback(async () => {\n if (!user) return;\n await readClaims(\n user,\n async () => ((await user.getIdTokenResult(true)).claims ?? {}) as Record<string, unknown>,\n \"refreshClaims\",\n );\n }, [user, readClaims]);\n\n // --- Derived at render: only the current session's claims are ever exposed ---\n const sessionClaims = user && resolved?.user === user ? resolved : null;\n const claims = sessionClaims ? sessionClaims.claims : config.defaultClaims;\n const claimsLoading = authLoading || (user !== null && sessionClaims === null);\n\n // --- Context value ---\n const loading = authLoading || claimsLoading || !gateOpen;\n\n const value = useMemo<AuthContextValue<TClaims>>(\n () => ({\n user,\n claims,\n loading,\n authLoading,\n claimsLoading,\n isAuthenticated: !!user,\n refreshClaims,\n }),\n [user, claims, loading, authLoading, claimsLoading, refreshClaims],\n );\n\n return (\n <AuthContext.Provider value={value}>\n {children}\n </AuthContext.Provider>\n );\n}\n"]}
@@ -0,0 +1,4 @@
1
+ export declare function readRedirectPath(key: string): string | null;
2
+ export declare function saveRedirectPath(key: string, path: string): void;
3
+ export declare function clearRedirectPath(key: string): void;
4
+ //# sourceMappingURL=redirect-storage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirect-storage.d.ts","sourceRoot":"","sources":["../../src/react/redirect-storage.ts"],"names":[],"mappings":"AAmBA,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAS3D;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAahE;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAQnD"}
@@ -0,0 +1,57 @@
1
+ // The post-login redirect path lives in localStorage. A browser that blocks site data throws
2
+ // on the storage access itself (FRONTEND-010), so every access here is guarded: a blocked read
3
+ // is "nothing saved", and a blocked write keeps the value in memory for the page's life so a
4
+ // same-page sign-in still lands on it. Outside a browser every call is a no-op.
5
+ const memory = new Map();
6
+ function inBrowser() {
7
+ return typeof window !== "undefined";
8
+ }
9
+ function storage() {
10
+ try {
11
+ return window.localStorage;
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ export function readRedirectPath(key) {
18
+ if (!inBrowser())
19
+ return null;
20
+ try {
21
+ const saved = storage()?.getItem(key) ?? null;
22
+ if (saved !== null)
23
+ return saved;
24
+ }
25
+ catch {
26
+ // Blocked store: fall through to the page-life copy.
27
+ }
28
+ return memory.get(key) ?? null;
29
+ }
30
+ export function saveRedirectPath(key, path) {
31
+ if (!inBrowser())
32
+ return;
33
+ const store = storage();
34
+ if (store) {
35
+ try {
36
+ store.setItem(key, path);
37
+ memory.delete(key);
38
+ return;
39
+ }
40
+ catch {
41
+ // Blocked store: keep the page-life copy below.
42
+ }
43
+ }
44
+ memory.set(key, path);
45
+ }
46
+ export function clearRedirectPath(key) {
47
+ if (!inBrowser())
48
+ return;
49
+ memory.delete(key);
50
+ try {
51
+ storage()?.removeItem(key);
52
+ }
53
+ catch {
54
+ // Blocked store: nothing was persisted there.
55
+ }
56
+ }
57
+ //# sourceMappingURL=redirect-storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirect-storage.js","sourceRoot":"","sources":["../../src/react/redirect-storage.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,+FAA+F;AAC/F,6FAA6F;AAC7F,gFAAgF;AAEhF,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;AAEzC,SAAS,SAAS;IAChB,OAAO,OAAO,MAAM,KAAK,WAAW,CAAC;AACvC,CAAC;AAED,SAAS,OAAO;IACd,IAAI,CAAC;QACH,OAAO,MAAM,CAAC,YAAY,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,GAAW;IAC1C,IAAI,CAAC,SAAS,EAAE;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,OAAO,EAAE,EAAE,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC;QAC9C,IAAI,KAAK,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;IACnC,CAAC;IAAC,MAAM,CAAC;QACP,qDAAqD;IACvD,CAAC;IACD,OAAO,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC;AACjC,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,GAAW,EAAE,IAAY;IACxD,IAAI,CAAC,SAAS,EAAE;QAAE,OAAO;IACzB,MAAM,KAAK,GAAG,OAAO,EAAE,CAAC;IACxB,IAAI,KAAK,EAAE,CAAC;QACV,IAAI,CAAC;YACH,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACzB,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACnB,OAAO;QACT,CAAC;QAAC,MAAM,CAAC;YACP,gDAAgD;QAClD,CAAC;IACH,CAAC;IACD,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,IAAI,CAAC,SAAS,EAAE;QAAE,OAAO;IACzB,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACnB,IAAI,CAAC;QACH,OAAO,EAAE,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC;IAC7B,CAAC;IAAC,MAAM,CAAC;QACP,8CAA8C;IAChD,CAAC;AACH,CAAC","sourcesContent":["// The post-login redirect path lives in localStorage. A browser that blocks site data throws\n// on the storage access itself (FRONTEND-010), so every access here is guarded: a blocked read\n// is \"nothing saved\", and a blocked write keeps the value in memory for the page's life so a\n// same-page sign-in still lands on it. Outside a browser every call is a no-op.\n\nconst memory = new Map<string, string>();\n\nfunction inBrowser(): boolean {\n return typeof window !== \"undefined\";\n}\n\nfunction storage(): Storage | null {\n try {\n return window.localStorage;\n } catch {\n return null;\n }\n}\n\nexport function readRedirectPath(key: string): string | null {\n if (!inBrowser()) return null;\n try {\n const saved = storage()?.getItem(key) ?? null;\n if (saved !== null) return saved;\n } catch {\n // Blocked store: fall through to the page-life copy.\n }\n return memory.get(key) ?? null;\n}\n\nexport function saveRedirectPath(key: string, path: string): void {\n if (!inBrowser()) return;\n const store = storage();\n if (store) {\n try {\n store.setItem(key, path);\n memory.delete(key);\n return;\n } catch {\n // Blocked store: keep the page-life copy below.\n }\n }\n memory.set(key, path);\n}\n\nexport function clearRedirectPath(key: string): void {\n if (!inBrowser()) return;\n memory.delete(key);\n try {\n storage()?.removeItem(key);\n } catch {\n // Blocked store: nothing was persisted there.\n }\n}\n"]}
@@ -35,7 +35,11 @@ export interface AuthGuardConfig {
35
35
  * - Does nothing while loading.
36
36
  * - Redirects unauthenticated users from protected routes to loginRoute.
37
37
  * - Redirects authenticated users from authRedirectRoutes to defaultRoute.
38
- * - Saves attempted path for post-login redirect.
38
+ * - Saves attempted path for post-login redirect — a visitor's deep link only. When a
39
+ * session ends (an observed signed-in → signed-out transition: a sign-out, a forced
40
+ * sign-out, an expiry, another tab), the path it ended on is NOT saved and any saved
41
+ * path is cleared, so the next account to sign in on this device never lands on the
42
+ * previous account's page.
39
43
  *
40
44
  * "Public" is decided ENTIRELY by `config.publicRoutes` (prefix match). The hook
41
45
  * carries no built-in or implicit public route: every route a consumer wants
@@ -1 +1 @@
1
- {"version":3,"file":"useAuthGuard.d.ts","sourceRoot":"","sources":["../../src/react/useAuthGuard.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,sEAAsE;IACtE,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,iCAAiC;IACjC,YAAY,CAAC,EAAE;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC;IAClE,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,mBAAmB;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,qBAAqB;IACrB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CA2E1D"}
1
+ {"version":3,"file":"useAuthGuard.d.ts","sourceRoot":"","sources":["../../src/react/useAuthGuard.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,sEAAsE;IACtE,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,qCAAqC;IACrC,UAAU,EAAE,MAAM,CAAC;IACnB,2DAA2D;IAC3D,YAAY,EAAE,MAAM,CAAC;IACrB,iCAAiC;IACjC,YAAY,CAAC,EAAE;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC;IAClE,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,mBAAmB;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,qBAAqB;IACrB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAID;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAsF1D"}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
  import { useEffect, useRef } from "react";
3
+ import { clearRedirectPath, readRedirectPath, saveRedirectPath } from "./redirect-storage.js";
3
4
  const DEFAULT_REDIRECT_KEY = "auth_redirect_path";
4
5
  /**
5
6
  * Handles route protection. Call in app shell component.
@@ -7,7 +8,11 @@ const DEFAULT_REDIRECT_KEY = "auth_redirect_path";
7
8
  * - Does nothing while loading.
8
9
  * - Redirects unauthenticated users from protected routes to loginRoute.
9
10
  * - Redirects authenticated users from authRedirectRoutes to defaultRoute.
10
- * - Saves attempted path for post-login redirect.
11
+ * - Saves attempted path for post-login redirect — a visitor's deep link only. When a
12
+ * session ends (an observed signed-in → signed-out transition: a sign-out, a forced
13
+ * sign-out, an expiry, another tab), the path it ended on is NOT saved and any saved
14
+ * path is cleared, so the next account to sign in on this device never lands on the
15
+ * previous account's page.
11
16
  *
12
17
  * "Public" is decided ENTIRELY by `config.publicRoutes` (prefix match). The hook
13
18
  * carries no built-in or implicit public route: every route a consumer wants
@@ -25,9 +30,23 @@ export function useAuthGuard(config) {
25
30
  // a deep link that routed through login then landed on defaultRoute instead of
26
31
  // the saved path. The latch resets whenever the pathname is off the auth-route set.
27
32
  const authRouteRedirected = useRef(false);
33
+ // The last settled authentication state this guard observed (null before the first), and
34
+ // the pathname a session ended on. The effect can re-run on that same pathname before the
35
+ // login navigation commits, so the "do not save" decision is held for that pathname rather
36
+ // than for one run.
37
+ const lastAuthenticated = useRef(null);
38
+ const sessionEndedOn = useRef(null);
28
39
  useEffect(() => {
29
40
  if (loading)
30
41
  return;
42
+ if (lastAuthenticated.current === true && !isAuthenticated) {
43
+ sessionEndedOn.current = pathname;
44
+ clearRedirectPath(redirectKey);
45
+ }
46
+ else if (isAuthenticated || sessionEndedOn.current !== pathname) {
47
+ sessionEndedOn.current = null;
48
+ }
49
+ lastAuthenticated.current = isAuthenticated;
31
50
  if (!authRedirectRoutes.includes(pathname)) {
32
51
  authRouteRedirected.current = false;
33
52
  }
@@ -38,11 +57,10 @@ export function useAuthGuard(config) {
38
57
  }
39
58
  const isPublic = publicRoutes.some((r) => pathname.startsWith(r));
40
59
  const isAuthRedirect = authRedirectRoutes.includes(pathname);
41
- // Unauthenticated on protected route -> save path, go to login
60
+ // Unauthenticated on protected route -> save a visitor's path, go to login
42
61
  if (!isAuthenticated && !isPublic) {
43
- if (typeof window !== "undefined") {
44
- localStorage.setItem(redirectKey, pathname);
45
- }
62
+ if (sessionEndedOn.current === null)
63
+ saveRedirectPath(redirectKey, pathname);
46
64
  replace(loginRoute);
47
65
  return;
48
66
  }
@@ -52,13 +70,11 @@ export function useAuthGuard(config) {
52
70
  return;
53
71
  authRouteRedirected.current = true;
54
72
  let target = defaultRoute;
55
- if (typeof window !== "undefined") {
56
- const saved = localStorage.getItem(redirectKey);
57
- if (saved && !authRedirectRoutes.includes(saved)) {
58
- target = saved;
59
- }
60
- localStorage.removeItem(redirectKey);
73
+ const saved = readRedirectPath(redirectKey);
74
+ if (saved && !authRedirectRoutes.includes(saved)) {
75
+ target = saved;
61
76
  }
77
+ clearRedirectPath(redirectKey);
62
78
  replace(target);
63
79
  }
64
80
  }, [
@@ -1 +1 @@
1
- {"version":3,"file":"useAuthGuard.js","sourceRoot":"","sources":["../../src/react/useAuthGuard.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AA+B1C,MAAM,oBAAoB,GAAG,oBAAoB,CAAC;AAElD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,YAAY,CAAC,MAAuB;IAClD,MAAM,EACJ,YAAY,EACZ,kBAAkB,EAClB,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,WAAW,GAAG,oBAAoB,EAClC,OAAO,EACP,QAAQ,EACR,OAAO,EACP,eAAe,GAChB,GAAG,MAAM,CAAC;IAEX,iFAAiF;IACjF,2EAA2E;IAC3E,iFAAiF;IACjF,+EAA+E;IAC/E,2EAA2E;IAC3E,gFAAgF;IAChF,+EAA+E;IAC/E,oFAAoF;IACpF,MAAM,mBAAmB,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAE1C,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,OAAO;YAAE,OAAO;QAEpB,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC3C,mBAAmB,CAAC,OAAO,GAAG,KAAK,CAAC;QACtC,CAAC;QAED,6BAA6B;QAC7B,IAAI,YAAY,IAAI,QAAQ,KAAK,GAAG,EAAE,CAAC;YACrC,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;YACrF,OAAO;QACT,CAAC;QAED,MAAM,QAAQ,GAAG,YAAY,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;QAClE,MAAM,cAAc,GAAG,kBAAkB,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAE7D,+DAA+D;QAC/D,IAAI,CAAC,eAAe,IAAI,CAAC,QAAQ,EAAE,CAAC;YAClC,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;gBAClC,YAAY,CAAC,OAAO,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;YAC9C,CAAC;YACD,OAAO,CAAC,UAAU,CAAC,CAAC;YACpB,OAAO;QACT,CAAC;QAED,gEAAgE;QAChE,IAAI,eAAe,IAAI,cAAc,EAAE,CAAC;YACtC,IAAI,mBAAmB,CAAC,OAAO;gBAAE,OAAO;YACxC,mBAAmB,CAAC,OAAO,GAAG,IAAI,CAAC;YACnC,IAAI,MAAM,GAAG,YAAY,CAAC;YAC1B,IAAI,OAAO,MAAM,KAAK,WAAW,EAAE,CAAC;gBAClC,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;gBAChD,IAAI,KAAK,IAAI,CAAC,kBAAkB,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;oBACjD,MAAM,GAAG,KAAK,CAAC;gBACjB,CAAC;gBACD,YAAY,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;YACvC,CAAC;YACD,OAAO,CAAC,MAAM,CAAC,CAAC;QAClB,CAAC;IACH,CAAC,EAAE;QACD,OAAO;QACP,eAAe;QACf,QAAQ;QACR,YAAY;QACZ,kBAAkB;QAClB,UAAU;QACV,YAAY;QACZ,YAAY;QACZ,WAAW;QACX,OAAO;KACR,CAAC,CAAC;AACL,CAAC","sourcesContent":["\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface AuthGuardConfig {\n /**\n * Routes accessible without authentication, matched by PREFIX\n * (`pathname.startsWith(entry)`), so `'/share/'` covers every `/share/:id`\n * beneath it and `'/terms'` covers `/terms/anything`. This is the SOLE source of public\n * routes — the hook has no built-in defaults and no app-specific literals, so\n * a consumer that needs a dynamic public section must list its prefix here.\n */\n publicRoutes: string[];\n /** Routes that redirect authenticated users away (login, register) */\n authRedirectRoutes: string[];\n /** Where unauthenticated users go */\n loginRoute: string;\n /** Where authenticated users go from authRedirectRoutes */\n defaultRoute: string;\n /** Special root path handling */\n rootRedirect?: { authenticated: string; unauthenticated: string };\n /** localStorage key for post-login redirect */\n redirectKey?: string;\n /** Navigation function (e.g. router.replace) */\n replace: (path: string) => void;\n /** Current path */\n pathname: string;\n /** From useAuth() */\n loading: boolean;\n /** From useAuth() */\n isAuthenticated: boolean;\n}\n\nconst DEFAULT_REDIRECT_KEY = \"auth_redirect_path\";\n\n/**\n * Handles route protection. Call in app shell component.\n *\n * - Does nothing while loading.\n * - Redirects unauthenticated users from protected routes to loginRoute.\n * - Redirects authenticated users from authRedirectRoutes to defaultRoute.\n * - Saves attempted path for post-login redirect.\n *\n * \"Public\" is decided ENTIRELY by `config.publicRoutes` (prefix match). The hook\n * carries no built-in or implicit public route: every route a consumer wants\n * reachable while signed out — including a dynamic section like `/share/:id` — is\n * listed by that consumer as a prefix.\n */\nexport function useAuthGuard(config: AuthGuardConfig): void {\n const {\n publicRoutes,\n authRedirectRoutes,\n loginRoute,\n defaultRoute,\n rootRedirect,\n redirectKey = DEFAULT_REDIRECT_KEY,\n replace,\n pathname,\n loading,\n isAuthenticated,\n } = config;\n\n // The auth-route redirect below is SINGLE-SHOT per auth-route visit. That branch\n // CONSUMES redirectKey and issues a navigation, but the navigation commits\n // asynchronously (the router fetches the target route first), so this effect can\n // legitimately re-run while pathname still reads the auth route (an auth-state\n // re-render, a loading flap). A re-run then finds the key already gone and\n // re-replaces to defaultRoute, CLOBBERING the in-flight saved-path navigation —\n // a deep link that routed through login then landed on defaultRoute instead of\n // the saved path. The latch resets whenever the pathname is off the auth-route set.\n const authRouteRedirected = useRef(false);\n\n useEffect(() => {\n if (loading) return;\n\n if (!authRedirectRoutes.includes(pathname)) {\n authRouteRedirected.current = false;\n }\n\n // Root path special handling\n if (rootRedirect && pathname === \"/\") {\n replace(isAuthenticated ? rootRedirect.authenticated : rootRedirect.unauthenticated);\n return;\n }\n\n const isPublic = publicRoutes.some((r) => pathname.startsWith(r));\n const isAuthRedirect = authRedirectRoutes.includes(pathname);\n\n // Unauthenticated on protected route -> save path, go to login\n if (!isAuthenticated && !isPublic) {\n if (typeof window !== \"undefined\") {\n localStorage.setItem(redirectKey, pathname);\n }\n replace(loginRoute);\n return;\n }\n\n // Authenticated user on auth-only route (login, register, etc.)\n if (isAuthenticated && isAuthRedirect) {\n if (authRouteRedirected.current) return;\n authRouteRedirected.current = true;\n let target = defaultRoute;\n if (typeof window !== \"undefined\") {\n const saved = localStorage.getItem(redirectKey);\n if (saved && !authRedirectRoutes.includes(saved)) {\n target = saved;\n }\n localStorage.removeItem(redirectKey);\n }\n replace(target);\n }\n }, [\n loading,\n isAuthenticated,\n pathname,\n publicRoutes,\n authRedirectRoutes,\n loginRoute,\n defaultRoute,\n rootRedirect,\n redirectKey,\n replace,\n ]);\n}\n"]}
1
+ {"version":3,"file":"useAuthGuard.js","sourceRoot":"","sources":["../../src/react/useAuthGuard.ts"],"names":[],"mappings":"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","sourcesContent":["\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { clearRedirectPath, readRedirectPath, saveRedirectPath } from \"./redirect-storage.js\";\n\nexport interface AuthGuardConfig {\n /**\n * Routes accessible without authentication, matched by PREFIX\n * (`pathname.startsWith(entry)`), so `'/share/'` covers every `/share/:id`\n * beneath it and `'/terms'` covers `/terms/anything`. This is the SOLE source of public\n * routes — the hook has no built-in defaults and no app-specific literals, so\n * a consumer that needs a dynamic public section must list its prefix here.\n */\n publicRoutes: string[];\n /** Routes that redirect authenticated users away (login, register) */\n authRedirectRoutes: string[];\n /** Where unauthenticated users go */\n loginRoute: string;\n /** Where authenticated users go from authRedirectRoutes */\n defaultRoute: string;\n /** Special root path handling */\n rootRedirect?: { authenticated: string; unauthenticated: string };\n /** localStorage key for post-login redirect */\n redirectKey?: string;\n /** Navigation function (e.g. router.replace) */\n replace: (path: string) => void;\n /** Current path */\n pathname: string;\n /** From useAuth() */\n loading: boolean;\n /** From useAuth() */\n isAuthenticated: boolean;\n}\n\nconst DEFAULT_REDIRECT_KEY = \"auth_redirect_path\";\n\n/**\n * Handles route protection. Call in app shell component.\n *\n * - Does nothing while loading.\n * - Redirects unauthenticated users from protected routes to loginRoute.\n * - Redirects authenticated users from authRedirectRoutes to defaultRoute.\n * - Saves attempted path for post-login redirect — a visitor's deep link only. When a\n * session ends (an observed signed-in → signed-out transition: a sign-out, a forced\n * sign-out, an expiry, another tab), the path it ended on is NOT saved and any saved\n * path is cleared, so the next account to sign in on this device never lands on the\n * previous account's page.\n *\n * \"Public\" is decided ENTIRELY by `config.publicRoutes` (prefix match). The hook\n * carries no built-in or implicit public route: every route a consumer wants\n * reachable while signed out — including a dynamic section like `/share/:id` — is\n * listed by that consumer as a prefix.\n */\nexport function useAuthGuard(config: AuthGuardConfig): void {\n const {\n publicRoutes,\n authRedirectRoutes,\n loginRoute,\n defaultRoute,\n rootRedirect,\n redirectKey = DEFAULT_REDIRECT_KEY,\n replace,\n pathname,\n loading,\n isAuthenticated,\n } = config;\n\n // The auth-route redirect below is SINGLE-SHOT per auth-route visit. That branch\n // CONSUMES redirectKey and issues a navigation, but the navigation commits\n // asynchronously (the router fetches the target route first), so this effect can\n // legitimately re-run while pathname still reads the auth route (an auth-state\n // re-render, a loading flap). A re-run then finds the key already gone and\n // re-replaces to defaultRoute, CLOBBERING the in-flight saved-path navigation —\n // a deep link that routed through login then landed on defaultRoute instead of\n // the saved path. The latch resets whenever the pathname is off the auth-route set.\n const authRouteRedirected = useRef(false);\n\n // The last settled authentication state this guard observed (null before the first), and\n // the pathname a session ended on. The effect can re-run on that same pathname before the\n // login navigation commits, so the \"do not save\" decision is held for that pathname rather\n // than for one run.\n const lastAuthenticated = useRef<boolean | null>(null);\n const sessionEndedOn = useRef<string | null>(null);\n\n useEffect(() => {\n if (loading) return;\n\n if (lastAuthenticated.current === true && !isAuthenticated) {\n sessionEndedOn.current = pathname;\n clearRedirectPath(redirectKey);\n } else if (isAuthenticated || sessionEndedOn.current !== pathname) {\n sessionEndedOn.current = null;\n }\n lastAuthenticated.current = isAuthenticated;\n\n if (!authRedirectRoutes.includes(pathname)) {\n authRouteRedirected.current = false;\n }\n\n // Root path special handling\n if (rootRedirect && pathname === \"/\") {\n replace(isAuthenticated ? rootRedirect.authenticated : rootRedirect.unauthenticated);\n return;\n }\n\n const isPublic = publicRoutes.some((r) => pathname.startsWith(r));\n const isAuthRedirect = authRedirectRoutes.includes(pathname);\n\n // Unauthenticated on protected route -> save a visitor's path, go to login\n if (!isAuthenticated && !isPublic) {\n if (sessionEndedOn.current === null) saveRedirectPath(redirectKey, pathname);\n replace(loginRoute);\n return;\n }\n\n // Authenticated user on auth-only route (login, register, etc.)\n if (isAuthenticated && isAuthRedirect) {\n if (authRouteRedirected.current) return;\n authRouteRedirected.current = true;\n let target = defaultRoute;\n const saved = readRedirectPath(redirectKey);\n if (saved && !authRedirectRoutes.includes(saved)) {\n target = saved;\n }\n clearRedirectPath(redirectKey);\n replace(target);\n }\n }, [\n loading,\n isAuthenticated,\n pathname,\n publicRoutes,\n authRedirectRoutes,\n loginRoute,\n defaultRoute,\n rootRedirect,\n redirectKey,\n replace,\n ]);\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttt-productions/auth-core",
3
- "version": "1.6.10",
3
+ "version": "1.6.12",
4
4
  "description": "Shared Firebase authentication utilities and React hooks for TTT Productions apps",
5
5
  "repository": {
6
6
  "type": "git",
@@ -66,12 +66,11 @@
66
66
  "devDependencies": {
67
67
  "@types/react": "^19.0.0",
68
68
  "@types/react-dom": "^19.0.0",
69
- "firebase": "^10.0.0",
69
+ "firebase": "^12.14.0",
70
70
  "firebase-admin": "^14.0.0",
71
71
  "firebase-functions": "^7.0.0",
72
72
  "react": "^19.2.0",
73
- "react-dom": "^19.2.0",
74
- "typescript": "^5.8.3"
73
+ "react-dom": "^19.2.0"
75
74
  },
76
75
  "author": "DJ (TTT Productions)",
77
76
  "license": "MIT"
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=assertAuth.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"assertAuth.test.d.ts","sourceRoot":"","sources":["../../src/server/assertAuth.test.ts"],"names":[],"mappings":""}
@@ -1,373 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- // NOTE: there is deliberately NO `vi.mock("firebase-functions/v2/https")` here.
3
- // assertAuth no longer constructs HttpsError — it throws AuthAssertionError and the
4
- // consuming app maps it (see authError.ts for why). The only remaining reference to
5
- // firebase-functions in assertAuth.ts is a TYPE import, which is erased at runtime.
6
- import { createAssertAuth } from "./assertAuth.js";
7
- import { AuthAssertionError } from "./authError.js";
8
- import { ACCEPTANCE_REQUIRED_REASON, isAcceptanceRequiredError } from "../acceptance.js";
9
- import { EMAIL_VERIFICATION_REQUIRED_REASON, isEmailVerificationRequiredError, } from "../email-verification.js";
10
- function makeRequest(opts) {
11
- const uid = opts?.uid ?? "uid-123";
12
- return {
13
- data: {},
14
- rawRequest: {},
15
- auth: {
16
- uid,
17
- token: { uid, email_verified: opts?.emailVerified ?? true },
18
- },
19
- };
20
- }
21
- function makeUnauthRequest() {
22
- return { data: {}, rawRequest: {}, auth: undefined };
23
- }
24
- const activeUserDoc = {
25
- uid: "uid-123",
26
- displayName: "Test User",
27
- status: "active",
28
- };
29
- // Minimal Firestore stub returning the active user doc, for tests that build
30
- // their own config instead of using the shared beforeEach harness.
31
- function fakeDbWithUser() {
32
- const userDocRef = {
33
- get: vi.fn().mockResolvedValue({ exists: true, data: () => activeUserDoc }),
34
- };
35
- return { doc: vi.fn(() => userDocRef) };
36
- }
37
- // --- harness ---------------------------------------------------------------
38
- let mockUserGet;
39
- let mockRequireAdmin;
40
- let mockDbDoc;
41
- let assertAuth;
42
- beforeEach(() => {
43
- mockUserGet = vi.fn().mockResolvedValue({ exists: true, data: () => activeUserDoc });
44
- mockRequireAdmin = vi.fn().mockResolvedValue(undefined);
45
- const userDocRef = { get: mockUserGet };
46
- mockDbDoc = vi.fn(() => userDocRef);
47
- const fakeDb = { doc: mockDbDoc };
48
- const config = {
49
- firestore: () => fakeDb,
50
- userProfilePath: (uid) => `userProfiles/${uid}`,
51
- getUserStatus: (user) => {
52
- if (user.status === "banned")
53
- return "banned";
54
- if (user.status === "suspended")
55
- return "suspended";
56
- return "active";
57
- },
58
- requireAdmin: mockRequireAdmin,
59
- };
60
- assertAuth = createAssertAuth(config);
61
- });
62
- // --- tests -----------------------------------------------------------------
63
- // REGRESSION GUARD (2026-07-20). assertAuth must throw a plain, framework-free
64
- // AuthAssertionError — never an HttpsError built inside this package. firebase-functions
65
- // 7.2.5 ships two distinct HttpsError classes (ESM entry vs the CJS callable runtime), so
66
- // a package-built HttpsError fails the runtime's `instanceof` check and onCall converts a
67
- // correct 400 into a 500 INTERNAL. Since assertAuth is the first line of every callable,
68
- // that silently broke EVERY auth rejection in deployed environments. If someone
69
- // reintroduces `new HttpsError(...)` here, these assertions fail.
70
- describe("throws a framework-free typed error (never a package-built HttpsError)", () => {
71
- it("rejects with an AuthAssertionError instance carrying the HttpsError code string", async () => {
72
- const err = await assertAuth(makeUnauthRequest()).catch((e) => e);
73
- expect(err).toBeInstanceOf(AuthAssertionError);
74
- expect(err.name).toBe("AuthAssertionError");
75
- expect(err.code).toBe("unauthenticated");
76
- });
77
- it("does not decorate the error with HttpsError's own shape", async () => {
78
- const err = (await assertAuth(makeUnauthRequest()).catch((e) => e));
79
- // `httpErrorCode`/`toJSON` are HttpsError internals — their absence proves the
80
- // package is not constructing one, which is the whole point of the fix.
81
- expect(err.httpErrorCode).toBeUndefined();
82
- expect(err.toJSON).toBeUndefined();
83
- });
84
- it("uses AuthAssertionError for the email-verification rejection too", async () => {
85
- const err = await assertAuth(makeRequest({ emailVerified: false }), {
86
- emailVerified: true,
87
- allowAnyStatus: true,
88
- }).catch((e) => e);
89
- expect(err).toBeInstanceOf(AuthAssertionError);
90
- expect(err.code).toBe("failed-precondition");
91
- });
92
- });
93
- describe("Authentication", () => {
94
- it("throws unauthenticated when request.auth is absent", async () => {
95
- await expect(assertAuth(makeUnauthRequest())).rejects.toMatchObject({
96
- code: "unauthenticated",
97
- });
98
- });
99
- it("succeeds with no Firestore reads when only allowAnyStatus is set", async () => {
100
- const ctx = await assertAuth(makeRequest(), { allowAnyStatus: true });
101
- expect(ctx.uid).toBe("uid-123");
102
- expect(ctx.userDoc).toBeUndefined();
103
- expect(mockDbDoc).not.toHaveBeenCalled();
104
- });
105
- });
106
- describe("Email verification", () => {
107
- it("throws failed-precondition when emailVerified required and token email_verified is false", async () => {
108
- await expect(assertAuth(makeRequest({ emailVerified: false }), {
109
- emailVerified: true,
110
- allowAnyStatus: true,
111
- })).rejects.toMatchObject({ code: "failed-precondition" });
112
- });
113
- it("carries the email-verification reason in details, with the message unchanged", async () => {
114
- const err = await assertAuth(makeRequest({ emailVerified: false }), {
115
- emailVerified: true,
116
- allowAnyStatus: true,
117
- }).catch((e) => e);
118
- expect(err).toBeInstanceOf(AuthAssertionError);
119
- expect(err).toMatchObject({
120
- code: "failed-precondition",
121
- message: "Email must be verified",
122
- details: { reason: EMAIL_VERIFICATION_REQUIRED_REASON },
123
- });
124
- expect(isEmailVerificationRequiredError(err)).toBe(true);
125
- expect(isAcceptanceRequiredError(err)).toBe(false);
126
- });
127
- it("succeeds when emailVerified required and token email_verified is true", async () => {
128
- await expect(assertAuth(makeRequest({ emailVerified: true }), {
129
- emailVerified: true,
130
- allowAnyStatus: true,
131
- })).resolves.toMatchObject({ uid: "uid-123" });
132
- });
133
- it("succeeds when allowUnverified is true even if email_verified is false", async () => {
134
- await expect(assertAuth(makeRequest({ emailVerified: false }), {
135
- emailVerified: true,
136
- allowUnverified: true,
137
- allowAnyStatus: true,
138
- })).resolves.toMatchObject({ uid: "uid-123" });
139
- });
140
- });
141
- describe("Not-banned check (default behavior)", () => {
142
- it("throws permission-denied when user status is banned", async () => {
143
- mockUserGet.mockResolvedValue({
144
- exists: true,
145
- data: () => ({ ...activeUserDoc, status: "banned" }),
146
- });
147
- await expect(assertAuth(makeRequest())).rejects.toMatchObject({
148
- code: "permission-denied",
149
- });
150
- });
151
- it("throws permission-denied when user status is suspended", async () => {
152
- mockUserGet.mockResolvedValue({
153
- exists: true,
154
- data: () => ({ ...activeUserDoc, status: "suspended" }),
155
- });
156
- await expect(assertAuth(makeRequest())).rejects.toMatchObject({
157
- code: "permission-denied",
158
- });
159
- });
160
- it("throws not-found when user doc does not exist", async () => {
161
- mockUserGet.mockResolvedValue({ exists: false, data: () => null });
162
- await expect(assertAuth(makeRequest())).rejects.toMatchObject({
163
- code: "not-found",
164
- });
165
- });
166
- it("succeeds when user doc has no status field", async () => {
167
- mockUserGet.mockResolvedValue({
168
- exists: true,
169
- data: () => ({ uid: "uid-123", displayName: "Test" }),
170
- });
171
- const ctx = await assertAuth(makeRequest());
172
- expect(ctx.uid).toBe("uid-123");
173
- expect(ctx.userDoc).toBeDefined();
174
- });
175
- it("succeeds when user status is active", async () => {
176
- const ctx = await assertAuth(makeRequest());
177
- expect(ctx.uid).toBe("uid-123");
178
- expect(ctx.userDoc).toMatchObject({ status: "active" });
179
- });
180
- it("skips user doc read entirely when allowAnyStatus is true", async () => {
181
- await assertAuth(makeRequest(), { allowAnyStatus: true });
182
- expect(mockUserGet).not.toHaveBeenCalled();
183
- });
184
- });
185
- describe("allowSuspended opt-in", () => {
186
- it("allows a suspended user when allowSuspended is true", async () => {
187
- mockUserGet.mockResolvedValue({
188
- exists: true,
189
- data: () => ({ ...activeUserDoc, status: "suspended" }),
190
- });
191
- const ctx = await assertAuth(makeRequest(), { allowSuspended: true });
192
- expect(ctx.uid).toBe("uid-123");
193
- expect(ctx.userDoc).toMatchObject({ status: "suspended" });
194
- });
195
- it("still blocks a banned user even when allowSuspended is true", async () => {
196
- mockUserGet.mockResolvedValue({
197
- exists: true,
198
- data: () => ({ ...activeUserDoc, status: "banned" }),
199
- });
200
- await expect(assertAuth(makeRequest(), { allowSuspended: true })).rejects.toMatchObject({ code: "permission-denied" });
201
- });
202
- it("still requires the user doc to exist when allowSuspended is true", async () => {
203
- mockUserGet.mockResolvedValue({ exists: false, data: () => null });
204
- await expect(assertAuth(makeRequest(), { allowSuspended: true })).rejects.toMatchObject({ code: "not-found" });
205
- });
206
- });
207
- describe("Admin check", () => {
208
- it("propagates the error when requireAdmin throws", async () => {
209
- mockRequireAdmin.mockRejectedValue(Object.assign(new Error("Administrator access required"), {
210
- code: "permission-denied",
211
- }));
212
- await expect(assertAuth(makeRequest(), {
213
- admin: { allowJrAdmin: false },
214
- allowAnyStatus: true,
215
- })).rejects.toMatchObject({ code: "permission-denied" });
216
- });
217
- it("succeeds and returns ctx when requireAdmin resolves", async () => {
218
- mockRequireAdmin.mockResolvedValue(undefined);
219
- const ctx = await assertAuth(makeRequest(), {
220
- admin: { allowJrAdmin: true },
221
- allowAnyStatus: true,
222
- });
223
- expect(ctx.uid).toBe("uid-123");
224
- expect(mockRequireAdmin).toHaveBeenCalledWith("uid-123", expect.any(Object), { allowJrAdmin: true });
225
- });
226
- it("populates ctx.admin with the requireAdmin result when an admin check runs", async () => {
227
- const adminMock = vi.fn().mockResolvedValue({ role: "superadmin" });
228
- const config = {
229
- firestore: () => fakeDbWithUser(),
230
- userProfilePath: (uid) => `userProfiles/${uid}`,
231
- getUserStatus: (user) => {
232
- if (user.status === "banned")
233
- return "banned";
234
- if (user.status === "suspended")
235
- return "suspended";
236
- return "active";
237
- },
238
- requireAdmin: adminMock,
239
- };
240
- const typedAssertAuth = createAssertAuth(config);
241
- const ctx = await typedAssertAuth(makeRequest(), {
242
- admin: { allowJrAdmin: true },
243
- });
244
- expect(ctx.admin).toEqual({ role: "superadmin" });
245
- });
246
- it("leaves ctx.admin undefined when no admin requirement is set", async () => {
247
- const ctx = await assertAuth(makeRequest());
248
- expect(ctx.admin).toBeUndefined();
249
- expect(mockRequireAdmin).not.toHaveBeenCalled();
250
- });
251
- });
252
- describe("Combined checks", () => {
253
- it("returns fully populated ctx when all checks pass", async () => {
254
- mockUserGet.mockResolvedValue({ exists: true, data: () => activeUserDoc });
255
- mockRequireAdmin.mockResolvedValue(undefined);
256
- const ctx = await assertAuth(makeRequest({ emailVerified: true }), {
257
- emailVerified: true,
258
- admin: { allowJrAdmin: true },
259
- });
260
- expect(ctx.uid).toBe("uid-123");
261
- expect(ctx.userDoc).toMatchObject({ status: "active" });
262
- expect(mockRequireAdmin).toHaveBeenCalled();
263
- });
264
- });
265
- // Acceptance-level gate (optional `acceptance` config). Every case below that expects a
266
- // refusal passes on the pre-gate factory only if the gate is ignored — i.e. it fails there.
267
- describe("Acceptance-level gate", () => {
268
- const CLAIM = "acceptedLevelClaim";
269
- function gatedAssertAuth(requiredLevel) {
270
- const requireAdmin = vi.fn().mockResolvedValue("admin");
271
- const config = {
272
- firestore: () => fakeDbWithUser(),
273
- userProfilePath: (uid) => `userProfiles/${uid}`,
274
- getUserStatus: (user) => (user.status === "banned" ? "banned" : "active"),
275
- requireAdmin: requireAdmin,
276
- acceptance: { claimKey: CLAIM, requiredLevel },
277
- };
278
- return { assert: createAssertAuth(config), requireAdmin };
279
- }
280
- function requestWithClaim(claim) {
281
- const req = makeRequest();
282
- if (claim !== undefined)
283
- req.auth.token[CLAIM] = claim;
284
- return req;
285
- }
286
- it("refuses a caller below the required level with a structured failed-precondition", async () => {
287
- const { assert } = gatedAssertAuth(() => 3);
288
- const err = await assert(requestWithClaim(2)).catch((e) => e);
289
- expect(err).toBeInstanceOf(AuthAssertionError);
290
- expect(err).toMatchObject({
291
- code: "failed-precondition",
292
- details: { reason: ACCEPTANCE_REQUIRED_REASON, requiredLevel: 3, acceptedLevel: 2 },
293
- });
294
- expect(isAcceptanceRequiredError(err)).toBe(true);
295
- expect(isEmailVerificationRequiredError(err)).toBe(false);
296
- });
297
- it("treats a missing claim as accepted level 0", async () => {
298
- const { assert } = gatedAssertAuth(() => 1);
299
- await expect(assert(requestWithClaim(undefined))).rejects.toMatchObject({
300
- details: { reason: ACCEPTANCE_REQUIRED_REASON, requiredLevel: 1, acceptedLevel: 0 },
301
- });
302
- });
303
- it.each([["a string", "5"], ["NaN", Number.NaN], ["a negative", -4], ["null", null]])("treats %s claim as accepted level 0 (fails closed)", async (_label, claim) => {
304
- const { assert } = gatedAssertAuth(() => 1);
305
- await expect(assert(requestWithClaim(claim))).rejects.toMatchObject({
306
- details: { acceptedLevel: 0 },
307
- });
308
- });
309
- it("passes a caller at or above the required level", async () => {
310
- const { assert } = gatedAssertAuth(() => 3);
311
- await expect(assert(requestWithClaim(3))).resolves.toMatchObject({ uid: "uid-123" });
312
- await expect(assert(requestWithClaim(7))).resolves.toMatchObject({ uid: "uid-123" });
313
- });
314
- it("awaits an async required-level reader", async () => {
315
- const { assert } = gatedAssertAuth(async () => 2);
316
- await expect(assert(requestWithClaim(1))).rejects.toMatchObject({ code: "failed-precondition" });
317
- });
318
- it.each([["zero", 0], ["NaN", Number.NaN], ["a negative", -1]])("requires nothing when the required level is %s", async (_label, level) => {
319
- const { assert } = gatedAssertAuth(() => level);
320
- await expect(assert(requestWithClaim(undefined))).resolves.toMatchObject({ uid: "uid-123" });
321
- });
322
- it("lets an allowUnaccepted callable through without reading the required level", async () => {
323
- const requiredLevel = vi.fn(() => 9);
324
- const { assert } = gatedAssertAuth(requiredLevel);
325
- await expect(assert(requestWithClaim(undefined), { allowUnaccepted: true })).resolves.toMatchObject({
326
- uid: "uid-123",
327
- });
328
- expect(requiredLevel).not.toHaveBeenCalled();
329
- });
330
- it("propagates a throwing required-level reader", async () => {
331
- const failure = new Error("config unavailable");
332
- const { assert } = gatedAssertAuth(() => {
333
- throw failure;
334
- });
335
- await expect(assert(requestWithClaim(5))).rejects.toBe(failure);
336
- });
337
- it("refuses before the admin check runs", async () => {
338
- const { assert, requireAdmin } = gatedAssertAuth(() => 2);
339
- await expect(assert(requestWithClaim(1), { admin: { allowJrAdmin: true }, allowAnyStatus: true })).rejects.toMatchObject({ code: "failed-precondition" });
340
- expect(requireAdmin).not.toHaveBeenCalled();
341
- });
342
- it("still gates a callable that skips the status check (allowAnyStatus)", async () => {
343
- const { assert } = gatedAssertAuth(() => 1);
344
- await expect(assert(requestWithClaim(0), { allowAnyStatus: true })).rejects.toMatchObject({
345
- code: "failed-precondition",
346
- });
347
- });
348
- it("keeps authentication first — an unauthenticated caller never reaches the gate", async () => {
349
- const requiredLevel = vi.fn(() => 1);
350
- const { assert } = gatedAssertAuth(requiredLevel);
351
- await expect(assert(makeUnauthRequest())).rejects.toMatchObject({ code: "unauthenticated" });
352
- expect(requiredLevel).not.toHaveBeenCalled();
353
- });
354
- it("is absent without config: an app that never sets `acceptance` behaves exactly as before", async () => {
355
- // The shared harness config has no `acceptance`; a token with no level claim still passes.
356
- await expect(assertAuth(makeRequest(), { allowUnaccepted: false })).resolves.toMatchObject({
357
- uid: "uid-123",
358
- });
359
- });
360
- it("leaves `details` undefined on every other rejection", async () => {
361
- const unauthenticated = (await assertAuth(makeUnauthRequest()).catch((e) => e));
362
- expect(unauthenticated.details).toBeUndefined();
363
- mockUserGet.mockResolvedValue({ exists: false, data: () => null });
364
- const missingProfile = (await assertAuth(makeRequest()).catch((e) => e));
365
- expect(missingProfile.code).toBe("not-found");
366
- expect(missingProfile.details).toBeUndefined();
367
- mockUserGet.mockResolvedValue({ exists: true, data: () => ({ ...activeUserDoc, status: "banned" }) });
368
- const banned = (await assertAuth(makeRequest()).catch((e) => e));
369
- expect(banned.code).toBe("permission-denied");
370
- expect(banned.details).toBeUndefined();
371
- });
372
- });
373
- //# sourceMappingURL=assertAuth.test.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"assertAuth.test.js","sourceRoot":"","sources":["../../src/server/assertAuth.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect, vi, beforeEach } from \"vitest\";\n\n// NOTE: there is deliberately NO `vi.mock(\"firebase-functions/v2/https\")` here.\n// assertAuth no longer constructs HttpsError — it throws AuthAssertionError and the\n// consuming app maps it (see authError.ts for why). The only remaining reference to\n// firebase-functions in assertAuth.ts is a TYPE import, which is erased at runtime.\nimport { createAssertAuth } from \"./assertAuth.js\";\nimport { AuthAssertionError } from \"./authError.js\";\nimport { ACCEPTANCE_REQUIRED_REASON, isAcceptanceRequiredError } from \"../acceptance.js\";\nimport {\n EMAIL_VERIFICATION_REQUIRED_REASON,\n isEmailVerificationRequiredError,\n} from \"../email-verification.js\";\nimport type { AssertAuthConfig, UserStatus } from \"./types.js\";\n\ntype TestUser = {\n uid: string;\n displayName?: string;\n status?: \"active\" | \"suspended\" | \"banned\";\n};\n\n\nfunction makeRequest(opts?: { uid?: string; emailVerified?: boolean }): any {\n const uid = opts?.uid ?? \"uid-123\";\n return {\n data: {},\n rawRequest: {},\n auth: {\n uid,\n token: { uid, email_verified: opts?.emailVerified ?? true },\n },\n };\n}\n\nfunction makeUnauthRequest(): any {\n return { data: {}, rawRequest: {}, auth: undefined };\n}\n\nconst activeUserDoc: TestUser = {\n uid: \"uid-123\",\n displayName: \"Test User\",\n status: \"active\",\n};\n\n// Minimal Firestore stub returning the active user doc, for tests that build\n// their own config instead of using the shared beforeEach harness.\nfunction fakeDbWithUser(): any {\n const userDocRef = {\n get: vi.fn().mockResolvedValue({ exists: true, data: () => activeUserDoc }),\n };\n return { doc: vi.fn(() => userDocRef) };\n}\n\n// --- harness ---------------------------------------------------------------\n\nlet mockUserGet: ReturnType<typeof vi.fn>;\nlet mockRequireAdmin: ReturnType<typeof vi.fn>;\nlet mockDbDoc: ReturnType<typeof vi.fn>;\nlet assertAuth: ReturnType<typeof createAssertAuth<TestUser>>;\n\nbeforeEach(() => {\n mockUserGet = vi.fn().mockResolvedValue({ exists: true, data: () => activeUserDoc });\n mockRequireAdmin = vi.fn().mockResolvedValue(undefined);\n\n const userDocRef = { get: mockUserGet };\n\n mockDbDoc = vi.fn(() => userDocRef);\n\n const fakeDb = { doc: mockDbDoc } as any;\n\n const config: AssertAuthConfig<TestUser> = {\n firestore: () => fakeDb,\n userProfilePath: (uid) => `userProfiles/${uid}`,\n getUserStatus: (user): UserStatus => {\n if (user.status === \"banned\") return \"banned\";\n if (user.status === \"suspended\") return \"suspended\";\n return \"active\";\n },\n requireAdmin: mockRequireAdmin as any,\n };\n\n assertAuth = createAssertAuth<TestUser>(config);\n});\n\n// --- tests -----------------------------------------------------------------\n\n// REGRESSION GUARD (2026-07-20). assertAuth must throw a plain, framework-free\n// AuthAssertionError — never an HttpsError built inside this package. firebase-functions\n// 7.2.5 ships two distinct HttpsError classes (ESM entry vs the CJS callable runtime), so\n// a package-built HttpsError fails the runtime's `instanceof` check and onCall converts a\n// correct 400 into a 500 INTERNAL. Since assertAuth is the first line of every callable,\n// that silently broke EVERY auth rejection in deployed environments. If someone\n// reintroduces `new HttpsError(...)` here, these assertions fail.\ndescribe(\"throws a framework-free typed error (never a package-built HttpsError)\", () => {\n it(\"rejects with an AuthAssertionError instance carrying the HttpsError code string\", async () => {\n const err = await assertAuth(makeUnauthRequest()).catch((e: unknown) => e);\n expect(err).toBeInstanceOf(AuthAssertionError);\n expect((err as AuthAssertionError).name).toBe(\"AuthAssertionError\");\n expect((err as AuthAssertionError).code).toBe(\"unauthenticated\");\n });\n\n it(\"does not decorate the error with HttpsError's own shape\", async () => {\n const err = (await assertAuth(makeUnauthRequest()).catch(\n (e: unknown) => e\n )) as AuthAssertionError & { httpErrorCode?: unknown; toJSON?: unknown };\n // `httpErrorCode`/`toJSON` are HttpsError internals — their absence proves the\n // package is not constructing one, which is the whole point of the fix.\n expect(err.httpErrorCode).toBeUndefined();\n expect(err.toJSON).toBeUndefined();\n });\n\n it(\"uses AuthAssertionError for the email-verification rejection too\", async () => {\n const err = await assertAuth(makeRequest({ emailVerified: false }), {\n emailVerified: true,\n allowAnyStatus: true,\n }).catch((e: unknown) => e);\n expect(err).toBeInstanceOf(AuthAssertionError);\n expect((err as AuthAssertionError).code).toBe(\"failed-precondition\");\n });\n});\n\ndescribe(\"Authentication\", () => {\n it(\"throws unauthenticated when request.auth is absent\", async () => {\n await expect(assertAuth(makeUnauthRequest())).rejects.toMatchObject({\n code: \"unauthenticated\",\n });\n });\n\n it(\"succeeds with no Firestore reads when only allowAnyStatus is set\", async () => {\n const ctx = await assertAuth(makeRequest(), { allowAnyStatus: true });\n expect(ctx.uid).toBe(\"uid-123\");\n expect(ctx.userDoc).toBeUndefined();\n expect(mockDbDoc).not.toHaveBeenCalled();\n });\n});\n\ndescribe(\"Email verification\", () => {\n it(\"throws failed-precondition when emailVerified required and token email_verified is false\", async () => {\n await expect(\n assertAuth(makeRequest({ emailVerified: false }), {\n emailVerified: true,\n allowAnyStatus: true,\n })\n ).rejects.toMatchObject({ code: \"failed-precondition\" });\n });\n\n it(\"carries the email-verification reason in details, with the message unchanged\", async () => {\n const err = await assertAuth(makeRequest({ emailVerified: false }), {\n emailVerified: true,\n allowAnyStatus: true,\n }).catch((e: unknown) => e);\n expect(err).toBeInstanceOf(AuthAssertionError);\n expect(err).toMatchObject({\n code: \"failed-precondition\",\n message: \"Email must be verified\",\n details: { reason: EMAIL_VERIFICATION_REQUIRED_REASON },\n });\n expect(isEmailVerificationRequiredError(err)).toBe(true);\n expect(isAcceptanceRequiredError(err)).toBe(false);\n });\n\n it(\"succeeds when emailVerified required and token email_verified is true\", async () => {\n await expect(\n assertAuth(makeRequest({ emailVerified: true }), {\n emailVerified: true,\n allowAnyStatus: true,\n })\n ).resolves.toMatchObject({ uid: \"uid-123\" });\n });\n\n it(\"succeeds when allowUnverified is true even if email_verified is false\", async () => {\n await expect(\n assertAuth(makeRequest({ emailVerified: false }), {\n emailVerified: true,\n allowUnverified: true,\n allowAnyStatus: true,\n })\n ).resolves.toMatchObject({ uid: \"uid-123\" });\n });\n});\n\ndescribe(\"Not-banned check (default behavior)\", () => {\n it(\"throws permission-denied when user status is banned\", async () => {\n mockUserGet.mockResolvedValue({\n exists: true,\n data: () => ({ ...activeUserDoc, status: \"banned\" }),\n });\n await expect(assertAuth(makeRequest())).rejects.toMatchObject({\n code: \"permission-denied\",\n });\n });\n\n it(\"throws permission-denied when user status is suspended\", async () => {\n mockUserGet.mockResolvedValue({\n exists: true,\n data: () => ({ ...activeUserDoc, status: \"suspended\" }),\n });\n await expect(assertAuth(makeRequest())).rejects.toMatchObject({\n code: \"permission-denied\",\n });\n });\n\n it(\"throws not-found when user doc does not exist\", async () => {\n mockUserGet.mockResolvedValue({ exists: false, data: () => null });\n await expect(assertAuth(makeRequest())).rejects.toMatchObject({\n code: \"not-found\",\n });\n });\n\n it(\"succeeds when user doc has no status field\", async () => {\n mockUserGet.mockResolvedValue({\n exists: true,\n data: () => ({ uid: \"uid-123\", displayName: \"Test\" }),\n });\n const ctx = await assertAuth(makeRequest());\n expect(ctx.uid).toBe(\"uid-123\");\n expect(ctx.userDoc).toBeDefined();\n });\n\n it(\"succeeds when user status is active\", async () => {\n const ctx = await assertAuth(makeRequest());\n expect(ctx.uid).toBe(\"uid-123\");\n expect(ctx.userDoc).toMatchObject({ status: \"active\" });\n });\n\n it(\"skips user doc read entirely when allowAnyStatus is true\", async () => {\n await assertAuth(makeRequest(), { allowAnyStatus: true });\n expect(mockUserGet).not.toHaveBeenCalled();\n });\n});\n\ndescribe(\"allowSuspended opt-in\", () => {\n it(\"allows a suspended user when allowSuspended is true\", async () => {\n mockUserGet.mockResolvedValue({\n exists: true,\n data: () => ({ ...activeUserDoc, status: \"suspended\" }),\n });\n const ctx = await assertAuth(makeRequest(), { allowSuspended: true });\n expect(ctx.uid).toBe(\"uid-123\");\n expect(ctx.userDoc).toMatchObject({ status: \"suspended\" });\n });\n\n it(\"still blocks a banned user even when allowSuspended is true\", async () => {\n mockUserGet.mockResolvedValue({\n exists: true,\n data: () => ({ ...activeUserDoc, status: \"banned\" }),\n });\n await expect(\n assertAuth(makeRequest(), { allowSuspended: true })\n ).rejects.toMatchObject({ code: \"permission-denied\" });\n });\n\n it(\"still requires the user doc to exist when allowSuspended is true\", async () => {\n mockUserGet.mockResolvedValue({ exists: false, data: () => null });\n await expect(\n assertAuth(makeRequest(), { allowSuspended: true })\n ).rejects.toMatchObject({ code: \"not-found\" });\n });\n});\n\n\ndescribe(\"Admin check\", () => {\n it(\"propagates the error when requireAdmin throws\", async () => {\n mockRequireAdmin.mockRejectedValue(\n Object.assign(new Error(\"Administrator access required\"), {\n code: \"permission-denied\",\n })\n );\n await expect(\n assertAuth(makeRequest(), {\n admin: { allowJrAdmin: false },\n allowAnyStatus: true,\n })\n ).rejects.toMatchObject({ code: \"permission-denied\" });\n });\n\n it(\"succeeds and returns ctx when requireAdmin resolves\", async () => {\n mockRequireAdmin.mockResolvedValue(undefined);\n const ctx = await assertAuth(makeRequest(), {\n admin: { allowJrAdmin: true },\n allowAnyStatus: true,\n });\n expect(ctx.uid).toBe(\"uid-123\");\n expect(mockRequireAdmin).toHaveBeenCalledWith(\n \"uid-123\",\n expect.any(Object),\n { allowJrAdmin: true }\n );\n });\n\n it(\"populates ctx.admin with the requireAdmin result when an admin check runs\", async () => {\n type AdminResult = { role: string };\n const adminMock = vi.fn().mockResolvedValue({ role: \"superadmin\" });\n const config: AssertAuthConfig<TestUser, AdminResult> = {\n firestore: () => fakeDbWithUser(),\n userProfilePath: (uid) => `userProfiles/${uid}`,\n getUserStatus: (user): UserStatus => {\n if (user.status === \"banned\") return \"banned\";\n if (user.status === \"suspended\") return \"suspended\";\n return \"active\";\n },\n requireAdmin: adminMock as any,\n };\n const typedAssertAuth = createAssertAuth<TestUser, AdminResult>(config);\n\n const ctx = await typedAssertAuth(makeRequest(), {\n admin: { allowJrAdmin: true },\n });\n\n expect(ctx.admin).toEqual({ role: \"superadmin\" });\n });\n\n it(\"leaves ctx.admin undefined when no admin requirement is set\", async () => {\n const ctx = await assertAuth(makeRequest());\n expect(ctx.admin).toBeUndefined();\n expect(mockRequireAdmin).not.toHaveBeenCalled();\n });\n});\n\ndescribe(\"Combined checks\", () => {\n it(\"returns fully populated ctx when all checks pass\", async () => {\n mockUserGet.mockResolvedValue({ exists: true, data: () => activeUserDoc });\n mockRequireAdmin.mockResolvedValue(undefined);\n\n const ctx = await assertAuth(makeRequest({ emailVerified: true }), {\n emailVerified: true,\n admin: { allowJrAdmin: true },\n });\n\n expect(ctx.uid).toBe(\"uid-123\");\n expect(ctx.userDoc).toMatchObject({ status: \"active\" });\n expect(mockRequireAdmin).toHaveBeenCalled();\n });\n});\n\n// Acceptance-level gate (optional `acceptance` config). Every case below that expects a\n// refusal passes on the pre-gate factory only if the gate is ignored — i.e. it fails there.\ndescribe(\"Acceptance-level gate\", () => {\n const CLAIM = \"acceptedLevelClaim\";\n\n function gatedAssertAuth(requiredLevel: () => number | Promise<number>) {\n const requireAdmin = vi.fn().mockResolvedValue(\"admin\");\n const config: AssertAuthConfig<TestUser, string> = {\n firestore: () => fakeDbWithUser(),\n userProfilePath: (uid) => `userProfiles/${uid}`,\n getUserStatus: (user): UserStatus => (user.status === \"banned\" ? \"banned\" : \"active\"),\n requireAdmin: requireAdmin as any,\n acceptance: { claimKey: CLAIM, requiredLevel },\n };\n return { assert: createAssertAuth<TestUser, string>(config), requireAdmin };\n }\n\n function requestWithClaim(claim: unknown): any {\n const req = makeRequest();\n if (claim !== undefined) req.auth.token[CLAIM] = claim;\n return req;\n }\n\n it(\"refuses a caller below the required level with a structured failed-precondition\", async () => {\n const { assert } = gatedAssertAuth(() => 3);\n const err = await assert(requestWithClaim(2)).catch((e: unknown) => e);\n expect(err).toBeInstanceOf(AuthAssertionError);\n expect(err).toMatchObject({\n code: \"failed-precondition\",\n details: { reason: ACCEPTANCE_REQUIRED_REASON, requiredLevel: 3, acceptedLevel: 2 },\n });\n expect(isAcceptanceRequiredError(err)).toBe(true);\n expect(isEmailVerificationRequiredError(err)).toBe(false);\n });\n\n it(\"treats a missing claim as accepted level 0\", async () => {\n const { assert } = gatedAssertAuth(() => 1);\n await expect(assert(requestWithClaim(undefined))).rejects.toMatchObject({\n details: { reason: ACCEPTANCE_REQUIRED_REASON, requiredLevel: 1, acceptedLevel: 0 },\n });\n });\n\n it.each([[\"a string\", \"5\"], [\"NaN\", Number.NaN], [\"a negative\", -4], [\"null\", null]])(\n \"treats %s claim as accepted level 0 (fails closed)\",\n async (_label, claim) => {\n const { assert } = gatedAssertAuth(() => 1);\n await expect(assert(requestWithClaim(claim))).rejects.toMatchObject({\n details: { acceptedLevel: 0 },\n });\n }\n );\n\n it(\"passes a caller at or above the required level\", async () => {\n const { assert } = gatedAssertAuth(() => 3);\n await expect(assert(requestWithClaim(3))).resolves.toMatchObject({ uid: \"uid-123\" });\n await expect(assert(requestWithClaim(7))).resolves.toMatchObject({ uid: \"uid-123\" });\n });\n\n it(\"awaits an async required-level reader\", async () => {\n const { assert } = gatedAssertAuth(async () => 2);\n await expect(assert(requestWithClaim(1))).rejects.toMatchObject({ code: \"failed-precondition\" });\n });\n\n it.each([[\"zero\", 0], [\"NaN\", Number.NaN], [\"a negative\", -1]])(\n \"requires nothing when the required level is %s\",\n async (_label, level) => {\n const { assert } = gatedAssertAuth(() => level);\n await expect(assert(requestWithClaim(undefined))).resolves.toMatchObject({ uid: \"uid-123\" });\n }\n );\n\n it(\"lets an allowUnaccepted callable through without reading the required level\", async () => {\n const requiredLevel = vi.fn(() => 9);\n const { assert } = gatedAssertAuth(requiredLevel);\n await expect(assert(requestWithClaim(undefined), { allowUnaccepted: true })).resolves.toMatchObject({\n uid: \"uid-123\",\n });\n expect(requiredLevel).not.toHaveBeenCalled();\n });\n\n it(\"propagates a throwing required-level reader\", async () => {\n const failure = new Error(\"config unavailable\");\n const { assert } = gatedAssertAuth(() => {\n throw failure;\n });\n await expect(assert(requestWithClaim(5))).rejects.toBe(failure);\n });\n\n it(\"refuses before the admin check runs\", async () => {\n const { assert, requireAdmin } = gatedAssertAuth(() => 2);\n await expect(\n assert(requestWithClaim(1), { admin: { allowJrAdmin: true }, allowAnyStatus: true })\n ).rejects.toMatchObject({ code: \"failed-precondition\" });\n expect(requireAdmin).not.toHaveBeenCalled();\n });\n\n it(\"still gates a callable that skips the status check (allowAnyStatus)\", async () => {\n const { assert } = gatedAssertAuth(() => 1);\n await expect(assert(requestWithClaim(0), { allowAnyStatus: true })).rejects.toMatchObject({\n code: \"failed-precondition\",\n });\n });\n\n it(\"keeps authentication first — an unauthenticated caller never reaches the gate\", async () => {\n const requiredLevel = vi.fn(() => 1);\n const { assert } = gatedAssertAuth(requiredLevel);\n await expect(assert(makeUnauthRequest())).rejects.toMatchObject({ code: \"unauthenticated\" });\n expect(requiredLevel).not.toHaveBeenCalled();\n });\n\n it(\"is absent without config: an app that never sets `acceptance` behaves exactly as before\", async () => {\n // The shared harness config has no `acceptance`; a token with no level claim still passes.\n await expect(assertAuth(makeRequest(), { allowUnaccepted: false })).resolves.toMatchObject({\n uid: \"uid-123\",\n });\n });\n\n it(\"leaves `details` undefined on every other rejection\", async () => {\n const unauthenticated = (await assertAuth(makeUnauthRequest()).catch((e: unknown) => e)) as AuthAssertionError;\n expect(unauthenticated.details).toBeUndefined();\n\n mockUserGet.mockResolvedValue({ exists: false, data: () => null });\n const missingProfile = (await assertAuth(makeRequest()).catch((e: unknown) => e)) as AuthAssertionError;\n expect(missingProfile.code).toBe(\"not-found\");\n expect(missingProfile.details).toBeUndefined();\n\n mockUserGet.mockResolvedValue({ exists: true, data: () => ({ ...activeUserDoc, status: \"banned\" }) });\n const banned = (await assertAuth(makeRequest()).catch((e: unknown) => e)) as AuthAssertionError;\n expect(banned.code).toBe(\"permission-denied\");\n expect(banned.details).toBeUndefined();\n });\n});\n"]}