connected_apps.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. """Connected apps: sign users in to external applications with Bambuddy.
  2. A minimal OAuth 2.0 authorization-code flow with PKCE (S256 only):
  3. 1. The app sends the user's browser to the SPA page ``/connect/authorize``.
  4. The page, running with the user's normal Bambuddy session, calls
  5. ``GET /connect/authorize/info`` to show who is asking and, after consent,
  6. ``POST /connect/authorize`` for a code. It then sends the browser back to
  7. the app's registered callback URL with that code.
  8. 2. The app's server calls ``POST /connect/token`` with the code, its client
  9. secret and the PKCE verifier, and receives the user's identity and
  10. permissions.
  11. What the app never gets is the user's Bambuddy login token. What a code is
  12. worth is deliberately small: single use, 60 seconds, bound to one app, its
  13. exact callback URL and the PKCE challenge, and only redeemable with the app's
  14. secret.
  15. Sign-in through an app only exists while Bambuddy authentication is enabled.
  16. With it disabled there are no users to sign in, and both authorize and token
  17. answer ``auth_disabled`` so the app can fall back to its own login rather
  18. than let everyone in.
  19. """
  20. import base64
  21. import functools
  22. import hashlib
  23. import hmac
  24. import logging
  25. import secrets
  26. from datetime import datetime, timedelta, timezone
  27. from typing import Annotated
  28. from fastapi import APIRouter, Depends, HTTPException, Query, Request
  29. from fastapi.security import HTTPAuthorizationCredentials
  30. from sqlalchemy import delete, select
  31. from sqlalchemy.exc import IntegrityError
  32. from sqlalchemy.ext.asyncio import AsyncSession
  33. from backend.app.core.auth import (
  34. RequirePermissionIfAuthEnabled,
  35. get_current_user,
  36. get_password_hash,
  37. get_user_by_username,
  38. is_auth_enabled,
  39. security,
  40. verify_password,
  41. )
  42. from backend.app.core.database import get_db
  43. from backend.app.core.permissions import Permission
  44. from backend.app.models.auth_ephemeral import AuthEphemeralToken, EventType, TokenType
  45. from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
  46. from backend.app.models.user import User
  47. from backend.app.schemas.connected_app import (
  48. ConnectAuthorizeInfo,
  49. ConnectAuthorizeRequest,
  50. ConnectAuthorizeResponse,
  51. ConnectedAppCreate,
  52. ConnectedAppResponse,
  53. ConnectedAppSecretResponse,
  54. ConnectedAppUpdate,
  55. ConnectedUser,
  56. ConnectTokenRequest,
  57. ConnectTokenResponse,
  58. )
  59. logger = logging.getLogger(__name__)
  60. router = APIRouter(prefix="/connect", tags=["connected-apps"])
  61. CODE_TTL = timedelta(seconds=60)
  62. # Failed token exchanges tolerated per client and per IP in the rate-limit
  63. # window (mfa.LOCKOUT_WINDOW). A working app fails almost never; this only has
  64. # to stop someone guessing secrets or codes.
  65. MAX_FAILED_TOKEN_EXCHANGES = 20
  66. AUTH_DISABLED = "auth_disabled"
  67. @functools.cache
  68. def _dummy_secret_hash() -> str:
  69. """Verified against when the client_id is unknown, so an unknown client
  70. costs the same bcrypt round as a wrong secret and the two can't be told
  71. apart by timing. Built on first use to keep bcrypt out of import time."""
  72. return get_password_hash(secrets.token_urlsafe(32))
  73. def _hash_code(code: str) -> str:
  74. return hashlib.sha256(code.encode()).hexdigest()
  75. def _s256(verifier: str) -> str:
  76. digest = hashlib.sha256(verifier.encode("ascii")).digest()
  77. return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
  78. def _new_client_credentials() -> tuple[str, str]:
  79. return f"bba_{secrets.token_hex(12)}", f"bbs_{secrets.token_urlsafe(32)}"
  80. def _with_secret(app: ConnectedApp, client_secret: str) -> ConnectedAppSecretResponse:
  81. return ConnectedAppSecretResponse(
  82. **ConnectedAppResponse.model_validate(app).model_dump(), client_secret=client_secret
  83. )
  84. async def _get_app_or_404(db: AsyncSession, app_id: int) -> ConnectedApp:
  85. app = await db.get(ConnectedApp, app_id)
  86. if app is None:
  87. raise HTTPException(404, "Connected app not found")
  88. return app
  89. # --------------------------------------------------------------------------
  90. # Admin: register and manage apps
  91. # --------------------------------------------------------------------------
  92. @router.get("/apps", response_model=list[ConnectedAppResponse])
  93. async def list_connected_apps(
  94. db: AsyncSession = Depends(get_db),
  95. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  96. ):
  97. result = await db.execute(select(ConnectedApp).order_by(ConnectedApp.created_at.desc()))
  98. return list(result.scalars().all())
  99. @router.post("/apps", response_model=ConnectedAppSecretResponse)
  100. async def create_connected_app(
  101. data: ConnectedAppCreate,
  102. db: AsyncSession = Depends(get_db),
  103. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  104. ):
  105. """Register an app. The client secret is in this response and nowhere else."""
  106. if not await is_auth_enabled(db):
  107. # Nobody could sign in through it, and an admin who set one up would
  108. # reasonably assume the app is now protected by Bambuddy's login.
  109. raise HTTPException(400, "Connected apps require authentication to be enabled")
  110. client_id, client_secret = _new_client_credentials()
  111. app = ConnectedApp(
  112. name=data.name.strip(),
  113. client_id=client_id,
  114. client_secret_hash=get_password_hash(client_secret),
  115. redirect_uri=data.redirect_uri,
  116. enabled=True,
  117. created_by_id=current_user.id if current_user else None,
  118. )
  119. db.add(app)
  120. await db.commit()
  121. await db.refresh(app)
  122. logger.info("Connected app %s '%s' registered", app.id, app.name)
  123. return _with_secret(app, client_secret)
  124. @router.patch("/apps/{app_id}", response_model=ConnectedAppResponse)
  125. async def update_connected_app(
  126. app_id: int,
  127. data: ConnectedAppUpdate,
  128. db: AsyncSession = Depends(get_db),
  129. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  130. ):
  131. app = await _get_app_or_404(db, app_id)
  132. if data.name is not None:
  133. app.name = data.name.strip()
  134. if data.redirect_uri is not None:
  135. app.redirect_uri = data.redirect_uri
  136. if data.enabled is not None:
  137. app.enabled = data.enabled
  138. await db.commit()
  139. await db.refresh(app)
  140. return app
  141. @router.post("/apps/{app_id}/rotate-secret", response_model=ConnectedAppSecretResponse)
  142. async def rotate_connected_app_secret(
  143. app_id: int,
  144. db: AsyncSession = Depends(get_db),
  145. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  146. ):
  147. """Replace the client secret. The old one stops working immediately."""
  148. app = await _get_app_or_404(db, app_id)
  149. _, client_secret = _new_client_credentials()
  150. app.client_secret_hash = get_password_hash(client_secret)
  151. await db.commit()
  152. await db.refresh(app)
  153. logger.info("Connected app %s secret rotated", app.id)
  154. return _with_secret(app, client_secret)
  155. @router.delete("/apps/{app_id}")
  156. async def delete_connected_app(
  157. app_id: int,
  158. db: AsyncSession = Depends(get_db),
  159. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  160. ):
  161. app = await _get_app_or_404(db, app_id)
  162. await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.app_id == app.id))
  163. # Outstanding codes would fail anyway (their app is gone); drop them now.
  164. await db.execute(
  165. delete(AuthEphemeralToken).where(
  166. AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
  167. AuthEphemeralToken.provider_id == app.id,
  168. )
  169. )
  170. await db.delete(app)
  171. await db.commit()
  172. logger.info("Connected app %s deleted", app_id)
  173. return {"message": "Connected app deleted"}
  174. # --------------------------------------------------------------------------
  175. # Authorize: called by the SPA with the signed-in user's session
  176. # --------------------------------------------------------------------------
  177. async def require_signed_in_user(
  178. credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
  179. db: AsyncSession = Depends(get_db),
  180. ) -> User:
  181. """The user behind a Bambuddy login token. API keys are refused.
  182. An API key would let a script sign its owner in to an app without the
  183. owner ever seeing a consent screen; only a person's own session may do
  184. that. ``get_current_user`` accepts JWTs only, so a ``bb_`` key fails there.
  185. """
  186. if not await is_auth_enabled(db):
  187. raise HTTPException(409, AUTH_DISABLED)
  188. return await get_current_user(credentials)
  189. async def _app_for_authorize(db: AsyncSession, client_id: str, redirect_uri: str) -> ConnectedApp:
  190. result = await db.execute(select(ConnectedApp).where(ConnectedApp.client_id == client_id))
  191. app = result.scalar_one_or_none()
  192. # One message for every case: the page must not redirect anywhere unless
  193. # all of these hold, and there is nothing useful to tell the user apart.
  194. if app is None or not app.enabled or not hmac.compare_digest(app.redirect_uri, redirect_uri):
  195. raise HTTPException(400, "Unknown app, app disabled, or callback URL does not match its registration")
  196. return app
  197. @router.get("/authorize/info", response_model=ConnectAuthorizeInfo)
  198. async def authorize_info(
  199. client_id: str = Query(..., max_length=64),
  200. redirect_uri: str = Query(..., max_length=500),
  201. db: AsyncSession = Depends(get_db),
  202. user: User = Depends(require_signed_in_user),
  203. ):
  204. """What the consent screen shows. Validates the request before the page acts on it."""
  205. app = await _app_for_authorize(db, client_id, redirect_uri)
  206. granted = await db.execute(
  207. select(ConnectedAppGrant.id).where(ConnectedAppGrant.app_id == app.id, ConnectedAppGrant.user_id == user.id)
  208. )
  209. return ConnectAuthorizeInfo(
  210. app_name=app.name, username=user.username, already_granted=granted.scalar_one_or_none() is not None
  211. )
  212. @router.post("/authorize", response_model=ConnectAuthorizeResponse)
  213. async def authorize(
  214. data: ConnectAuthorizeRequest,
  215. db: AsyncSession = Depends(get_db),
  216. user: User = Depends(require_signed_in_user),
  217. ):
  218. """Issue a code for the signed-in user and record their consent."""
  219. app = await _app_for_authorize(db, data.client_id, data.redirect_uri)
  220. now = datetime.now(timezone.utc)
  221. # Keep the table small: codes live for a minute, so anything expired is junk.
  222. await db.execute(
  223. delete(AuthEphemeralToken).where(
  224. AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
  225. AuthEphemeralToken.expires_at < now,
  226. )
  227. )
  228. granted = await db.execute(
  229. select(ConnectedAppGrant.id).where(ConnectedAppGrant.app_id == app.id, ConnectedAppGrant.user_id == user.id)
  230. )
  231. if granted.scalar_one_or_none() is None:
  232. db.add(ConnectedAppGrant(app_id=app.id, user_id=user.id))
  233. code = secrets.token_urlsafe(32)
  234. db.add(
  235. AuthEphemeralToken.new_connect_code(
  236. code_hash=_hash_code(code),
  237. username=user.username,
  238. app_id=app.id,
  239. code_challenge=data.code_challenge,
  240. expires_at=now + CODE_TTL,
  241. )
  242. )
  243. try:
  244. await db.commit()
  245. except IntegrityError:
  246. # Two tabs consenting at once both inserted the grant; the other one won.
  247. await db.rollback()
  248. db.add(
  249. AuthEphemeralToken.new_connect_code(
  250. code_hash=_hash_code(code),
  251. username=user.username,
  252. app_id=app.id,
  253. code_challenge=data.code_challenge,
  254. expires_at=now + CODE_TTL,
  255. )
  256. )
  257. await db.commit()
  258. logger.info("Connected app %s: code issued for user %s", app.id, user.id)
  259. return ConnectAuthorizeResponse(code=code, redirect_uri=app.redirect_uri)
  260. # --------------------------------------------------------------------------
  261. # Token: called by the app's server, authenticated by its client secret
  262. # --------------------------------------------------------------------------
  263. def _token_error(status_code: int, error: str) -> HTTPException:
  264. return HTTPException(status_code, {"error": error})
  265. @router.post("/token", response_model=ConnectTokenResponse)
  266. async def exchange_code(
  267. request: Request,
  268. data: ConnectTokenRequest,
  269. db: AsyncSession = Depends(get_db),
  270. ):
  271. """Swap a code for the user's identity and permissions.
  272. Every failure after the rate-limit check counts against both the client and
  273. the caller's IP. The response says only ``invalid_client`` (who is asking
  274. is wrong) or ``invalid_grant`` (the code is), never which check failed.
  275. """
  276. from backend.app.api.routes.auth import _get_client_ip
  277. from backend.app.api.routes.mfa import check_rate_limit, clear_failed_attempts, record_failed_attempt
  278. client_ip = _get_client_ip(request)
  279. await check_rate_limit(
  280. db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT, max_attempts=MAX_FAILED_TOKEN_EXCHANGES
  281. )
  282. await check_rate_limit(
  283. db, client_ip, event_type=EventType.CONNECT_TOKEN_IP, max_attempts=MAX_FAILED_TOKEN_EXCHANGES
  284. )
  285. async def fail(status_code: int, error: str, reason: str) -> HTTPException:
  286. await record_failed_attempt(db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT)
  287. await record_failed_attempt(db, client_ip, event_type=EventType.CONNECT_TOKEN_IP)
  288. logger.warning("Connected app token exchange refused for client %s: %s", data.client_id, reason)
  289. return _token_error(status_code, error)
  290. if not await is_auth_enabled(db):
  291. raise _token_error(400, AUTH_DISABLED)
  292. result = await db.execute(select(ConnectedApp).where(ConnectedApp.client_id == data.client_id))
  293. app = result.scalar_one_or_none()
  294. secret_ok = verify_password(data.client_secret, app.client_secret_hash if app else _dummy_secret_hash())
  295. if app is None or not secret_ok:
  296. raise await fail(401, "invalid_client", "unknown client or wrong secret")
  297. if not app.enabled:
  298. raise await fail(401, "invalid_client", "app disabled")
  299. # Consume first, then check: DELETE ... RETURNING makes the code single-use
  300. # even under concurrent exchanges, and a code that fails a later check is
  301. # spent all the same.
  302. now = datetime.now(timezone.utc)
  303. consumed = await db.execute(
  304. delete(AuthEphemeralToken)
  305. .where(
  306. AuthEphemeralToken.token == _hash_code(data.code),
  307. AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
  308. AuthEphemeralToken.provider_id == app.id,
  309. AuthEphemeralToken.expires_at > now,
  310. )
  311. .returning(AuthEphemeralToken.username, AuthEphemeralToken.nonce)
  312. )
  313. row = consumed.one_or_none()
  314. await db.commit()
  315. if row is None:
  316. raise await fail(400, "invalid_grant", "unknown, expired, reused or foreign code")
  317. username, code_challenge = row
  318. if not hmac.compare_digest(app.redirect_uri, data.redirect_uri):
  319. raise await fail(400, "invalid_grant", "callback URL mismatch")
  320. if not code_challenge or not hmac.compare_digest(_s256(data.code_verifier), code_challenge):
  321. raise await fail(400, "invalid_grant", "PKCE verifier mismatch")
  322. user = await get_user_by_username(db, username)
  323. if user is None or not user.is_active:
  324. raise await fail(400, "invalid_grant", "user gone or disabled")
  325. app.last_used_at = now.replace(tzinfo=None)
  326. await clear_failed_attempts(db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT)
  327. await db.commit()
  328. logger.info("Connected app %s: user %s signed in", app.id, user.id)
  329. return ConnectTokenResponse(
  330. user=ConnectedUser(
  331. id=user.id,
  332. username=user.username,
  333. email=user.email,
  334. is_admin=user.is_admin,
  335. groups=sorted(g.name for g in user.groups),
  336. permissions=sorted(user.get_permissions()),
  337. ),
  338. issued_at=now,
  339. )