103 lines
6.4 KiB
JavaScript
103 lines
6.4 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createAuthGateway } from "../src/lib/auth-gateway.mjs";
|
|
import { COOKIE_NAME } from "../src/lib/auth-policy.mjs";
|
|
const origin = "http://localhost:17322";
|
|
const config = { project: "thesisly", appOrigin: origin, apiOrigin: "https://core.example.test" };
|
|
function request(path, { method = "POST", headers = {}, body = "{}" } = {}) {
|
|
return new Request(`${origin}/api/foundry/${path}`, { method, headers: { origin, "content-type": "application/json", ...headers }, ...(method === "GET" ? {} : { body }) });
|
|
}
|
|
|
|
test("exchange keeps tokens in Cookie and forwards only approved headers", async () => {
|
|
const gateway = createAuthGateway(config, async (url, options) => {
|
|
assert.equal(url, "https://core.example.test/projects/thesisly/auth/exchange");
|
|
assert.equal(options.headers.get("x-foundry-code"), "single-use-code");
|
|
assert.equal(options.headers.get("x-foundry-verifier"), "tab-verifier");
|
|
assert.equal(options.headers.get("authorization"), null);
|
|
assert.equal(options.headers.get("cookie"), null);
|
|
assert.equal(options.redirect, "error");
|
|
assert.equal(options.cache, "no-store");
|
|
return Response.json({ access_token: "session-token", refresh_token: "never-expose", expires_in: 90 });
|
|
});
|
|
const response = await gateway(request("auth/exchange", { headers: { "x-foundry-code": "single-use-code", "x-foundry-verifier": "tab-verifier", authorization: "Bearer injected", cookie: "other_app_session=private" } }), "auth/exchange");
|
|
assert.equal(response.status, 200);
|
|
assert.match(response.headers.get("set-cookie"), /session-token; HttpOnly; SameSite=Lax; Path=\/; Max-Age=90$/);
|
|
assert.deepEqual(await response.json(), { expires_in: 90 });
|
|
assert.equal(response.headers.get("cache-control"), "no-store");
|
|
});
|
|
|
|
test("login and email verification require usable session responses", async () => {
|
|
for (const path of ["auth/login", "auth/verify-email"]) {
|
|
const valid = createAuthGateway({ ...config, appOrigin: "https://thesisly.example.test" }, async () => Response.json({ access_token: "token", expires_in: 90000000 }));
|
|
const ok = await valid(request(path, { headers: { origin: "https://thesisly.example.test" } }), path);
|
|
assert.equal(ok.status, 200);
|
|
assert.match(ok.headers.get("set-cookie"), /Max-Age=2592000; Secure$/);
|
|
for (const payload of [{ access_token: "token" }, { access_token: "token", expires_in: 0 }, { expires_in: 100 }, null]) {
|
|
const response = await createAuthGateway(config, async () => Response.json(payload))(request(path), path);
|
|
assert.equal(response.status, 502);
|
|
assert.equal(response.headers.get("set-cookie"), null);
|
|
assert.deepEqual(await response.json(), { error: "invalid_session_response" });
|
|
}
|
|
}
|
|
});
|
|
|
|
test("registration may request email verification without issuing a session", async () => {
|
|
const response = await createAuthGateway(config, async () => Response.json({ status: "verification_required" }, { status: 202 }))(request("auth/register"), "auth/register");
|
|
assert.equal(response.status, 202);
|
|
assert.equal(response.headers.get("set-cookie"), null);
|
|
});
|
|
|
|
test("restore uses only the site Cookie and honors the short renewal deadline", async () => {
|
|
const gateway = createAuthGateway(config, async (_url, options) => {
|
|
assert.equal(options.headers.get("authorization"), "Bearer stored-token");
|
|
assert.equal(options.headers.get("cookie"), null);
|
|
return Response.json({ id: "account-id", email: null }, { headers: { "X-Foundry-Session-Expires-In": "7" } });
|
|
});
|
|
const response = await gateway(request("auth/me", { method: "GET", headers: { cookie: `${COOKIE_NAME}=stored-token` } }), "auth/me");
|
|
assert.equal(response.status, 200);
|
|
assert.match(response.headers.get("set-cookie"), /Max-Age=7$/);
|
|
});
|
|
|
|
test("logout revokes before clearing; upstream failure preserves the local Cookie", async () => {
|
|
const req = () => request("auth/logout", { headers: { cookie: `${COOKIE_NAME}=stored-token` } });
|
|
const done = await createAuthGateway(config, async () => new Response(null, { status: 204 }))(req(), "auth/logout");
|
|
assert.equal(done.status, 204);
|
|
assert.match(done.headers.get("set-cookie"), /Max-Age=0$/);
|
|
const failed = await createAuthGateway(config, async () => { throw new Error("offline"); })(req(), "auth/logout");
|
|
assert.equal(failed.status, 502);
|
|
assert.equal(failed.headers.get("set-cookie"), null);
|
|
});
|
|
|
|
test("401 clears an expired session and strips unexpected refresh tokens", async () => {
|
|
const gateway = createAuthGateway(config, async () => Response.json({ error: "invalid_session", refresh_token: "private" }, { status: 401 }));
|
|
const response = await gateway(request("auth/me", { method: "GET", headers: { cookie: `${COOKIE_NAME}=expired` } }), "auth/me");
|
|
assert.match(response.headers.get("set-cookie"), /Max-Age=0$/);
|
|
assert.deepEqual(await response.json(), { error: "invalid_session" });
|
|
});
|
|
|
|
test("invalid paths, methods, origins, cookies and bodies never reach Core", async () => {
|
|
let calls = 0;
|
|
const gateway = createAuthGateway(config, async () => { ++calls; throw new Error("must not call"); });
|
|
const cases = [
|
|
["canvas", request("canvas"), 404],
|
|
["auth/login", request("auth/login", { method: "GET" }), 405],
|
|
["auth/login", request("auth/login", { headers: { origin: "https://other.example.test" } }), 403],
|
|
["auth/login", request("auth/login", { headers: { "sec-fetch-site": "cross-site" } }), 403],
|
|
["auth/login", request("auth/login", { body: "not json" }), 400],
|
|
["auth/login", request("auth/login", { body: JSON.stringify({ data: "x".repeat(16384) }) }), 413],
|
|
["auth/me", request("auth/me", { method: "GET" }), 401],
|
|
["auth/me", request("auth/me", { method: "GET", headers: { cookie: `${COOKIE_NAME}=%E0%A4%A` } }), 401],
|
|
];
|
|
for (const [path, req, expected] of cases) assert.equal((await gateway(req, path)).status, expected);
|
|
assert.equal(calls, 0);
|
|
});
|
|
|
|
test("rate limits retain Retry-After without minting or exposing credentials", async () => {
|
|
const gateway = createAuthGateway(config, async () => Response.json({ error: "rate_limited", access_token: "private", refresh_token: "private" }, { status: 429, headers: { "retry-after": "30" } }));
|
|
const response = await gateway(request("auth/login"), "auth/login");
|
|
assert.equal(response.status, 429);
|
|
assert.equal(response.headers.get("retry-after"), "30");
|
|
assert.equal(response.headers.get("set-cookie"), null);
|
|
assert.deepEqual(await response.json(), { error: "rate_limited" });
|
|
});
|