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:
parent
3bc16b581b
commit
29a9ff735e
14 changed files with 513 additions and 41 deletions
|
|
@ -6,4 +6,5 @@ def site_context(request):
|
|||
"site_name": "coulomb.social",
|
||||
"default_tenant_id": settings.DEFAULT_TENANT_ID,
|
||||
"oidc_enabled": settings.OIDC_ENABLED,
|
||||
"registration_enabled": bool(settings.NETKINGDOM_REGISTRATION_URL),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from authlib.integrations.httpx_client import OAuth2Client
|
||||
from authlib.jose import JsonWebKey, jwt
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
|
@ -52,17 +55,18 @@ def _oauth_client() -> OAuth2Client:
|
|||
return OAuth2Client(**kwargs)
|
||||
|
||||
|
||||
def build_authorization_url(*, state: str, code_verifier: str) -> str:
|
||||
def build_authorization_url(
|
||||
*, state: str, code_verifier: str, acr_values: str | None = None
|
||||
) -> str:
|
||||
if not oidc_configured():
|
||||
raise OIDCConfigurationError("OIDC is not enabled/configured")
|
||||
doc = discovery_document()
|
||||
auth_endpoint = doc["authorization_endpoint"]
|
||||
client = _oauth_client()
|
||||
uri, _ = client.create_authorization_url(
|
||||
auth_endpoint,
|
||||
state=state,
|
||||
code_verifier=code_verifier,
|
||||
)
|
||||
parameters = {"state": state, "code_verifier": code_verifier}
|
||||
if acr_values:
|
||||
parameters["acr_values"] = acr_values
|
||||
uri, _ = client.create_authorization_url(auth_endpoint, **parameters)
|
||||
return uri
|
||||
|
||||
|
||||
|
|
@ -80,17 +84,54 @@ def exchange_code(code: str, *, code_verifier: str) -> dict[str, Any]:
|
|||
|
||||
|
||||
def fetch_userinfo(access_token: str) -> dict[str, Any]:
|
||||
"""Best-effort userinfo. KeyCape may 401 for some subjects; id_token is enough."""
|
||||
if not access_token:
|
||||
return {}
|
||||
doc = discovery_document()
|
||||
userinfo_endpoint = doc.get("userinfo_endpoint")
|
||||
if not userinfo_endpoint:
|
||||
return {}
|
||||
resp = httpx.get(
|
||||
userinfo_endpoint,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15.0,
|
||||
try:
|
||||
resp = httpx.get(
|
||||
userinfo_endpoint,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=15.0,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"OIDC userinfo returned %s; continuing with id_token claims",
|
||||
resp.status_code,
|
||||
)
|
||||
return {}
|
||||
return resp.json()
|
||||
except Exception:
|
||||
logger.exception("OIDC userinfo request failed; continuing with id_token claims")
|
||||
return {}
|
||||
|
||||
|
||||
def decode_id_token(id_token: str) -> dict[str, Any]:
|
||||
"""Verify id_token with issuer JWKS and return claims."""
|
||||
if not id_token or id_token.count(".") != 2:
|
||||
raise OIDCConfigurationError("token response missing a usable id_token")
|
||||
doc = discovery_document()
|
||||
jwks_uri = doc.get("jwks_uri")
|
||||
if not jwks_uri:
|
||||
raise OIDCConfigurationError("issuer discovery missing jwks_uri")
|
||||
jwks = httpx.get(jwks_uri, timeout=15.0)
|
||||
jwks.raise_for_status()
|
||||
key_set = JsonWebKey.import_key_set(jwks.json())
|
||||
claims = jwt.decode(
|
||||
id_token,
|
||||
key_set,
|
||||
claims_options={
|
||||
"iss": {"essential": True, "value": settings.OIDC_ISSUER.rstrip("/")},
|
||||
"aud": {"essential": True, "value": settings.OIDC_CLIENT_ID},
|
||||
"exp": {"essential": True},
|
||||
"sub": {"essential": True},
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
claims.validate()
|
||||
return dict(claims)
|
||||
|
||||
|
||||
def new_pkce_pair() -> tuple[str, str]:
|
||||
|
|
@ -101,10 +142,16 @@ def new_pkce_pair() -> tuple[str, str]:
|
|||
|
||||
|
||||
def claims_from_token_response(token: dict[str, Any], userinfo: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Merge id_token claims (if present as dict) with userinfo."""
|
||||
"""Prefer verified id_token claims; overlay optional userinfo."""
|
||||
claims: dict[str, Any] = {}
|
||||
# authlib may leave id_token as JWT string; userinfo is preferred when available
|
||||
claims.update(userinfo or {})
|
||||
if not claims.get("sub") and isinstance(token.get("userinfo"), dict):
|
||||
id_token = token.get("id_token")
|
||||
if isinstance(id_token, str) and id_token:
|
||||
claims.update(decode_id_token(id_token))
|
||||
elif isinstance(token.get("userinfo"), dict):
|
||||
claims.update(token["userinfo"])
|
||||
# userinfo is optional enrichment (KeyCape may 401 for some subjects)
|
||||
if userinfo:
|
||||
claims.update(userinfo)
|
||||
if not claims.get("sub"):
|
||||
raise OIDCConfigurationError("OIDC response has no subject claim")
|
||||
return claims
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ app_name = "identity"
|
|||
|
||||
urlpatterns = [
|
||||
path("login/", views.login_start, name="login"),
|
||||
path("register/", views.registration_start, name="register"),
|
||||
path("callback/", views.oidc_callback, name="callback"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
path("dev-login/", views.dev_login, name="dev_login"),
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue