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.EmailOrUsernameBackendlets 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/andchange-unverified-email/cover the edge cases. Codes are checked withVerificationCode.verify()(constant-time, attempt-limited).purge_pending_accountsremoves accounts that never verified. - Signals on user creation build the default memory-verse collections.
JWT sessions
SimpleJWT, HS256 signed with SECRET_KEY.
| Token | Lifetime | Where clients keep it |
|---|---|---|
| Access | 1 hour (website uses 15-minute refresh cadence in memory) | Mobile: expo-secure-store. Web app: localStorage.token. Website: memory only. CRM: memory only |
| Refresh | 90 days, blacklisted on logout | Mobile: 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.
- Website, signed in:
POST /rest/auth/app-handoff/(Bearer) → a single-use code valid 60 seconds (EMAW.AppHandoffCode). - Browser is sent to
<app>/auth/handoff#code=…&next=/path. The fragment never reaches a server. - Web app:
POST /rest/auth/app-handoff/exchange/with{code}from an origin inAPP_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:
- A valid app JWT whose user is staff.
- When
CF_ACCESS_TEAM_DOMAINandCF_ACCESS_AUDare set, a valid Cloudflare Access JWT (EMAW/cf_access.py), forwarded by the Admin CRM's Worker asX-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):
| Capability | Lets you |
|---|---|
contacts.view, contacts.edit, contacts.bulk | See, edit, bulk-assign contacts |
notes, activity | Contact notes and timeline |
map | The contact map |
groups.manage, geography.manage | Groups/tags/roles; regions and areas |
templates, campaigns | Email templates; campaigns and audiences |
events | Events staff API |
store | Orders, inventory, analytics |
resources | Website resources |
access.manage | Grant 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.