♻️(auth) deliver CSRF token via session instead of cookie

The upcoming Capacitor mobile shell replays the Django session cookie
through its native HTTP layer but not the `csrftoken` cookie, so
cookie-based CSRF would break every mutation on mobile. Enabling
CSRF_USE_SESSIONS moves the secret server-side and removes the need for
a JS-readable cookie: the token is now delivered on the authenticated
/users/me/ response, cached in memory by the SPA and echoed as
X-CSRFToken. On web this is equivalent or safer — the secret is no
longer readable by scripts nor overridable via cross-subdomain cookie
tossing.
This commit is contained in:
jbpenrath
2026-07-22 11:16:41 +02:00
parent f058ee4d4a
commit 5038dd977a
11 changed files with 155 additions and 32 deletions
+6
View File
@@ -10823,6 +10823,11 @@
"description": "Get custom attributes for the instance.",
"readOnly": true
},
"csrf_token": {
"type": "string",
"description": "Return the session-bound CSRF token for the SPA to echo as X-CSRFToken.\n\nWith ``CSRF_USE_SESSIONS`` the secret lives in the session and is no\nlonger exposed as a cookie, so the token is delivered here over the\nauthenticated ``/users/me/`` channel.",
"readOnly": true
},
"abilities": {
"type": "object",
"description": "Instance permissions and capabilities",
@@ -10850,6 +10855,7 @@
},
"required": [
"abilities",
"csrf_token",
"custom_attributes",
"email",
"full_name",
+15
View File
@@ -10,6 +10,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import Count, Max, Q
from django.middleware.csrf import get_token
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema_field
from rest_framework import serializers
@@ -318,6 +319,20 @@ class UserWithAbilitiesSerializer(UserSerializer):
"""
exclude_abilities = False
csrf_token = serializers.SerializerMethodField(read_only=True)
class Meta(UserSerializer.Meta):
fields = UserSerializer.Meta.fields + ["csrf_token"]
read_only_fields = fields
def get_csrf_token(self, _instance) -> str:
"""Return the session-bound CSRF token for the SPA to echo as X-CSRFToken.
With ``CSRF_USE_SESSIONS`` the secret lives in the session and is no
longer exposed as a cookie, so the token is delivered here over the
authenticated ``/users/me/`` channel.
"""
return get_token(self.context["request"])
class UserWithoutAbilitiesSerializer(UserSerializer):
@@ -75,6 +75,33 @@ class TestBlobAPI:
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_upload_session_auth_accepts_session_csrf_token(
self, api_client, user_mailbox
):
"""A cookie-session upload succeeds when echoing the session CSRF token.
With ``CSRF_USE_SESSIONS`` the token is validated against the secret held
in the session (no ``csrftoken`` cookie). ``GET /users/me/`` both returns
the token and persists the secret in the session (via the CSRF
middleware), exactly as the SPA bootstrap does; echoing that token in the
``X-CSRFToken`` header is then accepted.
"""
_, user = api_client
csrf_client = APIClient(enforce_csrf_checks=True)
csrf_client.force_login(user) # real session → SessionAuthentication path
token = csrf_client.get(reverse("users-me")).data["csrf_token"]
url = reverse("blob-upload", kwargs={"mailbox_id": user_mailbox.id})
response = csrf_client.post(
url,
{"file": self._create_test_file()},
format="multipart",
HTTP_X_CSRFTOKEN=token,
)
assert response.status_code == status.HTTP_201_CREATED
def test_upload_download_blob(
self,
api_client,
+5
View File
@@ -36,6 +36,11 @@ class TestUsersGetMe:
assert response.status_code == 200
data = response.json()
# The CSRF token (session-bound, masked per request) is delivered here so
# the SPA can echo it as X-CSRFToken under CSRF_USE_SESSIONS; its value is
# non-deterministic, so assert its presence and drop it before comparing.
csrf_token = data.pop("csrf_token")
assert isinstance(csrf_token, str) and csrf_token != ""
assert data == {
"id": str(user.id),
"email": user.email,
+10
View File
@@ -1000,6 +1000,16 @@ class Base(Configuration):
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = 60 * 60 * 12
# Keep the CSRF secret in the server-side session instead of a readable
# `csrftoken` cookie. The SPA reads its token from the authenticated
# /users/me/ response, and the native shell from the session exchange — both
# echo it in the X-CSRFToken header, validated against the session secret.
# The native HTTP jar (CapacitorHttp) replays the session cookie reliably
# but not the csrf cookie, so this is what makes mobile mutations work; on
# web it is equivalent-or-safer (secret no longer JS-readable, immune to
# cross-subdomain cookie tossing).
CSRF_USE_SESSIONS = True
# OIDC - Authorization Code Flow
OIDC_CREATE_USER = values.BooleanValue(
default=False, environ_name="OIDC_CREATE_USER", environ_prefix=None
+30 -25
View File
@@ -54,6 +54,24 @@ async function navigateToSharedThread(page: Page, browserName: BrowserName) {
await page.waitForLoadState("networkidle");
}
/**
* Fetch the current user through the API session.
*
* Also the source of the CSRF token expected by DRF on unsafe verbs: with
* `CSRF_USE_SESSIONS` the backend no longer exposes a readable `csrftoken`
* cookie — the session-bound token is delivered in the authenticated
* /users/me/ payload instead, exactly how the SPA obtains it.
*/
async function fetchCurrentUser(page: Page) {
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
expect(meResponse.ok()).toBeTruthy();
return (await meResponse.json()) as {
id: string;
full_name: string;
csrf_token: string;
};
}
test.describe("Thread Events (Internal Messages)", () => {
test.beforeAll(async () => {
await resetDatabase();
@@ -421,17 +439,12 @@ test.describe("Thread Events (Internal Messages)", () => {
const threadId = threadMatch?.[1];
expect(threadId, "thread id should be present in URL").toBeTruthy();
// Reuse the existing browser session cookies they carry both the
// session id and the CSRF token expected by DRF on unsafe verbs.
const cookies = await page.context().cookies();
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
// Fetch the current user so we can mention ourselves. The UI filters
// Fetch the current user so we can mention ourselves (the UI filters
// out self-mentions, but the backend allows them — POSTing directly is
// the simplest way to create an unread mention visible to the test user.
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
expect(meResponse.ok()).toBeTruthy();
const me = (await meResponse.json()) as { id: string; full_name: string };
// the simplest way to create an unread mention visible to the test user)
// along with the session-bound CSRF token carried in the same payload.
const me = await fetchCurrentUser(page);
const csrfToken = me.csrf_token;
// Create an IM mentioning the current user. sync_mention_user_events
// runs in the post_save signal and materialises the UserEvent MENTION
@@ -547,8 +560,7 @@ test.describe("Thread Events (Internal Messages)", () => {
const eventId = eventDomId?.replace(/^thread-event-/, "");
expect(eventId, "event id should be present on aged bubble").toBeTruthy();
const cookies = await page.context().cookies();
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
const { csrf_token: csrfToken } = await fetchCurrentUser(page);
const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i);
const threadId = threadMatch?.[1];
const updateResponse = await page.request.patch(
@@ -615,11 +627,8 @@ test.describe("Thread Events (Assignations)", () => {
const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i);
const threadId = threadMatch?.[1];
expect(threadId, "thread id should be present in URL").toBeTruthy();
const cookies = await page.context().cookies();
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
expect(meResponse.ok()).toBeTruthy();
const me = (await meResponse.json()) as { id: string; full_name: string };
const me = await fetchCurrentUser(page);
const csrfToken = me.csrf_token;
await page.request.post(
`${API_URL}/api/v1.0/threads/${threadId}/events/`,
{
@@ -721,18 +730,14 @@ test.describe("Thread Events (Assignations)", () => {
await navigateToSharedThread(page, browserName);
// Drive the assignment through the API to keep this test independent
// of the popover flow exercised above. The cookies set during sign-in
// carry both the session id and the CSRF token DRF expects on POST.
// of the popover flow exercised above. The sign-in cookies carry the
// session id; the CSRF token comes from the /users/me/ payload.
const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i);
const threadId = threadMatch?.[1];
expect(threadId, "thread id should be present in URL").toBeTruthy();
const cookies = await page.context().cookies();
const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? "";
const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`);
expect(meResponse.ok()).toBeTruthy();
const me = (await meResponse.json()) as { id: string; full_name: string };
const me = await fetchCurrentUser(page);
const csrfToken = me.csrf_token;
const assignResponse = await page.request.post(
`${API_URL}/api/v1.0/threads/${threadId}/events/`,
@@ -0,0 +1,22 @@
import { getWebCsrfToken, setWebCsrfToken } from "./csrf";
describe("web csrf store", () => {
afterEach(() => {
setWebCsrfToken(undefined);
});
it("returns undefined before /users/me/ delivered a token", () => {
expect(getWebCsrfToken()).toBeUndefined();
});
it("returns the cached token", () => {
setWebCsrfToken("token-from-users-me");
expect(getWebCsrfToken()).toBe("token-from-users-me");
});
it("stays out of storage — in-memory only by design", () => {
setWebCsrfToken("token-from-users-me");
expect(localStorage.length).toBe(0);
expect(document.cookie).toBe("");
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* In-memory CSRF token for the web app.
*
* With `CSRF_USE_SESSIONS` the backend keeps the CSRF secret in the server-side
* session and no longer exposes a readable `csrftoken` cookie. The token is
* delivered over the authenticated `/users/me/` response, cached here, and
* injected as the `X-CSRFToken` header by `getCSRFToken()`. Kept in memory (not
* a cookie) so it is never auto-attached cross-site and is dropped on reload.
*/
let webCsrfToken: string | undefined;
export const setWebCsrfToken = (token: string | undefined): void => {
webCsrfToken = token;
};
export const getWebCsrfToken = (): string | undefined => webCsrfToken;
@@ -21,6 +21,12 @@ export interface UserWithAbilities {
readonly full_name: string | null;
/** Get custom attributes for the instance. */
readonly custom_attributes: UserWithAbilitiesCustomAttributes;
/** Return the session-bound CSRF token for the SPA to echo as X-CSRFToken.
With ``CSRF_USE_SESSIONS`` the secret lives in the session and is no
longer exposed as a cookie, so the token is delivered here over the
authenticated ``/users/me/`` channel. */
readonly csrf_token: string;
/** Instance permissions and capabilities */
readonly abilities: UserWithAbilitiesAbilities;
}
+9 -7
View File
@@ -1,3 +1,5 @@
import { getWebCsrfToken } from "./csrf";
export const errorCauses = async (response: Response, data?: unknown) => {
const errorsBody = (await response.json()) as Record<
string,
@@ -61,14 +63,14 @@ export const getHeaders = (headers: HeadersInit = {}, isMultipartFormData: boole
};
/**
* Retrieves the CSRF token from the document's cookies.
* Retrieves the CSRF token to echo in the X-CSRFToken header.
*
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
* With CSRF_USE_SESSIONS the secret lives in the server-side session and there
* is no readable `csrftoken` cookie: the token is delivered over the
* authenticated `/users/me/` response and cached in memory.
*
* @returns {string|undefined} The CSRF token if known, or undefined otherwise.
*/
export function getCSRFToken() {
return document.cookie
.split(";")
.filter((cookie) => cookie.trim().startsWith("csrftoken="))
.map((cookie) => cookie.split("=")[1])
.pop();
return getWebCsrfToken();
}
+8
View File
@@ -1,6 +1,7 @@
import React, { PropsWithChildren, useEffect, useMemo } from "react";
import { getRequestUrl } from "@/features/api/utils";
import { setWebCsrfToken } from "@/features/api/csrf";
import { useUsersMeRetrieve } from "@/features/api/gen/users/users";
import { Spinner } from "@gouvfr-lasuite/ui-kit";
import { UserWithAbilities } from "../api/gen/models/user_with_abilities";
@@ -55,6 +56,13 @@ export const Auth = ({
[config.FRONTEND_SILENT_LOGIN_ENABLED, user]
);
// Cache the session-bound CSRF token delivered with /users/me/ so mutations
// can echo it in the X-CSRFToken header (no `csrftoken` cookie any more under
// CSRF_USE_SESSIONS).
useEffect(() => {
if (user) setWebCsrfToken(user.csrf_token);
}, [user]);
useEffect(() => {
if (user !== null) return;