Session Management & RBAC System
Overview
PadawanForge implements a comprehensive session management system with Role-Based Access Control (RBAC) that provides secure authentication, session validation, and granular permission management. This system supports multi-provider OAuth authentication and sophisticated role hierarchies.
Architecture
Core Components
- Multi-Provider OAuth: Google, Discord, Apple, Slack authentication
- Session Validation: Secure session management with KV storage
- Role-Based Access Control: Granular permission system
- Session Security: CSRF protection and secure cookie handling
- Account Linking: Multiple OAuth providers per account
Session States
- Active: Valid session with current user
- Expired: Session past expiration time
- Invalid: Corrupted or missing session data
- Refreshed: Session renewed with new data
Common Issues & Solutions
Issue: “Session not found” with undefined sessionId
Symptoms:
Game session access failed: Session not found {
sessionId: 'undefined',
queryDuration: 1,
timestamp: '2025-07-31T18:14:23.018Z'
}
Root Causes:
- Invalid Route Parameters: The route parameter
idis not being properly extracted - Missing Validation: API routes not validating parameter existence before use
- Frontend State Issues: Component passing undefined values to API calls
- URL Encoding Problems: Special characters in session IDs causing parsing issues
Solutions:
1. Enhanced API Route Validation
// In API routes (e.g., /api/game-sessions/[id].ts)
export const GET: APIRoute = async ({ request, params, locals }) => {
const { id } = params;
// Enhanced validation for session ID
if (!id) {
const error = new Error('Game session ID is missing from request parameters');
const structuredError = errorLogger.logError(error, context, {
providedId: id,
idType: typeof id,
params: params,
url: request.url,
});
return errorLogger.createErrorResponse(structuredError, isDebugMode);
}
if (typeof id !== 'string') {
const error = new Error('Game session ID must be a string');
const structuredError = errorLogger.logError(error, context, {
providedId: id,
idType: typeof id,
params: params,
});
return errorLogger.createErrorResponse(structuredError, isDebugMode);
}
if (id.trim() === '') {
const error = new Error('Game session ID cannot be empty');
const structuredError = errorLogger.logError(error, context, {
providedId: id,
idType: typeof id,
params: params,
});
return errorLogger.createErrorResponse(structuredError, isDebugMode);
}
// Continue with validated ID...
};
2. Frontend Component Validation
// In React components (e.g., LazyRoomLoader, useRoomState)
const loadRoom = async (retry = false) => {
// Validate roomId before making any requests
if (!roomId || typeof roomId !== 'string' || roomId.trim() === '') {
const errorMessage = 'Invalid room ID provided';
setError(errorMessage);
setLoadingState('error');
clientLogger.logError(new Error(errorMessage), {
roomId,
roomIdType: typeof roomId,
roomIdLength: roomId?.length,
});
return;
}
// Continue with validated roomId...
};
3. URL Parameter Validation
// In Astro pages (e.g., /pages/game/room/[id].astro)
const { id } = Astro.params;
if (!id || typeof id !== 'string' || id.trim() === '') {
return Astro.redirect('/game?error=invalid-room-id');
}
// Validate ID format if needed
if (!/^[a-zA-Z0-9-_]+$/.test(id)) {
return Astro.redirect('/game?error=invalid-room-id-format');
}
4. Session ID Encoding/Decoding
// For URLs with special characters
const encodeSessionId = (id: string): string => {
return encodeURIComponent(id);
};
const decodeSessionId = (encodedId: string): string => {
return decodeURIComponent(encodedId);
};
// Usage in API routes
const { id: encodedId } = params;
const sessionId = decodeSessionId(encodedId);
Issue: Lobby Showing No Rooms Despite Database Having Data
Symptoms:
- Lobby browser displays “No rooms available” or empty state
- Database contains active game sessions
- API endpoints return data correctly
- Frontend components show loading state indefinitely
Root Cause:
Data Structure Mismatch - The useLazyRoomData hook expected different API response structures than what the APIs actually return.
API Response Structures:
// Game Sessions API returns:
{
"success": true,
"data": {
"gameSessions": [...]
}
}
// NPCs API returns:
{
"npcs": [...]
}
// Hook was expecting:
{
"gameSessions": [...]
}
Solution:
Fixed the data structure parsing in src/hooks/useLazyRoomData.ts:
// Before (incorrect):
const roomsResult = await roomsResponse.json() as { gameSessions: GameSessionInfo[] };
roomsData = roomsResult.gameSessions || [];
// After (correct):
const roomsResult = await roomsResponse.json() as { success: boolean; data: { gameSessions: GameSessionInfo[] } };
roomsData = roomsResult.data?.gameSessions || [];
Implementation Details:
- Game Sessions API: Updated to access
roomsResult.data.gameSessions - NPCs API: Kept as
npcsResult.npcs(already correct) - Applied to both parallel and sequential fetch modes
- Added proper TypeScript interfaces for response structures
Benefits:
- ✅ Correct Room Display: All rooms now appear in lobby browser
- ✅ Consistent Data Flow: Proper API response structure handling
- ✅ Better User Experience: Users can see and join available rooms
- ✅ Developer Clarity: Clear understanding of API response structures
Issue: Session Expiration Handling
Symptoms:
- Users getting logged out unexpectedly
- API calls returning 401 errors
- Session data not persisting
Solutions:
// Enhanced session validation with automatic refresh
export async function getFreshPlayerSession(astro: AstroGlobal): Promise<PlayerWithRoles | null> {
try {
const player = await getPlayerSession(astro);
if (!player) {
return null;
}
// Check if session needs refresh (every 30 minutes)
const sessionCookie = astro.cookies.get('session');
if (sessionCookie) {
const sessionData = JSON.parse(sessionCookie.value);
const lastRefresh = sessionData.lastRefresh || 0;
const now = Date.now();
if (now - lastRefresh > 30 * 60 * 1000) {
// Refresh session data from database
const freshPlayer = await loadPlayerWithRoles(player, astro.locals.runtime.env.DB);
await updateSessionData(astro, freshPlayer);
return freshPlayer;
}
}
return player;
} catch (error) {
apiLogger.error('Session refresh failed', error instanceof Error ? error : new Error(String(error)));
return null;
}
}
Issue: Race Conditions in Session Loading
Symptoms:
- Multiple API calls with same session ID
- Inconsistent session state
- Duplicate session creation
Solutions:
// Implement session loading locks
const sessionLocks = new Map<string, Promise<any>>();
export async function getPlayerSessionWithLock(astro: AstroGlobal): Promise<PlayerWithRoles | null> {
const sessionId = astro.cookies.get('session')?.value;
if (!sessionId) {
return null;
}
// Check if session is already being loaded
if (sessionLocks.has(sessionId)) {
return await sessionLocks.get(sessionId);
}
// Create new session loading promise
const sessionPromise = getPlayerSession(astro);
sessionLocks.set(sessionId, sessionPromise);
try {
const result = await sessionPromise;
return result;
} finally {
// Clean up lock
sessionLocks.delete(sessionId);
}
}
Implementation
Session Data Structure
interface SessionData {
id: string;
playerId: string;
playerUuid: string;
defaultProvider: string;
currentProvider: string;
provider: 'google' | 'discord' | 'apple' | 'slack';
email: string;
username: string;
avatar: string;
level: number;
experience: number;
birthday?: string;
location?: string;
gender?: 'M' | 'F' | 'Other' | 'Prefer not to say';
registrationCompleted?: boolean;
tutorialCompleted?: boolean;
onboardingCompletedAt?: string;
roles?: UserRole[];
permissions?: string[];
isAdmin?: boolean;
createdAt: Date;
expiresAt: Date;
lastRefresh?: number; // Add refresh tracking
}
Player with Roles Interface
interface PlayerWithRoles extends Player {
roles?: UserRole[];
permissions?: string[];
primaryRole?: UserRole;
isAdmin?: boolean;
}
interface UserRole {
id: number;
name: string;
display_name: string;
level: number;
is_system_role: boolean;
}
Session Management Functions
Get Player Session
import { getPlayerSession } from '@/lib/utils/session';
// Get current player session
const player = await getPlayerSession(astro);
if (player) {
console.log('Player authenticated:', player.username);
console.log('Roles:', player.roles?.map(r => r.name));
console.log('Permissions:', player.permissions);
} else {
console.log('No active session');
}
Get Fresh Player Session
import { getFreshPlayerSession } from '@/lib/utils/session';
// Get fresh session data (refreshes from database)
const player = await getFreshPlayerSession(astro);
// This function also handles session cleanup for invalid sessions
Create Player Session
import { createPlayerSession } from '@/lib/utils/session';
// Create new session after OAuth authentication
const sessionId = await createPlayerSession(astro, {
playerId: '123',
playerUuid: 'uuid-123',
provider: 'google',
email: 'user@example.com',
username: 'username',
avatar: 'https://example.com/avatar.jpg',
level: 1,
experience: 0,
roles: [{ id: 1, name: 'padawan', display_name: 'Padawan', level: 1, is_system_role: true }],
permissions: ['players.view', 'npcs.view'],
isAdmin: false
});
Clear Session
import { clearPlayerSession, clearAllPlayerSessions } from '@/lib/utils/session';
// Clear current session
await clearPlayerSession(astro);
// Clear all sessions for a player (force logout from all devices)
await clearAllPlayerSessions(astro, playerId);
Role-Based Access Control
Role Hierarchy
// Role levels (higher numbers = more permissions)
const ROLE_HIERARCHY = {
guest: 0,
padawan: 1,
game_master: 2,
guild_leader: 3,
system_admin: 4,
};
// Role definitions
const ROLES = {
GUEST: { id: 0, name: 'guest', display_name: 'Guest' },
PADAWAN: { id: 1, name: 'padawan', display_name: 'Padawan' },
GAME_MASTER: { id: 2, name: 'game_master', display_name: 'Game Master' },
GUILD_LEADER: { id: 3, name: 'guild_leader', display_name: 'Guild Leader' },
SYSTEM_ADMIN: { id: 4, name: 'system_admin', display_name: 'System Admin' },
};
Permission System
// Admin permission categories
const ADMIN_PERMISSIONS = {
// Player management
PLAYERS_VIEW: 'players.view',
PLAYERS_EDIT: 'players.edit',
PLAYERS_DELETE: 'players.delete',
PLAYERS_BAN: 'players.ban',
// NPC management
NPCS_VIEW: 'npcs.view',
NPCS_CREATE: 'npcs.create',
NPCS_EDIT: 'npcs.edit',
NPCS_DELETE: 'npcs.delete',
// Game management
GAMES_VIEW: 'games.view',
GAMES_CREATE: 'games.create',
GAMES_EDIT: 'games.edit',
GAMES_DELETE: 'games.delete',
GAMES_MODERATE: 'games.moderate',
// System administration
ADMIN_CONFIG: 'admin.config',
ADMIN_USERS: 'admin.users',
ADMIN_ROLES: 'admin.roles',
ADMIN_LOGS: 'admin.logs',
ADMIN_SYSTEM: 'admin.system',
// Content moderation
CONTENT_VIEW: 'content.view',
CONTENT_MODERATE: 'content.moderate',
CONTENT_DELETE: 'content.delete',
// Demo system
DEMO_ACCESS: 'demo.access',
};
Permission Checking Functions
import {
isAdmin,
hasPermission,
hasAnyPermission,
hasAllPermissions,
canAccessAdminRoute
} from '@/lib/utils/permissions';
// Check if user is admin
if (isAdmin(player)) {
console.log('User has admin access');
}
// Check specific permission
if (hasPermission(player, ADMIN_PERMISSIONS.PLAYERS_EDIT)) {
console.log('User can edit players');
}
// Check multiple permissions (any)
if (hasAnyPermission(player, [ADMIN_PERMISSIONS.PLAYERS_VIEW, ADMIN_PERMISSIONS.NPCS_VIEW])) {
console.log('User can view players or NPCs');
}
// Check multiple permissions (all)
if (hasAllPermissions(player, [ADMIN_PERMISSIONS.PLAYERS_VIEW, ADMIN_PERMISSIONS.PLAYERS_EDIT])) {
console.log('User can view and edit players');
}
// Check admin route access
if (canAccessAdminRoute(player, '/admin/players')) {
console.log('User can access players admin page');
}
Usage Examples
Middleware Integration
// In middleware.ts
import { getFreshPlayerSession } from '@/lib/utils/session';
import { isAdmin, canAccessAdminRoute } from '@/lib/utils/permissions';
export const onRequest = defineMiddleware(async ({ url, locals, cookies, redirect }, next) => {
const sessionCookie = cookies.get("session");
let player = null;
if (sessionCookie) {
try {
player = await getFreshPlayerSession({ locals, cookies, url } as any);
if (player) {
// Update last login
await updateLastLogin(player.playerId);
// Check admin route access
if (url.pathname.startsWith('/admin')) {
if (!canAccessAdminRoute(player, url.pathname)) {
return redirect('/auth/login');
}
}
}
} catch (error) {
console.error('Session validation error:', error);
}
}
// Continue to next middleware/route
return next();
});
API Route Protection
// Protected API route
export async function GET(request: Request) {
const player = await getPlayerSession(astro);
if (!player) {
throw new UnauthorizedError('Authentication required');
}
if (!hasPermission(player, ADMIN_PERMISSIONS.PLAYERS_VIEW)) {
throw new ForbiddenError('Insufficient permissions');
}
// Process request
const players = await getAllPlayers();
return jsonResponse({ players });
}
Component-Level Permission Checking
// React component with permission checking
function AdminPanel({ player }: { player: PlayerWithRoles }) {
if (!isAdmin(player)) {
return <div>Access denied</div>;
}
return (
<div>
{hasPermission(player, ADMIN_PERMISSIONS.PLAYERS_VIEW) && (
<PlayersList />
)}
{hasPermission(player, ADMIN_PERMISSIONS.NPCS_VIEW) && (
<NPCsList />
)}
{hasPermission(player, ADMIN_PERMISSIONS.ADMIN_CONFIG) && (
<SystemConfig />
)}
</div>
);
}
Role Assignment
import { RolePermissionService } from '@/lib/utils/permissions';
// Assign role to player
const roleService = new RolePermissionService(db);
await roleService.assignRole(playerUuid, roleId, assignedBy);
// Remove role from player
await roleService.removeRole(playerUuid, roleId);
// Assign default role
await roleService.assignDefaultRole(playerUuid);
OAuth Integration
Multi-Provider Support
// OAuth provider configurations
const OAUTH_PROVIDERS = {
google: {
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo',
scopes: ['openid', 'email', 'profile'],
},
discord: {
authUrl: 'https://discord.com/api/oauth2/authorize',
tokenUrl: 'https://discord.com/api/oauth2/token',
userInfoUrl: 'https://discord.com/api/v10/users/@me',
scopes: ['identify', 'email'],
},
apple: {
authUrl: 'https://appleid.apple.com/auth/authorize',
tokenUrl: 'https://appleid.apple.com/auth/token',
scopes: ['name', 'email'],
},
slack: {
authUrl: 'https://slack.com/oauth/v2/authorize',
tokenUrl: 'https://slack.com/api/oauth.v2.access',
userInfoUrl: 'https://slack.com/api/users.identity',
scopes: ['identity.basic', 'identity.email'],
},
};
Account Linking
import { UserService } from '@/lib/services/user-service';
const userService = new UserService(db);
// Link additional OAuth account
await userService.connectAccount(
playerUuid,
'discord',
discordUser,
false // not primary
);
// Set primary account
await userService.setPrimaryAccount(playerUuid, 'google');
// Disconnect account
await userService.disconnectAccount(playerUuid, 'slack');
Security Features
CSRF Protection
// OAuth state parameter for CSRF protection
const state = crypto.randomUUID();
const authUrl = `${provider.authUrl}?state=${state}&client_id=${clientId}`;
// Verify state parameter in callback
if (state !== expectedState) {
throw new Error('CSRF protection failed');
}
Session Security
// Secure session configuration
const sessionConfig = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/'
};
Token Validation
// API token validation
export async function validateApiToken(request: Request, apiToken: string) {
const authHeader = request.headers.get('authorization');
const customTokenHeader = request.headers.get('x-api-token');
let tokenToValidate = customTokenHeader;
if (authHeader) {
if (authHeader.startsWith('Bearer ')) {
tokenToValidate = authHeader.substring(7);
} else if (authHeader.startsWith('Token ')) {
tokenToValidate = authHeader.substring(6);
} else {
tokenToValidate = authHeader;
}
}
if (!tokenToValidate) return false;
// Use timing-safe comparison
return await safeCompare(apiToken.trim(), tokenToValidate.trim());
}
Session Lifecycle
Session Creation Flow
- OAuth Authentication: User authenticates with provider
- User Lookup: Check if user exists in database
- Account Creation: Create new account if needed
- Role Assignment: Assign default role and permissions
- Session Creation: Create session with user data
- Cookie Setting: Set secure session cookie
Session Validation Flow
- Cookie Extraction: Get session cookie from request
- Session Lookup: Retrieve session from KV storage
- Expiration Check: Verify session hasn’t expired
- User Validation: Validate user still exists
- Role Loading: Load current roles and permissions
- Session Refresh: Update last activity timestamp
Session Cleanup Flow
- Expiration Check: Identify expired sessions
- Invalidation: Mark sessions as invalid
- Storage Cleanup: Remove from KV storage
- Cookie Removal: Clear session cookie
- User Notification: Notify user of session expiry
Best Practices
1. Always Validate Sessions
// Always check session validity
const player = await getFreshPlayerSession(astro);
if (!player) {
return redirect('/auth/login');
}
2. Use Specific Permissions
// Good: Check specific permission
if (hasPermission(player, ADMIN_PERMISSIONS.PLAYERS_EDIT)) {
// Allow editing
}
// Bad: Check admin status only
if (isAdmin(player)) {
// Too broad
}
3. Implement Proper Error Handling
try {
const player = await getPlayerSession(astro);
// Process request
} catch (error) {
if (error.name === 'SessionExpiredError') {
return redirect('/auth/login');
}
throw error;
}
4. Regular Session Cleanup
// Implement periodic session cleanup
setInterval(async () => {
await cleanupExpiredSessions();
}, 3600000); // Every hour
Testing
Session Testing
describe('Session Management', () => {
it('should create valid session after OAuth', async () => {
const sessionId = await createPlayerSession(astro, playerData);
expect(sessionId).toBeDefined();
const player = await getPlayerSession(astro);
expect(player).toBeDefined();
expect(player.email).toBe(playerData.email);
});
it('should handle expired sessions', async () => {
// Create expired session
const expiredSession = { ...sessionData, expiresAt: new Date(Date.now() - 1000) };
const player = await getFreshPlayerSession(astro);
expect(player).toBeNull();
});
});
Permission Testing
describe('RBAC System', () => {
it('should check permissions correctly', () => {
const player = createMockPlayerWithRoles(['padawan']);
expect(hasPermission(player, ADMIN_PERMISSIONS.PLAYERS_VIEW)).toBe(true);
expect(hasPermission(player, ADMIN_PERMISSIONS.PLAYERS_EDIT)).toBe(false);
});
it('should handle admin route access', () => {
const adminPlayer = createMockPlayerWithRoles(['system_admin']);
const regularPlayer = createMockPlayerWithRoles(['padawan']);
expect(canAccessAdminRoute(adminPlayer, '/admin/players')).toBe(true);
expect(canAccessAdminRoute(regularPlayer, '/admin/players')).toBe(false);
});
});
This session management and RBAC system provides secure, scalable authentication and authorization for the PadawanForge platform.