From e3cb6ea694a461d18a868b215235012230227482 Mon Sep 17 00:00:00 2001 From: David Hadley Date: Tue, 4 Aug 2026 15:16:01 +0100 Subject: [PATCH 1/2] feat(auth-gateway): add user info endpoint --- backend/auth-core/src/oidc.rs | 51 +++++++++++++++---- backend/auth-gateway/src/auth_session_data.rs | 16 ++++++ backend/auth-gateway/src/callback.rs | 12 +++++ backend/auth-gateway/src/main.rs | 2 + backend/auth-gateway/src/userinfo.rs | 34 +++++++++++++ 5 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 backend/auth-gateway/src/userinfo.rs diff --git a/backend/auth-core/src/oidc.rs b/backend/auth-core/src/oidc.rs index 0b0a27a34..a5762e4ea 100644 --- a/backend/auth-core/src/oidc.rs +++ b/backend/auth-core/src/oidc.rs @@ -1,8 +1,8 @@ use crate::config::CommonConfig; use anyhow::anyhow; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use oauth2::{ClientId, ClientSecret, EndpointMaybeSet, EndpointNotSet, EndpointSet, reqwest}; -use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreTokenResponse}; +use oauth2::{ClientId, ClientSecret, reqwest}; +use openidconnect::core::CoreProviderMetadata; use openidconnect::{IssuerUrl, RefreshToken}; use sea_orm::{Database, DatabaseConnection}; use sodiumoxide::crypto::box_::{PublicKey, SecretKey}; @@ -26,13 +26,42 @@ pub async fn create_db_connection(config: &CommonConfig) -> Result, +} + +impl openidconnect::AdditionalClaims for DiamondAdditionalClaims {} + +pub type DiamondIdTokenFields = openidconnect::IdTokenFields< + DiamondAdditionalClaims, + openidconnect::EmptyExtraTokenFields, + openidconnect::core::CoreGenderClaim, + openidconnect::core::CoreJweContentEncryptionAlgorithm, + openidconnect::core::CoreJwsSigningAlgorithm, +>; + +pub type DiamondTokenResponse = + openidconnect::StandardTokenResponse; + +pub type OidcClient = openidconnect::Client< + DiamondAdditionalClaims, + openidconnect::core::CoreAuthDisplay, + openidconnect::core::CoreGenderClaim, + openidconnect::core::CoreJweContentEncryptionAlgorithm, + openidconnect::core::CoreJsonWebKey, + openidconnect::core::CoreAuthPrompt, + openidconnect::StandardErrorResponse, + DiamondTokenResponse, + openidconnect::core::CoreTokenIntrospectionResponse, + openidconnect::core::CoreRevocableToken, + openidconnect::core::CoreRevocationErrorResponse, + openidconnect::EndpointSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointNotSet, + openidconnect::EndpointMaybeSet, + openidconnect::EndpointMaybeSet, >; pub async fn create_oidc_client(config: &CommonConfig) -> Result<(OidcClient, reqwest::Client)> { @@ -48,7 +77,7 @@ pub async fn create_oidc_client(config: &CommonConfig) -> Result<(OidcClient, re ) .await?; - let oidc_client = CoreClient::from_provider_metadata( + let oidc_client = OidcClient::from_provider_metadata( provider_metadata, ClientId::new(config.client_id.to_string()), if config.client_secret.is_empty() { @@ -72,7 +101,7 @@ pub async fn exchange_refresh_token( oidc_client: &OidcClient, http_client: &reqwest::Client, refresh_token: &RefreshToken, -) -> Result { +) -> Result { let token_response = oidc_client .exchange_refresh_token(refresh_token)? .request_async(http_client) diff --git a/backend/auth-gateway/src/auth_session_data.rs b/backend/auth-gateway/src/auth_session_data.rs index 0a83e9112..77d64bcf0 100644 --- a/backend/auth-gateway/src/auth_session_data.rs +++ b/backend/auth-gateway/src/auth_session_data.rs @@ -23,17 +23,24 @@ pub struct TokenSessionData { pub access_token: AccessToken, pub access_token_expires_at: DateTime, pub refresh_token: RefreshToken, + pub name: Option, + pub preferred_username: Option, + pub fedid: Option, } impl TokenSessionData { pub const SESSION_KEY: &str = "token_session_data"; + #[allow(clippy::too_many_arguments)] pub fn new( issuer: IssuerUrl, subject: SubjectIdentifier, access_token: AccessToken, access_token_expires_at: DateTime, refresh_token: RefreshToken, + name: Option, + preferred_username: Option, + fedid: Option, ) -> Self { Self { issuer, @@ -41,6 +48,9 @@ impl TokenSessionData { access_token, access_token_expires_at, refresh_token, + name, + preferred_username, + fedid, } } @@ -48,6 +58,9 @@ impl TokenSessionData { token_response: &T, issuer: IssuerUrl, subject: SubjectIdentifier, + name: Option, + preferred_username: Option, + fedid: Option, ) -> Result { let access_token = token_response.access_token().clone(); let refresh_token = token_response @@ -64,6 +77,9 @@ impl TokenSessionData { access_token, access_token_expires_at, refresh_token, + name, + preferred_username, + fedid, )) } diff --git a/backend/auth-gateway/src/callback.rs b/backend/auth-gateway/src/callback.rs index 7980d37b5..49b6746c5 100644 --- a/backend/auth-gateway/src/callback.rs +++ b/backend/auth-gateway/src/callback.rs @@ -76,6 +76,18 @@ pub async fn callback( &token_response, claims.issuer().clone(), claims.subject().clone(), + claims + .name() + .and_then(|name| name.get(None)) + .map(|n| n.to_string()), + claims + .preferred_username() + .map(|username| username.to_string()), + claims + .additional_claims() + .fedid + .as_ref() + .map(|fedid| fedid.to_string()), )?; write_token_to_database(&state.database_connection, &token_data, &state.public_key).await?; session diff --git a/backend/auth-gateway/src/main.rs b/backend/auth-gateway/src/main.rs index a9c9eccdf..ca0228473 100644 --- a/backend/auth-gateway/src/main.rs +++ b/backend/auth-gateway/src/main.rs @@ -3,6 +3,7 @@ mod callback; mod config; mod login; mod state; +mod userinfo; use auth_core::middleware::inject_token::inject_token_with; use clap::Parser; @@ -96,6 +97,7 @@ fn create_router(state: Arc, graph_url: String) -> Router { .route("/auth/login", get(login::login)) .route("/auth/callback", get(callback::callback)) .route("/auth/logout", post(logout)) + .route("/auth/me", get(userinfo::userinfo)) .route("/healthcheck", get(auth_core::healthcheck::healthcheck)) .layer(session_layer) .layer( diff --git a/backend/auth-gateway/src/userinfo.rs b/backend/auth-gateway/src/userinfo.rs new file mode 100644 index 000000000..7f46f3476 --- /dev/null +++ b/backend/auth-gateway/src/userinfo.rs @@ -0,0 +1,34 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Json}, +}; +use serde::Serialize; +use tower_sessions::Session; + +use crate::Result; +use crate::auth_session_data::TokenSessionData; + +#[derive(Debug, Serialize)] +pub struct UserInfo { + pub name: Option, + pub preferred_username: Option, + pub fedid: Option, +} + +pub async fn userinfo(session: Session) -> Result { + let token_session_data: Option = + session.get(TokenSessionData::SESSION_KEY).await?; + + match token_session_data { + Some(token) => Ok(( + StatusCode::OK, + Json(UserInfo { + name: token.name, + preferred_username: token.preferred_username, + fedid: token.fedid, + }), + ) + .into_response()), + None => Ok(StatusCode::UNAUTHORIZED.into_response()), + } +} From 0702adae7449c88f70947bb2804144d2ba5986ed Mon Sep 17 00:00:00 2001 From: David Hadley Date: Wed, 5 Aug 2026 08:47:13 +0100 Subject: [PATCH 2/2] feat(dashboard): implement login/logout button for auth-gateway --- charts/dashboard/staging-values.yaml | 2 + charts/dashboard/templates/deployment.yaml | 4 + charts/dashboard/values.yaml | 2 + frontend/configure.sh | 2 + frontend/dashboard/.env.production | 2 + frontend/dashboard/src/vite-env.d.ts | 2 + frontend/relay-workflows-lib/.env.test | 3 + .../lib/components/RelayEnvironment.ts | 35 ++++++++- .../lib/components/WorkflowsNavbar.tsx | 15 +++- .../tests/components/RelayEnvironment.test.ts | 78 +++++++++++++++++++ .../tests/components/WorkflowsNavbar.test.tsx | 36 ++++++++- 11 files changed, 173 insertions(+), 8 deletions(-) diff --git a/charts/dashboard/staging-values.yaml b/charts/dashboard/staging-values.yaml index 85ddf8957..caf18bb9f 100644 --- a/charts/dashboard/staging-values.yaml +++ b/charts/dashboard/staging-values.yaml @@ -8,6 +8,8 @@ configuration: sourceDir: /usr/share/nginx/html useAuthGateway: "true" authGatewayLoginUrl: https://staging.workflows.diamond.ac.uk/auth/login + authGatewayUserInfoUrl: https://staging.workflows.diamond.ac.uk/auth/userinfo + logoutUrl: https://staging.workflows.diamond.ac.uk/auth/logout ingress: hosts: diff --git a/charts/dashboard/templates/deployment.yaml b/charts/dashboard/templates/deployment.yaml index 81d4b2a56..c694f5042 100644 --- a/charts/dashboard/templates/deployment.yaml +++ b/charts/dashboard/templates/deployment.yaml @@ -70,6 +70,10 @@ spec: value: {{ .Values.configuration.useAuthGateway | toString | quote }} - name: AUTH_GATEWAY_LOGIN_URL value: {{ .Values.configuration.authGatewayLoginUrl }} + - name: AUTH_GATEWAY_USER_INFO_URL + value: {{ .Values.configuration.authGatewayUserInfoUrl }} + - name: LOGOUT_URL + value: {{ .Values.configuration.logoutUrl }} livenessProbe: httpGet: path: /healthcheck diff --git a/charts/dashboard/values.yaml b/charts/dashboard/values.yaml index bdc31746b..68f76f26b 100644 --- a/charts/dashboard/values.yaml +++ b/charts/dashboard/values.yaml @@ -8,6 +8,8 @@ configuration: sourceDir: "/usr/share/nginx/html" useAuthGateway: "false" authGatewayLoginUrl: https://workflows.diamond.ac.uk/auth/login + authGatewayUserInfoUrl: https://workflows.diamond.ac.uk/auth/userinfo + logoutUrl: https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/logout image: registry: ghcr.io diff --git a/frontend/configure.sh b/frontend/configure.sh index bf28e7b6d..401ea0911 100644 --- a/frontend/configure.sh +++ b/frontend/configure.sh @@ -30,6 +30,8 @@ replace_placeholder KEYCLOAK_SCOPE replace_placeholder GRAPH_URL replace_placeholder GRAPH_WS_URL replace_placeholder AUTH_GATEWAY_LOGIN_URL +replace_placeholder AUTH_GATEWAY_USER_INFO_URL +replace_placeholder LOGOUT_URL replace_placeholder USE_AUTH_GATEWAY nginx -g 'daemon off;' diff --git a/frontend/dashboard/.env.production b/frontend/dashboard/.env.production index 60bcd1800..7abdd0b19 100644 --- a/frontend/dashboard/.env.production +++ b/frontend/dashboard/.env.production @@ -6,3 +6,5 @@ VITE_KEYCLOAK_SCOPE = "{{ KEYCLOAK_SCOPE }}" VITE_GRAPH_URL = "{{ GRAPH_URL }}" VITE_GRAPH_WS_URL = "{{ GRAPH_WS_URL }}" VITE_AUTH_GATEWAY_LOGIN_URL = "{{ AUTH_GATEWAY_LOGIN_URL }}" +VITE_AUTH_GATEWAY_USER_INFO_URL = "{{ AUTH_GATEWAY_USER_INFO_URL }}" +VITE_LOGOUT_URL = "{{ LOGOUT_URL }}" diff --git a/frontend/dashboard/src/vite-env.d.ts b/frontend/dashboard/src/vite-env.d.ts index 0b90d3e5c..b2b48768b 100644 --- a/frontend/dashboard/src/vite-env.d.ts +++ b/frontend/dashboard/src/vite-env.d.ts @@ -9,6 +9,8 @@ interface ImportMetaEnv { readonly VITE_GRAPH_WS_URL: string; readonly VITE_USE_AUTH_GATEWAY: string; readonly VITE_AUTH_GATEWAY_LOGIN_URL: string; + readonly VITE_AUTH_GATEWAY_USER_INFO_URL: string; + readonly VITE_LOGOUT_URL: string; } interface importMeta { diff --git a/frontend/relay-workflows-lib/.env.test b/frontend/relay-workflows-lib/.env.test index bd1ef44ef..d67ddfd80 100644 --- a/frontend/relay-workflows-lib/.env.test +++ b/frontend/relay-workflows-lib/.env.test @@ -1,3 +1,6 @@ VITE_ENABLE_MOCKING = true VITE_GRAPH_URL = "https://workflows.diamond.ac.uk/graphql" VITE_GRAPH_WS_URL = "wss://workflows.diamond.ac.uk/graphql/ws" +VITE_AUTH_GATEWAY_LOGIN_URL = "https://workflows.diamond.ac.uk/auth/login" +VITE_AUTH_GATEWAY_USER_INFO_URL = "https://workflows.diamond.ac.uk/auth/userinfo" +VITE_LOGOUT_URL = "https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/logout" diff --git a/frontend/relay-workflows-lib/lib/components/RelayEnvironment.ts b/frontend/relay-workflows-lib/lib/components/RelayEnvironment.ts index 70de44ff3..740925952 100644 --- a/frontend/relay-workflows-lib/lib/components/RelayEnvironment.ts +++ b/frontend/relay-workflows-lib/lib/components/RelayEnvironment.ts @@ -20,6 +20,8 @@ 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 AUTH_GATEWAY_USER_INFO_URL = + import.meta.env.VITE_AUTH_GATEWAY_USER_INFO_URL; const keycloak = await getKeycloak(); @@ -59,6 +61,11 @@ if (!USE_AUTH_GATEWAY) { }; } +function redirectToAuthGatewayLogin() { + const returnTo = encodeURIComponent(window.location.href); + window.location.assign(`${AUTH_GATEWAY_LOGIN_URL}?returnTo=${returnTo}`); +} + const fetchFn: FetchFunction = async (request, variables) => { if (!keycloak.authenticated) { await ensureKeycloakInit(); @@ -87,8 +94,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}`); + redirectToAuthGatewayLogin(); return {}; } @@ -152,6 +158,31 @@ export async function getRelayEnvironment(): Promise { } export async function getUser(): Promise { + if (USE_AUTH_GATEWAY) { + try { + const resp = await fetch(AUTH_GATEWAY_USER_INFO_URL, { + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (resp.status === 401) { + redirectToAuthGatewayLogin(); + return null; + } + if (!resp.ok) { + return null; + } + const data = (await resp.json()) as JSONObject; + const user: AuthState = { + name: data.name as string, + fedid: data.fedid as string, + }; + return user; + } catch (error) { + console.error("Failed to fetch user info: ", error); + return null; + } + } + if (!keycloak.authenticated) { await ensureKeycloakInit(); } diff --git a/frontend/relay-workflows-lib/lib/components/WorkflowsNavbar.tsx b/frontend/relay-workflows-lib/lib/components/WorkflowsNavbar.tsx index 30da6b253..4c4edf607 100644 --- a/frontend/relay-workflows-lib/lib/components/WorkflowsNavbar.tsx +++ b/frontend/relay-workflows-lib/lib/components/WorkflowsNavbar.tsx @@ -11,15 +11,24 @@ import { import { getUser } from "relay-workflows-lib"; import { useEffect, useState } from "react"; import { externalRedirect } from "../utils/coreUtils"; +import { getUseAuthGateway } from "../utils/useAuthGateway"; interface WorkflowsNavbarProps { sessionInfo?: string; } +const LOGOUT_URL = import.meta.env.VITE_LOGOUT_URL; + const handleLogout = () => { - externalRedirect( - "https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/logout", - ); + if (getUseAuthGateway()) { + fetch(LOGOUT_URL, { method: "POST", credentials: "include" }) + .then(() => { externalRedirect("/") }) + .catch((error: unknown) => { + console.error("Logout failed: ", error); + }); + } else { + externalRedirect(LOGOUT_URL); + } }; const WorkflowsNavbar: React.FC = ({ sessionInfo }) => { diff --git a/frontend/relay-workflows-lib/tests/components/RelayEnvironment.test.ts b/frontend/relay-workflows-lib/tests/components/RelayEnvironment.test.ts index af546e08a..1c4377866 100644 --- a/frontend/relay-workflows-lib/tests/components/RelayEnvironment.test.ts +++ b/frontend/relay-workflows-lib/tests/components/RelayEnvironment.test.ts @@ -28,3 +28,81 @@ describe("getUser", () => { }); }); }); + +describe("getUser with auth gateway enabled", () => { + beforeEach(() => { + window.__USE_AUTH_GATEWAY__ = "true"; + vi.resetModules(); + }); + + afterEach(() => { + delete window.__USE_AUTH_GATEWAY__; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("fetches user info from the auth gateway", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ name: "Mo C. Kuser", fedid: "mockuser" }), + }); + vi.stubGlobal("fetch", fetchMock); + const { getUser } = await import("relay-workflows-lib"); + + expect(await getUser()).toStrictEqual({ + name: "Mo C. Kuser", + fedid: "mockuser", + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://workflows.diamond.ac.uk/auth/userinfo", + { + credentials: "include", + headers: { Accept: "application/json" }, + }, + ); + }); + + it("redirects to login and returns null on 401", async () => { + const assignMock = vi + .spyOn(window.location, "assign") + .mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + }), + ); + const { getUser } = await import("relay-workflows-lib"); + + expect(await getUser()).toBeNull(); + expect(assignMock).toHaveBeenCalledWith( + `https://workflows.diamond.ac.uk/auth/login?returnTo=${encodeURIComponent( + window.location.href, + )}`, + ); + }); + + it("returns null on a non-OK response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.resolve({}), + }), + ); + const { getUser } = await import("relay-workflows-lib"); + + expect(await getUser()).toBeNull(); + }); + + it("returns null if the fetch fails", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network"))); + const { getUser } = await import("relay-workflows-lib"); + + expect(await getUser()).toBeNull(); + }); +}); diff --git a/frontend/relay-workflows-lib/tests/components/WorkflowsNavbar.test.tsx b/frontend/relay-workflows-lib/tests/components/WorkflowsNavbar.test.tsx index 17877d079..67c7adde5 100644 --- a/frontend/relay-workflows-lib/tests/components/WorkflowsNavbar.test.tsx +++ b/frontend/relay-workflows-lib/tests/components/WorkflowsNavbar.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { ThemeProvider } from "@mui/material/styles"; import { DiamondTheme, AuthState } from "@diamondlightsource/sci-react-ui"; @@ -19,6 +19,12 @@ describe("WorkflowsNavbar", () => { fedid: "ab12345", }; + afterEach(() => { + delete window.__USE_AUTH_GATEWAY__; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + it("renders with title and sessionInfo", () => { const { getByText } = render( @@ -60,8 +66,7 @@ describe("WorkflowsNavbar", () => { it("redirects to logout", async () => { vi.mocked(getUser).mockReturnValue(Promise.resolve(testUser)); const redirectSpy = vi.spyOn(coreUtils, "externalRedirect"); - const url = - "https://identity.diamond.ac.uk/realms/dls/protocol/openid-connect/logout"; + const url: string = import.meta.env.VITE_LOGOUT_URL; render( @@ -74,4 +79,29 @@ describe("WorkflowsNavbar", () => { await user.click(await screen.findByText("Logout")); expect(redirectSpy).toHaveBeenCalledWith(url); }); + + it("logs out via the auth gateway and redirects to home", async () => { + window.__USE_AUTH_GATEWAY__ = "true"; + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchMock); + vi.mocked(getUser).mockReturnValue(Promise.resolve(testUser)); + const redirectSpy = vi.spyOn(coreUtils, "externalRedirect"); + render( + + + + + , + ); + await screen.findByText("Tess Tuser"); + await user.click(screen.getByRole("button", { name: "User Avatar" })); + await user.click(await screen.findByText("Logout")); + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(import.meta.env.VITE_LOGOUT_URL, { + method: "POST", + credentials: "include", + }); + }); + expect(redirectSpy).toHaveBeenCalledWith("/"); + }); });