coulomb-social/tests/test_oidc_userinfo_binding.py
tegwick 5639603231
Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m44s
Bind optional UserInfo to verified OIDC identity claims
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
2026-10-30 23:11:18 +01:00

141 lines
5.9 KiB
Python

"""Exercise callback identity binding with real signed tokens and synthetic HTTP."""
import time
import httpx
import pytest
from authlib.jose import JsonWebKey, jwt
from django.urls import reverse
from coulomb_social.apps.identity import oidc, views
from coulomb_social.apps.members.models import Member
ISSUER = "https://issuer.example.test"
CLIENT = "synthetic-coulomb-social"
@pytest.fixture(scope="module")
def signing_key():
return JsonWebKey.generate_key("RSA", 2048, is_private=True)
@pytest.fixture
def callback(client, settings, monkeypatch, signing_key):
settings.OIDC_ENABLED = True
settings.OIDC_ISSUER = ISSUER
settings.OIDC_CLIENT_ID = CLIENT
settings.OIDC_REDIRECT_URI = "https://app.example.test/auth/callback/"
settings.USER_ENGINE_BASE_URL = ""
settings.USER_ENGINE_PROXY_SECRET = ""
settings.FLEX_AUTH_BASE_URL = ""
def invoke(userinfo, *, token_overrides=None, signed_overrides=None):
claims = {"iss": ISSUER, "aud": CLIENT, "sub": "signed-member",
"exp": int(time.time()) + 120, "iat": int(time.time()),
"tenant": "tenant:synthetic", "roles": ["user"], "groups": [],
"acr": "aal1", "assurance": {"mfa": False},
"name": "Token Profile", "email": "member@example.test"}
claims.update(signed_overrides or {})
token = {"id_token": jwt.encode({"alg": "RS256", "kid": "synthetic"},
claims, signing_key).decode(),
"access_token": "synthetic-access-token"}
token.update(token_overrides or {})
monkeypatch.setattr(oidc, "exchange_code", lambda *args, **kwargs: token)
def get(url, **kwargs):
responses = {
ISSUER + "/.well-known/openid-configuration": {
"jwks_uri": ISSUER + "/jwks", "userinfo_endpoint": ISSUER + "/userinfo"},
ISSUER + "/jwks": {"keys": [{**signing_key.as_dict(is_private=False),
"kid": "synthetic"}]},
ISSUER + "/userinfo": userinfo,
}
assert url in responses, "Only synthetic identity endpoints permitted"
return httpx.Response(200, json=responses[url], request=httpx.Request("GET", url))
monkeypatch.setattr(httpx, "get", get)
session = client.session
session[views.SESSION_OIDC_STATE] = "synthetic-state"
session[views.SESSION_OIDC_VERIFIER] = "synthetic-verifier"
session.save()
return client.get(reverse("identity:callback") + "?code=synthetic&state=synthetic-state")
return invoke
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-009")
@pytest.mark.parametrize("userinfo", [
{"sub": "another-member", "email": "member@example.test"},
{"name": "Profile without subject"},
{"sub": 123},
])
def test_callback_refuses_userinfo_without_matching_signed_subject(callback, client, userinfo):
assert callback(userinfo).status_code == 302
assert "_auth_user_id" not in client.session
assert Member.objects.count() == 0
assert views.SESSION_OIDC_STATE not in client.session
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-009")
@pytest.mark.parametrize("unsigned", [None, "", 123])
def test_callback_requires_verified_id_token_even_with_userinfo(callback, client, unsigned):
assert callback({"sub": "signed-member"}, token_overrides={
"id_token": unsigned, "userinfo": {"sub": "token-response-member"}}).status_code == 302
assert "_auth_user_id" not in client.session
assert Member.objects.count() == 0
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-009")
def test_matching_userinfo_enriches_profile_without_replacing_signed_authority(callback, client):
assert callback({"sub": "signed-member", "name": "Current Profile",
"email": "current@example.test", "iss": "https://other.example.test",
"aud": "other-client", "tenant": "tenant:other",
"roles": ["admin"], "groups": ["admins"], "acr": "aal2",
"assurance": {"mfa": True}, "azp": "other-client"}).status_code == 302
member = Member.objects.get(subject="signed-member")
assert member.issuer == ISSUER
assert member.tenant_id == "tenant:synthetic"
assert member.display_name == "Current Profile"
assert member.email == "current@example.test"
assert str(client.session["_auth_user_id"]) == str(member.user_id)
assert not member.user.has_usable_password()
# Check the actual downstream claims rather than treating login alone as
# proof that an overlay did not change the asserted authority.
assert client.session["identity_roles"] == ["user"]
assert client.session["identity_groups"] == []
assert client.session["identity_assurance"] == {"mfa": False}
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-009")
def test_unavailable_userinfo_keeps_verified_token_login(callback, client):
assert callback({}).status_code == 302
assert Member.objects.get(subject="signed-member").issuer == ISSUER
assert "_auth_user_id" in client.session
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-009")
def test_userinfo_cannot_satisfy_requested_step_up(callback, client):
session = client.session
session[views.SESSION_OIDC_REQUIRED_ACR] = "aal2"
session.save()
response = callback({"sub": "signed-member", "acr": "aal2", "assurance": {"mfa": True}})
assert response.status_code == 400
assert Member.objects.count() == 0
assert "_auth_user_id" not in client.session
@pytest.mark.django_db
@pytest.mark.usecase("CSOC-UC-009")
@pytest.mark.parametrize("overrides", [
{"iss": "https://other.example.test"}, {"aud": "other-client"},
{"exp": 1}, {"sub": ""},
])
def test_valid_userinfo_cannot_rescue_invalid_signed_token(callback, client, overrides):
assert callback({"sub": "signed-member"}, signed_overrides=overrides).status_code == 302
assert Member.objects.count() == 0
assert "_auth_user_id" not in client.session