Authentication
The @esengine/server package includes a pluggable authentication system that supports JWT, session-based auth, and custom providers.
Installation
Section titled “Installation”Authentication is included in the server package:
npm install @esengine/server jsonwebtokenNote:
jsonwebtokenis an optional peer dependency, required only for JWT authentication.
Quick Start
Section titled “Quick Start”JWT Authentication
Section titled “JWT Authentication”import { createServer } from '@esengine/server'import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
// Create JWT providerconst jwtProvider = createJwtAuthProvider({ secret: process.env.JWT_SECRET!, expiresIn: 3600, // 1 hour})
// Wrap server with authenticationconst server = withAuth(await createServer({ port: 3000 }), { provider: jwtProvider, extractCredentials: (req) => { const url = new URL(req.url ?? '', 'http://localhost') return url.searchParams.get('token') },})
// Define authenticated roomclass GameRoom extends withRoomAuth(Room, { requireAuth: true }) { onJoin(player) { console.log(`${player.user?.name} joined!`) }}
server.define('game', GameRoom)await server.start()Auth Providers
Section titled “Auth Providers”JWT Provider
Section titled “JWT Provider”Use JSON Web Tokens for stateless authentication:
import { createJwtAuthProvider } from '@esengine/server/auth'
const jwtProvider = createJwtAuthProvider({ // Required: secret key secret: 'your-secret-key',
// Optional: algorithm (default: HS256) algorithm: 'HS256',
// Optional: expiration in seconds (default: 3600) expiresIn: 3600,
// Optional: issuer for validation issuer: 'my-game-server',
// Optional: audience for validation audience: 'my-game-client',
// Optional: custom user extraction getUser: async (payload) => { // Fetch user from database return await db.users.findById(payload.sub) },})
// Sign a token (for login endpoints)const token = jwtProvider.sign({ sub: user.id, name: user.name, roles: ['player'],})
// Decode without verification (for debugging)const payload = jwtProvider.decode(token)Custom Provider
Section titled “Custom Provider”You can create custom authentication providers by implementing the IAuthProvider interface to integrate with any authentication system (OAuth, LDAP, custom database auth, etc.).
IAuthProvider Interface
Section titled “IAuthProvider Interface”interface IAuthProvider<TUser = unknown, TCredentials = unknown> { /** Provider name */ readonly name: string;
/** Verify credentials */ verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
/** Refresh token (optional) */ refresh?(token: string): Promise<AuthResult<TUser>>;
/** Revoke token (optional) */ revoke?(token: string): Promise<boolean>;}
interface AuthResult<TUser> { success: boolean; user?: TUser; error?: string; errorCode?: AuthErrorCode; token?: string; expiresAt?: number;}
type AuthErrorCode = | 'INVALID_CREDENTIALS' | 'EXPIRED_TOKEN' | 'INVALID_TOKEN' | 'USER_NOT_FOUND' | 'ACCOUNT_DISABLED' | 'RATE_LIMITED' | 'INSUFFICIENT_PERMISSIONS';Custom Provider Examples
Section titled “Custom Provider Examples”Example 1: Database Password Authentication
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
interface User { id: string username: string roles: string[]}
interface PasswordCredentials { username: string password: string}
class DatabaseAuthProvider implements IAuthProvider<User, PasswordCredentials> { readonly name = 'database'
async verify(credentials: PasswordCredentials): Promise<AuthResult<User>> { const { username, password } = credentials
// Query user from database const user = await db.users.findByUsername(username) if (!user) { return { success: false, error: 'User not found', errorCode: 'USER_NOT_FOUND' } }
// Verify password (using bcrypt or similar) const isValid = await bcrypt.compare(password, user.passwordHash) if (!isValid) { return { success: false, error: 'Invalid password', errorCode: 'INVALID_CREDENTIALS' } }
// Check account status if (user.disabled) { return { success: false, error: 'Account is disabled', errorCode: 'ACCOUNT_DISABLED' } }
return { success: true, user: { id: user.id, username: user.username, roles: user.roles } } }}Example 2: OAuth/Third-party Authentication
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
interface OAuthUser { id: string email: string name: string provider: string roles: string[]}
interface OAuthCredentials { provider: 'google' | 'github' | 'discord' accessToken: string}
class OAuthProvider implements IAuthProvider<OAuthUser, OAuthCredentials> { readonly name = 'oauth'
async verify(credentials: OAuthCredentials): Promise<AuthResult<OAuthUser>> { const { provider, accessToken } = credentials
try { // Verify token with provider const profile = await this.fetchUserProfile(provider, accessToken)
// Find or create local user let user = await db.users.findByOAuth(provider, profile.id) if (!user) { user = await db.users.create({ oauthProvider: provider, oauthId: profile.id, email: profile.email, name: profile.name, roles: ['player'] }) }
return { success: true, user: { id: user.id, email: user.email, name: user.name, provider, roles: user.roles } } } catch (error) { return { success: false, error: 'OAuth verification failed', errorCode: 'INVALID_TOKEN' } } }
private async fetchUserProfile(provider: string, token: string) { switch (provider) { case 'google': return fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()) case 'github': return fetch('https://api.github.com/user', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()) // Other providers... default: throw new Error(`Unsupported provider: ${provider}`) } }}Example 3: API Key Authentication
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
interface ApiUser { id: string name: string roles: string[] rateLimit: number}
class ApiKeyAuthProvider implements IAuthProvider<ApiUser, string> { readonly name = 'api-key'
private revokedKeys = new Set<string>()
async verify(apiKey: string): Promise<AuthResult<ApiUser>> { if (!apiKey || !apiKey.startsWith('sk_')) { return { success: false, error: 'Invalid API Key format', errorCode: 'INVALID_TOKEN' } }
if (this.revokedKeys.has(apiKey)) { return { success: false, error: 'API Key has been revoked', errorCode: 'INVALID_TOKEN' } }
// Query API Key from database const keyData = await db.apiKeys.findByKey(apiKey) if (!keyData) { return { success: false, error: 'API Key not found', errorCode: 'INVALID_CREDENTIALS' } }
// Check expiration if (keyData.expiresAt && keyData.expiresAt < Date.now()) { return { success: false, error: 'API Key has expired', errorCode: 'EXPIRED_TOKEN' } }
return { success: true, user: { id: keyData.userId, name: keyData.name, roles: keyData.roles, rateLimit: keyData.rateLimit }, expiresAt: keyData.expiresAt } }
async revoke(apiKey: string): Promise<boolean> { this.revokedKeys.add(apiKey) await db.apiKeys.revoke(apiKey) return true }}Using Custom Providers
Section titled “Using Custom Providers”import { createServer } from '@esengine/server'import { withAuth } from '@esengine/server/auth'
// Create custom providerconst dbAuthProvider = new DatabaseAuthProvider()
// Or use OAuth providerconst oauthProvider = new OAuthProvider()
// Use custom providerconst server = withAuth(await createServer({ port: 3000 }), { provider: dbAuthProvider, // or oauthProvider
// Extract credentials from WebSocket connection request extractCredentials: (req) => { const url = new URL(req.url, 'http://localhost')
// For database auth: get from query params const username = url.searchParams.get('username') const password = url.searchParams.get('password') if (username && password) { return { username, password } }
// For OAuth: get from token param const provider = url.searchParams.get('provider') const accessToken = url.searchParams.get('access_token') if (provider && accessToken) { return { provider, accessToken } }
// For API Key: get from header const apiKey = req.headers['x-api-key'] if (apiKey) { return apiKey as string }
return null },
onAuthFailure: (conn, error) => { console.log(`Auth failed: ${error.errorCode} - ${error.error}`) }})
await server.start()Combining Multiple Providers
Section titled “Combining Multiple Providers”You can create a composite provider to support multiple authentication methods:
import type { IAuthProvider, AuthResult } from '@esengine/server/auth'
interface MultiAuthCredentials { type: 'jwt' | 'oauth' | 'apikey' | 'password' data: unknown}
class MultiAuthProvider implements IAuthProvider<User, MultiAuthCredentials> { readonly name = 'multi'
constructor( private jwtProvider: JwtAuthProvider<User>, private oauthProvider: OAuthProvider, private apiKeyProvider: ApiKeyAuthProvider, private dbProvider: DatabaseAuthProvider ) {}
async verify(credentials: MultiAuthCredentials): Promise<AuthResult<User>> { switch (credentials.type) { case 'jwt': return this.jwtProvider.verify(credentials.data as string) case 'oauth': return this.oauthProvider.verify(credentials.data as OAuthCredentials) case 'apikey': return this.apiKeyProvider.verify(credentials.data as string) case 'password': return this.dbProvider.verify(credentials.data as PasswordCredentials) default: return { success: false, error: 'Unsupported authentication type', errorCode: 'INVALID_CREDENTIALS' } } }}Session Provider
Section titled “Session Provider”Use server-side sessions for stateful authentication:
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
// Custom storage implementationconst storage: ISessionStorage = { async get<T>(key: string): Promise<T | null> { return await redis.get(key) }, async set<T>(key: string, value: T): Promise<void> { await redis.set(key, value) }, async delete(key: string): Promise<boolean> { return await redis.del(key) > 0 },}
const sessionProvider = createSessionAuthProvider({ storage, sessionTTL: 86400000, // 24 hours in ms
// Optional: validate user on each request validateUser: (user) => !user.banned,})
// Create session (for login endpoints)const sessionId = await sessionProvider.createSession(user, { ipAddress: req.ip, userAgent: req.headers['user-agent'],})
// Revoke session (for logout)await sessionProvider.revoke(sessionId)Server Auth Mixin
Section titled “Server Auth Mixin”The withAuth function wraps your server to add authentication:
import { withAuth } from '@esengine/server/auth'
const server = withAuth(baseServer, { // Required: auth provider provider: jwtProvider,
// Required: extract credentials from request extractCredentials: (req) => { // From query string return new URL(req.url, 'http://localhost').searchParams.get('token')
// Or from headers // return req.headers['authorization']?.replace('Bearer ', '') },
// Optional: handle auth failure onAuthFailed: (conn, error) => { console.log(`Auth failed: ${error}`) },})Accessing Auth Context
Section titled “Accessing Auth Context”After authentication, the auth context is available on connections:
import { getAuthContext } from '@esengine/server/auth'
server.onConnect = (conn) => { const auth = getAuthContext(conn)
if (auth.isAuthenticated) { console.log(`User ${auth.userId} connected`) console.log(`Roles: ${auth.roles}`) }}Room Auth Mixin
Section titled “Room Auth Mixin”The withRoomAuth function adds authentication checks to rooms:
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
interface User { id: string name: string roles: string[]}
class GameRoom extends withRoomAuth<User>(Room, { // Require authentication to join requireAuth: true,
// Optional: require specific roles allowedRoles: ['player', 'premium'],
// Optional: role check mode ('any' or 'all') roleCheckMode: 'any',}) { // player has .auth and .user properties onJoin(player: AuthPlayer<User>) { console.log(`${player.user?.name} joined`) console.log(`Is premium: ${player.auth.hasRole('premium')}`) }
// Optional: custom auth validation async onAuth(player: AuthPlayer<User>): Promise<boolean> { // Additional validation logic if (player.auth.hasRole('banned')) { return false } return true }
@onMessage('Chat') handleChat(data: { text: string }, player: AuthPlayer<User>) { this.broadcast('Chat', { from: player.user?.name ?? 'Guest', text: data.text, }) }}AuthPlayer Interface
Section titled “AuthPlayer Interface”Players in auth rooms have additional properties:
interface AuthPlayer<TUser> extends Player { // Full auth context readonly auth: IAuthContext<TUser>
// User info (shortcut for auth.user) readonly user: TUser | null}Room Auth Helpers
Section titled “Room Auth Helpers”class GameRoom extends withRoomAuth<User>(Room) { someMethod() { // Get player by user ID const player = this.getPlayerByUserId('user-123')
// Get all players with a role const admins = this.getPlayersByRole('admin')
// Get player with auth info const authPlayer = this.getAuthPlayer(playerId) }}Auth Decorators
Section titled “Auth Decorators”@requireAuth
Section titled “@requireAuth”Mark message handlers as requiring authentication:
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
class GameRoom extends withRoomAuth(Room) { @requireAuth() @onMessage('Trade') handleTrade(data: TradeData, player: AuthPlayer) { // Only authenticated players can trade }
@requireAuth({ allowGuest: true }) @onMessage('Chat') handleChat(data: ChatData, player: AuthPlayer) { // Guests can also chat }}@requireRole
Section titled “@requireRole”Require specific roles for message handlers:
class AdminRoom extends withRoomAuth(Room) { @requireRole('admin') @onMessage('Ban') handleBan(data: BanData, player: AuthPlayer) { // Only admins can ban }
@requireRole(['moderator', 'admin']) @onMessage('Mute') handleMute(data: MuteData, player: AuthPlayer) { // Moderators OR admins can mute }
@requireRole(['verified', 'premium'], { mode: 'all' }) @onMessage('SpecialFeature') handleSpecial(data: any, player: AuthPlayer) { // Requires BOTH verified AND premium roles }}Auth Context API
Section titled “Auth Context API”The auth context provides various methods for checking authentication state:
interface IAuthContext<TUser> { // Authentication state readonly isAuthenticated: boolean readonly user: TUser | null readonly userId: string | null readonly roles: ReadonlyArray<string> readonly authenticatedAt: number | null readonly expiresAt: number | null
// Role checking hasRole(role: string): boolean hasAnyRole(roles: string[]): boolean hasAllRoles(roles: string[]): boolean}The AuthContext class (implementation) also provides:
class AuthContext<TUser> implements IAuthContext<TUser> { // Set authentication from result setAuthenticated(result: AuthResult<TUser>): void
// Clear authentication state clear(): void}Testing
Section titled “Testing”Use the mock auth provider for unit tests:
import { createMockAuthProvider } from '@esengine/server/auth/testing'
// Create mock provider with preset usersconst mockProvider = createMockAuthProvider({ users: [ { id: '1', name: 'Alice', roles: ['player'] }, { id: '2', name: 'Bob', roles: ['admin', 'player'] }, ], autoCreate: true, // Create users for unknown tokens})
// Use in testsconst server = withAuth(testServer, { provider: mockProvider, extractCredentials: (req) => req.headers['x-token'],})
// Verify with user ID as tokenconst result = await mockProvider.verify('1')// result.user = { id: '1', name: 'Alice', roles: ['player'] }
// Add/remove users dynamicallymockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })mockProvider.removeUser('3')
// Revoke tokensawait mockProvider.revoke('1')
// Reset to initial statemockProvider.clear()Error Handling
Section titled “Error Handling”Auth errors include error codes for programmatic handling:
type AuthErrorCode = | 'INVALID_CREDENTIALS' // Invalid username/password | 'INVALID_TOKEN' // Token is malformed or invalid | 'EXPIRED_TOKEN' // Token has expired | 'USER_NOT_FOUND' // User lookup failed | 'ACCOUNT_DISABLED' // User account is disabled | 'RATE_LIMITED' // Too many requests | 'INSUFFICIENT_PERMISSIONS' // Insufficient permissions
// In your auth failure handlerconst server = withAuth(baseServer, { provider: jwtProvider, extractCredentials, onAuthFailed: (conn, error) => { switch (error.errorCode) { case 'EXPIRED_TOKEN': conn.send('AuthError', { code: 'TOKEN_EXPIRED' }) break case 'INVALID_TOKEN': conn.send('AuthError', { code: 'INVALID_TOKEN' }) break default: conn.close() } },})Complete Example
Section titled “Complete Example”Here’s a complete example with JWT authentication:
import { createServer } from '@esengine/server'import { withAuth, withRoomAuth, createJwtAuthProvider, requireAuth, requireRole, type AuthPlayer,} from '@esengine/server/auth'
// Typesinterface User { id: string name: string roles: string[]}
// JWT Providerconst jwtProvider = createJwtAuthProvider<User>({ secret: process.env.JWT_SECRET!, expiresIn: 3600, getUser: async (payload) => ({ id: payload.sub as string, name: payload.name as string, roles: (payload.roles as string[]) ?? [], }),})
// Create authenticated serverconst server = withAuth( await createServer({ port: 3000 }), { provider: jwtProvider, extractCredentials: (req) => { return new URL(req.url ?? '', 'http://localhost') .searchParams.get('token') }, })
// Game Room with authclass GameRoom extends withRoomAuth<User>(Room, { requireAuth: true, allowedRoles: ['player'],}) { onCreate() { console.log('Game room created') }
onJoin(player: AuthPlayer<User>) { console.log(`${player.user?.name} joined!`) this.broadcast('PlayerJoined', { id: player.id, name: player.user?.name, }) }
@requireAuth() @onMessage('Move') handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) { // Handle movement }
@requireRole('admin') @onMessage('Kick') handleKick(data: { playerId: string }, player: AuthPlayer<User>) { const target = this.getPlayer(data.playerId) if (target) { this.kick(target, 'Kicked by admin') } }}
server.define('game', GameRoom)await server.start()Best Practices
Section titled “Best Practices”-
Secure your secrets: Never hardcode JWT secrets. Use environment variables.
-
Set reasonable expiration: Balance security and user experience when setting token TTL.
-
Validate on critical actions: Use
@requireAuthon sensitive message handlers. -
Use role-based access: Implement proper role hierarchy for admin functions.
-
Handle token refresh: Implement token refresh logic for long sessions.
-
Log auth events: Track login attempts and failures for security monitoring.
-
Test auth flows: Use
MockAuthProviderto test authentication scenarios.