mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 08:16:38 +00:00
feat: oidc renewal, groups, logout, scim
This commit is contained in:
1 parent
8bc777a428
commit
054f0f1b8b
29 files changed
+3392
-155
No files matched your search
+834
-22
@@ -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"}
|
||||
Reference in new issue
Block a user