Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const cspHeader = [
].join("; ");

const securityHeaders = [
// Report-Only first — switch to Content-Security-Policy once violations are reviewed
{ key: "Content-Security-Policy-Report-Only", value: cspHeader },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
Expand All @@ -45,6 +44,7 @@ const nextConfig = {
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
...securityHeaders,
],
},
];
Expand All @@ -61,14 +61,6 @@ const nextConfig = {
]
: [];
},
async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
};

export default withSentryConfig(nextConfig, {
Expand Down
22 changes: 22 additions & 0 deletions src/__tests__/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
describe("CSP header builder", () => {
it("builds a nonce-based report-only policy", () => {
const nonce = "abc123";
const policy = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' https://unpkg.com",
"connect-src 'self' https://horizon.stellar.org https://horizon-testnet.stellar.org https://api.paystack.co https://api.ng.termii.com https://*.ingest.sentry.io",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join("; ");

expect(policy).toContain("default-src 'self'");
expect(policy).toContain(`script-src 'self' 'nonce-${nonce}'`);
expect(policy).toContain("img-src 'self' data: https:");
});
});
26 changes: 26 additions & 0 deletions src/__tests__/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { middleware } from "@/middleware";

describe("middleware CSP headers", () => {
it("adds CSP report-only headers to API responses", () => {
const response = middleware({
headers: new Headers(),
nextUrl: { pathname: "/api/auth/logout" },
method: "GET",
} as any);

expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("default-src 'self'");
expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("script-src");
});

it("adds a nonce-based CSP header for page requests", () => {
const response = middleware({
headers: new Headers(),
nextUrl: { pathname: "/dashboard" },
method: "GET",
} as any);
const csp = response.headers.get("Content-Security-Policy-Report-Only") ?? "";

expect(csp).toContain("default-src 'self'");
expect(csp).toMatch(/script-src 'self' 'nonce-[^']+'/);
});
});
7 changes: 7 additions & 0 deletions src/app/api/cron/cleanup-tokens/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { purgeExpiredPii } from "@/lib/retention";

export async function POST() {
const purged = await purgeExpiredPii();
return NextResponse.json({ ok: true, purged });
}
16 changes: 11 additions & 5 deletions src/app/api/docs/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";

const SPEC_URL = "/api/docs/spec";
const SWAGGER_CSS_URL = "https://unpkg.com/swagger-ui-dist@5/swagger-ui.css";
const SWAGGER_JS_URL = "https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js";
const SWAGGER_CSS_SHA = "sha384-9Q2fpS+xeS4ffJy6CagnwoUl+4ldAYhOs9pgZuEKxypVModhmZFzeMlvVsAjf7uT";
const SWAGGER_JS_SHA = "sha384-IKpAWwsTL0pcw7/Amtnt2eXF4P1BK64WNuY2E/RG15SWLUW5HXzFuyqCSAr/DP8C";

const html = `<!DOCTYPE html>
export async function GET() {
const nonce = headers().get("x-nonce") ?? "";
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ajosave API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<link rel="stylesheet" href="${SWAGGER_CSS_URL}" integrity="${SWAGGER_CSS_SHA}" crossorigin="anonymous" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
<script src="${SWAGGER_JS_URL}" integrity="${SWAGGER_JS_SHA}" crossorigin="anonymous" nonce="${nonce}"></script>
<script nonce="${nonce}">
SwaggerUIBundle({
url: "${SPEC_URL}",
dom_id: "#swagger-ui",
Expand All @@ -25,7 +32,6 @@ const html = `<!DOCTYPE html>
</body>
</html>`;

export function GET() {
return new NextResponse(html, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
Expand Down
41 changes: 41 additions & 0 deletions src/lib/__tests__/refresh-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { revokeUserSessions, rotateRefreshToken, useRefreshToken } from "@/lib/refresh-tokens";
import { query } from "@/lib/db";
import { sendSms } from "@/lib/sms";

jest.mock("@/lib/db", () => ({ query: jest.fn() }));
jest.mock("@/lib/sms", () => ({ sendSms: jest.fn() }));

const mockedQuery = query as jest.MockedFunction<typeof query>;
const mockedSendSms = sendSms as jest.MockedFunction<typeof sendSms>;

describe("refresh token rotation", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("revokes user sessions and notifies SMS when an old token is reused", async () => {
mockedQuery
.mockResolvedValueOnce({ rows: [{ id: "user-1", phone: "+2348000000000" }] } as any)
.mockResolvedValueOnce({ rows: [] } as any)
.mockResolvedValueOnce({ rows: [{ token: "old-token", family_id: "family-1", revoked_at: new Date(), user_id: "user-1" }] } as any)
.mockResolvedValueOnce({ rows: [] } as any)
.mockResolvedValueOnce({ rows: [{ phone: "+2348000000000" }] } as any);

const initial = await rotateRefreshToken("user-1", "family-1");
const reused = await useRefreshToken(initial.token, "user-1");

expect(reused).toBeNull();
expect(mockedSendSms).toHaveBeenCalledWith("+2348000000000", expect.stringContaining("suspicious"));
});

it("issues a new refresh token and marks the previous one as revoked", async () => {
mockedQuery.mockResolvedValueOnce({ rows: [{ id: "user-2", phone: "+2348000000001" }] } as any);
mockedQuery.mockResolvedValueOnce({ rows: [] } as any);
mockedQuery.mockResolvedValueOnce({ rows: [] } as any);

const rotated = await rotateRefreshToken("user-2", "family-2");

expect(rotated.token).toBeTruthy();
expect(rotated.familyId).toBe("family-2");
});
});
21 changes: 21 additions & 0 deletions src/lib/__tests__/retention.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { purgeExpiredPii, anonymizeAuditActor } from "@/lib/retention";
import { query } from "@/lib/db";

jest.mock("@/lib/db", () => ({ query: jest.fn() }));

const mockedQuery = query as jest.MockedFunction<typeof query>;

describe("PII retention policy", () => {
beforeEach(() => jest.clearAllMocks());

it("purges expired PII records and anonymizes actor names", async () => {
mockedQuery.mockResolvedValueOnce({ rows: [{ id: "user-1" }] } as any);
mockedQuery.mockResolvedValueOnce({ rows: [] } as any);

const result = await purgeExpiredPii();
const anonymized = anonymizeAuditActor("Alice");

expect(result).toBeGreaterThanOrEqual(0);
expect(anonymized).toContain("user");
});
});
49 changes: 49 additions & 0 deletions src/lib/refresh-tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { query } from "./db";
import { sendSms } from "./sms";

function createToken(): string {
return `rt_${Math.random().toString(36).slice(2)}_${Date.now()}`;
}

export interface RefreshTokenRecord {
token: string;
familyId: string;
userId: string;
revokedAt?: Date | null;
}

export async function rotateRefreshToken(userId: string, familyId: string): Promise<RefreshTokenRecord> {
const token = createToken();
const userResult = await query<{ id: string; phone: string }>("SELECT id, phone FROM users WHERE id = $1", [userId]);
if (!userResult.rows[0]) {
throw new Error("User not found");
}

await query("INSERT INTO refresh_tokens (user_id, family_id, token, revoked_at, created_at) VALUES ($1, $2, $3, NULL, NOW())", [userId, familyId, token]);
return { token, familyId, userId };
}

export async function useRefreshToken(token: string, userId: string): Promise<RefreshTokenRecord | null> {
const result = await query<{ id: string; token: string; family_id: string; revoked_at: Date | null; user_id: string }>(
"SELECT id, token, family_id, revoked_at, user_id FROM refresh_tokens WHERE token = $1 AND user_id = $2",
[token, userId]
);

const record = (result as { rows?: Array<any> }).rows?.[0];
if (!record || record.revoked_at) {
await revokeUserSessions(userId);
const user = await query<{ phone: string }>("SELECT phone FROM users WHERE id = $1", [userId]);
const phone = (user as { rows?: Array<{ phone?: string }> } | undefined)?.rows?.[0]?.phone;
if (phone) {
await sendSms(phone, "Ajosave security alert: a suspicious refresh token reuse was detected and all your sessions were revoked.");
}
return null;
}

await query("UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1", [token]);
return { token: record.token, familyId: record.family_id, userId: record.user_id };
}

export async function revokeUserSessions(userId: string): Promise<void> {
await query("UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1", [userId]);
}
18 changes: 18 additions & 0 deletions src/lib/retention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { query } from "./db";

const RETENTION = {
pii: 2 * 365 * 24 * 60 * 60 * 1000,
auditLogs: 7 * 365 * 24 * 60 * 60 * 1000,
};

export async function purgeExpiredPii(): Promise<number> {
const cutoff = new Date(Date.now() - RETENTION.pii).toISOString();
const deleteUsers = await query("DELETE FROM users WHERE deleted_at IS NOT NULL AND deleted_at < $1", [cutoff]);
const deleteAuditLogs = await query("DELETE FROM audit_logs WHERE created_at < $1", [new Date(Date.now() - RETENTION.auditLogs).toISOString()]);

return Number(deleteUsers.rowCount ?? 0) + Number(deleteAuditLogs.rowCount ?? 0);
}

export function anonymizeAuditActor(actor: string): string {
return actor ? `deleted-user-${actor.length}` : "deleted-user";
}
7 changes: 7 additions & 0 deletions src/lib/user-deletion.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { query } from "./db";
import { anonymizeAuditActor } from "./retention";

export async function deleteUserAccount(userId: string): Promise<void> {
await query("UPDATE users SET deleted_at = NOW(), phone = NULL, email = NULL WHERE id = $1", [userId]);
await query("UPDATE audit_logs SET actor_name = $1 WHERE actor_id = $2", [anonymizeAuditActor("deleted-user"), userId]);
}
71 changes: 38 additions & 33 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "crypto";

const ALLOWED_ORIGINS = [
process.env.NEXT_PUBLIC_APP_URL,
Expand All @@ -10,55 +11,59 @@ const ALLOWED_ORIGINS = [
.filter(Boolean)
.map((origin) => origin!.trim().replace(/\/$/, ""));

function buildCsp(nonce: string): string {
return [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' https://unpkg.com",
"connect-src 'self' https://horizon.stellar.org https://horizon-testnet.stellar.org https://api.paystack.co https://api.ng.termii.com https://*.ingest.sentry.io",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
].join("; ");
}

export function middleware(request: NextRequest) {
const origin = request.headers.get("origin");
const nonce = randomBytes(16).toString("base64");
const csp = buildCsp(nonce);

const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);

// Handle CORS for API routes
if (request.nextUrl.pathname.startsWith("/api/")) {
const isAllowed = origin && ALLOWED_ORIGINS.includes(origin);
const response = request.method === "OPTIONS"
? new NextResponse(null, { status: 204 })
: NextResponse.next({ request: { headers: requestHeaders } });

// Handle preflight requests
if (request.method === "OPTIONS") {
const response = new NextResponse(null, { status: 204 });

if (isAllowed) {
response.headers.set("Access-Control-Allow-Origin", origin!);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
);
response.headers.set("Access-Control-Allow-Credentials", "true");
response.headers.set("Access-Control-Max-Age", "86400");
}

return response;
}

const response = NextResponse.next();
response.headers.set("Content-Security-Policy-Report-Only", csp);

if (isAllowed) {
response.headers.set("Access-Control-Allow-Origin", origin!);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With, X-CSRF-Token"
);
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With, X-CSRF-Token");
response.headers.set("Access-Control-Allow-Credentials", "true");
response.headers.set("Access-Control-Max-Age", "86400");
}

return response;
}

return NextResponse.next();
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy-Report-Only", csp);

return response;
}

export const config = {
matcher: "/api/:path*",
matcher: [
"/api/:path*",
// Apply CSP to all page routes (exclude static files and _next internals)
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};