151 lines
4.4 KiB
TypeScript
151 lines
4.4 KiB
TypeScript
import { createHmac } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import request from "supertest";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { buildApp } from "../src/server";
|
|
|
|
const JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
|
|
const COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
|
|
let app: FastifyInstance;
|
|
|
|
function base64UrlEncode(value: string): string {
|
|
return Buffer.from(value)
|
|
.toString("base64")
|
|
.replace(/=/g, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
}
|
|
|
|
function signHs256Token(payload: Record<string, unknown>, secret: string): string {
|
|
const header = { alg: "HS256", typ: "JWT" };
|
|
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
|
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
|
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
const signature = createHmac("sha256", secret)
|
|
.update(signingInput)
|
|
.digest("base64")
|
|
.replace(/=/g, "")
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_");
|
|
return `${signingInput}.${signature}`;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
app = await buildApp({
|
|
NODE_ENV: "test",
|
|
AUTH_DISABLED: false,
|
|
SEED_DEFAULT_BUDGET: false,
|
|
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
|
|
JWT_SECRET,
|
|
COOKIE_SECRET,
|
|
JWT_ISSUER: "skymoney-api",
|
|
JWT_AUDIENCE: "skymoney-web",
|
|
APP_ORIGIN: "http://localhost:5173",
|
|
});
|
|
await app.ready();
|
|
// Keep JWT verification behavior under test without requiring DB connectivity.
|
|
(app as any).ensureUser = async () => undefined;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (app) await app.close();
|
|
});
|
|
|
|
describe("A04 Cryptographic Failures (runtime adversarial checks)", () => {
|
|
const csrf = "runtime-test-csrf-token";
|
|
|
|
it("rejects token with wrong issuer", async () => {
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
const token = signHs256Token(
|
|
{
|
|
sub: `wrong-iss-${Date.now()}`,
|
|
iss: "attacker-issuer",
|
|
aud: "skymoney-web",
|
|
iat: nowSeconds,
|
|
exp: nowSeconds + 600,
|
|
},
|
|
JWT_SECRET
|
|
);
|
|
|
|
const res = await request(app.server)
|
|
.post("/auth/refresh")
|
|
.set("x-csrf-token", csrf)
|
|
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.code).toBe("UNAUTHENTICATED");
|
|
});
|
|
|
|
it("rejects token with wrong audience", async () => {
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
const token = signHs256Token(
|
|
{
|
|
sub: `wrong-aud-${Date.now()}`,
|
|
iss: "skymoney-api",
|
|
aud: "attacker-app",
|
|
iat: nowSeconds,
|
|
exp: nowSeconds + 600,
|
|
},
|
|
JWT_SECRET
|
|
);
|
|
|
|
const res = await request(app.server)
|
|
.post("/auth/refresh")
|
|
.set("x-csrf-token", csrf)
|
|
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.code).toBe("UNAUTHENTICATED");
|
|
});
|
|
|
|
it("rejects token with alg=none (unsigned token)", async () => {
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
const encodedHeader = base64UrlEncode(JSON.stringify({ alg: "none", typ: "JWT" }));
|
|
const encodedPayload = base64UrlEncode(
|
|
JSON.stringify({
|
|
sub: `none-alg-${Date.now()}`,
|
|
iss: "skymoney-api",
|
|
aud: "skymoney-web",
|
|
iat: nowSeconds,
|
|
exp: nowSeconds + 600,
|
|
})
|
|
);
|
|
const token = `${encodedHeader}.${encodedPayload}.`;
|
|
|
|
const res = await request(app.server)
|
|
.post("/auth/refresh")
|
|
.set("x-csrf-token", csrf)
|
|
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.code).toBe("UNAUTHENTICATED");
|
|
});
|
|
|
|
it("accepts token with correct signature, issuer, and audience", async () => {
|
|
const userId = `valid-${Date.now()}`;
|
|
const findUniqueMock = vi
|
|
.spyOn((app as any).prisma.user, "findUnique")
|
|
.mockResolvedValue({ id: userId, passwordChangedAt: null });
|
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
const token = signHs256Token(
|
|
{
|
|
sub: userId,
|
|
iss: "skymoney-api",
|
|
aud: "skymoney-web",
|
|
iat: nowSeconds,
|
|
exp: nowSeconds + 600,
|
|
},
|
|
JWT_SECRET
|
|
);
|
|
|
|
const res = await request(app.server)
|
|
.post("/auth/refresh")
|
|
.set("x-csrf-token", csrf)
|
|
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.ok).toBe(true);
|
|
findUniqueMock.mockRestore();
|
|
});
|
|
});
|