Skip to main content

Authentication System

Overview

The authentication system uses Better Auth for session management with OAuth 2.0 identity providers (Discord, Battle.net). Better Auth replaces the previous JWT-based system, providing cookie-based sessions, magic links, and bearer token support.

Better Auth Configuration

Better Auth is configured as a NestJS module that wraps the better-auth library:

  • Session storage: Database-backed via Prisma adapter
  • Session transport: HTTP-only cookies (browser) or Bearer tokens (API clients)
  • Plugins: bearer(), magicLink(), genericOAuth() (for Battle.net)

Plugins

PluginPurpose
bearerEnables Authorization: Bearer <token> auth
magicLinkPasswordless email sign-in
genericOAuthBattle.net OAuth (custom provider)

Authentication Flow

OAuth Sign-In (Discord / Battle.net)

sequenceDiagram
User->>Frontend: Click "Sign in with Discord"
Frontend->>Better Auth: GET /api/auth/signin/discord
Better Auth->>Discord: Redirect to OAuth consent
Discord->>User: Show consent screen
User->>Discord: Approve
Discord->>Better Auth: Callback with code
Better Auth->>Discord: Exchange code for token
Better Auth->>Database: Create/update session
Better Auth->>User: Set session cookie
User->>Frontend: Redirect to dashboard
sequenceDiagram
User->>Frontend: Enter email
Frontend->>Better Auth: POST /api/auth/magic-link
Better Auth->>Email: Send magic link
User->>Email: Click link
Email->>Better Auth: GET /api/auth/magic-link/verify
Better Auth->>Database: Create session
Better Auth->>User: Set session cookie

Protected Route Redirect Flow

When users access protected routes while unauthenticated, the frontend stores the intended destination and redirects after login:

  1. Frontend detects unauthenticated state
  2. Redirects to /login?redirect=/intended/path
  3. After successful OAuth callback, redirects to stored path

Session Management

Better Auth manages sessions via HTTP-only cookies:

  • Cookie name: Set by Better Auth (default: better-auth.session_token)
  • Storage: Database (Session table via Prisma)
  • Expiry: Configured in Better Auth options
  • Refresh: Automatic session extension on activity

Bearer Token Support

For API clients and testing, the bearer plugin enables:

Authorization: Bearer <session-token>

The token is the same session token stored in cookies, passed via header instead.

Client-Side Session Caching

The frontend has two independent session-checking paths:

  1. authClient.useSession() — A reactive nanostores atom that fetches /get-session once on mount and shares state across all React subscribers. Used in components for rendering auth-dependent UI.
  2. auth.getSession() — An imperative wrapper used in TanStack Router beforeLoad guards (requireAuth()). Runs before route components mount.

Without caching, every navigation to a protected route would fire a fresh GET /api/v1/auth/get-session call via requireAuth(), even though the session hasn't changed. To prevent this:

  • TTL cache (60s): auth.getSession() caches the result in memory. Subsequent calls within the TTL return the cached session instantly.
  • Request deduplication: Concurrent calls (e.g., nested route guards) share a single in-flight request instead of firing parallel fetches.
  • Cache invalidation: auth.signOut() and auth.invalidateSession() clear the cache immediately.

This lives in apps/web/src/lib/auth.ts. The reactive useSession() hook is unaffected — it manages its own state via the Better Auth nanostores atom.

Guards & Decorators

BetterAuthGuard (Global)

Registered as a global APP_GUARD. Every route requires authentication by default. Uses AllowAnonymous and OptionalAuth decorators to opt out.

@Controller('events')
export class EventController {
@Get()
listEvents() {
/* requires auth by default */
}
}

@AllowAnonymous()

Bypasses authentication entirely. Used for public endpoints:

@AllowAnonymous()
@Get('game-data/expansions')
getExpansions() { /* no auth needed */ }

@OptionalAuth()

Allows both authenticated and unauthenticated access. request.user is set if authenticated, null otherwise:

@OptionalAuth()
@Get('events/:id')
getEvent(@CurrentUser() user: User | null) {
return this.eventService.getEvent(id, user?.id);
}

AbilitiesGuard

Enforces CASL-based authorization after authentication:

@RequireAbilities({ action: Action.Update, subject: 'Guild' })
@Patch('guilds/:id/settings')
updateSettings(@Body() dto: UpdateSettingsDto) { /* ... */ }

See Permissions Architecture for CASL details.

Account Linking

Users can link multiple OAuth providers to a single account. Better Auth handles the linking flow:

  1. User is authenticated with one provider (e.g. Discord)
  2. User initiates link with another provider (e.g. Battle.net)
  3. Better Auth performs OAuth flow for the second provider
  4. Links the external identity to the existing user record

The frontend settings page shows linked accounts and allows unlinking.

Battle.net Token Management

Battle.net access tokens are encrypted at rest (AES-256-GCM) for security:

  • Tokens grant access to WoW character and guild data
  • Encrypted before storage, decrypted on use
  • Refresh logic handled by BattleNetTokenService
  • If refresh fails (401), user must re-link their Battle.net account

OAuth Providers

Discord

  • Scopes: identify, email
  • User lookup: discordId field
  • Auto-create: New user created if not found

Battle.net

  • Scopes: openid, wow.profile
  • Region: Configurable (default: us)
  • User lookup: battleNetId field
  • Token storage: Encrypted access token + expiry
  • OIDC compliant: Receives signed ID token

Configuration

Required environment variables:

# Better Auth
BETTER_AUTH_SECRET=your-secret-key-minimum-32-chars
BETTER_AUTH_URL=http://localhost:3001

# Discord OAuth
DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_CLIENT_SECRET=your-discord-client-secret

# Battle.net OAuth
BATTLENET_CLIENT_ID=your-battlenet-client-id
BATTLENET_CLIENT_SECRET=your-battlenet-client-secret
BATTLENET_REGION=us

# Token Encryption
ENCRYPTION_KEY=your-encryption-key-32-chars

E2E Testing

In the test environment, BetterAuthGuard is replaced with TestBetterAuthGuard:

  • Bypasses the Better Auth library entirely
  • Resolves sessions directly from the database using Bearer tokens
  • Respects @AllowAnonymous() and @OptionalAuth() decorators
  • Configured via overrideProvider(BetterAuthGuard).useClass(TestBetterAuthGuard) in test setup

ESM-only better-auth packages are mocked with CJS stubs in jest-e2e.config.js to avoid module format conflicts.

Security

  • Cookie: HTTP-only, Secure in production, SameSite=Lax
  • CSRF: OAuth state parameter prevents cross-site request forgery
  • Token encryption: AES-256-GCM for Battle.net tokens
  • Session invalidation: Sessions can be revoked from the database