Authentication

SSO login via Microsoft Entra ID with dynamic multi-tenant OAuth and role-based routing.

Multi-Tenant Architecture

Each organization has its own Microsoft Entra app registration. Credentials (tenant_id, client_id, client_secret) are stored encrypted (AES-256-GCM) in the organizations table during the setup wizard. The Go API handles the OAuth flow dynamically using each org’s credentials — no static env vars needed.

Login Flow

What the person sees:

  1. They open the sign-in page and enter their work email — the address they use every day, or the name they sign in to Microsoft with. Either finds their organisation.
  2. If they sign in with Microsoft, the card continues to Microsoft on its own. A Continue to Microsoft button is there only as a fallback.
  3. Microsoft opens with their sign-in name already filled in, so they enter only their password.
  4. They land on the area their access allows.

Only your main work email or your Microsoft sign-in name works here.

People who sign in with a password see a password field instead, even inside an organisation that uses Microsoft sign-in.

How it works:

  1. BFF calls POST /v1/auth/lookup-by-email → returns org info (name, logo, SSO status, and how this person signs in). The lookup matches the typed value against the person’s email, and — for Microsoft sign-in people only — against their stored Microsoft sign-in name, their UPN, or (for directory members only) their main address when their account is under their sign-in name
  2. BFF calls POST /v1/auth/microsoft/authorize with the org_id and the typed address as login_identifier
  3. Go API loads the org’s Entra credentials and constructs the OAuth authorization URL. Inside that organisation only, it looks up the person’s stored sign-in name and sends it to Microsoft as login_hint; with no stored name it sends what was typed
  4. User is redirected to Microsoft for authentication
  5. Microsoft redirects to /api/auth/sso/callback with an auth code
  6. BFF sends the code to POST /v1/auth/microsoft/callback
  7. Go API decrypts the org’s client secret, exchanges the code for tokens and fetches the user profile from Microsoft Graph. It finds the Lacroo user by Microsoft object ID first. If none matches, it looks for a user not yet linked to any Microsoft account, under the person’s main address, or under their UPN if that user is active. It never picks a user already linked to another Microsoft account. Before it changes anything, it refuses an account that is not active (as password sign-in does; a pending user is activated instead), and refuses the sign-in with a 409 if that person’s own email already signs in to a different organisation. Otherwise it stores the person’s current sign-in name, moves a pending user to active, and creates a session in ba_user + ba_session
  8. BFF receives session_token and session_expires from the Go API response
  9. BFF sets httpOnly cookies: lacroo.session_token, lacroo.org_id, lacroo.role, lacroo.surfaces
  10. User lands on the area their access allows

For admins

Members sign in with their Microsoft account. Some people’s mailbox is not the name they sign in to Microsoft with — joint-venture and alliance mailboxes are the common case. Lacroo keeps each person’s Microsoft sign-in name and fills it in for them, so this is handled automatically.

If a person’s sign-in name is not known yet, it is filled in after their first successful sign-in or the next directory refresh. The directory refreshes every night.

A person added under their main address or their sign-in name is linked to their Microsoft account the same way. A deactivated account is never linked, and cannot sign in.

Adding someone from the directory who already has an account under another of their addresses is refused. The message names that account, so you can find it on the Users page.

Password rule

Every new password needs at least 8 characters. This applies wherever a password is set: adding or editing a user on the Users page, accepting an invite, signing up in the app, and resetting a password.

Passwords set before the rule keep working. The rule applies the next time the person sets a new one.

The most a password can be is 72 characters.

If sign-in fails

The sign-in page shows one of these lines:

  • Microsoft sign-in was cancelled.
  • Microsoft couldn’t sign you in. Try again. Also shown to a deactivated account, and to a person whose Microsoft account was deleted and re-created. Contact Lacroo support to relink that person.
  • This email is already used by another organisation. That person already has an account in a different organisation.

The mobile app shows the same lines. A cancel on mobile simply returns to the sign-in screen.

Redirect URIs

Register both URIs in each org’s Microsoft Entra App Registration under Authentication → Platform configurations → Web → Redirect URIs — the first is the web app, the second is the mobile app:

http://localhost:3001/api/auth/sso/callback
http://localhost:3001/api/mobile/auth/sso/callback

For production:

https://app.yourdomain.com/api/auth/sso/callback
https://app.yourdomain.com/api/mobile/auth/sso/callback

The Single Sign-On settings page and the setup wizard’s SSO step both show the two exact values.

Architecture

Zero Trust Boundary

The BFF never accesses PostgreSQL directly. All database operations (session creation, validation, deletion) go through the Go API, which is the sole owner of the database connection.

ComponentDatabase Access
Go APIFull access via lacroo user
Next.js BFFNo direct database access — proxies via Go API

Session Lifecycle

Sessions are created by the Go API during the Microsoft callback flow and validated/deleted via dedicated internal endpoints:

  • Creation: HandleMicrosoftCallback in Go creates ba_user + ba_session records and returns session_token + session_expires to the BFF.
  • Validation: createAuthenticatedApiClient() calls POST /v1/auth/session/validate with the session token. The Go API returns verified user_id, email, org_id, and role from the database. The BFF then mints a short-lived JWT (5 min, HS256) for subsequent Go API calls.
  • Deletion: logout() calls POST /v1/auth/session/delete via the Go API, then clears all lacroo.* cookies.

OAuth Flow

Browser → BFF (server action) → Go API /microsoft/authorize → returns auth URL
Browser → Microsoft (user authenticates)
Microsoft → BFF /api/auth/sso/callback?code=...&state=...
BFF → Go API /microsoft/callback (code exchange + session creation)
Go API → returns session_token + user info
BFF → sets httpOnly cookies → redirects by role

BFF → Go API Authentication

All /v1/auth/* endpoints are protected by the InternalServiceAuth middleware, which requires the X-Internal-Service-Key header. This shared secret ensures only the BFF can call auth endpoints.

For /v1/orgs/* endpoints, the BFF issues a short-lived JWT (5 min, HS256) signed with BETTER_AUTH_SECRET containing the user’s sub, email, org_id, and role claims — all sourced from the Go API’s validated session, not from cookies. The Go API’s BearerAuth middleware validates this JWT. Each token names the part of the API it is for (its audience). The API refuses a token anywhere else.

Input Validation

All inputs are validated twice:

  1. BFF layer: Zod schemas in Server Actions validate shape, types, and formats before any API call.
  2. Go API layer: Struct validation + net/mail email parsing + length limits in handlers.

API Endpoints

MethodPathAuthPurpose
POST/v1/auth/lookup-by-emailInternalServiceAuthFind org by user email
POST/v1/auth/microsoft/authorizeInternalServiceAuthGenerate dynamic OAuth URL for an org
POST/v1/auth/microsoft/callbackInternalServiceAuthExchange auth code + create session
POST/v1/auth/session/validateInternalServiceAuthValidate session token, return user claims
POST/v1/auth/session/deleteInternalServiceAuthDelete session (logout)
GET/v1/orgs/{orgId}/dashboardBearerAuthGet org dashboard data (tenant-scoped)
DELETE/v1/orgs/{orgId}BearerAuthDelete org and all data (master_admin only)

State Token Security

The OAuth state parameter is HMAC-SHA256 signed: base64(orgID:timestamp).signature. This prevents CSRF attacks and org_id tampering. State tokens expire after 10 minutes.

Route Protection

Next.js middleware protects /org-dashboard/* routes. Unauthenticated requests are redirected to /login.

RouteBehavior
/org-dashboard/* (no session cookie)Redirect to /login?redirect=...
/org-dashboard (bare, with session)Redirect to /org-dashboard/[orgId] using lacroo.org_id cookie
/login (with session + org_id cookie)Redirect to /org-dashboard/[orgId]
/org-dashboard (no org_id cookie)Fallback page redirects to /login

Session Management

Cookies

CookiehttpOnlyPurpose
lacroo.session_tokenYesSession token (UUID), validated via Go API
lacroo.org_idYesUser’s org ID, used by middleware for redirects
lacroo.roleYesUser’s role, used for role-based routing

All cookies: sameSite: lax, secure in production, path: /, 7-day TTL.

Storage

PostgreSQL ba_session table, managed exclusively by the Go API. Sessions are created with a 7-day expiry.

Logout

The logout server action:

  1. Calls POST /v1/auth/session/delete via the Go API (with X-Internal-Service-Key)
  2. Clears all lacroo.* cookies
  3. Client redirects to /login

Delete Organization

DELETE /v1/orgs/{orgId} deletes an organisation. Only a master_admin of that organisation can call it (tenant check). The web client then logs the user out and redirects to /login.

What is deleted

  • The organisation’s own projects and everything in them.
  • Contracts it holds as head contractor, with their rates, dockets and plant.
  • Its roles, settings, integrations, alerts, files and invites, and the audit rows about its own records.
  • Every login of its people: sessions, passwords, reset codes and Microsoft links.
  • Each of its people with no records in another organisation’s project.

What stays

  • Records its people made in another organisation’s project: sign-ons, dockets, diaries, photos, comments, files, issues and audit rows. They stay in that project and still show the person’s name.
  • Audit rows its people wrote about another organisation’s record. A supplier’s submit on a head contractor’s docket is stored under the supplier, and it stays in that docket’s approval history with the person’s name.
  • A person with such records stays as a retired user. The name is kept. The email, phone, emergency contact and Microsoft identity are cleared, and they cannot sign in.
  • A head contractor’s contracts, rates, dockets and plant with the deleted supplier. They still name the supplier.
  • The organisation row itself, retired: status suspended, metadata.deleted_at set, name kept. It cannot be signed into, and inviting a supplier with the same name creates a new organisation.

After the delete

  • No picker offers the deleted supplier for new work: dockets, plant, materials, field users, crews, contracts, adding a supplier to a project, the docket import, and a project’s worker sign-up page.
  • Every live project assignment under it ends, the head contractor’s own workers engaged through it included. The rows stay as roster history.
  • A plant hired from it stops raising auto dockets. The plant shows “supplier deleted”.
  • A plant hired from it cannot start a new hire, and no new hire period can be added on its contract. Ending the current hire, and changing the rate of the open one, still work.
  • Duplicating a diary does not copy its dockets with the deleted supplier, or dockets priced from its contract. The rest of the diary is copied.
  • New work with it is refused, whatever sends it: a new docket, plant, contract, link, project link, assignment, payment method, crew or crew group, or moving a docket or plant onto it. That includes another company’s docket or plant priced from its contract or link project, and a billing rate from its contract on another company’s docket. The API answers 409 organization-deleted, “This company was deleted.”
  • Nobody can be added to it. A worker signing up for it, joining a project with it, or a head contractor inviting a contact into it, gets the same 409. No account is made. Setting a field user’s supplier to it is refused the same way.
  • A person who still names it as their supplier keeps their records but joins no new work under it: the Field Users tab lists them with the supplier marked “(Deleted)”, the new-docket roster blocks them, the Add Sign-On picker and crews leave them out, and a default assignment is refused. Set another supplier on the Field Users tab first.
  • Its own records stay editable. Its dockets and plant can move between its own contracts, and its own existing dockets can take new lines. Rate cards, payment-method lines, ERP pulls and rate imports on its existing records count as editing them.
  • Its history stays visible and editable. The project Suppliers tab, the supplier’s page, the supplier directory and the report and timesheet filters still list it, marked “Deleted”, with nothing on it that starts new work. GET .../suppliers and GET .../supplier-links leave it out unless called with include_deleted=true, and mark it supplier_deleted. Contracts and field users carry supplier_deleted too; the contracts list marks the supplier “(Deleted)”.
  • An existing plant or docket still shows its own supplier in its supplier field, marked “(Deleted)”. No other record is offered it.

Everything runs in one transaction (org.Service.DeleteOrg, queries in backend/db/queries/org_delete.sql). A second delete of the same organisation returns not found. The database refusal is the supplier_org_deleted trigger (migrations 20260925185443_refuse_new_work_with_deleted_supplier.sql and 20260925234855_refuse_deleted_supplier_everywhere_work_starts.sql). Its rule: a row may point at a deleted supplier only if it already belonged to that supplier.

Guards. delete_org_fk_graph_test.go fails when a new foreign key into organizations is added without saying how deleting an organisation handles it. Its integration half follows every CASCADE key from the rows the delete removes and fails on any nobody has reviewed. Whether a person is kept is read from every foreign key into users at run time (org_delete_user_references), so a new key counts without a code change. delete_org_audit_trail_test.go fails when the code writes an audit row of a kind nobody has classified: rows about a record another organisation can own (a docket, a diary, a contract) must stay while that record exists. Its integration half checks every kind already stored. deletedorg/guard_test.go fails when a new function adds a person to another organisation or sets their supplier (CreateFieldUser, CreatePendingSupplierUser, CreatePendingClientUser, UpdateUserSupplierFields) without calling deletedorg.Refuse first. deletedorg/offer_guard_test.go fails when a query that reads a supplier link neither recognises a deleted supplier (leaving it out, or labelling it) nor says why it cannot start new work, and when a pinned offer (the sign-on picker, the live-link fallback, the subcontractor header check) stops leaving it out. On the web, lib/deleted-supplier.guard.test.ts fails when a picker asks for deleted suppliers, a history screen stops listing them, or a picker that shows an existing plant or docket stops keeping that record’s own supplier (hooks/use-project-suppliers.ts).

Environment Variables

VariableWherePurpose
BETTER_AUTH_SECRETGo API + BFFShared secret for JWT signing + OAuth state HMAC
INTERNAL_SERVICE_SECRETGo API + BFFShared secret for BFF → Go API internal authentication
WEB_APP_URLGo APIWeb origin the redirect URIs shown to admins are built from
AES_ENCRYPTION_KEYGo API64-char hex string (32 bytes) for AES-256-GCM encryption of client secrets
ALLOWED_ORIGINSGo APICORS allowed origins
RATE_LIMIT_RPMGo APIOptional; defaults to 300. Requests per minute per signed-in user on each machine, and the per-IP limit on /v1/setup, /v1/auth and the live-updates connection. Not a secret; unset is safe.
PREAUTH_RATE_LIMIT_RPMGo APIOptional; defaults to 6000 (allowed 1000–1000000). Requests per minute per client IP on each machine, before sign-in is checked. Keyed on Fly-Client-IP. Not a secret; unset is safe — the default is sized from prod traffic (peak ≈525/min per IP).

No MICROSOFT_CLIENT_ID or MICROSOFT_CLIENT_SECRET env vars — credentials are stored per-org in the database.

Starting the Go API

The Go API reads env vars from os.Getenv, not from .env files. Source the .env before starting:

cd backend && set -a && source ../.env && set +a && go run ./api/cmd/server/main.go

Security

  • Internal service auth: All /v1/auth/* endpoints require X-Internal-Service-Key header (constant-time comparison)
  • Tenant isolation: Dashboard and delete endpoints verify org_id from JWT claims matches the requested org
  • Role-based access: Delete endpoint restricted to master_admin role
  • Rate limiting: a per-address limit before sign-in is checked, then a per-person limit after it. Public, setup and field sign-in routes have tighter per-address limits.
  • Request logs: record the route, never the full address or its query string.
  • Request body limit: 1MB global max body size
  • Security headers: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin
  • CORS: Configured via ALLOWED_ORIGINS environment variable
  • OAuth state: HMAC-signed with expiry to prevent CSRF
  • Client secrets: AES-256-GCM encrypted at rest, decrypted only during code exchange
  • JWT: HMAC-SHA256 with algorithm confusion protection
  • Session cookies: All three cookies are httpOnly, secure in production, sameSite: lax
  • Input validation: Double validation (Zod in BFF + Go struct validation)
  • Password rule: One rule for every new password, in backend/pkg/passwordpolicy (Go) and packages/password-policy (web and mobile). Go hashes a password only through passwordpolicy.Hash, which checks the rule first; guard tests on both sides fail the build if a path sets a password without it. Sign-in never checks the rule.
  • Log redaction: Sensitive fields (password, token, secret, etc.) are automatically redacted in structured logs
  • Production env validation: Go API crashes on startup if required secrets are missing or insecure

Key Files

FilePurpose
apps/web/src/app/api/auth/sso/callback/route.tsCustom OAuth callback — receives session from Go API, sets cookies, redirects
apps/web/src/lib/api.tscreateAuthenticatedApiClient() — validates session via Go API + JWT signing
apps/web/src/lib/auth.tsBetter Auth config with snake_case field mappings
apps/web/src/actions/auth.server.tsServer actions: lookupByEmail, getMicrosoftAuthURL, passwordLogin, logout
apps/web/src/lib/sso-error.tsThe three sign-in failure codes and their lines (mobile mirror: apps/mobile/src/lib/sso-error-copy.ts)
apps/web/src/actions/org.tsServer actions: getOrgDashboard, deleteOrg
apps/web/src/middleware.tsRoute protection + smart redirects using cookies
apps/web/src/app/org-dashboard/[orgId]/_components/dashboard-header.tsxHeader with logout + delete org buttons
backend/api/internal/auth/service.goGo service: OAuth, callback handling, session management, state signing; GenerateAuthorizeURL (the sign-in hint) and applyEntraIdentity (stores the sign-in name, pending → active)
backend/pkg/entragraph/signinname.goSignInName — the one rule for a person’s Microsoft sign-in name (members and guests)
backend/workers/activities/entra_identity_sync.goKeeps every SSO user’s sign-in name true after each directory refresh
backend/api/internal/auth/handler.goGo routes: /microsoft/*, /session/*, /lookup-by-email
backend/api/internal/org/handler.goGo routes: GET /{orgId}/dashboard, DELETE /{orgId} (tenant-scoped)
backend/api/internal/middleware/internal_auth.goInternalServiceAuth middleware (constant-time key comparison)
backend/api/internal/middleware/body_limit.goMaxBodySize middleware (1MB global limit)
backend/api/internal/middleware/redact_logger.goSensitive field redaction for slog
backend/api/internal/crypto/aes.goAES-256-GCM encrypt/decrypt for client secrets