Skip to main content

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

  1. An unauthenticated user is redirected to the AdjustSquare login page (adjustsquare_login_url) with a return_to pointing back at the ContentsVision callback. The frontend can fetch this URL from the public GET /api/auth/config.
  2. After login, AdjustSquare redirects back to /auth/callback?token=<JWT>.
  3. The callback page (app/auth/callback/page.tsx) calls GET /api/auth/me with the token as a bearer header.
  4. The backend validates the JWT and provisions the team and user.
  5. The frontend stores the token and user in localStorage and navigates to the dashboard.

How the token reaches the backend

The get_current_user dependency (core/auth_deps.py) accepts the token two ways:

  1. Authorization: Bearer <jwt> header (the normal case; the API client always sends it).
  2. The bt_shared_data cookie (adjustsquare_cookie_name), for deployments hosted on the same .adjustsquare.com subdomain 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:

ClaimMaps toNotes
user_idUser.external_idRequired. 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_nameUser.name
emailUser.emailUsed as a fallback identity key.
current_team_idTeam.external_idRequired. Still an integer (the Adjust Square team id; from CE's Team.legacy_id).
current_team_nameTeam.name
current_team_tokenTeam.team_tokenThe Adjust Square API token for this team.
current_team_ce_intake_keyTeam.contents_estimation_keyOptional. 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_id first, then by email (a fallback for accounts created before external_id was tracked); if absent, created on the team.
  • If a known user's team_id no 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:

EndpointLoaded byWhy 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 CDNthe CDN fetches from a public origin
GET /jobs/{id}/stream (SSE)EventSourceEventSource 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 on users.external_id, not the local users.id UUID. 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 when str(user.external_id) is in that set. Identity comes from the verified JWT, so a client cannot spoof it.
  • require_admin is a FastAPI dependency that depends on get_current_user and raises 403 "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/me surfaces is_admin: bool so 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 API 403, 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 sessionStorage flag), it probes /api/auth/me to pick up a cookie-based session.
  • Logout fires a keepalive POST 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.tsx wraps window.fetch once in the browser and watches every response. The decision of whether a response means "session expired" is the pure shouldLogoutOnResponse(url, status) in lib/sessionGuard.mjs (unit-tested): true only for a 401 from /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.ts handleSessionExpired() fires once: it records the event (a Sentry captureMessage at info level plus a PostHog session_expired event, 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_SECRET is 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_token from the JWT is what authenticates claim submission to Adjust Square, tying login and submission together. See the Adjust Square integration.