Skip to content

BFF Authentication Rewrite: Cookie-based sessions with CSRF protection#1579

Merged
lukemarsden merged 12 commits intomainfrom
feature/bff-auth-clean
Feb 5, 2026
Merged

BFF Authentication Rewrite: Cookie-based sessions with CSRF protection#1579
lukemarsden merged 12 commits intomainfrom
feature/bff-auth-clean

Conversation

@lukemarsden
Copy link
Collaborator

Summary

This PR implements a Backend-For-Frontend (BFF) authentication pattern, moving all token management from the frontend to the backend. The frontend now uses simple HttpOnly session cookies instead of managing JWTs, refresh tokens, and Authorization headers.

Design Document: design/2026-02-05-bff-auth-rewrite.md

Why This Change?

The current frontend authentication is a hybrid approach that's the worst of both worlds:

  • Frontend manages tokens in 5+ different locations (axios, API client, localStorage, React state, events)
  • Frontend deals with OAuth/OIDC complexity (refresh tokens, X-Token-Refreshed headers)
  • Race conditions when tokens expire
  • Multiple interceptors on axios instances

Key Changes

Backend:

  • New UserSession table for session storage (30-day sessions)
  • SessionManager handles session lifecycle and OIDC token refresh
  • Sessions authenticated via helix_session HttpOnly cookie
  • CSRF protection via helix_csrf cookie + X-CSRF-Token header validation
  • Transparent OIDC token refresh (backend refreshes tokens before they expire)
  • API keys and runner tokens continue to work unchanged

Frontend:

  • Removed all token management code (5+ storage locations eliminated)
  • No more localStorage.setItem('token', ...)
  • No more Authorization: Bearer headers
  • No more TOKEN_REFRESHED_EVENT handling
  • Axios automatically sends cookies + CSRF header

Security

  • SameSite=Lax cookies (prevents CSRF, allows OAuth redirects)
  • Double-Submit Cookie Pattern for CSRF protection
  • HttpOnly session cookie (JS cannot access session ID)
  • Secure cookies in production (HTTPS only)

Migration

Clean cutover - users will need to log in again after deployment. No backward compatibility needed since this only affects browser-based auth. API keys work unchanged.

Test plan

  • Login with email/password - verify session cookie set
  • Login with Google OIDC - verify session cookie set
  • API calls work without Authorization header
  • Page refresh maintains authentication
  • Logout clears session
  • API key authentication still works
  • CSRF protection blocks requests without X-CSRF-Token header

🤖 Generated with Claude Code

lukemarsden and others added 8 commits February 5, 2026 09:28
Comprehensive design document for implementing Backend-For-Frontend (BFF)
authentication pattern. Key changes:

- Frontend only gets an HttpOnly session cookie (helix_session)
- No tokens in JavaScript memory
- Backend stores and refreshes OIDC tokens transparently
- Works with both regular auth (email/password) and OIDC (Google)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Updated architecture diagram to show CLI/API clients using API keys
- Clarified that API keys (hl-xxx) and runner tokens work unchanged
- Simplified migration path: clean cutover, users just log in again
- Removed backward compatibility section (not needed)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Complete backend implementation for the BFF (Backend-For-Frontend) auth pattern:

Session Management:
- Add UserSession type with OIDC token storage (api/pkg/types/session.go)
- Add SessionManager for session lifecycle (api/pkg/server/session_manager.go)
- Add session store methods (api/pkg/store/store_user_sessions.go)
- Add TokenTypeSession for regular auth sessions

Auth Middleware Updates:
- Add session-first authentication in extractMiddleware
- Check helix_session cookie before falling back to token auth
- Add getUserFromSession method for BFF session validation
- Transparent OIDC token refresh via SessionManager

Login Endpoint Updates:
- Regular login: Creates BFF session instead of returning JWT
- OIDC callback: Creates BFF session with OIDC tokens stored
- Add /api/v1/auth/session endpoint for frontend session check
- Update logout to delete BFF session

API keys and runner tokens continue to work unchanged.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Explains how the BFF pattern handles token refresh:
- Back-channel refresh using refresh tokens (not front-channel iframe)
- Why this is better than Silent Renew (no third-party cookies needed)
- Requirements for Google OIDC (OIDC_OFFLINE_ACCESS=true)
- Refresh token rotation behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove all token management code (setToken, getTokenHeaders)
- Remove TOKEN_REFRESHED_EVENT and handleTokenRefreshHeader
- Remove securityWorker from API client singleton
- Add axios.defaults.withCredentials = true for automatic cookie sending
- Add isAuthError helper to suppress auth error snackbars (handled by redirect)

Part of BFF authentication rewrite - frontend should not manage tokens.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- account.tsx: Remove token/tokenUrlEscaped from context, remove
  TOKEN_REFRESHED_EVENT listener, remove api.setToken() calls
- streaming.tsx: Remove useAccount import, remove Authorization header
  from fetch, use credentials: 'same-origin' instead
- useWebsocket.ts: Remove token dependency, cookies sent automatically
- useKnowledge.ts: Remove Authorization header, use credentials
- Account.tsx, DesignReviewContent.tsx, InteractionInference.tsx,
  KnowledgeEditor.tsx, FileStore.tsx: Replace account.token checks
  with account.user (token no longer in context)

All frontend files now use BFF pattern - no token management in JS.
Session cookie is sent automatically with all same-origin requests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Backend:
- Add helix_csrf cookie (readable by JS) alongside helix_session cookie
- Generate cryptographically secure CSRF token on session creation
- Add ValidateCSRF() function to compare cookie value with header
- Add csrfMiddleware to validate X-CSRF-Token header on POST/PUT/DELETE/PATCH
- Exempt auth endpoints (login, logout, OIDC) from CSRF validation
- Skip CSRF validation for API key/runner token authenticated requests

Frontend:
- Add axios interceptor to read helix_csrf cookie and set X-CSRF-Token header
- Update streaming.tsx fetch() to include CSRF header

Design doc:
- Document Double-Submit Cookie Pattern for CSRF protection

This implements the standard CSRF mitigation pattern for cookie-based auth:
- SameSite=Lax prevents cross-site requests but allows OAuth redirects
- Dual-cookie (helix_session + helix_csrf) prevents CSRF attacks
- API clients using Authorization header are unaffected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Backend:
- Remove old transparent token refresh via cookies in auth_middleware
  (now handled by SessionManager for BFF sessions)
- Remove X-Token-Refreshed CORS header exposure (frontend no longer uses it)

Frontend:
- Delete useApi.test.ts (tested old token refresh interceptor which was removed)

The BFF pattern moves all token management to the backend via session cookies.
API keys and runner tokens still work unchanged via Authorization header.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
"gorm.io/gorm"
)

const (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we please move this to pkg/auth

}

// NewSessionManager creates a new session manager
func NewSessionManager(store store.Store, oidcClient authpkg.OIDC, cfg *config.ServerConfig) *SessionManager {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs test coverage

)

// CreateUserSession creates a new user session
func (s *PostgresStore) CreateUserSession(ctx context.Context, session *types.UserSession) (*types.UserSession, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these methods need tests

if session.ID == "" {
session.ID = uuid.New().String()
}
if session.CreatedAt.IsZero() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not needed

// CreateUserSession creates a new user session
func (s *PostgresStore) CreateUserSession(ctx context.Context, session *types.UserSession) (*types.UserSession, error) {
if session.ID == "" {
session.ID = uuid.New().String()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use ulids


// DeleteUserSessionsByUser deletes all sessions for a user (e.g., on logout from all devices)
func (s *PostgresStore) DeleteUserSessionsByUser(ctx context.Context, userID string) error {
return s.gdb.WithContext(ctx).Where("user_id = ?", userID).Delete(&types.UserSession{}).Error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check if user ID is specified

// Similar pattern to OAuthConnection but specifically for user authentication.
type UserSession struct {
ID string `json:"id" gorm:"primaryKey;type:uuid"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this gorm thing not needed

//
// Similar pattern to OAuthConnection but specifically for user authentication.
type UserSession struct {
ID string `json:"id" gorm:"primaryKey;type:uuid"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we use ulids


// Optional metadata for security/audit
UserAgent string `json:"user_agent,omitempty" gorm:"type:text"`
IPAddress string `json:"ip_address,omitempty" gorm:"type:varchar(45)"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont set the varchar here

}

// BeforeCreate sets default values for new sessions
func (s *UserSession) BeforeCreate(_ *gorm.DB) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete

- Add BFF session check to /api/v1/auth/authenticated endpoint
- Add BFF session check to /api/v1/auth/user endpoint
- Add withCredentials: true to Api client for session cookie sending
- Remove dead code: redundant authenticated calls from Layout.tsx/Sidebar.tsx
- Add debug logging to session_manager for troubleshooting

The frontend was making multiple parallel calls to the authenticated
endpoint, causing race conditions. This removes the redundant calls
and ensures the account context is the single source of truth for auth.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment on lines -548 to -551
// OK, set authentication cookies and redirect
cookieManager.Set(w, accessTokenCookie, token)
cookieManager.Set(w, refreshTokenCookie, token)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC the way this was implemented earlier generated the access/refresh tokens for both regular auth and OIDC auth. In the BFF pattern, still continuing to generate and store tokens for users makes sense. Deviating from this approach leads to an if/else path of regular auth (skip cookies, use custom flow) vs OIDC (write cookies, send cookies in header)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already addressed - both approaches set cookies (but local auth omits tokens)

  // Regular auth (line 542)
  s.sessionManager.CreateSession(ctx, w, r,
      user.ID,
      types.AuthProviderRegular,
      "",           // no OIDC access token
      "",           // no OIDC refresh token
      time.Time{},  // no OIDC token expiry
  )

  // OIDC auth (line 684)
  s.sessionManager.CreateSession(ctx, w, r,
      user.ID,
      types.AuthProviderOIDC,
      oauth2Token.AccessToken,
      oauth2Token.RefreshToken,
      oauth2Token.Expiry,
  )

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then that leads to 'What if regular auth lives alongside google OIDC in the future?'

Comment on lines -664 to +668
if oauth2Token.RefreshToken != "" {
cm.Set(w, refreshTokenCookie, oauth2Token.RefreshToken)
log.Info().Msg("Refresh token received and stored")
} else {
// No refresh token - this is expected for providers like Google when
// OIDC_OFFLINE_ACCESS is not enabled. Without a refresh token, the session
// will expire when the access token expires (typically 1 hour for Google).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write test here to handle cases where refresh token is not available eg. OIDC_OFFLINE_ACCESS is not enabled.

@chocobar
Copy link
Collaborator

chocobar commented Feb 5, 2026

Add test for session expiry (401 on expired, cleanup from DB)

@chocobar
Copy link
Collaborator

chocobar commented Feb 5, 2026

Add test for OIDC token refresh (expiring token triggers refresh, failure handling)

…ests

Addressing nessie993's PR review comments:

- Move session_manager.go from pkg/server to pkg/auth
- Use ULIDs with uss_ prefix instead of UUIDs for session IDs
- Add GenerateUserSessionID() to system/uuid.go
- Remove unnecessary gorm annotations from types/session.go
  - Removed DeletedAt field
  - Removed autoCreateTime/autoUpdateTime tags
  - Removed varchar type specification
  - Removed BeforeCreate/BeforeUpdate hooks
- Add input validation to all store methods (user ID, session ID checks)
- Add comprehensive test coverage for SessionManager (19 tests)
- Add comprehensive test coverage for store_user_sessions.go (13 tests)
- Export ClearSessionCookie for use in auth_middleware

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@chocobar
Copy link
Collaborator

chocobar commented Feb 5, 2026

It completely ignored the instruction about implementing a prompt=none silent auth flow. But given the complexity, let's put that as a separate task.

lukemarsden and others added 2 commits February 5, 2026 11:31
Addresses PR review comment about testing refresh token scenarios:
- NoRefreshToken: Session works without refresh token (OIDC_OFFLINE_ACCESS disabled)
- RefreshSucceeds: Token refresh works correctly
- RefreshFails: Session continues even when refresh fails (graceful degradation)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
GORM was inferring UUID type for the ID column without explicit type annotation,
causing "invalid input syntax for type uuid" errors when querying sessions with
ULID-prefixed IDs (e.g., uss_01abc...).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@lukemarsden lukemarsden dismissed chocobar’s stale review February 5, 2026 11:55

Review addressed - BFF auth is working after testing login/logout flow

@lukemarsden lukemarsden merged commit 52d080c into main Feb 5, 2026
9 checks passed
@lukemarsden lukemarsden deleted the feature/bff-auth-clean branch February 5, 2026 11:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants