From b607677f3063d3a208dafaa6bee611c911a8cb7f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 17 May 2026 22:43:09 +0200 Subject: [PATCH] refactor(auth): extract extractFaSessionId to \$lib/shared/cookies Move the Set-Cookie parser out of login/+page.server.ts into a shared module with its own Vitest coverage (single-header, multi-header getSetCookie path, missing-header, attribute-stripping, prefix-match-rejection). An Undici or Node upgrade that changes header shape now trips its own test instead of silently breaking login. Addresses PR #612 / Felix F2. Co-Authored-By: Claude Opus 4.7 --- frontend/src/lib/shared/cookies.spec.ts | 38 +++++++++++++++++++++++++ frontend/src/lib/shared/cookies.ts | 20 +++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 frontend/src/lib/shared/cookies.spec.ts create mode 100644 frontend/src/lib/shared/cookies.ts diff --git a/frontend/src/lib/shared/cookies.spec.ts b/frontend/src/lib/shared/cookies.spec.ts new file mode 100644 index 00000000..64bb9b2c --- /dev/null +++ b/frontend/src/lib/shared/cookies.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { extractFaSessionId } from './cookies'; + +describe('extractFaSessionId', () => { + it('extracts the opaque id from a single Set-Cookie header', () => { + const headers = ['fa_session=abc123; Path=/; HttpOnly; SameSite=Strict']; + expect(extractFaSessionId(headers)).toBe('abc123'); + }); + + it('extracts the value when multiple Set-Cookie headers are present (getSetCookie path)', () => { + const headers = [ + 'JSESSIONID=legacy; Path=/', + 'fa_session=xyz789; Path=/; Max-Age=28800; HttpOnly', + 'XSRF-TOKEN=ignored; Path=/' + ]; + expect(extractFaSessionId(headers)).toBe('xyz789'); + }); + + it('returns null when no header carries fa_session', () => { + expect(extractFaSessionId(['Other=foo; Path=/'])).toBeNull(); + }); + + it('returns null for an empty header list', () => { + expect(extractFaSessionId([])).toBeNull(); + }); + + it('strips all attributes after the first semicolon', () => { + const headers = ['fa_session=opaque-token-with.dots_and-dashes; Path=/; Secure; HttpOnly']; + expect(extractFaSessionId(headers)).toBe('opaque-token-with.dots_and-dashes'); + }); + + it('only matches a cookie whose name is exactly fa_session', () => { + // A different cookie name that happens to contain "fa_session" as a substring + // must not match — anchored to start of header. + const headers = ['xfa_session=should-not-match; Path=/']; + expect(extractFaSessionId(headers)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/shared/cookies.ts b/frontend/src/lib/shared/cookies.ts new file mode 100644 index 00000000..ca71e08d --- /dev/null +++ b/frontend/src/lib/shared/cookies.ts @@ -0,0 +1,20 @@ +/** + * Extracts the fa_session cookie value from a list of Set-Cookie response headers. + * + * The backend may append attributes like `Path`, `HttpOnly`, `SameSite=Strict`, + * `Max-Age`, `Secure`; we only forward the opaque session id — the SvelteKit + * cookies API rewrites the attributes itself when re-emitting to the browser. + * + * Pass the result of `response.headers.getSetCookie()` (modern Node/Undici) or + * a single-element array containing `response.headers.get('set-cookie')` for + * older runtimes that lack `getSetCookie`. + * + * Returns `null` if no fa_session cookie is present. + */ +export function extractFaSessionId(setCookieHeaders: string[]): string | null { + for (const header of setCookieHeaders) { + const match = header.match(/^fa_session=([^;]+)/); + if (match) return match[1]; + } + return null; +}