Authentication
Optics Terminal has no self-service registration and no password reset. Accounts are provisioned by staff, and there are two ways to sign in: WorkOS SSO for ordinary organisation users, and email + password for investors and for individual users who have been opted out of SSO.
Sign-in paths
Section titled “Sign-in paths”flowchart TD
Start["POST /login<br/>with email"] --> Lookup{"User exists<br/>and usesPasswordAuth()?"}
Lookup -->|"no"| WorkOS["Redirect to WorkOS AuthKit"]
Lookup -->|"yes"| Pw["Re-render login page<br/>with a password field"]
WorkOS --> Callback["GET /callback"]
Callback --> Sync["WorkOSAuthService<br/>create or update user"]
Sync --> Dash["Dashboard"]
Pw --> Which{"role === 'investor'?"}
Which -->|"yes"| Inv["POST /login/investor"]
Which -->|"no"| Std["POST /login/password"]
Inv --> Conf["Confidentiality notice"]
Std --> Dash
The login page is a single email field. Which path a user takes is decided server-side from their record, not by anything they choose — User::usesPasswordAuth() returns true when uses_password_auth is set or the role is investor.
WorkOS SSO
Section titled “WorkOS SSO”WorkOS AuthKit is the identity provider for ordinary users. The app never sees their password.
-
Start.
AuthLoginControllerlooks the email up. If the user is not a password user, it callsWorkOS::configure()and builds an AuthKit authorization URL withprovider: 'authkit'and the email asloginHint, then returnsInertia::location($url)to hand the browser over. -
State. A random
statevalue plus the base64-encoded previous URL is stored in the session and passed to WorkOS, so the callback can be tied back to the request that started it. -
Callback.
GET /callbackrunsAuthCallbackControllerthroughAuthKitAuthenticationRequest::authenticate(), which exchanges the code and invokesWorkOSAuthServiceto create or update the local user. -
Session. The WorkOS access token is kept in the session as
workos_access_token.HandleInertiaRequestsdecodes it on every request to readpermissions, andValidateSessionOrPasswordAuthrevalidates the session. -
Sign-out.
AuthLogoutControllerdecodes the token for itssid, clears the Laravel session, then redirects to the WorkOS logout URL so the IdP session ends too.
Configuration
Section titled “Configuration”Four values in config/services.php, all from the environment:
| Key | Env var | Purpose |
|---|---|---|
services.workos.client_id |
WORKOS_CLIENT_ID |
AuthKit client |
services.workos.secret |
WORKOS_API_KEY |
Server-side API key |
services.workos.redirect_url |
WORKOS_REDIRECT_URL |
Must match the /callback route |
services.workos.admin_org |
WORKOS_ADMIN_ORG_ID |
The inBeta staff organisation |
User and organisation sync
Section titled “User and organisation sync”WorkOSAuthService reconciles the WorkOS identity with the local records on every sign-in:
- Organisation. It reads the user’s WorkOS organisation memberships and takes the first one. That WorkOS organisation ID must already match an
Organization.workos_idlocally — if it doesn’t, sign-in fails with “Organization not found in system”. Organisations are never auto-created. - User. Matched on
workos_id. New users are created with name, email, organisation, role and department; returning users have their name and email refreshed in case they changed upstream. - Role. The WorkOS membership role slug is mapped to an application role:
ownerandadmin→admin,member→user,org-inbeta-staffpasses through, and anything unrecognised falls back touser.
Password users
Section titled “Password users”Two groups sign in with a password against the local users table. Both use Laravel’s standard web guard and Auth::attempt(), with the password column cast to hashed.
| Investors | Opted-out users | |
|---|---|---|
| Marked by | role = 'investor' |
uses_password_auth = true |
| Endpoint | POST /login/investor |
POST /login/password |
| Extra credential check | none beyond email + password | uses_password_auth = true is part of the credentials |
| Lands on | Confidentiality notice | Dashboard |
Passwords are set by staff, not by the user: the Filament user resource has a Change Password action (minimum 8 characters, requires confirmation) that is visible only to root, and a Sign in with password toggle visible to staff. There is no forgotten-password flow.
Investors
Section titled “Investors”Investors are the most constrained role, held inside their own area by two middlewares that run on every web request:
RedirectInvestorsbounces an investor to/investorsunless the route is on a small allowlist (the investor pages, confidentiality, logout, account, attachment serving and Livewire).EnsureInvestorConfidentialityConfirmedredirects to the confidentiality notice untilinvestor_confidentiality_confirmedis set in the session. Because it lives in the session, the notice reappears on every new sign-in.
EnsureInvestor (alias investor) is the inverse guard: it keeps non-investors out of investor routes.
Sessions and route protection
Section titled “Sessions and route protection”The workos route middleware alias points at ValidateSessionOrPasswordAuth, which picks the right validation per user:
- Impersonated requests pass straight through — the real credential is the staff session, already validated.
- Password users pass through, since there is no WorkOS session to check.
- Everyone else is handed to Laravel WorkOS’s
ValidateSessionWithWorkOS, which revalidates the token and logs the session out if it has been revoked upstream.
HandleInertiaRequests shares auth.user, auth.organization and auth.permissions with React. The organisation is cached for 60 seconds per organisation to avoid repeating five queries on every request.
Staff, admin and impersonation
Section titled “Staff, admin and impersonation”User::isStaff() is true for roles org-inbeta-staff and root, and canAccessPanel() returns exactly that — so the Filament panel at /admin is staff-only, using the same web session as the rest of the app.
Staff can impersonate a user from the organisation resource. The mechanics matter:
- The session’s real
user_idis never changed.ImpersonateUserre-applies the swap per request withAuth::guard('web')->setUser(), so the staff member stays genuinely signed in. - The middleware runs directly after
StartSessionin the priority list, ahead ofAuthenticateandSubstituteBindings— otherwise the organisation-scoped route bindings inbootstrap/app.phpwould resolve against the staff member’s organisation and 404. /admin, all Livewire endpoints,logoutand the stop-impersonation route are exempt, so the panel always acts as the real staff user.- Only staff sessions may carry the key; a non-staff session holding one has it dropped. Starting and stopping impersonation are both logged with the staff and target IDs.
Multi-tenancy as access control
Section titled “Multi-tenancy as access control”Tenancy is the main authorisation boundary. Route model binding for project and focusIndividual is scoped to auth()->user()->organization_id in bootstrap/app.php, so a URL naming another organisation’s project 404s rather than 403s. Beyond those bindings, scoping is by convention — see the caution on the Overview.
API and agent access
Section titled “API and agent access”The MCP server at /mcp is separate from browser sign-in. It uses Passport (auth:api) with OAuth 2.1 discovery and dynamic client registration, then EnsureAgentApproved rejects the initialize handshake unless the connecting assistant resolves to a provider the organisation has approved. Unrecognised clients are always rejected, and tool calls re-check approval per call so that withdrawing approval stops an in-flight session.
Known gaps
Section titled “Known gaps”CSRF verification is disabled for login and sso/start (see validateCsrfTokens(except:) in bootstrap/app.php). sso/start needs it because the marketing site posts cross-origin into the SSO flow; both are guest-only endpoints.