Private
Public Access
1
0

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:
2026-05-12 17:01:20 +00:00
parent 08add28b80
commit 86a6c714d4
18 changed files with 561 additions and 239 deletions

View 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>
)
}