diff --git a/flocks/auth/backend.py b/flocks/auth/backend.py index 93af8034a..23cd315d3 100644 --- a/flocks/auth/backend.py +++ b/flocks/auth/backend.py @@ -98,3 +98,37 @@ async def reassign_orphan_sessions( @classmethod async def migrate_legacy_sessions_to_admin(cls, admin_user_id: str) -> None: ... + # ----- Optional: bearer / machine-to-machine auth (proposed) ----- + # + # These two methods are OPTIONAL on the protocol. Backends that do not + # implement them simply return ``False`` / ``None`` respectively, and the + # server-side auth middleware falls back to the existing API-token path. + # + # Backends that DO implement them (e.g. a JWT / OIDC backend) take over + # Bearer token authentication for non-browser, non-cookie requests. + # This unlocks service-to-service use cases without leaking the shared + # API token across deployments. + + @classmethod + async def supports_bearer_token(cls) -> bool: + """Whether this backend can authenticate non-cookie Bearer tokens. + + Default: ``False``. Backends should override to opt in. + """ + return False + + @classmethod + async def authenticate_bearer_token( + cls, + token: str, + *, + audience: Optional[str] = None, + ) -> Optional["LocalUser"]: + """Resolve a Bearer token to a LocalUser. + + Default: ``None``. Backends that override ``supports_bearer_token`` + to ``True`` must also override this to return the LocalUser whose + claims match the token, or ``None`` to indicate invalid/untrusted. + """ + return None + diff --git a/tests/test_auth_backend_bearer_protocol.py b/tests/test_auth_backend_bearer_protocol.py new file mode 100644 index 000000000..69668afdf --- /dev/null +++ b/tests/test_auth_backend_bearer_protocol.py @@ -0,0 +1,52 @@ +"""Tests for the optional bearer-token AuthBackend protocol methods. + +These methods default to "no opt-in" so existing backends keep working. +""" + +from __future__ import annotations + +import pytest + + +class _DefaultBackend: + """Mirrors the default implementation of the new optional methods. + + Doesn't subclass anything — just verifies that callers can rely on the + defaults returning False / None. + """ + + @classmethod + async def supports_bearer_token(cls) -> bool: + return False + + @classmethod + async def authenticate_bearer_token(cls, token: str, *, audience=None): + return None + + +@pytest.mark.asyncio +async def test_default_supports_bearer_token_is_false(): + assert await _DefaultBackend.supports_bearer_token() is False + + +@pytest.mark.asyncio +async def test_default_authenticate_bearer_token_is_none(): + assert await _DefaultBackend.authenticate_bearer_token("anything") is None + + +@pytest.mark.asyncio +async def test_opt_in_backend_overrides(): + """A backend that opts in returns True and resolves the token.""" + + class _OptInBackend: + @classmethod + async def supports_bearer_token(cls) -> bool: + return True + + @classmethod + async def authenticate_bearer_token(cls, token: str, *, audience=None): + return f"local-user-for-{token}-aud-{audience}" + + assert await _OptInBackend.supports_bearer_token() is True + user = await _OptInBackend.authenticate_bearer_token("xyz", audience="flocks") + assert user == "local-user-for-xyz-aud-flocks" \ No newline at end of file