Skip to content

Commit d3d453d

Browse files
mjudeikisclaude
andauthored
Centralize portal auth session-expiry detection (#327)
* Centralize auth session-expiry detection in the portal Token-expiry detection was centralized only for GraphQL (via a window event); the ~25 hand-rolled REST fetch calls in the providers/admin/ tenant stores each re-checked status and silently swallowed an expired token, so some calls redirected to login while others left the user "logged in" staring at a raw error. Introduce portal/src/auth/session.ts as the single source of truth: - SESSION_EXPIRED_EVENT + notifySessionExpired() (latched so a page that fans out many parallel 401s fires one logout()+redirect, not N) - getBearerToken(): shared load-or-refresh core; signals expiry itself when an expired token can't be refreshed - authFetch(): the single entrypoint for authenticated hub calls — injects a refreshed bearer (+ optional X-Kedge-Org/Workspace via {tenant:true}) and fires notifySessionExpired() on a 401 401 anywhere (GraphQL or REST) now drops the session through one path. 403/404 stay legitimate authz/not-found answers (e.g. /api/admin/* for a non-admin); the GraphQL gateway's special 403/404 cluster-route handling is kept where it belongs. The module depends only on @/auth/token + the DOM, preserving the no-router/no-store constraint that keeps it out of the provider micro-frontend bundles. Wire useGraphQL, the auth store (getValidToken delegates to the shared core; logins re-arm the latch), and the providers/admin/tenant stores through it; delete the three duplicated authHeader helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * authFetch: merge headers via the Headers API Copilot review (PR #327): authFetch typed opts.headers as HeadersInit but merged it with a plain spread/cast to Record<string,string>, which would silently drop headers passed as a Headers instance or [name,value][]. All current callers pass plain objects, so not a live bug, but fix the footgun: build with the Headers API so every HeadersInit shape merges, with Authorization set last so it always wins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3ca062a commit d3d453d

6 files changed

Lines changed: 198 additions & 169 deletions

File tree

portal/src/auth/session.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
}

portal/src/composables/useGraphQL.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,13 @@ import { ref, watchEffect, onUnmounted, type Ref } from 'vue'
22
import { createGraphQLClient } from '@/graphql/client'
33
import { useAuthStore } from '@/stores/auth'
44
import { useTenantStore } from '@/stores/tenant'
5+
import { notifySessionExpired } from '@/auth/session'
56
import type { CombinedError } from '@urql/vue'
67

7-
// Window event the shell listens for to drop a dead session and bounce
8-
// to /login (see portal/src/App.vue). Decoupled from a direct
9-
// `@/router` import on purpose: this composable is reused by provider
10-
// micro-frontends via the `@kedge-edges` alias, and a static
11-
// `import { router } from '@/router'` dragged the ENTIRE portal SPA
12-
// (every route page → AppLayout → TerminalDock) into each provider's
13-
// IIFE bundle. That bundled TerminalDock's terminal-session store got
14-
// shadowed by the provider's dispatch-only terminal-adapter, killing
15-
// the SSH-terminal bridge in production builds. A window event keeps the
16-
// shell's redirect behaviour while staying a no-op inside providers
17-
// (they register no listener).
18-
export const SESSION_EXPIRED_EVENT = 'kedge-session-expired'
8+
// SESSION_EXPIRED_EVENT and the dead-session detection now live in
9+
// @/auth/session so the REST stores share the exact same redirect path.
10+
// Re-exported here for the existing importers (App.vue, tests).
11+
export { SESSION_EXPIRED_EVENT } from '@/auth/session'
1912

2013
interface UseQueryResult<T> {
2114
data: Ref<T | null>
@@ -50,9 +43,9 @@ function handleSessionFailure(err: CombinedError | undefined): boolean {
5043

5144
// Hand the actual logout + redirect to the shell (App.vue) so this
5245
// composable never has to touch `@/router` or assume a portal-shaped
53-
// auth store — see SESSION_EXPIRED_EVENT above. No-op inside provider
46+
// auth store — see @/auth/session. No-op inside provider
5447
// micro-frontends, where a dead query shouldn't tear down the host.
55-
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT))
48+
notifySessionExpired()
5649
return true
5750
}
5851

portal/src/stores/admin.ts

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,7 @@
44
import { defineStore } from 'pinia'
55
import { ref } from 'vue'
66

7-
import { STORAGE_KEYS } from '@/lib/constants'
8-
9-
// authHeader mirrors the tenant store: read the bearer from the persisted auth
10-
// blob directly to avoid an import cycle with the auth store.
11-
function authHeader(): Record<string, string> {
12-
try {
13-
const raw = localStorage.getItem(STORAGE_KEYS.auth)
14-
if (!raw) return {}
15-
const parsed = JSON.parse(raw) as { idToken?: string }
16-
if (parsed.idToken) return { Authorization: `Bearer ${parsed.idToken}` }
17-
} catch {
18-
/* ignore */
19-
}
20-
return {}
21-
}
7+
import { authFetch } from '@/auth/session'
228

239
export interface AdminUser {
2410
name: string
@@ -77,7 +63,7 @@ export const useAdminStore = defineStore('admin', () => {
7763
// sessions stay quiet.
7864
async function checkAccess(): Promise<boolean> {
7965
try {
80-
const resp = await fetch('/api/admin/access', { headers: { ...authHeader() } })
66+
const resp = await authFetch('/api/admin/access')
8167
isAdmin.value = resp.ok
8268
} catch {
8369
isAdmin.value = false
@@ -86,7 +72,7 @@ export const useAdminStore = defineStore('admin', () => {
8672
}
8773

8874
async function get<T>(path: string): Promise<T[]> {
89-
const resp = await fetch(path, { headers: { ...authHeader() } })
75+
const resp = await authFetch(path)
9076
if (resp.status === 403) {
9177
forbidden.value = true
9278
throw new Error('forbidden')
@@ -124,9 +110,9 @@ export const useAdminStore = defineStore('admin', () => {
124110
// The hub's Provider controller then provisions the sub-workspace +
125111
// ServiceAccount + kubeconfig Secret. Declarative — no imperative onboard.
126112
async function createProvider(name: string, displayName: string): Promise<void> {
127-
const resp = await fetch('/api/admin/providers', {
113+
const resp = await authFetch('/api/admin/providers', {
128114
method: 'POST',
129-
headers: { ...authHeader(), 'Content-Type': 'application/json' },
115+
headers: { 'Content-Type': 'application/json' },
130116
body: JSON.stringify({ name, displayName }),
131117
})
132118
if (resp.status === 403) {
@@ -139,9 +125,8 @@ export const useAdminStore = defineStore('admin', () => {
139125
// deleteProvider removes the Provider object; the controller's finalizer
140126
// tears down the provisioned sub-workspace.
141127
async function deleteProvider(name: string): Promise<void> {
142-
const resp = await fetch(`/api/admin/providers/${encodeURIComponent(name)}`, {
128+
const resp = await authFetch(`/api/admin/providers/${encodeURIComponent(name)}`, {
143129
method: 'DELETE',
144-
headers: { ...authHeader() },
145130
})
146131
if (resp.status === 403) {
147132
forbidden.value = true
@@ -156,9 +141,7 @@ export const useAdminStore = defineStore('admin', () => {
156141
// Secret the Provider controller wrote into root:kedge:system:providers) and
157142
// triggers a browser download.
158143
async function downloadProviderKubeconfig(name: string): Promise<void> {
159-
const resp = await fetch(`/api/admin/providers/${encodeURIComponent(name)}/kubeconfig`, {
160-
headers: { ...authHeader() },
161-
})
144+
const resp = await authFetch(`/api/admin/providers/${encodeURIComponent(name)}/kubeconfig`)
162145
if (resp.status === 403) {
163146
forbidden.value = true
164147
throw new Error('forbidden')

portal/src/stores/auth.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { defineStore } from 'pinia'
22
import { ref, computed } from 'vue'
33
import type { AuthMode, HealthzResponse, StoredAuth } from '@/auth/types'
4-
import { loadAuth, saveAuth, clearAuth, isExpired, refreshToken, parseClusterName } from '@/auth/token'
4+
import { loadAuth, saveAuth, clearAuth, parseClusterName } from '@/auth/token'
5+
import { getBearerToken, resetSessionExpired } from '@/auth/session'
56
import { fetchHealthz, loginWithToken } from '@/lib/api'
67
import { STORAGE_KEYS } from '@/lib/constants'
78

@@ -53,6 +54,7 @@ export const useAuthStore = defineStore('auth', () => {
5354
token.value = auth.idToken
5455
user.value = { email: auth.email, userId: auth.userId }
5556
clusterName.value = auth.clusterName
57+
resetSessionExpired()
5658
} catch (e) {
5759
error.value = e instanceof Error ? e.message : 'Login failed'
5860
throw e
@@ -66,21 +68,19 @@ export const useAuthStore = defineStore('auth', () => {
6668
token.value = auth.idToken
6769
user.value = { email: auth.email, userId: auth.userId }
6870
clusterName.value = auth.clusterName
71+
resetSessionExpired()
6972
}
7073

7174
async function getValidToken(): Promise<string> {
72-
const stored = loadAuth()
73-
if (!stored) throw new Error('Not authenticated')
74-
75-
if (!isExpired(stored)) return stored.idToken
76-
77-
const refreshed = await refreshToken(stored)
78-
if (refreshed) {
79-
token.value = refreshed.idToken
80-
return refreshed.idToken
75+
// Shared load/refresh core (also used by REST authFetch). It fires
76+
// SESSION_EXPIRED_EVENT itself when an expired token can't be
77+
// refreshed, so the shell already redirects; we still clear local
78+
// state and throw so in-flight callers stop.
79+
const valid = await getBearerToken()
80+
if (valid) {
81+
token.value = valid
82+
return valid
8183
}
82-
83-
// Token expired and refresh failed — force re-login
8484
logout()
8585
throw new Error('Session expired')
8686
}

0 commit comments

Comments
 (0)