- Add maxmind library for GeoIP database lookups when not behind Cloudflare - Extract client IP from multiple header sources (CF, X-Real-IP, X-Forwarded-For, etc.) - Change device fingerprints from UUID to base58 with device_ prefix - Add isValidDeviceFingerprint() that accepts both new and legacy formats - Colocate unit tests with source files, remove __tests__/unit directory - Add test coverage reporting to test script Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
105 lines
2.7 KiB
TypeScript
105 lines
2.7 KiB
TypeScript
import { generateSecureBase58Token } from "@reviq/utils";
|
|
import { base58 } from "@scure/base";
|
|
|
|
// Re-export for convenience
|
|
export { generateSecureBase58Token };
|
|
|
|
/**
|
|
* Token prefix for all RevIQ API tokens
|
|
*/
|
|
export const TOKEN_PREFIX = "reviq_";
|
|
|
|
/**
|
|
* Hash a token with SHA-256 for storage in database
|
|
* Never store raw tokens - always hash first
|
|
* Uses Web Crypto API for Cloudflare Workers compatibility
|
|
*/
|
|
export const hashToken = async (token: string): Promise<string> => {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(token);
|
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
const hashArray = new Uint8Array(hashBuffer);
|
|
return Array.from(hashArray)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
};
|
|
|
|
/**
|
|
* Validate that a token has the correct format
|
|
* Returns the raw bytes if valid, null if invalid
|
|
*/
|
|
export const parseToken = (token: string): Uint8Array | null => {
|
|
if (!token.startsWith(TOKEN_PREFIX)) {
|
|
return null;
|
|
}
|
|
const encoded = token.slice(TOKEN_PREFIX.length);
|
|
try {
|
|
const bytes = base58.decode(encoded);
|
|
// Expect 32 bytes of entropy
|
|
if (bytes.length !== 32) {
|
|
return null;
|
|
}
|
|
return bytes;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Check if a token has the valid reviq_ prefix format
|
|
*/
|
|
export const isValidTokenFormat = (token: string): boolean => {
|
|
return parseToken(token) !== null;
|
|
};
|
|
|
|
/**
|
|
* Generate a session token (UUID v4)
|
|
*/
|
|
export const generateSessionToken = (): string => {
|
|
return crypto.randomUUID();
|
|
};
|
|
|
|
/**
|
|
* 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 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);
|
|
};
|
|
|
|
/**
|
|
* Generate expiration date
|
|
*/
|
|
export const generateExpiry = (seconds: number): Date => {
|
|
return new Date(Date.now() + seconds * 1000);
|
|
};
|