Authentication & SSO
ContentsVision does not manage passwords. It trusts AdjustSquare as the identity provider: AdjustSquare issues a signed JWT, ContentsVision validates it, and on first sight it auto-creates the matching team and user. Everything else in the app is scoped to that team.
The login flow
- An unauthenticated user is redirected to the AdjustSquare login page (
adjustsquare_login_url) with areturn_topointing back at the ContentsVision callback. The frontend can fetch this URL from the publicGET /api/auth/config. - After login, AdjustSquare redirects back to
/auth/callback?token=<JWT>. - The callback page (
app/auth/callback/page.tsx) callsGET /api/auth/mewith the token as a bearer header. - The backend validates the JWT and provisions the team and user.
- The frontend stores the token and user in
localStorageand navigates to the dashboard.
How the token reaches the backend
The get_current_user dependency (core/auth_deps.py) accepts the token two ways:
Authorization: Bearer <jwt>header (the normal case; the API client always sends it).- The
bt_shared_datacookie (adjustsquare_cookie_name), for deployments hosted on the same.adjustsquare.comsubdomain where AdjustSquare already set the cookie.
If neither is present, the request gets 401.
Validating the JWT
core/security.py decodes the token with python-jose using HS256 and the shared secret ADJUSTSQUARE_JWT_SECRET. Audience and subject verification are disabled (AdjustSquare sets sub as an integer, and the user id is read from the payload directly). Missing required claims raise a clear error, which the route turns into 401.
The JWT claims
decode_token returns an AdjustSquareClaims with these fields:
| Claim | Maps to | Notes |
|---|---|---|
user_id | User.external_id | Required. A string (Contents Estimation, now the login source of truth, issues its user UUID; a legacy Adjust Square integer is normalized to its string form). Forwarded to Adjust Square as the integer adjuster_id only when it is a legacy integer id; a UUID user omits it (AS uses the team default adjuster). |
user_name | User.name | |
email | User.email | Used as a fallback identity key. |
current_team_id | Team.external_id | Required. Still an integer (the Adjust Square team id; from CE's Team.legacy_id). |
current_team_name | Team.name | |
current_team_token | Team.team_token | The Adjust Square API token for this team. |
current_team_ce_intake_key | Team.contents_estimation_key | Optional. The team's Contents Estimation intake key, so the Contents Estimation push authenticates per-team via SSO. See the Contents Estimation integration. |
The token is minted by Contents Estimation (GET /sso/contentsvision/authorize), signed with a secret shared with ContentsVision (CE's CONTENTSVISION_SSO_SECRET == CV's ADJUSTSQUARE_JWT_SECRET). Point ADJUSTSQUARE_LOGIN_URL at CE to route logins through it; CV's /auth/callback exchanges the ?token= unchanged.
Auto-provisioning teams and users
On every authenticated request, the backend ensures the team and user exist (auth_deps.py):
- Team is looked up by
team_token; if absent, created. - User is looked up by
external_idfirst, then byemail(a fallback for accounts created beforeexternal_idwas tracked); if absent, created on the team. - If a known user's
team_idno longer matches (they moved teams in AdjustSquare), it is updated. This keeps team membership in sync on every login.
The provisioned User is what get_current_user returns; get_actor derives a lightweight Actor(user_id, user_name, team_id) from it for activity logging.
As a side effect, get_current_user also stamps users.last_seen_at (at most once a minute per user, best-effort). This powers the admin dashboard's "Active Users (live)" stat: a user counts as active if they made an authenticated request in the last 5 minutes.
Team scoping
Because every claim carries a team_id, the API enforces isolation: a user can never see or touch another team's data. team_id is the application's security boundary (it is not a database foreign key, so it is enforced in code, not by the database).
Admin exception: allowlisted admins (Actor.is_admin, derived server-side from the same allowlist as require_admin) bypass team ownership checks on id-addressed resources (team_allowed in core/ownership.py), so an operator can open any team's claim, rooms, jobs, and items directly, e.g. following a link from the admin dashboard, Logs, or Media pages, without impersonating. List endpoints (dashboard claims, recent jobs) stay scoped to the admin's own team; the system-wide lists live under /api/admin/....
The ownership chain
The tenant boundary is Claim.team_id. Everything else hangs off a claim, so ownership is resolved by walking up to the claim:
InventoryItem -> ProcessingJob -> Room -> Claim.team_id
ProcessingJob -> Room -> Claim.team_id
Room -> Claim.team_id
app/core/ownership.py provides the shared helpers load_claim_for_team, load_room_for_team, load_job_for_team, and load_item_for_team. Each loads a resource by id and verifies it belongs to the caller's team, raising 404 (not 403) for both a missing id and a cross-team id, so a response never reveals that another team's resource exists. Every id-taking endpoint (in jobs.py, inventory.py, rooms.py, upload.py, uploads.py, activity.py) funnels through one of these. The activity log is scoped by a team_id column on activity_log, set from the actor when each row is written.
Capability URLs (media bytes)
A few endpoints serve raw bytes to elements that cannot send an Authorization header, so they are not team-checked and instead rely on the unguessable resource id (a UUID) as a capability:
| Endpoint | Loaded by | Why it can't carry auth |
|---|---|---|
GET /jobs/{id}/video, /audio | <video> / <audio src> | media elements send no headers |
GET /jobs/{id}/frames/{name} | <img src> | image elements send no headers |
GET /photos/{job_id}/{name} | <img src> + the imgproxy CDN | the CDN fetches from a public origin |
GET /jobs/{id}/stream (SSE) | EventSource | EventSource sends no headers |
The id is only discoverable through the team-scoped list endpoints, so cross-team access requires already knowing the UUID. Filenames on the byte-serving routes are traversal-checked (safe_path_segment) so they can't escape the job's directory. Inventory exports (Excel/CSV), by contrast, ARE team-scoped: the frontend downloads them with an authenticated fetch + blob (downloadExport) rather than a plain <a download> link.
Admin access (internal dashboard)
The internal admin dashboard is the one place that deliberately ignores team scoping: it aggregates across every team. Access is restricted to an explicit allowlist of operators.
- The allowlist lives in config as
ADMIN_EXTERNAL_USER_IDS(a set of strings, defaults to{"7", "10"}, that is Dan and Nathan). These are identity-provider user IDs, stored onusers.external_id, not the localusers.idUUID. Values are strings so they match both a legacy Adjust Square integer id (as its string form) and a Contents Estimation user UUID; set them to the operators' current ids as login moves to Contents Estimation. is_admin(user)(core/auth_deps.py) returns true whenstr(user.external_id)is in that set. Identity comes from the verified JWT, so a client cannot spoof it.require_adminis a FastAPI dependency that depends onget_current_userand raises403 "This page is restricted."for anyone not on the allowlist. Every/api/admin/...endpoint is guarded by it. This is the real gate; hiding the nav link is only cosmetic.GET /api/auth/mesurfacesis_admin: boolso the frontend can show the admin-only nav entries (Logs and Admin) only to admins, each marked with a small "Admin" badge in the sidebar. Those pages also guard themselves (page guard plus the API403, belt and suspenders).
To change who has access, edit ADMIN_EXTERNAL_USER_IDS (see configuration); no code change is needed.
Impersonation
Admins can view the app as any other user to debug their exact experience. From the admin dashboard's Impersonate a User picker, POST /api/admin/impersonate/{user_id} mints a short-lived AdjustSquare-format token for the target (signed with the same shared secret the app validates against, so it authenticates as that user) and returns it with the target's profile. lib/auth.tsx swaps the active token to the minted one while stashing the admin's own token, so every request now runs as the target; an app-wide amber banner shows who is being viewed and offers Stop impersonating, which restores the admin's session. The minted token carries an impersonated_by claim and the action is written to the activity log, so impersonation is always auditable. A user with no external_id cannot be impersonated (the token needs a numeric user id).
The frontend session
lib/auth.tsx keeps the session resilient:
- On load it restores the token and user from
localStorage(cv_auth_token,cv_auth_user). - If there is no stored session and the user did not explicitly log out this tab (a
sessionStorageflag), it probes/api/auth/meto pick up a cookie-based session. - Logout fires a
keepalivePOST to/api/auth/logout(so it survives the immediate client-side navigation), sets the logged-out flag, clears local storage, and resets state.
Session expiry (auto sign-out)
AdjustSquare JWTs expire (about 12 hours). Once expired, every authenticated API call returns 401 "Invalid or expired token". Rather than surface that raw error on the page, a global guard signs the user out cleanly:
components/SessionGuard.tsxwrapswindow.fetchonce in the browser and watches every response. The decision of whether a response means "session expired" is the pureshouldLogoutOnResponse(url, status)inlib/sessionGuard.mjs(unit-tested): true only for a401from/api/*, excluding/api/auth/*(so signing out or probing the session can't loop) and excluding third-party hosts like Sentry/PostHog ingest.- On a match,
lib/session.tshandleSessionExpired()fires once: it records the event (a SentrycaptureMessageat info level plus a PostHogsession_expiredevent, so the case is observable, which it otherwise is not, since a 401 is a handled response that neither error hook captures), clears the local session, and hard-redirects to/login?reason=expired. - The login page reads that flag and shows a "You were signed out, your session expired" notice, then offers the normal Adjust Square sign-in.
This turns an opaque red "Invalid or expired token" into a graceful re-authentication, and makes session-expiry frequency visible in Sentry and PostHog.
Operational notes
- A login event is written to the activity log only on the bearer-token exchange (the callback), not on every cookie session check, to avoid noise.
- If
ADJUSTSQUARE_JWT_SECRETis unset, the backend logs a loud error and every token fails validation. This is the first thing to check if "everyone is suddenly logged out." See troubleshooting. - The same
team_tokenfrom the JWT is what authenticates claim submission to Adjust Square, tying login and submission together. See the Adjust Square integration.