Auth Stack
Auth Stack
Sección titulada «Auth Stack»Decision rationale: [[adr-008-authentication]]. User entity and roles: [[adr-014-user-entity]]. API endpoint contracts: [[API]].
Mechanism
Sección titulada «Mechanism»httpOnly cookies — the frontend never sees or stores JWT tokens. The only component that changes between dev and prod is DEFAULT_AUTHENTICATION_CLASSES in settings.
| Environment | Auth class | Algorithm | Source |
|---|---|---|---|
| Dev (MVP) | CookieJWTAuthentication | HS256 (SimpleJWT) | apps/users/authentication.py |
| Post-MVP (prod) | CognitoJWTAuthentication | RS256 (JWKS endpoint) | not yet implemented |
Cookies
Sección titulada «Cookies»Configured in SIMPLE_JWT dict (config/settings/base.py). The Secure flag and SameSite value depend on the LOCAL env variable.
| Cookie | HttpOnly | Dev SameSite | Prod SameSite | Secure (dev) | Secure (prod) | Max-Age |
|---|---|---|---|---|---|---|
access_token | Yes | Lax | None | No | Yes | 1 h (3600 s) |
refresh_token | Yes | Lax | None | No | Yes | 7 d (604800 s) |
csrftoken | No | Lax | None | — | — | session |
refresh_token path is restricted to /api/v1/auth/token/refresh/ (narrower path than access_token).
Token rotation: ROTATE_REFRESH_TOKENS = True, BLACKLIST_AFTER_ROTATION = True. Logout blacklists the refresh token.
DRF Authentication Class (apps/users/authentication.py)
Sección titulada «DRF Authentication Class (apps/users/authentication.py)»CookieJWTAuthentication extends SimpleJWT’s JWTAuthentication:
- Reads
access_tokenfromrequest.COOKIES. - Validates the JWT signature (HS256,
SECRET_KEY). - For mutations (POST/PUT/PATCH/DELETE), enforces CSRF via
CsrfViewMiddleware.
Swap path: change DEFAULT_AUTHENTICATION_CLASSES in config/settings/production.py. The frontend, cookies, User model, and all endpoints stay identical.
User Model (apps/users/models.py)
Sección titulada «User Model (apps/users/models.py)»Extends AbstractBaseUser. OIDC-aligned from day one — Cognito migration requires no schema change.
| Field | Type | Cognito claim | Notes |
|---|---|---|---|
id | UUIDField (PK) | — | Internal PK; not used as external reference |
sub | UUIDField(unique=True) | sub | Canonical identifier; auto-generated locally |
email | EmailField(unique=True) | email | USERNAME_FIELD |
given_name | CharField(max_length=255) | given_name | |
family_name | CharField(max_length=255) | family_name | |
role | CharField(choices) | cognito:groups | ADMIN / MANAGER / SUPERVISOR / VIEWER / EMPLOYEE; default VIEWER |
email_verified | BooleanField | email_verified | Default False |
is_active | BooleanField | — | Soft-delete; False = account deactivated |
is_staff | BooleanField | — | Django admin access |
date_joined | DateTimeField(auto_now_add) | — |
UserSerializer exposes: sub, email, given_name, family_name, role, email_verified, is_staff.
Endpoints
Sección titulada «Endpoints»All under /api/v1/auth/ (see apps/users/urls.py).
| Method | Path | Auth required | Throttle |
|---|---|---|---|
| POST | /api/v1/auth/login/ | No (AllowAny) | login: 5/h |
| POST | /api/v1/auth/logout/ | Yes | logout: 20/h |
| POST | /api/v1/auth/token/refresh/ | No (reads cookie) | token_refresh: 20/h |
| GET | /api/v1/auth/me/ | Yes | user: 1000/h |
| DELETE | /api/v1/auth/account/ | Yes | — |
| GET | /api/v1/auth/export/ | Yes | — |
Login (LoginView): validates email + password via LoginSerializer (uses django.contrib.auth.authenticate), issues access_token + refresh_token cookies, returns { user: UserProfile }. Also triggers ensure_csrf_cookie so the browser receives csrftoken on the login POST response.
Logout (LogoutView): blacklists the refresh token, clears both auth cookies, returns 204.
Token refresh (TokenRefreshCookieView): reads refresh_token cookie, rotates tokens (issues new pair), returns 200. On invalid token: clears cookies and returns 401.
Me (MeView): returns UserProfile JSON with Cache-Control: no-store.
Account delete (AccountDeleteView): sets is_active = False, blacklists refresh token, clears cookies (GDPR Art. 17).
Account export (AccountExportView): returns JSON of email, given_name, family_name, role, date_joined, last_login with Content-Disposition: attachment (GDPR Art. 20).
Frontend Wiring
Sección titulada «Frontend Wiring»| File | Role |
|---|---|
core/services/auth.service.ts | _currentUser signal; login(), logout(), checkAuth(), loadProfile(), refreshToken(), deleteAccount() |
core/interceptors/auth.interceptor.ts | withCredentials: true for all non-auth requests; adds X-CSRFToken on mutations; auth endpoints (/auth/login/, /auth/logout/) explicitly exclude withCredentials to prevent stale session conflicts |
core/interceptors/error.interceptor.ts | Catches 401 (excluding auth URLs); single shared refresh attempt via Promise queue; on refresh failure calls auth.logout() |
core/guards/auth.guard.ts | Always calls checkAuth() (server round-trip via /me/) — no fast-path signal bypass |
core/guards/no-auth.guard.ts | Signal-only check (isAuthenticated()); safe because APP_INITIALIZER has already validated state before routing |
Auth state: _currentUser signal (UserProfile | null). isAuthenticated is a computed() from it.
checkAuth() flow:
- If
_currentUser()is already set, returntrueimmediately. - Otherwise call
loadProfile()(GET/me/). - The error interceptor handles any 401 → refresh → retry transparently before
loadProfile()rejects. - On reject: return
false.
Post-MVP: Cognito + Social Login
Sección titulada «Post-MVP: Cognito + Social Login»Google and other social providers federate through Cognito Managed Login. Django never receives Google tokens. The sub is stable regardless of provider. Implementation requires writing CognitoJWTAuthentication and setting three env vars (COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID, COGNITO_REGION). No frontend changes needed.