Skip to main content

Authentication and authorization

App users​

All clients (mobile, web app, website accounts) share one Django User table.

  • Username is a hash. SHA256(EMAW_SALT + lowercase email). The plaintext email is stored on the user for sending mail but is never logged or put in a URL. EMAW.backends.EmailOrUsernameBackend lets people log in with either.
  • Sign-up requires email verification. POST /rest/signup/ creates an inactive user and emails a 6-digit code (Microsoft Graph). POST /rest/verify-code/ activates; resend-verification/ and change-unverified-email/ cover the edge cases. Codes are checked with VerificationCode.verify() (constant-time, attempt-limited). purge_pending_accounts removes accounts that never verified.
  • Signals on user creation build the default memory-verse collections.

JWT sessions​

SimpleJWT, HS256 signed with SECRET_KEY.

TokenLifetimeWhere clients keep it
Access1 hour (website uses 15-minute refresh cadence in memory)Mobile: expo-secure-store. Web app: localStorage.token. Website: memory only. CRM: memory only
Refresh90 days, blacklisted on logoutMobile: secure store. Web app: localStorage.refreshToken. Website: localStorage['emaw:refresh']. CRM: HttpOnly cookie via the same-origin Worker proxy

Endpoints: POST /rest/api/token/ (login), POST /rest/api/token/refresh/, POST /rest/api/token/logout/, GET /rest/validate/token/.

Refresh and logout read {refresh} from the body first, then the HttpOnly cookie. Browsers treat the backend's cookie as third-party from the website and web app origins, so those send the token in the body; only the CRM (same origin through its Worker) relies on the cookie.

Rotation. A client sending "rotate": true (mobile builds after 1.1.2 do) gets a new refresh token on each refresh; the old one stays valid for REFRESH_ROTATION_GRACE_SECONDS (60) unless the user logged out (EMAW/token_rotation.py). JWT_ROTATE_REFRESH_TOKENS=1 turns this on for everyone and stays off until mobile 1.1.2 and earlier are retired.

Google sign-in​

POST /rest/auth/google/ accepts a Google ID token from any client id listed in GOOGLE_SIGNIN_CLIENT_IDS (web, iOS, Android; External consent screen). It signs in or creates the account, auto-linking by hashed email; an unverified account is activated and its password cleared. POST/DELETE /rest/auth/google/link/ link and unlink from settings; GET /rest/auth/identities/ lists linked identities. Identities live in EMAW.SocialIdentity keyed by Google sub (EMAW/social_auth.py, EMAW/google_identity.py).

This is a different OAuth client from the Internal one used for Google Meet (GOOGLE_OAUTH_*). Setup: docs/GOOGLE_SIGN_IN_SETUP.md.

Website → web app hand-off​

The website is the only place accounts are created, and it must move a signed-in visitor into the web app (a different origin) without sharing tokens.

  1. Website, signed in: POST /rest/auth/app-handoff/ (Bearer) → a single-use code valid 60 seconds (EMAW.AppHandoffCode).
  2. Browser is sent to <app>/auth/handoff#code=…&next=/path. The fragment never reaches a server.
  3. Web app: POST /rest/auth/app-handoff/exchange/ with {code} from an origin in APP_ALLOWED_ORIGINS (+ APP_EXTRA_ORIGINS) → {access, refresh}.

Contract: docs/WEBSITE_ACCOUNTS.md. Website runbook: docs/accounts-sandbox.md.

Website account history​

/rest/account/me|orders|registrations|donations/ return the signed-in user's purchases, matched by email_hash on the commerce records (ContactEmailHashMixin), so guest purchases made with the same email appear after sign-up. Throttle scope account_history (60/min).

Staff: Cloudflare Access + capabilities​

Staff endpoints (CRM, campaigns, events staff, store staff, resources staff) require two things:

  1. A valid app JWT whose user is staff.
  2. When CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD are set, a valid Cloudflare Access JWT (EMAW/cf_access.py), forwarded by the Admin CRM's Worker as X-Cf-Access-Jwt. Unset locally, so a local CRM build can skip Access.

Authorization is capability based (crm/capabilities.py, resolver crm/access.py, permissions crm/permissions.py, roles crm/roles.py):

CapabilityLets you
contacts.view, contacts.edit, contacts.bulkSee, edit, bulk-assign contacts
notes, activityContact notes and timeline
mapThe contact map
groups.manage, geography.manageGroups/tags/roles; regions and areas
templates, campaignsEmail templates; campaigns and audiences
eventsEvents staff API
storeOrders, inventory, analytics
resourcesWebsite resources
access.manageGrant access to others

Each user also has a scope: all, region, areas or self, which scope_contacts() applies to every queryset exposing contact data. Membership of the Django group CRM Staff (IsCrmStaff) grants everything with scope all. GET /rest/crm/me/ returns the caller's capabilities and scope; the Admin CRM builds its navigation from it.

New staff views must use CrmAccessViewMixin / HasCrmAccess("<capability>"); these call require_access, so Cloudflare Access enforcement comes for free. Do not hand-roll is_staff checks.

Full contract for the front end: docs/CRM_FRONTEND_GUIDE.md §2.

Throttling​

Rates live in REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"], scopes in EMAW/throttles.py. Auth endpoints use AttemptThrottle (5/min, Redis-backed, fails open). AI endpoints use AIThrottle. Clients surface 429 as a toast.

Admin​

Django admin at /admin/ is for superusers. It is behind the tunnel but not behind Cloudflare Access; keep superuser accounts few and strong.