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
33 changes: 33 additions & 0 deletions src/lib/admin-auth-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import crypto from "crypto";
import type { Request } from "express";
import type { AuthenticatedRequest } from "@/types/auth";

/**
* Timing-safe string comparison (#607).
*/
export function isTimingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

/**
* Validates admin authentication via either valid admin JWT or x-admin-key (#607, #608).
*/
export function isValidAdminAuth(req: Request): boolean {
const authReq = req as AuthenticatedRequest;
if (authReq.user && authReq.user.role === "admin") {
return true;
}

const suppliedKey = req.headers["x-admin-key"];
const expectedKey = process.env.ADMIN_API_KEY;

if (typeof suppliedKey === "string" && typeof expectedKey === "string" && expectedKey.length > 0) {
if (expectedKey === "dev-admin-key" && process.env.NODE_ENV === "production") {
return false; // Reject weak default keys in production
}
return isTimingSafeEqual(suppliedKey, expectedKey);
}

return false;
}
4 changes: 2 additions & 2 deletions src/middleware/admin-auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface AdminTokenPayload {
export function authenticateAdminJWT(req: Request, _res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization;

if (!authHeader?.startsWith("Bearer ")) {
if (!authHeader || !/^bearer\s+/i.test(authHeader)) {
next(
new HttpError(
401,
Expand All @@ -27,7 +27,7 @@ export function authenticateAdminJWT(req: Request, _res: Response, next: NextFun
return;
}

const token = authHeader.slice(7);
const token = authHeader.replace(/^bearer\s+/i, "");

try {
const secret = process.env.ADMIN_JWT_SECRET ?? process.env.JWT_SECRET;
Expand Down
16 changes: 15 additions & 1 deletion src/middleware/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,25 @@ export async function authenticateJWT(req: Request, _res: Response, next: NextFu
}

const { userId, stellarAddress, wallet, userType, role } = claims as Partial<AuthTokenPayload>;
const resolvedAddress = nonEmptyString(wallet) ?? nonEmptyString(stellarAddress) ?? subject;

// Issue #610: Check wallet suspension status
const { isWalletSuspended } = await import("@/middleware/suspended-wallet.middleware");
if (isWalletSuspended(resolvedAddress)) {
next(
new HttpError(
403,
"Wallet is suspended and cannot perform authenticated actions.",
buildAuthFailureDetails(token, "wallet_suspended")
)
);
return;
}

(req as AuthenticatedRequest).user = {
id: nonEmptyString(userId) ?? subject,
// Tokens are issued with the wallet address as subject.
stellarAddress: nonEmptyString(wallet) ?? nonEmptyString(stellarAddress) ?? subject,
stellarAddress: resolvedAddress,
email: null,
userType: userType || role || (null as unknown as UserType),
kycStatus: null as unknown as KYCStatus,
Expand Down
4 changes: 2 additions & 2 deletions src/routes/admin/approve-invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Request, Response } from "express";
import type { InvoiceService } from "@/services/invoice.service";
import { ServiceError } from "@/utils/service-error";
import { logger } from "@/observability/logger";
import { isValidAdminAuth } from "@/lib/admin-auth-helper";

interface ApproveInvoiceParams {
id: string;
Expand All @@ -21,8 +22,7 @@ export async function approveInvoice(
invoiceService: InvoiceService
) {
try {
const adminKey = req.headers["x-admin-key"];
if (adminKey !== process.env.ADMIN_API_KEY) {
if (!isValidAdminAuth(req)) {
return res.status(401).json({ error: "Unauthorized" });
}

Expand Down
4 changes: 2 additions & 2 deletions src/routes/admin/approve-kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { User } from "@/models/User.model";
import { KYCStatus } from "@/types/enums";
import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log";
import { logger } from "@/observability/logger";
import { isValidAdminAuth } from "@/lib/admin-auth-helper";

interface ApproveKYCBody {
userId: string;
Expand All @@ -16,10 +17,9 @@ export async function approveKYC(
dataSource: DataSource
) {
try {
const adminKey = req.headers["x-admin-key"];
const correlationId = req.headers["x-request-id"] as string | undefined;

if (adminKey !== process.env.ADMIN_API_KEY) {
if (!isValidAdminAuth(req)) {
logKYCReviewFailure(logger, {
userId: req.body?.userId,
reviewerId: req.body?.reviewerId,
Expand Down