Files
FabledSteward/steward/auth/oidc.py
T
bvandeusenandClaude Opus 5.5 f944cdb9b0
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 44s
CI / unit (push) Failing after 1m5s
CI / publish (push) Skipped
feat(auth): LDAP and SSO users keep their source; directory TLS is real (#5036)
#5036 "SSO and LDAP: users keep their auth source, no merging into local
accounts, is_active checked, LDAP TLS real and filter escaped".

- users gain auth_source (local/ldap/oidc) and external_id (migration 0027).
  A provider account is found by (source, subject) only: the LDAP entry DN
  or the OIDC sub. A first sign-in whose username or email another account
  holds is refused with a message naming why; a disabled user stays out.
- Local passwords sign in local accounts only.
- LDAP: port 636 is LDAPS, otherwise StartTLS before any bind; the server
  certificate is checked against the system CAs or a CA pasted in
  Settings -> Auth. The login name is escaped into the filter (RFC 4515),
  and an empty password is refused before the directory sees it.
- OIDC: the ID token must carry this sign-in's nonce and the userinfo sub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:10:40 -04:00

128 lines
4.4 KiB
Python

"""steward/auth/oidc.py
OIDC Authorization Code flow helpers using httpx.
No extra dependencies — works with any OIDC provider (Authentik, Keycloak, etc.).
JWT signature verification is intentionally skipped for homelab use;
we trust the HTTPS connection to the discovery-documented token/userinfo endpoints.
"""
from __future__ import annotations
import base64
import json
import logging
import os
import time
import httpx
logger = logging.getLogger(__name__)
# Module-level cache: {discovery_url: (fetched_at, doc)}
_discovery_cache: dict[str, tuple[float, dict]] = {}
_DISCOVERY_TTL = 300 # seconds
async def get_discovery(discovery_url: str) -> dict:
"""Fetch and cache the OIDC provider discovery document."""
now = time.monotonic()
if discovery_url in _discovery_cache:
fetched_at, doc = _discovery_cache[discovery_url]
if now - fetched_at < _DISCOVERY_TTL:
return doc
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(discovery_url)
resp.raise_for_status()
doc = resp.json()
_discovery_cache[discovery_url] = (now, doc)
return doc
def build_authorize_url(doc: dict, client_id: str, redirect_uri: str,
scopes: str, state: str, nonce: str) -> str:
"""Build the IdP authorization redirect URL."""
import urllib.parse
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scopes,
"state": state,
"nonce": nonce,
}
return doc["authorization_endpoint"] + "?" + urllib.parse.urlencode(params)
async def exchange_code(doc: dict, client_id: str, client_secret: str,
code: str, redirect_uri: str) -> dict:
"""Exchange authorization code for tokens. Returns token response dict."""
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
doc["token_endpoint"],
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": client_id,
"client_secret": client_secret,
},
)
resp.raise_for_status()
return resp.json()
async def get_userinfo(doc: dict, access_token: str) -> dict:
"""Fetch user info from the IdP's userinfo endpoint."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
doc["userinfo_endpoint"],
headers={"Authorization": f"Bearer {access_token}"},
)
resp.raise_for_status()
return resp.json()
def decode_id_token_payload(id_token: str) -> dict:
"""Decode ID token payload without verifying signature (homelab only)."""
try:
parts = id_token.split(".")
if len(parts) < 2:
return {}
payload_b64 = parts[1]
# Add padding
payload_b64 += "=" * (4 - len(payload_b64) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64))
except Exception:
return {}
def id_token_refusal(id_token: str, nonce: str | None, userinfo_sub: str) -> str | None:
"""Why this sign-in's ID token does not belong to it, or None when it does.
The token must carry the nonce this browser was sent off with, so a token
from another sign-in cannot be replayed into this one, and name the same
subject as the userinfo response (OIDC Core 5.3.2). The signature is not
checked (see the module note)."""
if not userinfo_sub:
return "SSO returned no subject (sub) for this user."
claims = decode_id_token_payload(id_token) if id_token else {}
if not claims:
return "SSO returned no ID token. Check that the scopes include openid."
if not nonce or claims.get("nonce") != nonce:
return "This SSO sign-in does not match the one Steward started. Try again."
if claims.get("sub") != userinfo_sub:
return "SSO returned an ID token for a different user. Try again."
return None
def map_role(groups: list[str], admin_group: str, operator_group: str) -> str:
"""Map IdP groups to a local role string."""
if admin_group and admin_group in groups:
return "admin"
if operator_group and operator_group in groups:
return "operator"
return "viewer"
def generate_state() -> str:
return base64.urlsafe_b64encode(os.urandom(24)).decode().rstrip("=")