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:

  1. Invalid Route Parameters: The route parameter id is not being properly extracted
  2. Missing Validation: API routes not validating parameter existence before use
  3. Frontend State Issues: Component passing undefined values to API calls
  4. 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:

  1. Game Sessions API: Updated to access roomsResult.data.gameSessions
  2. NPCs API: Kept as npcsResult.npcs (already correct)
  3. Applied to both parallel and sequential fetch modes
  4. 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

  1. OAuth Authentication: User authenticates with provider
  2. User Lookup: Check if user exists in database
  3. Account Creation: Create new account if needed
  4. Role Assignment: Assign default role and permissions
  5. Session Creation: Create session with user data
  6. Cookie Setting: Set secure session cookie

Session Validation Flow

  1. Cookie Extraction: Get session cookie from request
  2. Session Lookup: Retrieve session from KV storage
  3. Expiration Check: Verify session hasn’t expired
  4. User Validation: Validate user still exists
  5. Role Loading: Load current roles and permissions
  6. Session Refresh: Update last activity timestamp

Session Cleanup Flow

  1. Expiration Check: Identify expired sessions
  2. Invalidation: Mark sessions as invalid
  3. Storage Cleanup: Remove from KV storage
  4. Cookie Removal: Clear session cookie
  5. 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.

PadawanForge v1.4.1