test_oidc_group_sync_routes.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. """Route-level and end-to-end coverage for OIDC group sync (#3107).
  2. The original suite (test_oidc_group_sync.py) covers the helpers, the service
  3. and the schema. This file covers the three gaps from PR #3122 review:
  4. 1. The public provider list must not leak group sync config — an anonymous
  5. visitor who can reach the login page must not learn which IdP group name
  6. maps to which Bambuddy group (the review's blocker).
  7. 2. The 422s on create/update when a mapping value names no existing group.
  8. 3. The env path: BAMBUDDY_OIDC_GROUP_CLAIM / BAMBUDDY_OIDC_GROUP_MAPPING
  9. applied at startup, refused on unknown group names, refused on bad JSON.
  10. 4. oidc_callback end to end with a mapping configured: the auto-created
  11. user lands in the mapped group, and a second login applies revocation.
  12. """
  13. from __future__ import annotations
  14. import logging
  15. import os
  16. import secrets
  17. import time
  18. from datetime import datetime, timedelta, timezone
  19. from unittest.mock import patch
  20. import jwt as pyjwt
  21. import pytest
  22. from httpx import AsyncClient
  23. from sqlalchemy import select
  24. from sqlalchemy.ext.asyncio import AsyncSession
  25. from backend.app.core.oidc_env import apply_env_oidc_provider
  26. from backend.app.models.group import Group
  27. from backend.app.models.oidc_provider import OIDCProvider
  28. from backend.app.models.user import User
  29. from backend.tests.integration.test_mfa_api import (
  30. _auth_header,
  31. _make_test_rsa_key,
  32. _setup_and_login,
  33. )
  34. PROVIDER_BASE = {
  35. "name": "GroupSyncIdP",
  36. "issuer_url": "https://gs.test.example.com",
  37. "client_id": "gs-client",
  38. "client_secret": "gs-secret",
  39. "scopes": "openid email profile",
  40. "is_enabled": True,
  41. "auto_create_users": True,
  42. }
  43. async def _get_or_make_group(db: AsyncSession, name: str):
  44. """The conftest seeds the system groups, so anything named like one of
  45. those is fetched rather than created (its UNIQUE(name) would reject us)."""
  46. from sqlalchemy import select as sa_select
  47. row = (await db.execute(sa_select(Group).where(Group.name == name))).scalar_one_or_none()
  48. if row is not None:
  49. return row
  50. group = Group(name=name, description=f"Test group {name}")
  51. db.add(group)
  52. await db.commit()
  53. await db.refresh(group)
  54. return group
  55. _ADMIN_TOKEN: dict[str, str] = {}
  56. async def _admin_token(async_client: AsyncClient) -> str:
  57. """One admin per test database: /auth/setup enables auth and mints THE
  58. admin account, so repeated _setup_and_login calls with different
  59. usernames 401 (the second setup is refused). Cache the token per client.
  60. """
  61. key = str(id(async_client))
  62. if key not in _ADMIN_TOKEN:
  63. _ADMIN_TOKEN[key] = await _setup_and_login(async_client, "gsadmin", "gsadmin1")
  64. return _ADMIN_TOKEN[key]
  65. async def _create_provider(async_client: AsyncClient, **overrides):
  66. token = await _admin_token(async_client)
  67. body = {**PROVIDER_BASE, **overrides}
  68. resp = await async_client.post(
  69. "/api/v1/auth/oidc/providers",
  70. json=body,
  71. headers=_auth_header(token),
  72. )
  73. return resp
  74. class TestPublicListDoesNotLeakGroupSyncConfig:
  75. """The review's blocker: GET /oidc/providers is public and must serve the
  76. slim shape only. group_claim / group_mapping tell an attacker which IdP
  77. group to aim for (possibly Administrators)."""
  78. @pytest.mark.asyncio
  79. @pytest.mark.integration
  80. async def test_public_list_omits_group_fields(self, async_client: AsyncSession, db_session: AsyncSession):
  81. await _get_or_make_group(db_session, "Operators")
  82. resp = await _create_provider(
  83. async_client,
  84. group_claim="roles",
  85. group_mapping={"idp-ops": "Operators"},
  86. )
  87. assert resp.status_code == 201, resp.text
  88. public = await async_client.get("/api/v1/auth/oidc/providers")
  89. assert public.status_code == 200
  90. entry = next(p for p in public.json() if p["name"] == "GroupSyncIdP")
  91. assert set(entry.keys()) == {"id", "name", "has_icon", "is_autologin"}, (
  92. f"public provider response must stay slim, got keys: {sorted(entry.keys())}"
  93. )
  94. assert "group_claim" not in entry
  95. assert "group_mapping" not in entry
  96. @pytest.mark.asyncio
  97. @pytest.mark.integration
  98. async def test_admin_list_still_carries_group_fields(self, async_client: AsyncSession, db_session: AsyncSession):
  99. await _get_or_make_group(db_session, "Operators")
  100. resp = await _create_provider(
  101. async_client,
  102. group_claim="roles",
  103. group_mapping={"idp-ops": "Operators"},
  104. )
  105. assert resp.status_code == 201
  106. token = await _admin_token(async_client)
  107. admin_list = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
  108. entry = next(p for p in admin_list.json() if p["name"] == "GroupSyncIdP")
  109. assert entry["group_claim"] == "roles"
  110. assert entry["group_mapping"] == {"idp-ops": "Operators"}
  111. class TestMappingGroupValidation:
  112. """The 422s: mapping values must name existing groups, on create and update."""
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_create_rejects_unknown_group(self, async_client: AsyncSession):
  116. resp = await _create_provider(async_client, group_mapping={"idp-ops": "NoSuchGroup"})
  117. assert resp.status_code == 422, resp.text
  118. assert "NoSuchGroup" in resp.text
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_create_accepts_existing_group(self, async_client: AsyncSession, db_session: AsyncSession):
  122. await _get_or_make_group(db_session, "Operators")
  123. resp = await _create_provider(async_client, group_mapping={"idp-ops": "Operators"})
  124. assert resp.status_code == 201
  125. assert resp.json()["group_mapping"] == {"idp-ops": "Operators"}
  126. @pytest.mark.asyncio
  127. @pytest.mark.integration
  128. async def test_create_rejects_case_variant_of_existing_group(
  129. self, async_client: AsyncSession, db_session: AsyncSession
  130. ):
  131. """Exact match only (review point 8): a case variant would pass the
  132. check and then silently never resolve in the sync's exact lookup."""
  133. await _get_or_make_group(db_session, "Operators")
  134. resp = await _create_provider(async_client, group_mapping={"idp-ops": "operators"})
  135. assert resp.status_code == 422
  136. @pytest.mark.asyncio
  137. @pytest.mark.integration
  138. async def test_update_rejects_unknown_group(self, async_client: AsyncSession, db_session: AsyncSession):
  139. created = await _create_provider(async_client)
  140. assert created.status_code == 201
  141. provider_id = created.json()["id"]
  142. token = await _admin_token(async_client)
  143. resp = await async_client.put(
  144. f"/api/v1/auth/oidc/providers/{provider_id}",
  145. json={"group_mapping": {"idp-ops": "NoSuchGroup"}},
  146. headers=_auth_header(token),
  147. )
  148. assert resp.status_code == 422
  149. @pytest.mark.asyncio
  150. @pytest.mark.integration
  151. async def test_update_clears_mapping_with_empty_object(self, async_client: AsyncSession, db_session: AsyncSession):
  152. await _get_or_make_group(db_session, "Operators")
  153. created = await _create_provider(async_client, group_mapping={"idp-ops": "Operators"})
  154. provider_id = created.json()["id"]
  155. token = await _admin_token(async_client)
  156. resp = await async_client.put(
  157. f"/api/v1/auth/oidc/providers/{provider_id}",
  158. json={"group_mapping": {}},
  159. headers=_auth_header(token),
  160. )
  161. assert resp.status_code == 200
  162. assert resp.json()["group_mapping"] == {}
  163. @pytest.mark.asyncio
  164. @pytest.mark.integration
  165. async def test_namespaced_group_claim_accepted(self, async_client: AsyncSession):
  166. """Auth0-style namespaced claim names must be configurable (review note)."""
  167. resp = await _create_provider(async_client, group_claim="app/roles")
  168. assert resp.status_code == 201, resp.text
  169. assert resp.json()["group_claim"] == "app/roles"
  170. class TestEnvGroupMapping:
  171. """The env path: the code most likely to strand an operator at boot."""
  172. @pytest.mark.asyncio
  173. @pytest.mark.integration
  174. async def test_env_mapping_applied(self, db_session: AsyncSession, monkeypatch):
  175. await _get_or_make_group(db_session, "Operators")
  176. await db_session.commit()
  177. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP")
  178. monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env.test.example.com")
  179. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client")
  180. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret")
  181. monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_CLAIM", "roles")
  182. monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "Operators"}')
  183. for key in ("BAMBUDDY_OIDC_DEFAULT_GROUP", "BAMBUDDY_OIDC_SCOPES", "BAMBUDDY_OIDC_ENABLED"):
  184. monkeypatch.delenv(key, raising=False)
  185. await apply_env_oidc_provider(db_session)
  186. row = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP"))).scalar_one()
  187. assert row.group_claim == "roles"
  188. assert row.group_mapping == {"idp-ops": "Operators"}
  189. @pytest.mark.asyncio
  190. @pytest.mark.integration
  191. async def test_env_mapping_unknown_group_refuses_provider(self, db_session: AsyncSession, monkeypatch, caplog):
  192. """Unknown names must refuse the whole config (matching DEFAULT_GROUP):
  193. no provider row may be created carrying a mapping that never resolves."""
  194. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-Refused")
  195. monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env2.test.example.com")
  196. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client")
  197. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret")
  198. monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "NoSuchGroup"}')
  199. for key in (
  200. "BAMBUDDY_OIDC_DEFAULT_GROUP",
  201. "BAMBUDDY_OIDC_SCOPES",
  202. "BAMBUDDY_OIDC_ENABLED",
  203. "BAMBUDDY_OIDC_GROUP_CLAIM",
  204. ):
  205. monkeypatch.delenv(key, raising=False)
  206. await apply_env_oidc_provider(db_session)
  207. row = (
  208. await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-Refused"))
  209. ).scalar_one_or_none()
  210. assert row is None, "provider must not be created when a mapping value matches no group"
  211. @pytest.mark.asyncio
  212. @pytest.mark.integration
  213. async def test_env_mapping_bad_json_refuses_provider(self, db_session: AsyncSession, monkeypatch):
  214. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-BadJson")
  215. monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env3.test.example.com")
  216. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client")
  217. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret")
  218. monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", "{not json")
  219. for key in (
  220. "BAMBUDDY_OIDC_DEFAULT_GROUP",
  221. "BAMBUDDY_OIDC_SCOPES",
  222. "BAMBUDDY_OIDC_ENABLED",
  223. "BAMBUDDY_OIDC_GROUP_CLAIM",
  224. ):
  225. monkeypatch.delenv(key, raising=False)
  226. await apply_env_oidc_provider(db_session) # must not raise
  227. row = (
  228. await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-BadJson"))
  229. ).scalar_one_or_none()
  230. assert row is None
  231. @pytest.mark.asyncio
  232. @pytest.mark.integration
  233. @pytest.mark.parametrize(
  234. "mapping", ['{"staff": null}', '{"staff": 5}', '{"Admins": "Viewers", "admins": "Viewers"}']
  235. )
  236. async def test_env_mapping_bad_value_is_named_in_the_log(
  237. self, db_session: AsyncSession, monkeypatch, caplog, mapping
  238. ):
  239. """A malformed value is refused by name, not as a bare TypeError from
  240. the group lookup it would otherwise reach."""
  241. monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "EnvIdP-BadValue")
  242. monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://env5.test.example.com")
  243. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "env-client")
  244. monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_SECRET", "env-secret")
  245. monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", mapping)
  246. for key in (
  247. "BAMBUDDY_OIDC_DEFAULT_GROUP",
  248. "BAMBUDDY_OIDC_SCOPES",
  249. "BAMBUDDY_OIDC_ENABLED",
  250. "BAMBUDDY_OIDC_GROUP_CLAIM",
  251. ):
  252. monkeypatch.delenv(key, raising=False)
  253. with caplog.at_level(logging.ERROR, logger="backend.app.core.oidc_env"):
  254. await apply_env_oidc_provider(db_session)
  255. row = (
  256. await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-BadValue"))
  257. ).scalar_one_or_none()
  258. assert row is None
  259. assert "BAMBUDDY_OIDC_GROUP_MAPPING is invalid" in caplog.text
  260. assert "TypeError" not in caplog.text
  261. @pytest.mark.asyncio
  262. @pytest.mark.integration
  263. async def test_env_mapping_removed_clears_it(self, db_session: AsyncSession, monkeypatch):
  264. """The environment is the whole truth: dropping the variable clears
  265. the mapping on the next boot."""
  266. await _get_or_make_group(db_session, "Operators")
  267. await db_session.commit()
  268. env = {
  269. "BAMBUDDY_OIDC_NAME": "EnvIdP-Clear",
  270. "BAMBUDDY_OIDC_ISSUER_URL": "https://env4.test.example.com",
  271. "BAMBUDDY_OIDC_CLIENT_ID": "env-client",
  272. "BAMBUDDY_OIDC_CLIENT_SECRET": "env-secret",
  273. }
  274. for key in (
  275. "BAMBUDDY_OIDC_DEFAULT_GROUP",
  276. "BAMBUDDY_OIDC_SCOPES",
  277. "BAMBUDDY_OIDC_ENABLED",
  278. "BAMBUDDY_OIDC_GROUP_CLAIM",
  279. ):
  280. monkeypatch.delenv(key, raising=False)
  281. for k, v in env.items():
  282. monkeypatch.setenv(k, v)
  283. monkeypatch.setenv("BAMBUDDY_OIDC_GROUP_MAPPING", '{"idp-ops": "Operators"}')
  284. await apply_env_oidc_provider(db_session)
  285. row = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "EnvIdP-Clear"))).scalar_one()
  286. assert row.group_mapping == {"idp-ops": "Operators"}
  287. monkeypatch.delenv("BAMBUDDY_OIDC_GROUP_MAPPING")
  288. await apply_env_oidc_provider(db_session)
  289. await db_session.refresh(row)
  290. assert row.group_mapping == {}
  291. def _mock_oidc_httpx(discovery_doc, token_response, jwks_data):
  292. """An httpx.AsyncClient stand-in for oidc_callback: serves the discovery
  293. document, the JWKS payload and the token response, nothing else."""
  294. class _MockResp:
  295. def __init__(self, data):
  296. self._data = data
  297. self.status_code = 200
  298. self.is_success = True
  299. self.text = str(data)
  300. def json(self):
  301. return self._data
  302. def raise_for_status(self):
  303. pass
  304. class _MockHttpxClient:
  305. def __init__(self, *args, **kwargs):
  306. pass
  307. async def __aenter__(self):
  308. return self
  309. async def __aexit__(self, *args):
  310. pass
  311. async def get(self, url, **kwargs):
  312. if "jwks" in url:
  313. return _MockResp(jwks_data)
  314. return _MockResp(discovery_doc)
  315. async def post(self, url, **kwargs):
  316. return _MockResp(token_response)
  317. return _MockHttpxClient
  318. class TestCallbackAppliesMappingEndToEnd:
  319. """oidc_callback with a mapping configured: creation grants the mapped
  320. group, a later login applies revocation, and a manual group survives."""
  321. @staticmethod
  322. def _id_token(private_pem, issuer, client_id, nonce, groups, sub, email):
  323. now = int(time.time())
  324. return pyjwt.encode(
  325. {
  326. "sub": sub,
  327. "iss": issuer,
  328. "aud": client_id,
  329. "nonce": nonce,
  330. "email": email,
  331. "email_verified": True,
  332. "groups": groups,
  333. "iat": now,
  334. "exp": now + 300,
  335. },
  336. private_pem,
  337. algorithm="RS256",
  338. headers={"kid": "test-kid-1"},
  339. )
  340. async def _run_callback(self, async_client, db_session, provider_id, id_token, nonce, jwks, issuer):
  341. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  342. state = secrets.token_urlsafe(32)
  343. db_session.add(
  344. AuthEphemeralToken(
  345. token=state,
  346. token_type="oidc_state",
  347. provider_id=provider_id,
  348. nonce=nonce,
  349. code_verifier=secrets.token_urlsafe(48),
  350. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  351. )
  352. )
  353. await db_session.commit()
  354. discovery = {
  355. "issuer": issuer,
  356. "authorization_endpoint": f"{issuer}/auth",
  357. "token_endpoint": f"{issuer}/token",
  358. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  359. }
  360. token_response = {"access_token": "mock", "token_type": "Bearer", "id_token": id_token}
  361. client_cls = _mock_oidc_httpx(discovery, token_response, jwks)
  362. with patch("backend.app.api.routes.mfa.httpx.AsyncClient", client_cls):
  363. resp = await async_client.get(
  364. f"/api/v1/auth/oidc/callback?code=x&state={state}",
  365. follow_redirects=False,
  366. )
  367. return resp
  368. @pytest.mark.asyncio
  369. @pytest.mark.integration
  370. async def test_callback_grants_mapped_group_on_creation(self, async_client: AsyncClient, db_session: AsyncSession):
  371. operators = await _get_or_make_group(db_session, "Operators")
  372. await _get_or_make_group(db_session, "ManualGroup")
  373. private_pem, jwks = _make_test_rsa_key()
  374. issuer = "https://e2e-gs.test.example.com"
  375. nonce = secrets.token_urlsafe(16)
  376. resp = await _create_provider(
  377. async_client,
  378. name="E2E-GroupSync-IdP",
  379. issuer_url=issuer,
  380. client_id="gs-e2e-client",
  381. client_secret="sec",
  382. group_claim="groups",
  383. group_mapping={"idp-ops": "Operators"},
  384. )
  385. assert resp.status_code == 201, resp.text
  386. provider_id = resp.json()["id"]
  387. id_token = self._id_token(
  388. private_pem, issuer, "gs-e2e-client", nonce, ["idp-ops"], "gs-sub-1", "gse2e@example.com"
  389. )
  390. callback = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer)
  391. assert callback.status_code == 302, callback.text
  392. # 302 alone is not enough — the error path also 302s (review #1):
  393. # assert the exchange token, i.e. the login actually succeeded.
  394. assert "oidc_token=" in callback.headers.get("location", "")
  395. user = (await db_session.execute(select(User).where(User.email == "gse2e@example.com"))).scalar_one()
  396. group_ids = {g.id for g in user.groups}
  397. assert operators.id in group_ids, "auto-created user must land in the mapped group"
  398. @pytest.mark.asyncio
  399. @pytest.mark.integration
  400. async def test_second_login_applies_revocation_and_keeps_manual(
  401. self, async_client: AsyncClient, db_session: AsyncSession
  402. ):
  403. """Login 1 grants Operators via the mapping. An admin then adds
  404. ManualGroup by hand and the IdP revokes idp-ops. Login 2 must remove
  405. Operators and keep ManualGroup — the #1292 contract, end to end."""
  406. operators = await _get_or_make_group(db_session, "Operators2")
  407. manual = await _get_or_make_group(db_session, "ManualGroup2")
  408. private_pem, jwks = _make_test_rsa_key()
  409. issuer = "https://e2e-gs2.test.example.com"
  410. client_id = "gs-e2e-client-2"
  411. nonce = secrets.token_urlsafe(16)
  412. resp = await _create_provider(
  413. async_client,
  414. name="E2E-GroupSync-IdP-2",
  415. issuer_url=issuer,
  416. client_id=client_id,
  417. client_secret="sec",
  418. group_claim="groups",
  419. group_mapping={"idp-ops": "Operators2"},
  420. )
  421. assert resp.status_code == 201, resp.text
  422. provider_id = resp.json()["id"]
  423. # Login 1: IdP says idp-ops -> Operators2 granted at creation.
  424. id_token = self._id_token(private_pem, issuer, client_id, nonce, ["idp-ops"], "gs-sub-2", "gse2e2@example.com")
  425. cb = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer)
  426. assert cb.status_code == 302
  427. assert "oidc_token=" in cb.headers.get("location", "")
  428. user = (await db_session.execute(select(User).where(User.email == "gse2e2@example.com"))).scalar_one()
  429. # Creation assigns the default group (Viewers) per the existing
  430. # auto-create path; the sync adds the mapped group on top. Viewers is
  431. # NOT in the mapping, so it is a creation-time assignment and must
  432. # survive login 2 alongside the manual group.
  433. viewers = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  434. assert {g.id for g in user.groups} == {operators.id, viewers.id}
  435. # Admin assigns ManualGroup2 by hand; IdP revokes idp-ops.
  436. manual_group = (await db_session.execute(select(Group).where(Group.name == "ManualGroup2"))).scalar_one()
  437. user.groups = list(user.groups) + [manual_group]
  438. db_session.add(user)
  439. await db_session.commit()
  440. # Login 2: new nonce, no idp-ops in the claim.
  441. nonce2 = secrets.token_urlsafe(16)
  442. id_token2 = self._id_token(private_pem, issuer, client_id, nonce2, [], "gs-sub-2", "gse2e2@example.com")
  443. cb2 = await self._run_callback(async_client, db_session, provider_id, id_token2, nonce2, jwks, issuer)
  444. assert cb2.status_code == 302, cb2.text
  445. assert "oidc_token=" in cb2.headers.get("location", ""), (
  446. "login 2 must succeed (and carry an exchange token), not error out"
  447. )
  448. await db_session.refresh(user, attribute_names=["groups"])
  449. assert {g.id for g in user.groups} == {manual.id, viewers.id}, (
  450. "revoked mapped group must be removed; the creation-default group "
  451. "and the manual assignment must both survive"
  452. )
  453. @pytest.mark.asyncio
  454. @pytest.mark.integration
  455. async def test_sync_failure_does_not_block_login(
  456. self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch
  457. ):
  458. """Review blocker #1: a mid-sync failure must not take the login down.
  459. sync_oidc_user_groups catches the exception and calls db.rollback(),
  460. which expires every loaded object in the session. If the callback then
  461. reads user.username for the exchange token, that lazy-load raises
  462. MissingGreenlet and the user lands on ?oidc_error=user_resolution_failed
  463. — a failed sync blocking the login after all. The post-sync
  464. db.refresh(user) (all attributes) is what prevents it; this test
  465. fails with user_resolution_failed if that refresh is narrowed again.
  466. Asserts the exchange token in the Location, which a 302 alone cannot
  467. distinguish from the error redirect.
  468. """
  469. operators = await _get_or_make_group(db_session, "Operators")
  470. private_pem, jwks = _make_test_rsa_key()
  471. issuer = "https://e2e-gsfail.test.example.com"
  472. client_id = "gs-e2e-client-fail"
  473. nonce = secrets.token_urlsafe(16)
  474. resp = await _create_provider(
  475. async_client,
  476. name="E2E-GroupSync-Fail-IdP",
  477. issuer_url=issuer,
  478. client_id=client_id,
  479. client_secret="sec",
  480. group_claim="groups",
  481. group_mapping={"idp-ops": "Operators"},
  482. )
  483. assert resp.status_code == 201, resp.text
  484. provider_id = resp.json()["id"]
  485. id_token = self._id_token(
  486. private_pem, issuer, client_id, nonce, ["idp-ops"], "gs-sub-fail", "gsfaile@example.com"
  487. )
  488. from backend.app.services import oidc_group_sync as sync_module
  489. async def _explode(*args, **kwargs):
  490. raise RuntimeError("simulated mid-sync failure")
  491. monkeypatch.setattr(sync_module, "resolve_oidc_group_mapping", _explode)
  492. # The callback imports the name lazily inside the function, so patch
  493. # the module attribute the callback resolves it from.
  494. import backend.app.api.routes.mfa as mfa_module # noqa: F401 (sanity: module importable)
  495. callback = await self._run_callback(async_client, db_session, provider_id, id_token, nonce, jwks, issuer)
  496. location = callback.headers.get("location", "")
  497. assert "oidc_error=" not in location, f"a failed sync must not turn into a login error: {location}"
  498. assert "oidc_token=" in location, "the login must succeed and issue an exchange token despite the sync failure"
  499. # The user exists, and kept the groups they had (none mapped).
  500. user = (await db_session.execute(select(User).where(User.email == "gsfaile@example.com"))).scalar_one()
  501. assert operators.id not in {g.id for g in user.groups}