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,
|
||||
}
|
||||
}
|
||||
|
||||
205
frontend/src/pages/__tests__/SsoCallbackPage.test.tsx
Normal file
205
frontend/src/pages/__tests__/SsoCallbackPage.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
6
frontend/src/test/setup.ts
Normal file
6
frontend/src/test/setup.ts
Normal 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'
|
||||
Reference in New Issue
Block a user