From 2ff551db7a712c42a59138e5c8b91a93b7770540 Mon Sep 17 00:00:00 2001 From: Jeff Poegel <14828959+jpoegs@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:24:42 -0800 Subject: [PATCH] feat(auth): add OpenID Connect SSO login support Add OIDC browser-based SSO login flow to the console: - OIDC library (lib/oidc.ts): fetch providers, initiate login, parse callback - OIDC callback page: parses STS credentials from URL fragment - Login form: renders SSO provider buttons with "Or continue with" divider - Auth context: new loginWithStsCredentials method for direct STS login - Config types: OidcProvider interface and oidc field on SiteConfig - i18n: English translations for SSO-related strings --- app/(auth)/auth/login/page.tsx | 19 ++++++++ app/(auth)/auth/oidc-callback/page.tsx | 66 ++++++++++++++++++++++++++ components/auth/login-form.tsx | 31 ++++++++++++ components/user/dropdown.tsx | 6 +-- contexts/auth-context.tsx | 16 +++++++ i18n/locales/en-US.json | 6 +++ lib/oidc.ts | 57 ++++++++++++++++++++++ types/config.d.ts | 6 +++ 8 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 app/(auth)/auth/oidc-callback/page.tsx create mode 100644 lib/oidc.ts diff --git a/app/(auth)/auth/login/page.tsx b/app/(auth)/auth/login/page.tsx index 8774ded8..a6bde3cc 100644 --- a/app/(auth)/auth/login/page.tsx +++ b/app/(auth)/auth/login/page.tsx @@ -8,6 +8,8 @@ import { LoginForm, type LoginMethod } from "@/components/auth/login-form" import { useAuth } from "@/contexts/auth-context" import { useMessage } from "@/lib/feedback/message" import { configManager } from "@/lib/config" +import { fetchOidcProviders, initiateOidcLogin } from "@/lib/oidc" +import type { OidcProvider } from "@/types/config" export default function LoginPage() { return ( @@ -34,6 +36,7 @@ function LoginPageContent() { secretAccessKey: "", sessionToken: "", }) + const [oidcProviders, setOidcProviders] = useState([]) useEffect(() => { if (isAuthenticated) { @@ -48,6 +51,15 @@ function LoginPageContent() { } }, [searchParams, message, t, router]) + // Fetch OIDC providers on mount + useEffect(() => { + configManager.loadConfig().then((config) => { + if (config?.serverHost) { + fetchOidcProviders(config.serverHost).then(setOidcProviders) + } + }) + }, []) + const handleLogin = async (e: React.FormEvent) => { e.preventDefault() @@ -64,6 +76,11 @@ function LoginPageContent() { } } + const handleOidcLogin = async (providerId: string) => { + const config = await configManager.loadConfig() + initiateOidcLogin(config.serverHost, providerId) + } + return ( ) } diff --git a/app/(auth)/auth/oidc-callback/page.tsx b/app/(auth)/auth/oidc-callback/page.tsx new file mode 100644 index 00000000..43d687de --- /dev/null +++ b/app/(auth)/auth/oidc-callback/page.tsx @@ -0,0 +1,66 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useRouter } from "next/navigation" +import { useAuth } from "@/contexts/auth-context" +import { useMessage } from "@/lib/feedback/message" +import { parseOidcCallback } from "@/lib/oidc" +import { useTranslation } from "react-i18next" + +export default function OidcCallbackPage() { + const router = useRouter() + const { loginWithStsCredentials, isAuthenticated } = useAuth() + const message = useMessage() + const { t } = useTranslation() + const processed = useRef(false) + const [credentialsSet, setCredentialsSet] = useState(false) + const redirectPath = useRef("/browser") + + // Step 1: Parse hash and store credentials + useEffect(() => { + if (processed.current) return + processed.current = true + + const hash = window.location.hash + const credentials = parseOidcCallback(hash) + + if (!credentials) { + message.error(t("SSO login failed: invalid callback")) + router.replace("/auth/login") + return + } + + redirectPath.current = credentials.redirect || "/browser" + + loginWithStsCredentials({ + AccessKeyId: credentials.accessKey, + SecretAccessKey: credentials.secretKey, + SessionToken: credentials.sessionToken, + Expiration: credentials.expiration, + }) + .then(() => { + message.success(t("SSO Login Success")) + setCredentialsSet(true) + }) + .catch(() => { + message.error(t("SSO Login Failed")) + router.replace("/auth/login") + }) + }, [loginWithStsCredentials, router, message, t]) + + // Step 2: Wait for auth state to update before navigating + useEffect(() => { + if (credentialsSet && isAuthenticated) { + router.replace(redirectPath.current) + } + }, [credentialsSet, isAuthenticated, router]) + + return ( +
+
+
+

{t("Completing SSO login...")}

+
+
+ ) +} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index e8a8bc66..5b52333c 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -14,6 +14,8 @@ import { AuthHeroStatic } from "@/components/auth/heroes/hero-static" import { buildRoute } from "@/lib/routes" import logoImage from "@/assets/logo.svg" +import type { OidcProvider } from "@/types/config" + export type LoginMethod = "accessKeyAndSecretKey" | "sts" export interface LoginFormProps { @@ -34,6 +36,8 @@ export interface LoginFormProps { }> > handleLogin: (e: React.FormEvent) => void + oidcProviders?: OidcProvider[] + onOidcLogin?: (providerId: string) => void } export function LoginForm({ @@ -44,6 +48,8 @@ export function LoginForm({ sts, setSts, handleLogin, + oidcProviders, + onOidcLogin, }: LoginFormProps) { const { t } = useTranslation() @@ -192,6 +198,31 @@ export function LoginForm({
+ {oidcProviders && oidcProviders.length > 0 && onOidcLogin && ( +
+
+
+ + {t("Or continue with")} + +
+
+
+ {oidcProviders.map((provider) => ( + + ))} +
+
+ )} +

{t("Login Problems?")}{" "} diff --git a/components/user/dropdown.tsx b/components/user/dropdown.tsx index 54f8970e..95ef9517 100644 --- a/components/user/dropdown.tsx +++ b/components/user/dropdown.tsx @@ -65,11 +65,7 @@ export function UserDropdown() {

- {!isAdmin ? ( - {(userInfo as { account_name?: string })?.account_name ?? ""} - ) : ( - rustfsAdmin - )} + {(userInfo as { account_name?: string })?.account_name ?? (isAdmin ? "rustfsAdmin" : "")}
{!isAdmin && ( diff --git a/contexts/auth-context.tsx b/contexts/auth-context.tsx index 719e9d66..e8e311d7 100644 --- a/contexts/auth-context.tsx +++ b/contexts/auth-context.tsx @@ -19,6 +19,7 @@ interface AuthContextValue { credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider, customConfig?: SiteConfig, ) => Promise + loginWithStsCredentials: (credentials: Credentials) => Promise logout: () => void logoutAndRedirect: () => void setIsAdmin: (value: boolean) => void @@ -110,6 +111,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { [setCredentials, setPermanentCredentials, setPermanentStore], ) + const loginWithStsCredentials = useCallback( + async (creds: Credentials) => { + setCredentials({ + AccessKeyId: creds.AccessKeyId, + SecretAccessKey: creds.SecretAccessKey, + SessionToken: creds.SessionToken, + Expiration: creds.Expiration, + }) + setPermanentStore(undefined) + }, + [setCredentials, setPermanentStore], + ) + const logout = useCallback(() => { setStore({}) setPermanentStore(undefined) @@ -127,6 +141,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ login, + loginWithStsCredentials, logout, logoutAndRedirect, setIsAdmin, @@ -138,6 +153,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }), [ login, + loginWithStsCredentials, logout, logoutAndRedirect, setIsAdmin, diff --git a/i18n/locales/en-US.json b/i18n/locales/en-US.json index a1275306..4a95a905 100644 --- a/i18n/locales/en-US.json +++ b/i18n/locales/en-US.json @@ -142,6 +142,7 @@ "Configure KMS": "Configure KMS", "Configure server-side encryption for your objects using external key management services.": "Configure server-side encryption for your objects using external key management services.", "Configured": "Configured", + "Completing SSO login...": "Completing SSO login...", "Confirm": "Confirm", "Confirm Delete": "Confirm Delete", "Confirm Force Delete": "Confirm Force Delete", @@ -397,6 +398,7 @@ "Login Failed": "Login Failed", "Login Problems?": "Login Problems?", "Login Success": "Login Success", + "Login with {name}": "Login with {name}", "Logout": "Logout", "Logs": "Logs", "MNMD Mode": "MNMD Mode", @@ -497,6 +499,7 @@ "On-site Technical Service": "On-site Technical Service", "One-hour Response": "One-hour Response", "Online": "Online", + "Or continue with": "Or continue with", "Only ZIP files are supported, and file size should not exceed 10MB": "Only ZIP files are supported, and file size should not exceed 10MB", "Overwrite Warning": "Overwrite Warning", "Page will refresh automatically after saving configuration": "Page will refresh automatically after saving configuration", @@ -635,6 +638,9 @@ "SNMD Mode": "SNMD Mode", "SNND Mode": "SNND Mode", "SSE Settings": "SSE Settings", + "SSO Login Failed": "SSO Login Failed", + "SSO Login Success": "SSO Login Success", + "SSO login failed: invalid callback": "SSO login failed: invalid callback", "STS Key": "STS Key", "STS Login": "STS Login", "STS Session Token": "STS Session Token", diff --git a/lib/oidc.ts b/lib/oidc.ts new file mode 100644 index 00000000..ea81a329 --- /dev/null +++ b/lib/oidc.ts @@ -0,0 +1,57 @@ +import type { OidcProvider } from "@/types/config" + +/** + * Fetch configured OIDC providers from the server. + */ +export async function fetchOidcProviders(serverHost: string): Promise { + try { + const url = `${serverHost}/rustfs/admin/v3/oidc/providers` + const response = await fetch(url, { method: "GET" }) + if (!response.ok) return [] + return await response.json() + } catch { + return [] + } +} + +/** + * Redirect the browser to the OIDC authorization endpoint. + */ +export function initiateOidcLogin(serverHost: string, providerId: string, redirectAfter?: string): void { + let url = `${serverHost}/rustfs/admin/v3/oidc/authorize/${encodeURIComponent(providerId)}` + if (redirectAfter) { + url += `?redirect_after=${encodeURIComponent(redirectAfter)}` + } + window.location.href = url +} + +/** + * Parse STS credentials from the URL hash fragment (set by OIDC callback). + * Expected format: #accessKey=...&secretKey=...&sessionToken=...&expiration=...&redirect=/path + */ +export function parseOidcCallback(hash: string): { + accessKey: string + secretKey: string + sessionToken: string + expiration: string + redirect: string +} | null { + // Strip leading # from hash + const cleaned = hash.replace(/^#\/?/, "") + if (!cleaned) return null + + const params = new URLSearchParams(cleaned) + const accessKey = params.get("accessKey") + const secretKey = params.get("secretKey") + const sessionToken = params.get("sessionToken") + + if (!accessKey || !secretKey || !sessionToken) return null + + return { + accessKey, + secretKey, + sessionToken, + expiration: params.get("expiration") ?? "", + redirect: params.get("redirect") ?? "/", + } +} diff --git a/types/config.d.ts b/types/config.d.ts index 16fe6587..30531e64 100644 --- a/types/config.d.ts +++ b/types/config.d.ts @@ -18,10 +18,16 @@ export type Release = { date: string } +export type OidcProvider = { + provider_id: string + display_name: string +} + export interface SiteConfig { serverHost: string api: ApiConfig s3: S3Config session?: SessionConfig release?: Release + oidc?: OidcProvider[] }