Skip to content

Commit 0ab449e

Browse files
authored
fix(auth): support OIDC federated logout (#104)
1 parent f8c09cd commit 0ab449e

4 files changed

Lines changed: 76 additions & 16 deletions

File tree

app/(auth)/auth/oidc-callback/page.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ export default function OidcCallbackPage() {
3535

3636
redirectPath.current = isSafeRedirectPath(credentials.redirect, "/")
3737

38-
loginWithStsCredentials({
39-
AccessKeyId: credentials.accessKey,
40-
SecretAccessKey: credentials.secretKey,
41-
SessionToken: credentials.sessionToken,
42-
Expiration: credentials.expiration,
43-
})
38+
loginWithStsCredentials(
39+
{
40+
AccessKeyId: credentials.accessKey,
41+
SecretAccessKey: credentials.secretKey,
42+
SessionToken: credentials.sessionToken,
43+
Expiration: credentials.expiration,
44+
},
45+
credentials.logoutToken ? { logoutToken: credentials.logoutToken } : undefined,
46+
)
4447
.then(() => {
4548
message.success(t("SSO Login Success"))
4649
setCredentialsSet(true)

components/user/dropdown.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
66
import { useTranslation } from "react-i18next"
77
import { useTheme } from "next-themes"
88
import { RiUserLine, RiLockPasswordLine, RiLogoutBoxRLine, RiMore2Line } from "@remixicon/react"
9-
import { buildRoute } from "@/lib/routes"
9+
import { buildRoute, getLoginRoute } from "@/lib/routes"
1010
import { Button } from "@/components/ui/button"
1111
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
1212
import { useAuth } from "@/contexts/auth-context"
@@ -40,7 +40,7 @@ export function UserDropdown() {
4040
const { t } = useTranslation()
4141
const router = useRouter()
4242
const { resolvedTheme } = useTheme()
43-
const { logout, isAdmin } = useAuth()
43+
const { logoutWithOidcRedirect, isAdmin } = useAuth()
4444
const { userInfo } = usePermissions()
4545
const { state } = useSidebar()
4646
const isCollapsed = state === "collapsed"
@@ -60,8 +60,10 @@ export function UserDropdown() {
6060
}
6161

6262
const handleLogout = async () => {
63-
await logout()
64-
router.push("/auth/login")
63+
const redirected = await logoutWithOidcRedirect()
64+
if (!redirected) {
65+
router.push(getLoginRoute())
66+
}
6567
}
6668

6769
return (

contexts/auth-context.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getStsToken } from "@/lib/sts"
66
import type { SiteConfig } from "@/types/config"
77
import { getLoginRoute } from "@/lib/routes"
88
import { useLocalStorage } from "@/hooks/use-local-storage"
9+
import { buildOidcLogoutUrl, type OidcLogoutSession } from "@/lib/oidc"
910

1011
interface Credentials {
1112
AccessKeyId?: string
@@ -19,9 +20,10 @@ interface AuthContextValue {
1920
credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider,
2021
customConfig?: SiteConfig,
2122
) => Promise<unknown>
22-
loginWithStsCredentials: (credentials: Credentials) => Promise<void>
23+
loginWithStsCredentials: (credentials: Credentials, oidcSession?: OidcLogoutSession) => Promise<void>
2324
logout: () => void
2425
logoutAndRedirect: () => void
26+
logoutWithOidcRedirect: () => Promise<boolean>
2527
setIsAdmin: (value: boolean) => void
2628
getIsAdmin: () => boolean
2729
credentials: Credentials | undefined
@@ -44,9 +46,17 @@ function isValidCredentials(credentials: Credentials | undefined): boolean {
4446
return !isExpired(credentials.Expiration)
4547
}
4648

49+
function isValidOidcLogoutSession(session: OidcLogoutSession | undefined): session is OidcLogoutSession {
50+
return typeof session?.logoutToken === "string" && session.logoutToken.trim().length > 0
51+
}
52+
4753
export function AuthProvider({ children }: { children: ReactNode }) {
4854
const [store, setStore] = useLocalStorage<Credentials | undefined>("auth.credentials", undefined)
4955
const [isAdminStore, setIsAdminStore] = useLocalStorage<boolean | undefined>("auth.isAdmin", undefined)
56+
const [oidcSessionStore, setOidcSessionStore] = useLocalStorage<OidcLogoutSession | undefined>(
57+
"auth.oidcSession",
58+
undefined,
59+
)
5060

5161
const setCredentials = useCallback(
5262
(credentials: Credentials) => {
@@ -67,6 +77,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
6777
[setIsAdminStore],
6878
)
6979

80+
const setOidcSession = useCallback(
81+
(session: OidcLogoutSession | undefined) => {
82+
setOidcSessionStore(isValidOidcLogoutSession(session) ? session : undefined)
83+
},
84+
[setOidcSessionStore],
85+
)
86+
7087
const getIsAdmin = useCallback(() => {
7188
return !!isAdminStore
7289
}, [isAdminStore])
@@ -87,34 +104,53 @@ export function AuthProvider({ children }: { children: ReactNode }) {
87104
SessionToken: credentialsResponse.SessionToken,
88105
Expiration: credentialsResponse.Expiration?.toISOString(),
89106
})
107+
setOidcSession(undefined)
90108

91109
return credentialsResponse
92110
},
93-
[setCredentials],
111+
[setCredentials, setOidcSession],
94112
)
95113

96114
const loginWithStsCredentials = useCallback(
97-
async (creds: Credentials) => {
115+
async (creds: Credentials, oidcSession?: OidcLogoutSession) => {
98116
setCredentials({
99117
AccessKeyId: creds.AccessKeyId,
100118
SecretAccessKey: creds.SecretAccessKey,
101119
SessionToken: creds.SessionToken,
102120
Expiration: creds.Expiration,
103121
})
122+
setOidcSession(oidcSession)
104123
},
105-
[setCredentials],
124+
[setCredentials, setOidcSession],
106125
)
107126

108127
const logout = useCallback(() => {
109128
setStore(undefined)
110129
setIsAdminStore(undefined)
111-
}, [setStore, setIsAdminStore])
130+
setOidcSessionStore(undefined)
131+
}, [setStore, setIsAdminStore, setOidcSessionStore])
112132

113133
const logoutAndRedirect = useCallback(() => {
114134
logout()
115135
window.location.href = getLoginRoute()
116136
}, [logout])
117137

138+
const logoutWithOidcRedirect = useCallback(async () => {
139+
const oidcSession = isValidOidcLogoutSession(oidcSessionStore) ? oidcSessionStore : undefined
140+
logout()
141+
142+
if (!oidcSession) return false
143+
144+
try {
145+
const { configManager } = await import("@/lib/config")
146+
const config = await configManager.loadConfig()
147+
window.location.href = buildOidcLogoutUrl(config.serverHost, oidcSession.logoutToken)
148+
return true
149+
} catch {
150+
return false
151+
}
152+
}, [oidcSessionStore, logout])
153+
118154
const credentials = getCredentials()
119155
const isAuthenticated = isValidCredentials(store)
120156

@@ -124,6 +160,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
124160
loginWithStsCredentials,
125161
logout,
126162
logoutAndRedirect,
163+
logoutWithOidcRedirect,
127164
setIsAdmin,
128165
getIsAdmin,
129166
credentials,
@@ -135,6 +172,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
135172
loginWithStsCredentials,
136173
logout,
137174
logoutAndRedirect,
175+
logoutWithOidcRedirect,
138176
setIsAdmin,
139177
getIsAdmin,
140178
credentials,

lib/oidc.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { OidcProvider } from "@/types/config"
22
import { isSafeRedirectPath } from "@/lib/routes"
33

4+
export interface OidcLogoutSession {
5+
logoutToken: string
6+
}
7+
48
/**
59
* Fetch configured OIDC providers from the server.
610
*/
@@ -26,16 +30,27 @@ export function initiateOidcLogin(serverHost: string, providerId: string, redire
2630
window.location.href = url
2731
}
2832

33+
/**
34+
* Build the backend logout endpoint URL. The backend decides whether to perform
35+
* RP-initiated logout with the IdP or fall back to the console login page.
36+
*/
37+
export function buildOidcLogoutUrl(serverHost: string, logoutToken: string): string {
38+
const base = serverHost.replace(/\/$/, "")
39+
return `${base}/rustfs/admin/v3/oidc/logout?logout_token=${encodeURIComponent(logoutToken)}`
40+
}
41+
2942
/**
3043
* Parse STS credentials from the URL hash fragment (set by OIDC callback).
31-
* Expected format: #accessKey=...&secretKey=...&sessionToken=...&expiration=...&redirect=/path
44+
* Expected format:
45+
* #accessKey=...&secretKey=...&sessionToken=...&expiration=...&redirect=/path&logoutToken=...
3246
*/
3347
export function parseOidcCallback(hash: string): {
3448
accessKey: string
3549
secretKey: string
3650
sessionToken: string
3751
expiration: string
3852
redirect: string
53+
logoutToken?: string
3954
} | null {
4055
// Strip leading # from hash
4156
const cleaned = hash.replace(/^#\/?/, "")
@@ -45,6 +60,7 @@ export function parseOidcCallback(hash: string): {
4560
const accessKey = params.get("accessKey")
4661
const secretKey = params.get("secretKey")
4762
const sessionToken = params.get("sessionToken")
63+
const logoutToken = params.get("logoutToken") ?? undefined
4864

4965
if (!accessKey || !secretKey || !sessionToken) return null
5066

@@ -54,5 +70,6 @@ export function parseOidcCallback(hash: string): {
5470
sessionToken,
5571
expiration: params.get("expiration") ?? "",
5672
redirect: isSafeRedirectPath(params.get("redirect") ?? "", "/"),
73+
logoutToken: logoutToken || undefined,
5774
}
5875
}

0 commit comments

Comments
 (0)