From 171b54316fed03a57857b4184516ef2ebd02e524 Mon Sep 17 00:00:00 2001 From: TBThomas56 Date: Tue, 4 Aug 2026 23:03:06 +0100 Subject: [PATCH 01/12] feat(auth-core): added oidc capabilities --- backend/auth-core/src/oidc.rs | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) 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, From 55ca37210a76156c7937137932328c14badce587 Mon Sep 17 00:00:00 2001 From: TBThomas56 Date: Tue, 4 Aug 2026 23:03:28 +0100 Subject: [PATCH 02/12] feat(auth-core): backend querying for non-expired token --- backend/auth-core/src/database.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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, From e0a9afdd225cff3f287069aa7ddaddf8a9b7b155 Mon Sep 17 00:00:00 2001 From: TBThomas56 Date: Tue, 4 Aug 2026 23:03:46 +0100 Subject: [PATCH 03/12] feat(auth-gateway): added non cors route for status --- backend/auth-gateway/src/main.rs | 46 +++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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"); From 04e058f61eb5b291812e0e348c4520ad434a8ed8 Mon Sep 17 00:00:00 2001 From: TBThomas56 Date: Tue, 4 Aug 2026 23:14:06 +0100 Subject: [PATCH 04/12] feat(frontend): added authenticated component --- .../components/common/AuthStatusIndicator.tsx | 131 ++++++++++++++++++ frontend/workflows-lib/lib/main.ts | 4 + 2 files changed, 135 insertions(+) create mode 100644 frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx 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..74a9b94d6 --- /dev/null +++ b/frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState } from "react"; +import CircleIcon from "@mui/icons-material/Circle"; +import { IconButton, Stack, Tooltip, Typography } from "@mui/material"; + +export interface AuthStatusIndicatorProps { + gatewayUrl: string; + accessToken?: string; + cacheTtlMs?: number; + size?: number; + returnTo?: string; +} + +interface CachedStatus { + authenticated: boolean; + checkedAt: number; +} + +const CACHE_KEY = "workflows-auth-status"; + +const readCache = (ttlMs: number): boolean | null => { + try { + const raw = sessionStorage.getItem(CACHE_KEY); + 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; + } +}; + +const writeCache = (authenticated: boolean) => { + try { + sessionStorage.setItem( + CACHE_KEY, + JSON.stringify({ + authenticated, + checkedAt: Date.now(), + } satisfies CachedStatus), + ); + } catch { + // best-effort; ignore storage failures (e.g. private browsing) + } +}; + +const AuthStatusIndicator = ({ + gatewayUrl, + accessToken, + cacheTtlMs = 30000, + size = 20, + returnTo, +}: AuthStatusIndicatorProps) => { + const [status, setStatus] = useState(() => + readCache(cacheTtlMs), + ); + + useEffect(() => { + if (readCache(cacheTtlMs) !== null || !accessToken) return; + + let active = true; + void fetch(`${gatewayUrl}/auth/status`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + .then((res) => (res.ok ? (res.json() as Promise) : false)) + .then((result) => { + if (!active) return; + setStatus(result); + writeCache(result); + }) + .catch(() => { + if (active) setStatus(false); + }); + + return () => { + active = false; + }; + }, [gatewayUrl, accessToken, cacheTtlMs]); + + const authenticated = accessToken ? (status ?? false) : false; + + const handleClick = () => { + if (authenticated) return; + const loginUrl = new URL(`${gatewayUrl}/auth/login`); + if (returnTo) loginUrl.searchParams.set("returnTo", returnTo); + window.location.href = loginUrl.toString(); + }; + + 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/main.ts b/frontend/workflows-lib/lib/main.ts index 58519d320..d3a907ea7 100644 --- a/frontend/workflows-lib/lib/main.ts +++ b/frontend/workflows-lib/lib/main.ts @@ -20,6 +20,10 @@ 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 "./types"; export * from "./utils/commonUtils"; From a5541dc0e3ae61def4c5f083c13374a823319ad0 Mon Sep 17 00:00:00 2001 From: TBThomas56 Date: Tue, 4 Aug 2026 23:14:20 +0100 Subject: [PATCH 05/12] feat(frontend): added story for authenticated component --- .../stories/AuthStatusIndicator.stories.tsx | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx diff --git a/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx b/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx new file mode 100644 index 000000000..d7ce69617 --- /dev/null +++ b/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx @@ -0,0 +1,32 @@ +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; + +export const Unauthenticated: Story = { + args: { + gatewayUrl: "https://workflows.diamond.ac.uk", + }, +}; + +export const Authenticated: Story = { + args: { + gatewayUrl: "https://workflows.diamond.ac.uk", + accessToken: "example-token", + }, +}; From eac959eb9a0df5d4049c5ae32009cec855024136 Mon Sep 17 00:00:00 2001 From: TBThomas56 Date: Tue, 4 Aug 2026 23:15:11 +0100 Subject: [PATCH 06/12] test(frontend): added test for authenticated component --- .../components/AuthStatusIndicator.test.tsx | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx 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..8e429ec23 --- /dev/null +++ b/frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx @@ -0,0 +1,77 @@ +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 gatewayUrl = "https://gateway.example"; + +const mockFetch = (authenticated: boolean) => + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(authenticated), + }); + +describe("AuthStatusIndicator", () => { + const originalLocation = window.location; + + beforeEach(() => { + sessionStorage.clear(); + Object.defineProperty(window, "location", { + configurable: true, + value: { href: "" }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + }); + + 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( + `${gatewayUrl}/auth/status`, + expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + ); + }); + + it("redirects to login 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(window.location.href).toBe(`${gatewayUrl}/auth/login`); + }); + + it("uses the cached result on remount without re-fetching", async () => { + const fetchMock = mockFetch(true); + vi.stubGlobal("fetch", fetchMock); + + const { unmount } = render( + , + ); + await screen.findByLabelText("Workflows Authenticated"); + unmount(); + + render(); + await screen.findByLabelText("Workflows Authenticated"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); From ac343fc1c479a16a86095a38575b857672d93134 Mon Sep 17 00:00:00 2001 From: Thomas Binu Thomas Date: Tue, 11 Aug 2026 12:10:40 +0100 Subject: [PATCH 07/12] feat(frontend): auth status component and logic and hooks --- .../components/common/AuthStatusIndicator.tsx | 83 ++++--------------- .../workflows-lib/lib/hooks/useAuthStatus.ts | 70 ++++++++++++++++ frontend/workflows-lib/lib/main.ts | 2 + frontend/workflows-lib/lib/utils/authUtils.ts | 83 +++++++++++++++++++ 4 files changed, 171 insertions(+), 67 deletions(-) create mode 100644 frontend/workflows-lib/lib/hooks/useAuthStatus.ts create mode 100644 frontend/workflows-lib/lib/utils/authUtils.ts diff --git a/frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx b/frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx index 74a9b94d6..dcd997b85 100644 --- a/frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx +++ b/frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx @@ -1,88 +1,37 @@ -import { useEffect, useState } from "react"; 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 { - gatewayUrl: string; accessToken?: string; + /** Defaults to the current origin, where the gateway is served under `/auth`. */ + gatewayUrl?: string; cacheTtlMs?: number; size?: number; returnTo?: string; } -interface CachedStatus { - authenticated: boolean; - checkedAt: number; -} - -const CACHE_KEY = "workflows-auth-status"; - -const readCache = (ttlMs: number): boolean | null => { - try { - const raw = sessionStorage.getItem(CACHE_KEY); - 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; - } -}; - -const writeCache = (authenticated: boolean) => { - try { - sessionStorage.setItem( - CACHE_KEY, - JSON.stringify({ - authenticated, - checkedAt: Date.now(), - } satisfies CachedStatus), - ); - } catch { - // best-effort; ignore storage failures (e.g. private browsing) - } -}; - const AuthStatusIndicator = ({ - gatewayUrl, accessToken, - cacheTtlMs = 30000, + gatewayUrl, + cacheTtlMs, size = 20, returnTo, }: AuthStatusIndicatorProps) => { - const [status, setStatus] = useState(() => - readCache(cacheTtlMs), - ); - - useEffect(() => { - if (readCache(cacheTtlMs) !== null || !accessToken) return; - - let active = true; - void fetch(`${gatewayUrl}/auth/status`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) - .then((res) => (res.ok ? (res.json() as Promise) : false)) - .then((result) => { - if (!active) return; - setStatus(result); - writeCache(result); - }) - .catch(() => { - if (active) setStatus(false); - }); - - return () => { - active = false; - }; - }, [gatewayUrl, accessToken, cacheTtlMs]); - - const authenticated = accessToken ? (status ?? false) : false; + const { authenticated } = useAuthStatus({ + accessToken, + gatewayUrl, + cacheTtlMs, + }); const handleClick = () => { if (authenticated) return; - const loginUrl = new URL(`${gatewayUrl}/auth/login`); - if (returnTo) loginUrl.searchParams.set("returnTo", returnTo); - window.location.href = loginUrl.toString(); + window.open( + buildLoginUrl(returnTo, gatewayUrl), + "_blank", + "noopener,noreferrer", + ); }; const text = authenticated 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 d3a907ea7..dc9d72eef 100644 --- a/frontend/workflows-lib/lib/main.ts +++ b/frontend/workflows-lib/lib/main.ts @@ -25,7 +25,9 @@ export { 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 + } +} From 44a58484dcb7882c1ba46173795becaefd80aad7 Mon Sep 17 00:00:00 2001 From: Thomas Binu Thomas Date: Tue, 11 Aug 2026 14:04:54 +0100 Subject: [PATCH 08/12] test(frontend): add tests to auth status compnent and hooks --- .../components/AuthStatusIndicator.test.tsx | 67 +++--- .../tests/functions/authUtils.test.ts | 193 ++++++++++++++++++ .../tests/hooks/useAuthStatus.test.ts | 144 +++++++++++++ 3 files changed, 379 insertions(+), 25 deletions(-) create mode 100644 frontend/workflows-lib/tests/functions/authUtils.test.ts create mode 100644 frontend/workflows-lib/tests/hooks/useAuthStatus.test.ts diff --git a/frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx b/frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx index 8e429ec23..2071f1427 100644 --- a/frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx +++ b/frontend/workflows-lib/tests/components/AuthStatusIndicator.test.tsx @@ -3,7 +3,7 @@ import "@testing-library/jest-dom"; import userEvent from "@testing-library/user-event"; import { AuthStatusIndicator } from "../../lib/main"; -const gatewayUrl = "https://gateway.example"; +const origin = window.location.origin; const mockFetch = (authenticated: boolean) => vi.fn().mockResolvedValue({ @@ -12,66 +12,83 @@ const mockFetch = (authenticated: boolean) => }); describe("AuthStatusIndicator", () => { - const originalLocation = window.location; + let openMock: ReturnType; beforeEach(() => { sessionStorage.clear(); - Object.defineProperty(window, "location", { - configurable: true, - value: { href: "" }, - }); + openMock = vi.fn(); + vi.stubGlobal("open", openMock); }); afterEach(() => { vi.unstubAllGlobals(); - Object.defineProperty(window, "location", { - configurable: true, - value: originalLocation, - }); }); it("shows the authenticated state from a mocked response", async () => { const fetchMock = mockFetch(true); vi.stubGlobal("fetch", fetchMock); - render(); + render(); expect( await screen.findByLabelText("Workflows Authenticated"), ).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith( - `${gatewayUrl}/auth/status`, + `${origin}/auth/status`, expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), ); }); - it("redirects to login when clicked while unauthenticated", async () => { + it("opens login in a new tab when clicked while unauthenticated", async () => { vi.stubGlobal("fetch", mockFetch(false)); const user = userEvent.setup(); - render(); + render(); const indicator = await screen.findByLabelText( "Workflows Unauthenticated — click to log in", ); await user.click(indicator); - expect(window.location.href).toBe(`${gatewayUrl}/auth/login`); + expect(openMock).toHaveBeenCalledWith( + `${origin}/auth/login`, + "_blank", + "noopener,noreferrer", + ); }); - it("uses the cached result on remount without re-fetching", async () => { - const fetchMock = mockFetch(true); - vi.stubGlobal("fetch", fetchMock); + it("includes returnTo in the login url", async () => { + vi.stubGlobal("fetch", mockFetch(false)); + const user = userEvent.setup(); + + render( + , + ); - const { unmount } = render( - , + await user.click( + await screen.findByLabelText( + "Workflows Unauthenticated — click to log in", + ), ); - await screen.findByLabelText("Workflows Authenticated"); - unmount(); - render(); - await screen.findByLabelText("Workflows Authenticated"); + 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(fetchMock).toHaveBeenCalledTimes(1); + 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); + }); +}); From 1358727324095f67662de825d7cdd54b237fb39d Mon Sep 17 00:00:00 2001 From: Thomas Binu Thomas Date: Tue, 11 Aug 2026 14:03:52 +0100 Subject: [PATCH 09/12] feat(frontend): story for AuthStatusIndicator --- .../stories/AuthStatusIndicator.stories.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx b/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx index d7ce69617..223d05aa3 100644 --- a/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx +++ b/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx @@ -18,15 +18,35 @@ type Story = StoryObj; export default meta; +// The gateway is same-origin under /auth, which Storybook does not serve, so +// stub the status response to render each state deterministically. +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: { - gatewayUrl: "https://workflows.diamond.ac.uk", + accessToken: "example-token", }, }; export const Authenticated: Story = { + decorators: [ + (Story) => { + sessionStorage.clear(); + mockAuthStatus(true); + return ; + }, + ], args: { - gatewayUrl: "https://workflows.diamond.ac.uk", accessToken: "example-token", }, }; From 1ffbce06f1251b019af596069f03fac7bd4681f9 Mon Sep 17 00:00:00 2001 From: Thomas Binu Thomas Date: Tue, 11 Aug 2026 14:03:23 +0100 Subject: [PATCH 10/12] fix(frontend): change authGatewayUrl in charts and frontend changed it to /auth so that /auth/login works alongside /auth/status --- charts/dashboard/staging-values.yaml | 2 +- charts/dashboard/values.yaml | 2 +- .../relay-workflows-lib/lib/components/RelayEnvironment.ts | 6 ++---- .../workflows-lib/stories/AuthStatusIndicator.stories.tsx | 2 -- 4 files changed, 4 insertions(+), 8 deletions(-) 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/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/stories/AuthStatusIndicator.stories.tsx b/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx index 223d05aa3..ec0f7e465 100644 --- a/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx +++ b/frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx @@ -18,8 +18,6 @@ type Story = StoryObj; export default meta; -// The gateway is same-origin under /auth, which Storybook does not serve, so -// stub the status response to render each state deterministically. const mockAuthStatus = (authenticated: boolean) => { window.fetch = () => Promise.resolve(new Response(JSON.stringify(authenticated))); From 4b128eaf20f095e9cadf37eba7f3fd72af922f91 Mon Sep 17 00:00:00 2001 From: Thomas Binu Thomas Date: Tue, 11 Aug 2026 11:59:51 +0100 Subject: [PATCH 11/12] chore(charts): bumped dashboard chart version --- charts/dashboard/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From fec9d00e275ae38a5f7b788d62930dd53d8ab41a Mon Sep 17 00:00:00 2001 From: Thomas Binu Thomas Date: Tue, 11 Aug 2026 13:40:00 +0100 Subject: [PATCH 12/12] fix(ci): fix ci to run serial to avoid race conditions --- charts/prek.toml | 3 ++- frontend/prek.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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, },