fix: support Express IncomingHttpHeaders for client info extraction

- Add getHeaderValue() helper that works with multiple header formats:
  - Express req.get() method
  - Fetch API headers.get() method
  - Direct IncomingHttpHeaders object access
- Add isRequest() type guard to distinguish PayloadRequest from ClientInfo
- Use extractClientInfo() helper for consistent request/ClientInfo handling
- Apply same fix in auditAuthEvents.ts for hook context

This fixes the issue where PayloadRequest objects were incorrectly
detected as ClientInfo because IncomingHttpHeaders doesn't have .get()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-07 22:34:19 +00:00
parent 47d912016b
commit 0c0892f9de
2 changed files with 142 additions and 36 deletions

View file

@ -62,19 +62,61 @@ export const auditAfterLogout: CollectionAfterLogoutHook = async ({ req }) => {
console.log(`[Audit:Auth] Logout for ${user.email}`) console.log(`[Audit:Auth] Logout for ${user.email}`)
} }
/**
* Extrahiert einen Header-Wert aus verschiedenen Header-Formaten
*
* Unterstützt:
* - Express/Node IncomingHttpHeaders (object mit lowercase keys)
* - Fetch API Headers (mit .get() Methode)
* - Request mit .get() Methode (Express-style)
*/
function getHeaderValue(req: PayloadRequest, headerName: string): string | undefined {
const lowerName = headerName.toLowerCase()
// 1. Versuche req.get() (Express Request Methode)
if (typeof (req as { get?: (name: string) => string | undefined }).get === 'function') {
const value = (req as { get: (name: string) => string | undefined }).get(lowerName)
if (value) return value
}
// 2. Versuche headers.get() (Fetch API Headers)
if (req.headers && typeof (req.headers as { get?: (name: string) => string | null }).get === 'function') {
const value = (req.headers as { get: (name: string) => string | null }).get(lowerName)
if (value) return value
}
// 3. Direkter Zugriff auf headers object (IncomingHttpHeaders)
if (req.headers && typeof req.headers === 'object') {
const headers = req.headers as Record<string, string | string[] | undefined>
const value = headers[lowerName]
if (typeof value === 'string') return value
if (Array.isArray(value) && value.length > 0) return value[0]
}
return undefined
}
/** /**
* Extrahiert Client-Info aus einem PayloadRequest * Extrahiert Client-Info aus einem PayloadRequest
*
* Funktioniert mit:
* - PayloadRequest (Express-basiert mit IncomingHttpHeaders)
* - Next.js Request (Fetch API basiert)
*/ */
function extractClientInfo(req?: PayloadRequest): ClientInfo { function extractClientInfo(req?: PayloadRequest): ClientInfo {
if (!req?.headers?.get) return {} if (!req) return {}
const forwarded = req.headers.get('x-forwarded-for') const forwarded = getHeaderValue(req, 'x-forwarded-for')
const realIp = req.headers.get('x-real-ip') const realIp = getHeaderValue(req, 'x-real-ip')
const ipAddress =
(typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined) ||
(typeof realIp === 'string' ? realIp : undefined)
const userAgent = req.headers.get('user-agent') || undefined let ipAddress: string | undefined
if (forwarded) {
ipAddress = forwarded.split(',')[0]?.trim()
} else if (realIp) {
ipAddress = realIp
}
const userAgent = getHeaderValue(req, 'user-agent')
return { ipAddress, userAgent } return { ipAddress, userAgent }
} }

View file

@ -50,21 +50,65 @@ export interface AuditLogInput {
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} }
/**
* Extrahiert einen Header-Wert aus verschiedenen Header-Formaten
*
* Unterstützt:
* - Express/Node IncomingHttpHeaders (object mit lowercase keys)
* - Fetch API Headers (mit .get() Methode)
* - Request mit .get() Methode (Express-style)
*/
function getHeaderValue(
req: PayloadRequest,
headerName: string,
): string | undefined {
const lowerName = headerName.toLowerCase()
// 1. Versuche req.get() (Express Request Methode)
if (typeof (req as { get?: (name: string) => string | undefined }).get === 'function') {
const value = (req as { get: (name: string) => string | undefined }).get(lowerName)
if (value) return value
}
// 2. Versuche headers.get() (Fetch API Headers)
if (req.headers && typeof (req.headers as { get?: (name: string) => string | null }).get === 'function') {
const value = (req.headers as { get: (name: string) => string | null }).get(lowerName)
if (value) return value
}
// 3. Direkter Zugriff auf headers object (IncomingHttpHeaders)
if (req.headers && typeof req.headers === 'object') {
const headers = req.headers as Record<string, string | string[] | undefined>
const value = headers[lowerName]
if (typeof value === 'string') return value
if (Array.isArray(value) && value.length > 0) return value[0]
}
return undefined
}
/** /**
* Extrahiert Client-Informationen aus dem Request * Extrahiert Client-Informationen aus dem Request
*
* Funktioniert mit:
* - PayloadRequest (Express-basiert mit IncomingHttpHeaders)
* - Next.js Request (Fetch API basiert)
*/ */
function getClientInfo(req?: PayloadRequest): { ipAddress?: string; userAgent?: string } { function getClientInfo(req?: PayloadRequest): { ipAddress?: string; userAgent?: string } {
if (!req) return {} if (!req) return {}
// IP-Adresse aus verschiedenen Headern extrahieren // IP-Adresse aus verschiedenen Headern extrahieren
const forwarded = req.headers?.get?.('x-forwarded-for') const forwarded = getHeaderValue(req, 'x-forwarded-for')
const realIp = req.headers?.get?.('x-real-ip') const realIp = getHeaderValue(req, 'x-real-ip')
const ipAddress =
(typeof forwarded === 'string' ? forwarded.split(',')[0]?.trim() : undefined) ||
(typeof realIp === 'string' ? realIp : undefined) ||
'unknown'
const userAgent = req.headers?.get?.('user-agent') || undefined let ipAddress: string | undefined
if (forwarded) {
ipAddress = forwarded.split(',')[0]?.trim()
} else if (realIp) {
ipAddress = realIp
}
const userAgent = getHeaderValue(req, 'user-agent')
return { ipAddress, userAgent } return { ipAddress, userAgent }
} }
@ -156,6 +200,46 @@ export interface ClientInfo {
userAgent?: string userAgent?: string
} }
/**
* Prüft ob ein Objekt ein Request ist (hat headers property)
* im Gegensatz zu einem einfachen ClientInfo-Objekt
*/
function isRequest(obj: unknown): obj is PayloadRequest {
if (!obj || typeof obj !== 'object') return false
// Ein Request hat typischerweise headers, method, url etc.
// ClientInfo hat nur ipAddress und/oder userAgent
const asRecord = obj as Record<string, unknown>
// Wenn es headers hat, ist es ein Request
if ('headers' in asRecord) return true
// Wenn es method oder url hat, ist es ein Request
if ('method' in asRecord || 'url' in asRecord) return true
// Wenn es NUR ipAddress und/oder userAgent hat, ist es ClientInfo
const keys = Object.keys(asRecord)
const clientInfoKeys = ['ipAddress', 'userAgent']
if (keys.every((key) => clientInfoKeys.includes(key))) return false
// Im Zweifel als Request behandeln
return true
}
/**
* Extrahiert ClientInfo aus einem Request oder gibt das übergebene ClientInfo zurück
*/
function extractClientInfo(reqOrClientInfo?: PayloadRequest | ClientInfo): ClientInfo {
if (!reqOrClientInfo) return {}
if (isRequest(reqOrClientInfo)) {
return getClientInfo(reqOrClientInfo)
}
// Es ist bereits ClientInfo
return reqOrClientInfo
}
/** /**
* Loggt einen fehlgeschlagenen Login * Loggt einen fehlgeschlagenen Login
* *
@ -170,18 +254,7 @@ export async function logLoginFailed(
reason: string, reason: string,
reqOrClientInfo?: PayloadRequest | ClientInfo, reqOrClientInfo?: PayloadRequest | ClientInfo,
): Promise<void> { ): Promise<void> {
// Unterscheide zwischen PayloadRequest und direktem ClientInfo const clientInfo = extractClientInfo(reqOrClientInfo)
let clientInfo: ClientInfo = {}
if (reqOrClientInfo) {
if ('headers' in reqOrClientInfo && typeof reqOrClientInfo.headers?.get === 'function') {
// Es ist ein PayloadRequest - extrahiere Client-Info
clientInfo = getClientInfo(reqOrClientInfo as PayloadRequest)
} else {
// Es ist direktes ClientInfo
clientInfo = reqOrClientInfo as ClientInfo
}
}
await createAuditLog( await createAuditLog(
payload, payload,
@ -211,16 +284,7 @@ export async function logPasswordReset(
email: string, email: string,
reqOrClientInfo?: PayloadRequest | ClientInfo, reqOrClientInfo?: PayloadRequest | ClientInfo,
): Promise<void> { ): Promise<void> {
// Unterscheide zwischen PayloadRequest und direktem ClientInfo const clientInfo = extractClientInfo(reqOrClientInfo)
let clientInfo: ClientInfo = {}
if (reqOrClientInfo) {
if ('headers' in reqOrClientInfo && typeof reqOrClientInfo.headers?.get === 'function') {
clientInfo = getClientInfo(reqOrClientInfo as PayloadRequest)
} else {
clientInfo = reqOrClientInfo as ClientInfo
}
}
await createAuditLog( await createAuditLog(
payload, payload,