Technical docs

Auth and Session

Overview

Support reference for login, session bootstrap, logout, invite interaction, and cross-tab session sync.

Main frontend routes

  • `/login`
  • `/login/google`
  • `/login/auth-server/callback`
  • `/login/telegram`
  • `/desktop-auth/authorize`
  • `/settings`
  • legacy redirects: `/settings/appearance` and `/settings/connectedAccounts`
  • `/invite/[code]`

Main backend routes

  • `GET /api/auth/providers`
  • `POST /api/auth/ldap-single`
  • `GET /api/auth/auth-server/{provider}`
  • `GET /api/auth/auth-server/callback`
  • `GET /api/auth/google`
  • `GET /api/auth/google/callback`
  • `GET /api/auth/google/profile`
  • `GET /api/auth/telegram`
  • `GET /api/auth/telegram/callback`
  • `DELETE /api/auth/telegram`
  • `GET /api/auth/status`
  • `POST /api/auth/logout`
  • `GET /api/init/whoami`
  • Local dev auth routes:
  • `GET /api/auth/dev/users`
  • `POST /api/auth/dev/login`
  • Public invite routes:
  • `GET /api/invites/{code}`
  • `GET /api/invites/{code}/accept`

Session model

  • Sessions use cookie-based auth backed by `express-session` with Redis session storage.
  • The session cookie name defaults to `sessionId` and can be overridden by `SESSION_COOKIE_NAME`.
  • The backend session stores:
  • `userId`
  • `wsToken`
  • `authMiddleware` requires `req.session.userId` and resolves `req.user`.
  • `optionalAuthMiddleware` resolves `req.user` when a valid session exists, but does not require one.

`/api/init/whoami`

  • This is the frontend's real bootstrap endpoint.
  • If there is no valid session or no `wsToken`, it returns:
  • `user: null`
  • `wsToken: null`
  • If the session user no longer exists, it also returns `user: null` and `wsToken: null`.
  • If the session user's current organization is missing or its membership is no longer active (for example after the user was removed from the organization), the backend selects a valid organization:
  • the first active organization membership for that user
  • or a newly created default organization named `My Organization` when the user has no organization at all (auto-creation is skipped in one-organization mode)
  • If no organization remains available, the response keeps the user authenticated (`user` and `wsToken` present) but returns `current_organization_id: null` and `currentOrganization: null`.
  • On success it returns:
  • `user: user.safeData()`
  • `wsToken`
  • The frontend uses this response to populate:
  • current user
  • current organization
  • websocket token

User profile identity

  • The primary `User` record stores nullable `name` and `picture` fields in addition to email.
  • `user.safeData()` returns the trimmed primary user name and picture when present, then falls back to the linked legacy Google profile values.
  • New Google and auth-server users receive the current identity name and picture on the primary user record.
  • Returning Google and auth-server users refresh those primary profile fields during successful callback processing.
  • A database migration backfills missing primary profile fields from linked authentication records without replacing existing non-empty user values.
  • Staff, invites, conversations, messages, notifications, and admin serializers use the primary user profile fields, so display identity is no longer tied only to the legacy Google-auth relation.

Frontend auth lifecycle

  • `AuthContext` calls `/api/init/whoami` on mount.
  • It applies:
  • 10 second request timeout
  • one automatic retry after 2 seconds for network / timeout failures only
  • Backend `is_admin` is mapped to frontend `is_developer`.
  • Backend timestamps are mapped into frontend ISO date fields.
  • Unauthenticated portal routes show the redirecting overlay, then navigate to `/login` with the current URL captured as a return path; a hard browser redirect is the fallback when client routing does not complete. After sign-in the user returns to that URL (see Return to the original page after login).
  • When authentication succeeds but the user has no accessible organization, the portal shows a full-screen `No organization` state with a `Log out` button instead of portal content.
  • Auth infrastructure errors remain on the error screen with Retry instead of being treated as an ordinary unauthenticated redirect.
  • Logout posts to `/api/auth/logout`.
  • The backend destroys the Alloy session, clears the session cookie, and may return `logout_url`.
  • When `logout_url` is present, the frontend performs a full browser redirect through the auth-server end-session flow. Otherwise it clears local user, organization, and websocket-token state and the portal guard redirects to `/login`.
  • If `/api/init/whoami` still fails after the retry, the frontend keeps the user unauthenticated and sets `authError` to `network` or `timeout`.

Return to the original page after login

  • When an unauthenticated user opens a portal page, the frontend captures the current URL (path + query + hash) and redirects to `/login` with a `state` query parameter containing JSON: `{"returnPath":"<url>"}`.
  • The login page carries `returnPath` through sign-in:
  • auth-server redirect providers store it in the session when `GET /api/auth/auth-server/{provider}` starts the flow
  • the Google callback parses it from the OAuth `state`
  • the `ldap-single` form posts it as the optional `returnPath` request field
  • After successful sign-in the backend redirects:
  • new users first to onboarding (see New-user bootstrap); invite-based sign-in still lands on the invite page first
  • existing users to the validated `returnPath`, or to `/` when no return path was provided
  • Return paths are validated as same-origin relative paths (pathname + query + hash). Absolute URLs, `//`-prefixed paths, backslash-containing paths, and malformed state JSON fall back to `/`.
  • The auth layout also redirects an already-authenticated user who lands on `/login` to the validated return path, except on `/login/google` and `/login/auth-server/callback`, which continue their own flows.

Auth server sign-in flow

  • `/login` renders the shared sign-in panel with title `Welcome back` and subtitle `Choose a sign-in method to continue.`
  • The default browser auth flow is now `auth-server`. The login page loads sign-in methods from `GET /api/auth/providers` unless the browser storage has `alloy.authFlow` set to `legacy-google`, which preserves the legacy Google redirect flow.
  • `GET /api/auth/providers` returns enabled providers for the current environment. Supported provider types are `redirect`, `credentials`, and `ldap-single`.
  • Redirect providers are displayed as buttons with their configured labels and inline SVG icons when available.
  • If there is exactly one redirect provider and no credentials or `ldap-single` form provider, the frontend automatically starts that provider flow. Returning from logout no longer suppresses this automatic redirect.
  • The auth server uses a shared, source-independent invalidation flow: it ends the auth-server session, then redirects back to the Alloy login page.
  • Auth-server transition pages use the neutral title `Auth` on the default brand/domain, including authorization, enrollment, and logout transitions.
  • A configured credentials provider shows `Email`, `Password`, and `Login`, but submitting that generic credentials form currently shows `Password sign-in is not available yet.`
  • An enabled `ldap-single` provider renders a working `Username`, `Password`, and `Login` form. It submits to `POST /api/auth/ldap-single` and shows `Invalid username or password.` when the request does not complete successfully.
  • The LDAP request accepts `username`, `password`, and optional `inviteCode` and `returnPath`. Successful authentication uses the same user identity, registration policy, invite acceptance, session, websocket token, onboarding, and redirect pipeline as other auth-server sign-ins.
  • LDAP request outcomes include:
  • `400` when username or password is missing
  • `401` for rejected credentials or an incomplete LDAP flow
  • `404` when no usable `ldap-single` provider is enabled
  • `503` when the sign-in service is unavailable
  • `504` when the auth-server flow exceeds its 15-second deadline
  • The LDAP endpoint has a dedicated brute-force rate limiter in addition to the general auth limiter.
  • Clicking a redirect provider calls `GET /api/auth/auth-server/{provider}`. The backend validates the provider, stores auth state, any invite code, and any return path in the session, and redirects the browser to the provider auth URL.
  • `/login/auth-server/callback` reads the provider callback query, calls `GET /api/auth/auth-server/callback`, shows success or error toast text, and then follows the backend `redirect_url`.
  • `GET /api/auth/auth-server/callback` requires both `code` and the matching session auth state. On success it sets the browser session `userId` and `wsToken`.
  • Auth-server identities are stored separately from legacy Google auth identities, but Google-compatible auth-server sign-ins can link to existing Google users by provider identity or email.
  • If callback completion fails, the frontend returns to `/login?error=auth_server_callback` and shows `We were unable to complete sign-in. Please try again.`

Google login flow

  • `/login` is still the main sign-in entry, but it no longer always redirects straight to Google.
  • Once auth bootstrap finishes and the user is not authenticated, `/login` first calls `GET /api/auth/dev/users`.
  • If that request returns one or more users, `/login` renders a `Development sign-in` picker with:
  • one button per returned user
  • an `Admin` badge for `is_admin` users
  • a fallback `Sign in with Google` action
  • If the dev-users request fails or returns no users, `/login` falls back to `GET /api/auth/google` and redirects to the returned `auth_url`.
  • `/login` forwards an optional `state` query parameter to Google sign-in and also forwards `select_account=true` when present.
  • `/login/google` reads the Google `code` and optional `state`, calls `GET /api/auth/google/callback`, then:
  • shows the backend `message` when present
  • calls `refetchUser()`
  • routes to `redirect_url`
  • falls back to `/login` when no redirect is returned
  • Desktop-auth callback redirects are special-cased:
  • if `redirect_url` starts with `/api/desktop-auth/`, the frontend does a full browser redirect to `NEXT_PUBLIC_API_URL + redirect_url`
  • if `redirect_url` starts with `/desktop-auth/`, the frontend rewrites it to `NEXT_PUBLIC_API_URL + /api + redirect_url` and then does a full browser redirect
  • other app-local redirects still use client-side routing
  • In the callback, the backend:
  • exchanges the Google code
  • parses the OAuth `state` JSON for `inviteCode` and `returnPath`
  • creates or updates `User` and `UserGauth`
  • sets `req.session.userId`
  • generates and stores `req.session.wsToken`

Local development auth flow

  • Local dev auth is not available in production.
  • The backend returns `404 Not found` for these routes when any of the following is true:
  • `NODE_ENV === 'production'`
  • `LOCAL_DEV_AUTH_ENABLED === 'false'`
  • the app is not in `development` and `LOCAL_DEV_AUTH_ENABLED !== 'true'`
  • the request remote address is not loopback
  • the request host is not loopback
  • the request `Origin` header is present but is not loopback
  • `GET /api/auth/dev/users` returns sanitized active-user choices for the picker.
  • `POST /api/auth/dev/login` accepts:
  • `user_id`
  • optional `state`
  • A successful dev login creates the same browser session fields as Google OAuth:
  • `req.session.userId`
  • `req.session.wsToken`
  • If the provided `state` JSON includes `inviteCode`, the backend also attempts invite acceptance before returning.

Desktop auth authorization flow

  • `/desktop-auth/authorize` is the browser confirmation screen for desktop sync authorization.
  • The page reads these query params:
  • `return_to`
  • `consent_token`
  • `device_name` with fallback `Desktop sync app`
  • If the user is not authenticated, the page shows a sign-in prompt and links back into `/login` with a desktop-auth state payload.
  • That desktop-auth state currently stores `desktopAuthReturnTo` inside the login `state` JSON.
  • If the user is already authenticated, the page shows:
  • the signed-in Alloy account name and email
  • `Continue as ...`
  • `Use another account`
  • `Cancel`
  • `Use another account` logs the current user out and restarts `/login` with both the desktop-auth state and `select_account=true`.
  • Desktop authorization only proceeds when `return_to` resolves to `/desktop-auth/authorize` or `/api/desktop-auth/authorize` and does not already contain sensitive auth params such as access tokens, refresh tokens, codes, or ID tokens.
  • If validation fails, the page shows `This authorization request is invalid.` and disables continuation.

Personal settings and Telegram account linking

  • `/settings` is a unified personal settings page. Legacy `/settings/appearance` and `/settings/connectedAccounts` links redirect there.
  • It contains:
  • connected Gmail and Telegram accounts
  • interface language
  • dashboard theme
  • `Full-width AI responses`
  • Gmail is display-only and reflects the current Google-linked profile.
  • Telegram row behavior:
  • unlinked state shows `Connect`
  • linked state shows the account identifier plus `Disconnect` behind a confirmation dialog
  • the displayed identifier prefers `@username`, then linked name, then Telegram account ID
  • Connecting Telegram starts with `GET /api/auth/telegram` and redirects the browser to the returned auth URL.
  • `/login/telegram` is the frontend callback landing page. It reads the `code` query param, calls `GET /api/auth/telegram/callback`, updates the current user when a user payload is returned, and then redirects to the backend-provided `redirect_url` after a short success delay.
  • The Telegram auth routes are session-protected, so this flow links Telegram to the already authenticated user rather than acting as standalone anonymous sign-in.
  • `DELETE /api/auth/telegram` unlinks Telegram and returns the updated user payload.
  • Interface language choices are Automatic, English, Russian, Spanish, Ukrainian, Serbian, Kazakh, Romanian, and Portuguese (BR). Automatic follows the browser preference and falls back to English when the browser language is unsupported; Brazilian Portuguese browser tags (`pt` or `pt-BR`) resolve to Portuguese (BR).
  • Theme choices are Light, Latte, Dark, Dark Blue, and System.
  • The full-width switch selects full-width AI messages or bubble presentation in internal chat.

Invite interaction

  • Invite-aware login passes OAuth `state` as JSON: `{"inviteCode":"<code>"}`. The same `state` JSON may also carry a `returnPath` key for existing users.
  • In the Google callback:
  • the backend parses `state`
  • saves the session
  • then attempts invite acceptance when `inviteCode` is present
  • Successful invite-based sign-in returns `redirect_url: '/'`.
  • If callback-time invite acceptance fails with an app error, the backend returns `success: false` and `redirect_url: /invite/{code}` so the frontend lands back on the invite page.
  • Invite-based sign-up skips the normal non-invite onboarding redirect.
  • The local dev auth login route uses the same `inviteCode` parsing pattern when `state` is provided.
  • For invite-page behavior itself, see invite-flow.md.

Registration policies

  • Registration policy flags affect account creation only. Existing users can still sign in through the normal auth-server or Google callback path.
  • With `ONLY_INVITED_USERS=true`, a new account requires an organization invite that exists, is pending, and has not expired.
  • no invite returns `403` with `Registration is by invitation only`
  • an invalid, non-pending, or expired invite returns `403` with `Invite is invalid or has expired`
  • validation happens before the user row is created, so a rejected registration does not leave an orphan account
  • The invitation-only check applies to both auth-server and legacy Google new-user callbacks.
  • With `ONE_ORGANIZATION_MODE=true`, newly registered users without an invite join the single non-system organization instead of receiving a separate personal organization.
  • when that organization exists, the new user joins as `member` and it becomes their current organization
  • when none exists, the first user creates the normal default organization and becomes its owner
  • if more than one non-system organization exists, registration aborts because the single-organization invariant is broken
  • Invite-based registration continues to join the organization targeted by the invite rather than using the non-invited bootstrap path.

New-user bootstrap

  • Without single-organization mode, new Google or auth-server users without an invite get a default organization.
  • With single-organization mode, the same users join or create the one non-system organization as described above.
  • The callback then returns the onboarding redirect from `getOnboardingRedirectUrl(organization?.id)`.
  • Current onboarding helper behavior is:
  • `/staff/ai/{employee_id}?onboarding=true` when the organization already has an AI teammate
  • `/` when it does not
  • Existing users without an invite flow are redirected to the validated `returnPath` when one was provided, otherwise to `/`.

Cross-tab session sync

  • The frontend syncs session state across tabs in three ways:
  • `BroadcastChannel` messages for org changes and logout
  • Zustand subscription broadcasting current user / org changes
  • fallback `/api/init/whoami` check after a tab becomes visible after 30s hidden
  • Mismatch states shown by the UI are:
  • `user_changed`
  • `org_changed`
  • `logged_out`
  • The recovery path is a hard reload.

Support checks

  • If login appears stuck:
  • inspect `GET /api/auth/google` for a returned `auth_url`
  • inspect `/api/init/whoami`
  • inspect `/api/auth/google/callback`
  • check the session cookie name and Redis-backed session storage
  • If logout returns to Alloy but immediately signs the user in again:
  • confirm `POST /api/auth/logout` returned the configured auth-server end-session URL
  • confirm the auth-server invalidation flow ended its own session before redirecting back to `/login`
  • remember that a sole redirect provider now starts automatically on `/login`, including after logout
  • If local dev sign-in appears on a non-local environment or does not appear locally:
  • inspect `NODE_ENV`
  • inspect `LOCAL_DEV_AUTH_ENABLED`
  • inspect request host, origin, and remote address loopback checks
  • inspect `GET /api/auth/dev/users`
  • If desktop sync auth lands on the wrong redirect or cannot continue:
  • inspect `/desktop-auth/authorize` query params
  • inspect whether `return_to` passed validation
  • inspect whether Google callback returned a desktop-auth redirect path
  • If Telegram linking fails:
  • confirm the user already had a valid session before entering the Telegram flow
  • inspect `GET /api/auth/telegram`
  • inspect `GET /api/auth/telegram/callback?code=...`
  • confirm whether the backend returned a `redirect_url`
  • check whether the Telegram account is already linked to this user or another user
  • If the user lands in the wrong org:
  • inspect `currentOrganization` in `/api/init/whoami`
  • inspect whether the frontend received an org-change sync event from another tab
  • If a signed-in user was removed from their current organization or sees `No organization`:
  • confirm `/api/init/whoami` re-selected an available organization or returned `currentOrganization: null`
  • confirm the frontend shows the `No organization` state with a `Log out` button only when no organization remains
  • If invite login behaves differently from self-serve login:
  • confirm whether OAuth `state` included JSON with `inviteCode`
  • confirm whether invite acceptance succeeded in the callback path

Start building your AI team