Skip to content

Commit 19fa596

Browse files
committed
⚡️(yhub) skip the users/me call for callers without a session cookie
After removing the ForceSessionMiddleware we can detect without calling the /users/me endpoint if a user is anonymous or not. We now check the presence of the cookie session to avoid the http call and directly return an anonymous user.
1 parent 0694b8a commit 19fa596

9 files changed

Lines changed: 177 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to
88

99
### Changed
1010

11+
- ⚡️(yhub) skip the `users/me` call for callers without a session cookie
1112
- 🔧(helm) allow to set the period and failure threshold of probes
1213

1314
### Fixed

‎documentation/load-testing.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,12 @@ open one socket with a cookie of the manifest and read its logs for the
118118
store makes the clients anonymous, which looks like success on public
119119
documents. (In this repository's compose stack, the `Development`
120120
configuration keeps its sessions in another Redis database than `LoadTest`:
121-
mint with `REDIS_URL=redis://redis:6379/2` there.)
121+
mint with `REDIS_URL=redis://redis:6379/2` there.) One symptom has changed
122+
with the session-cookie fast path: an anonymous client is resolved locally
123+
by the collaboration server and no longer produces the `users_me:401` calls
124+
that used to betray a bad manifest in `yhub_backend_request_duration_seconds`
125+
— check `yhub_auth_duration_seconds{phase="authenticate"}` and the `userid`
126+
the logs report instead.
122127

123128
## Where the load comes from
124129

‎src/backend/core/tests/test_settings.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,26 @@
55
import os
66
import tempfile
77

8+
from django.conf import settings
9+
810
import pytest
911

1012
from impress.settings import Base, Production
1113

1214

15+
def test_session_cookie_name_matches_yhub_contract():
16+
"""
17+
The yhub collaboration server (`src/yhub-server/src/config.ts`) hardcodes
18+
the same session cookie name to decide whether a caller may hold a session
19+
at all: a caller whose cookies carry no `docs_sessionid` is resolved to
20+
anonymous there without calling `users/me`. Renaming the cookie on the
21+
backend side alone would silently degrade every signed-in user to
22+
anonymous, so the contract is pinned on both ends.
23+
"""
24+
25+
assert settings.SESSION_COOKIE_NAME == "docs_sessionid"
26+
27+
1328
def test_invalid_settings_oidc_email_configuration():
1429
"""
1530
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings

‎src/yhub-server/README.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ It is not a fork of yhub — it is a thin wrapper, written in TypeScript under
3030
below),
3131
- plugs in an auth plugin that resolves users and per-document access rights
3232
by calling the Docs Django backend (`/api/v1.0/users/me/` and
33-
`/api/v1.0/documents/{id}/`),
33+
`/api/v1.0/documents/{id}/`; the user call is skipped for any caller whose
34+
`Cookie` header does not carry the backend's `docs_sessionid` cookie —
35+
they can only be anonymous),
3436
- serves every route under the `/collaboration/` prefix
3537
(`server.apiPrefix`), including the websocket sync route
3638
`/collaboration/ws/v1/{org}/{docid}`,
@@ -201,6 +203,13 @@ yhub refuses the upgrade of a caller that holds the write but has no identity,
201203
because attributions carry the userid. Every anonymous edit is therefore
202204
attributed to that one shared author.
203205

206+
A caller whose `Cookie` header does not carry the backend's session cookie
207+
(`docs_sessionid`) is anonymous without a roundtrip: the backend only ever
208+
speaks sessions through that one cookie, so the `users/me` call could only
209+
answer 401 for them, and the identity is resolved locally instead. Browsers
210+
still send their other cookies (csrf, consent, …) on that header, which is why
211+
the check is on the session cookie rather than on the header being present.
212+
204213
### Origins and cors
205214

206215
`COLLABORATION_SERVER_ORIGIN` is the list of origins a browser may reach this

‎src/yhub-server/__tests__/backend.spec.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,55 @@ describe('backendFetch', () => {
172172
});
173173
});
174174

175+
describe('carriesSessionCookie', () => {
176+
// imported lazily per test through `load()` like the other describes, so it
177+
// runs after the file-level beforeEach has stubbed the env: a beforeAll on
178+
// the describe would import config.ts before the first beforeEach runs, and
179+
// only pass while every config read keeps a default
180+
181+
it('accepts a header carrying only the session cookie', async () => {
182+
const { carriesSessionCookie } = await load();
183+
expect(carriesSessionCookie('docs_sessionid=abc')).toBe(true);
184+
});
185+
186+
it('accepts the session cookie among others', async () => {
187+
const { carriesSessionCookie } = await load();
188+
expect(
189+
carriesSessionCookie('csrftoken=x; docs_sessionid=abc; consent=y'),
190+
).toBe(true);
191+
});
192+
193+
it('accepts it despite surrounding whitespace around the pair', async () => {
194+
// browsers separate cookies with "; " and may pad: the name is trimmed,
195+
// though it stays case-sensitive — RFC 6265 cookie names are
196+
const { carriesSessionCookie } = await load();
197+
expect(carriesSessionCookie('csrftoken=x; docs_sessionid = abc')).toBe(
198+
true,
199+
);
200+
});
201+
202+
it('is case-sensitive on the cookie name, as RFC 6265 makes them', async () => {
203+
const { carriesSessionCookie } = await load();
204+
expect(carriesSessionCookie('DOCS_SESSIONID=abc')).toBe(false);
205+
});
206+
207+
it('refuses a header with other cookies but no session one', async () => {
208+
const { carriesSessionCookie } = await load();
209+
expect(carriesSessionCookie('csrftoken=x; consent=y')).toBe(false);
210+
});
211+
212+
it('refuses lookalike names that are not the session cookie', async () => {
213+
const { carriesSessionCookie } = await load();
214+
expect(carriesSessionCookie('xdocs_sessionid=abc')).toBe(false);
215+
expect(carriesSessionCookie('docs_sessionid_extra=abc')).toBe(false);
216+
});
217+
218+
it('refuses an empty header', async () => {
219+
const { carriesSessionCookie } = await load();
220+
expect(carriesSessionCookie('')).toBe(false);
221+
});
222+
});
223+
175224
describe('the backend signing key', () => {
176225
it('publishes no JWK when YHUB_JWT_PRIVATE_KEY is unset', async () => {
177226
const { backendPublicJwk } = await load();

‎src/yhub-server/__tests__/server.spec.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,63 @@ describe('the S3 persistence plugin', () => {
204204
await expect(boot()).rejects.toThrow(/must be http:\/\/ or https:\/\//);
205205
});
206206
});
207+
208+
describe('the authenticate cookie gate', () => {
209+
// backend.ts is NOT mocked here: the spy on `fetch` observes the real
210+
// `backendFetch`, so these pin what the auth plugin wires together — who
211+
// is resolved locally, and who costs a backend call. Each test re-boots
212+
// through boot(), after the file-level beforeEach has stubbed BASE_ENV.
213+
const request = (
214+
headers: Record<string, string>,
215+
url = '/collaboration/ydoc/v1/docs/00000000-0000-4000-8000-000000000000/',
216+
) => ({
217+
getHeader: (name: string) => headers[name] ?? '',
218+
getUrl: () => url,
219+
});
220+
221+
const bootGate = async (): Promise<{
222+
authenticate: (req: ReturnType<typeof request>) => Promise<unknown>;
223+
fetchMock: ReturnType<typeof vi.fn>;
224+
}> => {
225+
const fetchMock = vi.fn();
226+
vi.stubGlobal('fetch', fetchMock);
227+
await boot();
228+
expect(createYHub.lastConfig.server).not.toBeNull();
229+
return {
230+
authenticate: createYHub.lastConfig.server.auth.authenticate,
231+
fetchMock,
232+
};
233+
};
234+
235+
it('resolves a caller holding other cookies but no session one to anonymous, without calling the backend', async () => {
236+
const { authenticate, fetchMock } = await bootGate();
237+
238+
const user = await authenticate(
239+
request({ cookie: 'csrftoken=x; consent=y' }),
240+
);
241+
242+
expect(user).toMatchObject({ userid: 'anonymous' });
243+
expect(fetchMock).not.toHaveBeenCalled();
244+
});
245+
246+
it('still asks the backend who a caller carrying the session cookie is', async () => {
247+
const { authenticate, fetchMock } = await bootGate();
248+
fetchMock.mockResolvedValue({
249+
ok: true,
250+
status: 200,
251+
json: async () => ({ id: 'user-1' }),
252+
});
253+
254+
const user = await authenticate(
255+
request({ cookie: 'csrftoken=x; docs_sessionid=abc' }),
256+
);
257+
258+
expect(user).toMatchObject({ userid: 'user-1' });
259+
expect(fetchMock.mock.calls[0][0]).toBe(
260+
'http://app-dev:8000/api/v1.0/users/me/',
261+
);
262+
expect(fetchMock.mock.calls[0][1].headers).toMatchObject({
263+
cookie: 'csrftoken=x; docs_sessionid=abc',
264+
});
265+
});
266+
});

‎src/yhub-server/src/backend.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
BACKEND_TOKEN_LIFETIME_S,
3434
BACKEND_TOKEN_MARGIN_MS,
3535
COLLABORATION_BACKEND_BASE_URL,
36+
SESSION_COOKIE_NAME,
3637
Y_PROVIDER_API_KEY,
3738
YHUB_JWT_PRIVATE_KEY,
3839
} from './config.js';
@@ -47,6 +48,16 @@ export interface BackendDocument {
4748
abilities?: DocumentAbilities;
4849
}
4950

51+
// Whether a caller's `Cookie` header carries the backend's session cookie.
52+
// The backend only ever speaks sessions through that one cookie, so a header
53+
// carrying others (csrf, consent, …) but not this one can only resolve to the
54+
// anonymous identity — the `users/me` call would answer 401 — and
55+
// `authenticate` answers it as such without the roundtrip.
56+
export const carriesSessionCookie = (cookieHeader: string): boolean =>
57+
cookieHeader
58+
.split(';')
59+
.some((pair) => pair.split('=')[0].trim() === SESSION_COOKIE_NAME);
60+
5061
const touchLog = logger.child({ module: 'updated-at-notifier' });
5162

5263
const backendSigningKey = YHUB_JWT_PRIVATE_KEY

‎src/yhub-server/src/config.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ export const MIN_MESSAGE_LIFETIME_MS = intEnv(
5858
);
5959
export const COLLABORATION_BACKEND_BASE_URL =
6060
process.env.COLLABORATION_BACKEND_BASE_URL || 'http://app-dev:8000';
61+
// The name of the backend's session cookie (`SESSION_COOKIE_NAME` on the
62+
// Django side, `docs_sessionid` there too). It decides whether
63+
// `authenticate` has to ask the backend who a caller is at all: the backend
64+
// only ever speaks sessions through that one cookie, so a `Cookie` header
65+
// carrying others (csrf, consent, …) but not this one can only resolve to the
66+
// anonymous identity — the `users/me` call would answer 401 — and is answered
67+
// anonymously without the roundtrip. Not read from the environment: a
68+
// two-party contract, hardcoded like the audiences.
69+
export const SESSION_COOKIE_NAME = 'docs_sessionid';
6170
export const allowedOrigins = (
6271
process.env.COLLABORATION_SERVER_ORIGIN || 'http://localhost:3000'
6372
).split(',');

‎src/yhub-server/src/server.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import { jwtVerify } from 'jose';
1212
import { api } from './api.js';
1313
// outbound calls to the Docs Django backend, and the keys both ends verify with
1414
import type { BackendDocument, BackendUser } from './backend.js';
15-
import { JWKS, backendFetch, touchDocument } from './backend.js';
15+
import {
16+
JWKS,
17+
backendFetch,
18+
carriesSessionCookie,
19+
touchDocument,
20+
} from './backend.js';
1621
import {
1722
ANONYMOUS_USERID,
1823
API_PREFIX,
@@ -192,9 +197,16 @@ const auth = createAuthPlugin<AppAuthInfo>({
192197
// `Origin` header at all, so on a deployment where the page and /collaboration/ share a
193198
// host every round would 401 while the PATCH beside it succeeded.
194199
//
195-
// A caller with no cookie at all is anonymous too: the probes arrive that way, and so does
196-
// a public document opened in a fresh browser. What they may do is `authorize`'s answer.
197-
if (!cookie) {
200+
// The backend only ever speaks sessions through its one session cookie
201+
// (`docs_sessionid`): a caller whose `Cookie` header does not carry it can
202+
// only resolve to the anonymous identity, and is answered as such without
203+
// the `users/me` roundtrip. This is not just the cookie-less case — the
204+
// probes and a public document in a fresh browser — but every signed-out
205+
// tab: since the backend stopped forcing a session onto anonymous users,
206+
// they hold no `docs_sessionid` at all, and browsers still send their other
207+
// cookies (csrf, consent, …) on this header. What an anonymous caller may
208+
// do is `authorize`'s answer, as before.
209+
if (!carriesSessionCookie(cookie)) {
198210
return { userid: ANONYMOUS_USERID, origin, endpoint };
199211
}
200212
try {

0 commit comments

Comments
 (0)