diff --git a/alembic/versions/20260608_1200-subsonic_app_password.py b/alembic/versions/20260608_1200-subsonic_app_password.py new file mode 100644 index 0000000..5746cff --- /dev/null +++ b/alembic/versions/20260608_1200-subsonic_app_password.py @@ -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") diff --git a/app/api/deps.py b/app/api/deps.py index 37d2565..2917434 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -10,19 +10,20 @@ from collections.abc import AsyncIterator from functools import lru_cache from typing import Annotated -from fastapi import Depends +from fastapi import Depends, Query from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession from app.application.auth_service import AuthService 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.user_service import UserService 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.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.repositories import ( SqlAlchemyAlbumRepository, @@ -64,6 +65,11 @@ def get_token_service() -> TokenService: return JwtTokenService(get_settings()) +@lru_cache +def get_subsonic_cipher() -> SubsonicCipher: + return SubsonicPasswordCipher(get_settings().subsonic_secret_key.get_secret_value()) + + # -- request-scoped services --------------------------------------------------- def get_auth_service(session: SessionDep) -> 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)] UserServiceDep = Annotated[UserService, Depends(get_user_service)] +SubsonicAuthServiceDep = Annotated[SubsonicAuthService, Depends(get_subsonic_auth_service)] # -- file storage (process-cached) --------------------------------------------- @@ -187,3 +201,29 @@ async def 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)] diff --git a/app/api/schemas/subsonic.py b/app/api/schemas/subsonic.py new file mode 100644 index 0000000..6ce0720 --- /dev/null +++ b/app/api/schemas/subsonic.py @@ -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 diff --git a/app/api/v1/admin.py b/app/api/v1/admin.py index c55235a..d1a2f35 100644 --- a/app/api/v1/admin.py +++ b/app/api/v1/admin.py @@ -9,7 +9,8 @@ from typing import Any 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 ( CreateUserRequest, ResetPasswordRequest, @@ -81,6 +82,14 @@ async def deactivate_user( 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") async def list_services(_admin: SuperUser) -> Any: ... diff --git a/app/api/v1/users.py b/app/api/v1/users.py index 8e4dd2e..60be89b 100644 --- a/app/api/v1/users.py +++ b/app/api/v1/users.py @@ -2,7 +2,8 @@ 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 router = APIRouter(prefix="/users", tags=["users"]) @@ -17,3 +18,22 @@ async def change_my_password( current_password=body.current_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)) diff --git a/app/application/subsonic_auth_service.py b/app/application/subsonic_auth_service.py new file mode 100644 index 0000000..d2a4c3f --- /dev/null +++ b/app/application/subsonic_auth_service.py @@ -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:``). 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-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 diff --git a/app/core/config.py b/app/core/config.py index d9874f4..86e2fe6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -45,6 +45,12 @@ class Settings(BaseSettings): access_token_ttl_seconds: int = 60 * 15 # 15 min 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_path: Path = Path("/data/media") transcode_cache_path: Path = Path("/data/transcode-cache") diff --git a/app/core/security.py b/app/core/security.py index af14f64..d741c1e 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -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. """ +import base64 import datetime as dt +import hashlib +import secrets import uuid import jwt +from cryptography.fernet import Fernet, InvalidToken from pwdlib import PasswordHash from app.core.config import Settings from app.domain.errors import AuthenticationError 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: """argon2id hasher with sensible defaults from pwdlib.""" diff --git a/app/domain/entities/__init__.py b/app/domain/entities/__init__.py index ebfee7d..29a8456 100644 --- a/app/domain/entities/__init__.py +++ b/app/domain/entities/__init__.py @@ -6,7 +6,7 @@ from app.domain.entities.like import Like from app.domain.entities.playlist import Playlist from app.domain.entities.storage import ObjectStat 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__ = [ "Album", @@ -16,6 +16,7 @@ __all__ = [ "ObjectStat", "PlayHistoryEntry", "Playlist", + "SubsonicCredentials", "Track", "User", ] diff --git a/app/domain/entities/user.py b/app/domain/entities/user.py index 93a725c..c4a948c 100644 --- a/app/domain/entities/user.py +++ b/app/domain/entities/user.py @@ -31,3 +31,14 @@ class Credentials: user: User 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 diff --git a/app/domain/ports.py b/app/domain/ports.py index 512d8ad..e69c9c8 100644 --- a/app/domain/ports.py +++ b/app/domain/ports.py @@ -19,6 +19,7 @@ from app.domain.entities import ( ObjectStat, PlayHistoryEntry, Playlist, + SubsonicCredentials, User, ) 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_active(self, user_id: uuid.UUID, is_active: bool) -> User: ... 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): @@ -109,6 +123,9 @@ class TrackRepository(Protocol): added_by: uuid.UUID | None, ) -> Track: ... 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( self, *, @@ -145,7 +162,14 @@ class AlbumRepository(Protocol): 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) 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]: ... diff --git a/app/infrastructure/db/models/user.py b/app/infrastructure/db/models/user.py index fbffaf8..3f0a383 100644 --- a/app/infrastructure/db/models/user.py +++ b/app/infrastructure/db/models/user.py @@ -18,6 +18,9 @@ class UserModel(UUIDPrimaryKeyMixin, TimestampMixin, Base): # 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_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): diff --git a/app/infrastructure/db/repositories/user_repository.py b/app/infrastructure/db/repositories/user_repository.py index a3a2d30..11e4b84 100644 --- a/app/infrastructure/db/repositories/user_repository.py +++ b/app/infrastructure/db/repositories/user_repository.py @@ -10,7 +10,7 @@ import uuid from sqlalchemy import func, select 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.infrastructure.db.models import UserModel @@ -91,3 +91,22 @@ class SqlAlchemyUserRepository: return ( await self._session.execute(select(func.count()).select_from(UserModel)) ).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() diff --git a/pyproject.toml b/pyproject.toml index c0894c6..077905b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ # auth "pyjwt>=2.10", "pwdlib[argon2]>=0.2.1", + # symmetric encryption for the recoverable Subsonic app-password (Fernet) + "cryptography>=44.0", # outbound http (ML client, MusicBrainz, AcoustID) "httpx>=0.28", # S3-compatible object storage @@ -71,6 +73,11 @@ select = [ ] 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] python_version = "3.14" strict = true diff --git a/tests/fakes.py b/tests/fakes.py index 76de7d9..aff5988 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -4,13 +4,14 @@ import datetime as dt import uuid from dataclasses import dataclass, replace -from app.domain.entities import Credentials, User +from app.domain.entities import Credentials, SubsonicCredentials, User @dataclass class _Stored: user: User password_hash: str + subsonic_password_enc: str | None = None class InMemoryUserRepository: @@ -61,6 +62,22 @@ class InMemoryUserRepository: async def count(self) -> int: 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 class _Token: diff --git a/tests/test_subsonic_auth.py b/tests/test_subsonic_auth.py new file mode 100644 index 0000000..d6d84ca --- /dev/null +++ b/tests/test_subsonic_auth.py @@ -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 diff --git a/uv.lock b/uv.lock index ead3615..8d586f4 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ] +[[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]] name = "fastapi" version = "0.136.3" @@ -724,6 +777,7 @@ dependencies = [ { name = "alembic" }, { name = "arq" }, { name = "asyncpg" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, { name = "pwdlib", extra = ["argon2"] }, @@ -752,6 +806,7 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14" }, { name = "arq", specifier = ">=0.26" }, { name = "asyncpg", specifier = ">=0.30" }, + { name = "cryptography", specifier = ">=44.0" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.2.1" },