Phase 2-4 of multi-provider authentication. All three providers slot
into the AuthOrchestrator from phase 1; no further changes to
/auth/login are needed.
LDAPS (app/auth/strategies/ldap_strategy.py):
- ldap3 with strict TLS cert validation (CERT_REQUIRED + CA bundle)
- Service-account search-then-bind flow (DN never exposed to caller)
- Filter chars escaped via ldap3.utils.conv.escape_filter_chars
- AD-flavored defaults: sAMAccountName / memberOf / objectGUID
- Refuses if filter returns >1 entry (anti-impersonation safety)
- Bind password encrypted at rest (Fernet), bootstrapped from env on
first start then stored in settings table
OIDC (app/auth/strategies/oidc_strategy.py + app/routers/auth_oidc.py):
- Authorization Code + PKCE (S256)
- Strict ID-Token validation via Authlib: signature (JWKS w/ auto-refresh
on rotation), iss (essential), aud (essential, must == client_id),
exp (essential), nonce (replay protection)
- State/PKCE-verifier/nonce stored in signed itsdangerous cookie (no
server-side session store needed)
- Discovery + JWKS cached in-process; JWKS auto-refetched on key miss
- Groups merged from both id_token claims and userinfo endpoint
- Hardened: prompt=select_account to defeat silent IdP reuse
SAML 2.0 (app/auth/strategies/saml_strategy.py + app/routers/auth_saml.py):
- python3-saml (OneLogin) with strict=true; xmlsec1 handles signature
validation. XSW attacks mitigated via strict assertion/response
signature position checks plus wantAssertionsSigned=true
- SP-initiated (/auth/saml/login) + IdP-initiated (POST /auth/saml/acs)
- /auth/saml/metadata serves signed SP descriptor
- RelayState same-origin check to prevent open redirect
- IdP metadata loaded from URL or file at startup
Wiring:
- app/main.py imports auth_oidc/auth_saml routers behind try/except so
the app still starts when authlib or python3-saml aren't installed
- Frontend login page fetches /auth/providers and renders matching
redirect buttons (Sign in with Entra ID / SAML SSO) plus the local
credentials form. Adds MFA second-step screen with 6-digit OTP input
when /auth/login returns mfa_required=true.
.env.example: full provider config blocks with worked examples for
Entra ID, Okta, Keycloak, Google. Each block is commented with the
exact format the corresponding admin needs.
The bootstrap routine in app/db_init.py validates DEFAULT_ADMIN_PASSWORD
against the password-strength policy (upper, lower, digit, special) and
silently refuses to create the admin if it fails. The shipped default
'changeme' violates the policy, so a fresh install ended up with no
admin user and login was impossible.
- Set .env.example default to 'ChangeMe123!' (passes strength check)
- README: spell out the strength requirements, add a log-grep to verify
the admin was created, and list DEFAULT_ADMIN_PASSWORD as a required
step in the install instructions.