Files
maziggy d56b48c499 feat(auth): connected apps - sign in to external applications with Bambuddy
Minimal OAuth 2.0 authorization-code flow with PKCE (S256): admins register
an app with one exact callback URL (Settings > API Keys > Connected Apps);
/connect/authorize asks for consent once and returns a single-use, 60 s code
bound to app, callback and challenge; POST /api/v1/connect/token swaps it,
with the client secret, for the user's identity and permissions. Codes and
secrets stored hashed, exchanges rate-limited per client and IP, no redirect
before the callback is validated, API keys cannot authorize, refused while
auth is disabled. i18n for all 15 locales.

-----

fix(db): upgrading from 0.2.4.0 or older no longer crashes at startup

The #2974 failure-reason conversion ran before the #1378 migration that adds
print_log_entries.failure_reason, so older databases stopped with "no such
column: failure_reason". It now skips a table without the column, only runs
where a legacy label exists, and on SQLite rebuilds archive_fts first, since
archives created before that index existed trip "database disk image is
malformed" when updated.
2026-09-26 12:51:53 +02:00

406 lines
16 KiB
Python

"""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,
)