feat(sso): Azure AD / Entra ID SAML 2.0 integration
Aegis CI / lint-and-test (push) Has been cancelled

- 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-06-08 13:48:36 +02:00
parent 5f8a196df3
commit a43d73cac8
4 changed files with 701 additions and 63 deletions
+30 -8
View File
@@ -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