|
| 1 | +// Single source of truth for "the session is dead — bounce to /login". |
| 2 | +// |
| 3 | +// Before this module the detection was scattered: the GraphQL layer had |
| 4 | +// its own 401/403/404 handler, while every REST `fetch` in the stores |
| 5 | +// re-implemented its own `if (!res.ok)` check and silently swallowed an |
| 6 | +// expired token (the user kept seeing errors instead of being sent to |
| 7 | +// login). Now both paths funnel through here: |
| 8 | +// - REST callers use authFetch(), which injects the bearer and fires |
| 9 | +// notifySessionExpired() on a 401. |
| 10 | +// - The GraphQL handler (composables/useGraphQL.ts) and the auth store |
| 11 | +// call notifySessionExpired() directly. |
| 12 | +// |
| 13 | +// The redirect itself lives in the shell (App.vue), reached via a window |
| 14 | +// event. We deliberately do NOT import `@/router` or any Pinia store |
| 15 | +// here: this module is pulled into provider micro-frontend bundles via |
| 16 | +// the `@kedge-edges` alias, and a static router/store import drags the |
| 17 | +// entire portal SPA into each provider's IIFE (see the long note in |
| 18 | +// composables/useGraphQL.ts). Depending only on `@/auth/token` (pure |
| 19 | +// functions) and the DOM keeps this leaf-level and cycle-free. |
| 20 | +import { loadAuth, isExpired, refreshToken } from '@/auth/token' |
| 21 | + |
| 22 | +// Window event the shell listens for to drop a dead session and redirect |
| 23 | +// to /login (see portal/src/App.vue). No-op inside provider |
| 24 | +// micro-frontends, which register no listener. |
| 25 | +export const SESSION_EXPIRED_EVENT = 'kedge-session-expired' |
| 26 | + |
| 27 | +// One page load can fan out a dozen authenticated requests (provider |
| 28 | +// list + admin probe + N GraphQL queries). When the token dies they all |
| 29 | +// come back 401 at once; without this latch each one would fire the event |
| 30 | +// and race a separate logout()+router.replace(). Latch until the next |
| 31 | +// successful login re-arms it via resetSessionExpired(). |
| 32 | +let notified = false |
| 33 | + |
| 34 | +// notifySessionExpired signals the shell that the persisted session can |
| 35 | +// no longer authenticate the user, exactly once per dead session. |
| 36 | +export function notifySessionExpired(): void { |
| 37 | + if (notified) return |
| 38 | + notified = true |
| 39 | + window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT)) |
| 40 | +} |
| 41 | + |
| 42 | +// resetSessionExpired re-arms the latch. Call it on a successful login so |
| 43 | +// a later expiry triggers the redirect again. |
| 44 | +export function resetSessionExpired(): void { |
| 45 | + notified = false |
| 46 | +} |
| 47 | + |
| 48 | +// getBearerToken returns a usable bearer, refreshing an expired one |
| 49 | +// in-place. Returns null when there is no session, or when an expired |
| 50 | +// token can't be refreshed — in the latter case it also fires |
| 51 | +// notifySessionExpired(), so callers don't have to detect dead sessions |
| 52 | +// themselves. This is the store-free core; useAuthStore.getValidToken() |
| 53 | +// wraps it to also sync the reactive token ref. |
| 54 | +export async function getBearerToken(): Promise<string | null> { |
| 55 | + const stored = loadAuth() |
| 56 | + if (!stored) return null |
| 57 | + if (!isExpired(stored)) return stored.idToken |
| 58 | + |
| 59 | + const refreshed = await refreshToken(stored) |
| 60 | + if (refreshed) return refreshed.idToken |
| 61 | + |
| 62 | + // Expired and unrefreshable: the session is dead. |
| 63 | + notifySessionExpired() |
| 64 | + return null |
| 65 | +} |
| 66 | + |
| 67 | +// Tenant selection persisted by stores/tenant.ts. Kept as a literal (not |
| 68 | +// imported) for the same cycle-avoidance reason as the rest of this file. |
| 69 | +const TENANT_STORAGE_KEY = 'kedge:portal:tenant' |
| 70 | + |
| 71 | +interface AuthHeaderOptions { |
| 72 | + // tenant: include X-Kedge-Org / X-Kedge-Workspace from the sidebar |
| 73 | + // selection so workspace-scoped hub endpoints (/api/orgs/.../providers) |
| 74 | + // target the workspace the user is viewing. The hub re-verifies these |
| 75 | + // against the caller's membership, so they can't be spoofed. |
| 76 | + tenant?: boolean |
| 77 | +} |
| 78 | + |
| 79 | +function tenantHeaders(): Record<string, string> { |
| 80 | + const h: Record<string, string> = {} |
| 81 | + try { |
| 82 | + const raw = localStorage.getItem(TENANT_STORAGE_KEY) |
| 83 | + if (raw) { |
| 84 | + const t = JSON.parse(raw) as { orgUUID?: string | null; workspaceUUID?: string | null } |
| 85 | + if (t.orgUUID) h['X-Kedge-Org'] = t.orgUUID |
| 86 | + if (t.workspaceUUID) h['X-Kedge-Workspace'] = t.workspaceUUID |
| 87 | + } |
| 88 | + } catch { |
| 89 | + /* ignore parse errors — header is best-effort */ |
| 90 | + } |
| 91 | + return h |
| 92 | +} |
| 93 | + |
| 94 | +// authFetch is the single entrypoint for authenticated REST calls to the |
| 95 | +// hub. It injects a refreshed bearer (and, when opts.tenant is set, the |
| 96 | +// org/workspace headers) and centrally detects a dead session: a 401 |
| 97 | +// means the token was rejected, so it fires notifySessionExpired() before |
| 98 | +// returning the response. Callers keep their own `if (!res.ok)` handling |
| 99 | +// for endpoint-specific errors. |
| 100 | +// |
| 101 | +// 403 and 404 are intentionally NOT treated as session failures here: |
| 102 | +// for an authenticated user they're legitimate authorization / not-found |
| 103 | +// answers (e.g. /api/admin/* returns 403 to a non-admin), and logging the |
| 104 | +// user out on them would be wrong. (The GraphQL gateway is the exception — |
| 105 | +// there a 403/404 means the cluster route itself is gone — so that nuance |
| 106 | +// stays in composables/useGraphQL.ts.) |
| 107 | +export async function authFetch( |
| 108 | + path: string, |
| 109 | + opts: RequestInit & AuthHeaderOptions = {}, |
| 110 | +): Promise<Response> { |
| 111 | + const { tenant, headers, ...init } = opts |
| 112 | + const token = await getBearerToken() |
| 113 | + |
| 114 | + // Build via the Headers API so every HeadersInit shape a caller might |
| 115 | + // pass (plain object, [name,value][], or a Headers instance) is merged |
| 116 | + // correctly — a plain spread/cast would silently drop the latter two. |
| 117 | + // Order matters: tenant headers first, then caller headers (so a caller |
| 118 | + // can override them), then Authorization last so it always wins. |
| 119 | + const merged = new Headers(tenant ? tenantHeaders() : undefined) |
| 120 | + if (headers) new Headers(headers).forEach((value, key) => merged.set(key, value)) |
| 121 | + if (token) merged.set('Authorization', `Bearer ${token}`) |
| 122 | + |
| 123 | + const res = await fetch(path, { |
| 124 | + credentials: 'same-origin', |
| 125 | + ...init, |
| 126 | + headers: merged, |
| 127 | + }) |
| 128 | + |
| 129 | + if (res.status === 401) notifySessionExpired() |
| 130 | + return res |
| 131 | +} |
0 commit comments