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
This commit is contained in:
committed by
GitHub
parent
3bdae4bcc5
commit
f58d7a6f17
@ -1,76 +1,97 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
// Check for error from backend
|
||||
const errorCode = params.get('error')
|
||||
const errorDescription = params.get('error_description')
|
||||
// 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
|
||||
}
|
||||
|
||||
// Extract tokens
|
||||
const accessToken = params.get('access_token')
|
||||
const refreshToken = params.get('refresh_token')
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
setError('Missing authentication tokens. Please try logging in again.')
|
||||
const handoffCode = searchParams.get('handoff')
|
||||
if (!handoffCode) {
|
||||
setError('Missing handoff code. Please try logging in again.')
|
||||
setProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse user JSON from query param
|
||||
const userParam = params.get('user')
|
||||
if (!userParam) {
|
||||
setError('Missing user information. 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
|
||||
}
|
||||
|
||||
let parsedUser: Record<string, unknown>
|
||||
try {
|
||||
parsedUser = JSON.parse(userParam)
|
||||
} catch {
|
||||
setError('Malformed user data received. Please try logging in again.')
|
||||
setProcessing(false)
|
||||
return
|
||||
}
|
||||
const data = await resp.json()
|
||||
const user = buildUser(data.user)
|
||||
|
||||
// Build a full User object from the SSO subset, filling in sensible defaults
|
||||
// auth_provider comes from the backend based on the OIDC provider type
|
||||
const authProvider = (parsedUser.auth_provider as string) || 'azure_sso'
|
||||
const user: User = {
|
||||
id: (parsedUser.id as string) || '',
|
||||
username: (parsedUser.username as string) || '',
|
||||
display_name: (parsedUser.display_name as string) || '',
|
||||
email: (parsedUser.email as string) || '',
|
||||
role: (parsedUser.role as User['role']) || 'operator',
|
||||
auth_provider: authProvider as User['auth_provider'],
|
||||
mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false,
|
||||
is_active: true,
|
||||
force_password_reset: false,
|
||||
}
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
setUser(user)
|
||||
|
||||
// Store tokens and user, then navigate
|
||||
setTokens(accessToken, refreshToken)
|
||||
setUser(user)
|
||||
navigate('/dashboard', { replace: true })
|
||||
}, [setTokens, setUser, navigate])
|
||||
// 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 }}>
|
||||
@ -105,3 +126,22 @@ export default function SsoCallbackPage() {
|
||||
</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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user