Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions backend/auth-core/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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,
Expand Down
38 changes: 36 additions & 2 deletions backend/auth-core/src/oidc.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -68,6 +72,36 @@ pub fn decode_secret_key(base64_key: &str) -> Result<SecretKey> {
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<DateTime<Utc>>,
}

/// 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<AccessTokenClaims> {
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<i64>,
}
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,
Expand Down
52 changes: 51 additions & 1 deletion backend/auth-gateway/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}

Expand Down Expand Up @@ -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`.

Copy link
Copy Markdown
Collaborator

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."

/// 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)))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.
eg return
{"user_is_authenticated": true}
or similar

}

async fn shutdown_signal() {
let mut sigterm: Signal =
signal(SignalKind::terminate()).expect("Failed to listen for SIGTERM");
Expand Down
131 changes: 131 additions & 0 deletions frontend/workflows-lib/lib/components/common/AuthStatusIndicator.tsx
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 = ({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
(2) displaying authentication status visually

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;
4 changes: 4 additions & 0 deletions frontend/workflows-lib/lib/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
32 changes: 32 additions & 0 deletions frontend/workflows-lib/stories/AuthStatusIndicator.stories.tsx
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",
},
};
Loading
Loading