feat: add OIDC sign-in, roles, sessions and client address rule

Authorization-code flow with PKCE through an idp adapter (with a fake), ID
token validation, identity and roles resolved from the directory with a short
cache, refusal of unknown, disabled and locked accounts with one generic page,
sessions that end when the account becomes unavailable, sign-out that ends the
server-side session, role decorators and the trusted-proxy client address rule.
Adds the OIDC_* and PUBLIC_BASE_URL configuration.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
2026-10-17 19:19:51 +02:00
co-authored by Claude Sonnet 5.5
parent 7458a2a815
commit 3c69c2a156
32 changed files with 1692 additions and 14 deletions
+33
View File
@@ -6,6 +6,7 @@ import ipaddress
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
@@ -42,6 +43,18 @@ class DirectoryConfig:
admin_group_dn: str
@dataclass(frozen=True)
class OidcConfig:
issuer: str # exactly as the provider reports it, including any trailing slash
client_id: str
client_secret: str = field(repr=False)
public_base_url: str # origin the users reach the portal at; no trailing slash
@property
def redirect_uri(self) -> str:
return f"{self.public_base_url}/auth/callback"
@dataclass(frozen=True)
class Config:
secret_key: str
@@ -52,6 +65,7 @@ class Config:
log_level: str
default_timezone: str
directory: DirectoryConfig
oidc: OidcConfig
def load_config(environ: Mapping[str, str]) -> Config:
@@ -135,6 +149,19 @@ def load_config(environ: Mapping[str, str]) -> Config:
if raw("LDAP_CA_FILE") is not None and not ca_file.is_file():
problems.append("LDAP_CA_FILE: must be an existing file")
issuer = required("OIDC_ISSUER", "issuer address from the provider's OpenID configuration")
client_id = required("OIDC_CLIENT_ID", "client ID of the provider's application")
client_secret = required("OIDC_CLIENT_SECRET", "client secret of the provider's application")
base_url = required("PUBLIC_BASE_URL", "address users reach the portal at, e.g. https://host")
base_url = base_url.rstrip("/")
if issuer and urlsplit(issuer).scheme != "https":
problems.append("OIDC_ISSUER: must be an https address")
if base_url:
parts = urlsplit(base_url)
allowed = ("https", "http") if debug else ("https",)
if parts.scheme not in allowed or not parts.netloc or parts.path or parts.query:
problems.append("PUBLIC_BASE_URL: must be an https origin such as https://host")
if problems:
raise ConfigError(problems)
@@ -155,4 +182,10 @@ def load_config(environ: Mapping[str, str]) -> Config:
publisher_group_dn=publisher_group_dn,
admin_group_dn=admin_group_dn,
),
oidc=OidcConfig(
issuer=issuer,
client_id=client_id,
client_secret=client_secret,
public_base_url=base_url,
),
)