diff --git a/backend/auth-core/src/database.rs b/backend/auth-core/src/database.rs index 2126c918d..65a4de194 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) } +/// Returns `true` if a non-expired token row is stored for `subject`. +/// Does not decrypt the refresh token, just checks for existence and expiration. +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..55d022ecd 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,36 @@ 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, + /// The token's `exp` claim, if present, as a UTC timestamp. + pub expires_at: Option>, +} + +/// Decodes the `sub` and `exp` claims from a JWT access token. +/// does not verify tokens signature as it is UX indicator +/// Complex changes in return response require it to be verified +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..d69b8213f 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,14 @@ fn create_router(state: Arc, graph_url: String) -> Router { AllowOrigin::default() }; + // `/auth/status` is authorized by a bearer token, not the session cookie, so + // it needs no credentials — which lets it allow any origin (`*`) and be called + // from any frontend without maintaining an origin allow-list. + 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 +118,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 — axum only applies a layer to routes added before it. This route + // gets only its own permissive, credential-free `status_cors` instead. + .route("/auth/status", get(status).layer(status_cors)) .with_state(state) } @@ -146,6 +159,43 @@ 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 `bool`. +/// 1. Reads the bearer access token from the `Authorization` header. +/// 2. Decodes its `sub` and `exp` (unverified — see `claims_from_access_token`). +/// 3. Returns `false` if the token is already expired, otherwise whether a +/// non-expired token is stored in the database for that subject. +/// +/// 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/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"; 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", + }, +}; 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); + }); +});