-
Notifications
You must be signed in to change notification settings - Fork 6
Tbt/gateway status #1506
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Tbt/gateway status #1506
Changes from all commits
20fa044
71f6e80
e39c189
ad623a2
463c904
974fad8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,8 +18,9 @@ use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer, cookie:: | |
| type Result<T> = std::result::Result<T, auth_core::error::Error>; | ||
|
|
||
| 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<AppState>, 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<AppState>, 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<Arc<AppState>>, 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<Arc<AppState>>, | ||
| headers: HeaderMap, | ||
| ) -> Result<impl IntoResponse> { | ||
| 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))) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would it be better to return a JSON object? That makes it self-documenting and makes it easier to extend the "status" endpoint in future if we need it. |
||
| } | ||
|
|
||
| async fn shutdown_signal() { | ||
| let mut sigterm: Signal = | ||
| signal(SignalKind::terminate()).expect("Failed to listen for SIGTERM"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = ({ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is mixing two separate concerns: (1) determining whether the user is authenticated I think that the goal is to provide react tooling that could be provided via a shared library that allows consuming applications to query authentication state and trigger the login flow if needed. This component currently makes a lot of UI decisions on behalf of consumers (indicator, colours, text, tooltip, button behaviour etc), which limits how other teams can integrate it into their applications. I'd suggest decoupling determining the users authenticated state, from a visual component that renders the state. Consuming applications could then decide how and when to present that information to users. We could still provide an AuthStatusIndicator component as a convenience wrapper on top of that, but I think the authentication state/query mechanism should exist independently of any particular UI. |
||
| gatewayUrl, | ||
| accessToken, | ||
| cacheTtlMs = 30000, | ||
| size = 20, | ||
| returnTo, | ||
| }: AuthStatusIndicatorProps) => { | ||
| const [status, setStatus] = useState<boolean | null>(() => | ||
| 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<boolean>) : 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 ( | ||
| <Tooltip title={tooltip}> | ||
| <IconButton | ||
| onClick={handleClick} | ||
| aria-label={tooltip} | ||
| data-testid="auth-status-indicator" | ||
| size="small" | ||
| disableRipple={authenticated} | ||
| sx={{ | ||
| cursor: authenticated ? "default" : "pointer", | ||
| border: "1px solid", | ||
| borderColor: "primary.main", | ||
| borderRadius: 1, | ||
| px: 1.5, | ||
| py: 0.5, | ||
| bgcolor: "primary.main", | ||
| }} | ||
| > | ||
| <Stack direction="row" spacing={1} alignItems="center"> | ||
| <CircleIcon | ||
| sx={{ | ||
| fontSize: size, | ||
| color: authenticated ? "success.main" : "grey.500", | ||
| }} | ||
| /> | ||
| <Typography | ||
| variant="h6" | ||
| fontWeight="bold" | ||
| sx={{ color: "common.white" }} | ||
| > | ||
| {text} | ||
| </Typography> | ||
| </Stack> | ||
| </IconButton> | ||
| </Tooltip> | ||
| ); | ||
| }; | ||
|
|
||
| export default AuthStatusIndicator; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof AuthStatusIndicator> = { | ||
| title: "AuthStatusIndicator", | ||
| component: AuthStatusIndicator, | ||
| decorators: [ | ||
| (Story) => ( | ||
| <ThemeProvider theme={DiamondTheme}> | ||
| <Story /> | ||
| </ThemeProvider> | ||
| ), | ||
| ], | ||
| }; | ||
|
|
||
| type Story = StoryObj<typeof AuthStatusIndicator>; | ||
|
|
||
| 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", | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
style: this docstring mostly describes the internal implementation (reading the token, decoding claims, checking the database). Readers can get those details from the code. For API documentation it's more useful to describe the observable behaviour and response contract, eg "this endpoint returns whether the current user identified by their access_token is logged in, with a JSON true or false response body."