coulomb-social/coulomb_social/apps/identity/oidc.py
tegwick 76ec8cfe41 Register coulomb-social OIDC client on live KeyCape (CSOC-WP-0002-T03)
Public PKCE client on kc.coulomb.social with local and production redirect
URIs. Add register-keycape-client.sh, document env, and harden public-client
token exchange (no secret). Authorize probe verified registered vs reject.
2026-08-09 01:50:51 +02:00

110 lines
3.3 KiB
Python

"""OIDC/PKCE helpers for NetKingdom IAM Profile issuers."""
from __future__ import annotations
import secrets
from typing import Any
from urllib.parse import urlencode
import httpx
from authlib.integrations.httpx_client import OAuth2Client
from django.conf import settings
class OIDCConfigurationError(RuntimeError):
pass
def oidc_configured() -> bool:
return bool(
settings.OIDC_ENABLED
and settings.OIDC_ISSUER
and settings.OIDC_CLIENT_ID
and settings.OIDC_REDIRECT_URI
)
def discovery_document() -> dict[str, Any]:
if not settings.OIDC_ISSUER and not settings.OIDC_DISCOVERY_URL:
raise OIDCConfigurationError("OIDC_ISSUER or OIDC_DISCOVERY_URL required")
url = settings.OIDC_DISCOVERY_URL or (
settings.OIDC_ISSUER.rstrip("/") + "/.well-known/openid-configuration"
)
resp = httpx.get(url, timeout=15.0)
resp.raise_for_status()
return resp.json()
def _oauth_client() -> OAuth2Client:
"""Public clients (KeyCape default for browser apps) use PKCE without a secret."""
secret = (settings.OIDC_CLIENT_SECRET or "").strip() or None
kwargs: dict[str, Any] = {
"client_id": settings.OIDC_CLIENT_ID,
"redirect_uri": settings.OIDC_REDIRECT_URI,
"scope": settings.OIDC_SCOPES,
"code_challenge_method": "S256",
}
if secret:
kwargs["client_secret"] = secret
else:
# Authlib: omit secret for public clients
kwargs["token_endpoint_auth_method"] = "none"
return OAuth2Client(**kwargs)
def build_authorization_url(*, state: str, code_verifier: str) -> 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,
)
return uri
def exchange_code(code: str, *, code_verifier: str) -> dict[str, Any]:
doc = discovery_document()
token_endpoint = doc["token_endpoint"]
client = _oauth_client()
token = client.fetch_token(
token_endpoint,
code=code,
code_verifier=code_verifier,
grant_type="authorization_code",
)
return token
def fetch_userinfo(access_token: str) -> dict[str, Any]:
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,
)
resp.raise_for_status()
return resp.json()
def new_pkce_pair() -> tuple[str, str]:
"""Return (state, code_verifier). Challenge is computed by Authlib client."""
state = secrets.token_urlsafe(24)
code_verifier = secrets.token_urlsafe(48)
return state, code_verifier
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."""
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):
claims.update(token["userinfo"])
return claims