Fix bin/publish: use correct .env path for rspade_system Fix bin/publish script: prevent grep exit code 1 from terminating script 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
625 lines
23 KiB
Plaintext
Executable File
625 lines
23 KiB
Plaintext
Executable File
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
|