From e3769ba7091ea4e387d868aa78026cf09ec04852 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 7 Aug 2026 20:52:32 -0300 Subject: [PATCH] feat(providers): accept JSON cookie objects in normalizeSessionCookieHeader (#9284) (#9335) Merge-train validated (tip 6ce4effef8). Vitest failures confirmed as base-red (#9679). --- _tasks | 1 + .../features/9284-json-cookie-input.md | 1 + src/lib/providers/webCookieAuth.ts | 71 +++++++++++- tests/unit/json-cookie-input.test.ts | 108 ++++++++++++++++++ 4 files changed, 175 insertions(+), 6 deletions(-) create mode 120000 _tasks create mode 100644 changelog.d/features/9284-json-cookie-input.md create mode 100644 tests/unit/json-cookie-input.test.ts diff --git a/_tasks b/_tasks new file mode 120000 index 0000000000..c17ee3177f --- /dev/null +++ b/_tasks @@ -0,0 +1 @@ +/home/diegosouzapw/dev/proxys/OmniRoute/_tasks \ No newline at end of file diff --git a/changelog.d/features/9284-json-cookie-input.md b/changelog.d/features/9284-json-cookie-input.md new file mode 100644 index 0000000000..100842dcc0 --- /dev/null +++ b/changelog.d/features/9284-json-cookie-input.md @@ -0,0 +1 @@ +- **feat(providers):** accept JSON cookie objects in normalizeSessionCookieHeader (#9284 — thanks @AIB1TAL0S) diff --git a/src/lib/providers/webCookieAuth.ts b/src/lib/providers/webCookieAuth.ts index b0cbeea326..0796e11fac 100644 --- a/src/lib/providers/webCookieAuth.ts +++ b/src/lib/providers/webCookieAuth.ts @@ -6,15 +6,74 @@ export function stripCookieInputPrefix(rawValue: string): string { return withoutBearer.replace(/^cookie:/i, "").trim(); } -export function normalizeSessionCookieHeader(rawValue: string, defaultCookieName: string): string { - const normalized = stripCookieInputPrefix(rawValue); - if (!normalized) return ""; +/** + * Parse a JSON array of cookie objects and produce a Cookie header string. + * + * Accepts the format exported by browser cookie-editor extensions / DevTools: + * ```json + * [ + * {"name":"sso","value":"eyJ0eXAi...","domain":".example.com","path":"/"}, + * {"name":"sso-rw","value":"eyJOTHER..."} + * ] + * ``` + * + * Only `name` and `value` are required. Extra fields (domain, path, expires, + * httpOnly, secure, sameSite) are silently ignored — they describe the cookie + * but are not part of the `Cookie` request header. + * + * @param rawValue - The user-provided cookie string (possibly JSON). + * @returns A Cookie header string, null if the input is not JSON (pass-through). + * @throws {Error} If a JSON entry is missing the required `name` or `value` field. + */ +export function parseJsonCookiesToHeader(rawValue: string): string | null { + const trimmed = (rawValue || "").trim(); + if (!trimmed || !trimmed.startsWith("[")) return null; - if (normalized.includes("=")) { - return normalized; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; } - return `${defaultCookieName}=${normalized}`; + if (!Array.isArray(parsed)) return null; + if (parsed.length === 0) return ""; + + const parts: string[] = []; + for (let i = 0; i < parsed.length; i++) { + const entry = parsed[i]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`Invalid cookie JSON at index ${i}: expected an object`); + } + const record = entry as Record; + + if (typeof record.name !== "string" || !record.name) { + throw new Error(`Invalid cookie JSON at index ${i}: missing required field 'name'`); + } + if (typeof record.value !== "string") { + throw new Error(`Invalid cookie JSON at index ${i}: missing required field 'value'`); + } + + parts.push(`${record.name}=${record.value}`); + } + + return parts.join("; "); +} + +export function normalizeSessionCookieHeader(rawValue: string, defaultCookieName: string): string { + const stripped = stripCookieInputPrefix(rawValue); + if (!stripped) return ""; + + const jsonResult = parseJsonCookiesToHeader(stripped); + if (jsonResult !== null) { + return jsonResult; + } + + if (stripped.includes("=")) { + return stripped; + } + + return `${defaultCookieName}=${stripped}`; } /** diff --git a/tests/unit/json-cookie-input.test.ts b/tests/unit/json-cookie-input.test.ts new file mode 100644 index 0000000000..1b518fcd16 --- /dev/null +++ b/tests/unit/json-cookie-input.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + parseJsonCookiesToHeader, + normalizeSessionCookieHeader, +} = await import("../../src/lib/providers/webCookieAuth.ts"); + +// parseJsonCookiesToHeader — unit tests +test("parseJsonCookiesToHeader: valid JSON array returns Cookie header string", () => { + const json = `[{"name":"sso","value":"eyJ0eXAi.abc.def"}]`; + assert.equal(parseJsonCookiesToHeader(json), "sso=eyJ0eXAi.abc.def"); +}); + +test("parseJsonCookiesToHeader: multiple entries joined with ; ", () => { + const json = `[ + {"name":"sso","value":"AAA.bbb"}, + {"name":"sso-rw","value":"CCC.ddd"}, + {"name":"cf_clearance","value":"zzz"} + ]`; + assert.equal(parseJsonCookiesToHeader(json), "sso=AAA.bbb; sso-rw=CCC.ddd; cf_clearance=zzz"); +}); + +test("parseJsonCookiesToHeader: extra optional fields are ignored gracefully", () => { + const json = `[{"name":"session","value":"abc","domain":".example.com","path":"/","httpOnly":true,"secure":true,"sameSite":"Lax"}]`; + assert.equal(parseJsonCookiesToHeader(json), "session=abc"); +}); + +test("parseJsonCookiesToHeader: missing name throws descriptive error at correct index", () => { + const json = `[{"name":"a","value":"1"},{"value":"no-name"}]`; + assert.throws( + () => parseJsonCookiesToHeader(json), + { message: "Invalid cookie JSON at index 1: missing required field 'name'" } + ); +}); + +test("parseJsonCookiesToHeader: missing value throws descriptive error at correct index", () => { + const json = `[{"name":"a","value":"1"},{"name":"no-value"}]`; + assert.throws( + () => parseJsonCookiesToHeader(json), + { message: "Invalid cookie JSON at index 1: missing required field 'value'" } + ); +}); + +test("parseJsonCookiesToHeader: empty array returns empty string", () => { + assert.equal(parseJsonCookiesToHeader("[]"), ""); +}); + +test("parseJsonCookiesToHeader: raw string (non-JSON) returns null (pass-through)", () => { + assert.equal(parseJsonCookiesToHeader("sso=eyJ0eXAi.abc.def"), null); + assert.equal(parseJsonCookiesToHeader("__Secure-authjs.session-token=abc"), null); + assert.equal(parseJsonCookiesToHeader("bearer xyz"), null); +}); + +test("parseJsonCookiesToHeader: malformed JSON returns null (pass-through, no crash)", () => { + assert.equal(parseJsonCookiesToHeader("[not valid json"), null); + assert.equal(parseJsonCookiesToHeader("{invalid}"), null); +}); + +test("parseJsonCookiesToHeader: empty/whitespace input returns null", () => { + assert.equal(parseJsonCookiesToHeader(""), null); + assert.equal(parseJsonCookiesToHeader(" "), null); +}); + +test("parseJsonCookiesToHeader: parsed non-array JSON returns null", () => { + assert.equal(parseJsonCookiesToHeader(`{"name":"test"}`), null); +}); + +test("parseJsonCookiesToHeader: entry with empty name throws error", () => { + const json = `[{"name":"","value":"abc"}]`; + assert.throws( + () => parseJsonCookiesToHeader(json), + { message: "Invalid cookie JSON at index 0: missing required field 'name'" } + ); +}); + +test("parseJsonCookiesToHeader: entry with empty value returns empty value in header", () => { + const json = `[{"name":"session","value":""}]`; + assert.equal(parseJsonCookiesToHeader(json), "session="); +}); + +// Integration tests via normalizeSessionCookieHeader +test("normalizeSessionCookieHeader: JSON input returns correct header", () => { + const json = `[{"name":"__Secure-authjs.session-token","value":"abc"}]`; + assert.equal( + normalizeSessionCookieHeader(json, "__Secure-authjs.session-token"), + "__Secure-authjs.session-token=abc" + ); +}); + +test("normalizeSessionCookieHeader: JSON input with prefix stripped works", () => { + const json = `[{"name":"sso","value":"eyJ0eXAi.abc"}]`; + assert.equal( + normalizeSessionCookieHeader(`Cookie: ${json}`, "sso"), + "sso=eyJ0eXAi.abc" + ); +}); + +test("normalizeSessionCookieHeader: raw string unchanged after JSON support added", () => { + assert.equal( + normalizeSessionCookieHeader("__Secure-authjs.session-token=abc", "__Secure-authjs.session-token"), + "__Secure-authjs.session-token=abc" + ); + assert.equal( + normalizeSessionCookieHeader("bare-value", "__Secure-authjs.session-token"), + "__Secure-authjs.session-token=bare-value" + ); +});