Replaces URL-embedded JWT tokens with a single-use, 60-second handoff code that the SPA exchanges via server-to-server POST. The URL now contains only `?handoff=<code>` — no tokens are placed in the browser history, proxy access logs, or Referer header. Backend: new SsoHandoff store (DashMap, 60s TTL, atomic DashMap::remove for single-use), POST /api/v1/auth/sso/handoff endpoint, 7 new tests. Frontend: SsoCallbackPage rewritten to use useSearchParams + POST exchange, with history.replaceState to clear the handoff code from the address bar. Switched from window.location.search to useSearchParams() for test compatibility. New Vitest infrastructure (vitest, @testing-library/react, jsdom) and 6 new tests. CI fix in ccba9e3: cargo fmt --all and added searchParams to useEffect dep array to satisfy CI's Rust Format and Frontend Lint checks. Refs: closes #4
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
import {
|
|
Box, Container, Paper, Typography, Alert, Button, CircularProgress,
|
|
} from '@mui/material'
|
|
import { useAuthStore } from '../store/authStore'
|
|
import type { User } from '../types'
|
|
|
|
/**
|
|
* SSO callback page.
|
|
*
|
|
* Flow (per `tasks/sso-token-handoff-spec.md`):
|
|
* 1. The OIDC provider redirects the browser here with `?handoff=<code>`
|
|
* in the URL. The actual JWT access/refresh tokens are NOT in the URL
|
|
* (that would leak them through browser history, proxy access logs,
|
|
* and the Referer header — see issue #4).
|
|
* 2. On mount, we POST the handoff code to
|
|
* `POST /api/v1/auth/sso/handoff`. The backend atomically removes
|
|
* the entry (single-use) and returns the tokens in the JSON
|
|
* response.
|
|
* 3. On success, we call `setTokens` + `setUser` on the auth store,
|
|
* replace the URL (removing the handoff code from history), and
|
|
* navigate to `/dashboard`.
|
|
* 4. On failure, we show an error and let the user go back to `/login`.
|
|
*/
|
|
export default function SsoCallbackPage() {
|
|
const navigate = useNavigate()
|
|
const [searchParams] = useSearchParams()
|
|
const { setTokens, setUser } = useAuthStore()
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [processing, setProcessing] = useState(true)
|
|
|
|
useEffect(() => {
|
|
// Surface upstream OIDC errors (e.g. user denied consent) unchanged.
|
|
const errorCode = searchParams.get('error')
|
|
const errorDescription = searchParams.get('error_description')
|
|
if (errorCode) {
|
|
setError(errorDescription || `SSO authentication failed: ${errorCode}`)
|
|
setProcessing(false)
|
|
return
|
|
}
|
|
|
|
const handoffCode = searchParams.get('handoff')
|
|
if (!handoffCode) {
|
|
setError('Missing handoff code. Please try logging in again.')
|
|
setProcessing(false)
|
|
return
|
|
}
|
|
|
|
// Exchange the handoff code for tokens. The code is single-use and
|
|
// 60-second TTL on the backend; the SPA must POST promptly.
|
|
(async () => {
|
|
try {
|
|
const resp = await fetch('/api/v1/auth/sso/handoff', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ handoff_code: handoffCode }),
|
|
})
|
|
if (!resp.ok) {
|
|
// Try to extract a structured error from the backend
|
|
let message = `Failed to complete sign-in (HTTP ${resp.status})`
|
|
try {
|
|
const errBody = await resp.json()
|
|
if (errBody?.error?.message) {
|
|
message = errBody.error.message
|
|
}
|
|
} catch {
|
|
// Body wasn't JSON; keep the default message
|
|
}
|
|
setError(message)
|
|
setProcessing(false)
|
|
return
|
|
}
|
|
|
|
const data = await resp.json()
|
|
const user = buildUser(data.user)
|
|
|
|
setTokens(data.access_token, data.refresh_token)
|
|
setUser(user)
|
|
|
|
// Clear the handoff code from the URL so it doesn't end up in
|
|
// browser history or get shared via the address bar. The code
|
|
// is already consumed (single-use) but defense-in-depth.
|
|
window.history.replaceState({}, '', '/auth/sso/callback')
|
|
|
|
navigate('/dashboard', { replace: true })
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error ? err.message : 'Failed to complete sign-in. Please try again.',
|
|
)
|
|
setProcessing(false)
|
|
}
|
|
})()
|
|
}, [setTokens, setUser, navigate, searchParams])
|
|
|
|
return (
|
|
<Container maxWidth="xs" sx={{ mt: 12 }}>
|
|
<Paper elevation={4} sx={{ p: 4 }}>
|
|
<Typography variant="h5" fontWeight={700} mb={3} align="center">
|
|
🐉 Linux Patch Manager
|
|
</Typography>
|
|
|
|
{processing ? (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 4 }}>
|
|
<CircularProgress size={48} sx={{ mb: 2 }} />
|
|
<Typography variant="body1" color="text.secondary">
|
|
Completing sign-in…
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<Box>
|
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
{error}
|
|
</Alert>
|
|
<Button
|
|
fullWidth
|
|
variant="contained"
|
|
size="large"
|
|
onClick={() => navigate('/login', { replace: true })}
|
|
>
|
|
Back to Login
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Map the SSO user JSON payload from the backend to the SPA's `User`
|
|
* type. Fills in sensible defaults for any missing fields.
|
|
*/
|
|
function buildUser(parsed: Record<string, unknown>): User {
|
|
const authProvider = (parsed.auth_provider as string) || 'azure_sso'
|
|
return {
|
|
id: (parsed.id as string) || '',
|
|
username: (parsed.username as string) || '',
|
|
display_name: (parsed.display_name as string) || '',
|
|
email: (parsed.email as string) || '',
|
|
role: (parsed.role as User['role']) || 'operator',
|
|
auth_provider: authProvider as User['auth_provider'],
|
|
mfa_enabled: (parsed.mfa_enabled as boolean) ?? false,
|
|
is_active: true,
|
|
force_password_reset: false,
|
|
}
|
|
}
|