From a7725ba51938eae7c1476a47982076e463ed5483 Mon Sep 17 00:00:00 2001 From: kitos Date: Mon, 8 Jun 2026 13:48:36 +0200 Subject: [PATCH] feat(sso): Azure AD / Entra ID SAML 2.0 integration - sso_service: fix process_callback for Azure AD claim URIs (email, role) - Default role_attr to full Azure role claim URI - Fallback email resolution via Azure email claim URI + NameID - Username defaults to full email (prevents collision with local accounts) - User lookup also tries email field for existing local accounts - Logs warning when unknown role received from IdP - frontend/api/sso.ts: new API module with getSsoStatus, getSsoConfig, updateSsoConfig - LoginPage: redesigned for SSO-first flow - Shows Azure SSO button as primary when SSO enabled+configured - Local login collapsed under "Emergency admin access" section - Falls back to normal local login form when SSO is disabled - SystemPage: new SsoConfigSection component (guided 5-step wizard) - Step 1: Copy SP Entity ID and ACS URL for IT team + metadata XML download - Step 2: Azure App Roles reference table (6 roles with exact values) - Step 3: Tenant ID field auto-fills idp_entity_id and idp_sso_url - Step 4: X.509 certificate paste field - Step 5: Attribute mapping pre-filled with Azure AD claim URIs - Enable/disable toggle + save --- backend/app/services/sso_service.py | 38 ++- frontend/src/api/sso.ts | 66 ++++ frontend/src/pages/LoginPage.tsx | 202 ++++++++---- frontend/src/pages/SystemPage.tsx | 458 ++++++++++++++++++++++++++++ 4 files changed, 701 insertions(+), 63 deletions(-) create mode 100644 frontend/src/api/sso.ts diff --git a/backend/app/services/sso_service.py b/backend/app/services/sso_service.py index 9279f10..98d2433 100644 --- a/backend/app/services/sso_service.py +++ b/backend/app/services/sso_service.py @@ -176,21 +176,43 @@ def process_callback(db: Session, request_data: dict) -> User: # Extract attributes attrs = auth.get_attributes() name_id = auth.get_nameid() - email_attr = cfg.attr_email or "email" - username_attr = cfg.attr_username or "username" - role_attr = cfg.attr_role or "role" - email = _first_attr(attrs, email_attr) or name_id or "" - username = _first_attr(attrs, username_attr) or email.split("@")[0] or name_id - role = _first_attr(attrs, role_attr) or cfg.default_role or "viewer" + # Attribute claim URIs — defaults support both plain names and Azure AD full URIs + email_attr = cfg.attr_email or "email" + username_attr = cfg.attr_username or "email" # Azure AD: use email as username + role_attr = cfg.attr_role or "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" + + # Resolve email: try configured attr → Azure email claim URI → NameID + email = ( + _first_attr(attrs, email_attr) + or _first_attr(attrs, "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") + or name_id + or "" + ) + + # Resolve username: keep full email (e.g. user@company.com) to avoid collisions with local accounts + raw_username = _first_attr(attrs, username_attr) or email or name_id or "" + username = raw_username.strip() or email.split("@")[0] or name_id + + # Resolve role: try configured attr → Azure role claim URI → default + role = ( + _first_attr(attrs, role_attr) + or _first_attr(attrs, "http://schemas.microsoft.com/ws/2008/06/identity/claims/role") + or cfg.default_role + or "viewer" + ) # Validate role valid_roles = {"admin", "red_lead", "blue_lead", "red_tech", "blue_tech", "viewer"} if role not in valid_roles: + log.warning("SSO: unknown role '%s' for user '%s', falling back to default", role, username) role = cfg.default_role or "viewer" - # Look up or provision user - user = db.query(User).filter(User.username == username).first() + # Look up or provision user — try username first, then email (for existing local accounts) + user = ( + db.query(User).filter(User.username == username).first() + or db.query(User).filter(User.email == email).first() + ) if user: # Refresh role from IdP on every login user.role = role diff --git a/frontend/src/api/sso.ts b/frontend/src/api/sso.ts new file mode 100644 index 0000000..ceacb44 --- /dev/null +++ b/frontend/src/api/sso.ts @@ -0,0 +1,66 @@ +import client from "./client"; + +export interface SsoStatus { + enabled: boolean; + provider_name: string | null; + configured: boolean; + login_url: string | null; +} + +export interface SsoConfig { + id: string; + is_enabled: boolean; + provider_name: string | null; + sp_entity_id: string | null; + sp_acs_url: string | null; + sp_slo_url: string | null; + sp_certificate: string | null; + idp_entity_id: string | null; + idp_sso_url: string | null; + idp_slo_url: string | null; + idp_certificate: string | null; + attr_email: string | null; + attr_username: string | null; + attr_role: string | null; + default_role: string | null; + auto_provision: boolean; + created_at: string | null; + updated_at: string | null; +} + +export interface SsoConfigUpdate { + is_enabled: boolean; + provider_name?: string | null; + sp_entity_id?: string | null; + sp_acs_url?: string | null; + sp_slo_url?: string | null; + sp_certificate?: string | null; + sp_private_key?: string | null; + idp_entity_id?: string | null; + idp_sso_url?: string | null; + idp_slo_url?: string | null; + idp_certificate?: string | null; + attr_email?: string | null; + attr_username?: string | null; + attr_role?: string | null; + default_role?: string | null; + auto_provision?: boolean; +} + +/** Public — used by LoginPage to decide which login to show. */ +export async function getSsoStatus(): Promise { + const { data } = await client.get("/sso/status"); + return data; +} + +/** Admin-only — full config. Returns 404 if never configured. */ +export async function getSsoConfig(): Promise { + const { data } = await client.get("/sso/config"); + return data; +} + +/** Admin-only — create or replace SSO config. */ +export async function updateSsoConfig(payload: SsoConfigUpdate): Promise { + const { data } = await client.put("/sso/config", payload); + return data; +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 4ad6d02..6b2515d 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,6 +1,9 @@ import { useState, type FormEvent } from "react"; import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronDown, ChevronUp, ShieldAlert, Building2, Loader2 } from "lucide-react"; import { useAuth } from "../context/AuthContext"; +import { getSsoStatus } from "../api/sso"; export default function LoginPage() { const { login, isAuthenticated } = useAuth(); @@ -10,6 +13,17 @@ export default function LoginPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [emergencyOpen, setEmergencyOpen] = useState(false); + + // Fetch SSO status to determine which login method to show + const { data: ssoStatus, isLoading: ssoLoading } = useQuery({ + queryKey: ["sso-status"], + queryFn: getSsoStatus, + retry: false, + staleTime: 30_000, + }); + + const ssoActive = ssoStatus?.enabled && ssoStatus?.configured; // If already logged in, redirect immediately if (isAuthenticated) { @@ -21,7 +35,6 @@ export default function LoginPage() { e.preventDefault(); setError(null); setLoading(true); - try { await login(username, password); navigate("/dashboard", { replace: true }); @@ -38,67 +51,146 @@ export default function LoginPage() { {/* Logo */}
Aegis -

- Aegis -

-

- MITRE ATT&CK Coverage Platform -

+

Aegis

+

MITRE ATT&CK Coverage Platform

- {/* Form */} -
-
- - setUsername(e.target.value)} - className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500" - placeholder="admin" - /> + {ssoLoading ? ( + /* Loading SSO status */ +
+
- - + + Sign in with {ssoStatus?.provider_name || "Azure AD"} + - {error && ( -

- {error} +

+ Use your corporate credentials — managed by your IT team

- )} - - + {/* Emergency local login (collapsible) */} +
+ + + {emergencyOpen && ( +
+

+ Local login — only for emergency admin access when SSO is unavailable. +

+
+ setUsername(e.target.value)} + placeholder="Username" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500" + /> + setPassword(e.target.value)} + placeholder="Password" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500" + /> + {error && ( +

+ {error} +

+ )} + +
+
+ )} +
+
+ ) : ( + /* ── Local login (SSO not configured) ───────────────────── */ +
+
+ + setUsername(e.target.value)} + className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500" + placeholder="admin" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500" + placeholder="••••••••" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ )} ); diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx index 3e3df75..353043d 100644 --- a/frontend/src/pages/SystemPage.tsx +++ b/frontend/src/pages/SystemPage.tsx @@ -27,8 +27,17 @@ import { ExternalLink, CalendarCheck, ShieldCheck, + KeyRound, + Copy, + Building2, } from "lucide-react"; import client from "../api/client"; +import { + getSsoConfig, + updateSsoConfig, + type SsoConfig, + type SsoConfigUpdate, +} from "../api/sso"; import { triggerMitreSync, triggerIntelScan, @@ -1075,6 +1084,9 @@ export default function SystemPage() { )} + {/* SSO / Azure AD Configuration */} + + {/* System Information */}

System Information

@@ -1768,6 +1780,452 @@ function CreateTemplateForm({ ); } +/* ── SSO / Azure AD Configuration Section ───────────────────────── */ + +const AZURE_ROLES = [ + { value: "admin", label: "Aegis Admin", desc: "Full platform access including system settings" }, + { value: "red_lead", label: "Aegis Red Lead", desc: "Red team lead — manage tests, campaigns and templates" }, + { value: "blue_lead", label: "Aegis Blue Lead", desc: "Blue team lead — validate tests and manage coverage" }, + { value: "red_tech", label: "Aegis Red Tech", desc: "Red team technician — execute tests" }, + { value: "blue_tech", label: "Aegis Blue Tech", desc: "Blue team technician — review detections" }, + { value: "viewer", label: "Aegis Viewer", desc: "Read-only access to dashboards and reports" }, +]; + +function CopyField({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + const copy = () => { + navigator.clipboard.writeText(value).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + return ( +
+ +
+ + {value || not set} + + +
+
+ ); +} + +function SsoConfigSection() { + const queryClient = useQueryClient(); + + // Derive default SP values from the current browser origin + const origin = window.location.origin; + const defaultSpEntityId = `${origin}/api/v1/sso/metadata`; + const defaultSpAcsUrl = `${origin}/api/v1/sso/callback`; + + const { data: existingConfig, isLoading: configLoading } = useQuery({ + queryKey: ["sso-config"], + queryFn: async () => { + try { + return await getSsoConfig(); + } catch { + return null; // 404 = not configured yet + } + }, + }); + + // Form state — pre-seeded from existing config when loaded + const [tenantId, setTenantId] = useState(""); + const [form, setForm] = useState({ + is_enabled: false, + provider_name: "Azure AD / Entra ID", + sp_entity_id: defaultSpEntityId, + sp_acs_url: defaultSpAcsUrl, + idp_entity_id: "", + idp_sso_url: "", + idp_certificate: "", + attr_email: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + attr_username: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + attr_role: "http://schemas.microsoft.com/ws/2008/06/identity/claims/role", + default_role: "viewer", + auto_provision: true, + }); + const [formLoaded, setFormLoaded] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + + // Load existing config into form (once) + if (existingConfig && !formLoaded) { + setForm({ + is_enabled: existingConfig.is_enabled, + provider_name: existingConfig.provider_name ?? "Azure AD / Entra ID", + sp_entity_id: existingConfig.sp_entity_id ?? defaultSpEntityId, + sp_acs_url: existingConfig.sp_acs_url ?? defaultSpAcsUrl, + idp_entity_id: existingConfig.idp_entity_id ?? "", + idp_sso_url: existingConfig.idp_sso_url ?? "", + idp_certificate: existingConfig.idp_certificate ?? "", + attr_email: existingConfig.attr_email ?? "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + attr_username: existingConfig.attr_username ?? "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + attr_role: existingConfig.attr_role ?? "http://schemas.microsoft.com/ws/2008/06/identity/claims/role", + default_role: existingConfig.default_role ?? "viewer", + auto_provision: existingConfig.auto_provision ?? true, + }); + // Try to extract tenant ID from existing idp_sso_url + if (existingConfig.idp_sso_url) { + const m = existingConfig.idp_sso_url.match(/microsoftonline\.com\/([^/]+)\//); + if (m) setTenantId(m[1]); + } + setFormLoaded(true); + } + + // Auto-fill IdP URLs when tenant ID is entered + const handleTenantChange = (val: string) => { + setTenantId(val); + if (val.trim()) { + setForm((f) => ({ + ...f, + idp_entity_id: `https://sts.windows.net/${val.trim()}/`, + idp_sso_url: `https://login.microsoftonline.com/${val.trim()}/saml2`, + })); + } + }; + + const saveMutation = useMutation({ + mutationFn: (payload: SsoConfigUpdate) => updateSsoConfig(payload), + onSuccess: () => { + setSaveSuccess(true); + setTimeout(() => setSaveSuccess(false), 4000); + queryClient.invalidateQueries({ queryKey: ["sso-config"] }); + queryClient.invalidateQueries({ queryKey: ["sso-status"] }); + }, + }); + + const handleSave = (e: React.FormEvent) => { + e.preventDefault(); + saveMutation.mutate(form); + }; + + const isConfigured = !!(form.idp_entity_id && form.idp_sso_url && form.idp_certificate); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Azure AD / Entra ID SSO

+

+ Delegate authentication to Azure Active Directory via SAML 2.0. + Users sign in with their corporate credentials; roles are assigned automatically via Azure App Roles. +

+
+
+ {/* Enabled badge */} + + {form.is_enabled && isConfigured ? ( + <> Active + ) : ( + <> {isConfigured ? "Disabled" : "Not configured"} + )} + +
+ + {configLoading ? ( +
+ +
+ ) : ( +
+ + {/* ── Step 1: SP values for IT ─────────────────────────── */} +
+
+ 1 +

Give these values to your IT team

+
+

+ Your IT team needs to register Aegis as an Enterprise Application in Azure AD. + Provide them with the following SP (Service Provider) values: +

+
+ + +
+ + + Download SP Metadata XML + + + (Alternative to manual entry — import this XML directly into Azure AD) + +
+
+
+ + {/* ── Step 2: Azure App Roles to create ───────────────── */} +
+
+ 2 +

Create App Roles in Azure

+
+

+ In your Azure Enterprise App → App Roles, create the following roles. The{" "} + Value field must match exactly (case-sensitive): +

+
+ + + + + + + + + + {AZURE_ROLES.map((r) => ( + + + + + + ))} + +
Display NameValue (exact)Description
{r.label} + + {r.value} + + {r.desc}
+
+

+ After creating roles, assign them to users or groups in Azure AD → Enterprise Applications → Users and groups. +

+
+ + {/* ── Step 3: IdP configuration ───────────────────────── */} +
+
+ 3 +

Enter Azure tenant details

+
+
+ {/* Tenant ID shortcut */} +
+ + handleTenantChange(e.target.value)} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none font-mono" + /> +

+ Found in Azure AD → Overview → Tenant ID +

+
+ + {/* IdP Entity ID */} +
+ + setForm({ ...form, idp_entity_id: e.target.value })} + placeholder="https://sts.windows.net/{tenant-id}/" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none font-mono" + /> +
+ + {/* IdP SSO URL */} +
+ + setForm({ ...form, idp_sso_url: e.target.value })} + placeholder="https://login.microsoftonline.com/{tenant-id}/saml2" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none font-mono" + /> +
+ + {/* Provider display name */} +
+ + setForm({ ...form, provider_name: e.target.value })} + placeholder="Azure AD / Entra ID" + className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-indigo-500 focus:outline-none" + /> +
+
+
+ + {/* ── Step 4: X.509 Certificate ───────────────────────── */} +
+
+ 4 +

Paste IdP certificate

+
+

+ In Azure AD → Enterprise App → Single sign-on → SAML Signing Certificate, download the{" "} + Base64 certificate and paste its contents below. +

+