import secrets import time from unittest.mock import patch from urllib.parse import parse_qs, urlsplit from authlib.integrations.django_client import OAuth from django.conf import settings from django.contrib.sessions.backends.db import SessionStore from django.test import RequestFactory, TestCase from joserfc import jwt from joserfc.jwk import RSAKey class AuthlibFlowTests(TestCase): """Exercise real Authlib state, PKCE, nonce and signature validation offline.""" @classmethod def setUpClass(cls): super().setUpClass() cls.key = RSAKey.generate_key(2048, parameters={"kid": "test-key"}) def flow(self, **changes): issuer = settings.DEX_ISSUER remote = OAuth().register("dex", client_id=settings.DEX_CLIENT_ID, client_secret="test-only", issuer=issuer, authorization_endpoint=issuer + "/auth", token_endpoint=issuer + "/token", jwks_uri=issuer + "/keys", id_token_signing_alg_values_supported=["RS256"], client_kwargs={"scope": "openid email profile federated:id", "code_challenge_method": "S256"}) request = RequestFactory().get("/members/login/", secure=True) request.session = SessionStore() nonce = secrets.token_urlsafe(32) response = remote.authorize_redirect(request, "https://eom.dev/members/oidc/callback/", nonce=nonce) auth_params = parse_qs(urlsplit(response.url).query) self.assertEqual(auth_params["code_challenge_method"], ["S256"]) self.assertTrue(auth_params["code_challenge"][0]) callback = RequestFactory().get("/members/oidc/callback/", {"code": "test-code", "state": auth_params["state"][0]}, secure=True) callback.session = request.session claims = {"iss": issuer, "sub": "test-sub", "aud": settings.DEX_CLIENT_ID, "iat": int(time.time()), "exp": int(time.time()) + 300, "nonce": nonce, **changes} token = {"id_token": jwt.encode({"alg": "RS256", "kid": "test-key"}, claims, self.key)} return remote, callback, token def exchange(self, remote, callback, token): with patch.object(remote, "fetch_access_token", return_value=token) as exchange, patch.object(remote, "fetch_jwk_set", return_value={"keys": [self.key.as_dict(private=False)]}): result = remote.authorize_access_token(callback, claims_options={"iss": {"essential": True, "value": settings.DEX_ISSUER}}, leeway=0) self.assertTrue(exchange.call_args.kwargs["code_verifier"]) return result def test_real_pkce_nonce_signature_and_one_use_state(self): remote, callback, token = self.flow() result = self.exchange(remote, callback, token) self.assertEqual(result["userinfo"]["sub"], "test-sub") with self.assertRaises(Exception): self.exchange(remote, callback, token) def test_wrong_nonce_issuer_audience_expiry_rejected(self): for changes in ({"nonce": "attacker"}, {"iss": "https://evil.example"}, {"aud": "another-client"}, {"exp": int(time.time()) - 100}): with self.subTest(changes=changes): remote, callback, token = self.flow(**changes) with self.assertRaises(Exception): self.exchange(remote, callback, token) def test_wrong_state_never_exchanges_code(self): remote, callback, token = self.flow() callback.GET = callback.GET.copy() callback.GET["state"] = "unknown-state" with patch.object(remote, "fetch_access_token") as exchange: with self.assertRaises(Exception): remote.authorize_access_token(callback) exchange.assert_not_called() def test_bad_signature_rejected(self): remote, callback, token = self.flow() other = RSAKey.generate_key(2048, parameters={"kid": "test-key"}) claims = {"iss": settings.DEX_ISSUER, "sub": "attacker", "aud": settings.DEX_CLIENT_ID, "iat": int(time.time()), "exp": int(time.time()) + 300, "nonce": "attacker"} token["id_token"] = jwt.encode({"alg": "RS256", "kid": "test-key"}, claims, other) with self.assertRaises(Exception): self.exchange(remote, callback, token)