Add OIDC/SSO authentication support
Backend: - Add OIDCConfig, OIDCConfigService, OIDCService classes for OIDC flow - Add OIDC endpoints: status, config (get/update), login, callback - Support authorization code flow with PKCE-compatible state parameter - JWKS-based ID token validation with RS256 support - Auto-provisioning of users from OIDC claims - Admin group mapping for automatic admin role assignment Frontend: - Add SSO login button on login page (conditionally shown when enabled) - Add OIDC admin configuration page (/admin/oidc) - Add SSO Configuration link in admin menu - Add OIDC types and API functions Security: - CSRF protection via state parameter in secure cookie - Secure cookie settings (httponly, secure, samesite=lax) - Client secret stored encrypted in database - Token validation using provider's JWKS endpoint
This commit is contained in:
@@ -9,6 +9,7 @@ import LoginPage from './pages/LoginPage';
|
||||
import ChangePasswordPage from './pages/ChangePasswordPage';
|
||||
import APIKeysPage from './pages/APIKeysPage';
|
||||
import AdminUsersPage from './pages/AdminUsersPage';
|
||||
import AdminOIDCPage from './pages/AdminOIDCPage';
|
||||
|
||||
// Component that checks if user must change password
|
||||
function RequirePasswordChange({ children }: { children: React.ReactNode }) {
|
||||
@@ -42,6 +43,7 @@ function AppRoutes() {
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/settings/api-keys" element={<APIKeysPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/oidc" element={<AdminOIDCPage />} />
|
||||
<Route path="/project/:projectName" element={<ProjectPage />} />
|
||||
<Route path="/project/:projectName/:packageName" element={<PackagePage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
AccessPermissionCreate,
|
||||
AccessPermissionUpdate,
|
||||
AccessLevel,
|
||||
OIDCConfig,
|
||||
OIDCConfigUpdate,
|
||||
OIDCStatus,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = '/api/v1';
|
||||
@@ -408,3 +411,35 @@ export async function revokeProjectAccess(projectName: string, username: string)
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// OIDC API
|
||||
export async function getOIDCStatus(): Promise<OIDCStatus> {
|
||||
const response = await fetch(`${API_BASE}/auth/oidc/status`);
|
||||
return handleResponse<OIDCStatus>(response);
|
||||
}
|
||||
|
||||
export async function getOIDCConfig(): Promise<OIDCConfig> {
|
||||
const response = await fetch(`${API_BASE}/auth/oidc/config`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<OIDCConfig>(response);
|
||||
}
|
||||
|
||||
export async function updateOIDCConfig(data: OIDCConfigUpdate): Promise<OIDCConfig> {
|
||||
const response = await fetch(`${API_BASE}/auth/oidc/config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
credentials: 'include',
|
||||
});
|
||||
return handleResponse<OIDCConfig>(response);
|
||||
}
|
||||
|
||||
export function getOIDCLoginUrl(returnTo?: string): string {
|
||||
const params = new URLSearchParams();
|
||||
if (returnTo) {
|
||||
params.set('return_to', returnTo);
|
||||
}
|
||||
const query = params.toString();
|
||||
return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
@@ -129,19 +129,31 @@ function Layout({ children }: LayoutProps) {
|
||||
API Keys
|
||||
</NavLink>
|
||||
{user.is_admin && (
|
||||
<NavLink
|
||||
to="/admin/users"
|
||||
className="user-menu-item"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
User Management
|
||||
</NavLink>
|
||||
<>
|
||||
<NavLink
|
||||
to="/admin/users"
|
||||
className="user-menu-item"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
User Management
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/oidc"
|
||||
className="user-menu-item"
|
||||
onClick={() => setShowUserMenu(false)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
SSO Configuration
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
<div className="user-menu-divider"></div>
|
||||
<button className="user-menu-item" onClick={handleLogout}>
|
||||
|
||||
405
frontend/src/pages/AdminOIDCPage.css
Normal file
405
frontend/src/pages/AdminOIDCPage.css
Normal file
@@ -0,0 +1,405 @@
|
||||
.admin-oidc-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-oidc-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.admin-oidc-header-content h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.admin-oidc-subtitle {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.admin-oidc-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: var(--success);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.875rem;
|
||||
animation: admin-oidc-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes admin-oidc-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-oidc-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-oidc-error svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-oidc-error span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-oidc-error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
color: var(--error);
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-oidc-error-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.admin-oidc-access-denied {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-oidc-access-denied-icon {
|
||||
color: var(--error);
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.admin-oidc-access-denied h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-oidc-access-denied p {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9375rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.admin-oidc-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-oidc-section {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.admin-oidc-section:last-of-type {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-oidc-section h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-oidc-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-oidc-form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-oidc-form-group label {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.admin-oidc-form-group input[type="text"],
|
||||
.admin-oidc-form-group input[type="password"],
|
||||
.admin-oidc-form-group input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-oidc-form-group input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-oidc-form-group input:hover:not(:disabled) {
|
||||
border-color: var(--border-secondary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.admin-oidc-form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.admin-oidc-form-group input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-oidc-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-oidc-field-help {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.admin-oidc-field-help code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.admin-oidc-secret-status {
|
||||
color: var(--success);
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-label input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-custom {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: 12px;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-custom::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom {
|
||||
background: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-label input[type="checkbox"]:checked + .admin-oidc-toggle-custom::after {
|
||||
left: 22px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-label input[type="checkbox"]:focus + .admin-oidc-toggle-custom {
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.admin-oidc-toggle-label:hover .admin-oidc-toggle-custom {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.admin-oidc-form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.admin-oidc-cancel-button {
|
||||
padding: 10px 18px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-oidc-cancel-button:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-oidc-cancel-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.admin-oidc-submit-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
background: var(--accent-gradient);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.admin-oidc-submit-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.admin-oidc-submit-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.admin-oidc-button-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: admin-oidc-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes admin-oidc-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-oidc-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 64px 24px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.admin-oidc-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-secondary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: admin-oidc-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.admin-oidc-info-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.admin-oidc-info-card h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-oidc-info-card p {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-oidc-callback-url {
|
||||
display: block;
|
||||
background: var(--bg-tertiary);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-oidc-form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
342
frontend/src/pages/AdminOIDCPage.tsx
Normal file
342
frontend/src/pages/AdminOIDCPage.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { getOIDCConfig, updateOIDCConfig } from '../api';
|
||||
import { OIDCConfig } from '../types';
|
||||
import './AdminOIDCPage.css';
|
||||
|
||||
function AdminOIDCPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [config, setConfig] = useState<OIDCConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [issuerUrl, setIssuerUrl] = useState('');
|
||||
const [clientId, setClientId] = useState('');
|
||||
const [clientSecret, setClientSecret] = useState('');
|
||||
const [scopes, setScopes] = useState('openid profile email');
|
||||
const [autoCreateUsers, setAutoCreateUsers] = useState(true);
|
||||
const [adminGroup, setAdminGroup] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
navigate('/login', { state: { from: '/admin/oidc' } });
|
||||
}
|
||||
}, [user, authLoading, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.is_admin) {
|
||||
loadConfig();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (successMessage) {
|
||||
const timer = setTimeout(() => setSuccessMessage(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [successMessage]);
|
||||
|
||||
async function loadConfig() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getOIDCConfig();
|
||||
setConfig(data);
|
||||
setEnabled(data.enabled);
|
||||
setIssuerUrl(data.issuer_url);
|
||||
setClientId(data.client_id);
|
||||
setScopes(data.scopes.join(' '));
|
||||
setAutoCreateUsers(data.auto_create_users);
|
||||
setAdminGroup(data.admin_group);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load OIDC configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (enabled && !issuerUrl.trim()) {
|
||||
setError('Issuer URL is required when OIDC is enabled');
|
||||
return;
|
||||
}
|
||||
if (enabled && !clientId.trim()) {
|
||||
setError('Client ID is required when OIDC is enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const scopesList = scopes.split(/\s+/).filter(s => s.length > 0);
|
||||
const updateData: Record<string, unknown> = {
|
||||
enabled,
|
||||
issuer_url: issuerUrl.trim(),
|
||||
client_id: clientId.trim(),
|
||||
scopes: scopesList,
|
||||
auto_create_users: autoCreateUsers,
|
||||
admin_group: adminGroup.trim(),
|
||||
};
|
||||
|
||||
if (clientSecret) {
|
||||
updateData.client_secret = clientSecret;
|
||||
}
|
||||
|
||||
await updateOIDCConfig(updateData);
|
||||
setSuccessMessage('OIDC configuration saved successfully');
|
||||
setClientSecret('');
|
||||
await loadConfig();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save OIDC configuration');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="admin-oidc-page">
|
||||
<div className="admin-oidc-loading">
|
||||
<div className="admin-oidc-spinner"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.is_admin) {
|
||||
return (
|
||||
<div className="admin-oidc-page">
|
||||
<div className="admin-oidc-access-denied">
|
||||
<div className="admin-oidc-access-denied-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Access Denied</h2>
|
||||
<p>You do not have permission to access this page. Admin privileges are required.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-oidc-page">
|
||||
<div className="admin-oidc-header">
|
||||
<div className="admin-oidc-header-content">
|
||||
<h1>Single Sign-On (OIDC)</h1>
|
||||
<p className="admin-oidc-subtitle">
|
||||
Configure OpenID Connect for SSO authentication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{successMessage && (
|
||||
<div className="admin-oidc-success">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||
</svg>
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="admin-oidc-error">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="admin-oidc-error-dismiss">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="admin-oidc-card">
|
||||
<div className="admin-oidc-loading">
|
||||
<div className="admin-oidc-spinner"></div>
|
||||
<span>Loading configuration...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSave} className="admin-oidc-card">
|
||||
<div className="admin-oidc-section">
|
||||
<h2>Status</h2>
|
||||
<div className="admin-oidc-toggle-group">
|
||||
<label className="admin-oidc-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<span className="admin-oidc-toggle-custom"></span>
|
||||
Enable OIDC Authentication
|
||||
</label>
|
||||
<p className="admin-oidc-field-help">
|
||||
When enabled, users can sign in using your organization's identity provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-oidc-section">
|
||||
<h2>Provider Configuration</h2>
|
||||
|
||||
<div className="admin-oidc-form-group">
|
||||
<label htmlFor="issuer-url">Issuer URL</label>
|
||||
<input
|
||||
id="issuer-url"
|
||||
type="url"
|
||||
value={issuerUrl}
|
||||
onChange={(e) => setIssuerUrl(e.target.value)}
|
||||
placeholder="https://your-provider.com"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className="admin-oidc-field-help">
|
||||
The base URL of your OIDC provider. Discovery document will be fetched from <code>/.well-known/openid-configuration</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-oidc-form-row">
|
||||
<div className="admin-oidc-form-group">
|
||||
<label htmlFor="client-id">Client ID</label>
|
||||
<input
|
||||
id="client-id"
|
||||
type="text"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-oidc-form-group">
|
||||
<label htmlFor="client-secret">
|
||||
Client Secret
|
||||
{config?.has_client_secret && (
|
||||
<span className="admin-oidc-secret-status"> (configured)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id="client-secret"
|
||||
type="password"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
placeholder={config?.has_client_secret ? 'Leave blank to keep current' : 'Enter client secret'}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-oidc-form-group">
|
||||
<label htmlFor="scopes">Scopes</label>
|
||||
<input
|
||||
id="scopes"
|
||||
type="text"
|
||||
value={scopes}
|
||||
onChange={(e) => setScopes(e.target.value)}
|
||||
placeholder="openid profile email"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className="admin-oidc-field-help">
|
||||
Space-separated list of OIDC scopes to request. Common scopes: openid, profile, email, groups.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-oidc-section">
|
||||
<h2>User Provisioning</h2>
|
||||
|
||||
<div className="admin-oidc-toggle-group">
|
||||
<label className="admin-oidc-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoCreateUsers}
|
||||
onChange={(e) => setAutoCreateUsers(e.target.checked)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<span className="admin-oidc-toggle-custom"></span>
|
||||
Auto-create users on first login
|
||||
</label>
|
||||
<p className="admin-oidc-field-help">
|
||||
When enabled, new users will be created automatically when they sign in via OIDC for the first time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-oidc-form-group">
|
||||
<label htmlFor="admin-group">Admin Group (optional)</label>
|
||||
<input
|
||||
id="admin-group"
|
||||
type="text"
|
||||
value={adminGroup}
|
||||
onChange={(e) => setAdminGroup(e.target.value)}
|
||||
placeholder="admin, orchard-admins"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<p className="admin-oidc-field-help">
|
||||
Users in this group (from the groups claim) will be granted admin privileges. Leave blank to disable automatic admin assignment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-oidc-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-oidc-cancel-button"
|
||||
onClick={loadConfig}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="admin-oidc-submit-button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<span className="admin-oidc-button-spinner"></span>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Configuration'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="admin-oidc-info-card">
|
||||
<h3>Callback URL</h3>
|
||||
<p>Configure your identity provider with the following callback URL:</p>
|
||||
<code className="admin-oidc-callback-url">
|
||||
{window.location.origin}/api/v1/auth/oidc/callback
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminOIDCPage;
|
||||
@@ -214,6 +214,62 @@
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* SSO Divider */
|
||||
.login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.login-divider::before,
|
||||
.login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-primary);
|
||||
}
|
||||
|
||||
.login-divider span {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
/* SSO Button */
|
||||
.login-sso-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.login-sso-button:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-secondary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.login-sso-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-sso-button svg {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { getOIDCStatus, getOIDCLoginUrl } from '../api';
|
||||
import { OIDCStatus } from '../types';
|
||||
import './LoginPage.css';
|
||||
|
||||
function LoginPage() {
|
||||
@@ -8,14 +10,37 @@ function LoginPage() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [oidcStatus, setOidcStatus] = useState<OIDCStatus | null>(null);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { user, login, loading: authLoading } = useAuth();
|
||||
const { user, login, loading: authLoading, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Get the return URL from location state, default to home
|
||||
const from = (location.state as { from?: string })?.from || '/';
|
||||
|
||||
// Load OIDC status on mount
|
||||
useEffect(() => {
|
||||
getOIDCStatus()
|
||||
.then(setOidcStatus)
|
||||
.catch(() => setOidcStatus({ enabled: false }));
|
||||
}, []);
|
||||
|
||||
// Handle SSO callback - check for oidc_success or oidc_error params
|
||||
useEffect(() => {
|
||||
const oidcSuccess = searchParams.get('oidc_success');
|
||||
const oidcError = searchParams.get('oidc_error');
|
||||
|
||||
if (oidcSuccess === 'true') {
|
||||
refreshUser().then(() => {
|
||||
navigate(from, { replace: true });
|
||||
});
|
||||
} else if (oidcError) {
|
||||
setError(decodeURIComponent(oidcError));
|
||||
}
|
||||
}, [searchParams, refreshUser, navigate, from]);
|
||||
|
||||
// Redirect if already logged in
|
||||
useEffect(() => {
|
||||
if (user && !authLoading) {
|
||||
@@ -129,6 +154,25 @@ function LoginPage() {
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{oidcStatus?.enabled && (
|
||||
<>
|
||||
<div className="login-divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
<a
|
||||
href={getOIDCLoginUrl(from !== '/' ? from : undefined)}
|
||||
className="login-sso-button"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
||||
<polyline points="10 17 15 12 10 7"/>
|
||||
<line x1="15" y1="12" x2="3" y2="12"/>
|
||||
</svg>
|
||||
Sign in with SSO
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="login-footer">
|
||||
|
||||
@@ -329,3 +329,29 @@ export interface CurrentUser extends User {
|
||||
[projectId: string]: AccessLevel;
|
||||
};
|
||||
}
|
||||
|
||||
// OIDC types
|
||||
export interface OIDCConfig {
|
||||
enabled: boolean;
|
||||
issuer_url: string;
|
||||
client_id: string;
|
||||
has_client_secret: boolean;
|
||||
scopes: string[];
|
||||
auto_create_users: boolean;
|
||||
admin_group: string;
|
||||
}
|
||||
|
||||
export interface OIDCConfigUpdate {
|
||||
enabled?: boolean;
|
||||
issuer_url?: string;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
scopes?: string[];
|
||||
auto_create_users?: boolean;
|
||||
admin_group?: string;
|
||||
}
|
||||
|
||||
export interface OIDCStatus {
|
||||
enabled: boolean;
|
||||
issuer_url?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user