vergabe-teilnahme/vergabe_teilnahme/apps/accounts/oidc.py
tegwick 8be281025b
Some checks failed
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Application acceptance / application-tests (push) Has been cancelled
Build and Publish Container Image / build-and-push (push) Successful in 40s
Add company welcome and verified NetKingdom sign-in
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
2026-09-12 02:43:31 +02:00

210 lines
7.6 KiB
Python

"""Bounded NetKingdom OIDC relying party for one isolated company instance."""
import base64
import hashlib
import json
import secrets
import time
from functools import lru_cache
from urllib.parse import urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
import jwt
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class LoginRejectedError(ValueError):
pass
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def configuration():
issuer = settings.NETKINGDOM_ISSUER
callback = settings.NETKINGDOM_CALLBACK
for value in (issuer, callback):
parts = urlsplit(value)
if (
parts.scheme != "https"
or not parts.hostname
or parts.username
or parts.password
or parts.query
or parts.fragment
or parts.hostname in {"localhost", "127.0.0.1"}
):
raise ImproperlyConfigured("NetKingdom requires fixed HTTPS issuer and callback URLs")
if not settings.NETKINGDOM_CLIENT_ID or not settings.NETKINGDOM_TENANT:
raise ImproperlyConfigured("NetKingdom client and company binding are required")
expected = settings.APP_BASE_PATH + "/accounts/oidc/callback/"
if urlsplit(callback).path != expected:
raise ImproperlyConfigured("NetKingdom callback must match the fixed company path")
if settings.SESSION_ENGINE == "django.contrib.sessions.backends.signed_cookies":
raise ImproperlyConfigured("OIDC requires server-side sessions")
return issuer, settings.NETKINGDOM_CLIENT_ID, callback
def read_json(url, data=None):
request = Request(
url,
data=data,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
)
with build_opener(NoRedirect).open(request, timeout=10) as response:
body = response.read(262145)
if len(body) > 262144:
raise LoginRejectedError("Oversized provider response")
result = json.loads(body)
if not isinstance(result, dict):
raise LoginRejectedError("Invalid provider response")
return result
@lru_cache(maxsize=8)
def discovery(issuer, period):
metadata = read_json(issuer.rstrip("/") + "/.well-known/openid-configuration")
if metadata.get("issuer") != issuer:
raise LoginRejectedError("Issuer mismatch")
for field in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
parts = urlsplit(metadata.get(field, ""))
if (
parts.scheme != "https"
or parts.netloc != urlsplit(issuer).netloc
or parts.username
or parts.password
or parts.fragment
or parts.query
):
raise LoginRejectedError("Unapproved provider endpoint")
if "S256" not in metadata.get(
"code_challenge_methods_supported", []
) or "RS256" not in metadata.get("id_token_signing_alg_values_supported", []):
raise LoginRejectedError("Provider does not support the admitted flow")
return metadata
@lru_cache(maxsize=8)
def key_client(uri):
return jwt.PyJWKClient(uri, timeout=10, lifespan=300)
def begin():
issuer, client, callback = configuration()
metadata = discovery(issuer, int(time.time() // 300))
pending = {
"state": secrets.token_urlsafe(32),
"nonce": secrets.token_urlsafe(32),
"verifier": secrets.token_urlsafe(64),
"created": time.time(),
}
challenge = (
base64.urlsafe_b64encode(
hashlib.sha256(pending["verifier"].encode("ascii")).digest(),
)
.decode("ascii")
.rstrip("=")
)
parameters = {
"response_type": "code",
"client_id": client,
"redirect_uri": callback,
"scope": "openid profile groups",
"state": pending["state"],
"nonce": pending["nonce"],
"code_challenge": challenge,
"code_challenge_method": "S256",
"prompt": "login",
"tenant_hint": settings.NETKINGDOM_TENANT,
}
return pending, metadata["authorization_endpoint"] + "?" + urlencode(parameters)
def verify_claims(claims):
"""Identity evidence plus the invited pilot's explicit company admission rule."""
tenant = settings.NETKINGDOM_TENANT
groups, roles = claims.get("groups"), claims.get("roles")
if (
claims.get("tenant") != tenant
or claims.get("tenant_source") != "directory"
or claims.get("principal_type") != "human"
or not isinstance(groups, list)
or not all(isinstance(g, str) for g in groups)
or not isinstance(roles, list)
or not all(isinstance(r, str) for r in roles)
or tenant + ":users" not in groups
or {"netkingdom-suspended", "net-kingdom-admins"} & set(groups)
or {"platform-operator", "platform-root", "emergency"} & set(roles)
):
raise LoginRejectedError("Company membership is not established")
assurance = claims.get("assurance")
if not isinstance(assurance, dict) or assurance.get("level") not in {"aal1", "aal2", "aal3"}:
raise LoginRejectedError("Missing authentication assurance")
if not isinstance(claims.get("sub"), str) or not 0 < len(claims["sub"]) <= 512:
raise LoginRejectedError("Invalid subject")
def complete(pending, state, code):
if (
not isinstance(pending, dict)
or not state
or not code
or len(code) > 8192
or not secrets.compare_digest(pending.get("state", ""), state)
or not 0 <= time.time() - pending.get("created", 0) <= 600
):
raise LoginRejectedError("Invalid or expired sign-in")
issuer, client, callback = configuration()
metadata = discovery(issuer, int(time.time() // 300))
tokens = read_json(
metadata["token_endpoint"],
urlencode(
{
"grant_type": "authorization_code",
"client_id": client,
"redirect_uri": callback,
"code": code,
"code_verifier": pending["verifier"],
}
).encode("ascii"),
)
token = tokens.get("id_token")
if not isinstance(token, str) or len(token) > 32768:
raise LoginRejectedError("Missing ID token")
key = key_client(metadata["jwks_uri"]).get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
key.key,
algorithms=["RS256"],
issuer=issuer,
audience=client,
options={"require": ["iss", "sub", "aud", "exp", "iat", "nonce"]},
)
audience = claims["aud"]
if (
(isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != client)
or ("azp" in claims and claims["azp"] != client)
or not isinstance(claims["nonce"], str)
or not secrets.compare_digest(claims["nonce"], pending["nonce"])
):
raise LoginRejectedError("Token binding mismatch")
if any(type(claims[field]) is not int for field in ("iat", "exp")):
raise LoginRejectedError("Invalid token timestamps")
if claims["iat"] < pending["created"] - 60 or claims["exp"] <= claims["iat"]:
raise LoginRejectedError("Token predates this sign-in")
verify_claims(claims)
# Store neither bearer tokens nor a provider password in the product session.
return {
"issuer": issuer,
"tenant": settings.NETKINGDOM_TENANT,
"client": client,
"subject": claims["sub"],
"label": str(claims.get("preferred_username") or "Ihr Benutzerkonto")[:150],
"expires": min(int(claims["exp"]), int(time.time()) + 300),
}