mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
49 lines
2.2 KiB
Python
49 lines
2.2 KiB
Python
"""Connected apps: external applications that sign users in with Bambuddy.
|
|
|
|
A connected app is registered by an admin with one exact callback URL. It
|
|
signs a user in through a minimal OAuth 2.0 authorization-code flow with PKCE
|
|
(see ``api/routes/connected_apps.py``): Bambuddy hands the app a single-use,
|
|
60-second code, and the app's server swaps it for the user's identity and
|
|
permissions. The app never sees the user's Bambuddy login token.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from backend.app.core.database import Base
|
|
|
|
|
|
class ConnectedApp(Base):
|
|
__tablename__ = "connected_apps"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(100))
|
|
# Public identifier the app sends on every request.
|
|
client_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
|
# bcrypt hash; the secret itself is shown once, at creation or rotation.
|
|
client_secret_hash: Mapped[str] = mapped_column(String(255))
|
|
# Codes are only ever delivered here. Compared exactly, never by prefix.
|
|
redirect_uri: Mapped[str] = mapped_column(String(500))
|
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
|
|
|
|
class ConnectedAppGrant(Base):
|
|
"""A user's consent for one app, so the consent screen is shown only once.
|
|
|
|
Deleting the app or the user removes the grant, and the next sign-in asks
|
|
again.
|
|
"""
|
|
|
|
__tablename__ = "connected_app_grants"
|
|
__table_args__ = (Index("uq_connected_app_grants_app_user", "app_id", "user_id", unique=True),)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
app_id: Mapped[int] = mapped_column(ForeignKey("connected_apps.id", ondelete="CASCADE"), index=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
|
granted_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|