mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
redis cache
This commit is contained in:
85
www/app/lib/__tests__/redisTokenCache.test.ts
Normal file
85
www/app/lib/__tests__/redisTokenCache.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
getTokenCache,
|
||||
setTokenCache,
|
||||
deleteTokenCache,
|
||||
TokenCacheEntry,
|
||||
KV,
|
||||
} from "../redisTokenCache";
|
||||
|
||||
const mockKV: KV & {
|
||||
clear: () => void;
|
||||
} = (() => {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
async get(key: string): Promise<string | null> {
|
||||
return data.get(key) || null;
|
||||
},
|
||||
|
||||
async setex(key: string, seconds_: number, value: string): Promise<"OK"> {
|
||||
data.set(key, value);
|
||||
return "OK";
|
||||
},
|
||||
|
||||
async del(key: string): Promise<number> {
|
||||
const existed = data.has(key);
|
||||
data.delete(key);
|
||||
return existed ? 1 : 0;
|
||||
},
|
||||
|
||||
clear() {
|
||||
data.clear();
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
describe("Redis Token Cache", () => {
|
||||
beforeEach(() => {
|
||||
mockKV.clear();
|
||||
});
|
||||
|
||||
test("basic write/read - value written equals value read", async () => {
|
||||
const testKey = "token:test-user-123";
|
||||
const testValue: TokenCacheEntry = {
|
||||
token: {
|
||||
sub: "test-user-123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
accessToken: "access-token-123",
|
||||
accessTokenExpires: Date.now() + 3600000, // 1 hour from now
|
||||
refreshToken: "refresh-token-456",
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await setTokenCache(mockKV, testKey, testValue);
|
||||
const retrievedValue = await getTokenCache(mockKV, testKey);
|
||||
|
||||
expect(retrievedValue).not.toBeNull();
|
||||
expect(retrievedValue).toEqual(testValue);
|
||||
expect(retrievedValue?.token.accessToken).toBe(testValue.token.accessToken);
|
||||
expect(retrievedValue?.token.sub).toBe(testValue.token.sub);
|
||||
expect(retrievedValue?.timestamp).toBe(testValue.timestamp);
|
||||
});
|
||||
|
||||
test("get returns null for non-existent key", async () => {
|
||||
const result = await getTokenCache(mockKV, "non-existent-key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("delete removes token from cache", async () => {
|
||||
const testKey = "token:delete-test";
|
||||
const testValue: TokenCacheEntry = {
|
||||
token: {
|
||||
accessToken: "test-token",
|
||||
accessTokenExpires: Date.now() + 3600000,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
await setTokenCache(mockKV, testKey, testValue);
|
||||
await deleteTokenCache(mockKV, testKey);
|
||||
|
||||
const result = await getTokenCache(mockKV, testKey);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
REFRESH_ACCESS_TOKEN_BEFORE,
|
||||
REFRESH_ACCESS_TOKEN_ERROR,
|
||||
} from "./auth";
|
||||
import {
|
||||
getTokenCache,
|
||||
setTokenCache,
|
||||
deleteTokenCache,
|
||||
} from "./redisTokenCache";
|
||||
import { tokenCacheRedis } from "./redisClient";
|
||||
|
||||
// TODO redis for vercel?
|
||||
const tokenCache = new Map<
|
||||
string,
|
||||
{ token: JWTWithAccessToken; timestamp: number }
|
||||
>();
|
||||
// REFRESH_ACCESS_TOKEN_BEFORE because refresh is based on access token expiration (imagine we cache it 30 days)
|
||||
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
|
||||
|
||||
@@ -55,7 +56,7 @@ export const authOptions: AuthOptions = {
|
||||
const expiresAtS = assertExists(account.expires_at);
|
||||
const expiresAtMs = expiresAtS * 1000;
|
||||
if (!account.access_token) {
|
||||
tokenCache.delete(KEY);
|
||||
await deleteTokenCache(tokenCacheRedis, KEY);
|
||||
} else {
|
||||
const jwtToken: JWTWithAccessToken = {
|
||||
...token,
|
||||
@@ -63,8 +64,7 @@ export const authOptions: AuthOptions = {
|
||||
accessTokenExpires: expiresAtMs,
|
||||
refreshToken: account.refresh_token,
|
||||
};
|
||||
// Store in memory cache
|
||||
tokenCache.set(KEY, {
|
||||
await setTokenCache(tokenCacheRedis, KEY, {
|
||||
token: jwtToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -72,7 +72,7 @@ export const authOptions: AuthOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
const currentToken = tokenCache.get(KEY);
|
||||
const currentToken = await getTokenCache(tokenCacheRedis, KEY);
|
||||
if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
|
||||
return currentToken.token;
|
||||
}
|
||||
@@ -109,10 +109,10 @@ async function lockedRefreshAccessToken(
|
||||
|
||||
const refreshPromise = (async () => {
|
||||
try {
|
||||
const cached = tokenCache.get(`token:${token.sub}`);
|
||||
const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
if (cached) {
|
||||
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
|
||||
tokenCache.delete(`token:${token.sub}`);
|
||||
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
|
||||
} else if (Date.now() < cached.token.accessTokenExpires) {
|
||||
return cached.token;
|
||||
}
|
||||
@@ -121,7 +121,7 @@ async function lockedRefreshAccessToken(
|
||||
const currentToken = cached?.token || (token as JWTWithAccessToken);
|
||||
const newToken = await refreshAccessToken(currentToken);
|
||||
|
||||
tokenCache.set(`token:${token.sub}`, {
|
||||
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
|
||||
token: newToken,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
2
www/app/lib/next.ts
Normal file
2
www/app/lib/next.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// next.js tries to run all the lib code during build phase; we don't always want it when e.g. we have connections initialized we don't want to have
|
||||
export const isBuildPhase = process.env.NEXT_PHASE?.includes("build");
|
||||
46
www/app/lib/redisClient.ts
Normal file
46
www/app/lib/redisClient.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import Redis from "ioredis";
|
||||
import { isBuildPhase } from "./next";
|
||||
|
||||
export type RedisClient = Pick<Redis, "get" | "setex" | "del">;
|
||||
|
||||
const getRedisClient = (): RedisClient => {
|
||||
const redisUrl = process.env.KV_URL;
|
||||
if (!redisUrl) {
|
||||
throw new Error("KV_URL environment variable is required");
|
||||
}
|
||||
const redis = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
redis.on("error", (error) => {
|
||||
console.error("Redis error:", error);
|
||||
});
|
||||
|
||||
// not necessary but will indicate redis config errors by failfast at startup
|
||||
// happens only once; after that connection is allowed to die and the lib is assumed to be able to restore it eventually
|
||||
redis.connect().catch((e) => {
|
||||
console.error("Failed to connect to Redis:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
return redis;
|
||||
};
|
||||
|
||||
// next.js buildtime usage - we want to isolate next.js "build" time concepts here
|
||||
const noopClient: RedisClient = (() => {
|
||||
const noopSetex: Redis["setex"] = async () => {
|
||||
return "OK" as const;
|
||||
};
|
||||
const noopDel: Redis["del"] = async () => {
|
||||
return 0;
|
||||
};
|
||||
return {
|
||||
get: async () => {
|
||||
return null;
|
||||
},
|
||||
setex: noopSetex,
|
||||
del: noopDel,
|
||||
};
|
||||
})();
|
||||
export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient();
|
||||
61
www/app/lib/redisTokenCache.ts
Normal file
61
www/app/lib/redisTokenCache.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
|
||||
|
||||
const TokenCacheEntrySchema = z.object({
|
||||
token: z.object({
|
||||
sub: z.string().optional(),
|
||||
name: z.string().nullish(),
|
||||
email: z.string().nullish(),
|
||||
accessToken: z.string(),
|
||||
accessTokenExpires: z.number(),
|
||||
refreshToken: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
|
||||
const TokenCacheEntryCodec = z.codec(z.string(), TokenCacheEntrySchema, {
|
||||
decode: (jsonString) => {
|
||||
const parsed = JSON.parse(jsonString);
|
||||
return TokenCacheEntrySchema.parse(parsed);
|
||||
},
|
||||
encode: (value) => JSON.stringify(value),
|
||||
});
|
||||
|
||||
export type TokenCacheEntry = z.infer<typeof TokenCacheEntrySchema>;
|
||||
|
||||
export type KV = {
|
||||
get(key: string): Promise<string | null>;
|
||||
setex(key: string, seconds: number, value: string): Promise<"OK">;
|
||||
del(key: string): Promise<number>;
|
||||
};
|
||||
|
||||
export async function getTokenCache(
|
||||
redis: KV,
|
||||
key: string,
|
||||
): Promise<TokenCacheEntry | null> {
|
||||
const data = await redis.get(key);
|
||||
if (!data) return null;
|
||||
|
||||
try {
|
||||
return TokenCacheEntryCodec.decode(data);
|
||||
} catch (error) {
|
||||
console.error("Invalid token cache data:", error);
|
||||
await redis.del(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setTokenCache(
|
||||
redis: KV,
|
||||
key: string,
|
||||
value: TokenCacheEntry,
|
||||
): Promise<void> {
|
||||
const encodedValue = TokenCacheEntryCodec.encode(value);
|
||||
const ttlSeconds = Math.floor(REFRESH_ACCESS_TOKEN_BEFORE / 1000);
|
||||
await redis.setex(key, ttlSeconds, encodedValue);
|
||||
}
|
||||
|
||||
export async function deleteTokenCache(redis: KV, key: string): Promise<void> {
|
||||
await redis.del(key);
|
||||
}
|
||||
Reference in New Issue
Block a user