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:
- 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.
- 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.
- Microsoft opens with their sign-in name already filled in, so they enter only their password.
- 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:
- 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 - BFF calls
POST /v1/auth/microsoft/authorizewith theorg_idand the typed address aslogin_identifier - 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 - User is redirected to Microsoft for authentication
- Microsoft redirects to
/api/auth/sso/callbackwith an auth code - BFF sends the code to
POST /v1/auth/microsoft/callback - 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
pendinguser 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 apendinguser toactive, and creates a session inba_user+ba_session - BFF receives
session_tokenandsession_expiresfrom the Go API response - BFF sets httpOnly cookies:
lacroo.session_token,lacroo.org_id,lacroo.role,lacroo.surfaces - 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/callbackFor production:
https://app.yourdomain.com/api/auth/sso/callback
https://app.yourdomain.com/api/mobile/auth/sso/callbackThe 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.
| Component | Database Access |
|---|---|
| Go API | Full access via lacroo user |
| Next.js BFF | No 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:
HandleMicrosoftCallbackin Go createsba_user+ba_sessionrecords and returnssession_token+session_expiresto the BFF. - Validation:
createAuthenticatedApiClient()callsPOST /v1/auth/session/validatewith the session token. The Go API returns verifieduser_id,email,org_id, androlefrom the database. The BFF then mints a short-lived JWT (5 min, HS256) for subsequent Go API calls. - Deletion:
logout()callsPOST /v1/auth/session/deletevia the Go API, then clears alllacroo.*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 roleBFF → 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:
- BFF layer: Zod schemas in Server Actions validate shape, types, and formats before any API call.
- Go API layer: Struct validation +
net/mailemail parsing + length limits in handlers.
API Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /v1/auth/lookup-by-email | InternalServiceAuth | Find org by user email |
POST | /v1/auth/microsoft/authorize | InternalServiceAuth | Generate dynamic OAuth URL for an org |
POST | /v1/auth/microsoft/callback | InternalServiceAuth | Exchange auth code + create session |
POST | /v1/auth/session/validate | InternalServiceAuth | Validate session token, return user claims |
POST | /v1/auth/session/delete | InternalServiceAuth | Delete session (logout) |
GET | /v1/orgs/{orgId}/dashboard | BearerAuth | Get org dashboard data (tenant-scoped) |
DELETE | /v1/orgs/{orgId} | BearerAuth | Delete 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.
| Route | Behavior |
|---|---|
/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
| Cookie | httpOnly | Purpose |
|---|---|---|
lacroo.session_token | Yes | Session token (UUID), validated via Go API |
lacroo.org_id | Yes | User’s org ID, used by middleware for redirects |
lacroo.role | Yes | User’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:
- Calls
POST /v1/auth/session/deletevia the Go API (withX-Internal-Service-Key) - Clears all
lacroo.*cookies - 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_atset, 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 .../suppliersandGET .../supplier-linksleave it out unless called withinclude_deleted=true, and mark itsupplier_deleted. Contracts and field users carrysupplier_deletedtoo; 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
| Variable | Where | Purpose |
|---|---|---|
BETTER_AUTH_SECRET | Go API + BFF | Shared secret for JWT signing + OAuth state HMAC |
INTERNAL_SERVICE_SECRET | Go API + BFF | Shared secret for BFF → Go API internal authentication |
WEB_APP_URL | Go API | Web origin the redirect URIs shown to admins are built from |
AES_ENCRYPTION_KEY | Go API | 64-char hex string (32 bytes) for AES-256-GCM encryption of client secrets |
ALLOWED_ORIGINS | Go API | CORS allowed origins |
RATE_LIMIT_RPM | Go API | Optional; 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_RPM | Go API | Optional; 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.goSecurity
- Internal service auth: All
/v1/auth/*endpoints requireX-Internal-Service-Keyheader (constant-time comparison) - Tenant isolation: Dashboard and delete endpoints verify
org_idfrom JWT claims matches the requested org - Role-based access: Delete endpoint restricted to
master_adminrole - 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_ORIGINSenvironment 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) andpackages/password-policy(web and mobile). Go hashes a password only throughpasswordpolicy.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
| File | Purpose |
|---|---|
apps/web/src/app/api/auth/sso/callback/route.ts | Custom OAuth callback — receives session from Go API, sets cookies, redirects |
apps/web/src/lib/api.ts | createAuthenticatedApiClient() — validates session via Go API + JWT signing |
apps/web/src/lib/auth.ts | Better Auth config with snake_case field mappings |
apps/web/src/actions/auth.server.ts | Server actions: lookupByEmail, getMicrosoftAuthURL, passwordLogin, logout |
apps/web/src/lib/sso-error.ts | The three sign-in failure codes and their lines (mobile mirror: apps/mobile/src/lib/sso-error-copy.ts) |
apps/web/src/actions/org.ts | Server actions: getOrgDashboard, deleteOrg |
apps/web/src/middleware.ts | Route protection + smart redirects using cookies |
apps/web/src/app/org-dashboard/[orgId]/_components/dashboard-header.tsx | Header with logout + delete org buttons |
backend/api/internal/auth/service.go | Go 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.go | SignInName — the one rule for a person’s Microsoft sign-in name (members and guests) |
backend/workers/activities/entra_identity_sync.go | Keeps every SSO user’s sign-in name true after each directory refresh |
backend/api/internal/auth/handler.go | Go routes: /microsoft/*, /session/*, /lookup-by-email |
backend/api/internal/org/handler.go | Go routes: GET /{orgId}/dashboard, DELETE /{orgId} (tenant-scoped) |
backend/api/internal/middleware/internal_auth.go | InternalServiceAuth middleware (constant-time key comparison) |
backend/api/internal/middleware/body_limit.go | MaxBodySize middleware (1MB global limit) |
backend/api/internal/middleware/redact_logger.go | Sensitive field redaction for slog |
backend/api/internal/crypto/aes.go | AES-256-GCM encrypt/decrypt for client secrets |