feat: oidc renewal, groups, logout, scim

This commit is contained in:
Alex committed 2026-06-10 00:57:18 +01:00
1 parent 8bc777a428
commit 054f0f1b8b
29 files changed
+3392 -155

No files matched your search

+834 -22
View File
@@ -4,6 +4,8 @@ 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
@@ -20,12 +22,14 @@ 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",
}
@@ -67,6 +71,34 @@ def id_token_claims(**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."""
@@ -83,16 +115,20 @@ class FakeRedis:
return self.store.pop(key, None)
def make_fake_get(jwks_keys):
"""requests.get stub serving discovery + JWKS; ``jwks_keys`` is mutable."""
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):
def fake_get(url, timeout=None, headers=None):
resp = Mock()
resp.status_code = 200
if "openid-configuration" in url:
resp.json.return_value = dict(DISCOVERY)
elif url == DISCOVERY["jwks_uri"]:
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
@@ -111,6 +147,9 @@ def oidc_settings(monkeypatch):
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)
@@ -214,6 +253,29 @@ class TestValidateIdToken:
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
@@ -274,12 +336,13 @@ class TestExchangeCode:
assert sent["client_id"] == CLIENT_ID
assert "client_secret" not in sent
def test_includes_client_secret_when_configured(self, monkeypatch):
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])
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"})
)
@@ -297,6 +360,195 @@ class TestExchangeCode:
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")))
@pytest.fixture(scope="module")
def app():
with patch("application.app.handle_auth", return_value={"sub": "test_user"}):
@@ -318,17 +570,49 @@ def fake_redis():
yield redis
def _mint_id_token_response(stored_nonce):
return Mock(
status_code=200,
json=Mock(
return_value={
"access_token": "at",
"token_type": "Bearer",
"id_token": sign_id_token(id_token_claims(nonce=stored_nonce)),
}
),
@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: {"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(fake_redis, state="state-1", nonce="nonce-1"):
fake_redis.set(
f"oidc:state:{state}",
json.dumps({"code_verifier": "verifier-1", "nonce": nonce}),
)
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
@@ -376,12 +660,12 @@ class TestLoginRoute:
@pytest.mark.unit
class TestCallbackRoute:
@pytest.fixture(autouse=True)
def _db(self, db_mocks):
self.db = db_mocks
def _seed_state(self, fake_redis, state="state-1", nonce="nonce-1"):
fake_redis.set(
f"oidc:state:{state}",
json.dumps({"code_verifier": "verifier-1", "nonce": nonce}),
)
return state, nonce
return _seed_state(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(fake_redis)
@@ -516,3 +800,531 @@ class TestLogoutRoute:
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(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 TestCallbackUserGate:
@pytest.fixture(autouse=True)
def _db(self, db_mocks):
self.db = db_mocks
def _callback(self, client, fake_redis):
state, nonce = _seed_state(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"]
self.db.users.upsert.assert_called_once_with("oidc-user-1")
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()
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_clears_session_revocations(self, client, fake_redis):
# A back-channel logout denylists the IdP sub; a fresh IdP-blessed
# login must lift session-level revocations or re-login would stay
# blocked until the denylist TTL expires.
with patch("application.api.oidc.routes.denylist") as deny:
response = self._callback(client, fake_redis)
assert "#oidc_code=" in response.headers["Location"]
deny.allow_user.assert_called_once_with("oidc-user-1")
deny.allow_idp_sub.assert_called_once_with("oidc-user-1")
def test_denied_login_does_not_clear_revocations(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"]
deny.allow_user.assert_not_called()
deny.allow_idp_sub.assert_not_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(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
@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_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_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"}