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

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,9 @@
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src/ --ext .ts,.tsx --max-warnings 0",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@ -25,6 +27,9 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.30.0",
@ -32,7 +37,9 @@
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.24.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jsdom": "^25.0.1",
"typescript": "^5.8.3",
"vite": "^6.3.3"
"vite": "^6.3.3",
"vitest": "^2.1.9"
}
}

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

View File

@ -0,0 +1,205 @@
/// Tests for SsoCallbackPage (issue #4 — SSO token handoff).
///
/// Per `tasks/sso-token-handoff-spec.md` §6.3:
/// 9. renders_processing_state_initially
/// 10. calls_handoff_endpoint_on_mount
/// 11. stores_tokens_and_user_on_success
/// 12. shows_error_on_handoff_failure
/// 13. shows_error_when_handoff_code_missing
/// 14. clears_handoff_code_from_url_after_success
///
/// We mock `fetch`, the auth store, and `window.history.replaceState`
/// so the test focuses on the page's effect-driven logic (URL parsing
/// → POST exchange → store update → navigation → URL cleanup). We do
/// NOT mock `react-router-dom` — instead, we use a real
/// `MemoryRouter` and assert on side effects (the auth store mocks +
/// `replaceState` spy + visible error text).
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import SsoCallbackPage from '../SsoCallbackPage'
// Mock the auth store — we don't want real zustand state leaking
// between tests, and we want to assert on setTokens/setUser calls.
const setTokensMock = vi.fn()
const setUserMock = vi.fn()
vi.mock('../../store/authStore', () => ({
useAuthStore: () => ({
setTokens: setTokensMock,
setUser: setUserMock,
}),
}))
// Helper: render the page with a controlled URL and let the test
// inspect the rendered output + the auth store mocks.
function renderAt(url: string) {
return render(
<MemoryRouter initialEntries={[url]}>
<SsoCallbackPage />
</MemoryRouter>,
)
}
beforeEach(() => {
setTokensMock.mockReset()
setUserMock.mockReset()
// Default fetch: never-resolving promise (keeps the page in
// "processing" state). Individual tests override this.
globalThis.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('SsoCallbackPage', () => {
// 9. renders_processing_state_initially — on mount with a handoff
// code, shows the spinner and "Completing sign-in…".
it('renders the processing state initially', async () => {
// Wrap in act() to flush the useEffect that calls fetch.
await act(async () => {
renderAt('/auth/sso/callback?handoff=test-code')
})
expect(screen.getByText(/completing sign-in/i)).toBeInTheDocument()
// The MUI CircularProgress renders a role="progressbar"
expect(screen.getByRole('progressbar')).toBeInTheDocument()
})
// 10. calls_handoff_endpoint_on_mount — mocks fetch and asserts
// the POST goes to /api/v1/auth/sso/handoff with
// { handoff_code: <code> }.
it('POSTs the handoff code to the backend on mount', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'a',
refresh_token: 'r',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'u1', username: 'tester' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=abc123')
})
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1)
})
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('/api/v1/auth/sso/handoff')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body)).toEqual({ handoff_code: 'abc123' })
})
// 11. stores_tokens_and_user_on_success — mocks a successful
// response, asserts setTokens and setUser are called, and
// setTokens receives the correct token values.
it('stores tokens + user on a successful exchange', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'access-jwt',
refresh_token: 'refresh-raw',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'user-42', username: 'alice' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=ok')
})
await waitFor(() => {
expect(setTokensMock).toHaveBeenCalledWith('access-jwt', 'refresh-raw')
})
expect(setUserMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'user-42', username: 'alice' }),
)
})
// 12. shows_error_on_handoff_failure — mocks a 400 response,
// asserts the error message is rendered and the spinner
// stops.
it('shows an error when the backend returns 400', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
error: { code: 'invalid_handoff', message: 'Handoff code has expired' },
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback?handoff=expired')
})
expect(await screen.findByText(/handoff code has expired/i)).toBeInTheDocument()
expect(screen.queryByText(/completing sign-in/i)).not.toBeInTheDocument()
// No token storage on error
expect(setTokensMock).not.toHaveBeenCalled()
expect(setUserMock).not.toHaveBeenCalled()
})
// 13. shows_error_when_handoff_code_missing — invokes the effect
// with no handoff code, asserts the "Missing handoff code"
// error is shown.
it('shows a missing-code error when ?handoff= is absent', async () => {
const fetchMock = vi.fn()
globalThis.fetch = fetchMock as unknown as typeof fetch
await act(async () => {
renderAt('/auth/sso/callback')
})
expect(await screen.findByText(/missing handoff code/i)).toBeInTheDocument()
// No fetch call should have been made
expect(fetchMock).not.toHaveBeenCalled()
})
// 14. clears_handoff_code_from_url_after_success — asserts
// window.history.replaceState is called to remove the
// ?handoff= param from the URL after a successful exchange.
it('clears the handoff code from the URL after a successful exchange', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'a',
refresh_token: 'r',
token_type: 'Bearer',
expires_in: 900,
user: { id: 'u', username: 'u' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
const replaceStateSpy = vi.spyOn(window.history, 'replaceState')
await act(async () => {
renderAt('/auth/sso/callback?handoff=secret-code')
})
await waitFor(() => {
expect(replaceStateSpy).toHaveBeenCalled()
})
// Verify the replaceState call cleared the query string — the
// third argument is the new URL ('/auth/sso/callback' with no
// query).
const args = replaceStateSpy.mock.calls[0]
expect(args[2]).toBe('/auth/sso/callback')
})
})

View File

@ -0,0 +1,6 @@
/// Vitest setup file — runs before each test file.
///
/// Imports `@testing-library/jest-dom` to register custom matchers like
/// `toBeInTheDocument`, `toHaveTextContent`, etc. that the SSO callback
/// tests rely on.
import '@testing-library/jest-dom/vitest'

18
frontend/vitest.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
/// Vitest configuration for the Patch Manager UI.
///
/// - Uses jsdom for a browser-like environment (needed for MUI + React
/// Testing Library).
/// - The `react()` plugin is required for JSX in test files.
/// - `globals: true` lets tests use `describe`, `it`, `expect` without
/// imports (matches the existing frontend conventions).
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
},
})