feat(subsonic): per-user encrypted app-password foundation

Subsonic auth (t=md5(password+salt), legacy p=) needs a recoverable secret,
but login passwords are stored as a one-way argon2 hash. Add a separate,
per-user app-password: high-entropy, random, and encrypted at rest with a
Fernet key derived from SUBSONIC_SECRET_KEY (never stored in the DB).

- SubsonicPasswordCipher + generate_subsonic_password in core.security
- users.subsonic_password_enc column (+ Alembic migration), repo + port methods
- SubsonicAuthService: verify (t+s / p / p=enc:) and rotate/reveal lifecycle
- self-service GET/POST /users/me/subsonic-password + admin rotate endpoint
- domain SubsonicCredentials + SubsonicCipher port; deps wiring

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Senko-san
2026-06-08 18:23:19 +03:00
parent 4ade6939b6
commit 7a17e3babd
17 changed files with 535 additions and 9 deletions
@@ -0,0 +1,32 @@
"""subsonic: per-user encrypted app-password
Revision ID: 20260608_subsonic_pw
Revises: 20260608_storage_uri
Create Date: 2026-06-08 12:00:00.000000
Adds ``users.subsonic_password_enc`` — the recoverable, Fernet-encrypted
Subsonic app-password (plan §7). NULL until the user generates one.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260608_subsonic_pw"
down_revision: str | None = "20260608_storage_uri"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("subsonic_password_enc", sa.String(length=255), nullable=True),
)
def downgrade() -> None:
op.drop_column("users", "subsonic_password_enc")
+43 -3
View File
@@ -10,19 +10,20 @@ from collections.abc import AsyncIterator
from functools import lru_cache from functools import lru_cache
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends, Query
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.application.auth_service import AuthService from app.application.auth_service import AuthService
from app.application.streaming_service import StreamingService from app.application.streaming_service import StreamingService
from app.application.subsonic_auth_service import SubsonicAuthService
from app.application.upload_service import UploadService from app.application.upload_service import UploadService
from app.application.user_service import UserService from app.application.user_service import UserService
from app.core.config import get_settings from app.core.config import get_settings
from app.core.security import Argon2PasswordHasher, JwtTokenService from app.core.security import Argon2PasswordHasher, JwtTokenService, SubsonicPasswordCipher
from app.domain.entities import User from app.domain.entities import User
from app.domain.errors import AuthenticationError, PermissionDeniedError from app.domain.errors import AuthenticationError, PermissionDeniedError
from app.domain.ports import FileStorage, PasswordHasher, TokenService from app.domain.ports import FileStorage, PasswordHasher, SubsonicCipher, TokenService
from app.infrastructure.db import get_sessionmaker from app.infrastructure.db import get_sessionmaker
from app.infrastructure.db.repositories import ( from app.infrastructure.db.repositories import (
SqlAlchemyAlbumRepository, SqlAlchemyAlbumRepository,
@@ -64,6 +65,11 @@ def get_token_service() -> TokenService:
return JwtTokenService(get_settings()) return JwtTokenService(get_settings())
@lru_cache
def get_subsonic_cipher() -> SubsonicCipher:
return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value())
# -- request-scoped services --------------------------------------------------- # -- request-scoped services ---------------------------------------------------
def get_auth_service(session: SessionDep) -> AuthService: def get_auth_service(session: SessionDep) -> AuthService:
return AuthService( return AuthService(
@@ -82,8 +88,16 @@ def get_user_service(session: SessionDep) -> UserService:
) )
def get_subsonic_auth_service(session: SessionDep) -> SubsonicAuthService:
return SubsonicAuthService(
users=SqlAlchemyUserRepository(session),
cipher=get_subsonic_cipher(),
)
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
UserServiceDep = Annotated[UserService, Depends(get_user_service)] UserServiceDep = Annotated[UserService, Depends(get_user_service)]
SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)]
# -- file storage (process-cached) --------------------------------------------- # -- file storage (process-cached) ---------------------------------------------
@@ -187,3 +201,29 @@ async def get_streaming_user(
StreamUser = Annotated[User, Depends(get_streaming_user)] StreamUser = Annotated[User, Depends(get_streaming_user)]
# -- subsonic (/rest) authentication -------------------------------------------
# Subsonic puts credentials in the query string: u + (t & s) | p, plus c/v/f.
# The dep extracts them and delegates verification to the service; domain errors
# propagate to the rest-aware exception handler, which renders the Subsonic
# error envelope (HTTP 200). HTTPS is mandatory — the secret rides in the URL.
async def get_subsonic_user(
service: SubsonicAuthServiceDep,
u: Annotated[str | None, Query()] = None,
t: Annotated[str | None, Query()] = None,
s: Annotated[str | None, Query()] = None,
p: Annotated[str | None, Query()] = None,
) -> User:
return await service.authenticate(username=u, token=t, salt=s, password=p)
SubsonicUser = Annotated[User, Depends(get_subsonic_user)]
async def get_subsonic_format(f: Annotated[str | None, Query()] = None) -> str | None:
"""The requested response format (``f``): ``xml`` (default) or ``json``."""
return f
SubsonicFormat = Annotated[str | None, Depends(get_subsonic_format)]
+13
View File
@@ -0,0 +1,13 @@
"""Schemas for Subsonic app-password self-service (native /api/v1 surface).
The Subsonic /rest layer itself returns its own XML/JSON envelope, not these
pydantic models — these only back the lifecycle endpoints that reveal/rotate the
recoverable app-password."""
from pydantic import BaseModel
class SubsonicPasswordResponse(BaseModel):
"""The plaintext Subsonic app-password, for pasting into a client."""
password: str
+10 -1
View File
@@ -9,7 +9,8 @@ from typing import Any
from fastapi import APIRouter, Query, status from fastapi import APIRouter, Query, status
from app.api.deps import SuperUser, UserServiceDep from app.api.deps import SubsonicAuthServiceDep, SuperUser, UserServiceDep
from app.api.schemas.subsonic import SubsonicPasswordResponse
from app.api.schemas.user import ( from app.api.schemas.user import (
CreateUserRequest, CreateUserRequest,
ResetPasswordRequest, ResetPasswordRequest,
@@ -81,6 +82,14 @@ async def deactivate_user(
return UserResponse.from_entity(await users.deactivate(user_id)) return UserResponse.from_entity(await users.deactivate(user_id))
@router.post("/users/{user_id}/subsonic-password", response_model=SubsonicPasswordResponse)
async def rotate_user_subsonic_password(
user_id: uuid.UUID, _admin: SuperUser, subsonic: SubsonicAuthServiceDep
) -> SubsonicPasswordResponse:
"""Rotate any user's Subsonic app-password and return the new plaintext."""
return SubsonicPasswordResponse(password=await subsonic.rotate(user_id))
@router.get("/services") @router.get("/services")
async def list_services(_admin: SuperUser) -> Any: ... async def list_services(_admin: SuperUser) -> Any: ...
+21 -1
View File
@@ -2,7 +2,8 @@
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, UserServiceDep from app.api.deps import CurrentUser, SubsonicAuthServiceDep, UserServiceDep
from app.api.schemas.subsonic import SubsonicPasswordResponse
from app.api.schemas.user import ChangePasswordRequest from app.api.schemas.user import ChangePasswordRequest
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -17,3 +18,22 @@ async def change_my_password(
current_password=body.current_password, current_password=body.current_password,
new_password=body.new_password, new_password=body.new_password,
) )
@router.get("/me/subsonic-password", response_model=SubsonicPasswordResponse)
async def reveal_my_subsonic_password(
user: CurrentUser, subsonic: SubsonicAuthServiceDep
) -> SubsonicPasswordResponse:
"""Reveal the caller's Subsonic app-password for copying into a client.
It's recoverable, so it can be read on demand; one is generated lazily on
first access. Paste it (with the username) into Symfonium/DSub."""
return SubsonicPasswordResponse(password=await subsonic.reveal(user.id))
@router.post("/me/subsonic-password", response_model=SubsonicPasswordResponse)
async def rotate_my_subsonic_password(
user: CurrentUser, subsonic: SubsonicAuthServiceDep
) -> SubsonicPasswordResponse:
"""Rotate the caller's Subsonic app-password (invalidates the previous one)."""
return SubsonicPasswordResponse(password=await subsonic.rotate(user.id))
+100
View File
@@ -0,0 +1,100 @@
"""SubsonicAuthService — app-password lifecycle + Subsonic auth verification.
The Subsonic protocol authenticates with either ``t=md5(password+salt)`` (+``s``)
or the legacy ``p=`` (plaintext or ``enc:<hex>``). Both need a *recoverable*
secret server-side, so Subsonic clients authenticate against a dedicated,
high-entropy app-password — never the argon2 login password. That app-password
is encrypted at rest (:class:`~app.domain.ports.SubsonicCipher`) and decrypted
only here, to verify a request or to reveal it for copying into a client.
This is an adapter over the existing user store; it adds no business state of its
own beyond the app-password column (CLAUDE.md: Subsonic is an adapter, not a
reimplementation).
"""
import hashlib
import hmac
import uuid
from app.core.security import generate_subsonic_password
from app.domain.entities import User
from app.domain.errors import AuthenticationError, NotFoundError, ValidationError
from app.domain.ports import SubsonicCipher, UserRepository
def _md5_hex(value: str) -> str:
return hashlib.md5(value.encode("utf-8"), usedforsecurity=False).hexdigest()
def _decode_legacy_password(p: str) -> str:
"""Decode a Subsonic ``p`` param: ``enc:<hex>`` (hex-encoded) or plaintext."""
if p.startswith("enc:"):
try:
return bytes.fromhex(p[4:]).decode("utf-8")
except ValueError as exc:
raise AuthenticationError("Wrong username or password.") from exc
return p
class SubsonicAuthService:
def __init__(self, *, users: UserRepository, cipher: SubsonicCipher) -> None:
self._users = users
self._cipher = cipher
async def authenticate(
self,
*,
username: str | None,
token: str | None,
salt: str | None,
password: str | None,
) -> User:
"""Resolve Subsonic query auth params to a domain :class:`User`.
Raises :class:`ValidationError` (Subsonic code 10) for missing params and
:class:`AuthenticationError` (code 40) for any credential mismatch — an
unknown user is reported identically to a wrong password (no enumeration).
"""
if not username:
raise ValidationError("Required parameter 'u' is missing.")
if not ((token and salt) or password):
raise ValidationError("Required authentication parameter is missing.")
creds = await self._users.get_subsonic_credentials_by_username(username)
if creds is None or not creds.user.is_active or creds.password_enc is None:
raise AuthenticationError("Wrong username or password.")
app_password = self._cipher.decrypt(creds.password_enc)
if token and salt:
expected = _md5_hex(app_password + salt)
if not hmac.compare_digest(expected, token.lower()):
raise AuthenticationError("Wrong username or password.")
else:
assert password is not None # guaranteed by the missing-param check above
supplied = _decode_legacy_password(password)
if not hmac.compare_digest(supplied, app_password):
raise AuthenticationError("Wrong username or password.")
return creds.user
async def rotate(self, user_id: uuid.UUID) -> str:
"""Generate a fresh app-password, store it encrypted, return the plaintext."""
await self._require_user(user_id)
password = generate_subsonic_password()
await self._users.set_subsonic_password_enc(user_id, self._cipher.encrypt(password))
return password
async def reveal(self, user_id: uuid.UUID) -> str:
"""Return the current app-password, generating one on first access."""
await self._require_user(user_id)
enc = await self._users.get_subsonic_password_enc(user_id)
if enc is None:
return await self.rotate(user_id)
return self._cipher.decrypt(enc)
async def _require_user(self, user_id: uuid.UUID) -> User:
user = await self._users.get_by_id(user_id)
if user is None:
raise NotFoundError("User not found.")
return user
+6
View File
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
access_token_ttl_seconds: int = 60 * 15 # 15 min access_token_ttl_seconds: int = 60 * 15 # 15 min
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first) refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
# -- subsonic ---------------------------------------------------------
# Symmetric key (any string) used to encrypt each user's recoverable
# Subsonic app-password at rest. A Fernet key is derived from it; rotating
# this value renders stored app-passwords undecryptable (rotate them too).
subsonic_secret_key: SecretStr = SecretStr("change-me-subsonic-key")
# -- media / storage -------------------------------------------------- # -- media / storage --------------------------------------------------
media_path: Path = Path("/data/media") media_path: Path = Path("/data/media")
transcode_cache_path: Path = Path("/data/transcode-cache") transcode_cache_path: Path = Path("/data/transcode-cache")
+38
View File
@@ -6,16 +6,54 @@ to this module (CLAUDE.md: security is a cross-cutting concern in ``core``).
Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly. Higher layers depend only on the Protocols, never on pwdlib/pyjwt directly.
""" """
import base64
import datetime as dt import datetime as dt
import hashlib
import secrets
import uuid import uuid
import jwt import jwt
from cryptography.fernet import Fernet, InvalidToken
from pwdlib import PasswordHash from pwdlib import PasswordHash
from app.core.config import Settings from app.core.config import Settings
from app.domain.errors import AuthenticationError from app.domain.errors import AuthenticationError
from app.domain.tokens import IssuedToken, TokenClaims, TokenType from app.domain.tokens import IssuedToken, TokenClaims, TokenType
# Length (in bytes of entropy) of a generated Subsonic app-password. 18 bytes of
# url-safe base64 → 24 characters, well above the Subsonic auth threat model.
_SUBSONIC_PASSWORD_ENTROPY_BYTES = 18
def generate_subsonic_password() -> str:
"""A fresh, high-entropy Subsonic app-password (url-safe, ~24 chars)."""
return secrets.token_urlsafe(_SUBSONIC_PASSWORD_ENTROPY_BYTES)
class SubsonicPasswordCipher:
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password.
Subsonic auth (``t=md5(password+salt)`` and legacy ``p=``) needs the plaintext
password server-side, so — unlike the argon2-hashed login password — the
app-password is stored *encrypted*, not hashed. A Fernet key (AES-128-CBC +
HMAC) is derived from the configured secret; the plaintext key never touches
the DB. Implements :class:`app.domain.ports.SubsonicCipher`.
"""
def __init__(self, secret_key: str) -> None:
digest = hashlib.sha256(secret_key.encode("utf-8")).digest()
self._fernet = Fernet(base64.urlsafe_b64encode(digest))
def encrypt(self, plaintext: str) -> str:
return self._fernet.encrypt(plaintext.encode("utf-8")).decode("ascii")
def decrypt(self, token: str) -> str:
try:
return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
except InvalidToken as exc:
# Wrong/rotated secret key, or corrupted ciphertext.
raise AuthenticationError("Stored Subsonic password could not be decrypted.") from exc
class Argon2PasswordHasher: class Argon2PasswordHasher:
"""argon2id hasher with sensible defaults from pwdlib.""" """argon2id hasher with sensible defaults from pwdlib."""
+2 -1
View File
@@ -6,7 +6,7 @@ from app.domain.entities.like import Like
from app.domain.entities.playlist import Playlist from app.domain.entities.playlist import Playlist
from app.domain.entities.storage import ObjectStat from app.domain.entities.storage import ObjectStat
from app.domain.entities.track import Artist, Track from app.domain.entities.track import Artist, Track
from app.domain.entities.user import Credentials, User from app.domain.entities.user import Credentials, SubsonicCredentials, User
__all__ = [ __all__ = [
"Album", "Album",
@@ -16,6 +16,7 @@ __all__ = [
"ObjectStat", "ObjectStat",
"PlayHistoryEntry", "PlayHistoryEntry",
"Playlist", "Playlist",
"SubsonicCredentials",
"Track", "Track",
"User", "User",
] ]
+11
View File
@@ -31,3 +31,14 @@ class Credentials:
user: User user: User
password_hash: str password_hash: str
@dataclass(frozen=True, slots=True)
class SubsonicCredentials:
"""A user paired with their *encrypted* Subsonic app-password.
``password_enc`` is ``None`` until the user generates one. Stays inside the
application layer; the plaintext is only recovered for auth verification."""
user: User
password_enc: str | None
+25 -1
View File
@@ -19,6 +19,7 @@ from app.domain.entities import (
ObjectStat, ObjectStat,
PlayHistoryEntry, PlayHistoryEntry,
Playlist, Playlist,
SubsonicCredentials,
User, User,
) )
from app.domain.entities.track import Artist, Track from app.domain.entities.track import Artist, Track
@@ -34,6 +35,19 @@ class UserRepository(Protocol):
async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ... async def set_superuser(self, user_id: uuid.UUID, is_superuser: bool) -> User: ...
async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ... async def set_active(self, user_id: uuid.UUID, is_active: bool) -> User: ...
async def count(self) -> int: ... async def count(self) -> int: ...
# -- subsonic app-password (recoverable, encrypted at rest) ----------
async def get_subsonic_credentials_by_username(
self, username: str
) -> SubsonicCredentials | None: ...
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None: ...
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None: ...
class SubsonicCipher(Protocol):
"""Symmetric encrypt/decrypt for the recoverable Subsonic app-password."""
def encrypt(self, plaintext: str) -> str: ...
def decrypt(self, token: str) -> str: ...
class RefreshTokenRepository(Protocol): class RefreshTokenRepository(Protocol):
@@ -109,6 +123,9 @@ class TrackRepository(Protocol):
added_by: uuid.UUID | None, added_by: uuid.UUID | None,
) -> Track: ... ) -> Track: ...
async def delete(self, track_id: uuid.UUID) -> None: ... async def delete(self, track_id: uuid.UUID) -> None: ...
# genres must come before ``list`` — the method named ``list`` shadows the
# builtin in later annotations (same pattern as AlbumRepository below).
async def genres(self) -> list[tuple[str, int]]: ...
async def list( async def list(
self, self,
*, *,
@@ -145,7 +162,14 @@ class AlbumRepository(Protocol):
async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ... async def track_count_many(self, album_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: ...
# list must come after any method using list[...] in its signature (name shadowing) # list must come after any method using list[...] in its signature (name shadowing)
async def list( async def list(
self, *, artist_id: uuid.UUID | None, q: str | None, limit: int, offset: int self,
*,
artist_id: uuid.UUID | None,
q: str | None,
limit: int,
offset: int,
sort_by: str = "title",
order: str = "asc",
) -> list[Album]: ... ) -> list[Album]: ...
+3
View File
@@ -18,6 +18,9 @@ class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
# Admin is a single flag in Phase 1 — no role system (plan §3.5). # Admin is a single flag in Phase 1 — no role system (plan §3.5).
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) is_superuser: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# Recoverable Subsonic app-password, Fernet-encrypted at rest. NULL until the
# user generates one. Never the argon2 login password — see core.security.
subsonic_password_enc: Mapped[str | None] = mapped_column(String(255), nullable=True)
class RefreshTokenModel(UUIDPrimaryKeyMixin, Base): class RefreshTokenModel(UUIDPrimaryKeyMixin, Base):
@@ -10,7 +10,7 @@ import uuid
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities import Credentials, User from app.domain.entities import Credentials, SubsonicCredentials, User
from app.domain.errors import NotFoundError from app.domain.errors import NotFoundError
from app.infrastructure.db.models import UserModel from app.infrastructure.db.models import UserModel
@@ -91,3 +91,22 @@ class SqlAlchemyUserRepository:
return ( return (
await self._session.execute(select(func.count()).select_from(UserModel)) await self._session.execute(select(func.count()).select_from(UserModel))
).scalar_one() ).scalar_one()
async def get_subsonic_credentials_by_username(
self, username: str
) -> SubsonicCredentials | None:
row = (
await self._session.execute(select(UserModel).where(UserModel.username == username))
).scalar_one_or_none()
if row is None:
return None
return SubsonicCredentials(user=_to_entity(row), password_enc=row.subsonic_password_enc)
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
row = await self._get_row(user_id)
return row.subsonic_password_enc
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
row = await self._get_row(user_id)
row.subsonic_password_enc = password_enc
await self._session.flush()
+7
View File
@@ -21,6 +21,8 @@ dependencies = [
# auth # auth
"pyjwt>=2.10", "pyjwt>=2.10",
"pwdlib[argon2]>=0.2.1", "pwdlib[argon2]>=0.2.1",
# symmetric encryption for the recoverable Subsonic app-password (Fernet)
"cryptography>=44.0",
# outbound http (ML client, MusicBrainz, AcoustID) # outbound http (ML client, MusicBrainz, AcoustID)
"httpx>=0.28", "httpx>=0.28",
# S3-compatible object storage # S3-compatible object storage
@@ -71,6 +73,11 @@ select = [
] ]
ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic ignore = ["B008"] # FastAPI Depends() in defaults is idiomatic
[tool.ruff.lint.per-file-ignores]
# Subsonic query params are camelCase by spec (artistCount, songId, …); the
# handler arg names must match the wire names exactly.
"app/api/rest/*" = ["N803"]
[tool.mypy] [tool.mypy]
python_version = "3.14" python_version = "3.14"
strict = true strict = true
+18 -1
View File
@@ -4,13 +4,14 @@ import datetime as dt
import uuid import uuid
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from app.domain.entities import Credentials, User from app.domain.entities import Credentials, SubsonicCredentials, User
@dataclass @dataclass
class _Stored: class _Stored:
user: User user: User
password_hash: str password_hash: str
subsonic_password_enc: str | None = None
class InMemoryUserRepository: class InMemoryUserRepository:
@@ -61,6 +62,22 @@ class InMemoryUserRepository:
async def count(self) -> int: async def count(self) -> int:
return len(self._by_id) return len(self._by_id)
async def get_subsonic_credentials_by_username(
self, username: str
) -> SubsonicCredentials | None:
for stored in self._by_id.values():
if stored.user.username == username:
return SubsonicCredentials(
user=stored.user, password_enc=stored.subsonic_password_enc
)
return None
async def get_subsonic_password_enc(self, user_id: uuid.UUID) -> str | None:
return self._by_id[user_id].subsonic_password_enc
async def set_subsonic_password_enc(self, user_id: uuid.UUID, password_enc: str) -> None:
self._by_id[user_id].subsonic_password_enc = password_enc
@dataclass @dataclass
class _Token: class _Token:
+131
View File
@@ -0,0 +1,131 @@
"""Unit tests for SubsonicAuthService — verification + app-password lifecycle.
DB-free: uses the in-memory user repository and a real cipher.
"""
import hashlib
import pytest
from app.application.subsonic_auth_service import SubsonicAuthService
from app.core.security import SubsonicPasswordCipher
from app.domain.errors import AuthenticationError, ValidationError
from tests.fakes import InMemoryUserRepository
pytestmark = pytest.mark.asyncio
_KNOWN_PASSWORD = "s3cret-app-password"
def _md5(value: str) -> str:
return hashlib.md5(value.encode(), usedforsecurity=False).hexdigest()
async def _service_with_user(*, password: str | None = _KNOWN_PASSWORD, active: bool = True):
users = InMemoryUserRepository()
cipher = SubsonicPasswordCipher("test-key")
user = await users.add(username="alice", password_hash="x", is_superuser=False)
if not active:
await users.set_active(user.id, False)
if password is not None:
await users.set_subsonic_password_enc(user.id, cipher.encrypt(password))
service = SubsonicAuthService(users=users, cipher=cipher)
return service, user
async def test_authenticate_token_salt_success() -> None:
service, user = await _service_with_user()
salt = "abcdef"
token = _md5(_KNOWN_PASSWORD + salt)
result = await service.authenticate(username="alice", token=token, salt=salt, password=None)
assert result.id == user.id
async def test_authenticate_plain_password_success() -> None:
service, user = await _service_with_user()
result = await service.authenticate(
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
)
assert result.id == user.id
async def test_authenticate_enc_password_success() -> None:
service, user = await _service_with_user()
enc = "enc:" + _KNOWN_PASSWORD.encode().hex()
result = await service.authenticate(username="alice", token=None, salt=None, password=enc)
assert result.id == user.id
async def test_authenticate_wrong_token_fails() -> None:
service, _ = await _service_with_user()
with pytest.raises(AuthenticationError):
await service.authenticate(
username="alice", token=_md5("wrong" + "abc"), salt="abc", password=None
)
async def test_authenticate_wrong_password_fails() -> None:
service, _ = await _service_with_user()
with pytest.raises(AuthenticationError):
await service.authenticate(username="alice", token=None, salt=None, password="nope")
async def test_authenticate_unknown_user_fails() -> None:
service, _ = await _service_with_user()
with pytest.raises(AuthenticationError):
await service.authenticate(
username="ghost", token=None, salt=None, password=_KNOWN_PASSWORD
)
async def test_authenticate_inactive_user_fails() -> None:
service, _ = await _service_with_user(active=False)
with pytest.raises(AuthenticationError):
await service.authenticate(
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
)
async def test_authenticate_no_password_set_fails() -> None:
service, _ = await _service_with_user(password=None)
with pytest.raises(AuthenticationError):
await service.authenticate(
username="alice", token=None, salt=None, password=_KNOWN_PASSWORD
)
async def test_authenticate_missing_username_is_validation_error() -> None:
service, _ = await _service_with_user()
with pytest.raises(ValidationError):
await service.authenticate(username=None, token=None, salt=None, password=_KNOWN_PASSWORD)
async def test_authenticate_missing_credentials_is_validation_error() -> None:
service, _ = await _service_with_user()
with pytest.raises(ValidationError):
await service.authenticate(username="alice", token=None, salt=None, password=None)
async def test_rotate_then_authenticate() -> None:
users = InMemoryUserRepository()
cipher = SubsonicPasswordCipher("test-key")
user = await users.add(username="bob", password_hash="x", is_superuser=False)
service = SubsonicAuthService(users=users, cipher=cipher)
password = await service.rotate(user.id)
result = await service.authenticate(username="bob", token=None, salt=None, password=password)
assert result.id == user.id
async def test_reveal_generates_then_is_stable() -> None:
users = InMemoryUserRepository()
cipher = SubsonicPasswordCipher("test-key")
user = await users.add(username="cara", password_hash="x", is_superuser=False)
service = SubsonicAuthService(users=users, cipher=cipher)
first = await service.reveal(user.id)
second = await service.reveal(user.id)
assert first == second # lazily generated once, then stable
rotated = await service.rotate(user.id)
assert rotated != first
Generated
+55
View File
@@ -415,6 +415,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
] ]
[[package]]
name = "cryptography"
version = "48.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.136.3" version = "0.136.3"
@@ -724,6 +777,7 @@ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "arq" }, { name = "arq" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "cryptography" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "pwdlib", extra = ["argon2"] }, { name = "pwdlib", extra = ["argon2"] },
@@ -752,6 +806,7 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "arq", specifier = ">=0.26" }, { name = "arq", specifier = ">=0.26" },
{ name = "asyncpg", specifier = ">=0.30" }, { name = "asyncpg", specifier = ">=0.30" },
{ name = "cryptography", specifier = ">=44.0" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", specifier = ">=0.28" }, { name = "httpx", specifier = ">=0.28" },
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.2.1" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.2.1" },