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
| Plugin | Purpose |
|---|---|
bearer | Enables Authorization: Bearer <token> auth |
magicLink | Passwordless email sign-in |
genericOAuth | Battle.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
Magic Link Sign-In
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:
- Frontend detects unauthenticated state
- Redirects to
/login?redirect=/intended/path - After successful OAuth callback, redirects to stored path
Session Management
Cookie Sessions
Better Auth manages sessions via HTTP-only cookies:
- Cookie name: Set by Better Auth (default:
better-auth.session_token) - Storage: Database (
Sessiontable 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:
authClient.useSession()— A reactive nanostores atom that fetches/get-sessiononce on mount and shares state across all React subscribers. Used in components for rendering auth-dependent UI.auth.getSession()— An imperative wrapper used in TanStack RouterbeforeLoadguards (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()andauth.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:
- User is authenticated with one provider (e.g. Discord)
- User initiates link with another provider (e.g. Battle.net)
- Better Auth performs OAuth flow for the second provider
- 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:
discordIdfield - Auto-create: New user created if not found
Battle.net
- Scopes:
openid,wow.profile - Region: Configurable (default:
us) - User lookup:
battleNetIdfield - 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