Complete identity smoke path: id_token claims, registration entry, cutover docs

Prefer verified KeyCape id_token claims when /userinfo returns 401; soft-fail
userinfo. Add CSOC-WP-0003 registration entry (disabled until NetKingdom URL),
AAL step-up hooks, smoke/cutover evidence for tegwick OIDC without MFA.
This commit is contained in:
tegwick 2026-08-09 22:42:51 +02:00
parent 3bc16b581b
commit 29a9ff735e
14 changed files with 513 additions and 41 deletions

View file

@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
SESSION_OIDC_STATE = "oidc_state"
SESSION_OIDC_VERIFIER = "oidc_code_verifier"
SESSION_OIDC_REQUIRED_ACR = "oidc_required_acr"
@require_GET
@ -31,8 +32,19 @@ def login_start(request: HttpRequest) -> HttpResponse:
state, verifier = oidc.new_pkce_pair()
request.session[SESSION_OIDC_STATE] = state
request.session[SESSION_OIDC_VERIFIER] = verifier
requested_acr = (
settings.OIDC_STEP_UP_ACR
if request.GET.get("assurance") == "aal2"
else None
)
if requested_acr:
request.session[SESSION_OIDC_REQUIRED_ACR] = requested_acr
else:
request.session.pop(SESSION_OIDC_REQUIRED_ACR, None)
try:
url = oidc.build_authorization_url(state=state, code_verifier=verifier)
url = oidc.build_authorization_url(
state=state, code_verifier=verifier, acr_values=requested_acr
)
except Exception:
logger.exception("OIDC authorization URL build failed")
messages.error(request, "Identity provider is unavailable. Try again later.")
@ -60,6 +72,7 @@ def oidc_callback(request: HttpRequest) -> HttpResponse:
state = request.GET.get("state")
expected_state = request.session.pop(SESSION_OIDC_STATE, None)
verifier = request.session.pop(SESSION_OIDC_VERIFIER, None)
required_acr = request.session.pop(SESSION_OIDC_REQUIRED_ACR, None)
if not code or not state or state != expected_state or not verifier:
return HttpResponseBadRequest("Invalid OIDC callback state")
@ -77,11 +90,34 @@ def oidc_callback(request: HttpRequest) -> HttpResponse:
return HttpResponseBadRequest("Token missing subject")
issuer = raw.get("iss") or settings.OIDC_ISSUER
if required_acr and not _claims_satisfy_step_up(raw, required_acr):
logger.warning("OIDC response did not satisfy requested assurance")
return HttpResponseBadRequest("Requested sign-in assurance was not satisfied")
claims = _claims_from_oidc_payload(raw, issuer=str(issuer), subject=str(sub))
establish_session(request, claims)
return redirect(settings.LOGIN_REDIRECT_URL)
@require_GET
def registration_start(request: HttpRequest) -> HttpResponse:
"""Send applicants only to the operator-configured registration service."""
url = settings.NETKINGDOM_REGISTRATION_URL
if not url:
messages.error(request, "Account registration is not available yet.")
return redirect("core:landing")
return redirect(url)
def _claims_satisfy_step_up(raw: dict, required_acr: str) -> bool:
assurance = raw.get("assurance") if isinstance(raw.get("assurance"), dict) else {}
acr = str(raw.get("acr") or assurance.get("aal") or "").lower()
if required_acr.lower() in {"aal2", "urn:netkingdom:aal2", "mfa"}:
return acr in {"aal2", "urn:netkingdom:aal2", "mfa"} or bool(
assurance.get("mfa")
)
return acr == required_acr.lower()
@require_http_methods(["GET", "POST"])
def dev_login(request: HttpRequest) -> HttpResponse:
"""Local-only claims form when OIDC is off. Never enable outside DEBUG."""