Files
client/specs/features/003-bff-sessions.md
2026-03-07 19:46:19 +01:00

11 KiB

Spec 003: BFF Session Management

Status: Phase 2 Complete (34 tests passing)

Problem

The current auth implementation stores forest-server's raw access_token and refresh_token directly in browser cookies. This has several problems:

  1. Security: Forest-server credentials are exposed to the browser. If XSS ever bypasses HttpOnly (or we need to read auth state client-side), the raw tokens are right there.
  2. No transparent refresh: The extractor checks cookie existence but can't detect token expiry. When the access_token expires, get_user() fails and the user gets redirected to login - even though the refresh_token is still valid. Users get randomly logged out.
  3. No user caching: Every authenticated page makes 2-3 gRPC round-trips (token_info + get_user + page-specific call). For server-rendered pages this is noticeable latency.
  4. No session concept: There's no way to list active sessions, revoke sessions, or track "last seen". The server is stateless in a way that hurts the product.

Solution: Backend-for-Frontend (BFF) Sessions

Forage server owns sessions. The browser gets an opaque session ID cookie. Forest-server tokens and cached user info live server-side only.

Browser  --[forage_session cookie]--> forage-server --[access_token]--> forest-server
                                          |
                                    [session store]
                                    sid -> { access_token, refresh_token,
                                             expires_at, user_cache }

Architecture

Session Store Trait

A trait in forage-core so the store is swappable and testable:

#[async_trait]
pub trait SessionStore: Send + Sync {
    async fn create(&self, data: SessionData) -> Result<SessionId, SessionError>;
    async fn get(&self, id: &SessionId) -> Result<Option<SessionData>, SessionError>;
    async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError>;
    async fn delete(&self, id: &SessionId) -> Result<(), SessionError>;
}

SessionId

An opaque, cryptographically random token. Not a UUID - use 32 bytes of rand::OsRng encoded as base64url. This is the only thing the browser sees.

SessionData

pub struct SessionData {
    pub access_token: String,
    pub refresh_token: String,
    pub access_expires_at: chrono::DateTime<Utc>,  // computed from expires_in_seconds
    pub user: Option<CachedUser>,                   // cached to avoid repeated get_user calls
    pub created_at: chrono::DateTime<Utc>,
    pub last_seen_at: chrono::DateTime<Utc>,
}

pub struct CachedUser {
    pub user_id: String,
    pub username: String,
    pub emails: Vec<UserEmail>,
}

In-Memory Store (Phase 1)

HashMap<SessionId, SessionData> behind a RwLock. Good enough for single-instance deployment. A background task reaps expired sessions periodically.

This is sufficient for now. When forage needs horizontal scaling, swap to a Redis or PostgreSQL-backed store behind the same trait.

Single cookie: forage_session

  • Value: the opaque SessionId (base64url, ~43 chars)
  • HttpOnly: yes
  • Secure: yes (always - even if we need to configure for local dev)
  • SameSite: Lax
  • Path: /
  • Max-Age: 30 days (the session lifetime, not the access token lifetime)

The previous forage_access and forage_refresh cookies are removed entirely.

Behavioral Contract

Login / Register Flow

  1. User submits login/signup form
  2. Forage calls forest-server's Login/Register RPC, gets AuthTokens
  3. Forage computes access_expires_at = now + expires_in_seconds
  4. Forage calls get_user to populate the user cache
  5. Forage creates a session in the store with tokens + user cache
  6. Forage sets forage_session cookie with the session ID
  7. Redirect to /dashboard

Authenticated Request Flow

  1. Extract forage_session cookie
  2. Look up session in store
  3. If no session: redirect to /login
  4. If access_expires_at is in the future (with margin): use cached access_token
  5. If access_token is expired or near-expiry (< 60s remaining): a. Call forest-server's RefreshToken RPC with the stored refresh_token b. On success: update session with new tokens + new expiry c. On failure (refresh_token also expired): delete session, redirect to /login
  6. Return session to the route handler (which has access_token + cached user)

Logout Flow

  1. Extract session ID from cookie
  2. Get refresh_token from session store
  3. Call forest-server's Logout RPC (best-effort)
  4. Delete session from store
  5. Clear the forage_session cookie
  6. Redirect to /

Session Expiry

  • Sessions expire after 30 days of inactivity (configurable)
  • last_seen_at is updated on each request
  • A background reaper runs every 5 minutes, removes sessions where last_seen_at + 30 days < now
  • If the refresh_token is rejected by forest-server, the session is destroyed immediately regardless of age

Changes to Existing Code

What Gets Replaced

  • auth.rs: MaybeAuth and RequireAuth extractors rewritten to use session store
  • auth.rs: auth_cookies() and clear_cookies() replaced with session cookie helpers
  • routes/auth.rs: Login/signup handlers create sessions instead of setting token cookies
  • routes/auth.rs: Logout handler destroys session
  • routes/auth.rs: Dashboard and token pages use session.user cache instead of calling get_user() every time

What Stays the Same

  • ForestAuth trait and GrpcForestClient - unchanged, still the interface to forest-server
  • Validation functions in forage-core - unchanged
  • Templates - unchanged (they receive the same data)
  • Route structure and URLs - unchanged
  • All existing tests continue to pass (mock gets wrapped in mock session store)

New Dependencies

  • rand (workspace): for cryptographic session ID generation
  • No new external session framework - the store is simple enough to own

AppState Changes

pub struct AppState {
    pub templates: TemplateEngine,
    pub forest_client: Arc<dyn ForestAuth>,
    pub sessions: Arc<dyn SessionStore>,  // NEW
}

Extractors (New Design)

Session extractor (replaces RequireAuth)

Extracts the session, handles refresh transparently, provides both the access_token (for forest-server calls that aren't cached) and cached user info.

pub struct Session {
    pub session_id: SessionId,
    pub access_token: String,
    pub user: CachedUser,
}

The extractor:

  1. Reads cookie
  2. Looks up session
  3. Refreshes token if needed (updating the store)
  4. Returns Session or redirects to /login

Because refresh updates the session store (not the cookie), no response headers need to be set during extraction. The cookie stays the same.

MaybeSession extractor (replaces MaybeAuth)

Same as Session but returns Option<Session> instead of redirecting. Used for pages like /signup and /login that behave differently when already authenticated.

Edge Cases

  • Concurrent requests during refresh: Two requests arrive with the same expired access_token. Both try to refresh. The session store update is behind a RwLock, so the second one will see the already-refreshed token. Alternatively, use a per-session Mutex for refresh operations to avoid double-refresh. Start simple (accept occasional double-refresh), optimize if it becomes a problem.

  • Session ID collision: 32 bytes of crypto-random = 256 bits of entropy. Collision probability is negligible.

  • Store grows unbounded: The reaper task handles this. For in-memory store, also enforce a max session count (e.g., 100k) as a safety valve.

  • Server restart loses all sessions: Yes. In-memory store is not durable. All users will need to re-login after a deploy. This is acceptable for now and is the primary motivation for eventually moving to Redis/Postgres.

  • Cookie without valid session: Treat as unauthenticated. Clear the stale cookie.

  • Forest-server down during refresh: Keep the existing session alive with the expired access_token. The next forest-server call will fail, and the route handler deals with it (same as today). Don't destroy the session just because refresh failed due to network - only destroy it if forest-server explicitly rejects the refresh token.

Test Strategy

Unit Tests (forage-core)

  • SessionId generation: length, format, uniqueness (generate 1000, assert no dupes)
  • SessionData expiry logic: is_access_expired(), needs_refresh() (with margin)
  • InMemorySessionStore: create/get/update/delete round-trip
  • InMemorySessionStore: get non-existent returns None
  • InMemorySessionStore: delete then get returns None

Integration Tests (forage-server)

All existing tests must continue passing. Additionally:

  • Login creates a session and sets forage_session cookie (not forage_access)
  • Dashboard with valid session cookie returns 200 with user content
  • Dashboard with expired access_token (but valid refresh) still returns 200 (transparent refresh)
  • Dashboard with expired session redirects to /login
  • Logout destroys session and clears cookie
  • Signup creates session same as login
  • Old forage_access / forage_refresh cookies are ignored (clean break)

Mock Session Store

For tests, use InMemorySessionStore directly (it's already simple). The mock ForestClient stays as-is for controlling gRPC behavior.

Implementation Order

  1. Add SessionId, SessionData, SessionStore trait, InMemorySessionStore to forage-core
  2. Add unit tests for session types and in-memory store
  3. Add rand dependency, implement SessionId::generate()
  4. Rewrite auth.rs extractors to use session store
  5. Rewrite route handlers to use new extractors
  6. Update AppState to include session store
  7. Update main.rs to create the in-memory store
  8. Update integration tests
  9. Add session reaper background task
  10. Remove old cookie helpers and constants

What This Does NOT Do

  • No Redis/Postgres session store yet (in-memory only)
  • No "active sessions" UI for users
  • No CSRF protection (SameSite=Lax is sufficient for form POSTs from same origin)
  • No session fixation protection beyond generating new IDs on login
  • No rate limiting on session creation (defer to forest-server's rate limiting)

Open Questions

  1. Should we invalidate all sessions for a user when they change their password? (Requires either forest-server notification or polling.)
  2. Session cookie name: forage_session or __Host-forage_session? (__Host- prefix forces Secure + no Domain + Path=/, which is stricter.)
  3. Should the user cache have a separate TTL (e.g., refresh user info every 5 min)? Or only refresh on explicit actions like "edit profile"?