Files
rspade_system/app/RSpade/man/auth.txt
root f6fac6c4bc Fix bin/publish: copy docs.dist from project root
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>
2025-10-21 02:08:33 +00:00

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