Private
Public Access
1
0

fix(security): stop embedding JWT tokens in SSO callback redirect URL (#4) (#14)

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:
Draco-Lunaris-Echo
2026-06-03 06:28:08 -05:00
committed by GitHub
parent 3bdae4bcc5
commit f58d7a6f17
11 changed files with 3158 additions and 77 deletions

View File

@ -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,
}
}