Ir al contenido

Auth Stack

Decision rationale: [[adr-008-authentication]]. User entity and roles: [[adr-014-user-entity]]. API endpoint contracts: [[API]].

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.

EnvironmentAuth classAlgorithmSource
Dev (MVP)CookieJWTAuthenticationHS256 (SimpleJWT)apps/users/authentication.py
Post-MVP (prod)CognitoJWTAuthenticationRS256 (JWKS endpoint)not yet implemented

Configured in SIMPLE_JWT dict (config/settings/base.py). The Secure flag and SameSite value depend on the LOCAL env variable.

CookieHttpOnlyDev SameSiteProd SameSiteSecure (dev)Secure (prod)Max-Age
access_tokenYesLaxNoneNoYes1 h (3600 s)
refresh_tokenYesLaxNoneNoYes7 d (604800 s)
csrftokenNoLaxNonesession

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:

  1. Reads access_token from request.COOKIES.
  2. Validates the JWT signature (HS256, SECRET_KEY).
  3. 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.

Extends AbstractBaseUser. OIDC-aligned from day one — Cognito migration requires no schema change.

FieldTypeCognito claimNotes
idUUIDField (PK)Internal PK; not used as external reference
subUUIDField(unique=True)subCanonical identifier; auto-generated locally
emailEmailField(unique=True)emailUSERNAME_FIELD
given_nameCharField(max_length=255)given_name
family_nameCharField(max_length=255)family_name
roleCharField(choices)cognito:groupsADMIN / MANAGER / SUPERVISOR / VIEWER / EMPLOYEE; default VIEWER
email_verifiedBooleanFieldemail_verifiedDefault False
is_activeBooleanFieldSoft-delete; False = account deactivated
is_staffBooleanFieldDjango admin access
date_joinedDateTimeField(auto_now_add)

UserSerializer exposes: sub, email, given_name, family_name, role, email_verified, is_staff.

All under /api/v1/auth/ (see apps/users/urls.py).

MethodPathAuth requiredThrottle
POST/api/v1/auth/login/No (AllowAny)login: 5/h
POST/api/v1/auth/logout/Yeslogout: 20/h
POST/api/v1/auth/token/refresh/No (reads cookie)token_refresh: 20/h
GET/api/v1/auth/me/Yesuser: 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).

FileRole
core/services/auth.service.ts_currentUser signal; login(), logout(), checkAuth(), loadProfile(), refreshToken(), deleteAccount()
core/interceptors/auth.interceptor.tswithCredentials: 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.tsCatches 401 (excluding auth URLs); single shared refresh attempt via Promise queue; on refresh failure calls auth.logout()
core/guards/auth.guard.tsAlways calls checkAuth() (server round-trip via /me/) — no fast-path signal bypass
core/guards/no-auth.guard.tsSignal-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:

  1. If _currentUser() is already set, return true immediately.
  2. Otherwise call loadProfile() (GET /me/).
  3. The error interceptor handles any 401 → refresh → retry transparently before loadProfile() rejects.
  4. On reject: return false.

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.