116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
import type { LegacyApiKeySimple } from "@hivegrid/db/api-keys.server";
|
|
import { createTwoTierCache, type CacheSerializer, type SharedStore, type TwoTierCache } from "@hivegrid/shared-store";
|
|
|
|
import { getSharedStore } from "~/shared-store.server";
|
|
import { createScopedLogger } from "~/utils/logger.server";
|
|
|
|
const log = createScopedLogger("api-key-auth-cache");
|
|
|
|
// Pub/sub is the primary revocation path. The short TTL bounds exposure when a
|
|
// replica misses an invalidation while disconnected from Redis.
|
|
export const API_KEY_AUTH_CACHE_TTL_SECONDS = 30;
|
|
|
|
type SerializedApiKeySnapshot = Omit<LegacyApiKeySimple, "expires"> & { expires: string | null };
|
|
|
|
const apiKeySerializer: CacheSerializer<LegacyApiKeySimple> = {
|
|
serialize(value) {
|
|
const serialized: SerializedApiKeySnapshot = {
|
|
...value,
|
|
expires: value.expires?.toISOString() ?? null,
|
|
};
|
|
return JSON.stringify(serialized);
|
|
},
|
|
deserialize(raw) {
|
|
const value = JSON.parse(raw) as SerializedApiKeySnapshot;
|
|
return {
|
|
...value,
|
|
expires: value.expires ? new Date(value.expires) : null,
|
|
};
|
|
},
|
|
};
|
|
|
|
function pubsubEnabledFromEnv(): boolean {
|
|
const raw = process.env.CACHE_INVALIDATION_ENABLED;
|
|
if (raw === undefined || raw === null || raw === "") return true;
|
|
return raw.toLowerCase() !== "false" && raw !== "0";
|
|
}
|
|
|
|
export function createApiKeyAuthCache(store: SharedStore, options: { now?: () => number } = {}): TwoTierCache<LegacyApiKeySimple> {
|
|
return createTwoTierCache<LegacyApiKeySimple>({
|
|
store,
|
|
namespace: "api-key-auth",
|
|
ttlSeconds: API_KEY_AUTH_CACHE_TTL_SECONDS,
|
|
l1MaxEntries: 10_000,
|
|
l1Now: options.now,
|
|
serializer: apiKeySerializer,
|
|
pubsubEnabled: pubsubEnabledFromEnv(),
|
|
logger: {
|
|
warn: (obj, msg) => log.warn(obj, msg),
|
|
info: (obj, msg) => log.info(obj, msg),
|
|
error: (obj, msg) => log.error(obj, msg),
|
|
},
|
|
});
|
|
}
|
|
|
|
let cacheSingleton: TwoTierCache<LegacyApiKeySimple> | null = null;
|
|
let subscribeDisposer: (() => Promise<void>) | null = null;
|
|
|
|
export function getApiKeyAuthCache(): TwoTierCache<LegacyApiKeySimple> {
|
|
cacheSingleton ??= createApiKeyAuthCache(getSharedStore());
|
|
return cacheSingleton;
|
|
}
|
|
|
|
/**
|
|
* Cache an already-verified authorization snapshot. The raw credential is
|
|
* neither part of LegacyApiKeySimple nor passed to this module; callers use a
|
|
* SHA-256 lookup key so Redis never receives plaintext API keys.
|
|
*/
|
|
export async function loadCachedApiKeySnapshot(
|
|
lookupCacheKey: string,
|
|
getFreshValue: () => Promise<LegacyApiKeySimple | null>
|
|
): Promise<LegacyApiKeySimple | null> {
|
|
const cache = getApiKeyAuthCache();
|
|
try {
|
|
const cached = await cache.get(lookupCacheKey);
|
|
if (cached !== null) return cached;
|
|
} catch (error) {
|
|
log.warn(
|
|
{ op: "api-key-auth-cache.read", err: { name: error instanceof Error ? error.name : "Error" } },
|
|
"API-key cache read failed; falling back to the database."
|
|
);
|
|
}
|
|
|
|
const fresh = await getFreshValue();
|
|
if (fresh !== null) {
|
|
try {
|
|
await cache.set(lookupCacheKey, fresh);
|
|
} catch (error) {
|
|
log.warn(
|
|
{ op: "api-key-auth-cache.write", err: { name: error instanceof Error ? error.name : "Error" } },
|
|
"API-key cache write failed; continuing with the database result."
|
|
);
|
|
}
|
|
}
|
|
return fresh;
|
|
}
|
|
|
|
/** Fail closed after key rotation, revocation, or deletion. */
|
|
export async function invalidateApiKeySnapshot(lookupCacheKey: string): Promise<void> {
|
|
const cache = getApiKeyAuthCache();
|
|
cache.deleteLocal(lookupCacheKey);
|
|
await cache.l2.invalidate(lookupCacheKey);
|
|
}
|
|
|
|
export async function subscribeApiKeyAuthCache(): Promise<() => Promise<void>> {
|
|
if (subscribeDisposer) return subscribeDisposer;
|
|
subscribeDisposer = await getApiKeyAuthCache().subscribe();
|
|
log.info({ namespace: "api-key-auth" }, "API-key authorization cache subscriber started.");
|
|
return subscribeDisposer;
|
|
}
|
|
|
|
/** Test seam. */
|
|
export function __resetApiKeyAuthCacheForTests(): void {
|
|
cacheSingleton = null;
|
|
subscribeDisposer = null;
|
|
}
|