1. refactor navigation + add additional nav items

2. authentication changes
This commit is contained in:
2026-02-07 21:17:41 -05:00
parent 8abb12d369
commit af73e162c5
6 changed files with 369 additions and 55 deletions

View File

@@ -1,26 +1,27 @@
import jwt from 'jsonwebtoken';
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import { API_URL } from '../config';
// JWT keys location - can be configured via environment variable
// In production, prefer using a secret management service (Vault, AWS Secrets Manager, etc.)
const secretFilePath = import.meta.env.JWT_KEYS_PATH || path.join(
os.homedir(),
'.config',
'api_jwt_keys.json'
);
// JWKS endpoint for fetching RSA public keys
const JWKS_URL = `${API_URL}/auth/jwks`;
// Warn if using default location in production
if (!import.meta.env.JWT_KEYS_PATH && !import.meta.env.DEV) {
console.warn(
'[SECURITY WARNING] JWT_KEYS_PATH not set. Using default location ~/.config/api_jwt_keys.json. ' +
'Consider using a secret management service in production.'
);
// Cache for JWKS keys
interface JwkKey {
kty: string;
use?: string;
kid: string;
alg?: string;
n: string;
e: string;
}
interface JwtKeyFile {
keys: Record<string, string>;
interface JwksResponse {
keys: JwkKey[];
}
interface CachedKeys {
keys: Map<string, string>; // kid -> PEM public key
fetchedAt: number;
}
export interface JwtPayload {
@@ -31,16 +32,98 @@ export interface JwtPayload {
[key: string]: unknown;
}
// Load and parse keys JSON once at startup
let keyFileData: JwtKeyFile;
try {
keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
} catch (err) {
console.error(`[CRITICAL] Failed to load JWT keys from ${secretFilePath}:`, (err as Error).message);
throw new Error('JWT keys file not found or invalid. Set JWT_KEYS_PATH environment variable.');
// Cache TTL: 5 minutes (keys are refreshed on cache miss or verification failure)
const CACHE_TTL_MS = 5 * 60 * 1000;
let cachedKeys: CachedKeys | null = null;
/**
* Convert a JWK RSA public key to PEM format
*/
function jwkToPem(jwk: JwkKey): string {
if (jwk.kty !== 'RSA') {
throw new Error(`Unsupported key type: ${jwk.kty}`);
}
// Decode base64url-encoded modulus and exponent
const n = Buffer.from(jwk.n, 'base64url');
const e = Buffer.from(jwk.e, 'base64url');
// Build the RSA public key using Node's crypto
const publicKey = crypto.createPublicKey({
key: {
kty: 'RSA',
n: jwk.n,
e: jwk.e,
},
format: 'jwk',
});
return publicKey.export({ type: 'spki', format: 'pem' }) as string;
}
export function verifyToken(token: string | null | undefined): JwtPayload | null {
/**
* Fetch JWKS from the API and cache the keys
*/
async function fetchJwks(): Promise<Map<string, string>> {
try {
const response = await fetch(JWKS_URL, {
headers: { 'Accept': 'application/json' },
});
if (!response.ok) {
throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`);
}
const jwks: JwksResponse = await response.json();
const keys = new Map<string, string>();
for (const jwk of jwks.keys) {
if (jwk.kty === 'RSA' && jwk.kid) {
try {
const pem = jwkToPem(jwk);
keys.set(jwk.kid, pem);
} catch (err) {
console.warn(`Failed to convert JWK ${jwk.kid} to PEM:`, (err as Error).message);
}
}
}
if (keys.size === 0) {
throw new Error('No valid RSA keys found in JWKS');
}
cachedKeys = {
keys,
fetchedAt: Date.now(),
};
console.log(`[JWT] Fetched ${keys.size} RSA public key(s) from JWKS`);
return keys;
} catch (error) {
console.error('[JWT] Failed to fetch JWKS:', (error as Error).message);
throw error;
}
}
/**
* Get cached keys or fetch fresh ones
*/
async function getKeys(forceRefresh = false): Promise<Map<string, string>> {
const now = Date.now();
if (!forceRefresh && cachedKeys && (now - cachedKeys.fetchedAt) < CACHE_TTL_MS) {
return cachedKeys.keys;
}
return fetchJwks();
}
/**
* Verify a JWT token using RS256 with keys from JWKS
*/
export async function verifyToken(token: string | null | undefined): Promise<JwtPayload | null> {
if (!token) {
return null;
}
@@ -52,14 +135,22 @@ export function verifyToken(token: string | null | undefined): JwtPayload | null
}
const kid = decoded.header.kid;
const key = keyFileData.keys[kid];
let keys = await getKeys();
let publicKey = keys.get(kid);
if (!key) {
throw new Error(`Unknown kid: ${kid}`);
// If key not found, try refreshing JWKS (handles key rotation)
if (!publicKey) {
console.log(`[JWT] Key ${kid} not in cache, refreshing JWKS...`);
keys = await getKeys(true);
publicKey = keys.get(kid);
if (!publicKey) {
throw new Error(`Unknown kid: ${kid}`);
}
}
// Verify using the correct key and HS256 algo
const payload = jwt.verify(token, key, { algorithms: ['HS256'] }) as JwtPayload;
// Verify using RS256
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) as JwtPayload;
return payload;
} catch (error) {
@@ -67,3 +158,53 @@ export function verifyToken(token: string | null | undefined): JwtPayload | null
return null;
}
}
/**
* Synchronous verification - uses cached keys only (for middleware compatibility)
* Falls back to null if cache is empty; caller should handle async verification
*/
export function verifyTokenSync(token: string | null | undefined): JwtPayload | null {
if (!token) {
return null;
}
if (!cachedKeys || cachedKeys.keys.size === 0) {
// No cached keys - caller needs to use async version
return null;
}
try {
const decoded = jwt.decode(token, { complete: true });
if (!decoded?.header?.kid) {
throw new Error('No kid in token header');
}
const kid = decoded.header.kid;
const publicKey = cachedKeys.keys.get(kid);
if (!publicKey) {
// Key not found - might need refresh, return null to signal async verification needed
return null;
}
// Verify using RS256
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) as JwtPayload;
return payload;
} catch (error) {
console.error('JWT verification failed:', (error as Error).message);
return null;
}
}
/**
* Pre-warm the JWKS cache at startup
*/
export async function initializeJwks(): Promise<void> {
try {
await fetchJwks();
} catch (error) {
console.error('[JWT] Failed to pre-warm JWKS cache:', (error as Error).message);
// Don't throw - allow lazy initialization on first verification
}
}