Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/api/api-key-auth-cache.server.ts
T

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;
}