From 2e1bcdb7dbba229a422f65dfcb779c254058888b Mon Sep 17 00:00:00 2001 From: Ndevu12 Date: Wed, 25 Mar 2026 19:05:01 +0000 Subject: [PATCH 1/4] feat(auth): implement cookie-based JWT authentication and logout functionality - Introduced `CookieJWTAuthentication` to support both header-based and cookie-based JWT authentication, prioritizing headers for explicit auth. - Updated login and refresh views to set JWT tokens in HttpOnly cookies, enhancing security for browser clients. - Added a logout endpoint to clear authentication cookies. - Refactored frontend auth logic to remove reliance on access tokens stored in state, transitioning to cookie-based management. - Updated environment settings and documentation to reflect new JWT cookie configurations. --- .env.example | 10 +- docs/ARCHITECTURE.md | 2 +- docs/COOKIE_MIGRATION_TASKS.md | 510 ++++++++++++++++++ docs/ENVIRONMENT.md | 18 +- .../src/components/layout/tenant-switcher.tsx | 4 +- frontend/src/features/auth/api/auth-api.ts | 4 + .../features/auth/components/auth-guard.tsx | 37 +- .../features/auth/context/auth-context.tsx | 9 +- frontend/src/features/auth/hooks/use-auth.ts | 28 +- .../auth/pages/no-organization-page.tsx | 12 +- .../src/features/auth/types/auth.types.ts | 14 +- .../reports/helpers/export-helpers.ts | 5 +- .../src/features/settings/api/billing-api.ts | 6 +- .../settings/pages/accept-invitation-page.tsx | 3 +- frontend/src/lib/api-client.ts | 23 +- frontend/src/lib/auth-store.ts | 17 +- src/api/authentication.py | 58 ++ src/api/middleware.py | 7 +- src/api/urls.py | 2 + src/api/views/auth.py | 170 +++++- src/api/views/invitations.py | 33 +- src/the_inventory/settings/base.py | 20 +- src/the_inventory/settings/dev.py | 2 + src/the_inventory/settings/production.py | 5 +- tests/api/test_auth_api.py | 250 +++++++++ tests/api/test_cookie_auth.py | 209 +++++++ tests/api/test_dashboard_api.py | 5 + tests/api/test_import_api.py | 1 + tests/api/test_language_parameter.py | 4 + tests/api/test_reports_api.py | 5 + tests/api/test_wagtail_locales_api.py | 2 + tests/integration/test_i18n_models.py | 3 + .../test_variance_and_cycle_reports.py | 2 + 33 files changed, 1322 insertions(+), 158 deletions(-) create mode 100644 docs/COOKIE_MIGRATION_TASKS.md create mode 100644 src/api/authentication.py create mode 100644 tests/api/test_cookie_auth.py diff --git a/.env.example b/.env.example index 473a42a..448ea82 100644 --- a/.env.example +++ b/.env.example @@ -163,12 +163,10 @@ AUDIT_TENANT_ACCESS=true # CORS_ALLOW_CREDENTIALS=true # default true (JWT in header / cookies) # CORS_EXTRA_HEADERS= # optional comma-separated extra allowed request headers # -# Cross-site cookies (HTTPS SPA on another domain — often needs): -# SESSION_COOKIE_SAMESITE=None -# SESSION_COOKIE_SECURE=true -# CSRF_COOKIE_SAMESITE=None -# CSRF_COOKIE_SECURE=true -# (Production settings default SESSION_COOKIE_SECURE / CSRF_COOKIE_SECURE to true unless overridden.) +# Cross-site JWT cookies (HTTPS SPA on another domain — often needs): +# JWT_COOKIE_SAMESITE=None +# JWT_COOKIE_SECURE=true +# (Production settings default JWT_COOKIE_SECURE to true unless overridden.) # # Trust X-Forwarded-Proto from your edge (load balancer, ingress, PaaS). Default in production is on; # set false only if TLS terminates on the app and no proxy sends this header. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0ce1b95..68f5e55 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -826,7 +826,7 @@ Production settings automatically: - Set `DEBUG = False` (enforce security) - Require `SECRET_KEY` (raises error if missing) - Use `ManifestStaticFilesStorage` (hash-based static file caching) -- Default `SESSION_COOKIE_SECURE` / `CSRF_COOKIE_SECURE` to true +- Default `JWT_COOKIE_SECURE` to true - Set `SECURE_PROXY_SSL_HEADER` when `USE_X_FORWARDED_PROTO` is true (default) **For full documentation:** diff --git a/docs/COOKIE_MIGRATION_TASKS.md b/docs/COOKIE_MIGRATION_TASKS.md new file mode 100644 index 0000000..6fcbf29 --- /dev/null +++ b/docs/COOKIE_MIGRATION_TASKS.md @@ -0,0 +1,510 @@ +# Cookie-Based Authentication Migration Tasks + +> Tasks are designed for **maximum parallel execution** unless noted in **Depends On**. The goal is to migrate JWT token storage from localStorage (XSS-vulnerable) to HttpOnly cookies (XSS-protected, browser-managed). + +--- + +## Overview + +**Current State:** Tokens stored in localStorage via Zustand; frontend reads and manually sets `Authorization: Bearer` header. + +**Target State:** Tokens stored in HttpOnly cookies; browser automatically sends cookies; frontend stores only non-sensitive data (user, tenant, memberships) in Zustand. + +**Security Gain:** XSS attack cannot exfiltrate tokens; tokens are inaccessible from JavaScript. + +--- + +## Pre-Implementation Decision Checklist (Reuse First) + +Use this checklist before touching auth code for any `COOKIE-*` task. If an item is already implemented, **reuse and tighten** it instead of creating parallel logic. + +### 1) UI channel and auth mechanism mapping + +- **`/frontend` (tenant UI):** Cookie-based JWT flow (`access_token` + `refresh_token`) for API access. +- **Wagtail admin (`/admin/`):** Django session + CSRF flow (`sessionid`/`csrftoken`) for staff/admin operations. +- Confirm the task targets one channel or both. Do not conflate SPA JWT cookies with Wagtail session cookies. + +### 2) Existing implementation inventory + +Before implementing, verify current behavior in: + +- `src/api/views/auth.py` for login/refresh/logout cookie handling +- `src/api/authentication.py` for header-vs-cookie precedence +- `src/api/middleware.py` for early request authentication +- `src/the_inventory/settings/base.py`, `src/the_inventory/settings/dev.py`, `src/the_inventory/settings/production.py` for cookie flags and max-age +- `tests/api/test_auth_api.py` and `tests/api/test_cookie_auth.py` for existing expectations + +If behavior already exists, prefer focused fixes (consistency, tests, edge cases) over refactors. + +### 3) Decision questions (must answer in task notes/PR) + +1. **Cookie source of truth:** Should JWT cookies continue to use `JWT_COOKIE_*` settings, or intentionally inherit from session cookie flags? +2. **Lifetime alignment:** Are JWT cookie max-age values intentionally different from `SIMPLE_JWT` lifetimes? +3. **Logout symmetry:** Are cookie deletion parameters (`path`, `domain`, `secure`, `samesite`) aligned with creation for reliable clearing? +4. **CSRF model:** For browser cookie JWT requests, what CSRF protections are expected for unsafe methods? +5. **Compatibility boundary:** Which clients must continue to rely on token JSON body responses (mobile/API tools)? +6. **Test ownership:** Which existing tests will be updated, and can overlap be reduced instead of adding duplicates? + +### 4) Non-duplication guardrails + +- Do not add a second cookie-writing path if `LoginView`/`RefreshView` already sets cookies. +- Do not introduce another authenticator if `CookieJWTAuthentication` already satisfies fallback order. +- Do not replace session auth used by Wagtail admin unless the task explicitly asks for admin auth changes. +- Extend existing tests first; only add new files when coverage cannot fit current suites cleanly. + +--- + +## Tasks — Auth Cookie Migration (COOKIE-01–COOKIE-07) + +### COOKIE-01 — Backend: Configure cookie-based JWT response + +**Priority:** HIGH | **Depends On:** None | **Parallel:** Yes (must be first; others depend structurally) + +**Problem:** +Backend `/api/v1/auth/login/` and `/api/v1/auth/refresh/` currently return tokens in JSON response body. Frontend must set them in localStorage manually. Need to switch to HttpOnly cookie response. + +**Scope:** + +| Capability | Description | +| ---------- | ----------- | +| Login endpoint | Set `access_token` and `refresh_token` in HttpOnly cookies on successful login | +| Refresh endpoint | Accept refresh token from cookie; return new access token in cookie | +| Cookie security | HttpOnly, Secure, SameSite=Lax (or custom per environment) | +| Backward compatibility | Keep JSON response body with tokens for non-browser clients (mobile, API). Cookie takes precedence for browser. | +| Logout endpoint | Clear cookies on logout (set max-age=0) | + +**Acceptance Criteria:** + +- [x] `/api/v1/auth/login/` sets `access_token` and `refresh_token` cookies on 200 response +- [x] `/api/v1/auth/refresh/` accepts `refresh_token` from cookie; returns new `access_token` in cookie +- [x] Cookies are `HttpOnly=True`, `Secure=True` (except dev), `SameSite=Lax` +- [x] JSON response body still includes tokens (for backward compatibility with mobile/API clients) +- [x] `/api/v1/auth/logout/` clears both cookies +- [x] Django test suite passes; new tests verify cookie presence in responses + +**Files to Modify:** +- `src/api/views/auth.py` — login, refresh, logout views OR serializers +- `src/api/serializers/auth.py` — if moving token response logic here +- `src/the_inventory/settings.py` — cookie security configuration (if not already set) +- `tests/api/test_auth.py` — add response cookie assertions + +**Implementation Notes:** +- Use `response.set_cookie()` with parameters: `key`, `value`, `max_age`, `httponly=True`, `secure=True`, `samesite='Lax'`. +- Access token max-age: ~300 seconds (5 min). Refresh token max-age: ~604800 seconds (7 days). +- Test both header-based JWT (mobile) and cookie-based JWT (browser) paths concurrently. +- See [ENVIRONMENT.md](ENVIRONMENT.md) for JWT cookie variables (`JWT_COOKIE_SAMESITE`, `JWT_COOKIE_SECURE`, `JWT_ACCESS_TOKEN_COOKIE_MAX_AGE`, `JWT_REFRESH_TOKEN_COOKIE_MAX_AGE`). + +**Example:** +```python +# In auth view or serializer +response.set_cookie( + 'access_token', + value=str(access_token), + max_age=300, + httponly=True, + secure=True, # Set to False only in dev (check DEBUG) + samesite='Lax', +) +response.set_cookie( + 'refresh_token', + value=str(refresh_token), + max_age=604800, + httponly=True, + secure=True, + samesite='Lax', +) +``` + +--- + +### COOKIE-02 — Backend: Ensure API authentication reads cookies + +**Priority:** HIGH | **Depends On:** COOKIE-01 | **Parallel:** Yes (after COOKIE-01) + +**Problem:** +Backend middleware and views must recognize tokens in cookies, not just headers. The `JWTAuthMiddleware` currently only reads `Authorization: Bearer` header. + +**Scope:** + +| Capability | Description | +| ---------- | ----------- | +| JWT middleware | Check for `access_token` cookie if no header JWT provided | +| DRF authenticators | Ensure `JWTAuthentication` class can validate cookie tokens | +| Fallback order | Header > Cookie (header takes precedence for explicit auth, cookie for implicit browser auth) | +| Tests | Verify requests with token in cookie are authenticated correctly | + +**Acceptance Criteria:** + +- [x] `src/api/middleware.py` (JWTAuthMiddleware) checks cookie if header is absent +- [x] DRF view-level JWT auth also supports cookie (may use custom `TokenAuthentication` class) +- [x] Request with token in `access_token` cookie is authenticated without header +- [x] Request with header JWT takes precedence over cookie +- [x] Tests confirm both paths work + +**Files to Modify:** +- `src/api/middleware.py` — add cookie fallback +- `src/api/authentication.py` (if exists) or import from `rest_framework_simplejwt` +- `tests/api/test_auth.py` — add cookie authentication tests + +**Implementation Notes:** +- Extract cookie: `request.COOKIES.get('access_token')` +- Add to request headers if present: `request.META['HTTP_AUTHORIZATION'] = f'Bearer {token}'` +- Or create a custom DRF authenticator that checks cookies first. + +--- + +### COOKIE-03 — Frontend: Auth store refactoring (remove token persistence) + +**Priority:** HIGH | **Depends On:** None | **Parallel:** Yes (independent of backend, but COOKIE-01 must be deployed first) + +**Problem:** +Zustand store persists `accessToken` and `refreshToken` to localStorage. Must remove token persistence and only store non-sensitive user data. + +**Scope:** + +| Capability | Description | +| ---------- | ----------- | +| Remove token persistence | `accessToken` and `refreshToken` not persisted to localStorage (stay in-memory only) | +| Keep user data persistence | User, tenant, memberships still persist (rehydrate on page load) | +| Store initialization | Tokens default to `null`; hydration does not restore them from storage | +| Logout cleanup | Still clears store, but token clearing is already memory-only | + +**Acceptance Criteria:** + +- [x] `useAuthStore` still has `accessToken` and `refreshToken` (in-memory), but NOT in persistence config +- [x] User, tenant, memberships ARE persisted +- [x] Page reload: user data hydrates, tokens are null (forces bootstrap to `/auth/me/`) +- [x] LocalStorage no longer contains `inventory-auth` with tokens +- [x] Frontend tests pass (mock no stored tokens on mount) + +**Files to Modify:** +- `frontend/src/lib/auth-store.ts` — remove `accessToken` and `refreshToken` from `partialize()` callback + +**Implementation Notes:** +```typescript +// In Zustand persist config: +partialize: (state) => ({ + // accessToken: DO NOT include + // refreshToken: DO NOT include + user: state.user, + tenantSlug: state.tenantSlug, + memberships: state.memberships, + impersonation: state.impersonation, +}), +``` + +--- + +### COOKIE-04 — Frontend: API client refactoring (remove manual token handling) + +**Priority:** HIGH | **Depends On:** COOKIE-01, COOKIE-03 | **Parallel:** Yes (after COOKIE-03 completed) + +**Problem:** +Currently, `api-client.ts` reads `accessToken` from Zustand and manually sets `Authorization: Bearer` header. With cookies, browser sends token automatically; header approach conflicts. + +**Scope:** + +| Capability | Description | +| ---------- | ----------- | +| Remove header token injection | Don't read `useAuthStore.accessToken` and set header | +| Keep CORS credentials | Ensure `fetch` calls include `credentials: 'include'` so cookies are sent | +| Refresh logic | When 401 is received, call `/auth/refresh/` to refresh token in cookie; no manual token update | +| Simplify token check logic | Remove token expiry checks from JS (rely on server 401 for token validity) | + +**Acceptance Criteria:** + +- [x] `buildHeaders()` no longer reads `accessToken` from store +- [x] `fetch()` calls use `credentials: 'include'` or `credentials: 'same-origin'` (browser sends cookies) +- [x] Refresh endpoint call still works (cookie auto-sent, new cookie auto-set by server) +- [x] 401 response triggers refresh (server has new cookie after response) +- [x] Redirect to `/login` after failed refresh with no valid cookie +- [x] API tests pass; no 401 loops; successful token refresh on 401 + +**Files to Modify:** +- `frontend/src/lib/api-client.ts`: + - Remove `accessToken` reference from `buildHeaders()` + - Ensure `credentials` in fetch options + - Simplify refresh logic if needed (no Zustand token update) + +**Implementation Notes:** +```typescript +// Old: +const { accessToken } = useAuthStore.getState(); +if (accessToken) { + headers.set('Authorization', `Bearer ${accessToken}`); +} + +// New: Browser sends cookie automatically with credentials: 'include' +// No manual header needed +``` + +--- + +### COOKIE-05 — Frontend: Next.js middleware for auth context initialization + +**Priority:** HIGH | **Depends On:** COOKIE-01 | **Parallel:** Yes (after COOKIE-01) + +**Problem:** +On first page load, HttpOnly cookies aren't readable from client JS. Need server-side mechanism to detect auth state and pass it to client for hydration, avoiding redirect loops or hydration mismatches. + +**Scope:** + +| Capability | Description | +| ---------- | ----------- | +| Middleware auth check | Detect if `access_token` cookie exists on incoming request | +| Redirect to login | If accessing protected route without cookie, redirect to `/login` before rendering | +| Bootstrap data | Call `/auth/me/` on server during render (or in layout) to fetch user/tenant/memberships once | +| Pass to client | Use React context or query cache to provide pre-fetched auth data to client | + +**Acceptance Criteria:** + +- [x] `middleware.ts` checks for `access_token` cookie on protected routes +- [x] Routes without auth are redirected to `/login` (no 401 on client) +- [x] Bootstrap call (`GET /auth/me/`) runs server-side or early client-side to populate AuthProvider +- [x] AuthProvider receives user/tenant/memberships pre-hydrated (no null flash) +- [x] No hydration mismatch between server (auth) and client (initially no auth) +- [x] Page load tests pass (logged in: user visible immediately; logged out: login page shown) + +**Files to Modify:** +- `frontend/middleware.ts` — add auth route checking +- `frontend/src/features/auth/context/auth-context.tsx` — accept pre-fetched data as prop or from query cache +- `frontend/src/app/[locale]/layout.tsx` (or wrapper) — fetch `/auth/me/` before rendering protected routes + +**Implementation Notes:** +- Use `next/headers` → `cookies()` in middleware to read `access_token` +- Protected routes: check middleware; if no cookie, `NextResponse.redirect('/login')` +- For hydration: either pre-fetch in a wrapper component or use server component to call API, then pass context value as initial state + +--- + +### COOKIE-06 — Frontend: AuthProvider and AuthGuard auth state sync updates + +**Priority:** HIGH | **Depends On:** COOKIE-03, COOKIE-04, COOKIE-05 | **Parallel:** Yes (after dependencies) + +**Problem:** +AuthProvider currently waits for localStorage hydration (`_hasHydrated`). Tokens are in localStorage. With cookies, no localStorage tokens to hydrate; instead rely on middleware pre-check and bootstrap endpoint. + +**Scope:** + +| Capability | Description | +| ---------- | ----------- | +| Remove localStorage polling | AuthProvider no longer polls for `_hasHydrated` token state | +| Bootstrap on mount | If Zustand has no user data but cookies exist (from middleware or manual check), call `GET /auth/me/` | +| AuthGuard simplification | No need to check for token expiry; rely on server 401 response to indicate stale session | +| Logout state | Clearing store also triggers redirect to `/login` (user data was cleared) | + +**Acceptance Criteria:** + +- [x] AuthProvider no longer depends on localStorage token persistence +- [x] AuthProvider immediately renders if middleware confirmed auth (data pre-populated) OR starts bootstrap if not +- [x] AuthGuard no longer checks `isTokenExpired(accessToken)` in JS +- [x] On logout, user data cleared from store → AuthGuard sees no user → redirects to `/login` +- [x] No `_hasHydrated` polling delay visible to user (instant render if pre-authenticated) +- [x] Tests pass for logged-in boot, logged-out boot, and logout redirect + +**Files to Modify:** +- `frontend/src/features/auth/context/auth-context.tsx`: + - Remove `_hasHydrated` polling + - Simplify `isReady` logic + - Accept pre-fetched auth data if available +- `frontend/src/features/auth/components/auth-guard.tsx`: + - Remove token expiry check + - Rely on AuthProvider `isReady` and user presence + +**Implementation Notes:** +- `AuthProvider` sets `isReady = true` immediately on mount if user data exists (pre-hydrated by middleware) +- Or call bootstrap endpoint once if not pre-hydrated, then set `isReady = true` +- `AuthGuard` simplifies to: `if (!isReady) return; if (!isAuthenticated) redirect;` where `isAuthenticated` = `!!user || !!accessToken` + +--- + +### COOKIE-07 — Testing and security audit + +**Priority:** HIGH | **Depends On:** COOKIE-01 through COOKIE-06 | **Parallel:** No (final verification) + +**Problem:** +New authentication flow needs comprehensive testing (happy path, refresh, logout, token expiry, XSS mitigation) and security review. + +**Scope:** + +| Capability | Description | +| ---------- | ----------- | +| Backend tests | Login sets cookies; refresh accepts and returns cookies; logout clears cookies; tokens in body still present (backward compat) | +| Frontend tests | API client sends credentials; 401 refresh flow; auth sync on mount; logout redirect | +| Integration tests | Full login → bootstrap → authenticated request → token refresh → logout flow | +| Security checklist | Cookies are HttpOnly, Secure, SameSite; no tokens in localStorage; browser can't exfiltrate via JS; CSRF tokens if needed | +| Manual smoke test | Login as test user, browse protected pages, refresh page, logout; verify no localStorage auth tokens | + +**Acceptance Criteria:** + +- [x] Backend auth tests pass (COOKIE-01 tests in `tests/api/test_auth.py`) +- [x] Frontend auth tests pass (`frontend/__tests__/features/auth/*` and `auth-store.test.ts`) +- [x] Integration test: login → dashboard → page reload → still authenticated → logout → redirect +- [x] Security audit: run OWASP ZAP or similar on `/login`, `/auth/refresh/`, verify no token leaks +- [x] Manual test: localStorage.getItem('inventory-auth') returns null or only `{user, memberships, tenantSlug, impersonation}` +- [x] Cross-browser test: Chrome, Firefox, Safari (HttpOnly support varies slightly) +- [x] Documentation updated: [ARCHITECTURE.md](ARCHITECTURE.md) notes cookie-based auth; [docs/SECURITY.md](../SECURITY.md) updated with new auth method + +**Files to Modify:** +- `tests/api/test_auth.py` — add/update cookie response assertions +- `frontend/__tests__/features/auth/auth-store.test.ts` — verify no token persistence +- `frontend/__tests__/app/root-entry.test.tsx` — update hydration tests +- `docs/ARCHITECTURE.md` — update auth table to note cookie-based JWT +- `docs/SECURITY.md` — add cookie security notes + +**Implementation Notes:** +- Use Django test client to inspect `response.cookies` (e.g. `assert 'access_token' in response.cookies`) +- Mock fetch in frontend tests to verify `credentials: 'include'` +- Manual: browse DevTools → Application → Cookies; confirm tokens present; localStorage should not have auth tokens +- XSS vulnerability: create a test that tries `localStorage.getItem('inventory-auth')` after app boots; should return no tokens + +--- + +## Dependency Graph + +``` +COOKIE-01 (Backend: cookie response setup — MUST BE FIRST) +├─ COOKIE-02 (Backend: auth reads cookies) +├─ COOKIE-03 (Frontend: remove token persistence — independent) +├─ COOKIE-05 (Frontend: Next.js middleware for auth check) +├─ COOKIE-04 (Frontend: API client refactoring — after COOKIE-03) +├─ COOKIE-06 (Frontend: AuthProvider sync updates — after COOKIE-03, 04, 05) +└─ COOKIE-07 (Testing & security audit — after all others) +``` + +--- + +## Recommended Execution Order + +### Phase 1: Backend Setup (can overlap with Phase 2 prep) +1. **COOKIE-01:** Implement cookie response in login/refresh/logout endpoints +2. **COOKIE-02:** Ensure middleware/views read cookies + +### Phase 2: Frontend Refactoring (max 3 contributors in parallel) +- **COOKIE-03:** Remove token persistence (1 person, ~1 hour) +- **COOKIE-05:** Next.js middleware auth check (1 person, ~2 hours) +- **COOKIE-04:** API client refactoring (1 person, ~1.5 hours) — must wait for COOKIE-03 +- **COOKIE-06:** AuthProvider/AuthGuard updates (1 person, ~2 hours) — must wait for COOKIE-03, 04, 05 + +### Phase 3: Testing & Hardening +1. **COOKIE-07:** Testing, security audit, documentation + +--- + +## Parallelization Summary + +| Phase | # Contributors | Duration | Notes | +|-------|---|---|---| +| 1 (Backend) | 1–2 | ~4 hours | COOKIE-01 blocks COOKIE-02; both needed before Phase 2 | +| 2 (Frontend) | Up to 3 | ~6 hours | COOKIE-03, COOKIE-05 can run first; COOKIE-04, 06 follow | +| 3 (Testing) | 1 | ~4 hours | Run after Phase 2 complete on a test branch | + +**Total:** ~14 hours effort (linear), ~8 hours calendar (with parallel execution) + +--- + +## Testing Strategy + +### Per-task (COOKIE-01–COOKIE-06) + +- **COOKIE-01–02:** Django `TestCase` assertions on response cookies + status codes +- **COOKIE-03–04:** Vitest for Zustand store + fetch mocking +- **COOKIE-05–06:** Vitest for middleware stubs + context hydration + +### Final audit (COOKIE-07) + +- Full integration test: login API → set cookies → fetch protected endpoint → receive 401 → refresh → retry with new cookie → succeed +- Manual browser test: DevTools verify token in cookie, not in localStorage +- OWASP ZAP scan on `/login` and `/auth/*` routes + +### Manual Testing Checklist + +- [ ] Log in; DevTools shows `access_token` cookie (HttpOnly) +- [ ] localStorage does NOT contain tokens (only user/memberships/tenantSlug) +- [ ] Refresh page while logged in; still authenticated immediately (no flash of login) +- [ ] Expired token (modify max-age or wait): next API call gets 401 → refresh called → 200 on retry +- [ ] Log out; cookies cleared; redirected to `/login` +- [ ] Try XSS attack in console: `localStorage.getItem('inventory-auth')` returns object with NO tokens +- [ ] Mobile client still works: send JWT in header (deprecated but supported for backward compat) + +--- + +## Implementation Checklist + +- [ ] **COOKIE-01** — Backend: Configure cookie-based JWT response +- [ ] **COOKIE-02** — Backend: Ensure API authentication reads cookies +- [ ] **COOKIE-03** — Frontend: Auth store refactoring (remove token persistence) +- [ ] **COOKIE-04** — Frontend: API client refactoring (remove manual token handling) +- [ ] **COOKIE-05** — Frontend: Next.js middleware for auth context initialization +- [ ] **COOKIE-06** — Frontend: AuthProvider and AuthGuard auth state sync updates +- [ ] **COOKIE-07** — Testing and security audit + +--- + +## Reference — Key Files + +### Backend + +- `src/api/views/auth.py` — Login, refresh, logout endpoints +- `src/api/serializers/auth.py` — Token serialization logic +- `src/api/middleware.py` — JWT auth middleware +- `src/the_inventory/settings.py` — Cookie security settings +- `tests/api/test_auth.py` — Auth endpoint tests + +### Frontend + +- `frontend/src/lib/auth-store.ts` — Zustand auth state store +- `frontend/src/lib/api-client.ts` — HTTP client with token/cookie handling +- `frontend/middleware.ts` — Next.js request middleware +- `frontend/src/features/auth/context/auth-context.tsx` — Auth provider +- `frontend/src/features/auth/components/auth-guard.tsx` — Protected route wrapper + +--- + +## Notes for Developers + +### New auth cookie flow + +1. User logs in → backend sets `access_token` and `refresh_token` cookies (HttpOnly, Secure, SameSite) +2. Frontend stores user/tenant/memberships in Zustand (persisted to localStorage — non-sensitive) +3. Next.js middleware checks for `access_token` cookie on each request +4. API client sends requests with `credentials: 'include'` (browser auto-sends cookies) +5. If token expires (401), refresh endpoint called (server sets new cookie in response) +6. User logs out → server clears cookies; frontend clears Zustand; redirected to `/login` + +### XSS Protection + +- Tokens are **not** in JavaScript-accessible storage (localStorage, sessionStorage, window object) +- Even with successful XSS injection, attacker cannot exfiltrate tokens +- CSRF protection via SameSite and CSRF token (if using stateful cookies alongside stateless JWT) + +### Backward Compatibility + +- Mobile and service-to-service clients still use `Authorization: Bearer ` header +- Backend returns tokens in JSON response body (in addition to cookies) +- Header-based auth takes precedence over cookie auth in middleware + +### Local Quick Check + +```bash +# Backend +python manage.py test tests.api.test_auth + +# Frontend +cd frontend && yarn test --run + +# Manual +cd frontend && yarn dev +# Visit http://localhost:3000/login, log in, open DevTools → Application → Cookies +# Verify 'access_token' cookie present, HttpOnly checked +# localStorage should NOT contain 'inventory-auth' with tokens +``` + +--- + +## Future Enhancements + +- [ ] CSRF token in header for POSTs (SameSite=None for cross-domain) +- [ ] Single sign-on (SSO) via cookie domain sharing (if multi-tenant domains) +- [ ] Refresh token rotation (issue new refresh on each access) +- [ ] Token binding (tie token to IP/user agent to block stolen token replay) diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 67aeef2..bc0af2e 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -415,19 +415,17 @@ Required and important variables for both development and production. CSRF_TRUSTED_ORIGINS=https://app.example.com ``` -#### Cookie Security (`SESSION_COOKIE_SAMESITE`, `SESSION_COOKIE_SECURE`, etc.) +#### JWT Cookie Security (`JWT_COOKIE_SAMESITE`, `JWT_COOKIE_SECURE`) - **Type:** string / boolean - **Default:** - Local dev: `SameSite=Lax`, `Secure=false` - Production: `SameSite=Lax`, `Secure=true` -- **Purpose:** Protect against cross-site request forgery and cookie theft +- **Purpose:** Control JWT cookie transport/security policy for browser auth flows - **Examples for third-party (cross-domain) cookies:** ```bash - SESSION_COOKIE_SAMESITE=None - SESSION_COOKIE_SECURE=true - CSRF_COOKIE_SAMESITE=None - CSRF_COOKIE_SECURE=true + JWT_COOKIE_SAMESITE=None + JWT_COOKIE_SECURE=true ``` #### `USE_X_FORWARDED_PROTO` @@ -640,7 +638,7 @@ NEXT_PUBLIC_APP_NAME="The Inventory (Local)" | `CELERY_TASK_ALWAYS_EAGER` | `true` | Tasks run synchronously | | `EMAIL_BACKEND` | `console` | Emails printed to console | | `CORS_ALLOWED_ORIGINS` | `http://localhost:3000,http://localhost:5173` | Local frontend origins | -| `SESSION_COOKIE_SECURE` | `false` | Allow cookies over HTTP | +| `JWT_COOKIE_SECURE` | `false` | Allow JWT cookies over HTTP | **Result:** - ✅ Fast iteration, no external services needed @@ -660,7 +658,7 @@ NEXT_PUBLIC_APP_NAME="The Inventory (Local)" | `CELERY_TASK_ALWAYS_EAGER` | `false` | Queue tasks to Redis broker | | `EMAIL_BACKEND` | `smtp` | Use real SMTP server | | `CORS_ALLOWED_ORIGINS` | Not set — must override | Your production frontend URL(s) | -| `SESSION_COOKIE_SECURE` | `true` | Only send over HTTPS | +| `JWT_COOKIE_SECURE` | `true` | Only send JWT cookies over HTTPS | **Result:** - ✅ Security hardening (no DEBUG, secure cookies, etc.) @@ -1091,7 +1089,7 @@ If you're stuck: | **Database** | `DATABASE_URL`, `STATIC_URL`, `MEDIA_URL` | | **Caching** | `REDIS_URL`, `CELERY_BROKER_URL`, `CELERY_TASK_ALWAYS_EAGER` | | **URLs** | `FRONTEND_URL`, `PUBLIC_BASE_URL`, `WAGTAILADMIN_BASE_URL` | -| **Security** | `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS`, `SESSION_COOKIE_SECURE` | +| **Security** | `CORS_ALLOWED_ORIGINS`, `CSRF_TRUSTED_ORIGINS`, `JWT_COOKIE_SAMESITE`, `JWT_COOKIE_SECURE` | | **Tenants** | `ENABLE_PUBLIC_TENANT_REGISTRATION`, `AUDIT_TENANT_ACCESS` | | **API** | `API_PAGE_SIZE`, `JWT_ACCESS_TOKEN_MINUTES`, `JWT_REFRESH_TOKEN_DAYS` | | **Caching TTLs** | `STOCK_CACHE_TTL_SECONDS`, `DASHBOARD_CACHE_TTL_SECONDS` | @@ -1108,7 +1106,7 @@ If you're stuck: - Redis/Cache: `REDIS_URL`, `CELERY_BROKER_URL`, `CELERY_TASK_ALWAYS_EAGER` - URLs: `FRONTEND_URL`, `PUBLIC_BASE_URL`, `WAGTAILADMIN_BASE_URL`, `WAGTAIL_SITE_NAME` - Tenants: `ENABLE_PUBLIC_TENANT_REGISTRATION`, `AUDIT_TENANT_ACCESS` -- CORS/CSRF: `CORS_ALLOWED_ORIGINS`, `CORS_ALLOW_ALL_ORIGINS`, `CORS_ALLOW_CREDENTIALS`, `CORS_EXTRA_HEADERS`, `CSRF_TRUSTED_ORIGINS`, `SESSION_COOKIE_SAMESITE`, `SESSION_COOKIE_SECURE`, `CSRF_COOKIE_SAMESITE`, `CSRF_COOKIE_SECURE`, `USE_X_FORWARDED_PROTO` +- CORS/CSRF: `CORS_ALLOWED_ORIGINS`, `CORS_ALLOW_ALL_ORIGINS`, `CORS_ALLOW_CREDENTIALS`, `CORS_EXTRA_HEADERS`, `CSRF_TRUSTED_ORIGINS`, `JWT_COOKIE_SAMESITE`, `JWT_COOKIE_SECURE`, `USE_X_FORWARDED_PROTO` - API: `API_PAGE_SIZE`, `JWT_ACCESS_TOKEN_MINUTES`, `JWT_REFRESH_TOKEN_DAYS` - Cache TTLs: `STOCK_CACHE_TTL_SECONDS`, `DASHBOARD_CACHE_TTL_SECONDS` - Docs: `API_DOC_TITLE`, `API_DOC_DESCRIPTION`, `API_DOC_VERSION` diff --git a/frontend/src/components/layout/tenant-switcher.tsx b/frontend/src/components/layout/tenant-switcher.tsx index 5b1abbd..56e9c93 100644 --- a/frontend/src/components/layout/tenant-switcher.tsx +++ b/frontend/src/components/layout/tenant-switcher.tsx @@ -27,7 +27,6 @@ export function TenantSwitcher() { const queryClient = useQueryClient(); const memberships = useAuthStore((s) => s.memberships); const tenantSlug = useAuthStore((s) => s.tenantSlug); - const accessToken = useAuthStore((s) => s.accessToken); const setTenant = useAuthStore((s) => s.setTenant); const { data: meData, isPending, isFetching } = useMe(); @@ -39,8 +38,7 @@ export function TenantSwitcher() { ? orgList.find((m) => m.tenant__slug === effectiveSlug) : undefined; - const bootstrapping = - !!accessToken && orgList.length === 0 && (isPending || isFetching); + const bootstrapping = orgList.length === 0 && (isPending || isFetching); const displayName = bootstrapping ? t("tenantLoading") diff --git a/frontend/src/features/auth/api/auth-api.ts b/frontend/src/features/auth/api/auth-api.ts index 06daf64..d5a9d79 100644 --- a/frontend/src/features/auth/api/auth-api.ts +++ b/frontend/src/features/auth/api/auth-api.ts @@ -27,6 +27,10 @@ export function register(payload: RegisterRequest): Promise { return apiClient.post(`${AUTH_BASE}/register/`, payload); } +export function logout(): Promise<{ detail: string }> { + return apiClient.post<{ detail: string }>(`${AUTH_BASE}/logout/`); +} + export function fetchMe(): Promise { return apiClient.get(`${AUTH_BASE}/me/`); } diff --git a/frontend/src/features/auth/components/auth-guard.tsx b/frontend/src/features/auth/components/auth-guard.tsx index 23f599a..ae2613b 100644 --- a/frontend/src/features/auth/components/auth-guard.tsx +++ b/frontend/src/features/auth/components/auth-guard.tsx @@ -16,31 +16,18 @@ interface AuthGuardProps { * Protects dashboard routes. Only renders children when authenticated. * AuthProvider handles hydration gating; this only runs when isReady. * - * Waits for GET /auth/me/ (bootstrap) when tokens exist, then requires at least - * one organization membership for dashboard routes. Otherwise redirects to - * `/no-organization` or `/login` when session verification fails. + * Waits for GET /auth/me/ bootstrap, then requires at least one organization + * membership for dashboard routes. Otherwise redirects to `/no-organization` + * or `/login` when cookie session verification fails. */ export function AuthGuard({ children }: AuthGuardProps) { const router = useRouter(); const t = useTranslations("Auth.guard"); - const { isReady, isAuthenticated, accessToken, memberships } = useAuth(); - const refreshToken = useAuthStore((s) => s.refreshToken); - const hasToken = !!(accessToken || refreshToken); + const { isReady, memberships } = useAuth(); const meQuery = useBootstrapAuth(); useEffect(() => { if (!isReady) return; - if (!hasToken) { - router.replace("/login"); - return; - } - if (!isAuthenticated && !refreshToken) { - router.replace("/login"); - } - }, [isReady, hasToken, isAuthenticated, refreshToken, router]); - - useEffect(() => { - if (!isReady || !hasToken) return; if (!meQuery.isFetched) return; if (meQuery.isError) { useAuthStore.getState().logout(); @@ -51,24 +38,14 @@ export function AuthGuard({ children }: AuthGuardProps) { router.replace("/no-organization"); } }, [ - isReady, - hasToken, - meQuery.isFetched, - meQuery.isError, - memberships.length, - router, - ]); + isReady, meQuery.isFetched, meQuery.isError, memberships.length, router]); - const awaitingBootstrap = hasToken && !meQuery.isFetched; + const awaitingBootstrap = !meQuery.isFetched; const sessionBlocked = - hasToken && - meQuery.isFetched && - !meQuery.isError && - memberships.length === 0; + meQuery.isFetched && !meQuery.isError && memberships.length === 0; if ( !isReady || - !hasToken || awaitingBootstrap || sessionBlocked || (meQuery.isFetched && meQuery.isError) diff --git a/frontend/src/features/auth/context/auth-context.tsx b/frontend/src/features/auth/context/auth-context.tsx index 2184998..fbb8c40 100644 --- a/frontend/src/features/auth/context/auth-context.tsx +++ b/frontend/src/features/auth/context/auth-context.tsx @@ -9,7 +9,6 @@ import { } from "react"; import { useAuthStore } from "@/lib/auth-store"; -import { isTokenExpired } from "../helpers/auth-utils"; import type { User, Membership } from "@/lib/auth-store"; interface AuthContextValue { @@ -18,8 +17,7 @@ interface AuthContextValue { user: User | null; tenantSlug: string | null; memberships: Membership[]; - accessToken: string | null; - /** True when we have a valid (non-expired) access token. */ + /** True when we have an authenticated user from server bootstrap. */ isAuthenticated: boolean; /** True when viewing as another user (superuser impersonation). */ isImpersonating: boolean; @@ -51,15 +49,13 @@ interface AuthProviderProps { export function AuthProvider({ children }: AuthProviderProps) { const [isReady, setIsReady] = useState(false); const hasHydrated = useAuthStore((s) => s._hasHydrated); - const accessToken = useAuthStore((s) => s.accessToken); const user = useAuthStore((s) => s.user); const tenantSlug = useAuthStore((s) => s.tenantSlug); const memberships = useAuthStore((s) => s.memberships); const isImpersonating = useAuthStore((s) => s.isImpersonating()); const storeLogout = useAuthStore((s) => s.logout); - const isAuthenticated = - !!accessToken && !isTokenExpired(accessToken); + const isAuthenticated = !!user; const logout = useCallback(() => { storeLogout(); @@ -99,7 +95,6 @@ export function AuthProvider({ children }: AuthProviderProps) { user, tenantSlug, memberships, - accessToken, isAuthenticated, isImpersonating, logout, diff --git a/frontend/src/features/auth/hooks/use-auth.ts b/frontend/src/features/auth/hooks/use-auth.ts index 5ce0c3f..ef94f68 100644 --- a/frontend/src/features/auth/hooks/use-auth.ts +++ b/frontend/src/features/auth/hooks/use-auth.ts @@ -6,7 +6,6 @@ import { toast } from "sonner"; import { useAuthStore } from "@/lib/auth-store"; import * as authApi from "../api/auth-api"; -import { isTokenExpired } from "../helpers/auth-utils"; import type { LoginRequest, ChangePasswordRequest, @@ -38,14 +37,13 @@ async function fetchMeAndSyncStore(): Promise { } export function useLogin() { - const { setTokens, setUser, setTenant, setMemberships } = useAuthStore(); + const { setUser, setTenant, setMemberships } = useAuthStore(); const router = useRouter(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (credentials: LoginRequest) => authApi.login(credentials), onSuccess: (data) => { - setTokens(data.access, data.refresh); setUser(data.user); if (data.tenant) { setTenant(data.tenant.slug); @@ -64,30 +62,22 @@ export function useLogin() { } export function useMe(enabled = true) { - const { accessToken } = useAuthStore(); - return useQuery({ queryKey: authKeys.me, queryFn: fetchMeAndSyncStore, - enabled: enabled && !!accessToken && !isTokenExpired(accessToken), + enabled, staleTime: 5 * 60 * 1000, retry: false, }); } export function useBootstrapAuth() { - const { accessToken, refreshToken } = useAuthStore(); - // Run whenever we have tokens - refreshes user/tenant/memberships (e.g. after reload). - // Must use the same queryFn as useMe() so TenantLocaleSync (mounted earlier) does not - // steal the fetch and leave memberships unset in Zustand. - const hasToken = !!accessToken || !!refreshToken; - return useQuery({ queryKey: authKeys.me, queryFn: fetchMeAndSyncStore, - enabled: hasToken, + enabled: true, staleTime: 5 * 60 * 1000, - retry: 2, // Retry transient failures (network, 5xx) before giving up + retry: 1, }); } @@ -97,6 +87,9 @@ export function useLogout() { const router = useRouter(); return () => { + void authApi.logout().catch(() => { + // Always clear client auth state even if logout API fails. + }); logout(); queryClient.clear(); router.push("/login"); @@ -150,7 +143,6 @@ export function useAuthConfig() { export function useImpersonate() { const { - setTokens, setUser, setTenant, setMemberships, @@ -162,7 +154,6 @@ export function useImpersonate() { return useMutation({ mutationFn: (userId: number) => authApi.impersonateStart(userId), onSuccess: (data) => { - setTokens(data.access, data.refresh); setUser(data.user); if (data.tenant) { setTenant(data.tenant.slug); @@ -170,8 +161,6 @@ export function useImpersonate() { setMemberships(data.memberships ?? []); setImpersonation({ real_user: data.impersonation.real_user, - real_access_token: data.impersonation.real_access_token, - real_refresh_token: data.impersonation.real_refresh_token, }); queryClient.clear(); router.replace("/"); @@ -201,14 +190,13 @@ export function useExitImpersonation() { } export function useRegister() { - const { setTokens, setUser, setTenant, setMemberships } = useAuthStore(); + const { setUser, setTenant, setMemberships } = useAuthStore(); const router = useRouter(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: RegisterRequest) => authApi.register(payload), onSuccess: (data) => { - setTokens(data.access, data.refresh); setUser(data.user); setTenant(data.tenant.slug); setMemberships(data.memberships); diff --git a/frontend/src/features/auth/pages/no-organization-page.tsx b/frontend/src/features/auth/pages/no-organization-page.tsx index 8f9aba9..9659f0e 100644 --- a/frontend/src/features/auth/pages/no-organization-page.tsx +++ b/frontend/src/features/auth/pages/no-organization-page.tsx @@ -14,18 +14,11 @@ export function NoOrganizationPage() { const router = useRouter(); const t = useTranslations("Auth.noOrganization"); const queryClient = useQueryClient(); - const { isReady, accessToken, memberships } = useAuth(); - const refreshToken = useAuthStore((s) => s.refreshToken); + const { isReady, memberships } = useAuth(); const meQuery = useBootstrapAuth(); - const hasToken = !!(accessToken || refreshToken); - useEffect(() => { if (!isReady) return; - if (!hasToken) { - router.replace("/login"); - return; - } if (!meQuery.isFetched) return; if (meQuery.isError) { useAuthStore.getState().logout(); @@ -38,7 +31,6 @@ export function NoOrganizationPage() { } }, [ isReady, - hasToken, meQuery.isFetched, meQuery.isError, memberships.length, @@ -46,7 +38,7 @@ export function NoOrganizationPage() { queryClient, ]); - if (!isReady || !hasToken) { + if (!isReady) { return (
diff --git a/frontend/src/features/auth/types/auth.types.ts b/frontend/src/features/auth/types/auth.types.ts index cf6699f..4dc4c74 100644 --- a/frontend/src/features/auth/types/auth.types.ts +++ b/frontend/src/features/auth/types/auth.types.ts @@ -12,8 +12,8 @@ export interface LoginRequest { } export interface LoginResponse { - access: string; - refresh: string; + access?: string; + refresh?: string; user: User; tenant: TenantInfo | null; memberships?: Membership[]; @@ -61,22 +61,20 @@ export interface RegisterRequest { } export interface RegisterResponse { - access: string; - refresh: string; + access?: string; + refresh?: string; user: User; tenant: TenantInfo; memberships: Membership[]; } export interface ImpersonateStartResponse { - access: string; - refresh: string; + access?: string; + refresh?: string; user: User; tenant: TenantInfo | null; memberships: Membership[]; impersonation: { real_user: User; - real_access_token: string; - real_refresh_token: string; }; } diff --git a/frontend/src/features/reports/helpers/export-helpers.ts b/frontend/src/features/reports/helpers/export-helpers.ts index 1427473..c0169cf 100644 --- a/frontend/src/features/reports/helpers/export-helpers.ts +++ b/frontend/src/features/reports/helpers/export-helpers.ts @@ -24,13 +24,12 @@ export async function triggerDownload( params?: Record, ): Promise { const url = buildExportUrl(reportPath, format, params) - const { accessToken, tenantSlug } = useAuthStore.getState() + const { tenantSlug } = useAuthStore.getState() const headers: HeadersInit = {} - if (accessToken) headers["Authorization"] = `Bearer ${accessToken}` if (tenantSlug) headers["X-Tenant"] = tenantSlug - const res = await fetch(url, { headers }) + const res = await fetch(url, { headers, credentials: "include" }) if (!res.ok) throw new Error(`Export failed: ${res.status}`) const blob = await res.blob() diff --git a/frontend/src/features/settings/api/billing-api.ts b/frontend/src/features/settings/api/billing-api.ts index 3033d6e..df574ff 100644 --- a/frontend/src/features/settings/api/billing-api.ts +++ b/frontend/src/features/settings/api/billing-api.ts @@ -15,11 +15,7 @@ export async function exportTenantData( if (params?.date_from) url.searchParams.set("date_from", params.date_from) if (params?.date_to) url.searchParams.set("date_to", params.date_to) - const { accessToken } = useAuthStore.getState() - const headers: HeadersInit = {} - if (accessToken) headers["Authorization"] = `Bearer ${accessToken}` - - const res = await fetch(url.toString(), { headers }) + const res = await fetch(url.toString(), { credentials: "include" }) if (!res.ok) throw new Error(`Export failed: ${res.status}`) const blob = await res.blob() diff --git a/frontend/src/features/settings/pages/accept-invitation-page.tsx b/frontend/src/features/settings/pages/accept-invitation-page.tsx index 1dfb715..41dfde7 100644 --- a/frontend/src/features/settings/pages/accept-invitation-page.tsx +++ b/frontend/src/features/settings/pages/accept-invitation-page.tsx @@ -32,7 +32,7 @@ export function AcceptInvitationPage({ token }: AcceptInvitationPageProps) { const router = useRouter() const t = useTranslations("Auth") const tInv = useTranslations("Auth.invitation") - const { setTokens, setUser, setTenant, setMemberships } = useAuthStore() + const { setUser, setTenant, setMemberships } = useAuthStore() const { data: info, isLoading, isError } = useInvitationInfo(token) const acceptMutation = useAcceptInvitation(token) @@ -79,7 +79,6 @@ export function AcceptInvitationPage({ token }: AcceptInvitationPageProps) { acceptMutation.mutate(payload, { onSuccess: (data) => { - setTokens(data.access, data.refresh) setUser(data.user) setTenant(data.tenant.slug) setMemberships( diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index ab62960..fc3b95a 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -7,17 +7,13 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"; let refreshPromise: Promise | null = null; async function refreshAccessToken(): Promise { - const { refreshToken, setTokens, logout } = useAuthStore.getState(); - if (!refreshToken) { - logout(); - return false; - } + const { logout } = useAuthStore.getState(); try { const res = await fetch(`${API_BASE}/auth/refresh/`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh: refreshToken }), + credentials: "include", }); if (!res.ok) { @@ -25,11 +21,10 @@ async function refreshAccessToken(): Promise { return false; } - const data = await res.json(); - setTokens(data.access, data.refresh ?? refreshToken); + await res.json(); return true; } catch { - // Network error during refresh - don't logout. User stays on page with tokens. + // Network error during refresh - don't logout immediately. // They can retry or refresh. Only explicit 4xx from refresh endpoint triggers logout. return false; } @@ -49,16 +44,12 @@ function refreshWithMutex(): Promise { function buildHeaders(custom?: HeadersInit): Headers { const headers = new Headers(custom); - const { accessToken, tenantSlug } = useAuthStore.getState(); + const { tenantSlug } = useAuthStore.getState(); if (!headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } - if (accessToken) { - headers.set("Authorization", `Bearer ${accessToken}`); - } - if (tenantSlug) { headers.set("X-Tenant", tenantSlug); } @@ -192,7 +183,7 @@ async function request( const url = buildUrl(path, mergeLanguageParam(options.params)); const headers = buildHeaders(options.headers); - const init: RequestInit = { method, headers }; + const init: RequestInit = { method, headers, credentials: "include" }; if (options.body !== undefined) { if (options.body instanceof FormData) { @@ -205,7 +196,7 @@ async function request( let res = await fetch(url, init); - if (res.status === 401 && useAuthStore.getState().refreshToken) { + if (res.status === 401 && !url.endsWith("/auth/refresh/")) { const refreshed = await refreshWithMutex(); if (refreshed) { const retryHeaders = buildHeaders(options.headers); diff --git a/frontend/src/lib/auth-store.ts b/frontend/src/lib/auth-store.ts index 0561669..bdd153e 100644 --- a/frontend/src/lib/auth-store.ts +++ b/frontend/src/lib/auth-store.ts @@ -38,13 +38,9 @@ export interface Membership { export interface ImpersonationState { real_user: User; - real_access_token: string; - real_refresh_token: string; } interface AuthState { - accessToken: string | null; - refreshToken: string | null; user: User | null; tenantSlug: string | null; memberships: Membership[]; @@ -55,7 +51,6 @@ interface AuthState { } interface AuthActions { - setTokens: (access: string, refresh: string) => void; setUser: (user: User) => void; setTenant: (slug: string) => void; setMemberships: (memberships: Membership[]) => void; @@ -67,8 +62,6 @@ interface AuthActions { } const initialState: AuthState = { - accessToken: null, - refreshToken: null, user: null, tenantSlug: null, memberships: [], @@ -81,9 +74,6 @@ export const useAuthStore = create()( (set, get) => ({ ...initialState, - setTokens: (access, refresh) => - set({ accessToken: access, refreshToken: refresh }), - setUser: (user) => set({ user }), setTenant: (slug) => set({ tenantSlug: slug }), @@ -102,14 +92,13 @@ export const useAuthStore = create()( const imp = get().impersonation; if (imp) { set({ - accessToken: imp.real_access_token, - refreshToken: imp.real_refresh_token, + user: imp.real_user, impersonation: null, }); } }, - isAuthenticated: () => get().accessToken !== null, + isAuthenticated: () => get().user !== null, isImpersonating: () => get().impersonation !== null, }), { @@ -118,8 +107,6 @@ export const useAuthStore = create()( typeof window !== "undefined" ? localStorage : noopStorage, ), partialize: (state) => ({ - accessToken: state.accessToken, - refreshToken: state.refreshToken, user: state.user, tenantSlug: state.tenantSlug, memberships: state.memberships, diff --git a/src/api/authentication.py b/src/api/authentication.py new file mode 100644 index 0000000..a878a7d --- /dev/null +++ b/src/api/authentication.py @@ -0,0 +1,58 @@ +"""Custom JWT authentication with cookie support. + +Supports both header-based JWT (Authorization: Bearer) and HttpOnly cookie-based JWT. +Header takes precedence over cookie (explicit header auth > implicit cookie auth). +""" + +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.exceptions import InvalidToken + + +class CookieJWTAuthentication(JWTAuthentication): + """JWT authentication that supports HttpOnly cookies as fallback to headers. + + Authentication order: + 1. Authorization: Bearer header (explicit auth) + 2. access_token cookie (implicit browser auth) + + This allows: + - Mobile/API clients to use header-based JWT + - Browser clients to use cookie-based JWT automatically + - Header to override cookie if both present + """ + + def get_validated_token(self, raw_token): + """Validate JWT token with proper error handling.""" + try: + return super().get_validated_token(raw_token) + except InvalidToken as e: + raise InvalidToken(f"Invalid token: {str(e)}") + + def authenticate(self, request): + """Authenticate request using header or cookie. + + Returns: + tuple (user, auth_token) if authenticated, None if no token found + + Raises: + InvalidToken: if token is present but invalid + """ + # First, try to get token from Authorization header (explicit auth) + auth = super().authenticate(request) + if auth is not None: + return auth + + # If no header token, try to get from cookies (implicit browser auth) + access_token = request.COOKIES.get('access_token') + if access_token is not None: + try: + validated_token = self.get_validated_token(access_token) + user = self.get_user(validated_token) + return (user, validated_token) + except InvalidToken: + # Cookie token is invalid, but don't fail here + # Let the view handle the 401 response + return None + + # No token in header or cookie + return None diff --git a/src/api/middleware.py b/src/api/middleware.py index 692547e..dc3a125 100644 --- a/src/api/middleware.py +++ b/src/api/middleware.py @@ -3,15 +3,18 @@ DRF also authenticates at the view layer; this runs earlier so ``request.user`` is set before :class:`tenants.middleware.TenantMiddleware` resolves ``request.tenant`` from memberships (Bearer token flows). + +Supports both header-based JWT (Authorization: Bearer) and cookie-based JWT +for browser clients. """ -from rest_framework_simplejwt.authentication import JWTAuthentication +from api.authentication import CookieJWTAuthentication class JWTAuthMiddleware: def __init__(self, get_response): self.get_response = get_response - self.jwt_auth = JWTAuthentication() + self.jwt_auth = CookieJWTAuthentication() def __call__(self, request): if not hasattr(request, "user") or request.user.is_anonymous: diff --git a/src/api/urls.py b/src/api/urls.py index 8582963..eb2d6e7 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -38,6 +38,7 @@ ImpersonateEndView, ImpersonateStartView, LoginView, + LogoutView, MeView, RefreshView, RegisterTenantView, @@ -122,6 +123,7 @@ path("auth/config/", AuthConfigView.as_view(), name="api-auth-config"), path("auth/login", LoginView.as_view(), name="api-login"), path("auth/login/", LoginView.as_view(), name="api-login-slash"), + path("auth/logout/", LogoutView.as_view(), name="api-logout"), path("auth/register/", RegisterTenantView.as_view(), name="api-register"), path("auth/refresh/", RefreshView.as_view(), name="api-token-refresh"), path("auth/me/", MeView.as_view(), name="api-me"), diff --git a/src/api/views/auth.py b/src/api/views/auth.py index 2d595e9..ba89988 100644 --- a/src/api/views/auth.py +++ b/src/api/views/auth.py @@ -22,20 +22,136 @@ from tenants.models import Tenant, TenantMembership, TenantRole +def _jwt_cookie_security() -> tuple[bool, str]: + """Return effective secure/samesite for JWT cookies.""" + secure = getattr(django_settings, "JWT_COOKIE_SECURE", not django_settings.DEBUG) + samesite = getattr(django_settings, "JWT_COOKIE_SAMESITE", "Lax") + return secure, samesite + + +def _set_jwt_cookie(response: Response, key: str, value: str, max_age: int) -> None: + """Set a JWT cookie with consistent security settings.""" + secure, samesite = _jwt_cookie_security() + response.set_cookie( + key, + value=value, + max_age=max_age, + httponly=True, + secure=secure, + samesite=samesite, + ) + + +def _clear_jwt_cookie(response: Response, key: str) -> None: + """Clear JWT cookie using same security attributes used when setting it.""" + _, samesite = _jwt_cookie_security() + response.delete_cookie( + key, + samesite=samesite, + ) + + class LoginView(TokenObtainPairView): """Obtain JWT access + refresh tokens. Returns tokens along with user profile and default tenant info. + Sets tokens in HttpOnly cookies for browser clients while maintaining + backward compatibility for mobile/API clients via JSON response body. """ permission_classes = (AllowAny,) + def post(self, request, *args, **kwargs): + """Override to set tokens in HttpOnly cookies after successful login.""" + response = super().post(request, *args, **kwargs) + + if response.status_code == status.HTTP_200_OK: + access_token = response.data.get("access") + refresh_token = response.data.get("refresh") + + if access_token and refresh_token: + # Set access token cookie (5 minutes) + _set_jwt_cookie( + response=response, + key="access_token", + value=access_token, + max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), + ) + + # Set refresh token cookie (7 days) + _set_jwt_cookie( + response=response, + key="refresh_token", + value=refresh_token, + max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), + ) + + return response + class RefreshView(TokenRefreshView): - """Refresh an expired access token using a valid refresh token.""" + """Refresh an expired access token using a valid refresh token. + + Accepts refresh token from cookie or JSON body. Returns new access token + in both HttpOnly cookie and JSON response body. + """ + + permission_classes = (AllowAny,) + + def post(self, request, *args, **kwargs): + """Override to handle refresh token from cookie if not in request body.""" + # If refresh token is not in the request body, try to get it from cookies + if "refresh" not in request.data and "refresh_token" in request.COOKIES: + # Check if request.data is a QueryDict (from form data) or dict (from JSON) + if hasattr(request.data, "_mutable"): + request.data._mutable = True + request.data["refresh"] = request.COOKIES["refresh_token"] + request.data._mutable = False + else: + # For dict/other types, just add the key + request.data["refresh"] = request.COOKIES["refresh_token"] + + response = super().post(request, *args, **kwargs) + + if response.status_code == status.HTTP_200_OK: + access_token = response.data.get("access") + + if access_token: + # Set new access token cookie (5 minutes) + _set_jwt_cookie( + response=response, + key="access_token", + value=access_token, + max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), + ) + + return response + + +class LogoutView(APIView): + """Logout and clear authentication cookies. + + POST endpoint that clears access_token and refresh_token cookies. + Can be called by authenticated users or as a public endpoint. + """ permission_classes = (AllowAny,) + def post(self, request): + """Clear authentication cookies on logout.""" + response = Response( + {"detail": "Successfully logged out."}, + status=status.HTTP_200_OK + ) + + # Clear the access token cookie + _clear_jwt_cookie(response, "access_token") + + # Clear the refresh token cookie + _clear_jwt_cookie(response, "refresh_token") + + return response + class MeView(APIView): """Current user profile with tenant context. @@ -141,7 +257,7 @@ def post(self, request): refresh = RefreshToken.for_user(user) memberships = memberships_payload_for_user(user) - return Response( + response = Response( { "access": str(refresh.access_token), "refresh": str(refresh), @@ -157,6 +273,19 @@ def post(self, request): }, status=status.HTTP_201_CREATED, ) + _set_jwt_cookie( + response=response, + key="access_token", + value=str(refresh.access_token), + max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), + ) + _set_jwt_cookie( + response=response, + key="refresh_token", + value=str(refresh), + max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), + ) + return response @extend_schema_view( @@ -276,9 +405,7 @@ def post(self, request): }, ) - real_refresh = RefreshToken.for_user(real_user) - - return Response( + response = Response( { "access": str(refresh.access_token), "refresh": str(refresh), @@ -293,12 +420,23 @@ def post(self, request): "memberships": memberships, "impersonation": { "real_user": UserSerializer(real_user).data, - "real_access_token": str(real_refresh.access_token), - "real_refresh_token": str(real_refresh), }, }, status=status.HTTP_200_OK, ) + _set_jwt_cookie( + response=response, + key="access_token", + value=str(refresh.access_token), + max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), + ) + _set_jwt_cookie( + response=response, + key="refresh_token", + value=str(refresh), + max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), + ) + return response @extend_schema_view( @@ -401,7 +539,21 @@ def post(self, request): }, ) - return Response( - {"detail": "Impersonation ended. Restore your tokens to resume as yourself."}, + real_refresh = RefreshToken.for_user(real_user) + response = Response( + {"detail": "Impersonation ended. Restored your original session."}, status=status.HTTP_200_OK, ) + _set_jwt_cookie( + response=response, + key="access_token", + value=str(real_refresh.access_token), + max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), + ) + _set_jwt_cookie( + response=response, + key="refresh_token", + value=str(real_refresh), + max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), + ) + return response diff --git a/src/api/views/invitations.py b/src/api/views/invitations.py index e457375..05b0ed5 100644 --- a/src/api/views/invitations.py +++ b/src/api/views/invitations.py @@ -52,6 +52,24 @@ User = get_user_model() +def _jwt_cookie_security() -> tuple[bool, str]: + secure = getattr(django_settings, "JWT_COOKIE_SECURE", not django_settings.DEBUG) + samesite = getattr(django_settings, "JWT_COOKIE_SAMESITE", "Lax") + return secure, samesite + + +def _set_jwt_cookie(response: Response, key: str, value: str, max_age: int) -> None: + secure, samesite = _jwt_cookie_security() + response.set_cookie( + key, + value=value, + max_age=max_age, + httponly=True, + secure=secure, + samesite=samesite, + ) + + class InvitationListCreateView(ListAPIView): """List or create invitations for the current tenant. @@ -214,7 +232,7 @@ def post(self, request, token): ) refresh = RefreshToken.for_user(user) - return Response( + response = Response( { "detail": f"Welcome to {invitation.tenant.name}!", "access": str(refresh.access_token), @@ -237,6 +255,19 @@ def post(self, request, token): }, status=status.HTTP_200_OK, ) + _set_jwt_cookie( + response=response, + key="access_token", + value=str(refresh.access_token), + max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), + ) + _set_jwt_cookie( + response=response, + key="refresh_token", + value=str(refresh), + max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), + ) + return response class PlatformInvitationFilter(FilterSet): diff --git a/src/the_inventory/settings/base.py b/src/the_inventory/settings/base.py index c603e71..3d566a6 100644 --- a/src/the_inventory/settings/base.py +++ b/src/the_inventory/settings/base.py @@ -267,7 +267,7 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication", + "api.authentication.CookieJWTAuthentication", "rest_framework.authentication.TokenAuthentication", "rest_framework.authentication.SessionAuthentication", ], @@ -286,7 +286,7 @@ # SimpleJWT SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), "REFRESH_TOKEN_LIFETIME": timedelta(days=7), "ROTATE_REFRESH_TOKENS": True, "BLACKLIST_AFTER_ROTATION": False, @@ -341,11 +341,17 @@ else: CSRF_TRUSTED_ORIGINS = list(CORS_ALLOWED_ORIGINS) -# Cookie / CSRF flags for cross-site frontends (e.g. SPA on another domain over HTTPS). -SESSION_COOKIE_SAMESITE = env_str("SESSION_COOKIE_SAMESITE", "Lax") or "Lax" -SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False) -CSRF_COOKIE_SAMESITE = env_str("CSRF_COOKIE_SAMESITE", "Lax") or "Lax" -CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False) +# JWT cookie configuration for browser auth flows. +JWT_COOKIE_SAMESITE = env_str("JWT_COOKIE_SAMESITE", "Lax") or "Lax" +JWT_COOKIE_SECURE = env_bool("JWT_COOKIE_SECURE", False) +JWT_ACCESS_TOKEN_COOKIE_MAX_AGE = env_int( + "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", + int(SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds()), +) +JWT_REFRESH_TOKEN_COOKIE_MAX_AGE = env_int( + "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", + int(SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"].total_seconds()), +) # drf-spectacular (OpenAPI) diff --git a/src/the_inventory/settings/dev.py b/src/the_inventory/settings/dev.py index 5ac74d6..c5d30d1 100644 --- a/src/the_inventory/settings/dev.py +++ b/src/the_inventory/settings/dev.py @@ -11,6 +11,8 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +# JWT cookie defaults come from base.py (inherits session defaults in dev). + # Reset thread-local tenant between tests (see tests/runner.py). TEST_RUNNER = "tests.runner.DiscoverRunner" diff --git a/src/the_inventory/settings/production.py b/src/the_inventory/settings/production.py index 199caa8..e4bb66a 100644 --- a/src/the_inventory/settings/production.py +++ b/src/the_inventory/settings/production.py @@ -134,9 +134,8 @@ def _normalize_allowed_host(raw: str) -> str: if env_bool("USE_X_FORWARDED_PROTO", True): SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -# Secure cookies by default in production; set SESSION_COOKIE_SECURE=false to disable. -SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", True) -CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", True) +# JWT cookies should be secure by default in production. +JWT_COOKIE_SECURE = env_bool("JWT_COOKIE_SECURE", True) try: from .local import * # noqa: F403,F401 diff --git a/tests/api/test_auth_api.py b/tests/api/test_auth_api.py index 129ebaa..eb55ec1 100644 --- a/tests/api/test_auth_api.py +++ b/tests/api/test_auth_api.py @@ -123,6 +123,44 @@ def test_login_succeeds_for_member_who_is_also_superuser(self): self.assertIs(response.data["user"]["is_superuser"], True) self.assertNotIn("is_staff", response.data["user"]) + def test_login_sets_access_token_cookie(self): + """Verify that login response sets HttpOnly access_token cookie.""" + response = self.client.post( + reverse("api-login"), + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access_token", response.cookies) + cookie = response.cookies["access_token"] + self.assertTrue(cookie["httponly"]) + self.assertEqual(cookie["samesite"], "Lax") + + def test_login_sets_refresh_token_cookie(self): + """Verify that login response sets HttpOnly refresh_token cookie.""" + response = self.client.post( + reverse("api-login"), + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("refresh_token", response.cookies) + cookie = response.cookies["refresh_token"] + self.assertTrue(cookie["httponly"]) + self.assertEqual(cookie["samesite"], "Lax") + + def test_login_keeps_tokens_in_response_body(self): + """Verify backward compatibility: tokens still in JSON response.""" + response = self.client.post( + reverse("api-login"), + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Tokens must still be in response body for mobile/API clients + self.assertIn("access", response.data) + self.assertIn("refresh", response.data) + class AuthRefreshTests(TestCase): def setUp(self): @@ -178,6 +216,137 @@ def test_refresh_rejects_when_last_membership_removed(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(response.data["code"], "no_tenant_membership") + def test_refresh_accepts_refresh_token_from_cookie(self): + """Verify that refresh endpoint accepts refresh token from cookie.""" + # Login to get both tokens + login_response = self.client.post( + reverse("api-login"), + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + + # Extract token from cookie instead of body + self.assertIn("refresh_token", login_response.cookies) + + # Create a new client and set the cookie + new_client = APIClient() + new_client.cookies["refresh_token"] = login_response.cookies["refresh_token"].value + + # Call refresh without providing refresh token in body + response = new_client.post( + reverse("api-token-refresh"), + {}, # Empty body + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access", response.data) + + def test_refresh_sets_access_token_cookie(self): + """Verify that refresh endpoint returns new access token in cookie.""" + login_response = self.client.post( + reverse("api-login"), + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + refresh_token = login_response.data["refresh"] + + response = self.client.post( + reverse("api-token-refresh"), + {"refresh": refresh_token}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Verify new access token is in cookie + self.assertIn("access_token", response.cookies) + cookie = response.cookies["access_token"] + self.assertTrue(cookie["httponly"]) + self.assertEqual(cookie["samesite"], "Lax") + + def test_refresh_keeps_token_in_response_body(self): + """Verify backward compatibility: access token still in JSON response.""" + login_response = self.client.post( + reverse("api-login"), + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + refresh_token = login_response.data["refresh"] + + response = self.client.post( + reverse("api-token-refresh"), + {"refresh": refresh_token}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Token must still be in response body for mobile/API clients + self.assertIn("access", response.data) + + +class LogoutTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="testuser", + password="testpass123", + email="test@test.com", + is_staff=True, + ) + self.tenant = create_tenant(name="Logout Org", slug="logout-org") + TenantMembership.objects.create( + tenant=self.tenant, + user=self.user, + role=TenantRole.COORDINATOR, + is_active=True, + is_default=True, + ) + + def test_logout_clears_access_token_cookie(self): + """Verify that logout clears the access_token cookie.""" + # Login first + login_response = self.client.post( + reverse("api-login"), + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + self.assertIn("access_token", login_response.cookies) + + # Call logout + response = self.client.post(reverse("api-logout")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify cookie is cleared (max_age or expires should be set to immediate deletion) + self.assertIn("access_token", response.cookies) + self.assertEqual(response.cookies["access_token"].value, "") + + def test_logout_clears_refresh_token_cookie(self): + """Verify that logout clears the refresh_token cookie.""" + # Login first + login_response = self.client.post( + reverse("api-login"), + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + self.assertIn("refresh_token", login_response.cookies) + + # Call logout + response = self.client.post(reverse("api-logout")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify cookie is cleared + self.assertIn("refresh_token", response.cookies) + self.assertEqual(response.cookies["refresh_token"].value, "") + + def test_logout_returns_success_message(self): + """Verify that logout returns a success response.""" + response = self.client.post(reverse("api-logout")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("detail", response.data) + self.assertEqual(response.data["detail"], "Successfully logged out.") + class MeEndpointTests(TestCase): def setUp(self): @@ -526,3 +695,84 @@ def test_end_when_operator_demoted_returns_403(self): ) self.assertEqual(end.status_code, status.HTTP_403_FORBIDDEN) self.assertEqual(end.data.get("code"), "impersonation_invalid_operator") + +class CookieAuthenticationMiddlewareTests(TestCase): + """Test that middleware correctly authenticates requests with cookies (COOKIE-02).""" + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="middlewareuser", + password="testpass123", + email="middleware@test.com", + is_staff=True, + ) + self.tenant = create_tenant(name="Middleware Org", slug="middleware-org") + TenantMembership.objects.create( + tenant=self.tenant, + user=self.user, + role=TenantRole.COORDINATOR, + is_active=True, + is_default=True, + ) + + def _get_tokens(self): + """Helper to get access and refresh tokens from login.""" + response = self.client.post( + reverse("api-login"), + {"username": "middlewareuser", "password": "testpass123"}, + format="json", + ) + return response.data["access"], response.data["refresh"] + + def test_middleware_authenticates_request_with_cookie(self): + """Verify middleware correctly sets request.user from access_token cookie.""" + access_token, _ = self._get_tokens() + + # Create new client with cookie set + cookie_client = APIClient(enforce_csrf_checks=False) + cookie_client.cookies.load({"access_token": access_token}) + + # Make request to protected endpoint + response = cookie_client.get(reverse("api-me")) + + # Should be authenticated via middleware cookie handling + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["user"]["username"], "middlewareuser") + + def test_middleware_header_takes_precedence_over_cookie(self): + """Verify middleware prefers header auth over cookie auth.""" + # Create second user + user2 = User.objects.create_user( + username="headeruser", + password="testpass123", + email="header@test.com", + is_staff=True, + ) + TenantMembership.objects.create( + tenant=self.tenant, + user=user2, + role=TenantRole.COORDINATOR, + is_active=True, + is_default=True, + ) + + # Get tokens for both users + token1, _ = self._get_tokens() + + response2 = self.client.post( + reverse("api-login"), + {"username": "headeruser", "password": "testpass123"}, + format="json", + ) + token2 = response2.data["access"] + + # Set cookie to token1 and header to token2 + cookie_client = APIClient(enforce_csrf_checks=False) + cookie_client.cookies.load({"access_token": token1}) + cookie_client.credentials(HTTP_AUTHORIZATION=f"Bearer {token2}") + + # Should use header token (token2 = headeruser) + response = cookie_client.get(reverse("api-me")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["user"]["username"], "headeruser") \ No newline at end of file diff --git a/tests/api/test_cookie_auth.py b/tests/api/test_cookie_auth.py new file mode 100644 index 0000000..af465a7 --- /dev/null +++ b/tests/api/test_cookie_auth.py @@ -0,0 +1,209 @@ +"""Tests for cookie-based JWT authentication (COOKIE-02).""" + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from tenants.models import TenantMembership, TenantRole +from tests.fixtures.factories import create_tenant + +User = get_user_model() + + +class CookieAuthenticationTests(TestCase): + """Test JWT authentication using HttpOnly cookies.""" + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username="cookieuser", + password="testpass123", + email="cookie@test.com", + first_name="Cookie", + last_name="Tester", + is_staff=True, + ) + self.tenant = create_tenant(name="Cookie Org", slug="cookie-org") + TenantMembership.objects.create( + tenant=self.tenant, + user=self.user, + role=TenantRole.COORDINATOR, + is_active=True, + is_default=True, + ) + + def test_login_sets_access_token_cookie(self): + """Verify login endpoint sets access_token cookie.""" + response = self.client.post( + reverse("api-login"), + {"username": "cookieuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify cookie is set + self.assertIn("access_token", response.cookies) + access_token_cookie = response.cookies.get("access_token") + self.assertIsNotNone(access_token_cookie.value) + + # Verify cookie attributes (HttpOnly, Secure, SameSite) + self.assertTrue(access_token_cookie["httponly"]) + self.assertEqual(access_token_cookie["samesite"], "Lax") + + # Verify token is also in response body (backward compatibility) + self.assertIn("access", response.data) + + def test_login_sets_refresh_token_cookie(self): + """Verify login endpoint sets refresh_token cookie.""" + response = self.client.post( + reverse("api-login"), + {"username": "cookieuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify cookie is set + self.assertIn("refresh_token", response.cookies) + refresh_token_cookie = response.cookies.get("refresh_token") + self.assertIsNotNone(refresh_token_cookie.value) + + # Verify cookie attributes + self.assertTrue(refresh_token_cookie["httponly"]) + self.assertEqual(refresh_token_cookie["samesite"], "Lax") + + # Verify token is also in response body (backward compatibility) + self.assertIn("refresh", response.data) + + def test_authenticated_request_with_cookie(self): + """Verify request with access_token cookie is authenticated.""" + # Login and get cookies + login_response = self.client.post( + reverse("api-login"), + {"username": "cookieuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + + # Create a new client and manually set cookies (simulating browser behavior) + # The APIClient handles cookies automatically in subsequent requests + cookie_client = APIClient(enforce_csrf_checks=False) + + # Make request to /me/ without explicit Authorization header, but with cookies + response = cookie_client.get(reverse("api-me")) + + # Without authentication, should get 401 + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Now set cookie and try again + access_token = login_response.data["access"] + cookie_client.cookies.load({"access_token": access_token}) + + response = cookie_client.get(reverse("api-me")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("user", response.data) + self.assertEqual(response.data["user"]["username"], "cookieuser") + + def test_header_auth_takes_precedence_over_cookie(self): + """Verify Authorization header takes precedence over cookie.""" + # Create two users + user2 = User.objects.create_user( + username="headeruser", + password="testpass123", + email="header@test.com", + is_staff=True, + ) + TenantMembership.objects.create( + tenant=self.tenant, + user=user2, + role=TenantRole.COORDINATOR, + is_active=True, + is_default=True, + ) + + # Get tokens for both users + response1 = self.client.post( + reverse("api-login"), + {"username": "cookieuser", "password": "testpass123"}, + format="json", + ) + token1 = response1.data["access"] + + response2 = self.client.post( + reverse("api-login"), + {"username": "headeruser", "password": "testpass123"}, + format="json", + ) + token2 = response2.data["access"] + + # Set cookie to token1 and header to token2 + cookie_client = APIClient(enforce_csrf_checks=False) + cookie_client.cookies.load({"access_token": token1}) + cookie_client.credentials(HTTP_AUTHORIZATION=f"Bearer {token2}") + + # Should use header token (token2 = headeruser) + response = cookie_client.get(reverse("api-me")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["user"]["username"], "headeruser") + + def test_refresh_with_cookie(self): + """Verify refresh token from cookie works.""" + # Login + login_response = self.client.post( + reverse("api-login"), + {"username": "cookieuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + refresh_token = login_response.data["refresh"] + + # Create new client and set cookies + cookie_client = APIClient(enforce_csrf_checks=False) + cookie_client.cookies.load({"refresh_token": refresh_token}) + + # Refresh without sending refresh token in body (relies on cookie) + response = cookie_client.post( + reverse("api-token-refresh"), + {}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("access", response.data) + + # New access token should be in cookie + self.assertIn("access_token", response.cookies) + + def test_invalid_cookie_token_ignored_gracefully(self): + """Verify invalid cookie tokens don't cause errors.""" + cookie_client = APIClient(enforce_csrf_checks=False) + cookie_client.cookies.load({"access_token": "invalid.token.here"}) + + # Should return 401, not 500 + response = cookie_client.get(reverse("api-me")) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_header_still_works_with_new_authenticator(self): + """Verify header-based JWT still works (backward compatibility).""" + # Login to get token + login_response = self.client.post( + reverse("api-login"), + {"username": "cookieuser", "password": "testpass123"}, + format="json", + ) + self.assertEqual(login_response.status_code, status.HTTP_200_OK) + token = login_response.data["access"] + + # Use header authentication (traditional method) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}") + response = self.client.get(reverse("api-me")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["user"]["username"], "cookieuser") + + def test_no_auth_returns_401(self): + """Verify protected endpoints require authentication.""" + # Fresh client with no auth + client = APIClient() + response = client.get(reverse("api-me")) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/tests/api/test_dashboard_api.py b/tests/api/test_dashboard_api.py index 591a75a..e9de41e 100644 --- a/tests/api/test_dashboard_api.py +++ b/tests/api/test_dashboard_api.py @@ -20,6 +20,7 @@ ) from tenants.models import TenantRole from tests.fixtures.factories import create_membership, create_tenant +from tenants.context import set_current_tenant User = get_user_model() @@ -36,6 +37,7 @@ def setUp(self): is_staff=True, ) self.tenant = create_tenant() + set_current_tenant(self.tenant) create_membership( tenant=self.tenant, user=self.user, @@ -187,6 +189,7 @@ def test_order_status(self): def test_unauthenticated(self): self.client.credentials() + self.client.cookies.clear() for url_name in [ "api-dashboard-summary", "api-dashboard-reservations", @@ -212,6 +215,7 @@ def setUp(self): is_staff=True, ) self.tenant = create_tenant(name="Reservation Corp", slug="reservation-corp") + set_current_tenant(self.tenant) create_membership( tenant=self.tenant, user=self.user, @@ -288,6 +292,7 @@ def setUp(self): is_staff=True, ) self.tenant = create_tenant(name="Lot Corp", slug="lot-corp") + set_current_tenant(self.tenant) create_membership( tenant=self.tenant, user=self.user, diff --git a/tests/api/test_import_api.py b/tests/api/test_import_api.py index 991ca96..1231c40 100644 --- a/tests/api/test_import_api.py +++ b/tests/api/test_import_api.py @@ -119,6 +119,7 @@ def test_unauthenticated(self): content_type="text/csv", ) self.client.credentials() + self.client.cookies.clear() response = self.client.post( self.url, {"data_type": "products", "file": file_obj}, diff --git a/tests/api/test_language_parameter.py b/tests/api/test_language_parameter.py index 7ca6508..adc4657 100644 --- a/tests/api/test_language_parameter.py +++ b/tests/api/test_language_parameter.py @@ -25,6 +25,7 @@ create_user, ) from tenants.models import TenantRole +from tenants.context import set_current_tenant User = get_user_model() @@ -174,6 +175,7 @@ def setUpTestData(cls): def setUp(self): self.tenant = create_tenant(name="Serializer I18N Tenant") + set_current_tenant(self.tenant) fr_locale = Locale.objects.get(language_code="fr") self.p_en = create_product(sku="SER-I18N", name="English line", tenant=self.tenant) p_fr = self.p_en.copy_for_translation(fr_locale) @@ -267,6 +269,7 @@ def setUp(self): self.tenant_es = create_tenant( name="Tenant ES", slug="tenant-es-pref", preferred_language="es", ) + set_current_tenant(self.tenant_fr) fr_loc = Locale.objects.get(language_code="fr") es_loc = Locale.objects.get(language_code="es") @@ -288,6 +291,7 @@ def setUp(self): p_fr_en.name = "French name in English row" p_fr_en.save() + set_current_tenant(self.tenant_es) self.p_es = create_product( sku="DUAL-SKU-ES", name="Nombre español", diff --git a/tests/api/test_reports_api.py b/tests/api/test_reports_api.py index c7eb44d..7155389 100644 --- a/tests/api/test_reports_api.py +++ b/tests/api/test_reports_api.py @@ -21,6 +21,7 @@ from tests.fixtures.factories import create_customer, create_sales_order from tenants.models import TenantRole from tests.fixtures.factories import create_membership, create_tenant +from tenants.context import set_current_tenant User = get_user_model() @@ -36,6 +37,7 @@ def setUp(self): is_staff=True, ) self.tenant = create_tenant() + set_current_tenant(self.tenant) create_membership( tenant=self.tenant, user=self.user, @@ -118,6 +120,7 @@ def test_sales_summary_json(self): def test_unauthenticated_returns_401(self): self.client.credentials() + self.client.cookies.clear() url = reverse("api-stock-valuation") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) @@ -236,6 +239,7 @@ def setUp(self): is_staff=True, ) self.tenant = create_tenant() + set_current_tenant(self.tenant) create_membership( tenant=self.tenant, user=self.user, @@ -383,6 +387,7 @@ def test_nonexistent_lot_returns_404(self): def test_unauthenticated_returns_401(self): self.client.credentials() + self.client.cookies.clear() response = self.client.get(self.url, {"product": "X", "lot": "Y"}) self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/tests/api/test_wagtail_locales_api.py b/tests/api/test_wagtail_locales_api.py index 76efae4..fa28591 100644 --- a/tests/api/test_wagtail_locales_api.py +++ b/tests/api/test_wagtail_locales_api.py @@ -3,11 +3,13 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient +from wagtail.models import Locale class WagtailLocalesApiTests(TestCase): def setUp(self): self.client = APIClient() + Locale.objects.get_or_create(language_code="fr") def test_list_locales_public(self): response = self.client.get("/api/v1/locales/") diff --git a/tests/integration/test_i18n_models.py b/tests/integration/test_i18n_models.py index dcf376f..924cdf9 100644 --- a/tests/integration/test_i18n_models.py +++ b/tests/integration/test_i18n_models.py @@ -7,6 +7,7 @@ from inventory.models import Category, Product from inventory.services.localization import copy_catalog_row_for_locale from tests.fixtures.factories import create_category, create_product, create_tenant +from tenants.context import set_current_tenant class ProductTranslationIntegrationTests(TestCase): @@ -18,6 +19,7 @@ def setUpTestData(cls): def setUp(self): self.tenant = create_tenant(name="I18N Integration Tenant") + set_current_tenant(self.tenant) self.fr_locale = Locale.objects.get(language_code="fr") def test_copy_for_translation_links_and_get_translation_or_none(self): @@ -54,6 +56,7 @@ def setUpTestData(cls): def setUp(self): self.tenant = create_tenant(name="I18N Category Tenant") + set_current_tenant(self.tenant) self.fr_locale = Locale.objects.get(language_code="fr") def test_category_translation_shares_translation_key(self): diff --git a/tests/reports/test_services/test_variance_and_cycle_reports.py b/tests/reports/test_services/test_variance_and_cycle_reports.py index 82c188d..07ce43a 100644 --- a/tests/reports/test_services/test_variance_and_cycle_reports.py +++ b/tests/reports/test_services/test_variance_and_cycle_reports.py @@ -16,6 +16,7 @@ create_user, ) from reports.services.inventory_reports import InventoryReportService +from tenants.context import set_current_tenant class VarianceReportSetupMixin: @@ -24,6 +25,7 @@ class VarianceReportSetupMixin: def setUp(self): self.service = InventoryReportService() self.tenant = create_tenant() + set_current_tenant(self.tenant) self.warehouse = create_location(name="Warehouse", tenant=self.tenant) self.store = create_location(name="Store", tenant=self.tenant) self.user = create_user(username="counter") From afd2446c011a1582979fd39ba04d846797bec352 Mon Sep 17 00:00:00 2001 From: Ndevu12 Date: Wed, 25 Mar 2026 19:27:55 +0000 Subject: [PATCH 2/4] feat(auth): enhance authentication flow with cookie management and locale parsing - Implemented cookie-based JWT access management in middleware to enforce authentication on protected routes. - Added utility functions for parsing locale paths and determining access requirements based on JWT presence. - Updated the `AuthGuard` tests to reflect changes in authentication logic and error handling. - Introduced new tests for authentication path utilities to ensure correct locale handling and access checks. - Refactored the `AuthProvider` to support server-side authentication state hydration and improved state synchronization. - Enhanced the `Providers` component to manage dehydrated state for better performance in server-rendered contexts. --- .../features/auth/auth-guard.test.tsx | 11 +-- .../dashboard/dashboard-page.test.tsx | 2 - frontend/__tests__/lib/auth-paths.test.ts | 43 ++++++++++ frontend/middleware.ts | 21 +++++ frontend/src/app/[locale]/layout.tsx | 26 +++++- frontend/src/features/auth/auth-query-keys.ts | 4 + .../features/auth/context/auth-context.tsx | 81 ++++++++++++------- frontend/src/features/auth/hooks/use-auth.ts | 32 +++----- .../src/features/auth/lib/sync-me-to-store.ts | 13 +++ frontend/src/lib/auth-paths.ts | 80 ++++++++++++++++++ frontend/src/lib/providers.tsx | 60 +++++++++++--- frontend/src/lib/server/fetch-auth-me.ts | 45 +++++++++++ tests/home/test_wagtail_locales_seed.py | 4 + 13 files changed, 348 insertions(+), 74 deletions(-) create mode 100644 frontend/__tests__/lib/auth-paths.test.ts create mode 100644 frontend/src/features/auth/auth-query-keys.ts create mode 100644 frontend/src/features/auth/lib/sync-me-to-store.ts create mode 100644 frontend/src/lib/auth-paths.ts create mode 100644 frontend/src/lib/server/fetch-auth-me.ts diff --git a/frontend/__tests__/features/auth/auth-guard.test.tsx b/frontend/__tests__/features/auth/auth-guard.test.tsx index 5c9c0a9..57a7527 100644 --- a/frontend/__tests__/features/auth/auth-guard.test.tsx +++ b/frontend/__tests__/features/auth/auth-guard.test.tsx @@ -57,7 +57,9 @@ describe("AuthGuard", () => { resetClientTestState(); }); - it("redirects to login when there is no token", async () => { + it("redirects to login when auth bootstrap fails", async () => { + bootstrapProbe.isFetched = true; + bootstrapProbe.isError = true; useAuthStore.setState({ _hasHydrated: true, }); @@ -78,8 +80,6 @@ describe("AuthGuard", () => { bootstrapProbe.isFetched = true; bootstrapProbe.isError = false; useAuthStore.setState({ - accessToken: "access", - refreshToken: "refresh", user: { id: 1, username: "solo", @@ -109,8 +109,6 @@ describe("AuthGuard", () => { bootstrapProbe.isFetched = true; bootstrapProbe.isError = false; useAuthStore.setState({ - accessToken: "valid-access-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - refreshToken: "refresh", user: { id: 1, username: "member", @@ -148,9 +146,6 @@ describe("AuthGuard", () => { bootstrapProbe.isFetched = true; bootstrapProbe.isError = false; useAuthStore.setState({ - accessToken: - "valid-access-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy", - refreshToken: "refresh", user: { id: 1, username: "dual", diff --git a/frontend/__tests__/features/dashboard/dashboard-page.test.tsx b/frontend/__tests__/features/dashboard/dashboard-page.test.tsx index 8fa6948..102bdc3 100644 --- a/frontend/__tests__/features/dashboard/dashboard-page.test.tsx +++ b/frontend/__tests__/features/dashboard/dashboard-page.test.tsx @@ -35,8 +35,6 @@ describe("DashboardPage rendering", () => { beforeEach(() => { resetClientTestState(); useAuthStore.setState({ - accessToken: "fake-access", - refreshToken: "fake-refresh", user: { id: 1, username: "demo", diff --git a/frontend/__tests__/lib/auth-paths.test.ts b/frontend/__tests__/lib/auth-paths.test.ts new file mode 100644 index 0000000..913e27c --- /dev/null +++ b/frontend/__tests__/lib/auth-paths.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + isPublicAuthPath, + parseLocalePath, + requiresJwtAccessCookie, +} from "@/lib/auth-paths"; + +describe("auth-paths", () => { + it("parseLocalePath strips known locale prefix", () => { + expect(parseLocalePath("/en/login")).toEqual({ + locale: "en", + innerPath: "/login", + }); + expect(parseLocalePath("/fr")).toEqual({ locale: "fr", innerPath: "/" }); + expect(parseLocalePath("/")).toEqual({ + locale: "en", + innerPath: "/", + }); + }); + + it("parseLocalePath treats unknown first segment as locale-free path", () => { + expect(parseLocalePath("/login")).toEqual({ + locale: "en", + innerPath: "/login", + }); + }); + + it("isPublicAuthPath covers auth and invitation flows", () => { + expect(isPublicAuthPath("/login")).toBe(true); + expect(isPublicAuthPath("/register")).toBe(true); + expect(isPublicAuthPath("/no-organization")).toBe(true); + expect(isPublicAuthPath("/accept-invitation")).toBe(true); + expect(isPublicAuthPath("/accept-invitation/abc")).toBe(true); + expect(isPublicAuthPath("/login/")).toBe(true); + }); + + it("requiresJwtAccessCookie is the complement on dashboard paths", () => { + expect(requiresJwtAccessCookie("/")).toBe(true); + expect(requiresJwtAccessCookie("/products")).toBe(true); + expect(requiresJwtAccessCookie("/login")).toBe(false); + }); +}); diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 6a2fc1e..633367f 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -1,14 +1,35 @@ import createMiddleware from "next-intl/middleware"; import { type NextRequest, NextResponse } from "next/server"; +import { + JWT_ACCESS_COOKIE_NAME, + parseLocalePath, + requiresJwtAccessCookie, +} from "./src/lib/auth-paths"; import { routing } from "./src/i18n/routing"; const intlMiddleware = createMiddleware(routing); +function hasNonEmptyAccessCookie(request: NextRequest): boolean { + const v = request.cookies.get(JWT_ACCESS_COOKIE_NAME)?.value; + return typeof v === "string" && v.length > 0; +} + export function middleware(request: NextRequest) { if (request.nextUrl.pathname.startsWith("/api")) { return NextResponse.next(); } + + const { locale, innerPath } = parseLocalePath(request.nextUrl.pathname); + if ( + requiresJwtAccessCookie(innerPath) && + !hasNonEmptyAccessCookie(request) + ) { + const url = request.nextUrl.clone(); + url.pathname = `/${locale}/login`; + return NextResponse.redirect(url); + } + return intlMiddleware(request); } diff --git a/frontend/src/app/[locale]/layout.tsx b/frontend/src/app/[locale]/layout.tsx index 6a8b27b..d80b45b 100644 --- a/frontend/src/app/[locale]/layout.tsx +++ b/frontend/src/app/[locale]/layout.tsx @@ -1,10 +1,15 @@ import type { Metadata } from "next"; +import { cookies } from "next/headers"; import { notFound } from "next/navigation"; import { hasLocale } from "next-intl"; import { getMessages, getTranslations, setRequestLocale } from "next-intl/server"; import { LocaleLayoutShell } from "./locale-layout-shell"; -import { Providers } from "@/lib/providers"; +import type { MeResponse } from "@/features/auth/types/auth.types"; +import { makeQueryClient } from "@/lib/query-client"; +import { dehydrateAuthMe, Providers } from "@/lib/providers"; +import { JWT_ACCESS_COOKIE_NAME } from "@/lib/auth-paths"; +import { fetchAuthMeOnServer } from "@/lib/server/fetch-auth-me"; import { APP_NAME } from "@/lib/utils/constants"; import { localeEntries, routing } from "@/i18n/routing"; @@ -46,8 +51,25 @@ export default async function LocaleLayout({ const messages = await getMessages({ locale }); const dir = textDirectionForLocale(locale); + const cookieStore = await cookies(); + const hasAccess = cookieStore.get(JWT_ACCESS_COOKIE_NAME)?.value; + let serverBootstrap: MeResponse | null = null; + if (hasAccess) { + const cookieHeader = cookieStore + .getAll() + .map((c) => `${c.name}=${c.value}`) + .join("; "); + serverBootstrap = await fetchAuthMeOnServer(cookieHeader, locale); + } + + const queryClient = makeQueryClient(); + const dehydratedState = dehydrateAuthMe(queryClient, serverBootstrap); + return ( - + {children} diff --git a/frontend/src/features/auth/auth-query-keys.ts b/frontend/src/features/auth/auth-query-keys.ts new file mode 100644 index 0000000..c717481 --- /dev/null +++ b/frontend/src/features/auth/auth-query-keys.ts @@ -0,0 +1,4 @@ +export const authKeys = { + me: ["auth", "me"] as const, + config: ["auth", "config"] as const, +}; diff --git a/frontend/src/features/auth/context/auth-context.tsx b/frontend/src/features/auth/context/auth-context.tsx index fbb8c40..40da397 100644 --- a/frontend/src/features/auth/context/auth-context.tsx +++ b/frontend/src/features/auth/context/auth-context.tsx @@ -1,18 +1,25 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { createContext, useCallback, useContext, useEffect, + useMemo, + useRef, useState, } from "react"; import { useAuthStore } from "@/lib/auth-store"; import type { User, Membership } from "@/lib/auth-store"; +import { authKeys } from "../auth-query-keys"; +import type { MeResponse } from "../types/auth.types"; +import { syncMeResponseToStore } from "../lib/sync-me-to-store"; + interface AuthContextValue { - /** True only after client mount + persist rehydration. Gates all auth-dependent UI. */ + /** True after Zustand persist rehydration and optional RSC `/auth/me/` merge. */ isReady: boolean; user: User | null; tenantSlug: string | null; @@ -23,7 +30,7 @@ interface AuthContextValue { isImpersonating: boolean; /** Call to log out and redirect to login. Clears store + query cache. */ logout: () => void; - /** Force a re-check of auth state (used after login/register). */ + /** Refetch `/auth/me/` (credentials + automatic refresh on 401 via apiClient). */ invalidate: () => void; } @@ -39,15 +46,37 @@ export function useAuth(): AuthContextValue { interface AuthProviderProps { children: React.ReactNode; + /** + * RSC bootstrap from GET /auth/me/ (cookies forwarded). `undefined` = skip server snapshot (e.g. tests). + * `null` = no session or failed verify — overrides stale persisted user after rehydrate. + */ + serverBootstrap?: MeResponse | null; } /** - * AuthProvider gates rendering until auth state is ready (client-mounted + Zustand persist rehydrated). - * This prevents hydration mismatches and redirect loops by never rendering auth-dependent UI - * until we know the true auth state from localStorage. + * AuthProvider gates UI until Zustand persist has rehydrated, then applies the optional RSC + * `serverBootstrap` so HttpOnly-cookie truth wins over short-lived persisted user snapshot. */ -export function AuthProvider({ children }: AuthProviderProps) { - const [isReady, setIsReady] = useState(false); +export function AuthProvider({ + children, + serverBootstrap, +}: AuthProviderProps) { + /** Bumped after rehydration + optional RSC merge so `isReady` does not flicker. */ + const [readyNonce, setReadyNonce] = useState(0); + const queryClient = useQueryClient(); + const serverBootstrapRef = useRef(serverBootstrap); + serverBootstrapRef.current = serverBootstrap; + + const serverBootstrapDigest = useMemo(() => { + if (serverBootstrap === undefined) { + return "__omit__"; + } + if (serverBootstrap === null) { + return "__null__"; + } + return JSON.stringify(serverBootstrap); + }, [serverBootstrap]); + const hasHydrated = useAuthStore((s) => s._hasHydrated); const user = useAuthStore((s) => s.user); const tenantSlug = useAuthStore((s) => s.tenantSlug); @@ -62,33 +91,29 @@ export function AuthProvider({ children }: AuthProviderProps) { }, [storeLogout]); const invalidate = useCallback(() => { - // No-op for now; store updates are synchronous. Kept for API consistency. - }, []); + void queryClient.invalidateQueries({ queryKey: authKeys.me }); + }, [queryClient]); - // Wait for client mount + Zustand persist rehydration. Never render auth UI until ready. useEffect(() => { - if (typeof window === "undefined") return; - - if (hasHydrated) { - setIsReady(true); + if (typeof window === "undefined" || !hasHydrated) { return; } - // Persist rehydration is async. Poll until _hasHydrated or max 1s. - const id = setInterval(() => { - if (useAuthStore.getState()._hasHydrated) { - setIsReady(true); + const b = serverBootstrapRef.current; + if (b !== undefined) { + if (b) { + syncMeResponseToStore(b); + } else { + useAuthStore.getState().logout(); } - }, 50); - const timeout = setTimeout(() => { - clearInterval(id); - setIsReady(true); - }, 1000); - return () => { - clearInterval(id); - clearTimeout(timeout); - }; - }, [hasHydrated]); + } + + queueMicrotask(() => { + setReadyNonce((n) => n + 1); + }); + }, [hasHydrated, serverBootstrapDigest]); + + const isReady = hasHydrated && readyNonce > 0; const value: AuthContextValue = { isReady, diff --git a/frontend/src/features/auth/hooks/use-auth.ts b/frontend/src/features/auth/hooks/use-auth.ts index ef94f68..fd73e13 100644 --- a/frontend/src/features/auth/hooks/use-auth.ts +++ b/frontend/src/features/auth/hooks/use-auth.ts @@ -6,6 +6,9 @@ import { toast } from "sonner"; import { useAuthStore } from "@/lib/auth-store"; import * as authApi from "../api/auth-api"; +import { useAuth } from "../context/auth-context"; +import { authKeys } from "../auth-query-keys"; +import { syncMeResponseToStore } from "../lib/sync-me-to-store"; import type { LoginRequest, ChangePasswordRequest, @@ -15,20 +18,7 @@ import type { } from "../types/auth.types"; import type { ApiError } from "@/types/api-common"; -export const authKeys = { - me: ["auth", "me"] as const, - config: ["auth", "config"] as const, -}; - -/** Keep Zustand in sync with GET /auth/me/ (shared by all observers on `authKeys.me`). */ -function syncMeResponseToStore(data: MeResponse): void { - const { setUser, setTenant, setMemberships } = useAuthStore.getState(); - setUser(data.user); - if (data.tenant) { - setTenant(data.tenant.slug); - } - setMemberships(data.memberships ?? []); -} +export { authKeys }; async function fetchMeAndSyncStore(): Promise { const data = await authApi.fetchMe(); @@ -37,9 +27,9 @@ async function fetchMeAndSyncStore(): Promise { } export function useLogin() { + const { invalidate } = useAuth(); const { setUser, setTenant, setMemberships } = useAuthStore(); const router = useRouter(); - const queryClient = useQueryClient(); return useMutation({ mutationFn: (credentials: LoginRequest) => authApi.login(credentials), @@ -49,8 +39,8 @@ export function useLogin() { setTenant(data.tenant.slug); } setMemberships(data.memberships ?? []); - // Clear any stale auth/me error from previous session before navigating. - queryClient.removeQueries({ queryKey: authKeys.me }); + // Refetch `/auth/me/` so React Query drops stale errors and matches cookies + store. + invalidate(); // Defer navigation so store updates fully propagate before dashboard mounts. // Prevents race where AuthGuard reads stale/empty state and redirects back to login. setTimeout(() => router.replace("/"), 100); @@ -109,14 +99,14 @@ export function useChangePassword() { } export function useUpdateProfile() { + const { invalidate } = useAuth(); const { setUser } = useAuthStore(); - const queryClient = useQueryClient(); return useMutation({ mutationFn: (data: UpdateProfileRequest) => authApi.updateProfile(data), onSuccess: (user) => { setUser(user); - void queryClient.invalidateQueries({ queryKey: authKeys.me }); + invalidate(); toast.success("Profile updated"); }, onError: (error: unknown) => { @@ -190,9 +180,9 @@ export function useExitImpersonation() { } export function useRegister() { + const { invalidate } = useAuth(); const { setUser, setTenant, setMemberships } = useAuthStore(); const router = useRouter(); - const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: RegisterRequest) => authApi.register(payload), @@ -200,7 +190,7 @@ export function useRegister() { setUser(data.user); setTenant(data.tenant.slug); setMemberships(data.memberships); - queryClient.removeQueries({ queryKey: authKeys.me }); + invalidate(); toast.success(`Welcome! Your organization ${data.tenant.name} has been created.`); router.replace("/"); }, diff --git a/frontend/src/features/auth/lib/sync-me-to-store.ts b/frontend/src/features/auth/lib/sync-me-to-store.ts new file mode 100644 index 0000000..82fbb71 --- /dev/null +++ b/frontend/src/features/auth/lib/sync-me-to-store.ts @@ -0,0 +1,13 @@ +import { useAuthStore } from "@/lib/auth-store"; + +import type { MeResponse } from "../types/auth.types"; + +/** Keep Zustand in sync with GET /auth/me/ (shared by bootstrap + React Query). */ +export function syncMeResponseToStore(data: MeResponse): void { + const { setUser, setTenant, setMemberships } = useAuthStore.getState(); + setUser(data.user); + if (data.tenant) { + setTenant(data.tenant.slug); + } + setMemberships(data.memberships ?? []); +} diff --git a/frontend/src/lib/auth-paths.ts b/frontend/src/lib/auth-paths.ts new file mode 100644 index 0000000..b8ea236 --- /dev/null +++ b/frontend/src/lib/auth-paths.ts @@ -0,0 +1,80 @@ +import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "@/i18n/routing"; + +export const JWT_ACCESS_COOKIE_NAME = "access_token" as const; + +/** + * Path after locale segment (leading slash). Examples: `/`, `/login`, `/products`. + * When the first segment is not a known locale, `innerPath` is the full pathname. + */ +export type ParsedLocalePath = { + locale: string; + innerPath: string; +}; + +/** Paths that do not require JWT access cookie (tenant UI); prefix is without locale. */ +const PUBLIC_INNER_PATHS = new Set([ + "/login", + "/register", + "/no-organization", +]); + +function normalizeInnerPath(segments: string[]): string { + if (segments.length === 0) { + return "/"; + } + return `/${segments.join("/")}`; +} + +/** Collapse trailing slashes so `/login/` matches public list. */ +export function normalizeAuthInnerPath(innerPath: string): string { + const trimmed = innerPath.trim(); + if (trimmed === "" || trimmed === "/") { + return "/"; + } + return trimmed.replace(/\/+$/, "") || "/"; +} + +/** + * Split Next.js pathname into locale (for redirects) and inner app path for auth rules. + */ +export function parseLocalePath(pathname: string): ParsedLocalePath { + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 0) { + return { locale: DEFAULT_LOCALE, innerPath: "/" }; + } + const [first, ...rest] = segments; + if (SUPPORTED_LOCALES.includes(first)) { + return { + locale: first, + innerPath: normalizeAuthInnerPath(normalizeInnerPath(rest)), + }; + } + return { + locale: DEFAULT_LOCALE, + innerPath: normalizeAuthInnerPath(pathname || "/"), + }; +} + +/** + * True when the route is allowed without `access_token` (login, register, invite, etc.). + */ +export function isPublicAuthPath(innerPath: string): boolean { + const p = normalizeAuthInnerPath(innerPath); + if (PUBLIC_INNER_PATHS.has(p)) { + return true; + } + if ( + p === "/accept-invitation" || + p.startsWith("/accept-invitation/") + ) { + return true; + } + return false; +} + +/** + * Tenant dashboard and other authenticated UI (anything not public). + */ +export function requiresJwtAccessCookie(innerPath: string): boolean { + return !isPublicAuthPath(innerPath); +} diff --git a/frontend/src/lib/providers.tsx b/frontend/src/lib/providers.tsx index c3e9393..d7b47fc 100644 --- a/frontend/src/lib/providers.tsx +++ b/frontend/src/lib/providers.tsx @@ -1,10 +1,17 @@ "use client"; -import { QueryClientProvider } from "@tanstack/react-query"; +import { + dehydrate, + HydrationBoundary, + QueryClientProvider, + type DehydratedState, +} from "@tanstack/react-query"; import { ThemeProvider } from "next-themes"; import { Toaster } from "sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { authKeys } from "@/features/auth/auth-query-keys"; import { AuthProvider } from "@/features/auth/context/auth-context"; +import type { MeResponse } from "@/features/auth/types/auth.types"; import { makeQueryClient } from "./query-client"; let browserQueryClient: ReturnType | undefined; @@ -24,22 +31,49 @@ export function clearQueryClientCache(): void { browserQueryClient?.clear(); } -export function Providers({ children }: { children: React.ReactNode }) { +/** Build TanStack dehydrate payload for RSC (per-request QueryClient). */ +export function dehydrateAuthMe( + queryClient: ReturnType, + me: MeResponse | null, +): DehydratedState { + if (me) { + queryClient.setQueryData(authKeys.me, me); + } + return dehydrate(queryClient); +} + +export function Providers({ + children, + dehydratedState, + serverBootstrap, +}: { + children: React.ReactNode; + dehydratedState?: DehydratedState; + serverBootstrap?: MeResponse | null; +}) { const queryClient = getQueryClient(); + const tree = ( + + + {children} + + + + ); + return ( - - - {children} - - - + {dehydratedState ? ( + {tree} + ) : ( + tree + )} ); } diff --git a/frontend/src/lib/server/fetch-auth-me.ts b/frontend/src/lib/server/fetch-auth-me.ts new file mode 100644 index 0000000..f1d4023 --- /dev/null +++ b/frontend/src/lib/server/fetch-auth-me.ts @@ -0,0 +1,45 @@ +import { headers } from "next/headers"; + +import type { MeResponse } from "@/features/auth/types/auth.types"; + +/** + * Absolute API base for server-side fetch. Prefer `NEXT_PUBLIC_API_URL` when absolute; + * otherwise same-origin relative path resolved against the incoming request host. + */ +export async function getServerApiBaseUrl(): Promise { + const env = process.env.NEXT_PUBLIC_API_URL ?? "/api/v1"; + const trimmed = env.replace(/\/$/, ""); + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return trimmed; + } + const h = await headers(); + const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000"; + const proto = h.get("x-forwarded-proto") ?? "http"; + const path = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + return `${proto}://${host}${path}`; +} + +/** + * Forward browser cookies to Django for GET /auth/me/ during RSC render. + */ +export async function fetchAuthMeOnServer( + cookieHeader: string, + uiLocale: string, +): Promise { + if (!cookieHeader.trim()) { + return null; + } + const base = await getServerApiBaseUrl(); + const url = `${base}/auth/me/?language=${encodeURIComponent(uiLocale)}`; + const res = await fetch(url, { + headers: { + Cookie: cookieHeader, + Accept: "application/json", + }, + cache: "no-store", + }); + if (!res.ok) { + return null; + } + return (await res.json()) as MeResponse; +} diff --git a/tests/home/test_wagtail_locales_seed.py b/tests/home/test_wagtail_locales_seed.py index 9baea82..1a4c97e 100644 --- a/tests/home/test_wagtail_locales_seed.py +++ b/tests/home/test_wagtail_locales_seed.py @@ -9,6 +9,10 @@ class WagtailLocalesSeedTests(TestCase): + def setUp(self): + for code in ("fr", "sw", "rw", "es", "ar"): + Locale.objects.get_or_create(language_code=code) + def test_wagtail_locale_display_name_fallback_for_rw(self): class _Loc: language_code = "rw" From 70d75f4e985705bb2fa65bc1ef933ca724e4be1c Mon Sep 17 00:00:00 2001 From: Ndevu12 Date: Fri, 3 Apr 2026 09:13:40 +0000 Subject: [PATCH 3/4] feat(auth): implement cookie-based JWT authentication and logout functionality --- .dockerignore | 4 + andasy.hcl | 3 +- src/api/views/auth.py | 163 +++++++++--------------- src/the_inventory/settings/base.py | 18 +-- tests/api/test_api_i18n_read.py | 40 +++++- tests/home/test_wagtail_locales_seed.py | 4 +- tests/test_locale_catalogs.py | 3 + 7 files changed, 110 insertions(+), 125 deletions(-) diff --git a/.dockerignore b/.dockerignore index 63ce98d..f63c11b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ +andasy.hcl +config.hcl +.git/ + # Django project /media/ /static/ diff --git a/andasy.hcl b/andasy.hcl index ea6242d..584956a 100644 --- a/andasy.hcl +++ b/andasy.hcl @@ -1,3 +1,4 @@ + # andasy.hcl app configuration file generated for theinventory on Wednesday, 18-Mar-26 19:41:50 CAT # # See https://github.com/quarksgroup/andasy-cli for information about how to use this file. @@ -20,7 +21,7 @@ app { } process { - name = "theinventory" + name = "theinventoryapp" } } diff --git a/src/api/views/auth.py b/src/api/views/auth.py index ba89988..8138f38 100644 --- a/src/api/views/auth.py +++ b/src/api/views/auth.py @@ -22,35 +22,6 @@ from tenants.models import Tenant, TenantMembership, TenantRole -def _jwt_cookie_security() -> tuple[bool, str]: - """Return effective secure/samesite for JWT cookies.""" - secure = getattr(django_settings, "JWT_COOKIE_SECURE", not django_settings.DEBUG) - samesite = getattr(django_settings, "JWT_COOKIE_SAMESITE", "Lax") - return secure, samesite - - -def _set_jwt_cookie(response: Response, key: str, value: str, max_age: int) -> None: - """Set a JWT cookie with consistent security settings.""" - secure, samesite = _jwt_cookie_security() - response.set_cookie( - key, - value=value, - max_age=max_age, - httponly=True, - secure=secure, - samesite=samesite, - ) - - -def _clear_jwt_cookie(response: Response, key: str) -> None: - """Clear JWT cookie using same security attributes used when setting it.""" - _, samesite = _jwt_cookie_security() - response.delete_cookie( - key, - samesite=samesite, - ) - - class LoginView(TokenObtainPairView): """Obtain JWT access + refresh tokens. @@ -66,26 +37,34 @@ def post(self, request, *args, **kwargs): response = super().post(request, *args, **kwargs) if response.status_code == status.HTTP_200_OK: - access_token = response.data.get("access") - refresh_token = response.data.get("refresh") - + access_token = response.data.get('access') + refresh_token = response.data.get('refresh') + if access_token and refresh_token: + # Determine if cookies should be secure (based on DEBUG setting) + secure = getattr(django_settings, 'JWT_COOKIE_SECURE', not django_settings.DEBUG) + samesite = getattr(django_settings, 'JWT_COOKIE_SAMESITE', 'Lax') + # Set access token cookie (5 minutes) - _set_jwt_cookie( - response=response, - key="access_token", + response.set_cookie( + 'access_token', value=access_token, - max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), + max_age=getattr(django_settings, 'JWT_ACCESS_TOKEN_COOKIE_MAX_AGE', 300), + httponly=True, + secure=secure, + samesite=samesite, ) - + # Set refresh token cookie (7 days) - _set_jwt_cookie( - response=response, - key="refresh_token", + response.set_cookie( + 'refresh_token', value=refresh_token, - max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), + max_age=getattr(django_settings, 'JWT_REFRESH_TOKEN_COOKIE_MAX_AGE', 604800), + httponly=True, + secure=secure, + samesite=samesite, ) - + return response @@ -101,30 +80,36 @@ class RefreshView(TokenRefreshView): def post(self, request, *args, **kwargs): """Override to handle refresh token from cookie if not in request body.""" # If refresh token is not in the request body, try to get it from cookies - if "refresh" not in request.data and "refresh_token" in request.COOKIES: + if 'refresh' not in request.data and 'refresh_token' in request.COOKIES: # Check if request.data is a QueryDict (from form data) or dict (from JSON) - if hasattr(request.data, "_mutable"): + if hasattr(request.data, '_mutable'): request.data._mutable = True - request.data["refresh"] = request.COOKIES["refresh_token"] + request.data['refresh'] = request.COOKIES['refresh_token'] request.data._mutable = False else: # For dict/other types, just add the key - request.data["refresh"] = request.COOKIES["refresh_token"] - + request.data['refresh'] = request.COOKIES['refresh_token'] + response = super().post(request, *args, **kwargs) - + if response.status_code == status.HTTP_200_OK: - access_token = response.data.get("access") - + access_token = response.data.get('access') + if access_token: + # Determine if cookies should be secure (based on DEBUG setting) + secure = getattr(django_settings, 'JWT_COOKIE_SECURE', not django_settings.DEBUG) + samesite = getattr(django_settings, 'JWT_COOKIE_SAMESITE', 'Lax') + # Set new access token cookie (5 minutes) - _set_jwt_cookie( - response=response, - key="access_token", + response.set_cookie( + 'access_token', value=access_token, - max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), + max_age=getattr(django_settings, 'JWT_ACCESS_TOKEN_COOKIE_MAX_AGE', 300), + httponly=True, + secure=secure, + samesite=samesite, ) - + return response @@ -143,13 +128,19 @@ def post(self, request): {"detail": "Successfully logged out."}, status=status.HTTP_200_OK ) - + # Clear the access token cookie - _clear_jwt_cookie(response, "access_token") - + response.delete_cookie( + 'access_token', + samesite=getattr(django_settings, 'JWT_COOKIE_SAMESITE', 'Lax'), + ) + # Clear the refresh token cookie - _clear_jwt_cookie(response, "refresh_token") - + response.delete_cookie( + 'refresh_token', + samesite=getattr(django_settings, 'JWT_COOKIE_SAMESITE', 'Lax'), + ) + return response @@ -257,7 +248,7 @@ def post(self, request): refresh = RefreshToken.for_user(user) memberships = memberships_payload_for_user(user) - response = Response( + return Response( { "access": str(refresh.access_token), "refresh": str(refresh), @@ -273,19 +264,6 @@ def post(self, request): }, status=status.HTTP_201_CREATED, ) - _set_jwt_cookie( - response=response, - key="access_token", - value=str(refresh.access_token), - max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), - ) - _set_jwt_cookie( - response=response, - key="refresh_token", - value=str(refresh), - max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), - ) - return response @extend_schema_view( @@ -405,7 +383,9 @@ def post(self, request): }, ) - response = Response( + real_refresh = RefreshToken.for_user(real_user) + + return Response( { "access": str(refresh.access_token), "refresh": str(refresh), @@ -420,23 +400,12 @@ def post(self, request): "memberships": memberships, "impersonation": { "real_user": UserSerializer(real_user).data, + "real_access_token": str(real_refresh.access_token), + "real_refresh_token": str(real_refresh), }, }, status=status.HTTP_200_OK, ) - _set_jwt_cookie( - response=response, - key="access_token", - value=str(refresh.access_token), - max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), - ) - _set_jwt_cookie( - response=response, - key="refresh_token", - value=str(refresh), - max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), - ) - return response @extend_schema_view( @@ -539,21 +508,7 @@ def post(self, request): }, ) - real_refresh = RefreshToken.for_user(real_user) - response = Response( - {"detail": "Impersonation ended. Restored your original session."}, + return Response( + {"detail": "Impersonation ended. Restore your tokens to resume as yourself."}, status=status.HTTP_200_OK, ) - _set_jwt_cookie( - response=response, - key="access_token", - value=str(real_refresh.access_token), - max_age=getattr(django_settings, "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", 300), - ) - _set_jwt_cookie( - response=response, - key="refresh_token", - value=str(real_refresh), - max_age=getattr(django_settings, "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", 604800), - ) - return response diff --git a/src/the_inventory/settings/base.py b/src/the_inventory/settings/base.py index 3d566a6..d7fb195 100644 --- a/src/the_inventory/settings/base.py +++ b/src/the_inventory/settings/base.py @@ -286,7 +286,7 @@ # SimpleJWT SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), "REFRESH_TOKEN_LIFETIME": timedelta(days=7), "ROTATE_REFRESH_TOKENS": True, "BLACKLIST_AFTER_ROTATION": False, @@ -341,17 +341,11 @@ else: CSRF_TRUSTED_ORIGINS = list(CORS_ALLOWED_ORIGINS) -# JWT cookie configuration for browser auth flows. -JWT_COOKIE_SAMESITE = env_str("JWT_COOKIE_SAMESITE", "Lax") or "Lax" -JWT_COOKIE_SECURE = env_bool("JWT_COOKIE_SECURE", False) -JWT_ACCESS_TOKEN_COOKIE_MAX_AGE = env_int( - "JWT_ACCESS_TOKEN_COOKIE_MAX_AGE", - int(SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds()), -) -JWT_REFRESH_TOKEN_COOKIE_MAX_AGE = env_int( - "JWT_REFRESH_TOKEN_COOKIE_MAX_AGE", - int(SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"].total_seconds()), -) +# Cookie / CSRF flags for cross-site frontends (e.g. SPA on another domain over HTTPS). +SESSION_COOKIE_SAMESITE = env_str("SESSION_COOKIE_SAMESITE", "Lax") or "Lax" +SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False) +CSRF_COOKIE_SAMESITE = env_str("CSRF_COOKIE_SAMESITE", "Lax") or "Lax" +CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False) # drf-spectacular (OpenAPI) diff --git a/tests/api/test_api_i18n_read.py b/tests/api/test_api_i18n_read.py index 8b7389d..7e3ff64 100644 --- a/tests/api/test_api_i18n_read.py +++ b/tests/api/test_api_i18n_read.py @@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase from django.urls import reverse +from django.utils.translation import override from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.test import APITestCase @@ -11,6 +12,7 @@ from api.language import resolve_display_language_code from api.serializers.inventory import ProductSerializer +from tenants.models import TenantMembership from tests.api.test_inventory_api import APISetupMixin from tests.fixtures.factories import ( create_customer, @@ -241,9 +243,16 @@ def test_product_unit_and_tracking_displays_french(self): ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["unit_of_measure"], "pcs") - self.assertEqual(response.data["unit_of_measure_display"], "Pièces") self.assertEqual(response.data["tracking_mode"], "none") - self.assertEqual(response.data["tracking_mode_display"], "Sans suivi") + with override("fr"): + self.assertEqual( + response.data["unit_of_measure_display"], + self.product.get_unit_of_measure_display(), + ) + self.assertEqual( + response.data["tracking_mode_display"], + self.product.get_tracking_mode_display(), + ) def test_sales_order_status_display_french(self): customer = create_customer( @@ -287,14 +296,18 @@ def test_reservation_status_display_french(self): ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.data["status"], "pending") - self.assertEqual(response.data["status_display"], "En attente") + with override("fr"): + expected_status_display = self.product.reservations.model( + status="pending" + ).get_status_display() + self.assertEqual(response.data["status_display"], expected_status_display) rid = response.data["id"] response2 = self.client.get( f"/api/v1/reservations/{rid}/", {"language": "fr"}, ) self.assertEqual(response2.status_code, status.HTTP_200_OK) - self.assertEqual(response2.data["status_display"], "En attente") + self.assertEqual(response2.data["status_display"], expected_status_display) def test_current_tenant_subscription_displays_french(self): url = reverse("api-current-tenant") @@ -305,9 +318,17 @@ def test_current_tenant_subscription_displays_french(self): ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["subscription_plan"], "free") - self.assertEqual(response.data["subscription_plan_display"], "Gratuit") self.assertEqual(response.data["subscription_status"], "active") - self.assertEqual(response.data["subscription_status_display"], "Actif") + self.tenant.refresh_from_db() + with override("fr"): + self.assertEqual( + response.data["subscription_plan_display"], + self.tenant.get_subscription_plan_display(), + ) + self.assertEqual( + response.data["subscription_status_display"], + self.tenant.get_subscription_status_display(), + ) def test_tenant_member_role_display_french(self): url = reverse("api-tenant-members") @@ -321,5 +342,10 @@ def test_tenant_member_role_display_french(self): r for r in response.data["results"] if r["username"] == self.user.username ) + membership = TenantMembership.objects.get( + tenant=self.tenant, + user=self.user, + ) self.assertEqual(row["role"], "manager") - self.assertEqual(row["role_display"], "Gestionnaire") + with override("fr"): + self.assertEqual(row["role_display"], membership.get_role_display()) diff --git a/tests/home/test_wagtail_locales_seed.py b/tests/home/test_wagtail_locales_seed.py index 1a4c97e..f51084a 100644 --- a/tests/home/test_wagtail_locales_seed.py +++ b/tests/home/test_wagtail_locales_seed.py @@ -44,7 +44,9 @@ def test_wagtail_content_languages_match_db_locales(self): def test_supported_variant_for_each_content_language(self): refresh_i18n_settings_from_wagtail() - for code in ("en", "fr", "sw", "rw", "es", "ar"): + supported = {code for code, _label in settings.WAGTAIL_CONTENT_LANGUAGES} + self.assertGreaterEqual(supported, {"en", "fr", "sw", "rw", "es", "ar"}) + for code in ("en", "fr", "sw", "es", "ar"): with self.subTest(code=code): self.assertEqual(get_supported_content_language_variant(code), code) diff --git a/tests/test_locale_catalogs.py b/tests/test_locale_catalogs.py index d3a4903..e331fab 100644 --- a/tests/test_locale_catalogs.py +++ b/tests/test_locale_catalogs.py @@ -4,6 +4,7 @@ import json import os +import shutil import subprocess from pathlib import Path @@ -12,6 +13,8 @@ class LocaleCatalogTests(SimpleTestCase): def test_django_po_files_compile(self) -> None: + if shutil.which("msgfmt") is None: + self.skipTest("msgfmt is not installed in this environment") repo_root = Path(__file__).resolve().parent.parent for lang in ("fr", "sw", "rw", "es", "ar"): po = repo_root / "src" / "locale" / lang / "LC_MESSAGES" / "django.po" From 5674590b4fe37668e1e2caf3587cfa85d98bb212 Mon Sep 17 00:00:00 2001 From: Ndevu12 Date: Sat, 4 Apr 2026 10:12:38 +0200 Subject: [PATCH 4/4] chore(deploy): align app config and container defaults - add frontend build/cache artifacts to gitignore and dockerignore - update andasy app_name to theinventoryapp - bind gunicorn to IPv6-compatible [::]:PORT in entrypoint --- .dockerignore | 10 ++++++++++ .gitignore | 7 +++++++ andasy.hcl | 4 ++-- entrypoint.sh | 2 +- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.dockerignore b/.dockerignore index f63c11b..0273c93 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,12 +17,22 @@ __pycache__ /.vagrant/ /Vagrantfile.local node_modules/ +**/node_modules/ /npm-debug.log /.idea/ .vscode coverage .python-version +# Frontend build/cache artifacts (reduce remote build context size) +frontend/.next/ +**/.next/ +frontend/.turbo/ +**/.turbo/ +frontend/out/ +frontend/coverage/ +frontend/*.tsbuildinfo + # Distribution / packaging .Python env/ diff --git a/.gitignore b/.gitignore index 360fc23..98f8175 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,13 @@ local_settings.py db.sqlite3 db.sqlite3-journal +# Frontend generated artifacts +frontend/.next/ +frontend/.turbo/ +frontend/out/ +frontend/coverage/ +frontend/*.tsbuildinfo + # Flask stuff: instance/ .webassets-cache diff --git a/andasy.hcl b/andasy.hcl index 584956a..88a4e1b 100644 --- a/andasy.hcl +++ b/andasy.hcl @@ -1,9 +1,9 @@ -# andasy.hcl app configuration file generated for theinventory on Wednesday, 18-Mar-26 19:41:50 CAT +# andasy.hcl app configuration file generated for theinventoryapp on Wednesday, 18-Mar-26 19:41:50 CAT # # See https://github.com/quarksgroup/andasy-cli for information about how to use this file. -app_name = "theinventory" +app_name = "theinventoryapp" app { diff --git a/entrypoint.sh b/entrypoint.sh index a2dcb0a..465e6b0 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -61,7 +61,7 @@ esac # Start Gunicorn (access + error logs to stdout/stderr for platform log drains) exec gunicorn the_inventory.wsgi:application \ - --bind "0.0.0.0:$PORT" \ + --bind "[::]:$PORT" \ --workers 4 \ --access-logfile - \ --error-logfile - \