212 lines
6.8 KiB
TypeScript
212 lines
6.8 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
import { SESSION_COOKIE_NAME, shouldUseSecureAuthCookie } from "@/lib/auth/config";
|
|
import {
|
|
chunkCookieValue,
|
|
chunkedCookieNames,
|
|
createSignedJsonValue,
|
|
parseSessionCookieValue,
|
|
} from "@/lib/auth/session";
|
|
import {
|
|
clearSessionCookieValues,
|
|
setSessionCookieValue,
|
|
} from "@/lib/server/auth/session-cookie";
|
|
|
|
type CookieContract = {
|
|
name: string;
|
|
chunkSize: number;
|
|
maxChunks: number;
|
|
maxValueLength: number;
|
|
chunkNames: string[];
|
|
writeExample: {
|
|
valueCharacter: string;
|
|
valueLength: number;
|
|
chunkLengths: number[];
|
|
expiresAtUnix: number;
|
|
};
|
|
attributes: {
|
|
httpOnly: boolean;
|
|
sameSite: "lax";
|
|
path: string;
|
|
productionSecure: boolean;
|
|
};
|
|
clear: {
|
|
value: string;
|
|
maxAgeSeconds: number;
|
|
};
|
|
secureResolutionCases: Array<{
|
|
name: string;
|
|
explicit: string;
|
|
publicBaseUrl: string;
|
|
requestUrl: string;
|
|
expected: boolean;
|
|
}>;
|
|
};
|
|
|
|
type SessionCookieFixture = {
|
|
version: 1;
|
|
cookie: CookieContract;
|
|
secret: string;
|
|
rawJson: string;
|
|
payload: string;
|
|
signature: string;
|
|
cookieValue: string;
|
|
};
|
|
|
|
const fixtureUrl = new URL("../contracts/auth/session-cookie-v1.json", import.meta.url);
|
|
const cookieEnvironmentKeys = [
|
|
"ZHINIAN_AUTH_COOKIE_SECURE",
|
|
"NEXT_PUBLIC_APP_URL",
|
|
"ZHINIAN_PUBLIC_BASE_URL",
|
|
] as const;
|
|
const originalCookieEnvironment = new Map(
|
|
cookieEnvironmentKeys.map((key) => [key, process.env[key]])
|
|
);
|
|
|
|
type RecordedCookie = {
|
|
name: string;
|
|
value: string;
|
|
options: Record<string, unknown>;
|
|
};
|
|
|
|
afterEach(() => {
|
|
for (const key of cookieEnvironmentKeys) {
|
|
const value = originalCookieEnvironment.get(key);
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
});
|
|
|
|
async function loadFixture(): Promise<SessionCookieFixture> {
|
|
return JSON.parse(await readFile(fixtureUrl, "utf8")) as SessionCookieFixture;
|
|
}
|
|
|
|
function cookieRecorder() {
|
|
const writes: RecordedCookie[] = [];
|
|
const response = {
|
|
cookies: {
|
|
set(name: string, value: string, options: Record<string, unknown>) {
|
|
writes.push({ name, value, options });
|
|
},
|
|
},
|
|
} as unknown as Parameters<typeof setSessionCookieValue>[0];
|
|
return { response, writes };
|
|
}
|
|
|
|
function configureCookieEnvironment(explicit: string, publicBaseUrl: string) {
|
|
delete process.env.ZHINIAN_AUTH_COOKIE_SECURE;
|
|
delete process.env.NEXT_PUBLIC_APP_URL;
|
|
delete process.env.ZHINIAN_PUBLIC_BASE_URL;
|
|
if (explicit) process.env.ZHINIAN_AUTH_COOKIE_SECURE = explicit;
|
|
if (publicBaseUrl) process.env.NEXT_PUBLIC_APP_URL = publicBaseUrl;
|
|
}
|
|
|
|
describe("session Cookie v1 cross-language contract", () => {
|
|
it("matches the language-neutral HMAC and payload golden vector", async () => {
|
|
const fixture = await loadFixture();
|
|
const rawSession = JSON.parse(fixture.rawJson) as unknown;
|
|
|
|
expect(fixture.version).toBe(1);
|
|
expect(await createSignedJsonValue(rawSession, fixture.secret)).toBe(fixture.cookieValue);
|
|
expect(fixture.cookieValue).toBe(`${fixture.payload}.${fixture.signature}`);
|
|
await expect(parseSessionCookieValue(fixture.cookieValue, fixture.secret, 150)).resolves.toMatchObject({
|
|
version: 1,
|
|
authMode: "admin",
|
|
expiresAt: 200,
|
|
user: {
|
|
id: "auth:customPC:1",
|
|
clientId: "customPC",
|
|
displayName: "张三",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("freezes cookie names, chunk boundaries, attributes, and stale-chunk clearing", async () => {
|
|
const { cookie } = await loadFixture();
|
|
const value = cookie.writeExample.valueCharacter.repeat(cookie.writeExample.valueLength);
|
|
const expires = new Date(cookie.writeExample.expiresAtUnix * 1000);
|
|
const { response, writes } = cookieRecorder();
|
|
configureCookieEnvironment("true", "");
|
|
|
|
expect(SESSION_COOKIE_NAME).toBe(cookie.name);
|
|
expect(chunkedCookieNames(cookie.name, cookie.maxChunks)).toEqual(cookie.chunkNames);
|
|
expect(chunkCookieValue(value, cookie.chunkSize).map((chunk) => chunk.length)).toEqual(
|
|
cookie.writeExample.chunkLengths
|
|
);
|
|
|
|
setSessionCookieValue(response, "http://127.0.0.1:3000", value, expires);
|
|
|
|
expect(writes.map(({ name }) => name)).toEqual(cookie.chunkNames);
|
|
expect(writes.slice(0, cookie.writeExample.chunkLengths.length).map(({ value: part }) => part.length)).toEqual(
|
|
cookie.writeExample.chunkLengths
|
|
);
|
|
for (const write of writes.slice(0, cookie.writeExample.chunkLengths.length)) {
|
|
expect(write.options).toMatchObject({
|
|
httpOnly: cookie.attributes.httpOnly,
|
|
sameSite: cookie.attributes.sameSite,
|
|
secure: cookie.attributes.productionSecure,
|
|
path: cookie.attributes.path,
|
|
expires,
|
|
});
|
|
expect(write.options).not.toHaveProperty("maxAge");
|
|
}
|
|
for (const write of writes.slice(cookie.writeExample.chunkLengths.length)) {
|
|
expect(write.value).toBe(cookie.clear.value);
|
|
expect(write.options).toMatchObject({
|
|
httpOnly: cookie.attributes.httpOnly,
|
|
sameSite: cookie.attributes.sameSite,
|
|
secure: cookie.attributes.productionSecure,
|
|
path: cookie.attributes.path,
|
|
maxAge: cookie.clear.maxAgeSeconds,
|
|
});
|
|
expect(write.options).not.toHaveProperty("expires");
|
|
}
|
|
});
|
|
|
|
it("rejects values beyond the 20-chunk read ceiling before writing", async () => {
|
|
const { cookie } = await loadFixture();
|
|
const { response, writes } = cookieRecorder();
|
|
|
|
expect(() =>
|
|
setSessionCookieValue(
|
|
response,
|
|
"https://app.example.test",
|
|
"x".repeat(cookie.maxValueLength + 1),
|
|
new Date(cookie.writeExample.expiresAtUnix * 1000)
|
|
)
|
|
).toThrow(/maximum supported size/i);
|
|
expect(writes).toEqual([]);
|
|
});
|
|
|
|
it("clears every possible chunk on logout with the legacy attributes", async () => {
|
|
const { cookie } = await loadFixture();
|
|
const { response, writes } = cookieRecorder();
|
|
configureCookieEnvironment("true", "");
|
|
|
|
clearSessionCookieValues(response, "http://127.0.0.1:3000");
|
|
|
|
expect(writes.map(({ name }) => name)).toEqual(cookie.chunkNames);
|
|
expect(writes).toHaveLength(cookie.maxChunks);
|
|
for (const write of writes) {
|
|
expect(write.value).toBe(cookie.clear.value);
|
|
expect(write.options).toEqual({
|
|
httpOnly: cookie.attributes.httpOnly,
|
|
sameSite: cookie.attributes.sameSite,
|
|
secure: cookie.attributes.productionSecure,
|
|
path: cookie.attributes.path,
|
|
maxAge: cookie.clear.maxAgeSeconds,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("uses the shared Secure resolution precedence", async () => {
|
|
const { cookie } = await loadFixture();
|
|
for (const testCase of cookie.secureResolutionCases) {
|
|
configureCookieEnvironment(testCase.explicit, testCase.publicBaseUrl);
|
|
expect(shouldUseSecureAuthCookie(testCase.requestUrl), testCase.name).toBe(testCase.expected);
|
|
}
|
|
});
|
|
});
|