"""Route-level and end-to-end coverage for OIDC group sync (#3107). The original suite (test_oidc_group_sync.py) covers the helpers, the service and the schema. This file covers the three gaps from PR #3122 review: 1. The public provider list must not leak group sync config — an anonymous visitor who can reach the login page must not learn which IdP group name maps to which Bambuddy group (the review's blocker). 2. The 422s on create/update when a mapping value names no existing group. 3. The env path: BAMBUDDY_OIDC_GROUP_CLAIM / BAMBUDDY_OIDC_GROUP_MAPPING applied at startup, refused on unknown group names, refused on bad JSON. 4. oidc_callback end to end with a mapping configured: the auto-created user lands in the mapped group, and a second login applies revocation. """ from __future__ import annotations import logging import os import secrets import time from datetime import datetime, timedelta, timezone from unittest.mock import patch import jwt as pyjwt import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.oidc_env import apply_env_oidc_provider from backend.app.models.group import Group from backend.app.models.oidc_provider import OIDCProvider from backend.app.models.user import User from backend.tests.integration.test_mfa_api import ( _auth_header, _make_test_rsa_key, _setup_and_login, ) PROVIDER_BASE = { "name": "GroupSyncIdP", "issuer_url": "https://gs.test.example.com", "client_id": "gs-client", "client_secret": "gs-secret", "scopes": "openid email profile", "is_enabled": True, "auto_create_users": True, } async def _get_or_make_group(db: AsyncSession, name: str): """The conftest seeds the system groups, so anything named like one of those is fetched rather than created (its UNIQUE(name) would reject us).""" from sqlalchemy import select as sa_select row = (await db.execute(sa_select(Group).where(Group.name == name))).scalar_one_or_none() if row is not None: return row group = Group(name=name, description=f"Test group {name}") db.add(group) await db.commit() await db.refresh(group) return group _ADMIN_TOKEN: dict[str, str] = {} async def _admin_token(async_client: AsyncClient) -> str: """One admin per test database: /auth/setup enables auth and mints THE admin account, so repeated _setup_and_login calls with different usernames 401 (the second setup is refused). Cache the token per client. """ key = str(id(async_client)) if key not in _ADMIN_TOKEN: _ADMIN_TOKEN[key] = await _setup_and_login(async_client, "gsadmin", "gsadmin1") return _ADMIN_TOKEN[key] async def _create_provider(async_client: AsyncClient, **overrides): token = await _admin_token(async_client) body = {**PROVIDER_BASE, **overrides} resp = await async_client.post( "/api/v1/auth/oidc/providers", json=body, headers=_auth_header(token), ) return resp class TestPublicListDoesNotLeakGroupSyncConfig: """The review's blocker: GET /oidc/providers is public and must serve the slim shape only. group_claim / group_mapping tell an attacker which IdP group to aim for (possibly Administrators).""" @pytest.mark.asyncio @pytest.mark.integration async def test_public_list_omits_group_fields(self, async_client: AsyncSession, db_session: AsyncSession): await _get_or_make_group(db_session, "Operators") resp = await _create_provider( async_client, group_claim="roles", group_mapping={"idp-ops": "Operators"}, ) assert resp.status_code == 201, resp.text public = await async_client.get("/api/v1/auth/oidc/providers") assert public.status_code == 200 entry = next(p for p in public.json() if p["name"] == "GroupSyncIdP") assert set(entry.keys()) == {"id", "name", "has_icon", "is_autologin"}, ( f"public provider response must stay slim, got keys: {sorted(entry.keys())}" ) assert "group_claim" not in entry assert "group_mapping" not in entry @pytest.mark.asyncio @pytest.mark.integration async def test_admin_list_still_carries_group_fields(self, async_client: AsyncSession, db_session: AsyncSession): await _get_or_make_group(db_session, "Operators") resp = await _create_provider( async_client, group_claim="roles", group_mapping={"idp-ops": "Operators"}, ) assert resp.status_code == 201 token = await _admin_token(async_client) admin_list = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token)) entry = next(p for p in admin_list.json() if p["name"] == "GroupSyncIdP") assert entry["group_claim"] == "roles" assert entry["group_mapping"] == {"idp-ops": "Operators"} class TestMappingGroupValidation: """The 422s: mapping values must name existing groups, on create and update.""" @pytest.mark.asyncio @pytest.mark.integration async def test_create_rejects_unknown_group(self, async_client: AsyncSession): resp = await _create_provider(async_client, group_mapping={"idp-ops": "NoSuchGroup"}) assert resp.status_code == 422, resp.text assert "NoSuchGroup" in resp.text @pytest.mark.asyncio @pytest.mark.integration async def test_create_accepts_existing_group(self, async_client: AsyncSession, db_session: AsyncSession): await _get_or_make_group(db_session, "Operators") resp = await _create_provider(async_client, group_mapping={"idp-ops": "Operators"}) assert resp.status_code == 201 assert resp.json()["group_mapping"] == {"idp-ops": "Operators"} @pytest.mark.asyncio @pytest.mark.integration async def test_create_rejects_case_variant_of_existing_group( self, async_client: AsyncSession, db_session: AsyncSession ): """Exact match only (review point 8): a case variant would pass the check and then silently never resolve in the sync's exact lookup.""" await _get_or_make_group(db_session, "Operators") resp = await _create_provider(async_client, group_mapping={"idp-ops": "operators"}) assert resp.status_code == 422 @pytest.mark.asyncio @pytest.mark.integration async def test_update_rejects_unknown_group(self, async_client: AsyncSession, db_session: AsyncSession): created = await _create_provider(async_client) assert created.status_code == 201 provider_id = created.json()["id"] token = await _admin_token(async_client) resp = await async_client.put( f"/api/v1/auth/oidc/providers/{provider_id}", json={"group_mapping": {"idp-ops": "NoSuchGroup"}}, headers=_auth_header(token), ) assert resp.status_code == 422 @pytest.mark.asyncio @pytest.mark.integration async def test_update_clears_mapping_with_empty_object(self, async_client: AsyncSession, db_session: AsyncSession): await _get_or_make_group(db_session, "Operators") created = await _create_provider(async_client, group_mapping={"idp-ops": "Operators"}) provider_id = created.json()["id"] token = await _admin_token(async_client) resp = await async_client.put( f"/api/v1/auth/oidc/providers/{provider_id}", json={"group_mapping": {}}, headers=_auth_header(token), ) assert resp.status_code == 200 assert resp.json()["group_mapping"] == {} @pytest.mark.asyncio @pytest.mark.integration async def test_namespaced_group_claim_accepted(self, async_client: AsyncSession): """Auth0-style namespaced claim names must be configurable (review note).""" resp = await _create_provider(async_client, group_claim="app/roles") assert resp.status_code == 201, resp.text assert resp.json()["group_claim"] == "app/roles" class TestEnvGroupMapping: """The env path: the code most likely to strand an operator at boot.""" @pytest.mark.asyncio @pytest.mark.integration async def test_env_mapping_applied(self, db_session: AsyncSession, monkeypatch): await _get_or_make_group(db_session, "Operators") await db_session.commit() monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP") monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env.test.example.com") monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client") monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret") monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_CLAIM", "roles") monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "Operators"}') for key in ("BAMBUDDY_OIDC_DEFAULT_GROUP", "BAMBUDDY_OIDC_SCOPES", "BAMBUDDY_OIDC_ENABLED"): monkeypatch.delenv(key, raising=False) await apply_env_oidc_provider(db_session) row = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP"))).scalar_one() assert row.group_claim == "roles" assert row.group_mapping == {"idp-ops": "Operators"} @pytest.mark.asyncio @pytest.mark.integration async def test_env_mapping_unknown_group_refuses_provider(self, db_session: AsyncSession, monkeypatch, caplog): """Unknown names must refuse the whole config (matching DEFAULT_GROUP): no provider row may be created carrying a mapping that never resolves.""" monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-Refused") monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env2.test.example.com") monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client") monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret") monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "NoSuchGroup"}') for key in ( "BAMBUDDY_OIDC_DEFAULT_GROUP", "BAMBUDDY_OIDC_SCOPES", "BAMBUDDY_OIDC_ENABLED", "BAMBUDDY_OIDC_GROUP_CLAIM", ): monkeypatch.delenv(key, raising=False) await apply_env_oidc_provider(db_session) row = ( await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-Refused")) ).scalar_one_or_none() assert row is None, "provider must not be created when a mapping value matches no group" @pytest.mark.asyncio @pytest.mark.integration async def test_env_mapping_bad_json_refuses_provider(self, db_session: AsyncSession, monkeypatch): monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-BadJson") monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env3.test.example.com") monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client") monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret") monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", "{not json") for key in ( "BAMBUDDY_OIDC_DEFAULT_GROUP", "BAMBUDDY_OIDC_SCOPES", "BAMBUDDY_OIDC_ENABLED", "BAMBUDDY_OIDC_GROUP_CLAIM", ): monkeypatch.delenv(key, raising=False) await apply_env_oidc_provider(db_session) # must not raise row = ( await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-BadJson")) ).scalar_one_or_none() assert row is None @pytest.mark.asyncio @pytest.mark.integration @pytest.mark.parametrize( "mapping", ['{"staff": null}', '{"staff": 5}', '{"Admins": "Viewers", "admins": "Viewers"}'] ) async def test_env_mapping_bad_value_is_named_in_the_log( self, db_session: AsyncSession, monkeypatch, caplog, mapping ): """A malformed value is refused by name, not as a bare TypeError from the group lookup it would otherwise reach.""" monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-BadValue") monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env5.test.example.com") monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client") monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret") monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", mapping) for key in ( "BAMBUDDY_OIDC_DEFAULT_GROUP", "BAMBUDDY_OIDC_SCOPES", "BAMBUDDY_OIDC_ENABLED", "BAMBUDDY_OIDC_GROUP_CLAIM", ): monkeypatch.delenv(key, raising=False) with caplog.at_level(logging.ERROR, logger="backend.app.core.oidc_env"): await apply_env_oidc_provider(db_session) row = ( await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-BadValue")) ).scalar_one_or_none() assert row is None assert "BAMBUDDY_OIDC_GROUP_MAPPING is invalid" in caplog.text assert "TypeError" not in caplog.text @pytest.mark.asyncio @pytest.mark.integration async def test_env_mapping_removed_clears_it(self, db_session: AsyncSession, monkeypatch): """The environment is the whole truth: dropping the variable clears the mapping on the next boot.""" await _get_or_make_group(db_session, "Operators") await db_session.commit() env = { "BAMBUDDY_OIDC_NAME": "EnvIdP-Clear", "BAMBUDDY_OIDC_ISSUER_URL": "https://env4.test.example.com", "BAMBUDDY_OIDC_CLIENT_ID": "env-client", "BAMBUDDY_OIDC_CLIENT_SECRET": "env-secret", } for key in ( "BAMBUDDY_OIDC_DEFAULT_GROUP", "BAMBUDDY_OIDC_SCOPES", "BAMBUDDY_OIDC_ENABLED", "BAMBUDDY_OIDC_GROUP_CLAIM", ): monkeypatch.delenv(key, raising=False) for k, v in env.items(): monkeypatch.setenv(k, v) monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "Operators"}') await apply_env_oidc_provider(db_session) row = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-Clear"))).scalar_one() assert row.group_mapping == {"idp-ops": "Operators"} monkeypatch.delenv("BAMBUDDY_OIDC_GROUP_MAPPING") await apply_env_oidc_provider(db_session) await db_session.refresh(row) assert row.group_mapping == {} def _mock_oidc_httpx(discovery_doc, token_response, jwks_data): """An httpx.AsyncClient stand-in for oidc_callback: serves the discovery document, the JWKS payload and the token response, nothing else.""" class _MockResp: def __init__(self, data): self._data = data self.status_code = 200 self.is_success = True self.text = str(data) def json(self): return self._data def raise_for_status(self): pass class _MockHttpxClient: def __init__(self, *args, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, *args): pass async def get(self, url, **kwargs): if "jwks" in url: return _MockResp(jwks_data) return _MockResp(discovery_doc) async def post(self, url, **kwargs): return _MockResp(token_response) return _MockHttpxClient class TestCallbackAppliesMappingEndToEnd: """oidc_callback with a mapping configured: creation grants the mapped group, a later login applies revocation, and a manual group survives.""" @staticmethod def _id_token(private_pem, issuer, client_id, nonce, groups, sub, email): now = int(time.time()) return pyjwt.encode( { "sub": sub, "iss": issuer, "aud": client_id, "nonce": nonce, "email": email, "email_verified": True, "groups": groups, "iat": now, "exp": now + 300, }, private_pem, algorithm="RS256", headers={"kid": "test-kid-1"}, ) async def _run_callback(self, async_client, db_session, provider_id, id_token, nonce, jwks, issuer): from backend.app.models.auth_ephemeral import AuthEphemeralToken state = secrets.token_urlsafe(32) db_session.add( AuthEphemeralToken( token=state, token_type="oidc_state", provider_id=provider_id, nonce=nonce, code_verifier=secrets.token_urlsafe(48), expires_at=datetime.now(timezone.utc) + timedelta(minutes=5), ) ) await db_session.commit() discovery = { "issuer": issuer, "authorization_endpoint": f"{issuer}/auth", "token_endpoint": f"{issuer}/token", "jwks_uri": f"{issuer}/.well-known/jwks.json", } token_response = {"access_token": "mock", "token_type": "Bearer", "id_token": id_token} client_cls = _mock_oidc_httpx(discovery, token_response, jwks) with patch("backend.app.api.routes.mfa.httpx.AsyncClient", client_cls): resp = await async_client.get( f"/api/v1/auth/oidc/callback?code=x&state={state}", follow_redirects=False, ) return resp @pytest.mark.asyncio @pytest.mark.integration async def test_callback_grants_mapped_group_on_creation(self, async_client: AsyncClient, db_session: AsyncSession): operators = await _get_or_make_group(db_session, "Operators") await _get_or_make_group(db_session, "ManualGroup") private_pem, jwks = _make_test_rsa_key() issuer = "https://e2e-gs.test.example.com" nonce = secrets.token_urlsafe(16) resp = await _create_provider( async_client, name="E2E-GroupSync-IdP", issuer_url=issuer, client_id="gs-e2e-client", client_secret="sec", group_claim="groups", group_mapping={"idp-ops": "Operators"}, ) assert resp.status_code == 201, resp.text provider_id = resp.json()["id"] id_token = self._id_token( private_pem, issuer, "gs-e2e-client", nonce, ["idp-ops"], "gs-sub-1", "gse2e@example.com" ) callback = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer) assert callback.status_code == 302, callback.text # 302 alone is not enough — the error path also 302s (review #1): # assert the exchange token, i.e. the login actually succeeded. assert "oidc_token=" in callback.headers.get("location", "") user = (await db_session.execute(select(User).where(User.email == "gse2e@example.com"))).scalar_one() group_ids = {g.id for g in user.groups} assert operators.id in group_ids, "auto-created user must land in the mapped group" @pytest.mark.asyncio @pytest.mark.integration async def test_second_login_applies_revocation_and_keeps_manual( self, async_client: AsyncClient, db_session: AsyncSession ): """Login 1 grants Operators via the mapping. An admin then adds ManualGroup by hand and the IdP revokes idp-ops. Login 2 must remove Operators and keep ManualGroup — the #1292 contract, end to end.""" operators = await _get_or_make_group(db_session, "Operators2") manual = await _get_or_make_group(db_session, "ManualGroup2") private_pem, jwks = _make_test_rsa_key() issuer = "https://e2e-gs2.test.example.com" client_id = "gs-e2e-client-2" nonce = secrets.token_urlsafe(16) resp = await _create_provider( async_client, name="E2E-GroupSync-IdP-2", issuer_url=issuer, client_id=client_id, client_secret="sec", group_claim="groups", group_mapping={"idp-ops": "Operators2"}, ) assert resp.status_code == 201, resp.text provider_id = resp.json()["id"] # Login 1: IdP says idp-ops -> Operators2 granted at creation. id_token = self._id_token(private_pem, issuer, client_id, nonce, ["idp-ops"], "gs-sub-2", "gse2e2@example.com") cb = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer) assert cb.status_code == 302 assert "oidc_token=" in cb.headers.get("location", "") user = (await db_session.execute(select(User).where(User.email == "gse2e2@example.com"))).scalar_one() # Creation assigns the default group (Viewers) per the existing # auto-create path; the sync adds the mapped group on top. Viewers is # NOT in the mapping, so it is a creation-time assignment and must # survive login 2 alongside the manual group. viewers = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one() assert {g.id for g in user.groups} == {operators.id, viewers.id} # Admin assigns ManualGroup2 by hand; IdP revokes idp-ops. manual_group = (await db_session.execute(select(Group).where(Group.name == "ManualGroup2"))).scalar_one() user.groups = list(user.groups) + [manual_group] db_session.add(user) await db_session.commit() # Login 2: new nonce, no idp-ops in the claim. nonce2 = secrets.token_urlsafe(16) id_token2 = self._id_token(private_pem, issuer, client_id, nonce2, [], "gs-sub-2", "gse2e2@example.com") cb2 = await self._run_callback(async_client, db_session, provider_id, id_token2, nonce2, jwks, issuer) assert cb2.status_code == 302, cb2.text assert "oidc_token=" in cb2.headers.get("location", ""), ( "login 2 must succeed (and carry an exchange token), not error out" ) await db_session.refresh(user, attribute_names=["groups"]) assert {g.id for g in user.groups} == {manual.id, viewers.id}, ( "revoked mapped group must be removed; the creation-default group " "and the manual assignment must both survive" ) @pytest.mark.asyncio @pytest.mark.integration async def test_sync_failure_does_not_block_login( self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch ): """Review blocker #1: a mid-sync failure must not take the login down. sync_oidc_user_groups catches the exception and calls db.rollback(), which expires every loaded object in the session. If the callback then reads user.username for the exchange token, that lazy-load raises MissingGreenlet and the user lands on ?oidc_error=user_resolution_failed — a failed sync blocking the login after all. The post-sync db.refresh(user) (all attributes) is what prevents it; this test fails with user_resolution_failed if that refresh is narrowed again. Asserts the exchange token in the Location, which a 302 alone cannot distinguish from the error redirect. """ operators = await _get_or_make_group(db_session, "Operators") private_pem, jwks = _make_test_rsa_key() issuer = "https://e2e-gsfail.test.example.com" client_id = "gs-e2e-client-fail" nonce = secrets.token_urlsafe(16) resp = await _create_provider( async_client, name="E2E-GroupSync-Fail-IdP", issuer_url=issuer, client_id=client_id, client_secret="sec", group_claim="groups", group_mapping={"idp-ops": "Operators"}, ) assert resp.status_code == 201, resp.text provider_id = resp.json()["id"] id_token = self._id_token( private_pem, issuer, client_id, nonce, ["idp-ops"], "gs-sub-fail", "gsfaile@example.com" ) from backend.app.services import oidc_group_sync as sync_module async def _explode(*args, **kwargs): raise RuntimeError("simulated mid-sync failure") monkeypatch.setattr(sync_module, "resolve_oidc_group_mapping", _explode) # The callback imports the name lazily inside the function, so patch # the module attribute the callback resolves it from. import backend.app.api.routes.mfa as mfa_module # noqa: F401 (sanity: module importable) callback = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer) location = callback.headers.get("location", "") assert "oidc_error=" not in location, f"a failed sync must not turn into a login error: {location}" assert "oidc_token=" in location, "the login must succeed and issue an exchange token despite the sync failure" # The user exists, and kept the groups they had (none mapped). user = (await db_session.execute(select(User).where(User.email == "gsfaile@example.com"))).scalar_one() assert operators.id not in {g.id for g in user.groups}