Files
arc53--docsgpt/tests/test_oidc.py
T
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

1662 lines
67 KiB
Python

"""Tests for the OIDC SSO module (application/api/oidc/)."""
import base64
import hashlib
import json
import time
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import Mock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from jose import jwk
from jose import jwt as jose_jwt
from application.core.settings import settings
ISSUER = "https://idp.test/app/"
CLIENT_ID = "docsgpt-test"
FRONTEND_URL = "http://frontend.test"
JWT_SECRET = "test-oidc-secret"
KID = "test-key-1"
BCL_EVENT = "http://schemas.openid.net/event/backchannel-logout"
DISCOVERY = {
"issuer": ISSUER,
"authorization_endpoint": "https://idp.test/authorize",
"token_endpoint": "https://idp.test/token",
"jwks_uri": "https://idp.test/jwks",
"userinfo_endpoint": "https://idp.test/userinfo",
"end_session_endpoint": "https://idp.test/end-session",
}
def _generate_rsa_pem():
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode("ascii")
PRIVATE_PEM = _generate_rsa_pem()
PUBLIC_JWK = {
**jwk.construct(PRIVATE_PEM, algorithm="RS256").public_key().to_dict(),
"kid": KID,
"use": "sig",
}
def sign_id_token(claims, kid=KID, key=PRIVATE_PEM, algorithm="RS256"):
return jose_jwt.encode(claims, key, algorithm=algorithm, headers={"kid": kid})
def id_token_claims(**overrides):
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": CLIENT_ID,
"sub": "oidc-user-1",
"email": "user@example.com",
"name": "OIDC User",
"nonce": "test-nonce",
"iat": now,
"exp": now + 300,
}
claims.update(overrides)
return claims
def logout_token_claims(**overrides):
now = int(time.time())
claims = {
"iss": ISSUER,
"aud": CLIENT_ID,
"sub": "oidc-user-1",
"iat": now,
"jti": "bcl-jti-1",
"events": {BCL_EVENT: {}},
}
claims.update(overrides)
return claims
def make_session_token(**overrides):
"""Mint a session JWT the way the callback does, for refresh-route tests."""
now = int(time.time())
payload = {
"sub": "oidc-user-1",
"jti": "jti-1",
"iat": now,
"exp": now + 3600,
"oidc_sub": "oidc-user-1",
}
payload.update(overrides)
return jose_jwt.encode(payload, JWT_SECRET, algorithm="HS256")
class FakeRedis:
"""Minimal stand-in for the redis-py client used by the oidc routes."""
def __init__(self):
self.store = {}
def set(self, key, value, ex=None, nx=False):
if nx and key in self.store:
return None
self.store[key] = value.encode("utf-8") if isinstance(value, str) else value
return True
def getdel(self, key):
return self.store.pop(key, None)
def make_fake_get(jwks_keys, discovery=None, userinfo=None, userinfo_status=200):
"""requests.get stub serving discovery + JWKS (+ optional userinfo); ``jwks_keys`` is mutable."""
document = dict(DISCOVERY if discovery is None else discovery)
def fake_get(url, timeout=None, headers=None):
resp = Mock()
resp.status_code = 200
if "openid-configuration" in url:
resp.json.return_value = dict(document)
elif url == document["jwks_uri"]:
resp.json.return_value = {"keys": list(jwks_keys)}
elif userinfo is not None and url == document.get("userinfo_endpoint"):
resp.status_code = userinfo_status
resp.json.return_value = dict(userinfo)
else:
resp.status_code = 404
return resp
return fake_get
@pytest.fixture(autouse=True)
def oidc_settings(monkeypatch):
monkeypatch.setattr(settings, "AUTH_TYPE", "oidc")
monkeypatch.setattr(settings, "OIDC_ISSUER", ISSUER)
monkeypatch.setattr(settings, "OIDC_CLIENT_ID", CLIENT_ID)
monkeypatch.setattr(settings, "OIDC_CLIENT_SECRET", None)
monkeypatch.setattr(settings, "OIDC_SCOPES", "openid profile email")
monkeypatch.setattr(settings, "OIDC_USER_ID_CLAIM", "sub")
monkeypatch.setattr(settings, "OIDC_FRONTEND_URL", FRONTEND_URL)
monkeypatch.setattr(settings, "OIDC_REDIRECT_URI", None)
monkeypatch.setattr(settings, "OIDC_SESSION_LIFETIME_SECONDS", 28800)
monkeypatch.setattr(settings, "OIDC_PROVIDER_NAME", None)
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", None)
monkeypatch.setattr(settings, "OIDC_GROUPS_CLAIM", "groups")
monkeypatch.setattr(settings, "JWT_SECRET_KEY", JWT_SECRET)
@pytest.fixture(autouse=True)
def reset_provider_cache():
from application.api.oidc import provider
provider.reset_cache()
yield
provider.reset_cache()
@pytest.mark.unit
class TestProviderDiscovery:
def test_discovery_fetched_once_then_cached(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
first = provider.get_discovery()
second = provider.get_discovery()
assert first == second == DISCOVERY
assert mock_requests.get.call_count == 1
def test_discovery_refetched_after_ttl(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
provider.get_discovery()
provider._cache["discovery_at"] -= provider.DISCOVERY_TTL_SECONDS + 1
provider.get_discovery()
assert mock_requests.get.call_count == 2
def test_discovery_failure_raises_oidc_error(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.return_value = Mock(status_code=502)
mock_requests.RequestException = Exception
with pytest.raises(provider.OIDCError):
provider.get_discovery()
@pytest.mark.unit
class TestValidateIdToken:
def _validate(self, token, nonce="test-nonce", jwks_keys=None):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get(
jwks_keys if jwks_keys is not None else [PUBLIC_JWK]
)
return provider.validate_id_token(token, nonce)
def test_valid_token_returns_claims(self):
claims = self._validate(sign_id_token(id_token_claims()))
assert claims["sub"] == "oidc-user-1"
assert claims["email"] == "user@example.com"
def test_nonce_mismatch_rejected(self):
from application.api.oidc import provider
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(id_token_claims(nonce="other")))
def test_wrong_audience_rejected(self):
from application.api.oidc import provider
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(id_token_claims(aud="someone-else")))
def test_wrong_issuer_rejected(self):
from application.api.oidc import provider
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(id_token_claims(iss="https://evil.test/")))
def test_expired_beyond_leeway_rejected(self):
from application.api.oidc import provider
expired = id_token_claims(exp=int(time.time()) - 120)
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(expired))
def test_expired_within_leeway_accepted(self):
barely = id_token_claims(exp=int(time.time()) - 30)
claims = self._validate(sign_id_token(barely))
assert claims["sub"] == "oidc-user-1"
def test_hs256_id_token_rejected(self):
from application.api.oidc import provider
forged = jose_jwt.encode(
id_token_claims(), JWT_SECRET, algorithm="HS256", headers={"kid": KID}
)
with pytest.raises(provider.OIDCError):
self._validate(forged)
def test_rekeyed_idp_with_reused_kid_recovers(self):
# The IdP replaced its signing key but kept the kid (mock IdP
# restarts, sloppy rotations): the cached key fails the signature,
# one forced JWKS refetch picks up the new key and validation
# succeeds.
from application.api.oidc import provider
new_pem = _generate_rsa_pem()
new_jwk = {
**jwk.construct(new_pem, algorithm="RS256").public_key().to_dict(),
"kid": KID,
"use": "sig",
}
token = sign_id_token(id_token_claims(), key=new_pem)
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
provider.get_jwks() # prime the cache with the OLD key
mock_requests.get.side_effect = make_fake_get([new_jwk])
claims = provider.validate_id_token(token, "test-nonce")
assert claims["sub"] == "oidc-user-1"
def test_unknown_kid_triggers_single_jwks_refetch(self):
from application.api.oidc import provider
rotated_pem = _generate_rsa_pem()
rotated_jwk = {
**jwk.construct(rotated_pem, algorithm="RS256").public_key().to_dict(),
"kid": "rotated-key",
"use": "sig",
}
token = sign_id_token(id_token_claims(), kid="rotated-key", key=rotated_pem)
jwks_keys = [PUBLIC_JWK]
jwks_calls = []
def fake_get(url, timeout=None):
resp = Mock()
resp.status_code = 200
if "openid-configuration" in url:
resp.json.return_value = dict(DISCOVERY)
elif url == DISCOVERY["jwks_uri"]:
jwks_calls.append(url)
# Old key on first fetch, rotated key appears on refetch.
resp.json.return_value = {"keys": list(jwks_keys)}
jwks_keys.append(rotated_jwk)
else:
resp.status_code = 404
return resp
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = fake_get
claims = provider.validate_id_token(token, "test-nonce")
assert claims["sub"] == "oidc-user-1"
assert len(jwks_calls) == 2
@pytest.mark.unit
class TestExchangeCode:
def test_posts_code_and_verifier(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = Mock(
status_code=200, json=Mock(return_value={"id_token": "x"})
)
tokens = provider.exchange_code("auth-code", "verifier-123", "https://app.test/cb")
assert tokens == {"id_token": "x"}
args, kwargs = mock_requests.post.call_args
assert args[0] == DISCOVERY["token_endpoint"]
sent = kwargs["data"]
assert sent["grant_type"] == "authorization_code"
assert sent["code"] == "auth-code"
assert sent["code_verifier"] == "verifier-123"
assert sent["redirect_uri"] == "https://app.test/cb"
assert sent["client_id"] == CLIENT_ID
assert "client_secret" not in sent
def test_includes_client_secret_when_post_method_supported(self, monkeypatch):
from application.api.oidc import provider
monkeypatch.setattr(settings, "OIDC_CLIENT_SECRET", "s3cret")
discovery = {**DISCOVERY, "token_endpoint_auth_methods_supported": ["client_secret_post"]}
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK], discovery=discovery)
mock_requests.post.return_value = Mock(
status_code=200, json=Mock(return_value={"id_token": "x"})
)
provider.exchange_code("auth-code", "verifier-123", "https://app.test/cb")
assert mock_requests.post.call_args.kwargs["data"]["client_secret"] == "s3cret"
def test_non_200_raises_oidc_error(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = Mock(status_code=400, text="bad request")
with pytest.raises(provider.OIDCError):
provider.exchange_code("auth-code", "verifier-123", "https://app.test/cb")
@pytest.mark.unit
class TestTokenEndpointAuthMethod:
def _exchange(self, discovery=None):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK], discovery=discovery)
mock_requests.post.return_value = Mock(
status_code=200, json=Mock(return_value={"id_token": "x"})
)
provider.exchange_code("auth-code", "verifier-123", "https://app.test/cb")
return mock_requests.post.call_args
def test_basic_auth_when_discovery_omits_methods(self, monkeypatch):
monkeypatch.setattr(settings, "OIDC_CLIENT_SECRET", "s3cret")
call = self._exchange()
assert call.kwargs["auth"] == (CLIENT_ID, "s3cret")
assert "client_secret" not in call.kwargs["data"]
def test_basic_auth_when_only_client_secret_basic_supported(self, monkeypatch):
monkeypatch.setattr(settings, "OIDC_CLIENT_SECRET", "s3cret")
discovery = {**DISCOVERY, "token_endpoint_auth_methods_supported": ["client_secret_basic"]}
call = self._exchange(discovery)
assert call.kwargs["auth"] == (CLIENT_ID, "s3cret")
assert "client_secret" not in call.kwargs["data"]
def test_post_auth_when_client_secret_post_supported(self, monkeypatch):
monkeypatch.setattr(settings, "OIDC_CLIENT_SECRET", "s3cret")
discovery = {
**DISCOVERY,
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
}
call = self._exchange(discovery)
assert call.kwargs["data"]["client_secret"] == "s3cret"
assert "auth" not in call.kwargs
def test_no_auth_kwarg_when_no_secret(self):
call = self._exchange()
assert "auth" not in call.kwargs
assert "client_secret" not in call.kwargs["data"]
@pytest.mark.unit
class TestFetchUserinfo:
def test_sends_bearer_token_and_returns_claims(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get(
[PUBLIC_JWK], userinfo={"sub": "oidc-user-1", "groups": ["devs"]}
)
info = provider.fetch_userinfo("at-123")
assert info == {"sub": "oidc-user-1", "groups": ["devs"]}
userinfo_calls = [
call
for call in mock_requests.get.call_args_list
if call.args[0] == DISCOVERY["userinfo_endpoint"]
]
assert len(userinfo_calls) == 1
assert userinfo_calls[0].kwargs["headers"]["Authorization"] == "Bearer at-123"
def test_missing_endpoint_raises(self):
from application.api.oidc import provider
discovery = {k: v for k, v in DISCOVERY.items() if k != "userinfo_endpoint"}
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK], discovery=discovery)
with pytest.raises(provider.OIDCError):
provider.fetch_userinfo("at-123")
def test_non_200_raises(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get(
[PUBLIC_JWK], userinfo={"sub": "x"}, userinfo_status=500
)
with pytest.raises(provider.OIDCError):
provider.fetch_userinfo("at-123")
@pytest.mark.unit
class TestRefreshGrant:
def test_posts_refresh_token_grant(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = Mock(
status_code=200, json=Mock(return_value={"access_token": "at-2"})
)
tokens = provider.refresh_grant("rt-old")
assert tokens == {"access_token": "at-2"}
sent = mock_requests.post.call_args.kwargs["data"]
assert sent["grant_type"] == "refresh_token"
assert sent["refresh_token"] == "rt-old"
assert sent["client_id"] == CLIENT_ID
@pytest.mark.unit
class TestValidateIdTokenNonceOptional:
def test_nonce_none_skips_nonce_check(self):
from application.api.oidc import provider
claims = id_token_claims()
del claims["nonce"]
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
decoded = provider.validate_id_token(sign_id_token(claims), nonce=None)
assert decoded["sub"] == "oidc-user-1"
def test_nonce_none_accepts_token_that_still_has_nonce(self):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
decoded = provider.validate_id_token(sign_id_token(id_token_claims()), nonce=None)
assert decoded["sub"] == "oidc-user-1"
@pytest.mark.unit
class TestValidateLogoutToken:
def _validate(self, token, jwks_keys=None):
from application.api.oidc import provider
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get(
jwks_keys if jwks_keys is not None else [PUBLIC_JWK]
)
return provider.validate_logout_token(token)
def test_valid_sub_token_returns_claims(self):
claims = self._validate(sign_id_token(logout_token_claims()))
assert claims["sub"] == "oidc-user-1"
def test_valid_sid_only_token(self):
claims = logout_token_claims(sid="sess-9")
del claims["sub"]
assert self._validate(sign_id_token(claims))["sid"] == "sess-9"
def test_missing_events_claim_rejected(self):
from application.api.oidc import provider
claims = logout_token_claims()
del claims["events"]
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(claims))
def test_wrong_event_uri_rejected(self):
from application.api.oidc import provider
claims = logout_token_claims(events={"http://other.event/uri": {}})
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(claims))
def test_nonce_prohibited(self):
from application.api.oidc import provider
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(logout_token_claims(nonce="n-1")))
def test_missing_sub_and_sid_rejected(self):
from application.api.oidc import provider
claims = logout_token_claims()
del claims["sub"]
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(claims))
def test_wrong_audience_rejected(self):
from application.api.oidc import provider
with pytest.raises(provider.OIDCError):
self._validate(sign_id_token(logout_token_claims(aud="someone-else")))
class _WatermarkRedis:
"""Minimal redis stand-in for the denylist (set + mget)."""
def __init__(self):
self.store = {}
def set(self, key, value, ex=None):
self.store[key] = value.encode("utf-8") if isinstance(value, str) else value
return True
def mget(self, keys):
return [self.store.get(key) for key in keys]
@pytest.mark.unit
class TestDenylistWatermark:
"""is_denied compares the token iat against the stored revocation timestamp."""
def _wire(self, monkeypatch):
from application.api.oidc import denylist
redis = _WatermarkRedis()
monkeypatch.setattr(denylist, "get_redis_instance", lambda: redis)
return denylist, redis
def test_session_before_revocation_denied_after_allowed(self, monkeypatch):
denylist, redis = self._wire(monkeypatch)
denylist.deny_user("u1")
watermark = int(redis.store[denylist._USER_PREFIX + "u1"])
# Issued before the revocation -> denied.
assert denylist.is_denied({"sub": "u1", "iat": watermark - 5}) is True
# Issued at/after the revocation (e.g. an immediate re-login) -> allowed.
assert denylist.is_denied({"sub": "u1", "iat": watermark + 5}) is False
def test_unrelated_identity_not_denied(self, monkeypatch):
denylist, redis = self._wire(monkeypatch)
denylist.deny_user("u1")
watermark = int(redis.store[denylist._USER_PREFIX + "u1"])
assert denylist.is_denied({"sub": "u2", "iat": watermark - 5}) is False
def test_missing_iat_is_conservatively_denied(self, monkeypatch):
denylist, _ = self._wire(monkeypatch)
denylist.deny_idp_sub("idp-1")
assert denylist.is_denied({"oidc_sub": "idp-1"}) is True
def test_no_entry_means_allowed(self, monkeypatch):
denylist, _ = self._wire(monkeypatch)
assert denylist.is_denied({"sub": "u1", "iat": 1}) is False
@pytest.fixture(scope="module")
def app():
with patch("application.app.handle_auth", return_value={"sub": "test_user"}):
from application.app import app as flask_app
flask_app.config["TESTING"] = True
yield flask_app
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def fake_redis():
redis = FakeRedis()
with patch("application.api.oidc.routes.get_redis_instance", return_value=redis):
yield redis
@pytest.fixture
def db_mocks():
"""Patch the DB seams of the oidc routes; default: unknown user, active on upsert."""
users_repo = Mock()
users_repo.get.return_value = None
users_repo.upsert.side_effect = lambda user_id, email=None: {
"user_id": user_id,
"active": True,
}
events_repo = Mock()
@contextmanager
def fake_session():
yield Mock()
with patch("application.api.oidc.routes.db_session", fake_session), patch(
"application.api.oidc.routes.db_readonly", fake_session
), patch(
"application.api.oidc.routes.UsersRepository", return_value=users_repo
), patch(
"application.api.oidc.routes.AuthEventsRepository", return_value=events_repo
):
yield SimpleNamespace(users=users_repo, events=events_repo)
def _seed_state(client, fake_redis, state="state-1", nonce="nonce-1"):
fake_redis.set(
f"oidc:state:{state}",
json.dumps({"code_verifier": "verifier-1", "nonce": nonce}),
)
# Mirror the browser cookie the login route sets, so the callback's
# state-binding (CSRF) check passes.
client.set_cookie("oidc_state", state)
return state, nonce
def _signed_token_response(claims, **extra):
"""Token-endpoint response carrying an id_token signed over ``claims``."""
body = {
"access_token": "at",
"token_type": "Bearer",
"id_token": sign_id_token(claims),
}
body.update(extra)
return Mock(status_code=200, json=Mock(return_value=body))
def _mint_id_token_response(stored_nonce, **extra):
return _signed_token_response(id_token_claims(nonce=stored_nonce), **extra)
@pytest.mark.unit
class TestLoginRoute:
def test_redirects_to_idp_with_pkce_and_stores_state(self, client, fake_redis):
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
response = client.get("/api/auth/oidc/login")
assert response.status_code == 302
location = response.headers["Location"]
assert location.startswith(DISCOVERY["authorization_endpoint"])
params = {k: v[0] for k, v in parse_qs(urlparse(location).query).items()}
assert params["response_type"] == "code"
assert params["client_id"] == CLIENT_ID
assert params["scope"] == "openid profile email"
assert params["code_challenge_method"] == "S256"
assert params["redirect_uri"].endswith("/api/auth/oidc/callback")
stored = json.loads(fake_redis.store[f"oidc:state:{params['state']}"])
assert stored["nonce"] == params["nonce"]
expected_challenge = (
base64.urlsafe_b64encode(
hashlib.sha256(stored["code_verifier"].encode("ascii")).digest()
)
.rstrip(b"=")
.decode("ascii")
)
assert params["code_challenge"] == expected_challenge
def test_503_when_redis_unavailable(self, client):
with patch("application.api.oidc.routes.get_redis_instance", return_value=None):
response = client.get("/api/auth/oidc/login")
assert response.status_code == 503
def test_503_when_discovery_fails(self, client, fake_redis):
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.return_value = Mock(status_code=502)
mock_requests.RequestException = Exception
response = client.get("/api/auth/oidc/login")
assert response.status_code == 503
@pytest.mark.unit
class TestOidcDisabled:
"""Outside AUTH_TYPE=oidc the routes 404 cleanly instead of 500-ing."""
def test_routes_404_when_not_oidc(self, client, monkeypatch):
monkeypatch.setattr(settings, "AUTH_TYPE", "session_jwt")
assert client.get("/api/auth/oidc/login").status_code == 404
assert client.get("/api/auth/oidc/logout").status_code == 404
# Even a (would-be) RS256 logout token must not reach discovery.
post = client.post(
"/api/auth/oidc/backchannel-logout", data={"logout_token": "x"}
)
assert post.status_code == 404
@pytest.mark.unit
class TestCallbackRoute:
@pytest.fixture(autouse=True)
def _db(self, db_mocks):
self.db = db_mocks
def _seed_state(self, client, fake_redis, state="state-1", nonce="nonce-1"):
return _seed_state(client, fake_redis, state=state, nonce=nonce)
def test_happy_path_mints_session_and_redirects_with_handoff(self, client, fake_redis):
state, nonce = self._seed_state(client, fake_redis)
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = _mint_id_token_response(nonce)
response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
assert response.status_code == 302
location = response.headers["Location"]
assert location.startswith(f"{FRONTEND_URL}/#oidc_code=")
handoff = location.split("#oidc_code=", 1)[1]
session_token = fake_redis.store[f"oidc:handoff:{handoff}"].decode("utf-8")
decoded = jose_jwt.decode(session_token, JWT_SECRET, algorithms=["HS256"])
assert decoded["sub"] == "oidc-user-1"
assert decoded["email"] == "user@example.com"
assert decoded["name"] == "OIDC User"
now = int(time.time())
assert now + 28800 - 60 <= decoded["exp"] <= now + 28800 + 60
# State must have been consumed.
assert f"oidc:state:{state}" not in fake_redis.store
def test_replayed_state_rejected(self, client, fake_redis):
state, nonce = self._seed_state(client, fake_redis)
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = _mint_id_token_response(nonce)
first = client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
replay = client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
assert "#oidc_code=" in first.headers["Location"]
assert replay.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=invalid_state"
def test_unknown_state_rejected(self, client, fake_redis):
# Cookie matches the state (passes the CSRF binding) but the state was
# never stored in Redis — the server-side lookup must still reject it.
client.set_cookie("oidc_state", "forged")
response = client.get("/api/auth/oidc/callback?code=abc&state=forged")
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=invalid_state"
def test_missing_state_cookie_rejected(self, client, fake_redis):
# State is valid server-side, but no browser cookie binds it — a forged
# cross-browser callback (login CSRF) lands here.
self._seed_state(client, fake_redis)
client.delete_cookie("oidc_state")
response = client.get("/api/auth/oidc/callback?code=abc&state=state-1")
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=invalid_state"
def test_mismatched_state_cookie_rejected(self, client, fake_redis):
# The cookie carries a different login attempt's state than the query
# param — reject rather than complete an attacker-seeded flow.
self._seed_state(client, fake_redis)
client.set_cookie("oidc_state", "attacker-state")
response = client.get("/api/auth/oidc/callback?code=abc&state=state-1")
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=invalid_state"
def test_idp_error_forwarded(self, client, fake_redis):
response = client.get("/api/auth/oidc/callback?error=access_denied")
assert (
response.headers["Location"]
== f"{FRONTEND_URL}/#oidc_error=access_denied"
)
def test_nonce_mismatch_fails_auth(self, client, fake_redis):
state, _ = self._seed_state(client, fake_redis, nonce="nonce-1")
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = _mint_id_token_response("evil-nonce")
response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=auth_failed"
def test_missing_user_id_claim(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_USER_ID_CLAIM", "preferred_username")
state, nonce = self._seed_state(client, fake_redis)
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = _mint_id_token_response(nonce)
response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=missing_claim"
def test_optional_profile_claims_omitted_when_absent(self, client, fake_redis):
state, nonce = self._seed_state(client, fake_redis)
claims = id_token_claims(nonce=nonce)
del claims["email"]
del claims["name"]
token_response = Mock(
status_code=200,
json=Mock(return_value={"id_token": sign_id_token(claims)}),
)
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = token_response
response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
handoff = response.headers["Location"].split("#oidc_code=", 1)[1]
session_token = fake_redis.store[f"oidc:handoff:{handoff}"].decode("utf-8")
decoded = jose_jwt.decode(session_token, JWT_SECRET, algorithms=["HS256"])
assert "email" not in decoded
assert "name" not in decoded
@pytest.mark.unit
class TestTokenRoute:
def test_handoff_code_single_use(self, client, fake_redis):
fake_redis.set("oidc:handoff:code-1", "minted-jwt")
first = client.post("/api/auth/oidc/token", json={"code": "code-1"})
replay = client.post("/api/auth/oidc/token", json={"code": "code-1"})
assert first.status_code == 200
assert first.get_json() == {"token": "minted-jwt"}
assert replay.status_code == 401
def test_unknown_code_rejected(self, client, fake_redis):
response = client.post("/api/auth/oidc/token", json={"code": "nope"})
assert response.status_code == 401
def test_missing_body_rejected(self, client, fake_redis):
response = client.post("/api/auth/oidc/token")
assert response.status_code == 401
def test_503_when_redis_unavailable(self, client):
with patch("application.api.oidc.routes.get_redis_instance", return_value=None):
response = client.post("/api/auth/oidc/token", json={"code": "code-1"})
assert response.status_code == 503
@pytest.mark.unit
class TestLogoutRoute:
def test_redirects_to_idp_end_session(self, client):
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
response = client.get("/api/auth/oidc/logout")
assert response.status_code == 302
location = response.headers["Location"]
assert location.startswith(DISCOVERY["end_session_endpoint"])
params = {k: v[0] for k, v in parse_qs(urlparse(location).query).items()}
assert params["post_logout_redirect_uri"] == FRONTEND_URL
assert params["client_id"] == CLIENT_ID
def test_falls_back_to_frontend_when_discovery_fails(self, client):
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.return_value = Mock(status_code=502)
mock_requests.RequestException = Exception
response = client.get("/api/auth/oidc/logout")
assert response.status_code == 302
assert response.headers["Location"] == FRONTEND_URL
@pytest.mark.unit
class TestCallbackGroups:
@pytest.fixture(autouse=True)
def _db(self, db_mocks):
self.db = db_mocks
def _callback(self, client, fake_redis, claims, userinfo=None, userinfo_status=200):
state, nonce = _seed_state(client, fake_redis)
claims = {**claims, "nonce": nonce}
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get(
[PUBLIC_JWK], userinfo=userinfo, userinfo_status=userinfo_status
)
mock_requests.post.return_value = _signed_token_response(claims)
return client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
def test_login_allowed_when_group_matches(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "admins, devs")
response = self._callback(client, fake_redis, id_token_claims(groups=["devs", "qa"]))
assert "#oidc_code=" in response.headers["Location"]
def test_login_denied_when_no_group_matches(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "admins, devs")
response = self._callback(client, fake_redis, id_token_claims(groups=["qa"]))
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=not_authorized"
call = self.db.events.insert.call_args
assert call.args == ("oidc-user-1", "oidc_login_denied")
assert call.kwargs["metadata"] == {"reason": "not_authorized", "groups": ["qa"]}
def test_single_string_group_claim_coerced_to_list(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "devs")
response = self._callback(client, fake_redis, id_token_claims(groups="devs"))
assert "#oidc_code=" in response.headers["Location"]
def test_allow_everyone_when_allowlist_unset(self, client, fake_redis):
response = self._callback(client, fake_redis, id_token_claims())
assert "#oidc_code=" in response.headers["Location"]
def test_custom_groups_claim_name(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "admin")
monkeypatch.setattr(settings, "OIDC_GROUPS_CLAIM", "roles")
response = self._callback(client, fake_redis, id_token_claims(roles=["admin"]))
assert "#oidc_code=" in response.headers["Location"]
def test_missing_groups_claim_recovered_via_userinfo(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "devs")
response = self._callback(
client,
fake_redis,
id_token_claims(),
userinfo={"sub": "oidc-user-1", "groups": ["devs"]},
)
assert "#oidc_code=" in response.headers["Location"]
def test_userinfo_sub_mismatch_fails_auth(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "devs")
response = self._callback(
client,
fake_redis,
id_token_claims(),
userinfo={"sub": "intruder", "groups": ["devs"]},
)
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=auth_failed"
def test_userinfo_failure_is_nonfatal_then_groups_denied(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "devs")
response = self._callback(
client,
fake_redis,
id_token_claims(),
userinfo={"sub": "oidc-user-1", "groups": ["devs"]},
userinfo_status=500,
)
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=not_authorized"
def test_missing_user_id_claim_recovered_via_userinfo(self, client, fake_redis, monkeypatch):
monkeypatch.setattr(settings, "OIDC_USER_ID_CLAIM", "preferred_username")
response = self._callback(
client,
fake_redis,
id_token_claims(),
userinfo={"sub": "oidc-user-1", "preferred_username": "alice"},
)
handoff = response.headers["Location"].split("#oidc_code=", 1)[1]
session_token = fake_redis.store[f"oidc:handoff:{handoff}"].decode("utf-8")
decoded = jose_jwt.decode(session_token, JWT_SECRET, algorithms=["HS256"])
assert decoded["sub"] == "alice"
@pytest.mark.unit
class TestCallbackAdminReconcile:
"""OIDC group -> admin reconcile fires on the login callback."""
@pytest.fixture(autouse=True)
def _db(self, db_mocks):
self.db = db_mocks
@pytest.fixture
def roles_repo(self):
repo = Mock()
repo.reconcile_oidc_admin.return_value = None
with patch("application.api.oidc.routes.UserRolesRepository", return_value=repo):
yield repo
def _callback(self, client, fake_redis, claims, userinfo=None):
state, nonce = _seed_state(client, fake_redis)
claims = {**claims, "nonce": nonce}
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK], userinfo=userinfo)
mock_requests.post.return_value = _signed_token_response(claims)
return client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
def _role_events(self, name):
return [
c
for c in self.db.events.insert.call_args_list
if len(c.args) >= 2 and c.args[1] == name
]
def test_no_admin_groups_is_a_noop(self, client, fake_redis, roles_repo):
# OIDC_ADMIN_GROUPS unset (default) — reconcile must never run.
response = self._callback(client, fake_redis, id_token_claims(groups=["devs"]))
assert "#oidc_code=" in response.headers["Location"]
roles_repo.reconcile_oidc_admin.assert_not_called()
def test_matching_group_grants_admin_and_audits(
self, client, fake_redis, roles_repo, monkeypatch
):
monkeypatch.setattr(settings, "OIDC_ADMIN_GROUPS", "admins")
roles_repo.reconcile_oidc_admin.return_value = "granted"
response = self._callback(client, fake_redis, id_token_claims(groups=["admins", "devs"]))
assert "#oidc_code=" in response.headers["Location"]
roles_repo.reconcile_oidc_admin.assert_called_once_with("oidc-user-1", True)
granted = self._role_events("role_granted")
assert len(granted) == 1
assert granted[0].kwargs["metadata"] == {"role": "admin", "source": "oidc_group"}
def test_non_matching_group_revokes_and_audits(
self, client, fake_redis, roles_repo, monkeypatch
):
monkeypatch.setattr(settings, "OIDC_ADMIN_GROUPS", "admins")
roles_repo.reconcile_oidc_admin.return_value = "revoked"
response = self._callback(client, fake_redis, id_token_claims(groups=["devs"]))
assert "#oidc_code=" in response.headers["Location"]
roles_repo.reconcile_oidc_admin.assert_called_once_with("oidc-user-1", False)
assert len(self._role_events("role_revoked")) == 1
def test_absent_groups_claim_does_not_revoke(
self, client, fake_redis, roles_repo, monkeypatch
):
# Admin mapping configured, but the IdP asserts NO groups claim (absent
# from both id_token and userinfo) — must skip, not revoke.
monkeypatch.setattr(settings, "OIDC_ADMIN_GROUPS", "admins")
response = self._callback(
client,
fake_redis,
id_token_claims(),
userinfo={"sub": "oidc-user-1"},
)
assert "#oidc_code=" in response.headers["Location"]
roles_repo.reconcile_oidc_admin.assert_not_called()
def test_reconcile_failure_does_not_block_login(
self, client, fake_redis, roles_repo, monkeypatch
):
monkeypatch.setattr(settings, "OIDC_ADMIN_GROUPS", "admins")
roles_repo.reconcile_oidc_admin.side_effect = RuntimeError("db down")
response = self._callback(client, fake_redis, id_token_claims(groups=["admins"]))
assert "#oidc_code=" in response.headers["Location"]
def test_admin_group_backfilled_from_userinfo_grants(
self, client, fake_redis, roles_repo, monkeypatch
):
# OIDC_ALLOWED_GROUPS unset but OIDC_ADMIN_GROUPS set, and the id_token
# omits groups: userinfo backfill (driven by the admin-groups clause in
# _effective_claims) must still surface the group and grant admin.
monkeypatch.setattr(settings, "OIDC_ADMIN_GROUPS", "admins")
roles_repo.reconcile_oidc_admin.return_value = "granted"
response = self._callback(
client,
fake_redis,
id_token_claims(), # no groups claim in the id_token
userinfo={"sub": "oidc-user-1", "groups": ["admins"]},
)
assert "#oidc_code=" in response.headers["Location"]
roles_repo.reconcile_oidc_admin.assert_called_once_with("oidc-user-1", True)
@pytest.mark.unit
class TestCallbackUserGate:
@pytest.fixture(autouse=True)
def _db(self, db_mocks):
self.db = db_mocks
def _callback(self, client, fake_redis):
state, nonce = _seed_state(client, fake_redis)
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = _mint_id_token_response(nonce)
return client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
def test_disabled_user_rejected(self, client, fake_redis):
self.db.users.get.return_value = {"user_id": "oidc-user-1", "active": False}
response = self._callback(client, fake_redis)
assert response.headers["Location"] == f"{FRONTEND_URL}/#oidc_error=account_disabled"
assert not any(key.startswith("oidc:handoff:") for key in fake_redis.store)
call = self.db.events.insert.call_args
assert call.args == ("oidc-user-1", "oidc_login_denied")
assert call.kwargs["metadata"] == {"reason": "account_disabled"}
def test_new_user_provisioned_and_login_audited(self, client, fake_redis):
response = self._callback(client, fake_redis)
assert "#oidc_code=" in response.headers["Location"]
# New user is provisioned with the email claim (for add-by-email).
self.db.users.upsert.assert_called_once_with(
"oidc-user-1", email="user@example.com"
)
call = self.db.events.insert.call_args
assert call.args == ("oidc-user-1", "oidc_login")
assert call.kwargs["metadata"] == {"email": "user@example.com", "groups": None}
assert call.kwargs["user_agent"]
def test_existing_active_user_not_upserted(self, client, fake_redis):
self.db.users.get.return_value = {"user_id": "oidc-user-1", "active": True}
response = self._callback(client, fake_redis)
assert "#oidc_code=" in response.headers["Location"]
self.db.users.upsert.assert_not_called()
# Existing user still gets a targeted email backfill (not a full upsert).
self.db.users.set_email.assert_called_once_with(
"oidc-user-1", "user@example.com"
)
def test_db_outage_does_not_block_login(self, client, fake_redis):
with patch(
"application.api.oidc.routes.db_session",
side_effect=RuntimeError("db down"),
):
response = self._callback(client, fake_redis)
assert "#oidc_code=" in response.headers["Location"]
def test_audit_insert_failure_does_not_block_login(self, client, fake_redis):
self.db.events.insert.side_effect = RuntimeError("insert failed")
response = self._callback(client, fake_redis)
assert "#oidc_code=" in response.headers["Location"]
def test_successful_login_does_not_touch_denylist(self, client, fake_redis):
# The denylist keys on a revocation timestamp and the minted session
# carries a newer iat, so a fresh login is allowed without clearing any
# entry — clearing would resurrect sessions revoked on other devices.
with patch("application.api.oidc.routes.denylist") as deny:
response = self._callback(client, fake_redis)
assert "#oidc_code=" in response.headers["Location"]
assert not deny.allow_user.called
assert not deny.allow_idp_sub.called
def test_denied_login_does_not_touch_denylist(self, client, fake_redis):
self.db.users.get.return_value = {"user_id": "oidc-user-1", "active": False}
with patch("application.api.oidc.routes.denylist") as deny:
response = self._callback(client, fake_redis)
assert "account_disabled" in response.headers["Location"]
assert not deny.allow_user.called
assert not deny.allow_idp_sub.called
@pytest.mark.unit
class TestSessionTokenMint:
@pytest.fixture(autouse=True)
def _db(self, db_mocks):
self.db = db_mocks
def _login_decoded(self, client, fake_redis, claims=None, **token_extra):
state, nonce = _seed_state(client, fake_redis)
claims = {**(claims or id_token_claims()), "nonce": nonce}
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = _signed_token_response(claims, **token_extra)
response = client.get(f"/api/auth/oidc/callback?code=abc&state={state}")
handoff = response.headers["Location"].split("#oidc_code=", 1)[1]
session_token = fake_redis.store[f"oidc:handoff:{handoff}"].decode("utf-8")
return jose_jwt.decode(session_token, JWT_SECRET, algorithms=["HS256"])
def test_contains_jti_and_oidc_sub(self, client, fake_redis):
decoded = self._login_decoded(client, fake_redis)
assert len(decoded["jti"]) == 36
assert decoded["oidc_sub"] == "oidc-user-1"
assert "oidc_sid" not in decoded
def test_oidc_sid_included_when_id_token_has_sid(self, client, fake_redis):
decoded = self._login_decoded(client, fake_redis, claims=id_token_claims(sid="sess-42"))
assert decoded["oidc_sid"] == "sess-42"
def test_picture_claim_passthrough(self, client, fake_redis):
decoded = self._login_decoded(
client, fake_redis, claims=id_token_claims(picture="https://img.test/me.png")
)
assert decoded["picture"] == "https://img.test/me.png"
def test_overlong_picture_dropped(self, client, fake_redis):
decoded = self._login_decoded(
client, fake_redis, claims=id_token_claims(picture="https://img.test/" + "x" * 2048)
)
assert "picture" not in decoded
def test_refresh_token_stored_under_jti(self, client, fake_redis):
decoded = self._login_decoded(client, fake_redis, refresh_token="rt-1")
assert fake_redis.store[f"oidc:refresh:{decoded['jti']}"] == b"rt-1"
def test_no_refresh_key_when_idp_sends_none(self, client, fake_redis):
self._login_decoded(client, fake_redis)
assert not any(key.startswith("oidc:refresh:") for key in fake_redis.store)
@pytest.mark.unit
class TestBackchannelLogoutRoute:
URL = "/api/auth/oidc/backchannel-logout"
@pytest.fixture(autouse=True)
def _seams(self, db_mocks):
self.db = db_mocks
with patch("application.api.oidc.routes.denylist") as deny:
self.denylist = deny
yield
def _post(self, client, claims=None, **kwargs):
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
if claims is not None:
kwargs.setdefault("data", {"logout_token": sign_id_token(claims)})
return client.post(self.URL, **kwargs)
def test_sub_logout_denylists_sub(self, client, fake_redis):
response = self._post(client, logout_token_claims())
assert response.status_code == 200
assert response.headers["Cache-Control"] == "no-store"
self.denylist.deny_idp_sub.assert_called_once_with("oidc-user-1")
self.denylist.deny_sid.assert_not_called()
call = self.db.events.insert.call_args
assert call.args == ("oidc-user-1", "backchannel_logout")
assert call.kwargs["metadata"] is None
def test_sid_only_logout_denylists_sid(self, client, fake_redis):
claims = logout_token_claims(sid="sess-7", jti="bcl-jti-2")
del claims["sub"]
response = self._post(client, claims)
assert response.status_code == 200
self.denylist.deny_sid.assert_called_once_with("sess-7")
self.denylist.deny_idp_sub.assert_not_called()
call = self.db.events.insert.call_args
assert call.args == ("sid:sess-7", "backchannel_logout")
assert call.kwargs["metadata"] == {"sid": "sess-7"}
def test_sub_and_sid_denylists_both(self, client, fake_redis):
response = self._post(client, logout_token_claims(sid="sess-8", jti="bcl-jti-3"))
assert response.status_code == 200
self.denylist.deny_idp_sub.assert_called_once_with("oidc-user-1")
self.denylist.deny_sid.assert_called_once_with("sess-8")
def test_json_body_accepted(self, client, fake_redis):
token = sign_id_token(logout_token_claims(jti="bcl-jti-4"))
response = self._post(client, json={"logout_token": token})
assert response.status_code == 200
def test_missing_token_rejected(self, client, fake_redis):
response = client.post(self.URL)
assert response.status_code == 400
assert response.headers["Cache-Control"] == "no-store"
def test_missing_events_rejected(self, client, fake_redis):
claims = logout_token_claims()
del claims["events"]
response = self._post(client, claims)
assert response.status_code == 400
assert response.get_json() == {"error": "invalid_logout_token"}
assert response.headers["Cache-Control"] == "no-store"
self.denylist.deny_idp_sub.assert_not_called()
def test_nonce_present_rejected(self, client, fake_redis):
response = self._post(client, logout_token_claims(nonce="n-1"))
assert response.status_code == 400
self.denylist.deny_idp_sub.assert_not_called()
def test_bad_signature_rejected(self, client, fake_redis):
rogue_pem = _generate_rsa_pem()
token = sign_id_token(logout_token_claims(), key=rogue_pem)
response = self._post(client, data={"logout_token": token})
assert response.status_code == 400
self.denylist.deny_idp_sub.assert_not_called()
def test_jti_replay_rejected(self, client, fake_redis):
token = sign_id_token(logout_token_claims(jti="bcl-jti-replay"))
first = self._post(client, data={"logout_token": token})
replay = self._post(client, data={"logout_token": token})
assert first.status_code == 200
assert replay.status_code == 400
assert self.denylist.deny_idp_sub.call_count == 1
def test_missing_jti_rejected(self, client, fake_redis):
# jti is required (Back-Channel Logout 1.0) and underpins replay
# protection — a token without one must not be accepted.
claims = logout_token_claims()
del claims["jti"]
response = self._post(client, claims)
assert response.status_code == 400
self.denylist.deny_idp_sub.assert_not_called()
def test_stale_iat_rejected(self, client, fake_redis):
# Beyond the jti replay-cache window the token can no longer be deduped,
# so an old iat is rejected outright (bounds replay after eviction).
claims = logout_token_claims(jti="bcl-stale", iat=int(time.time()) - 10000)
response = self._post(client, claims)
assert response.status_code == 400
self.denylist.deny_idp_sub.assert_not_called()
def test_revocation_write_failure_returns_502(self, client, fake_redis):
# Redis down: report failure so the IdP retries instead of recording a
# logout that never revoked anything.
self.denylist.deny_idp_sub.return_value = False
response = self._post(client, logout_token_claims(jti="bcl-502"))
assert response.status_code == 502
assert response.get_json() == {"error": "revocation_unavailable"}
@pytest.mark.unit
class TestRefreshRoute:
URL = "/api/auth/oidc/refresh"
@pytest.fixture(autouse=True)
def _seams(self, db_mocks):
self.db = db_mocks
with patch("application.api.oidc.routes.denylist") as deny:
deny.is_denied.return_value = False
self.denylist = deny
yield
def _auth(self, token):
return {"Authorization": f"Bearer {token}"}
def _refresh(self, client, token, idp_response=None, idp_status=200):
with patch("application.api.oidc.provider.requests") as mock_requests:
mock_requests.get.side_effect = make_fake_get([PUBLIC_JWK])
mock_requests.post.return_value = Mock(
status_code=idp_status,
text="error",
json=Mock(return_value=idp_response or {}),
)
response = client.post(self.URL, headers=self._auth(token))
return response, mock_requests
def test_happy_path_rotates_refresh_token(self, client, fake_redis):
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
id_claims = id_token_claims(sid="sess-1")
del id_claims["nonce"]
response, mock_requests = self._refresh(
client,
token,
idp_response={
"access_token": "at-2",
"refresh_token": "rt-new",
"id_token": sign_id_token(id_claims),
},
)
assert response.status_code == 200
decoded = jose_jwt.decode(
response.get_json()["token"], JWT_SECRET, algorithms=["HS256"]
)
assert decoded["sub"] == "oidc-user-1"
assert decoded["jti"] != "jti-1"
assert decoded["oidc_sub"] == "oidc-user-1"
assert decoded["oidc_sid"] == "sess-1"
assert "oidc:refresh:jti-1" not in fake_redis.store
assert fake_redis.store[f"oidc:refresh:{decoded['jti']}"] == b"rt-new"
sent = mock_requests.post.call_args.kwargs["data"]
assert sent["grant_type"] == "refresh_token"
assert sent["refresh_token"] == "rt-old"
call = self.db.events.insert.call_args
assert call.args == ("oidc-user-1", "oidc_refresh")
def test_identity_reused_when_no_id_token_returned(self, client, fake_redis):
token = make_session_token(
email="user@example.com", name="OIDC User", oidc_sid="sess-2"
)
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
response, _ = self._refresh(client, token, idp_response={"access_token": "at-2"})
assert response.status_code == 200
decoded = jose_jwt.decode(
response.get_json()["token"], JWT_SECRET, algorithms=["HS256"]
)
assert decoded["sub"] == "oidc-user-1"
assert decoded["email"] == "user@example.com"
assert decoded["oidc_sid"] == "sess-2"
# IdP kept the old refresh token, so it is re-stored under the new jti.
assert fake_redis.store[f"oidc:refresh:{decoded['jti']}"] == b"rt-old"
def test_refresh_reconciles_oidc_admin_with_fresh_claims(
self, client, fake_redis, monkeypatch
):
monkeypatch.setattr(settings, "OIDC_ADMIN_GROUPS", "admins")
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
id_claims = id_token_claims(groups=["admins"])
del id_claims["nonce"]
roles_repo = Mock()
roles_repo.reconcile_oidc_admin.return_value = "granted"
with patch(
"application.api.oidc.routes.UserRolesRepository", return_value=roles_repo
):
response, _ = self._refresh(
client,
token,
idp_response={
"access_token": "at-2",
"refresh_token": "rt-new",
"id_token": sign_id_token(id_claims),
},
)
assert response.status_code == 200
roles_repo.reconcile_oidc_admin.assert_called_once_with("oidc-user-1", True)
def test_refresh_absent_groups_claim_does_not_revoke(
self, client, fake_redis, monkeypatch
):
# Same absent-claim guard as the login path: a refresh id_token carrying
# no groups claim (and no userinfo backfill) must NOT revoke admin.
monkeypatch.setattr(settings, "OIDC_ADMIN_GROUPS", "admins")
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
id_claims = id_token_claims() # no groups claim
del id_claims["nonce"]
roles_repo = Mock()
with patch(
"application.api.oidc.routes.UserRolesRepository", return_value=roles_repo
):
response, _ = self._refresh(
client,
token,
idp_response={
"access_token": "at-2",
"refresh_token": "rt-new",
"id_token": sign_id_token(id_claims),
},
)
assert response.status_code == 200
roles_repo.reconcile_oidc_admin.assert_not_called()
def test_refresh_denied_when_groups_no_longer_allowed(
self, client, fake_redis, monkeypatch
):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "admins")
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
id_claims = id_token_claims(groups=["users"])
del id_claims["nonce"]
response, _ = self._refresh(
client,
token,
idp_response={
"access_token": "at-2",
"refresh_token": "rt-new",
"id_token": sign_id_token(id_claims),
},
)
assert response.status_code == 401
assert response.get_json() == {"error": "not_authorized"}
# Denial is audited and no renewed session/refresh token exists.
call = self.db.events.insert.call_args
assert call.args[1] == "oidc_login_denied"
assert call.kwargs["metadata"]["via"] == "refresh"
assert not any(k.startswith("oidc:refresh:") for k in fake_redis.store)
def test_refresh_allowed_when_group_still_matches(
self, client, fake_redis, monkeypatch
):
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "users, admins")
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
id_claims = id_token_claims(groups=["users"])
del id_claims["nonce"]
response, _ = self._refresh(
client,
token,
idp_response={
"access_token": "at-2",
"refresh_token": "rt-new",
"id_token": sign_id_token(id_claims),
},
)
assert response.status_code == 200
def test_refresh_without_id_token_skips_group_check(
self, client, fake_redis, monkeypatch
):
# No fresh claims to evaluate — membership was checked at login and
# will be re-checked the next time the IdP returns an id_token.
monkeypatch.setattr(settings, "OIDC_ALLOWED_GROUPS", "admins")
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
response, _ = self._refresh(client, token, idp_response={"access_token": "at-2"})
assert response.status_code == 200
def test_expired_session_rejected(self, client, fake_redis):
token = make_session_token(exp=int(time.time()) - 120)
response = client.post(self.URL, headers=self._auth(token))
assert response.status_code == 401
assert response.get_json() == {"error": "token_expired"}
def test_garbage_token_rejected(self, client, fake_redis):
response = client.post(self.URL, headers=self._auth("not-a-jwt"))
assert response.status_code == 401
assert response.get_json() == {"error": "invalid_token"}
def test_missing_authorization_rejected(self, client, fake_redis):
response = client.post(self.URL)
assert response.status_code == 401
assert response.get_json() == {"error": "invalid_token"}
def test_session_without_jti_rejected(self, client, fake_redis):
token = make_session_token(jti=None)
response = client.post(self.URL, headers=self._auth(token))
assert response.status_code == 401
assert response.get_json() == {"error": "invalid_token"}
def test_denylisted_session_rejected(self, client, fake_redis):
self.denylist.is_denied.return_value = True
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
response = client.post(self.URL, headers=self._auth(token))
assert response.status_code == 401
assert response.get_json() == {"error": "token_revoked"}
assert "oidc:refresh:jti-1" in fake_redis.store
def test_revocation_during_grant_blocks_renewal(self, client, fake_redis):
# is_denied passes the pre-grant check, but a back-channel logout lands
# during the IdP grant; the pre-mint re-check (anchored on the old iat)
# must catch it instead of minting a session that escapes the watermark.
self.denylist.is_denied.side_effect = [False, True]
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
id_claims = id_token_claims(sid="sess-1")
del id_claims["nonce"]
response, _ = self._refresh(
client,
token,
idp_response={
"access_token": "at-2",
"refresh_token": "rt-new",
"id_token": sign_id_token(id_claims),
},
)
assert response.status_code == 401
assert response.get_json() == {"error": "token_revoked"}
def test_disabled_user_rejected(self, client, fake_redis):
self.db.users.get.return_value = {"user_id": "oidc-user-1", "active": False}
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
response = client.post(self.URL, headers=self._auth(token))
assert response.status_code == 401
assert response.get_json() == {"error": "account_disabled"}
def test_no_stored_refresh_token_404(self, client, fake_redis):
token = make_session_token()
response = client.post(self.URL, headers=self._auth(token))
assert response.status_code == 404
assert response.get_json() == {"error": "no_refresh_token"}
def test_idp_refresh_failure_401(self, client, fake_redis):
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
response, _ = self._refresh(client, token, idp_status=400)
assert response.status_code == 401
assert response.get_json() == {"error": "refresh_failed"}
def test_503_when_redis_unavailable(self, client):
token = make_session_token()
with patch("application.api.oidc.routes.get_redis_instance", return_value=None):
response = client.post(self.URL, headers=self._auth(token))
assert response.status_code == 503
assert response.get_json() == {"error": "redis_unavailable"}
def test_transient_idp_failure_503_and_token_restored(self, client, fake_redis):
# 5xx from the IdP is transient: keep the (still-valid) refresh token and
# tell the client to retry rather than dropping a live session.
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
response, _ = self._refresh(client, token, idp_status=503)
assert response.status_code == 503
assert response.get_json() == {"error": "refresh_unavailable"}
assert fake_redis.store["oidc:refresh:jti-1"] == b"rt-old"
def test_remapped_disabled_identity_rejected(self, client, fake_redis):
# The refreshed id_token maps to a different (disabled) user id than the
# session sub — the post-grant gate must refuse it.
token = make_session_token()
fake_redis.store["oidc:refresh:jti-1"] = b"rt-old"
self.db.users.get.side_effect = (
lambda uid: {"user_id": uid, "active": False} if uid == "oidc-user-2" else None
)
id_claims = id_token_claims(sub="oidc-user-2")
del id_claims["nonce"]
response, _ = self._refresh(
client,
token,
idp_response={
"access_token": "at-2",
"refresh_token": "rt-new",
"id_token": sign_id_token(id_claims),
},
)
assert response.status_code == 401
assert response.get_json() == {"error": "account_disabled"}