diff --git a/backend/auth-core/src/database.rs b/backend/auth-core/src/database.rs index 2126c918d..6d754e8e2 100644 --- a/backend/auth-core/src/database.rs +++ b/backend/auth-core/src/database.rs @@ -79,6 +79,34 @@ pub async fn read_token_from_database( Ok(stored) } +/// Function only checks to verify /auth/status in auth-gateway +/// Any further changes to this should consider need for decoding the token +pub async fn token_exists_in_database( + connection: &DatabaseConnection, + subject: &SubjectIdentifier, +) -> Result { + info!( + subject = subject.as_str(), + "Checking token presence in database" + ); + let row = entity::oidc_tokens::Entity::find() + .filter(entity::oidc_tokens::Column::Subject.eq(subject.as_str())) + .one(connection) + .await?; + + let Some(row) = row else { + return Ok(false); + }; + + if let Some(expires_at) = row.expires_at + && to_utc(expires_at) < Utc::now() + { + return Ok(false); + } + + Ok(true) +} + pub async fn write_token_to_database( connection: &DatabaseConnection, token: &impl RefreshTokenInfo, diff --git a/backend/auth-core/src/oidc.rs b/backend/auth-core/src/oidc.rs index 0b0a27a34..ceb3db63d 100644 --- a/backend/auth-core/src/oidc.rs +++ b/backend/auth-core/src/oidc.rs @@ -1,9 +1,13 @@ use crate::config::CommonConfig; use anyhow::anyhow; -use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use base64::{ + Engine, + engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD}, +}; +use chrono::{DateTime, Utc}; use oauth2::{ClientId, ClientSecret, EndpointMaybeSet, EndpointNotSet, EndpointSet, reqwest}; use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreTokenResponse}; -use openidconnect::{IssuerUrl, RefreshToken}; +use openidconnect::{IssuerUrl, RefreshToken, SubjectIdentifier}; use sea_orm::{Database, DatabaseConnection}; use sodiumoxide::crypto::box_::{PublicKey, SecretKey}; @@ -68,6 +72,32 @@ pub fn decode_secret_key(base64_key: &str) -> Result { Ok(SecretKey::from_slice(&BASE64.decode(base64_key)?).ok_or(anyhow!("Invalid secret key"))?) } +/// The subset of access-token claims the gateway's `/auth/status` endpoint needs. +pub struct AccessTokenClaims { + pub subject: SubjectIdentifier, + pub expires_at: Option>, +} + +pub fn claims_from_access_token(access_token: &str) -> Result { + let payload = access_token + .split('.') + .nth(1) + .ok_or_else(|| anyhow!("access token is not a well-formed JWT"))?; + let decoded = URL_SAFE_NO_PAD.decode(payload)?; + + #[derive(serde::Deserialize)] + struct RawClaims { + sub: String, + exp: Option, + } + let raw: RawClaims = serde_json::from_slice(&decoded)?; + let expires_at = raw.exp.and_then(|exp| DateTime::from_timestamp(exp, 0)); + Ok(AccessTokenClaims { + subject: SubjectIdentifier::new(raw.sub), + expires_at, + }) +} + pub async fn exchange_refresh_token( oidc_client: &OidcClient, http_client: &reqwest::Client, diff --git a/backend/auth-gateway/src/main.rs b/backend/auth-gateway/src/main.rs index a9c9eccdf..9a34134f3 100644 --- a/backend/auth-gateway/src/main.rs +++ b/backend/auth-gateway/src/main.rs @@ -18,8 +18,9 @@ use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer, cookie:: type Result = std::result::Result; use axum::{ - Router, + Json, Router, extract::{Request, State}, + http::HeaderMap, middleware, response::IntoResponse, routing::{get, post}, @@ -87,6 +88,13 @@ fn create_router(state: Arc, graph_url: String) -> Router { AllowOrigin::default() }; + // Separate CorsLayer that allows any origin for `/auth/status` since it is + // authorized by a bearer token + let status_cors = CorsLayer::new() + .allow_origin(AllowOrigin::any()) + .allow_methods([Method::GET, Method::OPTIONS]) + .allow_headers([hyper::header::AUTHORIZATION, hyper::header::CONTENT_TYPE]); + Router::new() .fallback_service(proxy) .layer(middleware::from_fn_with_state( @@ -109,6 +117,10 @@ fn create_router(state: Arc, graph_url: String) -> Router { .allow_origin(cors_origin) .allow_credentials(true), ) + // Registered *after* the credentialed CORS layer so it is not wrapped by + // it any other routes should be wrapped by CorsLayer above if not having a similar bearer + // token authentication to `/auth/status` + .route("/auth/status", get(status).layer(status_cors)) .with_state(state) } @@ -146,6 +158,38 @@ async fn logout(State(state): State>, session: Session) -> Result< Ok(axum::http::StatusCode::OK) } +/// Status handler that returns the user's authentication status as a JSON true or false response body +/// Response is marked cacheable to reduce load on databse +async fn status( + State(state): State>, + headers: HeaderMap, +) -> Result { + let cache_headers = [ + (hyper::header::CACHE_CONTROL, "private, max-age=30"), + (hyper::header::VARY, "Authorization"), + ]; + + let access_token = headers + .get(hyper::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or_else(|| anyhow::anyhow!("missing or malformed Authorization header"))?; + + let claims = auth_core::oidc::claims_from_access_token(access_token)?; + + if let Some(expires_at) = claims.expires_at + && expires_at <= chrono::Utc::now() + { + return Ok((cache_headers, Json(false))); + } + + let is_authenticated = + auth_core::database::token_exists_in_database(&state.database_connection, &claims.subject) + .await?; + + Ok((cache_headers, Json(is_authenticated))) +} + async fn shutdown_signal() { let mut sigterm: Signal = signal(SignalKind::terminate()).expect("Failed to listen for SIGTERM"); diff --git a/charts/dashboard/Chart.yaml b/charts/dashboard/Chart.yaml index bdb4b32ad..386af0861 100644 --- a/charts/dashboard/Chart.yaml +++ b/charts/dashboard/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: dashboard description: A dashboard for Diamond workflows type: application -version: 0.2.28 +version: 0.2.29 appVersion: 0.1.15 dependencies: - name: common diff --git a/charts/dashboard/staging-values.yaml b/charts/dashboard/staging-values.yaml index 85ddf8957..5dc7ba615 100644 --- a/charts/dashboard/staging-values.yaml +++ b/charts/dashboard/staging-values.yaml @@ -7,7 +7,7 @@ configuration: graphWsUrl: wss://staging.workflows.diamond.ac.uk/api/ws sourceDir: /usr/share/nginx/html useAuthGateway: "true" - authGatewayLoginUrl: https://staging.workflows.diamond.ac.uk/auth/login + authGatewayLoginUrl: https://staging.workflows.diamond.ac.uk/auth ingress: hosts: diff --git a/charts/dashboard/values.yaml b/charts/dashboard/values.yaml index bdc31746b..18621d8e2 100644 --- a/charts/dashboard/values.yaml +++ b/charts/dashboard/values.yaml @@ -7,7 +7,7 @@ configuration: graphWsUrl: wss://workflows.diamond.ac.uk/graphql/ws sourceDir: "/usr/share/nginx/html" useAuthGateway: "false" - authGatewayLoginUrl: https://workflows.diamond.ac.uk/auth/login + authGatewayLoginUrl: https://workflows.diamond.ac.uk/auth image: registry: ghcr.io diff --git a/charts/prek.toml b/charts/prek.toml index 4893981a3..58eee154b 100644 --- a/charts/prek.toml +++ b/charts/prek.toml @@ -5,8 +5,9 @@ hooks = [ id = "prettier", name = "prettier (charts yaml)", language = "system", - entry = "npx --yes prettier@3.6.2 --write", + entry = "npx --cache /tmp/prek-npx-charts --yes prettier@3.6.2 --write", files = "^[^/]+/[^/]*\\.ya?ml$", pass_filenames = true, + require_serial = true, }, ] diff --git a/frontend/prek.toml b/frontend/prek.toml index ce644bd60..c384b8bb0 100644 --- a/frontend/prek.toml +++ b/frontend/prek.toml @@ -5,7 +5,7 @@ hooks = [ id = "prettier", name = "prettier", language = "system", - entry = "npx --yes prettier@3.6.2 --config .prettierrc --ignore-path ../.gitignore --check \"**/*.{js,ts,tsx,json}\"", + entry = "npx --cache /tmp/prek-npx-frontend --yes prettier@3.6.2 --config .prettierrc --ignore-path ../.gitignore --check \"**/*.{js,ts,tsx,json}\"", files = "\\.(js|ts|tsx|json)$", pass_filenames = false, }, diff --git a/frontend/relay-workflows-lib/lib/components/RelayEnvironment.ts b/frontend/relay-workflows-lib/lib/components/RelayEnvironment.ts index 70de44ff3..2a2524375 100644 --- a/frontend/relay-workflows-lib/lib/components/RelayEnvironment.ts +++ b/frontend/relay-workflows-lib/lib/components/RelayEnvironment.ts @@ -12,14 +12,13 @@ import { getKeycloak } from "../utils/keycloak"; import { createClient } from "graphql-ws"; import { AuthState } from "@diamondlightsource/sci-react-ui"; import { parseJwt } from "../utils/coreUtils"; -import { JSONObject } from "workflows-lib"; +import { JSONObject, buildLoginUrl } from "workflows-lib"; import { getUseAuthGateway } from "../utils/useAuthGateway"; const HTTP_ENDPOINT = import.meta.env.VITE_GRAPH_URL; const WS_ENDPOINT = import.meta.env.VITE_GRAPH_WS_URL; const KEYCLOAK_SCOPE = import.meta.env.VITE_KEYCLOAK_SCOPE; const USE_AUTH_GATEWAY = getUseAuthGateway(); -const AUTH_GATEWAY_LOGIN_URL = import.meta.env.VITE_AUTH_GATEWAY_LOGIN_URL; const keycloak = await getKeycloak(); @@ -87,8 +86,7 @@ const fetchFn: FetchFunction = async (request, variables) => { } const resp = await fetch(HTTP_ENDPOINT, fetchOptions); if (USE_AUTH_GATEWAY && resp.status === 401) { - const returnTo = encodeURIComponent(window.location.href); - window.location.assign(`${AUTH_GATEWAY_LOGIN_URL}?returnTo=${returnTo}`); + window.location.assign(buildLoginUrl(window.location.href)); return {}; } diff --git a/frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx b/frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx new file mode 100644 index 000000000..dcd997b85 --- /dev/null +++ b/frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx @@ -0,0 +1,80 @@ +import CircleIcon from "@mui/icons-material/Circle"; +import { IconButton, Stack, Tooltip, Typography } from "@mui/material"; +import { useAuthStatus } from "../../hooks/useAuthStatus"; +import { buildLoginUrl } from "../../utils/authUtils"; + +export interface AuthStatusIndicatorProps { + accessToken?: string; + /** Defaults to the current origin, where the gateway is served under `/auth`. */ + gatewayUrl?: string; + cacheTtlMs?: number; + size?: number; + returnTo?: string; +} + +const AuthStatusIndicator = ({ + accessToken, + gatewayUrl, + cacheTtlMs, + size = 20, + returnTo, +}: AuthStatusIndicatorProps) => { + const { authenticated } = useAuthStatus({ + accessToken, + gatewayUrl, + cacheTtlMs, + }); + + const handleClick = () => { + if (authenticated) return; + window.open( + buildLoginUrl(returnTo, gatewayUrl), + "_blank", + "noopener,noreferrer", + ); + }; + + const text = authenticated + ? "Workflows Authenticated" + : "Workflows Unauthenticated"; + const tooltip = authenticated ? text : `${text} — click to log in`; + + return ( + + + + + + {text} + + + + + ); +}; + +export default AuthStatusIndicator; diff --git a/frontend/workflows-lib/lib/hooks/useAuthStatus.ts b/frontend/workflows-lib/lib/hooks/useAuthStatus.ts new file mode 100644 index 000000000..b5dcc34d6 --- /dev/null +++ b/frontend/workflows-lib/lib/hooks/useAuthStatus.ts @@ -0,0 +1,70 @@ +import { useEffect, useMemo, useState } from "react"; +import { + authStatusCacheKey, + fetchAuthStatus, + readAuthStatusCache, + writeAuthStatusCache, +} from "../utils/authUtils"; + +export interface UseAuthStatusOptions { + accessToken?: string; + gatewayUrl?: string; + cacheTtlMs?: number; +} + +export interface AuthStatus { + authenticated: boolean; + loading: boolean; +} + +interface FetchedStatus { + cacheKey: string; + authenticated: boolean; +} + +export function useAuthStatus({ + accessToken, + gatewayUrl, + cacheTtlMs = 30000, +}: UseAuthStatusOptions): AuthStatus { + const cacheKey = useMemo( + () => (accessToken ? authStatusCacheKey(accessToken, gatewayUrl) : null), + [accessToken, gatewayUrl], + ); + + const known = useMemo( + () => (cacheKey ? readAuthStatusCache(cacheKey, cacheTtlMs) : false), + [cacheKey, cacheTtlMs], + ); + + const [fetched, setFetched] = useState(null); + + useEffect(() => { + if (!accessToken || !cacheKey || known !== null) return; + + const controller = new AbortController(); + void fetchAuthStatus({ + accessToken, + gatewayUrl, + signal: controller.signal, + }) + .then((authenticated) => { + writeAuthStatusCache(cacheKey, authenticated); + setFetched({ cacheKey, authenticated }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + console.error("Failed to check authentication status", error); + setFetched({ cacheKey, authenticated: false }); + }); + + return () => { + controller.abort(); + }; + }, [cacheKey, known, accessToken, gatewayUrl]); + + const resolved = + known ?? (fetched?.cacheKey === cacheKey ? fetched.authenticated : null); + + return { authenticated: resolved ?? false, loading: resolved === null }; +} diff --git a/frontend/workflows-lib/lib/main.ts b/frontend/workflows-lib/lib/main.ts index 58519d320..dc9d72eef 100644 --- a/frontend/workflows-lib/lib/main.ts +++ b/frontend/workflows-lib/lib/main.ts @@ -20,8 +20,14 @@ export { } from "./components/common/RepositoryLinkBase"; export { default as WorkflowErrorBoundaryWithRetry } from "./components/workflow/WorkflowErrorBoundaryWithRetry"; export { default as WorkflowErrorBoundary } from "./components/workflow/WorkflowsErrorBoundary"; +export { + default as AuthStatusIndicator, + type AuthStatusIndicatorProps, +} from "./components/common/AuthStatusIndicator"; export * from "./components/common/StatusIcons"; +export * from "./hooks/useAuthStatus"; export * from "./types"; +export * from "./utils/authUtils"; export * from "./utils/commonUtils"; export * from "./utils/tasksFlowUtils"; export * from "../tests/components/data"; diff --git a/frontend/workflows-lib/lib/utils/authUtils.ts b/frontend/workflows-lib/lib/utils/authUtils.ts new file mode 100644 index 000000000..8fccddcd5 --- /dev/null +++ b/frontend/workflows-lib/lib/utils/authUtils.ts @@ -0,0 +1,83 @@ +export interface AuthStatusRequest { + accessToken: string; + gatewayUrl?: string; + signal?: AbortSignal; +} + +interface CachedStatus { + authenticated: boolean; + checkedAt: number; +} + +const CACHE_KEY_PREFIX = "workflows-auth-status"; + +export function baseGatewayUrl(gatewayUrl?: string): string { + const base = gatewayUrl ?? window.location.origin; + return base.replace(/\/+$/, "").replace(/\/auth(\/(login|status))?$/, ""); +} + +export async function fetchAuthStatus({ + accessToken, + gatewayUrl, + signal, +}: AuthStatusRequest): Promise { + const response = await fetch(`${baseGatewayUrl(gatewayUrl)}/auth/status`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal, + }); + return response.ok ? ((await response.json()) as boolean) : false; +} + +export function buildLoginUrl(returnTo?: string, gatewayUrl?: string): string { + const loginUrl = new URL(`${baseGatewayUrl(gatewayUrl)}/auth/login`); + if (returnTo) loginUrl.searchParams.set("returnTo", returnTo); + return loginUrl.toString(); +} + +function hash(value: string): string { + let result = 5381; + for (let i = 0; i < value.length; i++) { + result = (result * 33) ^ value.charCodeAt(i); + } + return (result >>> 0).toString(36); +} + +export function authStatusCacheKey( + accessToken: string, + gatewayUrl?: string, +): string { + const scope = `${baseGatewayUrl(gatewayUrl)}|${accessToken}`; + return `${CACHE_KEY_PREFIX}:${hash(scope)}`; +} + +export function readAuthStatusCache( + cacheKey: string, + ttlMs: number, +): boolean | null { + try { + const raw = sessionStorage.getItem(cacheKey); + if (!raw) return null; + const cached = JSON.parse(raw) as CachedStatus; + if (Date.now() - cached.checkedAt > ttlMs) return null; + return cached.authenticated; + } catch { + return null; + } +} + +export function writeAuthStatusCache( + cacheKey: string, + authenticated: boolean, +): void { + try { + sessionStorage.setItem( + cacheKey, + JSON.stringify({ + authenticated, + checkedAt: Date.now(), + } satisfies CachedStatus), + ); + } catch { + // sessionStorage may be unavailable or full; caching is best-effort + } +} diff --git a/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx b/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx new file mode 100644 index 000000000..ec0f7e465 --- /dev/null +++ b/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx @@ -0,0 +1,50 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { ThemeProvider, DiamondTheme } from "@diamondlightsource/sci-react-ui"; +import { AuthStatusIndicator } from "../lib/main"; + +const meta: Meta = { + title: "AuthStatusIndicator", + component: AuthStatusIndicator, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +type Story = StoryObj; + +export default meta; + +const mockAuthStatus = (authenticated: boolean) => { + window.fetch = () => + Promise.resolve(new Response(JSON.stringify(authenticated))); +}; + +export const Unauthenticated: Story = { + decorators: [ + (Story) => { + sessionStorage.clear(); + mockAuthStatus(false); + return ; + }, + ], + args: { + accessToken: "example-token", + }, +}; + +export const Authenticated: Story = { + decorators: [ + (Story) => { + sessionStorage.clear(); + mockAuthStatus(true); + return ; + }, + ], + args: { + accessToken: "example-token", + }, +}; diff --git a/frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx b/frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx new file mode 100644 index 000000000..2071f1427 --- /dev/null +++ b/frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import userEvent from "@testing-library/user-event"; +import { AuthStatusIndicator } from "../../lib/main"; + +const origin = window.location.origin; + +const mockFetch = (authenticated: boolean) => + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(authenticated), + }); + +describe("AuthStatusIndicator", () => { + let openMock: ReturnType; + + beforeEach(() => { + sessionStorage.clear(); + openMock = vi.fn(); + vi.stubGlobal("open", openMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the authenticated state from a mocked response", async () => { + const fetchMock = mockFetch(true); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect( + await screen.findByLabelText("Workflows Authenticated"), + ).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + `${origin}/auth/status`, + expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + ); + }); + + it("opens login in a new tab when clicked while unauthenticated", async () => { + vi.stubGlobal("fetch", mockFetch(false)); + const user = userEvent.setup(); + + render(); + + const indicator = await screen.findByLabelText( + "Workflows Unauthenticated — click to log in", + ); + await user.click(indicator); + + expect(openMock).toHaveBeenCalledWith( + `${origin}/auth/login`, + "_blank", + "noopener,noreferrer", + ); + }); + + it("includes returnTo in the login url", async () => { + vi.stubGlobal("fetch", mockFetch(false)); + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + await screen.findByLabelText( + "Workflows Unauthenticated — click to log in", + ), + ); + + expect(openMock).toHaveBeenCalledWith( + `${origin}/auth/login?returnTo=https%3A%2F%2Fapp.example%2Fvisits`, + "_blank", + "noopener,noreferrer", + ); + }); + + it("does not open a tab when already authenticated", async () => { + vi.stubGlobal("fetch", mockFetch(true)); + const user = userEvent.setup(); + + render(); + + await user.click(await screen.findByLabelText("Workflows Authenticated")); + + expect(openMock).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/workflows-lib/tests/functions/authUtils.test.ts b/frontend/workflows-lib/tests/functions/authUtils.test.ts new file mode 100644 index 000000000..df3d4341f --- /dev/null +++ b/frontend/workflows-lib/tests/functions/authUtils.test.ts @@ -0,0 +1,193 @@ +import { + authStatusCacheKey, + baseGatewayUrl, + buildLoginUrl, + fetchAuthStatus, + readAuthStatusCache, + writeAuthStatusCache, +} from "../../lib/utils/authUtils"; + +// jsdom serves the tests from this origin; the gateway is same-origin under /auth +const origin = window.location.origin; +const otherGateway = "https://gateway.example"; + +describe("fetchAuthStatus", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("calls the same-origin status route with the bearer token", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(true), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(fetchAuthStatus({ accessToken: "tok" })).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + `${origin}/auth/status`, + expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + ); + }); + + it("honours an explicit gateway override", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: () => Promise.resolve(true) }); + vi.stubGlobal("fetch", fetchMock); + + await fetchAuthStatus({ accessToken: "tok", gatewayUrl: otherGateway }); + + expect(fetchMock).toHaveBeenCalledWith( + `${otherGateway}/auth/status`, + expect.anything(), + ); + }); + + it("treats a non-ok response as unauthenticated", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue({ ok: false, json: () => Promise.resolve(true) }), + ); + + await expect(fetchAuthStatus({ accessToken: "tok" })).resolves.toBe(false); + }); + + it("propagates network failures to the caller", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + + await expect(fetchAuthStatus({ accessToken: "tok" })).rejects.toThrow( + "offline", + ); + }); + + it("passes the abort signal through", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: () => Promise.resolve(false) }); + vi.stubGlobal("fetch", fetchMock); + const controller = new AbortController(); + + await fetchAuthStatus({ accessToken: "tok", signal: controller.signal }); + + expect(fetchMock).toHaveBeenCalledWith( + `${origin}/auth/status`, + expect.objectContaining({ signal: controller.signal }), + ); + }); +}); + +describe("baseGatewayUrl", () => { + it("defaults to the current origin", () => { + expect(baseGatewayUrl()).toBe(origin); + }); + + test.each([ + [otherGateway, otherGateway], + [`${otherGateway}/`, otherGateway], + [`${otherGateway}/auth`, otherGateway], + [`${otherGateway}/auth/`, otherGateway], + // the historic AUTH_GATEWAY_LOGIN_URL form + [`${otherGateway}/auth/login`, otherGateway], + [`${otherGateway}/auth/status`, otherGateway], + ])("reduces %s to its base", (input, expected) => { + expect(baseGatewayUrl(input)).toBe(expected); + }); + + it("leaves an unrelated path intact", () => { + expect(baseGatewayUrl(`${otherGateway}/gateway`)).toBe( + `${otherGateway}/gateway`, + ); + }); +}); + +describe("buildLoginUrl", () => { + it("builds a same-origin login url without a returnTo", () => { + expect(buildLoginUrl()).toBe(`${origin}/auth/login`); + }); + + it("encodes returnTo as a query parameter", () => { + expect(buildLoginUrl("https://app.example/visits?a=1")).toBe( + `${origin}/auth/login?returnTo=https%3A%2F%2Fapp.example%2Fvisits%3Fa%3D1`, + ); + }); + + it("honours an explicit gateway override", () => { + expect(buildLoginUrl(undefined, otherGateway)).toBe( + `${otherGateway}/auth/login`, + ); + }); + + it("does not double up the path when given a full login url", () => { + expect(buildLoginUrl(undefined, `${otherGateway}/auth/login`)).toBe( + `${otherGateway}/auth/login`, + ); + }); +}); + +describe("authStatusCacheKey", () => { + it("is stable for the same token", () => { + expect(authStatusCacheKey("tok")).toBe(authStatusCacheKey("tok")); + }); + + it("differs per token and per gateway", () => { + const base = authStatusCacheKey("tok"); + expect(authStatusCacheKey("other-tok")).not.toBe(base); + expect(authStatusCacheKey("tok", otherGateway)).not.toBe(base); + }); + + it("matches across the base and full login url forms", () => { + expect(authStatusCacheKey("tok", `${otherGateway}/auth/login`)).toBe( + authStatusCacheKey("tok", otherGateway), + ); + }); + + it("does not embed the raw access token", () => { + expect(authStatusCacheKey("secret-token")).not.toContain("secret-token"); + }); +}); + +describe("auth status cache", () => { + const cacheKey = authStatusCacheKey("tok"); + + beforeEach(() => { + sessionStorage.clear(); + vi.useRealTimers(); + }); + + it("round-trips a written value", () => { + writeAuthStatusCache(cacheKey, true); + expect(readAuthStatusCache(cacheKey, 30000)).toBe(true); + }); + + it("round-trips a cached false", () => { + writeAuthStatusCache(cacheKey, false); + expect(readAuthStatusCache(cacheKey, 30000)).toBe(false); + }); + + it("returns null when nothing is cached", () => { + expect(readAuthStatusCache(cacheKey, 30000)).toBeNull(); + }); + + it("returns null once the entry is older than the ttl", () => { + vi.useFakeTimers(); + writeAuthStatusCache(cacheKey, true); + vi.advanceTimersByTime(30001); + + expect(readAuthStatusCache(cacheKey, 30000)).toBeNull(); + }); + + it("returns null for an unparsable entry", () => { + sessionStorage.setItem(cacheKey, "not json"); + expect(readAuthStatusCache(cacheKey, 30000)).toBeNull(); + }); + + it("does not leak between keys", () => { + writeAuthStatusCache(cacheKey, true); + expect( + readAuthStatusCache(authStatusCacheKey("other-tok"), 30000), + ).toBeNull(); + }); +}); diff --git a/frontend/workflows-lib/tests/hooks/useAuthStatus.test.ts b/frontend/workflows-lib/tests/hooks/useAuthStatus.test.ts new file mode 100644 index 000000000..84a280760 --- /dev/null +++ b/frontend/workflows-lib/tests/hooks/useAuthStatus.test.ts @@ -0,0 +1,144 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { useAuthStatus } from "../../lib/hooks/useAuthStatus"; + +const origin = window.location.origin; + +const mockFetch = (authenticated: boolean) => + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(authenticated), + }); + +describe("useAuthStatus", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("resolves to authenticated from the same-origin gateway", async () => { + const fetchMock = mockFetch(true); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAuthStatus({ accessToken: "tok" })); + + expect(result.current.loading).toBe(true); + await waitFor(() => { + expect(result.current.authenticated).toBe(true); + }); + expect(result.current.loading).toBe(false); + expect(fetchMock).toHaveBeenCalledWith( + `${origin}/auth/status`, + expect.anything(), + ); + }); + + it("does not fetch without an access token", async () => { + const fetchMock = mockFetch(true); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAuthStatus({})); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.authenticated).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reuses the cached result on remount without re-fetching", async () => { + const fetchMock = mockFetch(true); + vi.stubGlobal("fetch", fetchMock); + + const first = renderHook(() => useAuthStatus({ accessToken: "tok" })); + await waitFor(() => { + expect(first.result.current.authenticated).toBe(true); + }); + first.unmount(); + + const second = renderHook(() => useAuthStatus({ accessToken: "tok" })); + await waitFor(() => { + expect(second.result.current.authenticated).toBe(true); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reuses a cached false without re-fetching", async () => { + const fetchMock = mockFetch(false); + vi.stubGlobal("fetch", fetchMock); + + const first = renderHook(() => useAuthStatus({ accessToken: "tok" })); + await waitFor(() => { + expect(first.result.current.loading).toBe(false); + }); + first.unmount(); + + const second = renderHook(() => useAuthStatus({ accessToken: "tok" })); + await waitFor(() => { + expect(second.result.current.loading).toBe(false); + }); + + expect(second.result.current.authenticated).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("re-fetches when the access token changes", async () => { + const fetchMock = mockFetch(true); + vi.stubGlobal("fetch", fetchMock); + + const { result, rerender } = renderHook( + ({ accessToken }: { accessToken: string }) => + useAuthStatus({ accessToken }), + { initialProps: { accessToken: "tok" } }, + ); + await waitFor(() => { + expect(result.current.authenticated).toBe(true); + }); + + rerender({ accessToken: "other-tok" }); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + expect(fetchMock).toHaveBeenLastCalledWith( + `${origin}/auth/status`, + expect.objectContaining({ + headers: { Authorization: "Bearer other-tok" }, + }), + ); + }); + + it("falls back to unauthenticated when the request fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); + + const { result } = renderHook(() => useAuthStatus({ accessToken: "tok" })); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.authenticated).toBe(false); + }); + + it("aborts the in-flight request on unmount", async () => { + let capturedSignal: AbortSignal | undefined; + const fetchMock = vi + .fn() + .mockImplementation((_url: string, init: RequestInit) => { + capturedSignal = init.signal ?? undefined; + return new Promise(() => {}); + }); + vi.stubGlobal("fetch", fetchMock); + + const { unmount } = renderHook(() => useAuthStatus({ accessToken: "tok" })); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + unmount(); + + expect(capturedSignal?.aborted).toBe(true); + }); +});