| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405 |
- """Connected apps: sign users in to external applications with Bambuddy.
- A minimal OAuth 2.0 authorization-code flow with PKCE (S256 only):
- 1. The app sends the user's browser to the SPA page ``/connect/authorize``.
- The page, running with the user's normal Bambuddy session, calls
- ``GET /connect/authorize/info`` to show who is asking and, after consent,
- ``POST /connect/authorize`` for a code. It then sends the browser back to
- the app's registered callback URL with that code.
- 2. The app's server calls ``POST /connect/token`` with the code, its client
- secret and the PKCE verifier, and receives the user's identity and
- permissions.
- What the app never gets is the user's Bambuddy login token. What a code is
- worth is deliberately small: single use, 60 seconds, bound to one app, its
- exact callback URL and the PKCE challenge, and only redeemable with the app's
- secret.
- Sign-in through an app only exists while Bambuddy authentication is enabled.
- With it disabled there are no users to sign in, and both authorize and token
- answer ``auth_disabled`` so the app can fall back to its own login rather
- than let everyone in.
- """
- import base64
- import functools
- import hashlib
- import hmac
- import logging
- import secrets
- from datetime import datetime, timedelta, timezone
- from typing import Annotated
- from fastapi import APIRouter, Depends, HTTPException, Query, Request
- from fastapi.security import HTTPAuthorizationCredentials
- from sqlalchemy import delete, select
- from sqlalchemy.exc import IntegrityError
- from sqlalchemy.ext.asyncio import AsyncSession
- from backend.app.core.auth import (
- RequirePermissionIfAuthEnabled,
- get_current_user,
- get_password_hash,
- get_user_by_username,
- is_auth_enabled,
- security,
- verify_password,
- )
- from backend.app.core.database import get_db
- from backend.app.core.permissions import Permission
- from backend.app.models.auth_ephemeral import AuthEphemeralToken, EventType, TokenType
- from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
- from backend.app.models.user import User
- from backend.app.schemas.connected_app import (
- ConnectAuthorizeInfo,
- ConnectAuthorizeRequest,
- ConnectAuthorizeResponse,
- ConnectedAppCreate,
- ConnectedAppResponse,
- ConnectedAppSecretResponse,
- ConnectedAppUpdate,
- ConnectedUser,
- ConnectTokenRequest,
- ConnectTokenResponse,
- )
- logger = logging.getLogger(__name__)
- router = APIRouter(prefix="/connect", tags=["connected-apps"])
- CODE_TTL = timedelta(seconds=60)
- # Failed token exchanges tolerated per client and per IP in the rate-limit
- # window (mfa.LOCKOUT_WINDOW). A working app fails almost never; this only has
- # to stop someone guessing secrets or codes.
- MAX_FAILED_TOKEN_EXCHANGES = 20
- AUTH_DISABLED = "auth_disabled"
- @functools.cache
- def _dummy_secret_hash() -> str:
- """Verified against when the client_id is unknown, so an unknown client
- costs the same bcrypt round as a wrong secret and the two can't be told
- apart by timing. Built on first use to keep bcrypt out of import time."""
- return get_password_hash(secrets.token_urlsafe(32))
- def _hash_code(code: str) -> str:
- return hashlib.sha256(code.encode()).hexdigest()
- def _s256(verifier: str) -> str:
- digest = hashlib.sha256(verifier.encode("ascii")).digest()
- return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
- def _new_client_credentials() -> tuple[str, str]:
- return f"bba_{secrets.token_hex(12)}", f"bbs_{secrets.token_urlsafe(32)}"
- def _with_secret(app: ConnectedApp, client_secret: str) -> ConnectedAppSecretResponse:
- return ConnectedAppSecretResponse(
- **ConnectedAppResponse.model_validate(app).model_dump(), client_secret=client_secret
- )
- async def _get_app_or_404(db: AsyncSession, app_id: int) -> ConnectedApp:
- app = await db.get(ConnectedApp, app_id)
- if app is None:
- raise HTTPException(404, "Connected app not found")
- return app
- # --------------------------------------------------------------------------
- # Admin: register and manage apps
- # --------------------------------------------------------------------------
- @router.get("/apps", response_model=list[ConnectedAppResponse])
- async def list_connected_apps(
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
- ):
- result = await db.execute(select(ConnectedApp).order_by(ConnectedApp.created_at.desc()))
- return list(result.scalars().all())
- @router.post("/apps", response_model=ConnectedAppSecretResponse)
- async def create_connected_app(
- data: ConnectedAppCreate,
- db: AsyncSession = Depends(get_db),
- current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
- ):
- """Register an app. The client secret is in this response and nowhere else."""
- if not await is_auth_enabled(db):
- # Nobody could sign in through it, and an admin who set one up would
- # reasonably assume the app is now protected by Bambuddy's login.
- raise HTTPException(400, "Connected apps require authentication to be enabled")
- client_id, client_secret = _new_client_credentials()
- app = ConnectedApp(
- name=data.name.strip(),
- client_id=client_id,
- client_secret_hash=get_password_hash(client_secret),
- redirect_uri=data.redirect_uri,
- enabled=True,
- created_by_id=current_user.id if current_user else None,
- )
- db.add(app)
- await db.commit()
- await db.refresh(app)
- logger.info("Connected app %s '%s' registered", app.id, app.name)
- return _with_secret(app, client_secret)
- @router.patch("/apps/{app_id}", response_model=ConnectedAppResponse)
- async def update_connected_app(
- app_id: int,
- data: ConnectedAppUpdate,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
- ):
- app = await _get_app_or_404(db, app_id)
- if data.name is not None:
- app.name = data.name.strip()
- if data.redirect_uri is not None:
- app.redirect_uri = data.redirect_uri
- if data.enabled is not None:
- app.enabled = data.enabled
- await db.commit()
- await db.refresh(app)
- return app
- @router.post("/apps/{app_id}/rotate-secret", response_model=ConnectedAppSecretResponse)
- async def rotate_connected_app_secret(
- app_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
- ):
- """Replace the client secret. The old one stops working immediately."""
- app = await _get_app_or_404(db, app_id)
- _, client_secret = _new_client_credentials()
- app.client_secret_hash = get_password_hash(client_secret)
- await db.commit()
- await db.refresh(app)
- logger.info("Connected app %s secret rotated", app.id)
- return _with_secret(app, client_secret)
- @router.delete("/apps/{app_id}")
- async def delete_connected_app(
- app_id: int,
- db: AsyncSession = Depends(get_db),
- _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
- ):
- app = await _get_app_or_404(db, app_id)
- await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.app_id == app.id))
- # Outstanding codes would fail anyway (their app is gone); drop them now.
- await db.execute(
- delete(AuthEphemeralToken).where(
- AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
- AuthEphemeralToken.provider_id == app.id,
- )
- )
- await db.delete(app)
- await db.commit()
- logger.info("Connected app %s deleted", app_id)
- return {"message": "Connected app deleted"}
- # --------------------------------------------------------------------------
- # Authorize: called by the SPA with the signed-in user's session
- # --------------------------------------------------------------------------
- async def require_signed_in_user(
- credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
- db: AsyncSession = Depends(get_db),
- ) -> User:
- """The user behind a Bambuddy login token. API keys are refused.
- An API key would let a script sign its owner in to an app without the
- owner ever seeing a consent screen; only a person's own session may do
- that. ``get_current_user`` accepts JWTs only, so a ``bb_`` key fails there.
- """
- if not await is_auth_enabled(db):
- raise HTTPException(409, AUTH_DISABLED)
- return await get_current_user(credentials)
- async def _app_for_authorize(db: AsyncSession, client_id: str, redirect_uri: str) -> ConnectedApp:
- result = await db.execute(select(ConnectedApp).where(ConnectedApp.client_id == client_id))
- app = result.scalar_one_or_none()
- # One message for every case: the page must not redirect anywhere unless
- # all of these hold, and there is nothing useful to tell the user apart.
- if app is None or not app.enabled or not hmac.compare_digest(app.redirect_uri, redirect_uri):
- raise HTTPException(400, "Unknown app, app disabled, or callback URL does not match its registration")
- return app
- @router.get("/authorize/info", response_model=ConnectAuthorizeInfo)
- async def authorize_info(
- client_id: str = Query(..., max_length=64),
- redirect_uri: str = Query(..., max_length=500),
- db: AsyncSession = Depends(get_db),
- user: User = Depends(require_signed_in_user),
- ):
- """What the consent screen shows. Validates the request before the page acts on it."""
- app = await _app_for_authorize(db, client_id, redirect_uri)
- granted = await db.execute(
- select(ConnectedAppGrant.id).where(ConnectedAppGrant.app_id == app.id, ConnectedAppGrant.user_id == user.id)
- )
- return ConnectAuthorizeInfo(
- app_name=app.name, username=user.username, already_granted=granted.scalar_one_or_none() is not None
- )
- @router.post("/authorize", response_model=ConnectAuthorizeResponse)
- async def authorize(
- data: ConnectAuthorizeRequest,
- db: AsyncSession = Depends(get_db),
- user: User = Depends(require_signed_in_user),
- ):
- """Issue a code for the signed-in user and record their consent."""
- app = await _app_for_authorize(db, data.client_id, data.redirect_uri)
- now = datetime.now(timezone.utc)
- # Keep the table small: codes live for a minute, so anything expired is junk.
- await db.execute(
- delete(AuthEphemeralToken).where(
- AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
- AuthEphemeralToken.expires_at < now,
- )
- )
- granted = await db.execute(
- select(ConnectedAppGrant.id).where(ConnectedAppGrant.app_id == app.id, ConnectedAppGrant.user_id == user.id)
- )
- if granted.scalar_one_or_none() is None:
- db.add(ConnectedAppGrant(app_id=app.id, user_id=user.id))
- code = secrets.token_urlsafe(32)
- db.add(
- AuthEphemeralToken.new_connect_code(
- code_hash=_hash_code(code),
- username=user.username,
- app_id=app.id,
- code_challenge=data.code_challenge,
- expires_at=now + CODE_TTL,
- )
- )
- try:
- await db.commit()
- except IntegrityError:
- # Two tabs consenting at once both inserted the grant; the other one won.
- await db.rollback()
- db.add(
- AuthEphemeralToken.new_connect_code(
- code_hash=_hash_code(code),
- username=user.username,
- app_id=app.id,
- code_challenge=data.code_challenge,
- expires_at=now + CODE_TTL,
- )
- )
- await db.commit()
- logger.info("Connected app %s: code issued for user %s", app.id, user.id)
- return ConnectAuthorizeResponse(code=code, redirect_uri=app.redirect_uri)
- # --------------------------------------------------------------------------
- # Token: called by the app's server, authenticated by its client secret
- # --------------------------------------------------------------------------
- def _token_error(status_code: int, error: str) -> HTTPException:
- return HTTPException(status_code, {"error": error})
- @router.post("/token", response_model=ConnectTokenResponse)
- async def exchange_code(
- request: Request,
- data: ConnectTokenRequest,
- db: AsyncSession = Depends(get_db),
- ):
- """Swap a code for the user's identity and permissions.
- Every failure after the rate-limit check counts against both the client and
- the caller's IP. The response says only ``invalid_client`` (who is asking
- is wrong) or ``invalid_grant`` (the code is), never which check failed.
- """
- from backend.app.api.routes.auth import _get_client_ip
- from backend.app.api.routes.mfa import check_rate_limit, clear_failed_attempts, record_failed_attempt
- client_ip = _get_client_ip(request)
- await check_rate_limit(
- db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT, max_attempts=MAX_FAILED_TOKEN_EXCHANGES
- )
- await check_rate_limit(
- db, client_ip, event_type=EventType.CONNECT_TOKEN_IP, max_attempts=MAX_FAILED_TOKEN_EXCHANGES
- )
- async def fail(status_code: int, error: str, reason: str) -> HTTPException:
- await record_failed_attempt(db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT)
- await record_failed_attempt(db, client_ip, event_type=EventType.CONNECT_TOKEN_IP)
- logger.warning("Connected app token exchange refused for client %s: %s", data.client_id, reason)
- return _token_error(status_code, error)
- if not await is_auth_enabled(db):
- raise _token_error(400, AUTH_DISABLED)
- result = await db.execute(select(ConnectedApp).where(ConnectedApp.client_id == data.client_id))
- app = result.scalar_one_or_none()
- secret_ok = verify_password(data.client_secret, app.client_secret_hash if app else _dummy_secret_hash())
- if app is None or not secret_ok:
- raise await fail(401, "invalid_client", "unknown client or wrong secret")
- if not app.enabled:
- raise await fail(401, "invalid_client", "app disabled")
- # Consume first, then check: DELETE ... RETURNING makes the code single-use
- # even under concurrent exchanges, and a code that fails a later check is
- # spent all the same.
- now = datetime.now(timezone.utc)
- consumed = await db.execute(
- delete(AuthEphemeralToken)
- .where(
- AuthEphemeralToken.token == _hash_code(data.code),
- AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
- AuthEphemeralToken.provider_id == app.id,
- AuthEphemeralToken.expires_at > now,
- )
- .returning(AuthEphemeralToken.username, AuthEphemeralToken.nonce)
- )
- row = consumed.one_or_none()
- await db.commit()
- if row is None:
- raise await fail(400, "invalid_grant", "unknown, expired, reused or foreign code")
- username, code_challenge = row
- if not hmac.compare_digest(app.redirect_uri, data.redirect_uri):
- raise await fail(400, "invalid_grant", "callback URL mismatch")
- if not code_challenge or not hmac.compare_digest(_s256(data.code_verifier), code_challenge):
- raise await fail(400, "invalid_grant", "PKCE verifier mismatch")
- user = await get_user_by_username(db, username)
- if user is None or not user.is_active:
- raise await fail(400, "invalid_grant", "user gone or disabled")
- app.last_used_at = now.replace(tzinfo=None)
- await clear_failed_attempts(db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT)
- await db.commit()
- logger.info("Connected app %s: user %s signed in", app.id, user.id)
- return ConnectTokenResponse(
- user=ConnectedUser(
- id=user.id,
- username=user.username,
- email=user.email,
- is_admin=user.is_admin,
- groups=sorted(g.name for g in user.groups),
- permissions=sorted(user.get_permissions()),
- ),
- issued_at=now,
- )
|