fix(build): use Web Crypto API for Edge Runtime compatibility + final build verification

- Rewrite admin-session.ts to use Web Crypto API (crypto.subtle) instead
  of Node.js crypto module, ensuring compatibility with Edge Runtime
  in middleware
- Add ADMIN_SESSION_SECRET to .env.example
- Build passes with zero warnings
This commit is contained in:
dimitar 2026-07-29 19:07:43 +02:00
parent 94ef7901e2
commit 0d00b1ec9c
4 changed files with 40 additions and 24 deletions

View File

@ -19,4 +19,7 @@ S3_BUCKET_NAME=monuments-images
# App
NEXT_PUBLIC_APP_URL=https://testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
NEXT_PUBLIC_APP_DOMAIN=testbed.mk
# Admin
ADMIN_SESSION_SECRET=your-random-64-char-secret-here-change-it-in-production

View File

@ -14,7 +14,7 @@ export async function POST(req: NextRequest) {
}
if (username === SUPER_USERNAME && password === SUPER_PASSWORD) {
const session = createAdminSession({ username: SUPER_USERNAME, role: "SUPER_ADMIN" });
const session = await createAdminSession({ username: SUPER_USERNAME, role: "SUPER_ADMIN" });
const res = NextResponse.json({ success: true });
res.cookies.set(cookieOptions(session));
return res;
@ -30,7 +30,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Невалидно корисничко име или лозинка" }, { status: 401 });
}
const session = createAdminSession({ username: admin.username, role: admin.role });
const session = await createAdminSession({ username: admin.username, role: admin.role });
const res = NextResponse.json({ success: true });
res.cookies.set(cookieOptions(session));
return res;

View File

@ -1,5 +1,4 @@
import { cookies } from "next/headers";
import { createHmac, timingSafeEqual } from "crypto";
const COOKIE_NAME = "admin_session";
const SEP = ".";
@ -10,10 +9,17 @@ function getSecret(): string {
return secret;
}
function sign(payload: string): string {
const hmac = createHmac("sha256", getSecret());
hmac.update(payload);
return hmac.digest("base64url");
async function sign(payload: string): Promise<string> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(getSecret()),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
export interface AdminSession {
@ -21,37 +27,37 @@ export interface AdminSession {
role: "SUPER_ADMIN" | "ADMIN";
}
export function createAdminSession(data: AdminSession): string {
const payload = Buffer.from(JSON.stringify(data)).toString("base64url");
return payload + SEP + sign(payload);
export async function createAdminSession(data: AdminSession): Promise<string> {
const payload = btoa(JSON.stringify(data));
return payload + SEP + (await sign(payload));
}
export function verifyAdminSession(token: string): AdminSession | null {
export async function verifyAdminSession(token: string): Promise<AdminSession | null> {
const sepIdx = token.lastIndexOf(SEP);
if (sepIdx === -1) return null;
const payload = token.slice(0, sepIdx);
const sig = token.slice(sepIdx + 1);
const expectedSig = sign(payload);
const sigBuf = Buffer.from(sig);
const expectedBuf = Buffer.from(expectedSig);
if (sigBuf.length !== expectedBuf.length) return null;
const expectedSig = await sign(payload);
if (sig.length !== expectedSig.length) return null;
try {
if (!timingSafeEqual(sigBuf, expectedBuf)) return null;
if (!constantTimeEqual(sig, expectedSig)) return null;
} catch {
return null;
}
try {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
return JSON.parse(atob(payload));
} catch {
return null;
}
}
export async function getAdminSession(): Promise<AdminSession | null> {
const store = await cookies();
const token = store.get(COOKIE_NAME)?.value;
if (!token) return null;
return verifyAdminSession(token);
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
export const COOKIE_NAME_ADMIN = COOKIE_NAME;
@ -66,3 +72,10 @@ export function cookieOptions(value: string) {
path: "/admin",
};
}
export async function getAdminSession(): Promise<AdminSession | null> {
const store = await cookies();
const token = store.get(COOKIE_NAME)?.value;
if (!token) return null;
return verifyAdminSession(token);
}

View File

@ -36,7 +36,7 @@ export default clerkMiddleware(async (auth, req: NextRequest) => {
}
const token = req.cookies.get(COOKIE_NAME_ADMIN)?.value;
if (!token || !verifyAdminSession(token)) {
if (!token || !(await verifyAdminSession(token))) {
if (req.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
}