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.
110 lines
3.3 KiB
Python
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
|