test_connected_apps.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. """Connected apps: sign-in to external applications with a Bambuddy account.
  2. These lock down what a code is worth: single use, 60 seconds, one app, its
  3. exact callback URL, the PKCE challenge, and only with the app's secret. And
  4. that none of it works while Bambuddy authentication is disabled.
  5. """
  6. import base64
  7. import hashlib
  8. import secrets
  9. from datetime import datetime, timedelta, timezone
  10. import pytest
  11. from httpx import AsyncClient
  12. CALLBACK = "http://orders.local:8090/auth/callback"
  13. def _pkce() -> tuple[str, str]:
  14. verifier = secrets.token_urlsafe(48)
  15. challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
  16. return verifier, challenge
  17. def _auth(token: str) -> dict:
  18. return {"Authorization": f"Bearer {token}"}
  19. @pytest.fixture
  20. async def admin_token(async_client: AsyncClient) -> str:
  21. await async_client.post(
  22. "/api/v1/auth/setup",
  23. json={"auth_enabled": True, "admin_username": "connectadmin", "admin_password": "AdminPass1!"},
  24. )
  25. login = await async_client.post("/api/v1/auth/login", json={"username": "connectadmin", "password": "AdminPass1!"})
  26. return login.json()["access_token"]
  27. @pytest.fixture
  28. async def operator(async_client: AsyncClient, admin_token: str) -> dict:
  29. groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin_token))).json()
  30. operators = next(g for g in groups if g["name"] == "Operators")
  31. user = (
  32. await async_client.post(
  33. "/api/v1/users/",
  34. headers=_auth(admin_token),
  35. json={"username": "shopworker", "password": "Operatorpass1!", "group_ids": [operators["id"]]},
  36. )
  37. ).json()
  38. login = await async_client.post("/api/v1/auth/login", json={"username": "shopworker", "password": "Operatorpass1!"})
  39. return {"id": user["id"], "token": login.json()["access_token"]}
  40. async def _register(async_client: AsyncClient, admin_token: str, **overrides) -> dict:
  41. payload = {"name": "Bambuddy Orders", "redirect_uri": CALLBACK, **overrides}
  42. response = await async_client.post("/api/v1/connect/apps", headers=_auth(admin_token), json=payload)
  43. assert response.status_code == 200, response.text
  44. return response.json()
  45. async def _authorize(async_client: AsyncClient, user_token: str, app: dict, challenge: str, **overrides):
  46. payload = {
  47. "client_id": app["client_id"],
  48. "redirect_uri": app["redirect_uri"],
  49. "code_challenge": challenge,
  50. "code_challenge_method": "S256",
  51. **overrides,
  52. }
  53. return await async_client.post("/api/v1/connect/authorize", headers=_auth(user_token), json=payload)
  54. async def _code(async_client: AsyncClient, user_token: str, app: dict) -> tuple[str, str]:
  55. verifier, challenge = _pkce()
  56. response = await _authorize(async_client, user_token, app, challenge)
  57. assert response.status_code == 200, response.text
  58. return response.json()["code"], verifier
  59. async def _exchange(async_client: AsyncClient, app: dict, code: str, verifier: str, **overrides):
  60. payload = {
  61. "grant_type": "authorization_code",
  62. "code": code,
  63. "redirect_uri": app["redirect_uri"],
  64. "client_id": app["client_id"],
  65. "client_secret": app["client_secret"],
  66. "code_verifier": verifier,
  67. **overrides,
  68. }
  69. # No Authorization header: the app authenticates with its secret alone.
  70. return await async_client.post("/api/v1/connect/token", json=payload)
  71. class TestRegistration:
  72. async def test_secret_is_shown_once(self, async_client, admin_token):
  73. app = await _register(async_client, admin_token)
  74. assert app["client_id"].startswith("bba_")
  75. assert app["client_secret"].startswith("bbs_")
  76. listed = (await async_client.get("/api/v1/connect/apps", headers=_auth(admin_token))).json()
  77. assert [a["client_id"] for a in listed] == [app["client_id"]]
  78. assert "client_secret" not in listed[0]
  79. async def test_refused_while_authentication_is_disabled(self, async_client):
  80. response = await async_client.post("/api/v1/connect/apps", json={"name": "Orders", "redirect_uri": CALLBACK})
  81. assert response.status_code == 400
  82. async def test_non_admin_cannot_register(self, async_client, operator):
  83. response = await async_client.post(
  84. "/api/v1/connect/apps",
  85. headers=_auth(operator["token"]),
  86. json={"name": "Orders", "redirect_uri": CALLBACK},
  87. )
  88. assert response.status_code == 403
  89. @pytest.mark.parametrize(
  90. "redirect_uri",
  91. [
  92. "javascript:alert(1)",
  93. "/relative/callback",
  94. "http://orders.local/cb#frag",
  95. "http://user:pw@orders.local/cb",
  96. "ftp://orders.local/cb",
  97. ],
  98. )
  99. async def test_callback_must_be_a_plain_absolute_http_url(self, async_client, admin_token, redirect_uri):
  100. response = await async_client.post(
  101. "/api/v1/connect/apps",
  102. headers=_auth(admin_token),
  103. json={"name": "Orders", "redirect_uri": redirect_uri},
  104. )
  105. assert response.status_code == 422
  106. class TestSignIn:
  107. async def test_full_flow_returns_identity_and_permissions(self, async_client, admin_token, operator):
  108. app = await _register(async_client, admin_token)
  109. info = await async_client.get(
  110. "/api/v1/connect/authorize/info",
  111. headers=_auth(operator["token"]),
  112. params={"client_id": app["client_id"], "redirect_uri": CALLBACK},
  113. )
  114. assert info.status_code == 200
  115. assert info.json() == {"app_name": "Bambuddy Orders", "username": "shopworker", "already_granted": False}
  116. code, verifier = await _code(async_client, operator["token"], app)
  117. response = await _exchange(async_client, app, code, verifier)
  118. assert response.status_code == 200, response.text
  119. user = response.json()["user"]
  120. assert user["id"] == operator["id"]
  121. assert user["username"] == "shopworker"
  122. assert user["is_admin"] is False
  123. assert user["groups"] == ["Operators"]
  124. assert "queue:create" in user["permissions"]
  125. info_after = await async_client.get(
  126. "/api/v1/connect/authorize/info",
  127. headers=_auth(operator["token"]),
  128. params={"client_id": app["client_id"], "redirect_uri": CALLBACK},
  129. )
  130. assert info_after.json()["already_granted"] is True
  131. async def test_authorize_needs_a_login_not_an_api_key(self, async_client, admin_token):
  132. app = await _register(async_client, admin_token)
  133. key = (
  134. await async_client.post("/api/v1/api-keys/", headers=_auth(admin_token), json={"name": "script"})
  135. ).json()["key"]
  136. _, challenge = _pkce()
  137. for headers in ({"X-API-Key": key}, _auth(key)):
  138. response = await async_client.post(
  139. "/api/v1/connect/authorize",
  140. headers=headers,
  141. json={
  142. "client_id": app["client_id"],
  143. "redirect_uri": CALLBACK,
  144. "code_challenge": challenge,
  145. "code_challenge_method": "S256",
  146. },
  147. )
  148. assert response.status_code == 401
  149. async def test_callback_must_match_exactly_at_authorize(self, async_client, admin_token, operator):
  150. app = await _register(async_client, admin_token)
  151. _, challenge = _pkce()
  152. for wrong in (CALLBACK + "/", CALLBACK + "?x=1", "http://evil.example/auth/callback"):
  153. response = await _authorize(async_client, operator["token"], app, challenge, redirect_uri=wrong)
  154. assert response.status_code == 400
  155. async def test_only_s256_is_accepted(self, async_client, admin_token, operator):
  156. app = await _register(async_client, admin_token)
  157. verifier, _ = _pkce()
  158. response = await _authorize(async_client, operator["token"], app, verifier[:43], code_challenge_method="plain")
  159. assert response.status_code == 422
  160. class TestCodeIsWorthLittle:
  161. async def test_code_is_single_use(self, async_client, admin_token, operator):
  162. app = await _register(async_client, admin_token)
  163. code, verifier = await _code(async_client, operator["token"], app)
  164. assert (await _exchange(async_client, app, code, verifier)).status_code == 200
  165. replay = await _exchange(async_client, app, code, verifier)
  166. assert replay.status_code == 400
  167. assert replay.json()["detail"] == {"error": "invalid_grant"}
  168. async def test_expired_code_is_refused(self, async_client, admin_token, operator, db_session):
  169. from sqlalchemy import update
  170. from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
  171. app = await _register(async_client, admin_token)
  172. code, verifier = await _code(async_client, operator["token"], app)
  173. await db_session.execute(
  174. update(AuthEphemeralToken)
  175. .where(AuthEphemeralToken.token_type == TokenType.CONNECT_CODE)
  176. .values(expires_at=datetime.now(timezone.utc) - timedelta(seconds=1))
  177. )
  178. await db_session.commit()
  179. assert (await _exchange(async_client, app, code, verifier)).status_code == 400
  180. async def test_code_is_not_stored_in_plain(self, async_client, admin_token, operator, db_session):
  181. from sqlalchemy import select
  182. from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
  183. app = await _register(async_client, admin_token)
  184. code, _ = await _code(async_client, operator["token"], app)
  185. stored = (
  186. await db_session.execute(
  187. select(AuthEphemeralToken.token).where(AuthEphemeralToken.token_type == TokenType.CONNECT_CODE)
  188. )
  189. ).scalar_one()
  190. assert stored != code
  191. async def test_wrong_verifier_spends_the_code(self, async_client, admin_token, operator):
  192. app = await _register(async_client, admin_token)
  193. code, verifier = await _code(async_client, operator["token"], app)
  194. other_verifier, _ = _pkce()
  195. assert (await _exchange(async_client, app, code, other_verifier)).status_code == 400
  196. # A failed attempt burns the code, so the right verifier can't be tried next.
  197. assert (await _exchange(async_client, app, code, verifier)).status_code == 400
  198. async def test_wrong_callback_at_exchange_is_refused(self, async_client, admin_token, operator):
  199. app = await _register(async_client, admin_token)
  200. code, verifier = await _code(async_client, operator["token"], app)
  201. response = await _exchange(async_client, app, code, verifier, redirect_uri=CALLBACK + "x")
  202. assert response.status_code == 400
  203. async def test_wrong_secret_is_invalid_client(self, async_client, admin_token, operator):
  204. app = await _register(async_client, admin_token)
  205. code, verifier = await _code(async_client, operator["token"], app)
  206. response = await _exchange(async_client, app, code, verifier, client_secret="bbs_wrong")
  207. assert response.status_code == 401
  208. assert response.json()["detail"] == {"error": "invalid_client"}
  209. # The code survives a caller that can't prove who it is.
  210. assert (await _exchange(async_client, app, code, verifier)).status_code == 200
  211. async def test_unknown_client_is_invalid_client(self, async_client, admin_token, operator):
  212. app = await _register(async_client, admin_token)
  213. code, verifier = await _code(async_client, operator["token"], app)
  214. response = await _exchange(async_client, app, code, verifier, client_id="bba_unknown")
  215. assert response.status_code == 401
  216. async def test_one_apps_code_is_useless_to_another(self, async_client, admin_token, operator):
  217. app_a = await _register(async_client, admin_token, name="A")
  218. app_b = await _register(async_client, admin_token, name="B")
  219. code, verifier = await _code(async_client, operator["token"], app_a)
  220. assert (await _exchange(async_client, app_b, code, verifier)).status_code == 400
  221. async def test_user_disabled_after_consent_is_refused(self, async_client, admin_token, operator):
  222. app = await _register(async_client, admin_token)
  223. code, verifier = await _code(async_client, operator["token"], app)
  224. await async_client.patch(
  225. f"/api/v1/users/{operator['id']}", headers=_auth(admin_token), json={"is_active": False}
  226. )
  227. assert (await _exchange(async_client, app, code, verifier)).status_code == 400
  228. class TestAppLifecycle:
  229. async def test_disabled_app_can_neither_authorize_nor_exchange(self, async_client, admin_token, operator):
  230. app = await _register(async_client, admin_token)
  231. code, verifier = await _code(async_client, operator["token"], app)
  232. await async_client.patch(
  233. f"/api/v1/connect/apps/{app['id']}", headers=_auth(admin_token), json={"enabled": False}
  234. )
  235. _, challenge = _pkce()
  236. assert (await _authorize(async_client, operator["token"], app, challenge)).status_code == 400
  237. assert (await _exchange(async_client, app, code, verifier)).status_code == 401
  238. async def test_rotated_secret_replaces_the_old_one(self, async_client, admin_token, operator):
  239. app = await _register(async_client, admin_token)
  240. rotated = (
  241. await async_client.post(f"/api/v1/connect/apps/{app['id']}/rotate-secret", headers=_auth(admin_token))
  242. ).json()
  243. assert rotated["client_secret"] != app["client_secret"]
  244. code, verifier = await _code(async_client, operator["token"], app)
  245. assert (await _exchange(async_client, app, code, verifier)).status_code == 401
  246. assert (
  247. await _exchange(async_client, app, code, verifier, client_secret=rotated["client_secret"])
  248. ).status_code == 200
  249. async def test_changed_callback_invalidates_codes_for_the_old_one(self, async_client, admin_token, operator):
  250. app = await _register(async_client, admin_token)
  251. code, verifier = await _code(async_client, operator["token"], app)
  252. await async_client.patch(
  253. f"/api/v1/connect/apps/{app['id']}",
  254. headers=_auth(admin_token),
  255. json={"redirect_uri": "http://orders.local:9000/cb"},
  256. )
  257. assert (await _exchange(async_client, app, code, verifier)).status_code == 400
  258. async def test_deleting_the_app_removes_grants_and_codes(self, async_client, admin_token, operator, db_session):
  259. from sqlalchemy import func, select
  260. from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
  261. from backend.app.models.connected_app import ConnectedAppGrant
  262. app = await _register(async_client, admin_token)
  263. await _code(async_client, operator["token"], app)
  264. response = await async_client.delete(f"/api/v1/connect/apps/{app['id']}", headers=_auth(admin_token))
  265. assert response.status_code == 200
  266. grants = await db_session.execute(select(func.count()).select_from(ConnectedAppGrant))
  267. codes = await db_session.execute(
  268. select(func.count())
  269. .select_from(AuthEphemeralToken)
  270. .where(AuthEphemeralToken.token_type == TokenType.CONNECT_CODE)
  271. )
  272. assert grants.scalar_one() == 0
  273. assert codes.scalar_one() == 0
  274. class TestAuthenticationDisabled:
  275. async def test_authorize_and_token_say_auth_disabled(self, async_client):
  276. info = await async_client.get(
  277. "/api/v1/connect/authorize/info", params={"client_id": "bba_x", "redirect_uri": CALLBACK}
  278. )
  279. assert info.status_code == 409
  280. assert info.json()["detail"] == "auth_disabled"
  281. verifier, _ = _pkce()
  282. token = await async_client.post(
  283. "/api/v1/connect/token",
  284. json={
  285. "grant_type": "authorization_code",
  286. "code": "x",
  287. "redirect_uri": CALLBACK,
  288. "client_id": "bba_x",
  289. "client_secret": "bbs_x",
  290. "code_verifier": verifier,
  291. },
  292. )
  293. assert token.status_code == 400
  294. assert token.json()["detail"] == {"error": "auth_disabled"}
  295. class TestRateLimit:
  296. async def test_repeated_failures_lock_the_client_out(self, async_client, admin_token, operator):
  297. from backend.app.api.routes.connected_apps import MAX_FAILED_TOKEN_EXCHANGES
  298. app = await _register(async_client, admin_token)
  299. verifier, _ = _pkce()
  300. for _ in range(MAX_FAILED_TOKEN_EXCHANGES):
  301. response = await _exchange(async_client, app, "not-a-code", verifier)
  302. assert response.status_code == 400
  303. code, good_verifier = await _code(async_client, operator["token"], app)
  304. assert (await _exchange(async_client, app, code, good_verifier)).status_code == 429