Some checks failed
Use-case regression gate / usecase-regressions (push) Failing after 6m44s
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
141 lines
5.9 KiB
Python
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
|