Skip to content

Add Firebase session cookie support (verify + cookie identity for Ninja/LayeredAuth) #15

Description

@lukwam

Motivation

The Firebase provider only supports ID-token Bearer auth (verify_tokenfirebase_admin.auth.verify_id_token). It has no support for Firebase session cookies (firebase_admin.auth.create_session_cookie / verify_session_cookie).

Firebase session cookies are the recommended primitive for cookie-based / SSR web sessions (a long-lived, Google-signed JWT set as an HttpOnly cookie, verified locally by the edge and authoritatively by the backend). A consumer adopting that pattern currently has to drop out of altissimo and hand-roll verification + a Ninja auth class, because:

  1. There's no verify_session_cookie anywhere in the provider/service.
  2. LayeredAuth.__call__ only delegates to identity when an Authorization: Bearer header is present — so even after suggestion: Loosen LayeredAuth identity type from NinjaHttpBearer to Protocol #10 loosened the identity type to a Protocol, a cookie-based identity can never fire in the layered/BFF pattern. Identity resolution is hard-coupled to the bearer header.

We hit this in a downstream service and implemented it locally (reference impl below); filing so it can live in the library and consumers can delete their bolt-on.

Proposed API surface

Provider (providers/firebase.py):

@staticmethod
def verify_session_cookie(cookie: str, *, check_revoked: bool = False) -> FirebaseUser:
    """Verify a Firebase session cookie → FirebaseUser. Mirrors verify_token."""
    # firebase_admin.auth.verify_session_cookie(cookie, check_revoked=check_revoked)
    # → map to FirebaseUser (see "Open question" re: get_user vs claims)

Service (service.py):

def validate_firebase_session(self, cookie: str, *, check_revoked: bool = False) -> FirebaseUser: ...

Ninja adapter (ninja/__init__.py):

  • A cookie auth class, e.g. FirebaseSessionAuth(cookie_name="__session", check_revoked=False), reading request.COOKIES[cookie_name], returning FirebaseUser | None.
  • A combined bearer-or-cookie identity (either a flag on FirebaseAuth, or a FirebaseIdentityAuth) so a single auth= accepts both — this is what most consumers want during a Bearer→cookie migration.

LayeredAuth — the key change: when no Authorization: Bearer header is present, still consult a cookie-capable identity before falling through to anonymous. Options:

Test stubs (testing.py):

  • StubFirebaseSessionAuth and/or make StubLayeredAuth cookie-aware, so downstream tests can exercise the cookie path.
  • Implementation note: django-ninja's TestClient builds a request whose request.COOKIES is a bare Mock (truthy .get()) unless COOKIES= is passed — the cookie reader should only trust an actual str so stubbed requests don't spuriously authenticate.

Framework parity: mirror in the fastapi and flask adapters.

Design questions to settle

  1. FirebaseUser mapping — claims vs get_user. The Bearer path does verify_id_token then get_user(uid) + _to_firebase_user (authoritative disabled/custom_claims, one RPC). For cookies we can either (a) build FirebaseUser straight from the verified cookie claims (uid/email/email_verified/name/picture — no RPC), or (b) do the same get_user round-trip for parity. Suggest (a) by default, with check_revoked=True covering the disabled/revoked cases without a second lookup.
  2. check_revoked default. Session cookies are long-lived (up to 14 days), so revocation matters more than for a 1h ID token, and verify_session_cookie(check_revoked=True) also rejects disabled users — at the cost of one Firebase RPC per request. Lean toward True as the safe default (the backend is the authoritative revocation gate), configurable off for a local-signature-only fast path.
  3. Do we also want a create_session_cookie helper in the provider (mint side), or keep minting in consumer code?

Acceptance criteria

  • FirebaseAuthProvider.verify_session_cookie(...) returning FirebaseUser, raising the same AuthUnauthorizedError/AuthForbiddenError family as verify_token.
  • AuthService.validate_firebase_session(...).
  • Ninja cookie auth class + a bearer-or-cookie identity, usable as a drop-in for FirebaseAuth().
  • LayeredAuth can resolve a cookie-based identity when no bearer header is present.
  • check_revoked plumbed through, documented, with a chosen default.
  • Test stubs support the cookie path (+ the TestClient COOKIES Mock caveat handled).
  • fastapi + flask adapter parity.
  • Docs/README updated with the session-cookie flow.

Reference implementation (downstream, works today)

Verifier + the two Ninja entry points we run in production, for reference:

def verify_session_cookie(cookie: str) -> FirebaseUser | None:
    try:
        decoded = firebase_admin.auth.verify_session_cookie(cookie, check_revoked=CHECK_REVOKED)
    except Exception:
        return None  # invalid / expired / revoked / disabled
    try:
        return FirebaseUser(
            uid=decoded["uid"],
            email=decoded.get("email"),
            email_verified=bool(decoded.get("email_verified", False)),
            display_name=decoded.get("name"),
            photo_url=decoded.get("picture"),
        )
    except Exception:
        return None

class FirebaseOrSessionAuth:  # required identity: Bearer OR cookie
    def __init__(self): self._bearer = FirebaseAuth()
    def __call__(self, request):
        user = self._bearer(request)            # Bearer first (delegates to altissimo, unchanged)
        if user is not None: return user
        cookie = _read_session_cookie(request)  # request.COOKIES[name], str-guarded
        return verify_session_cookie(cookie) if cookie else None

class SessionAwareLayeredAuth(LayeredAuth):     # public: gate + optional Bearer/cookie identity
    def __call__(self, request):
        gate = self._gate(request)
        if gate is None: return None
        request.gate_auth = gate
        auth_header = request.headers.get("Authorization", "")
        if auth_header.startswith("Bearer "):   # original behavior, unchanged
            token = auth_header[7:].strip()
            if not token: request.auth = None; return self._ANONYMOUS
            return self._identity.authenticate(request, token) or None
        cookie = _read_session_cookie(request)   # NEW: cookie fallback
        if cookie:
            user = verify_session_cookie(cookie)
            return user if user is not None else None
        request.auth = None
        return self._ANONYMOUS

Related: #10 (loosening LayeredAuth identity to a Protocol) — this is the runtime counterpart: the type is loosened, but __call__ still can't drive a non-bearer identity.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions