feat: Complete Azure SSO implementation (v0.1.3)
- Add SSO session cleanup task (10-min expiry, 60s purge interval) - Change callback to redirect to frontend with tokens as query params - Add sso_callback_url to SecurityConfig with serde default - Add SsoCallbackPage.tsx for handling SSO callback redirects - Add /auth/sso/callback public route to App.tsx - Add Sign in with Microsoft Azure button to LoginPage - Replace insecure decode_jwt_payload with verify_id_token - Implement JWKS caching (1-hour TTL) and RSA signature verification - Validate iss, aud, exp claims on id_token - Add jsonwebtoken dependency to pm-web crate - Update config.example.toml with sso_callback_url setting - Add sso_callback_url to settings response (read-only from TOML)
This commit is contained in:
105
frontend/src/pages/SsoCallbackPage.tsx
Normal file
105
frontend/src/pages/SsoCallbackPage.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } 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'
|
||||
|
||||
export default function SsoCallbackPage() {
|
||||
const navigate = useNavigate()
|
||||
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')
|
||||
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.')
|
||||
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
|
||||
}
|
||||
|
||||
let parsedUser: Record<string, unknown>
|
||||
try {
|
||||
parsedUser = JSON.parse(userParam)
|
||||
} catch {
|
||||
setError('Malformed user data received. Please try logging in again.')
|
||||
setProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Build a full User object from the SSO subset, filling in sensible defaults
|
||||
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: 'azure_sso',
|
||||
mfa_enabled: (parsedUser.mfa_enabled as boolean) ?? false,
|
||||
is_active: true,
|
||||
force_password_reset: false,
|
||||
}
|
||||
|
||||
// Store tokens and user, then navigate
|
||||
setTokens(accessToken, refreshToken)
|
||||
setUser(user)
|
||||
navigate('/dashboard', { replace: true })
|
||||
}, [setTokens, setUser, navigate])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user