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
19 changes: 19 additions & 0 deletions app/(auth)/auth/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -34,6 +36,7 @@ function LoginPageContent() {
secretAccessKey: "",
sessionToken: "",
})
const [oidcProviders, setOidcProviders] = useState<OidcProvider[]>([])

useEffect(() => {
if (isAuthenticated) {
Expand All @@ -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()

Expand All @@ -64,6 +76,11 @@ function LoginPageContent() {
}
}

const handleOidcLogin = async (providerId: string) => {
const config = await configManager.loadConfig()
initiateOidcLogin(config.serverHost, providerId)
}

return (
<LoginForm
method={method}
Expand All @@ -73,6 +90,8 @@ function LoginPageContent() {
sts={sts}
setSts={setSts}
handleLogin={handleLogin}
oidcProviders={oidcProviders}
onOidcLogin={handleOidcLogin}
/>
)
}
66 changes: 66 additions & 0 deletions app/(auth)/auth/oidc-callback/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-gray-100 dark:bg-neutral-800">
<div className="text-center">
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto" />
<p className="text-gray-600 dark:text-neutral-400">{t("Completing SSO login...")}</p>
</div>
</div>
)
}
31 changes: 31 additions & 0 deletions components/auth/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,6 +36,8 @@ export interface LoginFormProps {
}>
>
handleLogin: (e: React.FormEvent) => void
oidcProviders?: OidcProvider[]
onOidcLogin?: (providerId: string) => void
}

export function LoginForm({
Expand All @@ -44,6 +48,8 @@ export function LoginForm({
sts,
setSts,
handleLogin,
oidcProviders,
onOidcLogin,
}: LoginFormProps) {
const { t } = useTranslation()

Expand Down Expand Up @@ -192,6 +198,31 @@ export function LoginForm({
</Tabs>
</div>

{oidcProviders && oidcProviders.length > 0 && onOidcLogin && (
<div className="space-y-3">
<div className="relative flex items-center">
<div className="flex-grow border-t border-gray-200 dark:border-neutral-700" />
<span className="mx-3 flex-shrink text-xs text-gray-500 dark:text-neutral-500">
{t("Or continue with")}
</span>
<div className="flex-grow border-t border-gray-200 dark:border-neutral-700" />
</div>
<div className="grid gap-2">
{oidcProviders.map((provider) => (
<Button
key={provider.provider_id}
type="button"
variant="outline"
className="w-full justify-center"
onClick={() => onOidcLogin(provider.provider_id)}
>
{t("Login with {name}", { name: provider.display_name })}
</Button>
))}
</div>
</div>
)}

<div>
<p className="text-sm text-gray-600 dark:text-neutral-400">
{t("Login Problems?")}{" "}
Expand Down
6 changes: 1 addition & 5 deletions components/user/dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,7 @@ export function UserDropdown() {
<DropdownMenuItem asChild>
<div className="flex cursor-default items-center gap-2">
<RiUserLine className="h-4 w-4" />
{!isAdmin ? (
<span>{(userInfo as { account_name?: string })?.account_name ?? ""}</span>
) : (
<span>rustfsAdmin</span>
)}
<span>{(userInfo as { account_name?: string })?.account_name ?? (isAdmin ? "rustfsAdmin" : "")}</span>
</div>
</DropdownMenuItem>
{!isAdmin && (
Expand Down
16 changes: 16 additions & 0 deletions contexts/auth-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface AuthContextValue {
credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider,
customConfig?: SiteConfig,
) => Promise<unknown>
loginWithStsCredentials: (credentials: Credentials) => Promise<void>
logout: () => void
logoutAndRedirect: () => void
setIsAdmin: (value: boolean) => void
Expand Down Expand Up @@ -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)
Expand All @@ -127,6 +141,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const value = useMemo<AuthContextValue>(
() => ({
login,
loginWithStsCredentials,
logout,
logoutAndRedirect,
setIsAdmin,
Expand All @@ -138,6 +153,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}),
[
login,
loginWithStsCredentials,
logout,
logoutAndRedirect,
setIsAdmin,
Expand Down
6 changes: 6 additions & 0 deletions i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
57 changes: 57 additions & 0 deletions lib/oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { OidcProvider } from "@/types/config"

/**
* Fetch configured OIDC providers from the server.
*/
export async function fetchOidcProviders(serverHost: string): Promise<OidcProvider[]> {
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") ?? "/",
}
}
6 changes: 6 additions & 0 deletions types/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
Loading