Skip to content

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.

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 AuthKit is the identity provider for ordinary users. The app never sees their password.

  1. Start. AuthLoginController looks the email up. If the user is not a password user, it calls WorkOS::configure() and builds an AuthKit authorization URL with provider: 'authkit' and the email as loginHint, then returns Inertia::location($url) to hand the browser over.

  2. State. A random state value 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.

  3. Callback. GET /callback runs AuthCallbackController through AuthKitAuthenticationRequest::authenticate(), which exchanges the code and invokes WorkOSAuthService to create or update the local user.

  4. Session. The WorkOS access token is kept in the session as workos_access_token. HandleInertiaRequests decodes it on every request to read permissions, and ValidateSessionOrPasswordAuth revalidates the session.

  5. Sign-out. AuthLogoutController decodes the token for its sid, clears the Laravel session, then redirects to the WorkOS logout URL so the IdP session ends too.

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

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_id locally — 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: owner and adminadmin, memberuser, org-inbeta-staff passes through, and anything unrecognised falls back to user.

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 are the most constrained role, held inside their own area by two middlewares that run on every web request:

  • RedirectInvestors bounces an investor to /investors unless the route is on a small allowlist (the investor pages, confidentiality, logout, account, attachment serving and Livewire).
  • EnsureInvestorConfidentialityConfirmed redirects to the confidentiality notice until investor_confidentiality_confirmed is 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.

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.

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_id is never changed. ImpersonateUser re-applies the swap per request with Auth::guard('web')->setUser(), so the staff member stays genuinely signed in.
  • The middleware runs directly after StartSession in the priority list, ahead of Authenticate and SubstituteBindings — otherwise the organisation-scoped route bindings in bootstrap/app.php would resolve against the staff member’s organisation and 404.
  • /admin, all Livewire endpoints, logout and 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.

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.

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.

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.