test_oidc_relogin.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. """E2E test for issue #1285: SSO user can re-login after admin deletion.
  2. Reproduces the exact symptom from the issue: a user logs in via OIDC
  3. (auto_create_users=True), gets created, is then deleted by the admin, and
  4. attempts to log in again. With the fix in delete_user (UserOIDCLink cleanup)
  5. + the orphan-cleanup migration, the second OIDC callback must trigger
  6. auto_create_users and produce a fresh user — instead of redirecting to
  7. "account_inactive" because of the orphan link.
  8. """
  9. from __future__ import annotations
  10. import base64
  11. import secrets
  12. import time
  13. from datetime import datetime, timedelta, timezone
  14. from unittest.mock import patch
  15. import jwt as pyjwt
  16. import pytest
  17. from cryptography.hazmat.primitives import serialization
  18. from cryptography.hazmat.primitives.asymmetric import rsa
  19. from httpx import AsyncClient
  20. from sqlalchemy import select
  21. from sqlalchemy.ext.asyncio import AsyncSession
  22. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  23. from backend.app.models.oidc_provider import UserOIDCLink
  24. from backend.app.models.user import User
  25. def _make_rsa_key():
  26. """Throwaway RSA + JWKS for the mocked IdP."""
  27. priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  28. pem = priv.private_bytes(
  29. serialization.Encoding.PEM,
  30. serialization.PrivateFormat.TraditionalOpenSSL,
  31. serialization.NoEncryption(),
  32. )
  33. pub = priv.public_key().public_numbers()
  34. def _b64url(n: int, length: int) -> str:
  35. return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
  36. jwks = {
  37. "keys": [
  38. {
  39. "kty": "RSA",
  40. "use": "sig",
  41. "alg": "RS256",
  42. "kid": "test-kid-1",
  43. "n": _b64url(pub.n, 256),
  44. "e": _b64url(pub.e, 3),
  45. }
  46. ]
  47. }
  48. return pem, jwks
  49. class _MockResp:
  50. def __init__(self, data):
  51. self._data = data
  52. self.status_code = 200
  53. self.is_success = True
  54. self.text = str(data)
  55. def json(self):
  56. return self._data
  57. def raise_for_status(self):
  58. pass
  59. def _mock_httpx_factory(discovery_doc, jwks_data, token_response):
  60. class _MockHttpxClient:
  61. def __init__(self, *args, **kwargs):
  62. pass
  63. async def __aenter__(self):
  64. return self
  65. async def __aexit__(self, *args):
  66. pass
  67. async def get(self, url, **kwargs):
  68. if "jwks" in url:
  69. return _MockResp(jwks_data)
  70. return _MockResp(discovery_doc)
  71. async def post(self, url, **kwargs):
  72. return _MockResp(token_response)
  73. return _MockHttpxClient
  74. async def _trigger_oidc_callback(
  75. async_client: AsyncClient,
  76. db_session: AsyncSession,
  77. provider_id: int,
  78. issuer: str,
  79. client_id: str,
  80. private_pem: bytes,
  81. jwks_data: dict,
  82. *,
  83. sub: str,
  84. email: str,
  85. ) -> str:
  86. """Run a full mocked OIDC callback and return the resulting access token."""
  87. nonce = secrets.token_urlsafe(16)
  88. state = secrets.token_urlsafe(32)
  89. code_verifier = secrets.token_urlsafe(48)
  90. now = int(time.time())
  91. id_token = pyjwt.encode(
  92. {
  93. "sub": sub,
  94. "iss": issuer,
  95. "aud": client_id,
  96. "nonce": nonce,
  97. "email": email,
  98. "email_verified": True,
  99. "iat": now,
  100. "exp": now + 300,
  101. },
  102. private_pem,
  103. algorithm="RS256",
  104. headers={"kid": "test-kid-1"},
  105. )
  106. db_session.add(
  107. AuthEphemeralToken(
  108. token=state,
  109. token_type="oidc_state",
  110. provider_id=provider_id,
  111. nonce=nonce,
  112. code_verifier=code_verifier,
  113. expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
  114. )
  115. )
  116. await db_session.commit()
  117. discovery = {
  118. "issuer": issuer,
  119. "authorization_endpoint": f"{issuer}/auth",
  120. "token_endpoint": f"{issuer}/token",
  121. "jwks_uri": f"{issuer}/.well-known/jwks.json",
  122. }
  123. token_response = {
  124. "access_token": "mock-access",
  125. "token_type": "Bearer",
  126. "id_token": id_token,
  127. }
  128. with patch(
  129. "backend.app.api.routes.mfa.httpx.AsyncClient",
  130. _mock_httpx_factory(discovery, jwks_data, token_response),
  131. ):
  132. callback_resp = await async_client.get(
  133. f"/api/v1/auth/oidc/callback?code=test-code&state={state}",
  134. follow_redirects=False,
  135. )
  136. assert callback_resp.status_code == 302, callback_resp.text
  137. location = callback_resp.headers.get("location", "")
  138. assert "oidc_token=" in location, f"Expected oidc_token in redirect, got: {location}"
  139. exchange_token = location.split("oidc_token=")[1].split("&")[0]
  140. exchange_resp = await async_client.post(
  141. "/api/v1/auth/oidc/exchange",
  142. json={"oidc_token": exchange_token},
  143. )
  144. assert exchange_resp.status_code == 200, exchange_resp.text
  145. return exchange_resp.json()["access_token"]
  146. class TestOidcReloginAfterDelete:
  147. """Issue #1285: SSO user must be recreatable after admin deletion."""
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_relogin_after_delete_recreates_user_via_auto_create(
  151. self, async_client: AsyncClient, db_session: AsyncSession
  152. ):
  153. """User created via OIDC → deleted by admin → second OIDC login creates a new user.
  154. Without the delete_user UserOIDCLink-cleanup fix, the second callback finds
  155. the orphan link, fails to load the now-deleted user, and redirects to
  156. ``account_inactive`` — never reaching auto_create_users.
  157. """
  158. private_pem, jwks = _make_rsa_key()
  159. issuer = "https://idp.relogin-test.example.com"
  160. client_id = "relogin-test-client"
  161. sub = "oidc-sub-relogin-1285"
  162. email = "relogin@example.com"
  163. # Admin setup + create OIDC provider
  164. await async_client.post(
  165. "/api/v1/auth/setup",
  166. json={
  167. "auth_enabled": True,
  168. "admin_username": "reloginadm",
  169. "admin_password": "AdminPass1!",
  170. },
  171. )
  172. login_resp = await async_client.post(
  173. "/api/v1/auth/login",
  174. json={"username": "reloginadm", "password": "AdminPass1!"},
  175. )
  176. admin_token = login_resp.json()["access_token"]
  177. headers = {"Authorization": f"Bearer {admin_token}"}
  178. create_resp = await async_client.post(
  179. "/api/v1/auth/oidc/providers",
  180. json={
  181. "name": "ReloginIdP",
  182. "issuer_url": issuer,
  183. "client_id": client_id,
  184. "client_secret": "test-secret",
  185. "scopes": "openid email profile",
  186. "is_enabled": True,
  187. "auto_create_users": True,
  188. },
  189. headers=headers,
  190. )
  191. assert create_resp.status_code == 201, create_resp.text
  192. provider_id = create_resp.json()["id"]
  193. # ── First OIDC login: creates user + link ──
  194. await _trigger_oidc_callback(
  195. async_client,
  196. db_session,
  197. provider_id,
  198. issuer,
  199. client_id,
  200. private_pem,
  201. jwks,
  202. sub=sub,
  203. email=email,
  204. )
  205. await db_session.commit()
  206. first_user_row = await db_session.execute(select(User).where(User.email == email))
  207. first_user = first_user_row.scalar_one()
  208. first_user_id = first_user.id
  209. first_user_created_at = first_user.created_at
  210. first_link_row = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_user_id == sub))
  211. assert first_link_row.scalar_one().user_id == first_user_id
  212. # ── Admin deletes the user ──
  213. del_resp = await async_client.delete(
  214. f"/api/v1/users/{first_user_id}",
  215. headers=headers,
  216. )
  217. assert del_resp.status_code == 204, del_resp.text
  218. await db_session.commit()
  219. # With the fix the orphan link is gone too — verifying because that
  220. # is exactly the precondition for auto_create to fire on retry.
  221. link_after_delete = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_user_id == sub))
  222. assert link_after_delete.scalar_one_or_none() is None, (
  223. "Orphan UserOIDCLink left after delete — would block re-login per #1285"
  224. )
  225. # And the user row itself is gone (#1285 prerequisite).
  226. user_after_delete = await db_session.execute(select(User).where(User.email == email))
  227. assert user_after_delete.scalar_one_or_none() is None
  228. # ── Second OIDC login with the same sub: auto_create must run again ──
  229. # The helper already asserts a 302 with oidc_token=… — that alone proves
  230. # auto_create fired (otherwise the callback would have redirected to
  231. # /?oidc_error=account_inactive and the helper would have failed).
  232. await _trigger_oidc_callback(
  233. async_client,
  234. db_session,
  235. provider_id,
  236. issuer,
  237. client_id,
  238. private_pem,
  239. jwks,
  240. sub=sub,
  241. email=email,
  242. )
  243. await db_session.commit()
  244. second_row = await db_session.execute(select(User).where(User.email == email))
  245. second_user = second_row.scalar_one()
  246. # SQLite recycles primary-key ids when AUTOINCREMENT is not declared, so
  247. # comparing ids is not a reliable freshness signal across delete+recreate.
  248. # The decisive proof: a new user row was created (post-delete) and a
  249. # fresh link points at it. created_at must not be earlier than the
  250. # original — equality is acceptable on fast machines where seconds match.
  251. assert second_user.created_at >= first_user_created_at, (
  252. f"Re-created user has earlier created_at ({second_user.created_at}) "
  253. f"than the deleted original ({first_user_created_at}) — bug regression"
  254. )
  255. # And a fresh link for the new user
  256. link_after = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_user_id == sub))
  257. assert link_after.scalar_one().user_id == second_user.id