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

@ -5,6 +5,7 @@ import { darkTheme } from './theme/theme'
import { useAuthStore } from './store/authStore'
import AppLayout from './components/AppLayout'
import LoginPage from './pages/LoginPage'
import SsoCallbackPage from './pages/SsoCallbackPage'
import MfaSetupPage from './pages/MfaSetupPage'
import HostsPage from './pages/HostsPage'
import HostDetailPage from './pages/HostDetailPage'
@ -89,6 +90,7 @@ function App() {
<Routes>
{/* Public */}
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/sso/callback" element={<SsoCallbackPage />} />
{/* Protected — wrapped in AppLayout with sidebar navigation */}
<Route element={<RequireAuth><AppLayout /></RequireAuth>}>

View File

@ -139,7 +139,7 @@ export default function AppLayout() {
<Divider />
<Box sx={{ p: 1.5 }}>
<Typography variant="caption" color="text.secondary">
Linux Patch Manager v0.1.0
Linux Patch Manager v0.1.3
</Typography>
</Box>
</Box>

View File

@ -4,11 +4,13 @@ import {
Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, InputAdornment, IconButton,
List, ListItem, ListItemIcon, ListItemText,
Divider,
} from '@mui/material'
import {
Visibility, VisibilityOff,
Check as CheckIcon, Close as CloseIcon,
} from '@mui/icons-material'
import { Cloud as CloudIcon } from '@mui/icons-material'
import { authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import type { User } from '../types'
@ -323,6 +325,15 @@ export default function LoginPage() {
>
{loading ? <CircularProgress size={24} /> : 'Sign In'}
</Button>
<Divider sx={{ my: 3 }}>or</Divider>
<Button
fullWidth variant="outlined" size="large"
startIcon={<CloudIcon />}
onClick={() => { window.location.href = '/api/v1/auth/azure/login' }}
disabled={loading}
>
Sign in with Microsoft Azure
</Button>
</Box>
)}
</Paper>

View File

@ -7,6 +7,7 @@ import {
import { ContentCopy as CopyIcon } from '@mui/icons-material'
import QRCode from 'qrcode'
import { authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
const STEPS = ['Get your QR code', 'Verify code', 'Done']
@ -23,6 +24,7 @@ export default function MfaSetupPage() {
authApi.getMfaSetup()
.then((res) => {
setSetup(res.data)
console.log('[MFA Setup] Success:', res.status, res.data)
// Generate QR code from otpauth URI
if (res.data.otp_uri) {
QRCode.toDataURL(res.data.otp_uri, {
@ -31,10 +33,31 @@ export default function MfaSetupPage() {
color: { dark: '#000000', light: '#ffffff' },
})
.then((url) => setQrDataUrl(url))
.catch(() => setError('Failed to generate QR code.'))
.catch((qrErr) => {
console.error('[MFA Setup] QR generation failed:', qrErr)
setError('Failed to generate QR code.')
})
} else {
console.error('[MFA Setup] No otp_uri in response:', res.data)
setError('MFA setup returned invalid data. No OTP URI found.')
}
})
.catch((err) => {
const status = err?.response?.status
const data = err?.response?.data
const message = err?.message
const token = useAuthStore.getState().accessToken
console.error('[MFA Setup] Failed:', { status, data, message, hasToken: !!token })
if (status === 401) {
setError('Authentication required. Please log in again.')
} else if (status === 403) {
setError('You do not have permission to set up MFA.')
} else if (message === 'Network Error') {
setError('Network error. Please check your connection and try again.')
} else {
setError(`Failed to load MFA setup: ${message || 'Unknown error'} (Status: ${status || 'N/A'})`)
}
})
.catch(() => setError('Failed to load MFA setup.'))
}, [])
const handleCopySecret = () => {

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

View File

@ -1,4 +1,5 @@
import { useEffect, useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions,
DialogContent, DialogContentText, DialogTitle, FormControlLabel, IconButton,
@ -75,6 +76,7 @@ function PasswordStrengthIndicator({ password }: { password: string }) {
export default function UsersPage() {
const currentUser = useAuthStore(s => s.user)
const navigate = useNavigate()
const isAdmin = currentUser?.role === 'admin'
const [users, setUsers] = useState<User[]>([])
@ -327,8 +329,17 @@ export default function UsersPage() {
color={u.role === 'admin' ? 'primary' : 'default'} />
</TableCell>
<TableCell>
<Chip size="small" label={u.mfa_enabled ? 'On' : 'Off'}
color={u.mfa_enabled ? 'success' : 'warning'} />
{u.mfa_enabled ? (
<Chip size="small" label="On" color="success" />
) : currentUser?.id === u.id ? (
<Tooltip title="Enable MFA">
<Chip size="small" label="Off" color="warning"
sx={{ cursor: 'pointer', '&:hover': { opacity: 0.8 } }}
onClick={() => navigate('/mfa/setup')} />
</Tooltip>
) : (
<Chip size="small" label="Off" color="default" />
)}
</TableCell>
<TableCell>
<Chip size="small" label={u.is_active ? 'Active' : 'Disabled'}
@ -460,24 +471,41 @@ export default function UsersPage() {
/>
</Box>
{/* MFA status & disable */}
{/* MFA status */}
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" color="text.secondary">MFA Status:</Typography>
<Chip size="small"
label={editUser?.mfa_enabled ? 'Enabled' : 'Disabled'}
color={editUser?.mfa_enabled ? 'success' : 'default'}
/>
{editUser?.mfa_enabled && (
{editUser?.mfa_enabled ? (
<Button size="small" color="error" variant="outlined"
onClick={() => editUser && handleMfaDisable(editUser)}>
Disable MFA
</Button>
) : (
currentUser?.id === editUser?.id ? (
<Button size="small" color="primary" variant="outlined"
onClick={() => navigate('/mfa/setup')}>
Enable MFA
</Button>
) : (
<Typography variant="caption" color="text.secondary">
User must enable MFA from their own profile settings.
</Typography>
)
)}
</Box>
{editUser?.mfa_enabled && (
{editUser?.mfa_enabled ? (
<Typography variant="caption" color="warning.main" sx={{ display: 'block', mt: 0.5 }}>
Disabling MFA reduces account security for this user.
</Typography>
) : (
currentUser?.id === editUser?.id && (
<Typography variant="caption" color="info.main" sx={{ display: 'block', mt: 0.5 }}>
You will be guided through authenticator app setup.
</Typography>
)
)}
</>
)}