diff --git a/apps/api-server/package.json b/apps/api-server/package.json index b16262f..4143808 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -10,7 +10,7 @@ "lint": "eslint . --cache", "clean": "rm -rf dist .eslintcache", "test:e2e": "bun test src/__tests__/e2e --no-parallel", - "test:unit": "bun test src/__tests__/unit" + "test": "bun test --coverage src/utils" }, "dependencies": { "@formatjs/intl-durationformat": "^0.9.2", @@ -25,6 +25,7 @@ "@simplewebauthn/server": "^13.2.2", "@simplewebauthn/types": "^12.0.0", "kysely": "^0.28.2", + "maxmind": "^5.0.3", "pino": "^10.1.0", "postmark": "^4.0.5", "zxcvbn": "^4.4.2" diff --git a/apps/api-server/src/utils/crypto.ts b/apps/api-server/src/utils/crypto.ts index b6eab81..3fe33d4 100644 --- a/apps/api-server/src/utils/crypto.ts +++ b/apps/api-server/src/utils/crypto.ts @@ -1,7 +1,8 @@ +import { generateSecureBase58Token } from "@reviq/utils"; import { base58 } from "@scure/base"; -// Re-export generateSecureBase58Token from shared utils -export { generateSecureBase58Token } from "@reviq/utils"; +// Re-export for convenience +export { generateSecureBase58Token }; /** * Token prefix for all RevIQ API tokens @@ -59,10 +60,40 @@ export const generateSessionToken = (): string => { }; /** - * Generate a device fingerprint (UUID v4) + * Device fingerprint prefix for new fingerprints + */ +export const DEVICE_FINGERPRINT_PREFIX = "device_"; + +/** + * Generate a device fingerprint (base58 with device_ prefix) */ export const generateDeviceFingerprint = (): string => { - return crypto.randomUUID(); + return generateSecureBase58Token(DEVICE_FINGERPRINT_PREFIX); +}; + +/** + * Check if a string is a valid device fingerprint. + * Accepts both new format (device_ prefix) and legacy UUIDs. + */ +export const isValidDeviceFingerprint = (fingerprint: string): boolean => { + // New format: device_ prefix with base58 + if (fingerprint.startsWith(DEVICE_FINGERPRINT_PREFIX)) { + const base58Part = fingerprint.slice(DEVICE_FINGERPRINT_PREFIX.length); + if (base58Part.length === 0) { + return false; + } + try { + base58.decode(base58Part); + return true; + } catch { + return false; + } + } + + // Legacy format: UUID v4 + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(fingerprint); }; /** diff --git a/apps/api-server/src/utils/geo.test.ts b/apps/api-server/src/utils/geo.test.ts new file mode 100644 index 0000000..f7c9521 --- /dev/null +++ b/apps/api-server/src/utils/geo.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + _resetForTesting, + _setReaderForTesting, + extractClientIP, + getGeoInfo, + getUserAgent, + lookupGeoFromIP, +} from "./geo.js"; + +const createHeaders = (entries: Record): Headers => { + return new Headers(entries); +}; + +describe("extractClientIP", () => { + test("extracts from CF-Connecting-IP", () => { + const headers = createHeaders({ "CF-Connecting-IP": "1.2.3.4" }); + expect(extractClientIP(headers)).toBe("1.2.3.4"); + }); + + test("extracts from True-Client-IP", () => { + const headers = createHeaders({ "True-Client-IP": "5.6.7.8" }); + expect(extractClientIP(headers)).toBe("5.6.7.8"); + }); + + test("extracts from X-Real-IP", () => { + const headers = createHeaders({ "X-Real-IP": "10.20.30.40" }); + expect(extractClientIP(headers)).toBe("10.20.30.40"); + }); + + test("extracts first IP from X-Forwarded-For", () => { + const headers = createHeaders({ + "X-Forwarded-For": "100.1.2.3, 200.4.5.6, 10.0.0.1", + }); + expect(extractClientIP(headers)).toBe("100.1.2.3"); + }); + + test("extracts from X-Client-IP", () => { + const headers = createHeaders({ "X-Client-IP": "192.168.1.1" }); + expect(extractClientIP(headers)).toBe("192.168.1.1"); + }); + + test("prioritizes CF-Connecting-IP over others", () => { + const headers = createHeaders({ + "CF-Connecting-IP": "1.1.1.1", + "True-Client-IP": "2.2.2.2", + "X-Real-IP": "3.3.3.3", + "X-Forwarded-For": "4.4.4.4", + "X-Client-IP": "5.5.5.5", + }); + expect(extractClientIP(headers)).toBe("1.1.1.1"); + }); + + test("returns null when no IP headers present", () => { + expect(extractClientIP(createHeaders({}))).toBeNull(); + }); + + test("trims whitespace", () => { + const headers = createHeaders({ "CF-Connecting-IP": " 1.2.3.4 " }); + expect(extractClientIP(headers)).toBe("1.2.3.4"); + }); + + test("handles IPv6", () => { + const headers = createHeaders({ + "CF-Connecting-IP": "2001:0db8:85a3::8a2e:0370:7334", + }); + expect(extractClientIP(headers)).toBe("2001:0db8:85a3::8a2e:0370:7334"); + }); +}); + +describe("lookupGeoFromIP", () => { + beforeEach(() => { + _resetForTesting(); + }); + + test("returns nulls when reader not initialized", () => { + expect(lookupGeoFromIP("8.8.8.8")).toEqual({ + city: null, + region: null, + country: null, + }); + }); + + test("returns geo data from reader", () => { + _setReaderForTesting({ + get: (ip: string) => + ip === "8.8.8.8" + ? { + city: { names: { en: "Mountain View" } }, + subdivisions: [{ names: { en: "California" } }], + country: { iso_code: "US" }, + } + : null, + } as never); + + expect(lookupGeoFromIP("8.8.8.8")).toEqual({ + city: "Mountain View", + region: "California", + country: "US", + }); + }); + + test("returns nulls for unknown IP", () => { + _setReaderForTesting({ get: () => null } as never); + expect(lookupGeoFromIP("0.0.0.0")).toEqual({ + city: null, + region: null, + country: null, + }); + }); + + test("handles partial geo data", () => { + _setReaderForTesting({ + get: () => ({ country: { iso_code: "DE" } }), + } as never); + + expect(lookupGeoFromIP("1.2.3.4")).toEqual({ + city: null, + region: null, + country: "DE", + }); + }); + + test("handles reader errors gracefully", () => { + _setReaderForTesting({ + get: () => { + throw new Error("Lookup failed"); + }, + } as never); + + expect(lookupGeoFromIP("invalid")).toEqual({ + city: null, + region: null, + country: null, + }); + }); +}); + +describe("getGeoInfo", () => { + beforeEach(() => { + _resetForTesting(); + }); + + test("uses Cloudflare geo headers when present", () => { + const headers = createHeaders({ + "CF-Connecting-IP": "1.2.3.4", + "CF-IPCountry": "US", + "CF-IPCity": "San Francisco", + "CF-Region": "California", + }); + + expect(getGeoInfo(headers)).toEqual({ + ip: "1.2.3.4", + city: "San Francisco", + region: "California", + country: "US", + }); + }); + + test("prefers CF headers over GeoIP lookup", () => { + _setReaderForTesting({ + get: () => ({ + city: { names: { en: "Wrong" } }, + country: { iso_code: "XX" }, + }), + } as never); + + const headers = createHeaders({ + "CF-Connecting-IP": "1.2.3.4", + "CF-IPCountry": "US", + }); + + expect(getGeoInfo(headers).country).toBe("US"); + }); + + test("falls back to GeoIP lookup when no CF geo headers", () => { + _setReaderForTesting({ + get: () => ({ + city: { names: { en: "Berlin" } }, + subdivisions: [{ names: { en: "Berlin" } }], + country: { iso_code: "DE" }, + }), + } as never); + + const headers = createHeaders({ "X-Real-IP": "1.2.3.4" }); + + expect(getGeoInfo(headers)).toEqual({ + ip: "1.2.3.4", + city: "Berlin", + region: "Berlin", + country: "DE", + }); + }); + + test("returns IP even without geo data", () => { + const headers = createHeaders({ "X-Forwarded-For": "203.0.113.50" }); + const result = getGeoInfo(headers); + + expect(result.ip).toBe("203.0.113.50"); + expect(result.city).toBeNull(); + }); + + test("returns all nulls when no headers", () => { + expect(getGeoInfo(createHeaders({}))).toEqual({ + ip: null, + city: null, + region: null, + country: null, + }); + }); +}); + +describe("getUserAgent", () => { + test("returns User-Agent header", () => { + const headers = createHeaders({ "User-Agent": "Mozilla/5.0 Chrome/120" }); + expect(getUserAgent(headers)).toBe("Mozilla/5.0 Chrome/120"); + }); + + test('returns "Unknown" when missing', () => { + expect(getUserAgent(createHeaders({}))).toBe("Unknown"); + }); +}); diff --git a/apps/api-server/src/utils/geo.ts b/apps/api-server/src/utils/geo.ts index 7c1d417..abccdb9 100644 --- a/apps/api-server/src/utils/geo.ts +++ b/apps/api-server/src/utils/geo.ts @@ -1,3 +1,6 @@ +import type { CityResponse, Reader } from "maxmind"; +import { open } from "maxmind"; + export interface GeoInfo { ip: string | null; city: string | null; @@ -5,37 +8,152 @@ export interface GeoInfo { country: string | null; } +/** Default path for GeoLite2-City database */ +const DEFAULT_GEOIP_PATH = "/usr/share/GeoIP/GeoLite2-City.mmdb"; + +// Module-level reader instance +let geoReader: Reader | null = null; +let initialized = false; + /** - * Extract geolocation info from request headers - * Supports Cloudflare headers in production, falls back to standard headers - * @param headers - Request headers - * @returns Geolocation information extracted from headers + * Initialize the GeoIP database reader. + * Uses GEOIP_DATABASE_PATH env var, defaults to /usr/share/GeoIP/GeoLite2-City.mmdb + */ +export const initGeoReader = async (): Promise => { + if (initialized) { + return; + } + + const dbPath = Bun.env.GEOIP_DATABASE_PATH ?? DEFAULT_GEOIP_PATH; + + try { + geoReader = await open(dbPath); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.warn(`GeoIP database not available at ${dbPath}: ${message}`); + } + + initialized = true; +}; + +// ===== Test Helpers ===== +// These are exported for testing only. Do not use in production code. + +/** @internal Reset state for testing */ +export const _resetForTesting = (): void => { + geoReader = null; + initialized = false; +}; + +/** @internal Set a mock reader for testing */ +export const _setReaderForTesting = ( + reader: Reader | null, +): void => { + geoReader = reader; + initialized = true; +}; + +// ===== Public API ===== + +/** + * Extract the client IP address from request headers. + * Checks headers in priority order: + * 1. CF-Connecting-IP (Cloudflare) + * 2. True-Client-IP (Cloudflare Enterprise / Akamai) + * 3. X-Real-IP (nginx, other proxies) + * 4. X-Forwarded-For (first IP in chain) + * 5. X-Client-IP (some proxies) + */ +export const extractClientIP = (headers: Headers): string | null => { + const cfIP = headers.get("CF-Connecting-IP"); + if (cfIP) { + return cfIP.trim(); + } + + const trueClientIP = headers.get("True-Client-IP"); + if (trueClientIP) { + return trueClientIP.trim(); + } + + const realIP = headers.get("X-Real-IP"); + if (realIP) { + return realIP.trim(); + } + + const forwardedFor = headers.get("X-Forwarded-For"); + if (forwardedFor) { + const firstIP = forwardedFor.split(",")[0]?.trim(); + if (firstIP) { + return firstIP; + } + } + + const clientIP = headers.get("X-Client-IP"); + if (clientIP) { + return clientIP.trim(); + } + + return null; +}; + +/** + * Look up geo information from an IP address using the GeoIP database. + * Returns null values if the database isn't initialized or IP is unknown. + */ +export const lookupGeoFromIP = ( + ip: string, +): { city: string | null; region: string | null; country: string | null } => { + if (!geoReader) { + return { city: null, region: null, country: null }; + } + + try { + const result = geoReader.get(ip); + if (!result) { + return { city: null, region: null, country: null }; + } + + return { + city: result.city?.names.en ?? null, + region: result.subdivisions?.[0]?.names.en ?? null, + country: result.country?.iso_code ?? null, + }; + } catch { + return { city: null, region: null, country: null }; + } +}; + +/** + * Extract geolocation info from request headers. + * Uses Cloudflare headers when available, falls back to GeoIP database lookup. */ export const getGeoInfo = (headers: Headers): GeoInfo => { - // Try Cloudflare headers first (production) - const cfIP = headers.get("CF-Connecting-IP"); + const ip = extractClientIP(headers); + + // Try Cloudflare geo headers first const cfCountry = headers.get("CF-IPCountry"); const cfCity = headers.get("CF-IPCity"); const cfRegion = headers.get("CF-Region"); - // Fallback to X-Forwarded-For or X-Real-IP - const forwardedFor = headers.get("X-Forwarded-For"); - const realIP = headers.get("X-Real-IP"); + if (cfCountry || cfCity || cfRegion) { + return { + ip, + city: cfCity ?? null, + region: cfRegion ?? null, + country: cfCountry ?? null, + }; + } - const ip = cfIP ?? realIP ?? forwardedFor?.split(",")[0]?.trim() ?? null; + // Fall back to GeoIP database lookup + if (ip) { + return { ip, ...lookupGeoFromIP(ip) }; + } - return { - ip, - city: cfCity ?? null, - region: cfRegion ?? null, - country: cfCountry ?? null, - }; + return { ip: null, city: null, region: null, country: null }; }; /** - * Extract User-Agent from request headers - * @param headers - Request headers - * @returns User-Agent string or "Unknown" if not present + * Extract User-Agent from request headers. */ export const getUserAgent = (headers: Headers): string => { return headers.get("User-Agent") ?? "Unknown"; diff --git a/apps/api-server/src/__tests__/unit/passkey-helpers.test.ts b/apps/api-server/src/utils/passkey-helpers.test.ts similarity index 98% rename from apps/api-server/src/__tests__/unit/passkey-helpers.test.ts rename to apps/api-server/src/utils/passkey-helpers.test.ts index 39f2e33..0efd040 100644 --- a/apps/api-server/src/__tests__/unit/passkey-helpers.test.ts +++ b/apps/api-server/src/utils/passkey-helpers.test.ts @@ -2,14 +2,14 @@ * Unit tests for passkey-helpers utility functions */ -import type { PasskeyRow } from "../../utils/passkey-helpers.js"; +import type { PasskeyRow } from "./passkey-helpers.js"; import { describe, expect, test } from "bun:test"; import { base64urlToUint8Array, formatPasskeyDate, parsePasskeyRow, uint8ArrayToBase64url, -} from "../../utils/passkey-helpers.js"; +} from "./passkey-helpers.js"; describe("base64urlToUint8Array", () => { test("converts base64url string to Uint8Array", () => { diff --git a/bun.lock b/bun.lock index b063665..641ca0e 100644 --- a/bun.lock +++ b/bun.lock @@ -27,6 +27,7 @@ "@simplewebauthn/server": "^13.2.2", "@simplewebauthn/types": "^12.0.0", "kysely": "^0.28.2", + "maxmind": "^5.0.3", "pino": "^10.1.0", "postmark": "^4.0.5", "zxcvbn": "^4.4.2", @@ -846,6 +847,8 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "maxmind": ["maxmind@5.0.3", "", { "dependencies": { "mmdb-lib": "3.0.1", "tiny-lru": "11.4.5" } }, "sha512-oMtZwLrsp0LcZehfYKIirtwKMBycMMqMA1/Dc9/BlUqIEtXO75mIzMJ3PYCV1Ji+BpoUCk+lTzRfh9c+ptGdyQ=="], + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -856,6 +859,8 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mmdb-lib": ["mmdb-lib@3.0.1", "", {}, "sha512-dyAyMR+cRykZd1mw5altC9f4vKpCsuywPwo8l/L5fKqDay2zmqT0mF/BvUoXnQiqGn+nceO914rkPKJoyFnGxA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -1024,6 +1029,8 @@ "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], + "tiny-lru": ["tiny-lru@11.4.5", "", {}, "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],