11import bcrypt from "bcryptjs" ;
22import { recordMerchantApiUsage } from "./api-usage.js" ;
33import { verifyApiGatewayRequestSignature } from "./api-gateway-signature.js" ;
4+ import { queryWithRetry } from "./db.js" ;
45
56const SALT_ROUNDS = 12 ;
67
8+ const MERCHANT_SELECT_COLUMNS =
9+ "id, email, business_name, notification_email, branding_config, merchant_settings, webhook_secret, webhook_secret_old, webhook_secret_expiry, webhook_version, payment_limits, api_key, api_key_expires_at, api_key_old, api_key_old_expires_at" ;
10+
11+ // Auth failure rate limiting per client IP (issue #767)
12+ const AUTH_FAIL_RATE_LIMIT_MAX = Number ( process . env . AUTH_FAIL_RATE_LIMIT_MAX || 10 ) ;
13+ const AUTH_FAIL_RATE_LIMIT_WINDOW_MS = Number ( process . env . AUTH_FAIL_RATE_LIMIT_WINDOW_MS || 60_000 ) ;
14+ const _authFailState = new Map ( ) ;
15+
16+ export function _resetAuthFailStateForTests ( ) {
17+ _authFailState . clear ( ) ;
18+ }
19+
20+ function isAuthRateLimited ( ip , now = Date . now ( ) ) {
21+ const state = _authFailState . get ( ip ) ;
22+ if ( ! state || now >= state . windowStart + AUTH_FAIL_RATE_LIMIT_WINDOW_MS ) return false ;
23+ return state . count >= AUTH_FAIL_RATE_LIMIT_MAX ;
24+ }
25+
26+ function recordAuthFailure ( ip , now = Date . now ( ) ) {
27+ const state = _authFailState . get ( ip ) ;
28+ if ( ! state || now >= state . windowStart + AUTH_FAIL_RATE_LIMIT_WINDOW_MS ) {
29+ _authFailState . set ( ip , { count : 1 , windowStart : now } ) ;
30+ } else {
31+ state . count += 1 ;
32+ }
33+ }
34+
35+ // Single-query lookup covering both current and rotated API keys.
36+ // deleted_at IS NULL applies to both paths — previously missing on the old-key
37+ // path which allowed deleted merchants to authenticate via a rotated key (#767).
38+ // queryWithRetry handles transient DB failures automatically (#766).
39+ async function defaultMerchantLookup ( apiKey ) {
40+ const result = await queryWithRetry (
41+ `SELECT ${ MERCHANT_SELECT_COLUMNS }
42+ FROM merchants
43+ WHERE deleted_at IS NULL
44+ AND (api_key = $1 OR api_key_old = $1)
45+ LIMIT 1` ,
46+ [ apiKey ] ,
47+ { label : "auth-merchant-lookup" } ,
48+ ) ;
49+ return result . rows [ 0 ] || null ;
50+ }
51+
752/**
853 * Hash a plain-text merchant password with bcrypt.
954 * @param {string } plaintext
@@ -24,28 +69,25 @@ export async function verifyPassword(plaintext, hash) {
2469}
2570
2671export function createApiKeyAuth ( {
27- supabaseClient = null ,
72+ supabaseClient = null , // unused for API key auth; retained for session-auth compat
2873 usageRecorder = recordMerchantApiUsage ,
2974 verifyGatewaySignature = verifyApiGatewayRequestSignature ,
3075 requireSignature = false ,
76+ merchantLookup = defaultMerchantLookup ,
3177} = { } ) {
3278 return async function requireApiKeyAuth ( req , res , next ) {
3379 try {
34- // Another auth layer (for example x402 token bridge) may have already
35- // attached a merchant context. If so, honor it and continue.
80+ // Another auth layer (e.g. x402 token bridge) may have already attached a
81+ // merchant context. If so, honor it and continue.
3682 if ( req . merchant ?. id ) {
3783 try {
38- await usageRecorder ( {
39- merchantId : req . merchant . id ,
40- req,
41- } ) ;
84+ await usageRecorder ( { merchantId : req . merchant . id , req } ) ;
4285 } catch ( usageError ) {
4386 console . warn ( "Failed to record merchant API usage:" , usageError . message ) ;
4487 }
4588 return next ( ) ;
4689 }
4790
48- const client = supabaseClient || ( await import ( "./supabase.js" ) ) . supabase ;
4991 const headerValue = req . get ( "x-api-key" ) ;
5092 const apiKey = typeof headerValue === "string" ? headerValue . trim ( ) : "" ;
5193 const signatureHeader = req . get ( "x-api-signature" ) ;
@@ -55,11 +97,10 @@ export function createApiKeyAuth({
5597 return res . status ( 401 ) . json ( { error : "Missing x-api-key header" } ) ;
5698 }
5799
58- // Backward-compatible API gateway hardening: verify request HMAC
59- // signature only when signature headers are supplied by the client.
60100 const hasSignatureHeader =
61101 typeof signatureHeader === "string" && signatureHeader . trim ( ) . startsWith ( "sha256=" ) ;
62- const hasTimestampHeader = typeof timestampHeader === "string" && timestampHeader . trim ( ) . length > 0 ;
102+ const hasTimestampHeader =
103+ typeof timestampHeader === "string" && timestampHeader . trim ( ) . length > 0 ;
63104 const signatureProvided = hasSignatureHeader && hasTimestampHeader ;
64105
65106 if ( requireSignature && ! signatureProvided ) {
@@ -88,74 +129,49 @@ export function createApiKeyAuth({
88129 }
89130 }
90131
91- // First try to find merchant by current API key
92- let { data : merchant , error } = await client
93- . from ( "merchants" )
94- . select (
95- "id, email, business_name, notification_email, branding_config, merchant_settings, webhook_secret, webhook_secret_old, webhook_secret_expiry, webhook_version, payment_limits, api_key, api_key_expires_at, api_key_old, api_key_old_expires_at"
96- )
97- . eq ( "api_key" , apiKey )
98- . is ( "deleted_at" , null )
99- . maybeSingle ( ) ;
100-
101- if ( error ) {
102- error . status = 500 ;
103- throw error ;
104- }
132+ // Block IPs that have exceeded the failed-attempt threshold (#767)
133+ const clientIp = req . ip || "unknown" ;
134+ if ( isAuthRateLimited ( clientIp ) ) {
135+ return res . status ( 429 ) . json ( {
136+ error : "Too many failed authentication attempts" ,
137+ code : "AUTH_RATE_LIMITED" ,
138+ } ) ;
139+ }
105140
106- // If not found by current key, check if it's the old key during rotation overlap
107- if ( ! merchant ) {
108- const { data : oldKeyMerchant , error : oldKeyError } = await client
109- . from ( "merchants" )
110- . select (
111- "id, email, business_name, notification_email, branding_config, merchant_settings, webhook_secret, webhook_secret_old, webhook_secret_expiry, webhook_version, payment_limits, api_key, api_key_expires_at, api_key_old, api_key_old_expires_at"
112- )
113- . eq ( "api_key_old" , apiKey )
114- . maybeSingle ( ) ;
115-
116- if ( oldKeyError ) {
117- oldKeyError . status = 500 ;
118- throw oldKeyError ;
119- }
141+ // Single combined query for current and rotated keys (#765).
142+ // Retry logic is provided by queryWithRetry (#766).
143+ let merchant ;
144+ try {
145+ merchant = await merchantLookup ( apiKey ) ;
146+ } catch ( err ) {
147+ err . status = 500 ;
148+ throw err ;
149+ }
120150
121- if ( ! oldKeyMerchant ) {
122- return res . status ( 401 ) . json ( { error : "Invalid API key" } ) ;
123- }
151+ if ( ! merchant ) {
152+ recordAuthFailure ( clientIp ) ;
153+ return res . status ( 401 ) . json ( { error : "Invalid API key" } ) ;
154+ }
124155
125- // Check if old key has expired (overlap period ended)
126- const now = new Date ( ) ;
127- if (
128- oldKeyMerchant . api_key_old_expires_at &&
129- new Date ( oldKeyMerchant . api_key_old_expires_at ) < now
130- ) {
131- return res . status ( 401 ) . json ( {
132- error : "API key has expired. Please rotate to a new key." ,
133- code : "API_KEY_EXPIRED"
134- } ) ;
135- }
156+ // Determine which key matched to validate the correct expiry field
157+ const now = new Date ( ) ;
158+ const usedCurrentKey = merchant . api_key === apiKey ;
159+ const expiresAt = usedCurrentKey
160+ ? merchant . api_key_expires_at
161+ : merchant . api_key_old_expires_at ;
136162
137- merchant = oldKeyMerchant ;
138- } else {
139- // Check if current API key has expired
140- const now = new Date ( ) ;
141- if (
142- merchant . api_key_expires_at &&
143- new Date ( merchant . api_key_expires_at ) < now
144- ) {
145- return res . status ( 401 ) . json ( {
146- error : "API key has expired. Please rotate to a new key." ,
147- code : "API_KEY_EXPIRED"
148- } ) ;
149- }
150- }
163+ if ( expiresAt && new Date ( expiresAt ) < now ) {
164+ recordAuthFailure ( clientIp ) ;
165+ return res . status ( 401 ) . json ( {
166+ error : "API key has expired. Please rotate to a new key." ,
167+ code : "API_KEY_EXPIRED" ,
168+ } ) ;
169+ }
151170
152171 req . merchant = merchant ;
153172
154173 try {
155- await usageRecorder ( {
156- merchantId : merchant . id ,
157- req,
158- } ) ;
174+ await usageRecorder ( { merchantId : merchant . id , req } ) ;
159175 } catch ( usageError ) {
160176 // Usage metrics should never block API traffic.
161177 console . warn ( "Failed to record merchant API usage:" , usageError . message ) ;
@@ -190,7 +206,7 @@ export function requireSessionAuth() {
190206
191207 const client = ( await import ( "./supabase.js" ) ) . supabase ;
192208 const merchantId = payload . id || payload . merchant_id ;
193-
209+
194210 if ( ! merchantId ) {
195211 return res . status ( 401 ) . json ( { error : "Invalid token payload: missing merchant identification" } ) ;
196212 }
0 commit comments