diff --git a/Dockerfile b/Dockerfile index 55d2aaf..d6fbf16 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,18 +12,15 @@ RUN pnpm run build FROM nginx:1.27-alpine -# `envsubst` is provided by gettext; the official nginx:alpine image already -# includes it. We list it here defensively in case the base image ever drops it. -RUN apk add --no-cache gettext - COPY --from=builder /app/dist /usr/share/nginx/html COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh # nginx config: security-headers snippet (included at server level AND inside # any location block that declares its own add_header — nginx replaces rather -# than merges across levels) + default.conf template that the entrypoint -# renders at container start (envsubst on ${CSP_REMOTE}). +# than merges across levels) + default.conf that the entrypoint copies into +# conf.d at container start. No runtime templating anymore (the SPA is +# same-origin only), but the file keeps its .template name and location. RUN mkdir -p /etc/nginx/snippets COPY nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template diff --git a/docker-compose.yml b/docker-compose.yml index 195aee9..0479f05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,9 @@ services: insight-front: + # Serves the static SPA only. Auth (/auth/*) and the API (/api/*) are + # fronted by the nginx `gateway` in front of this container, so no OIDC + # env is needed here anymore. build: . ports: - "8080:80" - environment: - OIDC_ISSUER: ${OIDC_ISSUER:-} - OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index df3fae8..fd4f09a 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,125 +1,17 @@ #!/bin/sh set -e -# Escape a value for safe interpolation inside a JavaScript string literal -# wrapped in double quotes. Backslash MUST be first so the other escapes -# we add aren't themselves doubled. Newlines / CR aren't realistic in an -# OIDC issuer URL or client_id, but if anything weird ever creeps in via -# env, we reject the value at validation time below — never silently -# embed a multi-line value. -escape_js() { - printf '%s' "$1" | sed \ - -e 's|\\|\\\\|g' \ - -e 's|"|\\"|g' -} - -# Reject values containing characters that don't belong in an OIDC issuer -# or client_id: whitespace, control chars, anything that breaks a JS -# string literal in nasty ways. If any var has those, fail loudly. -contains_unsafe_chars() { - printf '%s' "$1" | LC_ALL=C grep -q '[[:cntrl:][:space:]]' -} -if contains_unsafe_chars "${OIDC_ISSUER:-}" || contains_unsafe_chars "${OIDC_CLIENT_ID:-}"; then - echo "ERROR: OIDC_ISSUER or OIDC_CLIENT_ID contains whitespace or control characters; refusing to start." >&2 - exit 1 -fi - -# DEV_USER_EMAIL is the docker-compose dev stack's escape hatch — it -# turns on impersonation in production builds when no OIDC is configured. -# It's mutually exclusive with real OIDC: shipping both means a -# misconfigured prod deploy where everyone gets silently impersonated as -# DEV_USER_EMAIL. Fail loud here rather than serving a broken-but-up app. -if [ -n "${DEV_USER_EMAIL:-}" ]; then - if contains_unsafe_chars "$DEV_USER_EMAIL"; then - echo "ERROR: DEV_USER_EMAIL contains whitespace or control characters; refusing to start." >&2 - exit 1 - fi - if [ -n "${OIDC_ISSUER:-}" ] || [ -n "${OIDC_CLIENT_ID:-}" ]; then - echo "ERROR: DEV_USER_EMAIL is set together with OIDC config — these are mutually exclusive." >&2 - echo " Either remove DEV_USER_EMAIL (real OIDC flow), or clear OIDC_ISSUER/OIDC_CLIENT_ID (dev impersonation)." >&2 - exit 1 - fi -fi - -# OIDC_SCOPES is space-separated, so internal whitespace is expected. Validate -# each token has no control chars (still a JS-string-injection concern). -if [ -n "${OIDC_SCOPES:-}" ]; then - for tok in $OIDC_SCOPES; do - if printf '%s' "$tok" | LC_ALL=C grep -q '[[:cntrl:]]'; then - echo "ERROR: OIDC_SCOPES contains control characters; refusing to start." >&2 - exit 1 - fi - done -fi - -# Write runtime config to an external JS file. We can't inline the script -# in index.html — strict CSP (`script-src 'self'`) would reject it. Always -# write the file so index.html's ||g' /usr/share/nginx/html/index.html -sed -i "s|||" \ - /usr/share/nginx/html/index.html - -# Render CSP with the actual OIDC issuer origin so connect-src / frame-src can -# be tight (specific host) instead of broad `https:`. Falls back to `https:` if -# OIDC_ISSUER is not set, keeping the build usable without runtime config. -if [ -n "$OIDC_ISSUER" ]; then - # Strip path/query/fragment to get just `scheme://host[:port]`. Validate - # that the input actually looks like a URL — if not, fall back to `https:` - # rather than splatting a malformed value into the CSP header. The - # character class [^/?#] stops at the first authority terminator, so an - # OIDC_ISSUER like https://issuer.example.com?foo=bar still yields a - # valid CSP source (https://issuer.example.com), not a malformed token. - if echo "$OIDC_ISSUER" | grep -qE '^[A-Za-z][A-Za-z0-9+.-]*://[^[:space:]/?#]+'; then - # Extraction MUST exclude whitespace too (matches validation). Otherwise - # an OIDC_ISSUER like `https://issuer.example.com https:` would pass - # validation (first authority looks fine) but the sed greedy match would - # extract `https://issuer.example.com https:` — CSP directive values are - # space-separated, so that smuggles in `https:` as a third allowed CSP - # source, opening connect-src/frame-src to ALL https origins. - CSP_REMOTE=$(echo "$OIDC_ISSUER" | sed -E 's|^([A-Za-z][A-Za-z0-9+.-]*://[^[:space:]/?#]+).*|\1|') - else - echo "WARN: OIDC_ISSUER='$OIDC_ISSUER' is not a well-formed URL — using https: in CSP" - CSP_REMOTE="https:" - fi -else - CSP_REMOTE="https:" -fi -export CSP_REMOTE - -# Substitute placeholders in nginx config template. -envsubst '${CSP_REMOTE}' < /etc/nginx/templates/default.conf.template \ - > /etc/nginx/conf.d/default.conf -echo "CSP connect-src/frame-src remote: $CSP_REMOTE" +# The SPA is a pure static bundle served behind the nginx `gateway`, which +# fronts `/api/*`, `/auth/*`, and `/` (this container). Authentication is a +# server-side cookie/BFF flow: the browser hits `/auth/login` (gateway -> +# authenticator -> IdP), gets a `__Host-sid` session cookie, and the SPA calls +# `/api/*` and `/auth/me` same-origin with `credentials: 'include'`. There is +# no client-side OIDC and no runtime config injection anymore, so this +# entrypoint only renders the nginx config template and execs the CMD. + +# Place the nginx config. It has no runtime placeholders anymore (the SPA is +# same-origin only, so the CSP needs no injected OIDC issuer origin), but the +# build ships it under /etc/nginx/templates, so copy it into conf.d here. +cp /etc/nginx/templates/default.conf.template /etc/nginx/conf.d/default.conf exec "$@" diff --git a/nginx/default.conf.template b/nginx/default.conf.template index a73e00a..8224c5b 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -1,5 +1,5 @@ map $sent_http_content_type $csp_header { - default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ${CSP_REMOTE}; frame-src 'self' ${CSP_REMOTE}; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'"; + default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; object-src 'none'"; } server { @@ -15,9 +15,9 @@ server { # location block that declares its own add_header (nginx replaces rather # than merges across levels). CSP: 'unsafe-inline' for style-src is # required for React inline styles + recharts; tighten via nonce in a - # later iteration. connect-src/frame-src use the actual OIDC issuer origin - # (substituted by docker-entrypoint.sh) when available, falling back to - # `https:` if OIDC_ISSUER is not set at runtime. + # later iteration. The SPA is same-origin only (it calls /api/* and + # /auth/* through the gateway that fronts this container), so connect-src + # and frame-src stay at 'self' — there is no client-side OIDC / IdP origin. include /etc/nginx/snippets/security-headers.conf; # MSW service worker ships in dist/ from public/ but is inert without diff --git a/nginx/security-headers.conf b/nginx/security-headers.conf index 77ecdb2..07869f5 100644 --- a/nginx/security-headers.conf +++ b/nginx/security-headers.conf @@ -1,9 +1,8 @@ add_header Content-Security-Policy $csp_header always; -# SAMEORIGIN (not DENY) so the OIDC silent-renew hidden iframe can load -# the SPA's redirect_uri from the same origin. Third-party framing remains -# blocked; combined with frame-ancestors 'self' in the CSP, this keeps -# clickjacking protection while not breaking automaticSilentRenew in -# oidc-client-ts. +# SAMEORIGIN (not DENY): the SPA is same-origin only, so allowing same-origin +# framing is harmless and future-proofs any in-app iframe. Third-party framing +# stays blocked; combined with frame-ancestors 'self' in the CSP this keeps +# clickjacking protection intact. add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; diff --git a/src/api/catalog-provider.tsx b/src/api/catalog-provider.tsx index 4adc0f2..a95bedf 100644 --- a/src/api/catalog-provider.tsx +++ b/src/api/catalog-provider.tsx @@ -41,7 +41,8 @@ export function CatalogProvider({ }: { children: React.ReactNode; }): React.ReactElement { - const { tenantId } = useAuth(); + const { session } = useAuth(); + const tenantId = session?.tenants[0] ?? null; const queryClient = useQueryClient(); const prevTenantRef = useRef(tenantId); diff --git a/src/api/fetch-with-auth.test.ts b/src/api/fetch-with-auth.test.ts index e2642c5..959cec4 100644 --- a/src/api/fetch-with-auth.test.ts +++ b/src/api/fetch-with-auth.test.ts @@ -1,29 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { getDevBearerEmail } = vi.hoisted(() => ({ - getDevBearerEmail: vi.fn<() => string | null>(), -})); -vi.mock("@/auth/use-viewer", () => ({ getDevBearerEmail })); - import { authStore } from "@/auth/auth-store"; -import { OidcManager } from "@/auth/oidc-manager"; import { fetchWithAuth } from "./fetch-with-auth"; const fetchMock = () => globalThis.fetch as ReturnType; -function authHeaderOfLastCall(): string | null { +function initOfLastCall(): RequestInit { const calls = fetchMock().mock.calls; - const init = calls[calls.length - 1]?.[1] as RequestInit; - return new Headers(init.headers).get("Authorization"); + return (calls[calls.length - 1]?.[1] ?? {}) as RequestInit; } describe("fetchWithAuth", () => { + let assign: ReturnType; + beforeEach(() => { authStore.reset(); - authStore.setToken("tok"); - getDevBearerEmail.mockReset(); - getDevBearerEmail.mockReturnValue(null); + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }); vi.stubGlobal("fetch", vi.fn()); + // jsdom's `window.location.assign` is a non-configurable no-op; replace + // the whole location object so the full-page login bounce is observable. + assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign, pathname: "/dash", search: "?q=1" }, + }); }); afterEach(() => { @@ -32,73 +37,32 @@ describe("fetchWithAuth", () => { authStore.reset(); }); + it("sends credentials:'include' and no Authorization / X-Tenant-ID header", async () => { + fetchMock().mockResolvedValue(new Response(null, { status: 200 })); + await fetchWithAuth("/x", { headers: { Accept: "application/json" } }); + const init = initOfLastCall(); + expect(init.credentials).toBe("include"); + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBeNull(); + expect(headers.get("X-Tenant-ID")).toBeNull(); + }); + it("returns non-401 responses unchanged without touching auth", async () => { const ok = new Response(null, { status: 200 }); fetchMock().mockResolvedValue(ok); - const refresh = vi.spyOn(OidcManager, "refresh"); const res = await fetchWithAuth("/x"); expect(res).toBe(ok); - expect(refresh).not.toHaveBeenCalled(); - }); - - it("retries with the refreshed token on a 401", async () => { - const r200 = new Response(null, { status: 200 }); - fetchMock() - .mockResolvedValueOnce(new Response(null, { status: 401 })) - .mockResolvedValueOnce(r200); - vi.spyOn(OidcManager, "refresh").mockResolvedValue("newtok"); - const res = await fetchWithAuth("/x"); - expect(res).toBe(r200); - expect(fetchMock()).toHaveBeenCalledTimes(2); + expect(authStore.getSnapshot().status).toBe("authenticated"); + expect(assign).not.toHaveBeenCalled(); }); - it("requires reauth and returns the 401 when refresh fails", async () => { + it("on a 401 marks the session unauthenticated and bounces into the login flow", async () => { const r401 = new Response(null, { status: 401 }); fetchMock().mockResolvedValue(r401); - vi.spyOn(OidcManager, "refresh").mockResolvedValue(null); - const requireReauth = vi - .spyOn(OidcManager, "requireReauth") - .mockResolvedValue(undefined); const res = await fetchWithAuth("/x"); expect(res).toBe(r401); - expect(requireReauth).toHaveBeenCalledWith("refresh_failed"); - }); - - it("requires reauth when the retry is still 401", async () => { - fetchMock().mockResolvedValue(new Response(null, { status: 401 })); - vi.spyOn(OidcManager, "refresh").mockResolvedValue("newtok"); - const requireReauth = vi - .spyOn(OidcManager, "requireReauth") - .mockResolvedValue(undefined); - const res = await fetchWithAuth("/x"); - expect(res.status).toBe(401); - expect(requireReauth).toHaveBeenCalledWith("token_rejected"); - }); - - it("mints a dev bearer only for the disabled + dev_bypass state", async () => { - authStore.reset(); - authStore.setStatus("disabled", "dev_bypass"); - getDevBearerEmail.mockReturnValue("dev@example.com"); - fetchMock().mockResolvedValue(new Response(null, { status: 200 })); - await fetchWithAuth("/x"); - expect(authHeaderOfLastCall()).toMatch(/^Bearer \S+\.\S+\./); - }); - - it("never mints an unsigned bearer while renewing under OIDC", async () => { - authStore.reset(); - authStore.setStatus("renewing"); - getDevBearerEmail.mockReturnValue("dev@example.com"); - fetchMock().mockResolvedValue(new Response(null, { status: 200 })); - await fetchWithAuth("/x"); - expect(authHeaderOfLastCall()).toBeNull(); - }); - - it("fails closed on an unconfigured deploy (missing_oidc_config)", async () => { - authStore.reset(); - authStore.setStatus("disabled", "missing_oidc_config"); - getDevBearerEmail.mockReturnValue("dev@example.com"); - fetchMock().mockResolvedValue(new Response(null, { status: 200 })); - await fetchWithAuth("/x"); - expect(authHeaderOfLastCall()).toBeNull(); + expect(authStore.getSnapshot().status).toBe("unauthenticated"); + expect(assign).toHaveBeenCalledTimes(1); + expect(assign.mock.calls[0][0]).toMatch(/^\/auth\/login\?return_to=/); }); }); diff --git a/src/api/fetch-with-auth.ts b/src/api/fetch-with-auth.ts index 92a5928..5e53f04 100644 --- a/src/api/fetch-with-auth.ts +++ b/src/api/fetch-with-auth.ts @@ -1,64 +1,23 @@ import { authStore } from "@/auth/auth-store"; -import { OidcManager } from "@/auth/oidc-manager"; -import { getDevBearerEmail } from "@/auth/use-viewer"; - -function devBearer(): string | null { - // getDevBearerEmail() returns null unless the active viewer source is - // dev-style (dev / override). It will never resolve to an OIDC user's - // email, even when a build-time dev fallback is configured — that - // would risk a mid-bootstrap OIDC session (token still null) leaking - // an unsigned JWT bearing the real user's identity. - const email = getDevBearerEmail(); - if (!email) return null; - const b64url = (o: object): string => - btoa(JSON.stringify(o)) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/, ""); - const header = b64url({ alg: "none", typ: "JWT" }); - const payload = b64url({ - email, - preferred_username: email, - sub: `dev:${email}`, - }); - return `${header}.${payload}.`; -} - -function injectAuthHeaders(headers: Headers): void { - const { status, reason, token, tenantId } = authStore.getSnapshot(); - // Only mint a dev/impersonation bearer for the genuine dev bypass. Under - // real OIDC the token is briefly null while renewing — never let a URL - // `?override=` identity forge an unsigned `alg:none` bearer there. An - // unconfigured prod deploy lands on `disabled`/`missing_oidc_config` and - // must fail closed too, hence the explicit `dev_bypass` reason check. - const devEligible = status === "disabled" && reason === "dev_bypass"; - const bearer = token ?? (devEligible ? devBearer() : null); - if (bearer) headers.set("Authorization", `Bearer ${bearer}`); - if (tenantId) headers.set("X-Tenant-ID", tenantId); -} +import { signIn } from "@/auth/use-auth"; +/** + * Fetch an in-cluster API through the gateway. Auth is entirely cookie-based + * (NGINX_BFF): we send the `__Host-sid` cookie via `credentials: "include"` and + * the gateway injects the ES256 gateway JWT downstream — the SPA attaches no + * `Authorization` header and asserts no tenant. A 401 means the session is gone + * or expired; bounce the whole page into the login flow (there is no + * client-side token to refresh). + */ export async function fetchWithAuth( input: RequestInfo | URL, init: RequestInit = {}, ): Promise { - const headers = new Headers(init.headers); - injectAuthHeaders(headers); - - const res = await fetch(input, { ...init, headers }); - - if (res.status !== 401) return res; - - const newToken = await OidcManager.refresh(); - if (!newToken) { - void OidcManager.requireReauth("refresh_failed"); - return res; - } + const res = await fetch(input, { ...init, credentials: "include" }); - const retryHeaders = new Headers(init.headers); - injectAuthHeaders(retryHeaders); - const retryRes = await fetch(input, { ...init, headers: retryHeaders }); - if (retryRes.status === 401) { - void OidcManager.requireReauth("token_rejected"); + if (res.status === 401) { + authStore.setUnauthenticated(); + signIn(); } - return retryRes; + return res; } diff --git a/src/api/identity-client.test.ts b/src/api/identity-client.test.ts new file mode 100644 index 0000000..445e2d1 --- /dev/null +++ b/src/api/identity-client.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/api/fetch-with-auth", () => ({ fetchWithAuth: vi.fn() })); + +import { fetchWithAuth } from "@/api/fetch-with-auth"; + +import { getPerson, IdentityApiError } from "./identity-client"; + +const mockFetch = fetchWithAuth as unknown as ReturnType; + +function response(body: unknown, init?: { ok?: boolean; status?: number }): Response { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + json: async () => body, + } as unknown as Response; +} + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("getPerson", () => { + it("POSTs /profiles with an {email} body and maps the profile", async () => { + mockFetch.mockResolvedValueOnce( + response({ + person_id: "p-1", + insight_tenant_id: "t-1", + email: "bob.park@example.com", + display_name: "Bob Park", + job_title: "Lead", + supervisor_email: "ceo@example.com", + }), + ); + + const person = await getPerson("bob.park@example.com"); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe("/api/identity/v1/profiles"); + expect(init).toMatchObject({ method: "POST" }); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + value_type: "email", + value: "bob.park@example.com", + }); + + expect(person.person_id).toBe("p-1"); + expect(person.email).toBe("bob.park@example.com"); + expect(person.job_title).toBe("Lead"); + expect(person.supervisor_email).toBe("ceo@example.com"); + // Omitted optional strings default to ""; omitted parent fields stay null. + expect(person.department).toBe(""); + expect(person.parent_id).toBeNull(); + expect(person.parent_email).toBeNull(); + }); + + it("maps subordinates recursively and drops any without an email", async () => { + mockFetch.mockResolvedValueOnce( + response({ + person_id: "p-lead", + insight_tenant_id: "t-1", + email: "lead@example.com", + subordinates: [ + { person_id: "p-2", insight_tenant_id: "t-1", email: "ic1@example.com" }, + // no email -> dropped (would be a broken link + duplicate "" key) + { person_id: "p-3", insight_tenant_id: "t-1" }, + { person_id: "p-4", insight_tenant_id: "t-1", email: " " }, + ], + }), + ); + + const person = await getPerson("lead@example.com"); + + expect(person.subordinates.map((s) => s.email)).toEqual(["ic1@example.com"]); + }); + + it("throws IdentityApiError with the status + body on a non-ok response", async () => { + mockFetch.mockResolvedValueOnce( + response({ error: "not_found" }, { ok: false, status: 404 }), + ); + + await expect(getPerson("ghost@example.com")).rejects.toMatchObject({ + name: "IdentityApiError", + status: 404, + body: { error: "not_found" }, + }); + }); + + it("throws IdentityApiError(invalid_json) when the body is not JSON", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected token"); + }, + } as unknown as Response); + + await expect(getPerson("bob@example.com")).rejects.toMatchObject({ + status: 200, + body: { error: "invalid_json" }, + }); + }); + + it("rejects a profile missing the required email", async () => { + mockFetch.mockResolvedValueOnce( + response({ person_id: "p-1", insight_tenant_id: "t-1" }), + ); + + const err = await getPerson("bob@example.com").catch((e: unknown) => e); + expect(err).toBeInstanceOf(IdentityApiError); + expect((err as IdentityApiError).body).toEqual({ error: "missing_email" }); + }); +}); diff --git a/src/api/identity-client.ts b/src/api/identity-client.ts index 9168c92..44041e5 100644 --- a/src/api/identity-client.ts +++ b/src/api/identity-client.ts @@ -3,6 +3,13 @@ * (see `PersonResponse.cs`). When mocks are off and the endpoint fails the * caller surfaces the failure to the UI — never silently falls back to seeded * data. + * + * The legacy `GET /persons/{email}` lookup (RFC 8594 deprecated) is replaced by + * `POST /profiles` with a `{ value_type, value }` body. The wire shape + * (`ProfileResponse`) mirrors the C# `PersonResponse`, but nearly every field is + * optional; we normalize it back into the required-string `IdentityPerson` + * projection the UI already consumes so callers and the org-tree sidebar are + * unchanged. */ import { fetchWithAuth } from "@/api/fetch-with-auth"; @@ -12,6 +19,28 @@ const BASE = (import.meta.env.VITE_IDENTITY_BASE as string | undefined) ?? "/api/identity/v1"; +/** Wire shape of `POST /profiles` (snake_case; optional fields omitted). */ +interface ProfileResponse { + person_id: string; + insight_tenant_id: string; + email?: string; + display_name?: string; + first_name?: string; + last_name?: string; + department?: string; + division?: string; + job_title?: string; + status?: string; + username?: string; + employee_id?: string; + supervisor_email?: string; + supervisor_name?: string; + parent_email?: string; + parent_person_id?: string; + subordinates?: ProfileResponse[]; + ids?: unknown[]; +} + export class IdentityApiError extends Error { status: number; body: unknown; @@ -24,16 +53,58 @@ export class IdentityApiError extends Error { } } +/** + * Normalize a `ProfileResponse` into the FE `IdentityPerson`. `email` is the UI + * identity (org-tree sidebar links + React keys), so the top-level profile is + * guaranteed to carry one by `getPerson`; subordinates the wire returns without + * an email are dropped rather than projected to `""` — an empty email would make + * broken sidebar links and collide as duplicate React keys across siblings. Other + * optional strings default to `""`; omitted parent/supervisor fields stay `null`. + */ +function toIdentityPerson(p: ProfileResponse): IdentityPerson { + return { + person_id: p.person_id, + email: p.email ?? "", + display_name: p.display_name ?? "", + first_name: p.first_name ?? "", + last_name: p.last_name ?? "", + department: p.department ?? "", + division: p.division ?? "", + job_title: p.job_title ?? "", + status: p.status ?? "", + parent_email: p.parent_email ?? null, + // `parent_id` has no ProfileResponse source; preserve the prior default. + parent_id: null, + parent_person_id: p.parent_person_id ?? null, + supervisor_email: p.supervisor_email ?? null, + supervisor_name: p.supervisor_name ?? null, + subordinates: (p.subordinates ?? []) + .filter((s) => Boolean(s.email?.trim())) + .map(toIdentityPerson), + }; +} + export async function getPerson(email: string): Promise { - const url = `${BASE}/persons/${encodeURIComponent(email)}`; - const res = await fetchWithAuth(url); + const url = `${BASE}/profiles`; + const res = await fetchWithAuth(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value_type: "email", value: email }), + }); if (!res.ok) { const body = await res.json().catch(() => null); throw new IdentityApiError(res.status, body); } + let profile: ProfileResponse; try { - return (await res.json()) as IdentityPerson; + profile = (await res.json()) as ProfileResponse; } catch { throw new IdentityApiError(res.status, { error: "invalid_json" }); } + // `email` is the queried identity + the UI's key; a profile without it is + // unusable, so surface it rather than projecting an empty-string person. + if (!profile.email?.trim()) { + throw new IdentityApiError(res.status, { error: "missing_email" }); + } + return toIdentityPerson(profile); } diff --git a/src/api/use-catalog.test.tsx b/src/api/use-catalog.test.tsx index fb6a1ca..d8ffdd9 100644 --- a/src/api/use-catalog.test.tsx +++ b/src/api/use-catalog.test.tsx @@ -37,12 +37,22 @@ import { useCatalogLinkMap } from "./use-catalog-link-map"; const fetchCatalog = catalogClient.fetchCatalog as ReturnType; +/** Seed an authenticated session scoped to a single tenant. */ +function signInTenant(tenantId: string): void { + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: [tenantId], + roles: ["user"], + }); +} + function makeOkResponse( metrics: Array> = [], links: catalogClient.MetricQueryLink[] = [], ): catalogClient.CatalogResponse { return { - tenant_id: authStore.getSnapshot().tenantId ?? "t-fallback", + tenant_id: authStore.getSnapshot().session?.tenants[0] ?? "t-fallback", generated_at: "2026-06-01T00:00:00Z", metrics: metrics.map((m, i) => ({ id: m.id ?? `id-${i}`, @@ -84,7 +94,7 @@ function withClient(): { describe("useCatalog", () => { beforeEach(() => { authStore.reset(); - authStore.setTenantId("t-1"); + signInTenant("t-1"); fetchCatalog.mockReset(); }); afterEach(() => { @@ -226,7 +236,7 @@ describe("useCatalog", () => { // Simulate the CatalogProvider's behavior: a tenant switch removes // every catalog-keyed query. act(() => { - authStore.setTenantId("t-2"); + signInTenant("t-2"); client.removeQueries({ queryKey: ["catalog"] }); }); @@ -239,7 +249,7 @@ describe("useCatalog", () => { describe("useCatalogLinkMap", () => { beforeEach(() => { authStore.reset(); - authStore.setTenantId("t-1"); + signInTenant("t-1"); fetchCatalog.mockReset(); }); afterEach(() => { diff --git a/src/api/use-catalog.ts b/src/api/use-catalog.ts index 1b55f3f..7e41ac0 100644 --- a/src/api/use-catalog.ts +++ b/src/api/use-catalog.ts @@ -85,7 +85,8 @@ export type UseCatalogResult = { * product-default only. */ export function useCatalog(args: CatalogRequest = {}): UseCatalogResult { - const { tenantId } = useAuth(); + const { session } = useAuth(); + const tenantId = session?.tenants[0] ?? null; const roleSlug = args.role_slug; const teamId = args.team_id; diff --git a/src/api/view-configs.test.tsx b/src/api/view-configs.test.tsx index 76becdd..c6d0ec6 100644 --- a/src/api/view-configs.test.tsx +++ b/src/api/view-configs.test.tsx @@ -44,7 +44,7 @@ type Row = Partial & { metric_key: string }; function buildResponse(rows: Row[]): catalogClient.CatalogResponse { return { - tenant_id: authStore.getSnapshot().tenantId ?? "t-1", + tenant_id: authStore.getSnapshot().session?.tenants[0] ?? "t-1", generated_at: "2026-06-01T00:00:00Z", metrics: rows.map((r, i) => ({ id: r.id ?? `id-${i}`, @@ -103,7 +103,12 @@ async function renderWithFetched( describe("useTeamViewConfig", () => { beforeEach(() => { authStore.reset(); - authStore.setTenantId("t-1"); + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }); fetchCatalog.mockReset(); }); afterEach(() => { diff --git a/src/auth/auth-store.ts b/src/auth/auth-store.ts index ba9d5fe..eb3cb10 100644 --- a/src/auth/auth-store.ts +++ b/src/auth/auth-store.ts @@ -1,12 +1,6 @@ -import type { AuthReason, AuthSnapshot, AuthStatus, AuthUser } from "./types"; - -let snapshot: AuthSnapshot = { - status: "initializing", - token: null, - user: null, - tenantId: null, - reason: null, -}; +import type { AuthSnapshot, Session } from "./types"; + +let snapshot: AuthSnapshot = { status: "loading", session: null }; const listeners = new Set<() => void>(); @@ -26,37 +20,19 @@ export const authStore = { return snapshot; }, - setStatus(status: AuthStatus, reason: AuthReason | null = null): void { - if (snapshot.status === status && snapshot.reason === reason) return; - snapshot = { ...snapshot, status, reason }; - emit(); - }, - - setToken(token: string | null): void { - if (snapshot.token === token) return; - snapshot = { ...snapshot, token }; + setAuthenticated(session: Session): void { + snapshot = { status: "authenticated", session }; emit(); }, - setUser(user: AuthUser | null): void { - snapshot = { ...snapshot, user }; - emit(); - }, - - setTenantId(tenantId: string | null): void { - if (snapshot.tenantId === tenantId) return; - snapshot = { ...snapshot, tenantId }; + setUnauthenticated(): void { + if (snapshot.status === "unauthenticated" && snapshot.session === null) return; + snapshot = { status: "unauthenticated", session: null }; emit(); }, reset(): void { - snapshot = { - status: "initializing", - token: null, - user: null, - tenantId: null, - reason: null, - }; + snapshot = { status: "loading", session: null }; emit(); }, }; diff --git a/src/auth/config.ts b/src/auth/config.ts deleted file mode 100644 index f6ff91e..0000000 --- a/src/auth/config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { OidcConfig } from "./types"; - -export function readOidcConfig(): OidcConfig | null { - const raw = window.__OIDC_CONFIG__; - if (!raw?.issuer_url || !raw.client_id) return null; - const scopes = (raw.scopes ?? "") - .split(/\s+/) - .map((s) => s.trim()) - .filter(Boolean); - if (scopes.length === 0) return null; - return { - issuer_url: raw.issuer_url, - client_id: raw.client_id, - redirect_uri: `${window.location.origin}/callback`, - scopes, - response_type: "code", - }; -} diff --git a/src/auth/dev-config.ts b/src/auth/dev-config.ts deleted file mode 100644 index f152ce6..0000000 --- a/src/auth/dev-config.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Reader for window.__DEV_CONFIG__ — the runtime escape hatch that lets -// a production bundle do dev-impersonation when shipped into the -// docker-compose dev stack. The bundle stays auth-locked in any -// deployment that doesn't explicitly populate this (the entrypoint -// refuses to emit it when OIDC is also configured). -export function readDevUserEmail(): string | null { - const raw = window.__DEV_CONFIG__?.devUserEmail; - if (typeof raw !== "string") return null; - const trimmed = raw.trim(); - return trimmed.length === 0 ? null : trimmed; -} diff --git a/src/auth/impersonation.ts b/src/auth/impersonation.ts deleted file mode 100644 index 589fd7d..0000000 --- a/src/auth/impersonation.ts +++ /dev/null @@ -1,55 +0,0 @@ -const KEY = "viewer:override"; -const PARAM = "__override"; - -const listeners = new Set<() => void>(); - -function emit(): void { - for (const fn of listeners) fn(); -} - -function safeRead(): string | null { - try { - return sessionStorage.getItem(KEY); - } catch { - return null; - } -} - -function safeWrite(value: string | null): void { - try { - if (value === null) sessionStorage.removeItem(KEY); - else sessionStorage.setItem(KEY, value); - } catch { - return; - } -} - -export const overrideStore = { - subscribe(fn: () => void): () => void { - listeners.add(fn); - return () => { - listeners.delete(fn); - }; - }, - getSnapshot: safeRead, -}; - -export function getOverrideEmail(): string | null { - return safeRead(); -} - -export function captureOverrideFromUrl(): void { - if (typeof window === "undefined") return; - const url = new URL(window.location.href); - const raw = url.searchParams.get(PARAM); - if (raw === null) return; - safeWrite(raw.trim() || null); - url.searchParams.delete(PARAM); - window.history.replaceState(window.history.state, "", url); - emit(); -} - -export function clearOverride(): void { - safeWrite(null); - emit(); -} diff --git a/src/auth/index.ts b/src/auth/index.ts index ed626d5..5c7cf59 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,24 +1,5 @@ -export { OidcManager } from "./oidc-manager"; export { authStore } from "./auth-store"; -export { useAuth } from "./use-auth"; -export { getStartUrl, storeStartUrl } from "./start-url"; -export { - captureOverrideFromUrl, - clearOverride, - getOverrideEmail, -} from "./impersonation"; -export { - getViewerEmail, - isDevImpersonating, - useViewer, - type Viewer, - type ViewerSource, -} from "./use-viewer"; -export type { - AuthReason, - AuthSnapshot, - AuthStatus, - AuthUser, - OidcConfig, - OidcSigninState, -} from "./types"; +export { loadSession } from "./session"; +export { useAuth, signIn, signOut } from "./use-auth"; +export { getViewerEmail, useViewer, type Viewer } from "./use-viewer"; +export type { AuthSnapshot, AuthStatus, Session } from "./types"; diff --git a/src/auth/oidc-manager.test.ts b/src/auth/oidc-manager.test.ts deleted file mode 100644 index 2b70f56..0000000 --- a/src/auth/oidc-manager.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const { signinRedirect, signinRedirectCallback, handlers } = vi.hoisted(() => ({ - signinRedirect: vi.fn(), - signinRedirectCallback: vi.fn(), - handlers: {} as { - userUnloaded?: () => void; - accessTokenExpired?: () => void; - silentRenewError?: () => void; - }, -})); - -vi.mock("oidc-client-ts", () => { - class UserManager { - events = { - addUserLoaded: vi.fn(), - addUserUnloaded: (fn: () => void) => { - handlers.userUnloaded = fn; - }, - addAccessTokenExpired: (fn: () => void) => { - handlers.accessTokenExpired = fn; - }, - addSilentRenewError: (fn: () => void) => { - handlers.silentRenewError = fn; - }, - }; - getUser = vi.fn().mockResolvedValue(null); - signinRedirect = signinRedirect; - signinRedirectCallback = signinRedirectCallback; - } - class WebStorageStateStore {} - return { UserManager, WebStorageStateStore }; -}); - -function withOidcConfig(): void { - window.__OIDC_CONFIG__ = { - issuer_url: "https://idp.example", - client_id: "client", - scopes: "openid profile", - }; -} - -beforeEach(() => { - // Fresh module each test resets the module-level `redirectInFlight` / - // `initPromise` state; the hoisted `signinRedirect` spy persists. - vi.resetModules(); - signinRedirect.mockReset(); - signinRedirect.mockResolvedValue(undefined); - signinRedirectCallback.mockReset(); - window.history.pushState({}, "", "/"); - withOidcConfig(); -}); - -afterEach(() => { - delete window.__OIDC_CONFIG__; - vi.restoreAllMocks(); -}); - -describe("OidcManager.signIn redirect guard", () => { - it("fires a single redirect for concurrent callers", async () => { - const { OidcManager } = await import("./oidc-manager"); - await Promise.all([ - OidcManager.signIn(), - OidcManager.signIn(), - OidcManager.signIn(), - ]); - expect(signinRedirect).toHaveBeenCalledTimes(1); - }); - - it("preserves the current location in the redirect state", async () => { - const { OidcManager } = await import("./oidc-manager"); - await OidcManager.signIn(); - expect(signinRedirect).toHaveBeenCalledWith({ - state: { - returnUrl: window.location.pathname + window.location.search, - }, - }); - }); - - it("resets the guard so a failed redirect can be retried", async () => { - signinRedirect.mockRejectedValueOnce(new Error("redirect_failed")); - const { OidcManager } = await import("./oidc-manager"); - await expect(OidcManager.signIn()).rejects.toThrow("redirect_failed"); - await OidcManager.signIn(); - expect(signinRedirect).toHaveBeenCalledTimes(2); - }); - - it("never returns the user back to /callback", async () => { - window.history.pushState({}, "", "/callback?code=abc"); - const { OidcManager } = await import("./oidc-manager"); - await OidcManager.signIn(); - expect(signinRedirect).toHaveBeenCalledWith({ state: { returnUrl: "/" } }); - }); - - it("preserves path, query and hash of the current location", async () => { - window.history.pushState({}, "", "/ic/bob?period=month#section"); - const { OidcManager } = await import("./oidc-manager"); - await OidcManager.signIn(); - expect(signinRedirect).toHaveBeenCalledWith({ - state: { returnUrl: "/ic/bob?period=month#section" }, - }); - }); -}); - -describe("OidcManager.handleCallback return-url safety", () => { - function callbackUser(returnUrl: unknown) { - return { access_token: "tok", profile: {}, state: { returnUrl } }; - } - - it("rejects cross-origin and backslash-smuggled targets", async () => { - const { OidcManager } = await import("./oidc-manager"); - for (const evil of [ - "//evil.com", - "/\\evil.com", - "https://evil.com/x", - "javascript:alert(1)", - ]) { - signinRedirectCallback.mockResolvedValueOnce(callbackUser(evil)); - const url = await OidcManager.handleCallback("https://app/callback?code=x"); - expect(url).toBe("/"); - } - }); - - it("keeps a same-origin path with query and hash", async () => { - signinRedirectCallback.mockResolvedValue( - callbackUser("/ic/bob?period=month#section"), - ); - const { OidcManager } = await import("./oidc-manager"); - const url = await OidcManager.handleCallback("https://app/callback?code=x"); - expect(url).toBe("/ic/bob?period=month#section"); - }); -}); - -describe("OidcManager.requireReauth", () => { - it("flips to reauth_required and redirects", async () => { - const { OidcManager } = await import("./oidc-manager"); - const { authStore } = await import("./auth-store"); - await OidcManager.requireReauth("refresh_failed"); - expect(signinRedirect).toHaveBeenCalledTimes(1); - expect(authStore.getSnapshot().status).toBe("reauth_required"); - expect(authStore.getSnapshot().reason).toBe("refresh_failed"); - }); - - it("flips to reauth_failed when the redirect cannot start", async () => { - signinRedirect.mockRejectedValueOnce(new Error("boom")); - const { OidcManager } = await import("./oidc-manager"); - const { authStore } = await import("./auth-store"); - await OidcManager.requireReauth("token_rejected"); - expect(authStore.getSnapshot().status).toBe("reauth_failed"); - }); - - it("never rejects, so callers can fire-and-forget", async () => { - signinRedirect.mockRejectedValueOnce(new Error("boom")); - const { OidcManager } = await import("./oidc-manager"); - await expect(OidcManager.requireReauth("refresh_failed")).resolves.toBeUndefined(); - }); - - it("is a no-op without OIDC (no IdP to redirect to)", async () => { - delete window.__OIDC_CONFIG__; - const { OidcManager } = await import("./oidc-manager"); - const { authStore } = await import("./auth-store"); - await OidcManager.requireReauth("refresh_failed"); - expect(signinRedirect).not.toHaveBeenCalled(); - expect(authStore.getSnapshot().status).toBe("disabled"); - }); -}); - -describe("OidcManager renewal escalation", () => { - it("marks the session renewing when the access token expires", async () => { - const { OidcManager } = await import("./oidc-manager"); - const { authStore } = await import("./auth-store"); - await OidcManager.init(); - authStore.setStatus("authenticated"); - authStore.setToken("tok"); - handlers.accessTokenExpired?.(); - expect(authStore.getSnapshot().status).toBe("renewing"); - expect(authStore.getSnapshot().token).toBeNull(); - }); - - it("escalates to reauth_required when silent renew gives up", async () => { - const { OidcManager } = await import("./oidc-manager"); - const { authStore } = await import("./auth-store"); - await OidcManager.init(); - authStore.setStatus("authenticated"); - handlers.silentRenewError?.(); - await vi.waitFor(() => { - expect(authStore.getSnapshot().reason).toBe("silent_renew_failed"); - }); - expect(authStore.getSnapshot().status).toBe("reauth_required"); - expect(signinRedirect).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/auth/oidc-manager.ts b/src/auth/oidc-manager.ts deleted file mode 100644 index 3d9862f..0000000 --- a/src/auth/oidc-manager.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { UserManager, WebStorageStateStore, type User } from "oidc-client-ts"; - -import { authStore } from "./auth-store"; -import { readOidcConfig } from "./config"; -import { readDevUserEmail } from "./dev-config"; -import type { AuthReason, AuthUser, OidcConfig, OidcSigninState } from "./types"; - -let userManager: UserManager | null = null; -let initPromise: Promise | null = null; -let refreshPromise: Promise | null = null; -let redirectPromise: Promise | null = null; - -const { promise: authReady, resolve: authReadyResolve } = - Promise.withResolvers(); - -function buildSettings(config: OidcConfig) { - const store = new WebStorageStateStore({ store: sessionStorage }); - return { - authority: config.issuer_url, - client_id: config.client_id, - redirect_uri: config.redirect_uri, - post_logout_redirect_uri: window.location.origin, - scope: config.scopes.join(" "), - response_type: config.response_type, - automaticSilentRenew: true, - userStore: store, - stateStore: store, - }; -} - -/** - * Same-origin validation for the post-callback redirect target. - * - * `window.location.replace(raw)` honors absolute URLs, protocol-relative - * `//host/path`, backslash variants (`/\host` — browsers fold `\`→`/`), and - * `javascript:` / `data:` schemes — any of which would pivot a freshly minted - * session to an attacker origin if `state.returnUrl` is tampered with (XSS, - * malicious extension, shared device, library bug). Resolve against our own - * origin and only echo back a same-origin path + query + hash. - */ -function safeReturnUrl(raw: unknown): string { - if (typeof raw !== "string") return "/"; - try { - const url = new URL(raw, window.location.origin); - if (url.origin !== window.location.origin) return "/"; - return url.pathname + url.search + url.hash; - } catch { - return "/"; - } -} - -function toAuthUser(user: User): AuthUser { - const profile = user.profile ?? {}; - return { - sub: profile.sub, - email: typeof profile.email === "string" ? profile.email : undefined, - name: typeof profile.name === "string" ? profile.name : undefined, - }; -} - -function wireEvents(um: UserManager): void { - um.events.addUserLoaded((user) => { - authStore.setToken(user.access_token); - authStore.setUser(toAuthUser(user)); - authStore.setStatus("authenticated"); - }); - - um.events.addUserUnloaded(() => { - authStore.setToken(null); - authStore.setStatus("renewing"); - }); - - um.events.addAccessTokenExpired(() => { - authStore.setToken(null); - authStore.setStatus("renewing"); - }); - - // Background silent renew has given up — the session can't recover on its - // own, so escalate to an interactive redirect without waiting for the next - // request to 401. - um.events.addSilentRenewError(() => { - void OidcManager.requireReauth("silent_renew_failed"); - }); -} - -async function doInit(): Promise { - authStore.setStatus("initializing"); - - const config = readOidcConfig(); - - if (!config) { - // No OIDC config: auth is not active. In Vite dev or when the runtime - // injected a dev user email (compose dev stack with the published ghcr - // image) this is an intentional bypass; otherwise it's an unconfigured - // deploy that fails closed (no token is minted, so requests 401 and - // surface nothing — `requireReauth` has no IdP to redirect to and no-ops). - const runtimeDevEmail = readDevUserEmail(); - if (import.meta.env.DEV || runtimeDevEmail) { - console.warn( - "[OidcManager] No window.__OIDC_CONFIG__ — auth bypassed (dev impersonation).", - ); - authStore.setStatus("disabled", "dev_bypass"); - } else { - authStore.setStatus("disabled", "missing_oidc_config"); - } - authReadyResolve(null); - return; - } - - userManager = new UserManager(buildSettings(config)); - wireEvents(userManager); - - const existingUser = await userManager.getUser(); - if (existingUser && !existingUser.expired) { - authStore.setToken(existingUser.access_token); - authStore.setUser(toAuthUser(existingUser)); - authStore.setStatus("authenticated"); - authReadyResolve(existingUser); - } else { - // Configured but no live session — an interactive sign-in is needed. - // `beforeLoad` performs the redirect for this first-load case. - authStore.setStatus("reauth_required"); - authReadyResolve(null); - } -} - -export const OidcManager = { - authReady, - - async init(): Promise { - if (!initPromise) initPromise = doInit(); - return initPromise; - }, - - async ensureReady(): Promise { - await OidcManager.init(); - return authReady; - }, - - async getUser(): Promise { - if (!userManager) return null; - return userManager.getUser(); - }, - - async refresh(): Promise { - if (refreshPromise) return refreshPromise; - refreshPromise = (async () => { - if (!userManager) return null; - try { - // signinSilent replaces the stored user atomically on success and - // leaves the previous one untouched on failure — do NOT removeUser() - // first, that would synchronously fire addUserUnloaded and flip the - // store to `renewing` mid-renew, blanking the token for any - // concurrent fetchWithAuth caller. - const newUser = await userManager.signinSilent(); - const token = newUser?.access_token ?? null; - if (token) { - authStore.setToken(token); - authStore.setUser(toAuthUser(newUser!)); - authStore.setStatus("authenticated"); - } - return token; - } catch { - return null; - } - })().finally(() => { - refreshPromise = null; - }); - return refreshPromise; - }, - - async signIn(): Promise { - await OidcManager.init(); - if (!userManager) return; - // Share a single in-flight redirect (same idiom as `refresh`/`init`): - // concurrent callers — multiple 401s, the silent-renew failure, the - // first-load guard — await the very same promise and observe the same - // outcome, so a redirect that rejects rejects for all of them (letting - // every `requireReauth` caller fall through to `reauth_failed`). On - // success the page unloads; on rejection the slot is cleared for a retry. - if (redirectPromise) return redirectPromise; - // Never return the user to /callback — it has no `code` on a fresh visit - // and would loop straight back into the failure screen. - const path = window.location.pathname; - const returnUrl = - path === "/callback" - ? "/" - : path + window.location.search + window.location.hash; - const state: OidcSigninState = { returnUrl }; - redirectPromise = userManager.signinRedirect({ state }).finally(() => { - redirectPromise = null; - }); - return redirectPromise; - }, - - /** - * Single owner of the "session is dead, recover it" decision. Both the - * 401 path (`fetchWithAuth`) and the background silent-renew failure funnel - * here so the redirect lives in one place with one outcome model: - * - no IdP to redirect to (dev / unconfigured) → no-op, leave the app as-is - * - redirect starts → status `reauth_required`, the overlay covers the UI - * - redirect can't start → status `reauth_failed`, the gate offers a retry - * - * Never rejects — callers may `void` it; failures surface through state. - */ - async requireReauth(reason: AuthReason | null = null): Promise { - await OidcManager.init(); - if (!userManager) return; - authStore.setStatus("reauth_required", reason); - try { - await OidcManager.signIn(); - } catch { - authStore.setStatus("reauth_failed", reason); - } - }, - - async handleCallback(callbackUrl: string): Promise { - await OidcManager.init(); - if (!userManager) throw new Error("oidc_not_initialized"); - const user = await userManager.signinRedirectCallback(callbackUrl); - authStore.setToken(user.access_token); - authStore.setUser(toAuthUser(user)); - authStore.setStatus("authenticated"); - authReadyResolve(user); - const state = user.state as OidcSigninState | undefined; - return safeReturnUrl(state?.returnUrl); - }, - - async signOut(): Promise { - const um = userManager; - if (!um) { - authStore.reset(); - return; - } - // Capture id_token before clearing so the end_session call still has - // id_token_hint — some IdPs require it. - let idTokenHint: string | undefined; - try { - const user = await um.getUser(); - idTokenHint = user?.id_token; - } catch { - idTokenHint = undefined; - } - // Targeted cleanup via oidc-client-ts APIs — `removeUser()` drops the - // active user entry; `clearStaleState()` purges stale code/state entries - // from prior incomplete redirects. Both stay within the OIDC namespace, - // so unrelated sessionStorage entries (current or future) aren't lost. - try { - await um.removeUser(); - await um.clearStaleState(); - } catch { - // storage unavailable or already cleared — proceed to redirect. - } - authStore.reset(); - await um.signoutRedirect({ id_token_hint: idTokenHint }); - }, -}; diff --git a/src/auth/session.test.ts b/src/auth/session.test.ts new file mode 100644 index 0000000..dc592c3 --- /dev/null +++ b/src/auth/session.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { authStore } from "./auth-store"; +import { loadSession } from "./session"; + +const fetchMock = () => globalThis.fetch as ReturnType; + +describe("loadSession", () => { + beforeEach(() => { + authStore.reset(); + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + authStore.reset(); + }); + + it("populates the store from a 200 /auth/me and returns authenticated", async () => { + fetchMock().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + user: "p-1", + email: "bob@example.com", + tenants: ["t-1"], + roles: ["user"], + }), + }); + + const status = await loadSession(); + + expect(status).toBe("authenticated"); + const snap = authStore.getSnapshot(); + expect(snap.status).toBe("authenticated"); + expect(snap.session).toEqual({ + personId: "p-1", + email: "bob@example.com", + tenants: ["t-1"], + roles: ["user"], + }); + const [url, init] = fetchMock().mock.calls[0]; + expect(url).toBe("/auth/me"); + expect(init).toMatchObject({ credentials: "include" }); + }); + + it("defaults missing fields to empty values", async () => { + fetchMock().mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await loadSession(); + + expect(authStore.getSnapshot().session).toEqual({ + personId: "", + email: "", + tenants: [], + roles: [], + }); + }); + + it("fails closed to unauthenticated on a non-ok response", async () => { + fetchMock().mockResolvedValueOnce({ ok: false, json: async () => ({}) }); + + const status = await loadSession(); + + expect(status).toBe("unauthenticated"); + expect(authStore.getSnapshot().status).toBe("unauthenticated"); + }); + + it("fails closed on a network error reaching the authenticator", async () => { + fetchMock().mockRejectedValueOnce(new Error("network down")); + + const status = await loadSession(); + + expect(status).toBe("unauthenticated"); + expect(authStore.getSnapshot().status).toBe("unauthenticated"); + }); +}); diff --git a/src/auth/session.ts b/src/auth/session.ts new file mode 100644 index 0000000..7afdca6 --- /dev/null +++ b/src/auth/session.ts @@ -0,0 +1,39 @@ +import { authStore } from "./auth-store"; +import type { AuthStatus } from "./types"; + +/** + * Probe `GET /auth/me` once and populate the store. The browser sends the + * `__Host-sid` cookie (same-origin, credentials included); the authenticator + * returns the session summary or 401. Any non-200 fails closed to + * `unauthenticated` so the app redirects to login rather than rendering + * half-authenticated. Called once at boot (main.tsx) before the router mounts. + */ +export async function loadSession(): Promise { + try { + const res = await fetch("/auth/me", { + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + authStore.setUnauthenticated(); + return "unauthenticated"; + } + const body = (await res.json()) as { + user?: string; + email?: string; + tenants?: string[]; + roles?: string[]; + }; + authStore.setAuthenticated({ + personId: body.user ?? "", + email: body.email ?? "", + tenants: body.tenants ?? [], + roles: body.roles ?? [], + }); + return "authenticated"; + } catch { + // Network error reaching the authenticator — fail closed. + authStore.setUnauthenticated(); + return "unauthenticated"; + } +} diff --git a/src/auth/start-url.ts b/src/auth/start-url.ts deleted file mode 100644 index 38452d4..0000000 --- a/src/auth/start-url.ts +++ /dev/null @@ -1,9 +0,0 @@ -let startUrl: string | null = null; - -export function storeStartUrl(): void { - startUrl = window.location.href; -} - -export function getStartUrl(): string | null { - return startUrl; -} diff --git a/src/auth/types.ts b/src/auth/types.ts index fb8276c..935468c 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -1,75 +1,28 @@ -export type OidcConfig = { - issuer_url: string; - client_id: string; - redirect_uri: string; - scopes: string[]; - response_type: "code"; -}; - -export type OidcSigninState = { returnUrl?: string }; +// Cookie/BFF auth model (NGINX_BFF). The browser holds only the opaque +// `__Host-sid` session cookie; the SPA never sees tokens. Session identity +// comes from the authenticator's `GET /auth/me`. /** - * OIDC session lifecycle from the app's point of view. Whether the app must - * redirect to the IdP is a property of the status tag itself — not a predicate - * computed over a free-form error string. - * - * initializing — bootstrapping; `init()` in flight. - * disabled — OIDC not active (dev bypass, or unconfigured deploy). The - * app runs without an interactive login; never redirects. - * authenticated — valid session, token present. - * renewing — token gone/expired, silent renew may still recover it. - * Transient and non-terminal: the app keeps rendering. - * reauth_required— renewal is no longer possible; an interactive redirect is - * needed. The setter is responsible for kicking the - * redirect: `requireReauth()` does so directly, while - * `doInit()` sets it for the first-load case where the root - * `beforeLoad` performs the redirect. Don't assume the - * component tree triggers it. - * reauth_failed — the redirect itself could not start; recoverable via retry. + * Session lifecycle from the SPA's point of view. + * loading — the initial `/auth/me` probe is in flight. + * authenticated — a live session; `session` is populated. + * unauthenticated — no valid session; the app redirects to `/auth/login`. */ -export type AuthStatus = - | "initializing" - | "disabled" - | "authenticated" - | "renewing" - | "reauth_required" - | "reauth_failed"; +export type AuthStatus = "loading" | "authenticated" | "unauthenticated"; -/** Diagnostic cause carried alongside a status — for telemetry, never control flow. */ -export type AuthReason = - | "dev_bypass" - | "missing_oidc_config" - | "silent_renew_failed" - | "refresh_failed" - | "token_rejected"; - -export type AuthUser = { - email?: string; - name?: string; - sub?: string; +/** The session summary returned by `GET /auth/me`. */ +export type Session = { + /** Internal person id (UUID) — the gateway JWT `sub`. */ + personId: string; + /** The person's email — the SPA's person key (org tree, IC routes). */ + email: string; + /** Signed tenant memberships. */ + tenants: string[]; + /** Access-control roles. */ + roles: string[]; }; export type AuthSnapshot = { status: AuthStatus; - token: string | null; - user: AuthUser | null; - tenantId: string | null; - reason: AuthReason | null; + session: Session | null; }; - -declare global { - interface Window { - __OIDC_CONFIG__?: { - issuer_url?: string; - client_id?: string; - scopes?: string; - }; - // Runtime dev-impersonation config. Populated by the FE container's - // entrypoint only when DEV_USER_EMAIL is set AND no OIDC config is - // provided. Production deploys with real OIDC must NOT set this — the - // entrypoint refuses to emit both at once. - __DEV_CONFIG__?: { - devUserEmail?: string; - }; - } -} diff --git a/src/auth/use-auth.test.ts b/src/auth/use-auth.test.ts new file mode 100644 index 0000000..f7e5483 --- /dev/null +++ b/src/auth/use-auth.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { authStore } from "./auth-store"; +import { signOut } from "./use-auth"; + +const fetchMock = () => globalThis.fetch as ReturnType; + +describe("signOut", () => { + let assign: ReturnType; + + beforeEach(() => { + authStore.reset(); + authStore.setAuthenticated({ + personId: "p-1", + email: "bob@example.com", + tenants: ["t-1"], + roles: ["user"], + }); + vi.stubGlobal("fetch", vi.fn()); + assign = vi.fn(); + Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, assign }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + authStore.reset(); + }); + + it("POSTs /auth/logout, clears the session, and follows rp_logout_url", async () => { + fetchMock().mockResolvedValueOnce({ + json: async () => ({ rp_logout_url: "https://idp.example/logout" }), + }); + + await signOut(); + + const [url, init] = fetchMock().mock.calls[0]; + expect(url).toBe("/auth/logout"); + expect(init).toMatchObject({ method: "POST", credentials: "include" }); + expect(authStore.getSnapshot().status).toBe("unauthenticated"); + expect(assign).toHaveBeenCalledWith("https://idp.example/logout"); + }); + + it("falls back to / when no rp_logout_url is returned", async () => { + fetchMock().mockResolvedValueOnce({ json: async () => ({}) }); + + await signOut(); + + expect(assign).toHaveBeenCalledWith("/"); + expect(authStore.getSnapshot().status).toBe("unauthenticated"); + }); + + it("still clears the session and bounces to / on a network error", async () => { + fetchMock().mockRejectedValueOnce(new Error("network down")); + + await signOut(); + + expect(authStore.getSnapshot().status).toBe("unauthenticated"); + expect(assign).toHaveBeenCalledWith("/"); + }); +}); diff --git a/src/auth/use-auth.ts b/src/auth/use-auth.ts index 72bd309..caa7a16 100644 --- a/src/auth/use-auth.ts +++ b/src/auth/use-auth.ts @@ -1,11 +1,50 @@ import { useSyncExternalStore } from "react"; import { authStore } from "./auth-store"; -import { OidcManager } from "./oidc-manager"; import type { AuthSnapshot } from "./types"; +let redirecting = false; + +/** Sanitize a return-to into a site-relative path (mirrors the backend guard). */ +function safeReturnTo(path: string): string { + return path.startsWith("/") && !path.startsWith("//") ? path : "/"; +} + +/** + * Redirect the whole page into the login flow. The gateway + authenticator own + * the OIDC dance; we only hand them a `return_to`. Guarded so multiple 401s in + * flight don't stack redirects. + */ +export function signIn(returnTo?: string): void { + if (redirecting) return; + redirecting = true; + const dest = safeReturnTo(returnTo ?? window.location.pathname + window.location.search); + window.location.assign(`/auth/login?return_to=${encodeURIComponent(dest)}`); +} + +/** + * Revoke the session server-side, then follow the RP-initiated logout URL the + * authenticator returns (or fall back to the app root). + */ +export async function signOut(): Promise { + let dest = "/"; + try { + const res = await fetch("/auth/logout", { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }); + const body = (await res.json().catch(() => ({}))) as { rp_logout_url?: string | null }; + if (body.rp_logout_url) dest = body.rp_logout_url; + } catch { + // ignore — best-effort logout; still bounce the browser. + } + authStore.setUnauthenticated(); + window.location.assign(dest); +} + export type UseAuthResult = AuthSnapshot & { - signIn: () => Promise; + signIn: (returnTo?: string) => void; signOut: () => Promise; }; @@ -15,9 +54,5 @@ export function useAuth(): UseAuthResult { authStore.getSnapshot, authStore.getSnapshot, ); - return { - ...snap, - signIn: OidcManager.signIn, - signOut: OidcManager.signOut, - }; + return { ...snap, signIn, signOut }; } diff --git a/src/auth/use-viewer.test.ts b/src/auth/use-viewer.test.ts new file mode 100644 index 0000000..f878e19 --- /dev/null +++ b/src/auth/use-viewer.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { authStore } from "./auth-store"; +import { getViewerEmail } from "./use-viewer"; + +afterEach(() => { + authStore.reset(); +}); + +describe("getViewerEmail", () => { + it("returns the session email when authenticated", () => { + authStore.setAuthenticated({ + personId: "p-1", + email: "bob@example.com", + tenants: [], + roles: [], + }); + + expect(getViewerEmail()).toBe("bob@example.com"); + }); + + it("returns null when there is no session", () => { + authStore.setUnauthenticated(); + + expect(getViewerEmail()).toBeNull(); + }); +}); diff --git a/src/auth/use-viewer.ts b/src/auth/use-viewer.ts index f80aaea..e530573 100644 --- a/src/auth/use-viewer.ts +++ b/src/auth/use-viewer.ts @@ -1,87 +1,34 @@ import { useSyncExternalStore } from "react"; import { authStore } from "./auth-store"; -import { readDevUserEmail } from "./dev-config"; -import { getOverrideEmail, overrideStore } from "./impersonation"; - -const BUILD_DEV_VIEWER_EMAIL = - (import.meta.env.VITE_DEV_USER_EMAIL as string | undefined) ?? ""; - -const MOCK_VIEWER_EMAIL = "bob.park@example.com"; -const MOCKS_ENABLED = import.meta.env.VITE_ENABLE_MOCKS === "true"; - -export type ViewerSource = "override" | "oidc" | "dev" | "none"; +/** + * The current viewer, derived from the session summary (`/auth/me`). The SPA is + * email-keyed (org tree, IC routes), so `email` is the primary handle; + * `personId` (the gateway JWT `sub`) is exposed for callers that need the UUID. + */ export type Viewer = { email: string | null; - source: ViewerSource; + personId: string | null; }; -// Production builds only honor a dev email explicitly injected at runtime -// (window.__DEV_CONFIG__.devUserEmail). The build-time VITE_DEV_USER_EMAIL -// fallback is consulted only in Vite dev — otherwise a production build -// that happened to have a VITE_ value baked in would silently impersonate. -function resolveDevEmail(): string { - const runtime = readDevUserEmail(); - if (runtime) return runtime; - return import.meta.env.DEV ? BUILD_DEV_VIEWER_EMAIL : ""; -} - function resolve(): Viewer { - const override = getOverrideEmail(); - if (override) return { email: override, source: "override" }; - const snap = authStore.getSnapshot(); - if (snap.user?.email) return { email: snap.user.email, source: "oidc" }; - if (MOCKS_ENABLED) return { email: MOCK_VIEWER_EMAIL, source: "dev" }; - const devEmail = resolveDevEmail(); - if (devEmail) return { email: devEmail, source: "dev" }; - return { email: null, source: "none" }; + const { session } = authStore.getSnapshot(); + return { + email: session?.email ?? null, + personId: session?.personId ?? null, + }; } export function useViewer(): Viewer { - // Subscribe to authStore so the hook re-renders when the OIDC user changes - // (signIn, refresh, signOut). NOTE: `resolve()` returns a new object every - // call — no referential stability across renders. Three call sites today - // (routes/index, routes/ic.$person.team, components/app-sidebar) all - // destructure `email`, so the new ref doesn't propagate. If a caller ever - // passes the whole `Viewer` to a `React.memo`'d child, wrap in `useMemo` - // or memoize inside `resolve()`. useSyncExternalStore( authStore.subscribe, authStore.getSnapshot, authStore.getSnapshot, ); - useSyncExternalStore( - overrideStore.subscribe, - overrideStore.getSnapshot, - overrideStore.getSnapshot, - ); return resolve(); } export function getViewerEmail(): string | null { return resolve().email; } - -// Email to use for the unsigned dev-mode bearer token in fetch-with-auth. -// Returns null unless the active viewer source is dev-style (`dev` for -// MOCKS/runtime/build-time dev email, `override` for sessionStorage -// impersonation). This prevents a mid-bootstrap OIDC session — where -// authStore.token is null but a build-time dev email is also configured — -// from accidentally minting an unsigned JWT bearing the OIDC user's -// identity. The function is the source of truth for "is this request -// authenticated via dev impersonation?" and intentionally does NOT -// resolve to an OIDC viewer's email. -export function getDevBearerEmail(): string | null { - const v = resolve(); - return v.source === "dev" || v.source === "override" ? v.email : null; -} - -export function isDevImpersonating(): boolean { - // True only when the ACTIVE viewer source is dev-style — not when a - // dev email is merely configured. Without this check an OIDC session - // running alongside a build-time dev email would falsely show the - // impersonation banner / hint. - const { source } = resolve(); - return source === "dev" || source === "override"; -} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 8e4d4a9..b3960a2 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -10,7 +10,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useViewer } from "@/auth"; -import { DevImpersonationHint } from "@/components/dev-impersonation-hint"; import { SidebarV2Settings } from "@/components/sidebar-v2-settings"; import { ThemeSwitcher } from "@/components/theme-switcher"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -119,9 +118,7 @@ export function AppSidebar() { - {!viewerEmail ? ( - - ) : viewer ? ( + {viewer ? ( diff --git a/src/components/auth-gate.test.tsx b/src/components/auth-gate.test.tsx index 8c38345..79a1544 100644 --- a/src/components/auth-gate.test.tsx +++ b/src/components/auth-gate.test.tsx @@ -1,9 +1,8 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; -import "@/i18n"; +import i18n from "@/i18n"; import { authStore } from "@/auth/auth-store"; -import { OidcManager } from "@/auth/oidc-manager"; import { AuthGate } from "./auth-gate"; function renderGate() { @@ -14,46 +13,53 @@ function renderGate() { ); } +const redirectingText = () => i18n.t("auth.redirecting"); + describe("", () => { afterEach(() => { authStore.reset(); - vi.restoreAllMocks(); }); it("renders children when authenticated", () => { - act(() => authStore.setStatus("authenticated")); - renderGate(); - expect(screen.getByText("protected")).toBeInTheDocument(); - }); - - it("renders children when auth is disabled", () => { - act(() => authStore.setStatus("disabled", "dev_bypass")); + act(() => + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }), + ); renderGate(); expect(screen.getByText("protected")).toBeInTheDocument(); }); - it("renders children while renewing (transient, non-terminal)", () => { - act(() => authStore.setStatus("renewing")); + it("shows the redirect overlay instead of children while loading", () => { + // reset() leaves the store in its initial `loading` status. renderGate(); - expect(screen.getByText("protected")).toBeInTheDocument(); + expect(screen.queryByText("protected")).not.toBeInTheDocument(); + expect(screen.getByText(redirectingText())).toBeInTheDocument(); }); - it("shows the redirect overlay instead of children when reauth is required", () => { - act(() => authStore.setStatus("authenticated")); + it("shows the redirect overlay instead of children when unauthenticated", () => { + act(() => authStore.setUnauthenticated()); renderGate(); - act(() => authStore.setStatus("reauth_required", "refresh_failed")); expect(screen.queryByText("protected")).not.toBeInTheDocument(); - expect(screen.getByRole("status")).toBeInTheDocument(); + expect(screen.getByText(redirectingText())).toBeInTheDocument(); }); - it("offers a retry that re-triggers reauth when the redirect failed", () => { - const requireReauth = vi - .spyOn(OidcManager, "requireReauth") - .mockResolvedValue(undefined); - act(() => authStore.setStatus("reauth_failed", "refresh_failed")); + it("swaps to the overlay when a live session drops to unauthenticated", () => { + act(() => + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }), + ); renderGate(); + expect(screen.getByText("protected")).toBeInTheDocument(); + act(() => authStore.setUnauthenticated()); expect(screen.queryByText("protected")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button")); - expect(requireReauth).toHaveBeenCalledTimes(1); + expect(screen.getByText(redirectingText())).toBeInTheDocument(); }); }); diff --git a/src/components/auth-gate.tsx b/src/components/auth-gate.tsx index 392610b..f41090b 100644 --- a/src/components/auth-gate.tsx +++ b/src/components/auth-gate.tsx @@ -1,7 +1,6 @@ import { useTranslation } from "react-i18next"; -import { OidcManager, useAuth } from "@/auth"; -import { AuthError } from "@/components/auth-error"; +import { useAuth } from "@/auth"; import { FullScreenLoading } from "@/components/full-screen-loading"; type AuthGateProps = { @@ -9,31 +8,20 @@ type AuthGateProps = { }; /** - * Presents the runtime auth state. The redirect itself is owned by - * `OidcManager.requireReauth()` (called from the 401 path and the silent-renew - * failure), so this gate only renders: - * - `reauth_required` → the redirect overlay, rendered instead of `children` - * so the 401'd subtree never enters the tree and no error cells paint. - * - `reauth_failed` → a recoverable error with a retry, so a failed - * redirect never pins the app behind a permanent overlay. + * Renders the cookie/BFF auth state. The redirect into the login flow is owned + * by the root `beforeLoad` (and the 401 path in `fetchWithAuth`); this gate only + * withholds the app subtree until a session is confirmed: + * - `loading` → the boot `/auth/me` probe is in flight. + * - `unauthenticated` → a full-page redirect to `/auth/login` is in flight, + * so keep showing the overlay rather than painting a 401'd tree. + * - `authenticated` → render the app. */ export function AuthGate({ children }: AuthGateProps): React.ReactNode { const { t } = useTranslation(); const { status } = useAuth(); - if (status === "reauth_required") { - return ; + if (status !== "authenticated") { + return ; } - - if (status === "reauth_failed") { - return ( - void OidcManager.requireReauth()} - /> - ); - } - return children; } diff --git a/src/components/dev-impersonation-hint.tsx b/src/components/dev-impersonation-hint.tsx deleted file mode 100644 index 882d020..0000000 --- a/src/components/dev-impersonation-hint.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { TriangleAlertIcon } from "lucide-react"; -import { Trans, useTranslation } from "react-i18next"; - -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; - -export function DevImpersonationHint(): React.ReactElement | null { - // DEV-only diagnostic. In prod the root guard either lets a real auth'd - // user through or redirects to the IdP — this branch is unreachable, so - // the whole component tree-shakes out via Vite's import.meta.env.DEV - // constant. Hook call stays above the early-return so React-Hooks ESLint - // is happy and per-render order is stable in dev. - const { t } = useTranslation(); - if (!import.meta.env.DEV) return null; - return ( -
- - - {t("dev_impersonation_hint.title")} - - }} - /> - - -
- ); -} diff --git a/src/components/impersonation-banner.tsx b/src/components/impersonation-banner.tsx deleted file mode 100644 index 786f10a..0000000 --- a/src/components/impersonation-banner.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { UserCogIcon } from "lucide-react"; -import { Trans, useTranslation } from "react-i18next"; - -import { clearOverride, useViewer } from "@/auth"; -import { - Alert, - AlertAction, - AlertDescription, - AlertTitle, -} from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; - -function handleStop(): void { - clearOverride(); - window.location.assign("/"); -} - -export function ImpersonationBanner(): React.ReactElement | null { - const { email, source } = useViewer(); - const { t } = useTranslation(); - - if (source !== "override" || !email) return null; - - return ( - - - {t("impersonation_banner.title")} - - }} - /> - - - - - - ); -} diff --git a/src/components/widgets/v2/counters-block.test.tsx b/src/components/widgets/v2/counters-block.test.tsx index f746dbd..ae21422 100644 --- a/src/components/widgets/v2/counters-block.test.tsx +++ b/src/components/widgets/v2/counters-block.test.tsx @@ -60,7 +60,12 @@ const STATS: PeerStats = { p25: 3, p50: 5, p75: 10, min: 1, max: 15, n: 12 }; describe("", () => { beforeEach(() => { authStore.reset(); - authStore.setTenantId("t-1"); + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }); fetchCatalog.mockReset(); }); afterEach(() => { diff --git a/src/components/widgets/v2/distribution-strip.test.tsx b/src/components/widgets/v2/distribution-strip.test.tsx index 3929795..a3364f6 100644 --- a/src/components/widgets/v2/distribution-strip.test.tsx +++ b/src/components/widgets/v2/distribution-strip.test.tsx @@ -59,7 +59,12 @@ const STATS: PeerStats = { p25: 3, p50: 5, p75: 10, min: 1, max: 15, n: 12 }; describe("", () => { beforeEach(() => { authStore.reset(); - authStore.setTenantId("t-1"); + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }); fetchCatalog.mockReset(); }); afterEach(() => { diff --git a/src/components/widgets/v2/members-heatmap/index.test.tsx b/src/components/widgets/v2/members-heatmap/index.test.tsx index fe3d985..70326f5 100644 --- a/src/components/widgets/v2/members-heatmap/index.test.tsx +++ b/src/components/widgets/v2/members-heatmap/index.test.tsx @@ -168,7 +168,12 @@ function deptMap( describe("", () => { beforeEach(() => { authStore.reset(); - authStore.setTenantId("t-1"); + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }); fetchCatalog.mockReset(); }); afterEach(() => { diff --git a/src/components/widgets/v2/section-card.test.tsx b/src/components/widgets/v2/section-card.test.tsx index c97f57c..c161cdc 100644 --- a/src/components/widgets/v2/section-card.test.tsx +++ b/src/components/widgets/v2/section-card.test.tsx @@ -57,7 +57,12 @@ function makeBullet(overrides: Partial = {}): BulletMetric { describe(" peer-driven coloring", () => { beforeEach(() => { authStore.reset(); - authStore.setTenantId("t-1"); + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }); fetchCatalog.mockReset(); }); afterEach(() => { diff --git a/src/components/widgets/v2/team-members-attention.test.tsx b/src/components/widgets/v2/team-members-attention.test.tsx index 73310ce..5b9f08f 100644 --- a/src/components/widgets/v2/team-members-attention.test.tsx +++ b/src/components/widgets/v2/team-members-attention.test.tsx @@ -125,7 +125,12 @@ function deptMap( describe("", () => { beforeEach(() => { authStore.reset(); - authStore.setTenantId("t-1"); + authStore.setAuthenticated({ + personId: "p-1", + email: "bob.park@example.com", + tenants: ["t-1"], + roles: ["user"], + }); fetchCatalog.mockReset(); }); afterEach(() => { diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index c72c6e4..6f4e79a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -191,17 +191,7 @@ "footer": "Insight · what's new · 13 July 2026" }, "auth": { - "callback": { - "completing": "Completing sign-in...", - "failed_title": "Sign-in failed", - "missing_code": "No authorization code in the callback URL.", - "exchange_failed": "We couldn't exchange the authorization code for a session." - }, - "reauth": { - "redirecting": "Session expired — redirecting to sign-in…", - "failed_title": "Couldn't reach sign-in", - "failed_message": "We couldn't redirect you to sign in. Check your connection and try again." - } + "redirecting": "Redirecting to sign in…" }, "error_boundary": { "title": "Something went wrong" @@ -210,15 +200,6 @@ "title": "Synthetic data", "description_html": "UI is rendering using mock data." }, - "impersonation_banner": { - "title": "Impersonation active", - "description_html": "Viewing as {{email}}", - "stop": "Stop" - }, - "dev_impersonation_hint": { - "title": "Dev impersonation not configured", - "description_html": "Nothing to display. Either set VITE_DEV_USER_EMAIL in .env.local to a person email present in the identity service, or configure window.__OIDC_CONFIG__ via public/oidc-config.js and sign in." - }, "members_table": { "view_team_stats": "View team stats", "click_hint": "Click member to open IC dashboard" diff --git a/src/main.tsx b/src/main.tsx index 63ab7c3..673fce8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,16 +6,13 @@ import { I18nextProvider } from "react-i18next"; import "./index.css"; import { CatalogProvider } from "@/api/catalog-provider"; -import { OidcManager, captureOverrideFromUrl, storeStartUrl } from "@/auth"; +import { loadSession } from "@/auth"; import { AppErrorBoundary } from "@/components/app-error-boundary"; import { ThemeProvider } from "@/components/theme-provider"; import i18n from "@/i18n"; import { queryClient } from "@/query-client"; import { router } from "./router"; -captureOverrideFromUrl(); -storeStartUrl(); - async function enableMocking(): Promise { if (!import.meta.env.DEV) return; if (import.meta.env.VITE_ENABLE_MOCKS !== "true") return; @@ -24,21 +21,23 @@ async function enableMocking(): Promise { } void enableMocking() - .then(() => OidcManager.init()) + // Probe the session once (mocks, if enabled, intercept /auth/me) before the + // router mounts, so the root beforeLoad reads a resolved auth store. + .then(() => loadSession()) .then(() => { - createRoot(document.getElementById("root")!).render( - - - - - - - - - - - - - , - ); -}); + createRoot(document.getElementById("root")!).render( + + + + + + + + + + + + + , + ); + }); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 528ff3e..f9236e3 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -310,7 +310,20 @@ interface BatchQueryRequest { }>; } +// Stable synthetic session for mock/Storybook runs. The old in-code +// MOCKS_ENABLED viewer path is gone; an authenticated viewer now comes from +// the same `/auth/me` probe the real app uses, so the boot `loadSession()` +// call resolves to `authenticated` against these handlers. +const MOCK_SESSION = { + user: "00000000-0000-0000-0000-0000000000bb", + email: defaultPersonId, + tenants: ["00000000-0000-0000-0000-000000000001"], + roles: ["user"], +}; + export const handlers = [ + http.get("/auth/me", () => HttpResponse.json(MOCK_SESSION)), + http.post("/auth/logout", () => HttpResponse.json({ rp_logout_url: null })), http.post("/api/analytics/v1/metric-results", async ({ request }) => { const body = (await request .json() @@ -367,10 +380,13 @@ export const handlers = [ return HttpResponse.json(buildMockCatalogResponse(tenantId)); }, ), - http.get( - "/api/identity/v1/persons/:email", - ({ params }) => { - const email = decodeURIComponent(params.email as string); + http.post( + "/api/identity/v1/profiles", + async ({ request }) => { + const body = (await request.json().catch(() => null)) as + | { value_type?: string; value?: string } + | null; + const email = body?.value ?? ""; const tree = buildIdentityTree(email); if (!tree) { return HttpResponse.json( diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a62857b..6a9d8e5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,7 +10,6 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as WhatsNewRouteImport } from './routes/whats-new' -import { Route as CallbackRouteImport } from './routes/callback' import { Route as IndexRouteImport } from './routes/index' import { Route as IcPersonRouteImport } from './routes/ic.$person' import { Route as IcPersonIndexRouteImport } from './routes/ic.$person.index' @@ -22,11 +21,6 @@ const WhatsNewRoute = WhatsNewRouteImport.update({ path: '/whats-new', getParentRoute: () => rootRouteImport, } as any) -const CallbackRoute = CallbackRouteImport.update({ - id: '/callback', - path: '/callback', - getParentRoute: () => rootRouteImport, -} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -55,7 +49,6 @@ const IcPersonPersonalRoute = IcPersonPersonalRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute - '/callback': typeof CallbackRoute '/whats-new': typeof WhatsNewRoute '/ic/$person': typeof IcPersonRouteWithChildren '/ic/$person/personal': typeof IcPersonPersonalRoute @@ -64,7 +57,6 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute - '/callback': typeof CallbackRoute '/whats-new': typeof WhatsNewRoute '/ic/$person/personal': typeof IcPersonPersonalRoute '/ic/$person/team': typeof IcPersonTeamRoute @@ -73,7 +65,6 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute - '/callback': typeof CallbackRoute '/whats-new': typeof WhatsNewRoute '/ic/$person': typeof IcPersonRouteWithChildren '/ic/$person/personal': typeof IcPersonPersonalRoute @@ -84,7 +75,6 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/callback' | '/whats-new' | '/ic/$person' | '/ic/$person/personal' @@ -93,7 +83,6 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' - | '/callback' | '/whats-new' | '/ic/$person/personal' | '/ic/$person/team' @@ -101,7 +90,6 @@ export interface FileRouteTypes { id: | '__root__' | '/' - | '/callback' | '/whats-new' | '/ic/$person' | '/ic/$person/personal' @@ -111,7 +99,6 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute - CallbackRoute: typeof CallbackRoute WhatsNewRoute: typeof WhatsNewRoute IcPersonRoute: typeof IcPersonRouteWithChildren } @@ -125,13 +112,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WhatsNewRouteImport parentRoute: typeof rootRouteImport } - '/callback': { - id: '/callback' - path: '/callback' - fullPath: '/callback' - preLoaderRoute: typeof CallbackRouteImport - parentRoute: typeof rootRouteImport - } '/': { id: '/' path: '/' @@ -188,7 +168,6 @@ const IcPersonRouteWithChildren = IcPersonRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, - CallbackRoute: CallbackRoute, WhatsNewRoute: WhatsNewRoute, IcPersonRoute: IcPersonRouteWithChildren, } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index bfd510e..7df768f 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -2,10 +2,9 @@ import { Outlet, createRootRoute } from "@tanstack/react-router"; import { TooltipProvider } from "@/components/ui/tooltip"; import { getPerson } from "@/api/identity-client"; -import { OidcManager, authStore, getViewerEmail } from "@/auth"; +import { authStore, getViewerEmail, signIn } from "@/auth"; import { AppSidebar } from "@/components/app-sidebar"; import { AuthGate } from "@/components/auth-gate"; -import { ImpersonationBanner } from "@/components/impersonation-banner"; import { MockBanner } from "@/components/mock-banner"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { queryClient } from "@/query-client"; @@ -21,23 +20,16 @@ async function prefetchViewerIdentity(): Promise { } export const Route = createRootRoute({ - beforeLoad: async ({ location }) => { - if (location.pathname === "/callback") return; - await OidcManager.init(); - + beforeLoad: async () => { + // The session was probed once at boot (main.tsx → loadSession), so the + // store is already resolved here. No client-side token dance — an absent + // session means a full-page bounce to the gateway's login flow. const { status } = authStore.getSnapshot(); if (status === "authenticated") { await prefetchViewerIdentity(); return; } - // No interactive login to perform when OIDC is inactive — let the app - // render (dev bypass works; an unconfigured deploy fails closed downstream). - if (status === "disabled") return; - - // requireReauth never rejects: a redirect that can't start lands in - // `reauth_failed` and the route still mounts, so AuthGate shows the retry - // UI instead of throwing an unhandled error out of beforeLoad. - await OidcManager.requireReauth(); + signIn(window.location.pathname + window.location.search); }, component: RootLayout, }); @@ -49,7 +41,6 @@ function RootLayout() { - diff --git a/src/routes/callback.tsx b/src/routes/callback.tsx deleted file mode 100644 index e518969..0000000 --- a/src/routes/callback.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { useTranslation } from "react-i18next"; - -import { OidcManager, getStartUrl } from "@/auth"; -import { AuthError } from "@/components/auth-error"; -import { Card, CardContent } from "@/components/ui/card"; -import { Spinner } from "@/components/ui/spinner"; - -type CallbackResult = - | { ok: true; returnUrl: string } - | { ok: false; error: "missing_code" | "exchange_failed" }; - -export const Route = createFileRoute("/callback")({ - loader: async (): Promise => { - const url = getStartUrl(); - if (!url) return { ok: false, error: "missing_code" }; - let hasCode: boolean; - try { - hasCode = new URL(url).searchParams.has("code"); - } catch { - hasCode = false; - } - if (!hasCode) return { ok: false, error: "missing_code" }; - try { - const returnUrl = await OidcManager.handleCallback(url); - return { ok: true, returnUrl }; - } catch { - return { ok: false, error: "exchange_failed" }; - } - }, - component: CallbackScreen, -}); - -function CallbackScreen() { - const data = Route.useLoaderData(); - const { t } = useTranslation(); - - useEffect(() => { - if (data.ok) { - window.location.replace(data.returnUrl); - } - }, [data]); - - if (data.ok) { - return ( -
- - - -

- {t("auth.callback.completing")} -

-
-
-
- ); - } - - return ( - void OidcManager.signIn().catch(() => undefined)} - /> - ); -} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 1a0e6d2..a52229a 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useViewer } from "@/auth"; -import { DevImpersonationHint } from "@/components/dev-impersonation-hint"; +import { FullScreenLoading } from "@/components/full-screen-loading"; import { IcDashboardScreen } from "@/screens/ic-dashboard"; export const Route = createFileRoute("/")({ @@ -10,7 +10,8 @@ export const Route = createFileRoute("/")({ function IndexRoute() { const { email } = useViewer(); - if (email) return ; - // Dev-only state — guard filters prod into signIn(). - return ; + // An authenticated session always carries an email; the loading fallback + // only shows in the brief window before the store resolves. + if (!email) return ; + return ; } diff --git a/src/test/catalog-test-utils.tsx b/src/test/catalog-test-utils.tsx index 26ef34e..4d28430 100644 --- a/src/test/catalog-test-utils.tsx +++ b/src/test/catalog-test-utils.tsx @@ -28,7 +28,7 @@ export function buildCatalogResponse( rows: Array>, ): CatalogResponse { return { - tenant_id: authStore.getSnapshot().tenantId ?? "t-1", + tenant_id: authStore.getSnapshot().session?.tenants[0] ?? "t-1", generated_at: "2026-06-01T00:00:00Z", metrics: rows.map((r, i) => ({ id: r.id ?? `id-${i}`, diff --git a/vite.config.ts b/vite.config.ts index 1f2f9b0..8a20932 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -22,11 +22,21 @@ export default defineConfig(({ mode }) => { }, server: proxyTarget ? { + // Proxy both /api and /auth to the gateway dev target so the + // cookie/BFF flow works under `pnpm dev`: /auth/login, /auth/me, + // /auth/logout and all /api/* calls hit the same gateway origin. + // changeOrigin keeps the upstream Host correct; the `__Host-sid` + // cookie works over http://localhost because the proxy leaves the + // response Set-Cookie untouched (no cookie-domain rewrite). proxy: { "/api": { target: proxyTarget, changeOrigin: true, }, + "/auth": { + target: proxyTarget, + changeOrigin: true, + }, }, } : undefined,