Files
vulncheck/app/auth/strategies/oidc_strategy.py
T
vulncheck b8e8870b29 feat(auth): LDAPS + OIDC + SAML 2.0 strategies and SSO routers
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.
2026-05-12 19:11:32 +02:00

117 lines
4.5 KiB
Python

"""
OAuth 2.0 / OpenID Connect strategy.
Uses Authlib (authlib.integrations.starlette_client) for the heavy lifting:
- Discovery (.well-known/openid-configuration)
- PKCE (S256) on the authorization request
- ID-Token validation (signature via JWKS, iss, aud, exp, nbf, nonce)
- Userinfo fetch for claims not present in ID-Token
This strategy does NOT do credential auth — it's a callback-only strategy.
The router (app/routers/auth_oidc.py) drives the flow and hands the
verified `id_token`/`userinfo` payload to `parse_userinfo()` here.
"""
from __future__ import annotations
import logging
import os
from typing import List
from app.auth.strategies.base import AuthError, AuthStrategy, ExternalIdentity
from app.models.user import AuthProvider
logger = logging.getLogger(__name__)
def _split_groups(value) -> List[str]:
"""Groups claim can be list-of-strings (Entra), list-of-objs (some Keycloaks), or comma-string."""
if value is None:
return []
if isinstance(value, list):
out = []
for v in value:
if isinstance(v, str):
out.append(v)
elif isinstance(v, dict):
name = v.get("name") or v.get("displayName") or v.get("id")
if name:
out.append(str(name))
return out
if isinstance(value, str):
return [g.strip() for g in value.split(",") if g.strip()]
return []
class OidcStrategy(AuthStrategy):
"""
OIDC. Configuration via env vars (OIDC_*). Provider is enabled only
when discovery URL and client credentials are set.
"""
name = AuthProvider.OIDC
def __init__(self):
self.discovery_url = os.getenv("OIDC_DISCOVERY_URL", "").strip()
self.client_id = os.getenv("OIDC_CLIENT_ID", "").strip()
self.client_secret = os.getenv("OIDC_CLIENT_SECRET", "").strip()
self.redirect_uri = os.getenv("OIDC_REDIRECT_URI", "").strip()
self.scopes = os.getenv("OIDC_SCOPES", "openid profile email").strip()
self.provider_name = os.getenv("OIDC_PROVIDER_NAME", "OIDC").strip()
# Claim names
self.claim_username = os.getenv("OIDC_CLAIM_USERNAME", "preferred_username")
self.claim_email = os.getenv("OIDC_CLAIM_EMAIL", "email")
self.claim_groups = os.getenv("OIDC_CLAIM_GROUPS", "groups")
self.claim_display = os.getenv("OIDC_CLAIM_DISPLAY", "name")
self.claim_subject = os.getenv("OIDC_CLAIM_SUBJECT", "sub")
@property
def is_enabled(self) -> bool:
return bool(self.discovery_url and self.client_id and self.client_secret and self.redirect_uri)
def authenticate_credentials(self, username: str, password: str) -> ExternalIdentity:
raise NotImplementedError("OIDC is redirect-based; use the /auth/oidc/* router.")
# Called by the router after Authlib has validated the ID token.
def parse_userinfo(self, userinfo: dict, id_token_claims: dict) -> ExternalIdentity:
# Prefer ID-Token claims for security-relevant fields (signed, validated).
sub = id_token_claims.get(self.claim_subject) or userinfo.get("sub")
if not sub:
raise AuthError(
detail="OIDC response missing 'sub' claim",
safe_message="Identity provider error",
)
email = id_token_claims.get(self.claim_email) or userinfo.get(self.claim_email)
username = (
id_token_claims.get(self.claim_username)
or userinfo.get(self.claim_username)
or (email.split("@")[0] if email else None)
or sub
)
display = id_token_claims.get(self.claim_display) or userinfo.get(self.claim_display) or username
# Groups can come from either ID-Token or userinfo. Merge.
groups = list(
set(
_split_groups(id_token_claims.get(self.claim_groups))
+ _split_groups(userinfo.get(self.claim_groups))
)
)
if not email:
# Some IdPs require explicit 'email' scope to populate it. We can still
# provision, but role-mapping by group remains the only path.
logger.warning("OIDC userinfo has no email claim for sub=%s", sub[:8])
email = f"{sub}@oidc.local"
return ExternalIdentity(
provider=AuthProvider.OIDC,
external_id=str(sub),
username=str(username),
email=str(email),
display_name=str(display) if display else None,
groups=groups,
extra={"iss": id_token_claims.get("iss")},
)