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

110 lines
3.3 KiB
Python

from datetime import datetime
from typing import Literal
from urllib.parse import urlsplit
from pydantic import BaseModel, Field, field_validator
from backend.app.schemas.print_queue import UTCDatetime
# RFC 7636: 43-128 chars from the unreserved set. The challenge is the
# base64url SHA-256 of the verifier, so it is always exactly 43 chars.
_PKCE_VERIFIER_PATTERN = r"^[A-Za-z0-9\-._~]{43,128}$"
_PKCE_CHALLENGE_PATTERN = r"^[A-Za-z0-9\-_]{43}$"
def _validate_redirect_uri(value: str) -> str:
"""Accept only an absolute http(s) URL without a fragment.
Plain http is allowed: connected apps typically run on the same LAN as
Bambuddy, often without TLS. What matters is that codes can only ever be
delivered to the one URL an admin registered, and that is enforced by an
exact match at authorize and token time.
"""
value = value.strip()
parts = urlsplit(value)
if parts.scheme not in ("http", "https") or not parts.netloc:
raise ValueError("Callback URL must be an absolute http:// or https:// URL")
if parts.fragment:
raise ValueError("Callback URL must not contain a #fragment")
if parts.username or parts.password:
raise ValueError("Callback URL must not contain credentials")
return value
class ConnectedAppCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
redirect_uri: str = Field(min_length=1, max_length=500)
_check_redirect = field_validator("redirect_uri")(_validate_redirect_uri)
class ConnectedAppUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
redirect_uri: str | None = Field(default=None, min_length=1, max_length=500)
enabled: bool | None = None
@field_validator("redirect_uri")
@classmethod
def _check_redirect(cls, value: str | None) -> str | None:
return None if value is None else _validate_redirect_uri(value)
class ConnectedAppResponse(BaseModel):
id: int
name: str
client_id: str
redirect_uri: str
enabled: bool
created_at: UTCDatetime
last_used_at: UTCDatetime | None = None
class Config:
from_attributes = True
class ConnectedAppSecretResponse(ConnectedAppResponse):
"""Returned by create and rotate only: the one time the secret is shown."""
client_secret: str
class ConnectAuthorizeInfo(BaseModel):
app_name: str
username: str
already_granted: bool
class ConnectAuthorizeRequest(BaseModel):
client_id: str = Field(min_length=1, max_length=64)
redirect_uri: str = Field(min_length=1, max_length=500)
code_challenge: str = Field(pattern=_PKCE_CHALLENGE_PATTERN)
code_challenge_method: Literal["S256"]
class ConnectAuthorizeResponse(BaseModel):
code: str
redirect_uri: str
class ConnectTokenRequest(BaseModel):
grant_type: Literal["authorization_code"]
code: str = Field(min_length=1, max_length=128)
redirect_uri: str = Field(min_length=1, max_length=500)
client_id: str = Field(min_length=1, max_length=64)
client_secret: str = Field(min_length=1, max_length=128)
code_verifier: str = Field(pattern=_PKCE_VERIFIER_PATTERN)
class ConnectedUser(BaseModel):
id: int
username: str
email: str | None = None
is_admin: bool
groups: list[str]
permissions: list[str]
class ConnectTokenResponse(BaseModel):
user: ConnectedUser
issued_at: datetime