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:
@ -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>
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user