From 6cdba4a645e642a5a56c68405d33b0bbcb7c9282 Mon Sep 17 00:00:00 2001 From: xiejava Date: Wed, 2 Sep 2026 20:41:31 +0800 Subject: [PATCH] feat(auth): make bearer-token resolution pluggable via AuthBackend protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today, non-browser requests to Flocks authenticate via a single shared API token stored in .secret.json. Leaking it grants admin-equivalent access across all tenants — incompatible with multi-tenant service-to- service deployments (e.g. a facade issuing per-team JWTs). Extend the existing AuthBackend protocol with two OPTIONAL methods: - supports_bearer_token() default False - authenticate_bearer_token(token) default None Backends that override these opt in to serving Bearer auth themselves. The follow-up server-side PR will consult supports_bearer_token() in apply_auth_for_request and route accordingly; until then this PR is a pure protocol extension with zero behavior change. Use case: a JWTAuthBackend (sample in a separate PR) decodes a short-lived per-team JWT, returns LocalUser with tenant_ids populated, and gives the facade real per-team isolation in Flocks without sharing the API token across tenants. Refs: - PoC reference implementation (not part of this PR): github.com/xiejava1018/flocks @ workshop/poc-c0 - Design: see PR description --- flocks/auth/backend.py | 34 ++++++++++++++ tests/test_auth_backend_bearer_protocol.py | 52 ++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/test_auth_backend_bearer_protocol.py 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