NAME auth - RSX authentication, multi-tenant, and experience-based session system SYNOPSIS Multi-realm authentication with request-scoped overrides for complex B2B SaaS applications DESCRIPTION The RSX authentication system provides flexible session management for B2B SaaS applications with support for: 1. Multi-tenant organizations (site_id) 2. Multiple authentication realms (experience_id) 3. Customer portals vs staff panels with separate logins 4. Subdomain-based tenant enforcement 5. Organization picker workflows (Trello-style) Unlike traditional single-context authentication, RSX allows users to be logged into multiple "experiences" simultaneously using the same cookie. For example, a user can be logged into a customer portal as one user AND a staff admin panel as a different user, with the system automatically determining which session to use based on the request context. Key differences from traditional authentication: - Traditional: One user per session cookie - RSX: Multiple sessions per cookie (different experiences) - Traditional: Site/tenant determined by session record - RSX: Site can be enforced per-request (subdomain) without session writes - Traditional: Can't be logged in as different users simultaneously - RSX: Customer portal login + staff panel login using same cookie Benefits: - Support customer portals and staff panels simultaneously - Subdomain-based tenant enforcement without session overhead - Flexible organization switching (Trello-style) when needed - Clean API for request-scoped overrides CORE CONCEPTS Site (site_id): Represents data segregation for multi-tenant applications. Each site is typically an organization/company/tenant. Can be set in session (persisted) or overridden per-request (ephemeral). Experience (experience_id): Represents authentication realm or context. Different experiences use different session records but same cookie. Typical values: - 0: Default (standard user login) - 1: Staff portal (admin/employee accounts) - 2: Customer portal (client-facing accounts) Request-Scoped Override: Temporary value that takes precedence for current request only. Does NOT modify session record in database. Use case: Subdomain enforcement, temporary context switching. EXPERIENCE-BASED AUTHENTICATION Concept: An experience is an authentication realm. The same user can be logged into different experiences simultaneously, each with its own session record and user_id, all sharing one cookie token. How It Works: 1. Request arrives with cookie token 2. Framework sets experience_id based on URL path (e.g., /staff/* = 1) 3. Session::init() loads session WHERE token=X AND experience_id=Y 4. Different experience = different session = different user Database Structure: sessions table has experience_id column (default 0) Same session_token can have multiple rows with different experience_id Each experience has its own user_id, site_id, csrf_token Use Cases: Customer Portal + Staff Panel: // Customer visits /portal/dashboard Session::set_experience_id(2); // Loads customer portal session (experience_id=2, user_id=123) // Same user visits /staff/admin Session::set_experience_id(1); // Loads staff session (experience_id=1, user_id=456) // User is logged in as BOTH simultaneously Public Site + Admin: // Anonymous visitor on public site Session::set_experience_id(0); // No login required // Same visitor accesses /admin Session::set_experience_id(1); // Requires staff login (different experience) SETTING EXPERIENCE CONTEXT In Main.php pre_dispatch: public static function pre_dispatch(Request $request, array $params = []) { $path = $request->path(); // Determine experience based on URL if (str_starts_with($path, 'staff/')) { Session::set_experience_id(1); // Staff realm } elseif (str_starts_with($path, 'portal/')) { Session::set_experience_id(2); // Customer realm } else { Session::set_experience_id(0); // Default realm } return null; } Based on Subdomain: $host = $request->getHost(); if (str_ends_with($host, '-staff.yourapp.com')) { Session::set_experience_id(1); // Staff subdomain } elseif (str_ends_with($host, '-portal.yourapp.com')) { Session::set_experience_id(2); // Customer subdomain } API Methods: Session::set_experience_id(int $id): void Set experience context for current request. Clears cached session/user/site data and forces re-initialization with new experience filter. Session::get_experience_id(): int Get current experience ID (default 0). REQUEST-SCOPED SITE OVERRIDE Concept: Override site_id for current request without modifying session record. Use case: Subdomain enforcement where subdomain determines tenant. Behavior: - Session::get_site_id() returns override value if set - Session record site_id remains unchanged - Override cleared at end of request (not persisted) - Useful when site is determined by subdomain, not user choice Use Case - Subdomain Enforcement: // User visits acme.yourapp.com $subdomain = 'acme'; $site = Site_Model::where('subdomain', $subdomain)->first(); if ($site) { // Enforce this site for current request Session::set_request_site_id_override($site->id); // All code that calls Session::get_site_id() will get this value // Session record NOT modified } // Later in controller: $site_id = Session::get_site_id(); // Returns enforced site_id Use Case - Temporary Context: // Temporarily switch site context without changing session Session::set_request_site_id_override($other_site_id); // Process data for other site $data = process_for_site(); // Clear override to return to normal Session::clear_request_site_id_override(); API Methods: Session::set_request_site_id_override(int $site_id): void Override site_id for current request only. Does NOT persist to database. Takes precedence over session site_id. Session::clear_request_site_id_override(): void Remove override, return to normal session-based site_id. Session::has_request_site_id_override(): bool Check if override is currently active. MULTI-TENANT PATTERNS Pattern 1: Subdomain Enforcement Each tenant has a subdomain. Site is determined by subdomain, not user choice. User cannot switch sites. Implementation: In Main.php pre_dispatch: $host = $request->getHost(); $subdomain = explode('.', $host)[0]; $site = Site_Model::where('subdomain', $subdomain)->first(); if ($site) { Session::set_request_site_id_override($site->id); } Result: - acme.yourapp.com → site_id enforced to Acme's site - widget.yourapp.com → site_id enforced to Widget's site - Session record site_id remains 0 (not set) - No database writes for site switching Pattern 2: Organization Picker (Trello-Style) User logs in, then picks which organization to work with. Can switch organizations at any time. Implementation: Login: Session::set_user_id($user_id); // Just login Organization Selection: // Show list of user's organizations $sites = Site_User_Model::where('user_id', $user_id)->get(); // User picks one Session::set_site_id($selected_site_id); // Persists to session Switching Organizations: Session::set_site_id($different_site_id); // Updates session Result: - User explicitly chooses organization - Choice persisted in session record - Can switch organizations without re-login Pattern 3: Combined (Subdomain + Organization Picker) Subdomain determines site, but user can belong to multiple sites and explicitly choose which one to access. Implementation: // Subdomain enforcement $site = Site_Model::where('subdomain', $subdomain)->first(); if ($site) { Session::set_request_site_id_override($site->id); } // Verify user has access $site_user = Site_User_Model::where('user_id', Session::get_user_id()) ->where('site_id', Session::get_site_id()) ->first(); if (!$site_user) { return redirect('/access-denied'); } Result: - Subdomain determines which site - User must have access to that site - Cannot switch sites (determined by subdomain) CUSTOMER PORTAL IMPLEMENTATION Concept: Customer portal is a separate authentication realm (experience_id=2) where end-user customers log in with their own accounts, distinct from staff/admin accounts. Database Structure: users table: - id, email, password, name, etc. - is_customer TINYINT(1) (true for customer portal users) - is_staff TINYINT(1) (true for staff panel users) OR separate tables: customer_users table (customer portal accounts) staff_users table (staff panel accounts) Main.php Experience Detection: public static function pre_dispatch(Request $request, array $params = []) { if (str_starts_with($request->path(), 'portal/')) { Session::set_experience_id(2); // Customer realm } elseif (str_starts_with($request->path(), 'staff/')) { Session::set_experience_id(1); // Staff realm } else { Session::set_experience_id(0); // Default } return null; } Customer Login: #[Route('/portal/login')] public static function customer_login(Request $request, array $params = []) { // Experience already set to 2 by pre_dispatch if ($request->method() === 'POST') { $email = $request->input('email'); $password = $request->input('password'); $user = User_Model::where('email', $email) ->where('is_customer', true) ->first(); if ($user && password_verify($password, $user->password)) { Session::set_user($user); // Creates session with experience_id=2 return redirect('/portal/dashboard'); } Rsx::flash_error('Invalid credentials'); } return view('portal/login'); } Staff Login: #[Route('/staff/login')] public static function staff_login(Request $request, array $params = []) { // Experience already set to 1 by pre_dispatch if ($request->method() === 'POST') { $email = $request->input('email'); $password = $request->input('password'); $user = User_Model::where('email', $email) ->where('is_staff', true) ->first(); if ($user && password_verify($password, $user->password)) { Session::set_user($user); // Creates session with experience_id=1 return redirect('/staff/dashboard'); } Rsx::flash_error('Invalid credentials'); } return view('staff/login'); } Result: - Same user can be logged into /portal/ and /staff/ simultaneously - Different user accounts (different user_id per experience) - Single cookie token with multiple session records - Automatic context switching based on URL ORGANIZATION PICKER WORKFLOW (INCOMPLETE FEATURE) Current State: The framework currently supports setting site_id via Session::set_site() and retrieving it via Session::get_site_id(). However, there is no built-in UI workflow for organization selection. Needed Implementation: 1. Organization Listing Route: Route that shows all organizations user has access to. Query Site_User_Model to find user's sites. 2. Organization Selection Handler: Accepts site_id, verifies user has access, calls Session::set_site_id() to persist choice. 3. Organization Switcher Component: UI component in layout showing current organization with dropdown to switch to different organization. 4. Middleware/Hook: Check if user has site_id set. If not, redirect to organization picker (for multi-tenant apps requiring explicit selection). Example Implementation: Route - Show Organizations: #[Route('/select-organization')] #[Auth('Permission::authenticated()')] public static function select_organization(Request $request, array $params = []) { $user_id = Session::get_user_id(); $sites = Site_User_Model::where('user_id', $user_id) ->with('site') ->get(); return view('auth/select-organization', [ 'sites' => $sites, ]); } Route - Set Organization: #[Route('/set-organization/:site_id')] #[Auth('Permission::authenticated()')] public static function set_organization(Request $request, array $params = []) { $site_id = $params['site_id']; $user_id = Session::get_user_id(); // Verify access $site_user = Site_User_Model::where('user_id', $user_id) ->where('site_id', $site_id) ->first(); if (!$site_user) { Rsx::flash_error('Access denied to this organization'); return redirect('/select-organization'); } // Set site in session (persists) Session::set_site_id($site_id); Rsx::flash_success('Switched to ' . $site_user->site->name); return redirect('/dashboard'); } Main.php - Require Organization Selection: public static function pre_dispatch(Request $request, array $params = []) { // For logged-in users on app routes if (Session::is_logged_in() && str_starts_with($request->path(), 'app/')) { // If no site selected, redirect to picker if (Session::get_site_id() === 0) { // Allow access to selection routes if (!str_starts_with($request->path(), 'select-organization')) { return redirect('/select-organization'); } } } return null; } Future Enhancements: - Remember last selected organization per user - Auto-select if user only has one organization - Organization switcher in navigation bar - Organization-specific branding/theming COMBINING SUBDOMAIN + EXPERIENCE Use Case: Customer portal and staff panel, each with their own subdomain, both multi-tenant with subdomain-based site enforcement. URL Structure: Customer portals: - acme-portal.yourapp.com - widget-portal.yourapp.com Staff panels: - acme-staff.yourapp.com - widget-staff.yourapp.com Implementation: public static function pre_dispatch(Request $request, array $params = []) { $host = $request->getHost(); $parts = explode('.', $host); $subdomain = $parts[0] ?? ''; // Parse subdomain for tenant and experience if (str_ends_with($subdomain, '-portal')) { // Customer portal $tenant = str_replace('-portal', '', $subdomain); Session::set_experience_id(2); } elseif (str_ends_with($subdomain, '-staff')) { // Staff panel $tenant = str_replace('-staff', '', $subdomain); Session::set_experience_id(1); } else { // Default experience, no tenant enforcement Session::set_experience_id(0); return null; } // Enforce site based on tenant subdomain $site = Site_Model::where('subdomain', $tenant)->first(); if ($site) { Session::set_request_site_id_override($site->id); } else { return response('Tenant not found', 404); } return null; } Result: - acme-portal.yourapp.com → experience_id=2, site_id=acme - acme-staff.yourapp.com → experience_id=1, site_id=acme - Same cookie works across both subdomains - Different sessions, different users, same tenant API REFERENCE Experience Methods: Session::set_experience_id(int $experience_id): void Set authentication realm for current request. Clears cached data and re-initializes session with new experience filter. Does NOT persist to database - request-scoped only. In CLI mode: sets static property. Session::get_experience_id(): int Get current experience ID. Default 0. In CLI mode: returns static property. Request-Scoped Site Override: Session::set_request_site_id_override(int $site_id): void Override site_id for current request only. Takes precedence over session record site_id. Does NOT persist to database. Clears cached site. Session::clear_request_site_id_override(): void Remove site override, return to session-based site_id. Clears cached site. Session::has_request_site_id_override(): bool Check if request currently has site override active. Modified Methods: Session::get_site_id(): int Returns request override if set, otherwise session site_id. Respects this precedence: 1. Request override (set_request_site_id_override) 2. CLI static property 3. Session record site_id 4. Default 0 Session::init(): void Now filters by experience_id when loading session: WHERE session_token=? AND active=1 AND experience_id=? MIGRATION PATH Phase 1: Add Experience Support (Backward Compatible) 1. Run migration to add experience_id column (default 0) 2. All existing sessions have experience_id=0 3. Existing code continues working unchanged Phase 2: Enable Experience-Based Features 1. Add experience detection in Main.php pre_dispatch 2. Create customer portal login routes 3. Create staff panel login routes 4. Test simultaneous logins to both experiences Phase 3: Add Subdomain Enforcement 1. Add subdomain detection in Main.php pre_dispatch 2. Call Session::set_request_site_id_override() 3. Verify site filtering works correctly Phase 4: Organization Picker (Optional) 1. Create organization selection routes 2. Add organization switcher UI component 3. Add middleware to require organization selection EXAMPLES Example 1 - Customer Portal + Staff Panel: Main.php: public static function pre_dispatch(Request $request, array $params = []) { if (str_starts_with($request->path(), 'portal/')) { Session::set_experience_id(2); } elseif (str_starts_with($request->path(), 'staff/')) { Session::set_experience_id(1); } return null; } Usage: User visits /portal/dashboard → logged in as customer (user_id=123) User visits /staff/admin → logged in as staff (user_id=456) Both work simultaneously with same cookie Example 2 - Subdomain-Based Multi-Tenant: Main.php: public static function pre_dispatch(Request $request, array $params = []) { $host = $request->getHost(); $subdomain = explode('.', $host)[0]; $site = Site_Model::where('subdomain', $subdomain)->first(); if ($site) { Session::set_request_site_id_override($site->id); } return null; } Usage: User visits acme.yourapp.com → site_id=1 (Acme) User visits widget.yourapp.com → site_id=2 (Widget) Site enforced by subdomain, not user choice Example 3 - Organization Picker: After Login: $user_id = Session::get_user_id(); $sites = Site_User_Model::where('user_id', $user_id)->get(); // Show picker if (count($sites) > 1) { return view('select-organization', ['sites' => $sites]); } else { Session::set_site_id($sites[0]->site_id); return redirect('/dashboard'); } TROUBLESHOOTING Wrong User Loaded: Problem: Getting unexpected user when calling Session::get_user() Solution: - Check current experience_id: Session::get_experience_id() - Verify experience is set correctly in Main.php pre_dispatch - Confirm session record has correct experience_id in database Subdomain Enforcement Not Working: Problem: Session::get_site_id() returns session value, not override Solution: - Verify set_request_site_id_override() is called before get_site_id() - Check Main.php pre_dispatch is executing - Confirm subdomain parsing logic is correct Can't Login to Multiple Experiences: Problem: Logging into staff panel logs out customer portal Solution: - Verify experience_id column exists in sessions table - Check experience_id is set BEFORE calling Session::set_user() - Confirm different experiences create different session records Organization Picker Not Required: Problem: Users can access app without selecting organization Solution: - Add check in Main.php pre_dispatch - Redirect to /select-organization if site_id === 0 - Allow access to selection routes to prevent redirect loop SEE ALSO session - RSX session management API reference model - Model system with relationships routing - Type-safe URL generation and route patterns