diff --git a/app/(auth)/auth/oidc-callback/page.tsx b/app/(auth)/auth/oidc-callback/page.tsx index 6e171d0..1918f15 100644 --- a/app/(auth)/auth/oidc-callback/page.tsx +++ b/app/(auth)/auth/oidc-callback/page.tsx @@ -35,12 +35,15 @@ export default function OidcCallbackPage() { redirectPath.current = isSafeRedirectPath(credentials.redirect, "/") - loginWithStsCredentials({ - AccessKeyId: credentials.accessKey, - SecretAccessKey: credentials.secretKey, - SessionToken: credentials.sessionToken, - Expiration: credentials.expiration, - }) + loginWithStsCredentials( + { + AccessKeyId: credentials.accessKey, + SecretAccessKey: credentials.secretKey, + SessionToken: credentials.sessionToken, + Expiration: credentials.expiration, + }, + credentials.logoutToken ? { logoutToken: credentials.logoutToken } : undefined, + ) .then(() => { message.success(t("SSO Login Success")) setCredentialsSet(true) diff --git a/components/user/dropdown.tsx b/components/user/dropdown.tsx index 8e8a7df..67f34f9 100644 --- a/components/user/dropdown.tsx +++ b/components/user/dropdown.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation" import { useTranslation } from "react-i18next" import { useTheme } from "next-themes" import { RiUserLine, RiLockPasswordLine, RiLogoutBoxRLine, RiMore2Line } from "@remixicon/react" -import { buildRoute } from "@/lib/routes" +import { buildRoute, getLoginRoute } from "@/lib/routes" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { useAuth } from "@/contexts/auth-context" @@ -40,7 +40,7 @@ export function UserDropdown() { const { t } = useTranslation() const router = useRouter() const { resolvedTheme } = useTheme() - const { logout, isAdmin } = useAuth() + const { logoutWithOidcRedirect, isAdmin } = useAuth() const { userInfo } = usePermissions() const { state } = useSidebar() const isCollapsed = state === "collapsed" @@ -60,8 +60,10 @@ export function UserDropdown() { } const handleLogout = async () => { - await logout() - router.push("/auth/login") + const redirected = await logoutWithOidcRedirect() + if (!redirected) { + router.push(getLoginRoute()) + } } return ( diff --git a/contexts/auth-context.tsx b/contexts/auth-context.tsx index 2810da1..693f190 100644 --- a/contexts/auth-context.tsx +++ b/contexts/auth-context.tsx @@ -6,6 +6,7 @@ import { getStsToken } from "@/lib/sts" import type { SiteConfig } from "@/types/config" import { getLoginRoute } from "@/lib/routes" import { useLocalStorage } from "@/hooks/use-local-storage" +import { buildOidcLogoutUrl, type OidcLogoutSession } from "@/lib/oidc" interface Credentials { AccessKeyId?: string @@ -19,9 +20,10 @@ interface AuthContextValue { credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider, customConfig?: SiteConfig, ) => Promise - loginWithStsCredentials: (credentials: Credentials) => Promise + loginWithStsCredentials: (credentials: Credentials, oidcSession?: OidcLogoutSession) => Promise logout: () => void logoutAndRedirect: () => void + logoutWithOidcRedirect: () => Promise setIsAdmin: (value: boolean) => void getIsAdmin: () => boolean credentials: Credentials | undefined @@ -44,9 +46,17 @@ function isValidCredentials(credentials: Credentials | undefined): boolean { return !isExpired(credentials.Expiration) } +function isValidOidcLogoutSession(session: OidcLogoutSession | undefined): session is OidcLogoutSession { + return typeof session?.logoutToken === "string" && session.logoutToken.trim().length > 0 +} + export function AuthProvider({ children }: { children: ReactNode }) { const [store, setStore] = useLocalStorage("auth.credentials", undefined) const [isAdminStore, setIsAdminStore] = useLocalStorage("auth.isAdmin", undefined) + const [oidcSessionStore, setOidcSessionStore] = useLocalStorage( + "auth.oidcSession", + undefined, + ) const setCredentials = useCallback( (credentials: Credentials) => { @@ -67,6 +77,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { [setIsAdminStore], ) + const setOidcSession = useCallback( + (session: OidcLogoutSession | undefined) => { + setOidcSessionStore(isValidOidcLogoutSession(session) ? session : undefined) + }, + [setOidcSessionStore], + ) + const getIsAdmin = useCallback(() => { return !!isAdminStore }, [isAdminStore]) @@ -87,34 +104,53 @@ export function AuthProvider({ children }: { children: ReactNode }) { SessionToken: credentialsResponse.SessionToken, Expiration: credentialsResponse.Expiration?.toISOString(), }) + setOidcSession(undefined) return credentialsResponse }, - [setCredentials], + [setCredentials, setOidcSession], ) const loginWithStsCredentials = useCallback( - async (creds: Credentials) => { + async (creds: Credentials, oidcSession?: OidcLogoutSession) => { setCredentials({ AccessKeyId: creds.AccessKeyId, SecretAccessKey: creds.SecretAccessKey, SessionToken: creds.SessionToken, Expiration: creds.Expiration, }) + setOidcSession(oidcSession) }, - [setCredentials], + [setCredentials, setOidcSession], ) const logout = useCallback(() => { setStore(undefined) setIsAdminStore(undefined) - }, [setStore, setIsAdminStore]) + setOidcSessionStore(undefined) + }, [setStore, setIsAdminStore, setOidcSessionStore]) const logoutAndRedirect = useCallback(() => { logout() window.location.href = getLoginRoute() }, [logout]) + const logoutWithOidcRedirect = useCallback(async () => { + const oidcSession = isValidOidcLogoutSession(oidcSessionStore) ? oidcSessionStore : undefined + logout() + + if (!oidcSession) return false + + try { + const { configManager } = await import("@/lib/config") + const config = await configManager.loadConfig() + window.location.href = buildOidcLogoutUrl(config.serverHost, oidcSession.logoutToken) + return true + } catch { + return false + } + }, [oidcSessionStore, logout]) + const credentials = getCredentials() const isAuthenticated = isValidCredentials(store) @@ -124,6 +160,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { loginWithStsCredentials, logout, logoutAndRedirect, + logoutWithOidcRedirect, setIsAdmin, getIsAdmin, credentials, @@ -135,6 +172,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { loginWithStsCredentials, logout, logoutAndRedirect, + logoutWithOidcRedirect, setIsAdmin, getIsAdmin, credentials, diff --git a/lib/oidc.ts b/lib/oidc.ts index a4f1cd1..b4432ee 100644 --- a/lib/oidc.ts +++ b/lib/oidc.ts @@ -1,6 +1,10 @@ import type { OidcProvider } from "@/types/config" import { isSafeRedirectPath } from "@/lib/routes" +export interface OidcLogoutSession { + logoutToken: string +} + /** * Fetch configured OIDC providers from the server. */ @@ -26,9 +30,19 @@ export function initiateOidcLogin(serverHost: string, providerId: string, redire window.location.href = url } +/** + * Build the backend logout endpoint URL. The backend decides whether to perform + * RP-initiated logout with the IdP or fall back to the console login page. + */ +export function buildOidcLogoutUrl(serverHost: string, logoutToken: string): string { + const base = serverHost.replace(/\/$/, "") + return `${base}/rustfs/admin/v3/oidc/logout?logout_token=${encodeURIComponent(logoutToken)}` +} + /** * Parse STS credentials from the URL hash fragment (set by OIDC callback). - * Expected format: #accessKey=...&secretKey=...&sessionToken=...&expiration=...&redirect=/path + * Expected format: + * #accessKey=...&secretKey=...&sessionToken=...&expiration=...&redirect=/path&logoutToken=... */ export function parseOidcCallback(hash: string): { accessKey: string @@ -36,6 +50,7 @@ export function parseOidcCallback(hash: string): { sessionToken: string expiration: string redirect: string + logoutToken?: string } | null { // Strip leading # from hash const cleaned = hash.replace(/^#\/?/, "") @@ -45,6 +60,7 @@ export function parseOidcCallback(hash: string): { const accessKey = params.get("accessKey") const secretKey = params.get("secretKey") const sessionToken = params.get("sessionToken") + const logoutToken = params.get("logoutToken") ?? undefined if (!accessKey || !secretKey || !sessionToken) return null @@ -54,5 +70,6 @@ export function parseOidcCallback(hash: string): { sessionToken, expiration: params.get("expiration") ?? "", redirect: isSafeRedirectPath(params.get("redirect") ?? "", "/"), + logoutToken: logoutToken || undefined, } }