Gravity Gravity Docs

Gravity Authentication

Two independent auth systems exist side by side — don't confuse them:

Admin auth Customer portal auth
Cookie token portal_session / portal_refresh
Protects /admin/* /portal/*
FastAPI dependency require_admin (app/deps.py) require_portal (app/deps.py)
Primary backend Adhara SDK Adhara's portal API (app/services/portal_auth.py)

app/auth.py holds only the shared, framework-free facts both dependencies need — cookie name constants and the dev-bypass flag checks (dev_admin_enabled(), dev_portal_enabled(), theme_tweaks_enabled()). The actual route-guarding logic lives in app/deps.py, as FastAPI Depends() callables, not decorators — a route declares admin_ctx: dict = Depends(require_admin) as a parameter, and FastAPI calls require_admin before the route body runs.

This doc covers admin auth — the one most people mean by "logging into Gravity."

Admin auth: Adhara SDK is the primary path

Real deployments require the Adhara SDK. app/services/adhara.py::validate_token() calls into it; require_admin (app/deps.py) runs before every /admin/* route's controller body and checks the token cookie against it:

Browser GET /admin/blog/manage  (Cookie: token=...)
  → app/deps.py :: require_admin(request)
      → app/services/adhara.py :: validate_token(token)
          - SDK not installed → always returns False
          - SDK installed → AdharaClient.auth.validate_token(token)
      → valid: returns {current_user, admin, sdk_available} — becomes the route's admin_ctx argument
      → invalid/missing: raises RedirectRequired('/login')
          (a single exception handler in app/main.py turns this into a 302)

Without adhara-sdk installed, validate_token() always returns False and every /admin/* route redirects to /login, which shows an SDK-required banner instead of a working login form (app/controllers/main.py::login() passes sdk_available=False to the template). app.services.adhara.get_user_info(token) resolves the current username/admin flag once a request is authenticated.

Client-side, public/js/auth.js (vanilla JS, no jQuery) POSTs to /api and stores the returned JWT in localStorage and as the token cookie.

Local fallback (GRAVITY_DEV_ADMIN, and UserManagement.py)

Two separate things make local testing possible without a real Adhara account:

  1. GRAVITY_DEV_ADMIN=1 — the blunt bypass. require_admin checks app.auth.dev_admin_enabled() first and skips the auth check entirely when it's set. This is what make editor turns on. Never set this in a deployed environment — none of the four deploy scripts (scripts/deploy_{vercel,gcp,cloudflare,azure}.sh) set it, so a normal deploy stays locked.
  2. gravityApp/app/UserManagement.py — a local, file-backed user store (JsonFile.py-based, JWT via JsonWebToken.py) that still exists and still backs the admin "manage users" UI and the user_add_new/ user_edit/user_delete JSON commands (see the JSON Command API section of architecture.md). It is a real, working local auth store — not a stub — but it is not what gates /admin/* in a real deployment; Adhara SDK validation is. See architecture.md §3 for the full request-dispatch path.

These two are independent: GRAVITY_DEV_ADMIN=1 bypasses the login check outright, while UserManagement.py is a genuine (if local-only) credential store that the JSON command API still uses for user management regardless of which login path is active.

Portal auth (customer-facing, separate system)

/portal/* uses an entirely different session — require_portal (app/deps.py), portal_session/portal_refresh cookies, backed by Adhara's workspace-scoped customer portal API (app/services/portal_auth.py), no SDK required. It supports silent token refresh before falling back to a login redirect — an expired portal_session with a still-valid portal_refresh cookie gets quietly re-minted, with the new cookies set directly on the dependency's injected Response parameter, so the route body never needs to know a refresh happened — and its own local dev bypass, GRAVITY_DEV_PORTAL=1 (see app.auth.dev_portal_enabled() — always a synthetic local session, even with Adhara configured, since there's no way to mint a real Adhara-signed token without an actual login). Never set this in production either.