Skip to content
Merged
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
15 changes: 9 additions & 6 deletions app/(auth)/auth/oidc-callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions components/user/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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 (
Expand Down
48 changes: 43 additions & 5 deletions contexts/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,9 +20,10 @@ interface AuthContextValue {
credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider,
customConfig?: SiteConfig,
) => Promise<unknown>
loginWithStsCredentials: (credentials: Credentials) => Promise<void>
loginWithStsCredentials: (credentials: Credentials, oidcSession?: OidcLogoutSession) => Promise<void>
logout: () => void
logoutAndRedirect: () => void
logoutWithOidcRedirect: () => Promise<boolean>
setIsAdmin: (value: boolean) => void
getIsAdmin: () => boolean
credentials: Credentials | undefined
Expand All @@ -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<Credentials | undefined>("auth.credentials", undefined)
const [isAdminStore, setIsAdminStore] = useLocalStorage<boolean | undefined>("auth.isAdmin", undefined)
const [oidcSessionStore, setOidcSessionStore] = useLocalStorage<OidcLogoutSession | undefined>(
"auth.oidcSession",
undefined,
)

const setCredentials = useCallback(
(credentials: Credentials) => {
Expand All @@ -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])
Expand All @@ -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)

Expand All @@ -124,6 +160,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
loginWithStsCredentials,
logout,
logoutAndRedirect,
logoutWithOidcRedirect,
setIsAdmin,
getIsAdmin,
credentials,
Expand All @@ -135,6 +172,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
loginWithStsCredentials,
logout,
logoutAndRedirect,
logoutWithOidcRedirect,
setIsAdmin,
getIsAdmin,
credentials,
Expand Down
19 changes: 18 additions & 1 deletion lib/oidc.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -26,16 +30,27 @@ 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
secretKey: string
sessionToken: string
expiration: string
redirect: string
logoutToken?: string
} | null {
// Strip leading # from hash
const cleaned = hash.replace(/^#\/?/, "")
Expand All @@ -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

Expand All @@ -54,5 +70,6 @@ export function parseOidcCallback(hash: string): {
sessionToken,
expiration: params.get("expiration") ?? "",
redirect: isSafeRedirectPath(params.get("redirect") ?? "", "/"),
logoutToken: logoutToken || undefined,
}
}
Loading