Security

SAML, OIDC, and MFA

Vigilo supports three authentication paths into a workspace: SAML 2.0 (SP-initiated), OIDC via Keycloak (and any other compliant IdP), and a local…

Last updated

Overview

Vigilo supports three authentication paths into a workspace: SAML 2.0 (SP-initiated), OIDC via Keycloak (and any other compliant IdP), and a local password+MFA path for break-glass admin access. Each workspace can pin a preferred path; SCIM (covered separately) provisions users into the workspace and lets the IdP own membership.

This article covers the SAML MVP (WC.1), the OIDC integration via mozilla-django-oidc, and the TOTP-based MFA layer (F1) that adds a second factor on top of either federation path or local login.

SAML 2.0

MVP scope (WC.1)

The SAML implementation uses python3-saml, which depends on the xmlsec1 system library. Make sure libxmlsec1-dev (Debian/Ubuntu) or libxmlsec1 (Alpine) is installed on every Django host; missing it produces an opaque ImportError at first request.

The flow is SP-initiated:

  1. The user clicks Sign in with SAML on the workspace login page.
  2. Django redirects to the IdP's SSO URL with a signed AuthnRequest.
  3. The IdP authenticates the user (and runs its own MFA if configured).
  4. The IdP POSTs a signed Response to the ACS endpoint at /auth/saml/acs/.
  5. Django validates the signature against the IdP's certificate, extracts the NameID and attribute statements, finds or creates a UserProfile, attaches a WorkspaceMembership if SCIM has not already done so, and creates a Django session.

Settings configuration

SAML is configured per workspace under Workspace.settings['saml']:

{
  "enabled": true,
  "idp_entity_id": "https://idp.acme.com/sso",
  "idp_sso_url": "https://idp.acme.com/sso/login",
  "idp_x509_cert": "<base64-DER>",
  "sp_entity_id": "vigilo:acme-prod",
  "attribute_map": {
    "email": "EmailAddress",
    "display_name": "DisplayName",
    "groups": "Groups"
  }
}

The ACS URL the IdP posts to is https://{vigilo-host}/auth/saml/acs/?ws={slug}. The trailing ws query parameter scopes the response to the correct workspace.

Each workspace can run its own SAML connection — useful in multi-tenant deployments where each customer brings their own IdP.

Attribute-to-role mapping

If the IdP sends a Groups attribute, Vigilo maps it to workspace roles via OIDCGroupMapping rows (shared between SAML and OIDC). See SCIM provisioning for the mapping detail.

OIDC

Mozilla-django-oidc

OIDC uses mozilla-django-oidc configured per workspace from Workspace.settings['oidc'] or the global env vars KEYCLOAK_*:

KEYCLOAK_SERVER_URL=https://keycloak.acme.com
KEYCLOAK_REALM=vigilo
KEYCLOAK_CLIENT_ID=vigilo-django
KEYCLOAK_CLIENT_SECRET=...

Per-workspace overrides live in Workspace.settings['oidc'] with the same keys. A workspace with overrides uses its own IdP; one without falls back to the global Keycloak.

Flow

  1. User clicks Sign in with SSO.
  2. Django redirects to the OIDC /authorize endpoint with PKCE.
  3. The IdP authenticates and redirects back to /oidc/callback/.
  4. Django exchanges the code for an access + ID token, validates the JWT signature against the IdP's JWKS, extracts claims (email, name, groups), finds or creates the UserProfile.
  5. A Django session is created; the React app receives a session cookie.

Refresh

The access token is short-lived (5-15 min typically); the OIDC middleware refreshes it silently on every request that needs it. If the IdP revokes the user, the next refresh fails and the Vigilo session is invalidated.

Choosing between SAML and OIDC

The choice is per-workspace and depends on the IdP:

  • Okta, Azure AD, Google Workspace, Auth0, OneLogin — all support both. OIDC is the easier integration; SAML is required if the customer's security team mandates "no JS-based redirects" or if you need IdP-initiated flows.
  • Active Directory Federation Services (ADFS) — SAML is the smoother path.
  • Keycloak — OIDC, always. Keycloak ships an OIDC-first design.
  • Internal homegrown IdP — usually SAML.

Set the path in Settings → Authentication; only one of SAML and OIDC can be "the primary" per workspace, but both can be enabled if you want to offer two sign-in buttons.

MFA (F1)

TOTP-based MFA layered on top of either federation path or local login. The library is pyotp; QR code generation is qrcode + pillow.

Endpoints

  • POST /api/v1/auth/setup-totp/ — generates a fresh shared secret, returns {secret, otpauth_uri, qr_png_base64}. The secret is stored encrypted (CMEK-wrapped, see encryption article) in UserProfile.totp_secret.
  • POST /api/v1/auth/enable-totp/ — body {code: "123456"}. Validates the code against the secret, flips mfa_enabled=True on the profile, generates 10 backup codes (hashed, returned once), and writes them to UserProfile.backup_codes.
  • POST /api/v1/auth/confirm-totp/ — verifies a code mid-session for actions that require recent MFA (see the IP allowlist + sessions article for MFARequiredForAction).

Backup codes are single-use; each is hashed with bcrypt at write time, compared against on verify, and marked consumed on success. When the user has burned through 8 of 10, the UI warns them to regenerate.

Admin override

When a user loses their device and all backup codes, a workspace owner can run Settings → Members → {user} → Reset MFA. The action clears totp_secret, mfa_enabled, and backup_codes, and forces the user to re-enroll on next login. Every reset writes an audit log entry with the resetting owner's identity — auditors love this and you should never delete those records.

Enforcement

A workspace can require MFA for all logins (set Workspace.settings['mfa_required'] = True). The login flow then refuses to issue a session for any user without mfa_enabled. New users are forced through TOTP setup on first login.

Individual sensitive actions (delete workspace, rotate signing key, export user data) can additionally require recent MFA via mfa_required_for_actions (see the IP allowlist + sessions article).

Common workflows

1. Enable SAML for a new workspace

  1. In your IdP, register Vigilo as an SP. ACS URL: https://{host}/auth/saml/acs/?ws={slug}. Entity ID: vigilo:{slug}.
  2. Export the IdP signing cert as base64-DER.
  3. In Vigilo: Settings → Authentication → SAML → Configure. Paste the IdP entity ID, SSO URL, and cert.
  4. Save. Test the round-trip with Send test AuthnRequest.

2. Enroll TOTP

  1. Click your avatar → Security.
  2. Click Set up authenticator app. Scan the QR with Google Authenticator, 1Password, Authy, etc.
  3. Enter the current 6-digit code to confirm.
  4. Save the 10 backup codes somewhere safe (1Password vault, printed in a safe). They are shown once.

3. Lost device

  1. Try a backup code at the MFA prompt.
  2. If all are consumed, contact a workspace owner.
  3. The owner runs Reset MFA for your account.
  4. You re-enroll on next login.

Permissions

Action Role
Configure SAML/OIDC for workspace Owner
Enable workspace-wide MFA requirement Owner
Reset another user's MFA Owner
Set up own MFA Any member

Troubleshooting

SAML ACS returns "invalid signature". The IdP cert in settings is stale (rotated by IdP team). Re-export and paste again.

OIDC login loops back to login. The IdP isn't returning the email claim, or it returns it under a different name. Adjust attribute_map or the equivalent OIDC claim mapping.

TOTP codes always fail. Clock skew between the user's device and the Vigilo server. pyotp allows ±1 step by default (~30s); a device that's 2+ minutes off needs NTP synced.

xmlsec1 ImportError on a fresh deploy. Install the system library: apt install libxmlsec1-dev libxmlsec1-openssl then pip install --force-reinstall python3-saml.

Related articles