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

/// 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<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
34 changes: 32 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,32 @@ 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,
pub expires_at: Option<DateTime<Utc>>,
}

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
46 changes: 45 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,13 @@ fn create_router(state: Arc<AppState>, 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(
Expand All @@ -109,6 +117,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 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)
}

Expand Down Expand Up @@ -146,6 +158,38 @@ 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 JSON true or false response body
/// 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)))
Comment thread
TBThomas56 marked this conversation as resolved.
}

async fn shutdown_signal() {
let mut sigterm: Signal =
signal(SignalKind::terminate()).expect("Failed to listen for SIGTERM");
Expand Down
2 changes: 1 addition & 1 deletion charts/dashboard/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion charts/dashboard/staging-values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion charts/dashboard/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion charts/prek.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",

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.

why is this change needed?

this directory isn't guaranteed to always available.

files = "^[^/]+/[^/]*\\.ya?ml$",
pass_filenames = true,
require_serial = true,
},
]
2 changes: 1 addition & 1 deletion frontend/prek.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}\"",

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.

why is this change needed?

this directory isn't guaranteed to always available.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the CI for this was failing due to race conditions between charts/prek.toml and frontend/prek.toml. The cache is to use whichever came first

files = "\\.(js|ts|tsx|json)$",
pass_filenames = false,

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.

13 commits, could be squashed and cleaned up a little?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't say so - kept them separaed with changes in auth-core, auth-gateway in the backend. Charts for dashboard, along with frontend changes (component, story, test)
Correct me if I am wrong - seems like it became a full stack thing that needed changing

},

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.

CI is failing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 {};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 = ({
Comment thread
TBThomas56 marked this conversation as resolved.
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 (
<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;
70 changes: 70 additions & 0 deletions frontend/workflows-lib/lib/hooks/useAuthStatus.ts
Original file line number Diff line number Diff line change
@@ -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<FetchedStatus | null>(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 };
}
6 changes: 6 additions & 0 deletions frontend/workflows-lib/lib/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading
Loading