From 948fe3935eeb1540ed07dccf0c0cdc94f3b7253c Mon Sep 17 00:00:00 2001 From: chunzhimoe <60135925+chunzhimoe@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:34:48 +0800 Subject: [PATCH] feat: Upstash Redis for per-user CF credential storage --- .env.example | 5 ++++ package-lock.json | 16 +++++++++++ package.json | 3 ++- src/lib/credentials.ts | 60 ++++++++++++++++++++---------------------- src/lib/redis.ts | 6 +++++ 5 files changed, 58 insertions(+), 32 deletions(-) create mode 100644 src/lib/redis.ts diff --git a/.env.example b/.env.example index 28cf29e..157577f 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,11 @@ CLOUDFLARE_API_TOKEN=your_api_token_here CF_SERVICE_CLIENT_ID= CF_SERVICE_CLIENT_SECRET= +# Upstash Redis — stores per-user CF credentials (multi-user) +# Create a free database at https://console.upstash.com +UPSTASH_REDIS_URL=https://your-db.upstash.io +UPSTASH_REDIS_TOKEN=your_upstash_token_here + # GitHub OAuth (create at https://github.com/settings/developers) # Callback URL: https://your-domain.vercel.app/api/auth/callback/github GITHUB_CLIENT_ID= diff --git a/package-lock.json b/package-lock.json index 5eb60dd..f4f3fad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "cf-ssh-launcher", "version": "0.1.0", "dependencies": { + "@upstash/redis": "^1.34.9", "jose": "^5.10.0", "lucide-react": "^0.487.0", "next": "^15.3.1", @@ -1026,6 +1027,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@upstash/redis": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.37.0.tgz", + "integrity": "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001787", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", @@ -1828,6 +1838,12 @@ "node": ">=14.17" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 8f71263..7d419f0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "lucide-react": "^0.487.0", "next-themes": "^0.4.6", "jose": "^5.10.0", - "next-auth": "^4.24.11" + "next-auth": "^4.24.11", + "@upstash/redis": "^1.34.9" }, "devDependencies": { "@types/node": "^22.14.1", diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts index 26ddb14..a6734bf 100644 --- a/src/lib/credentials.ts +++ b/src/lib/credentials.ts @@ -1,58 +1,56 @@ -import { cookies } from "next/headers"; -import { jwtDecrypt, EncryptJWT } from "jose"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/authOptions"; +import { redis } from "@/lib/redis"; export interface CFCreds { accountId: string; apiToken: string; } -function encKey(): Uint8Array { - const s = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? "dev-secret-change-me"; - const buf = Buffer.from(s.padEnd(32, "0").slice(0, 32), "utf8"); - return new Uint8Array(buf); +/** Redis key for a given user's CF credentials */ +function credsKey(userId: string) { + return `cf_creds:${userId}`; +} + +/** Derive a stable user key from the next-auth session */ +async function currentUserId(): Promise { + const session = await getServerSession(authOptions); + if (!session?.user) return null; + const user = session.user as { id?: string; email?: string | null }; + return user.id ?? user.email ?? null; } export async function saveCFCreds(creds: CFCreds): Promise { - const token = await new EncryptJWT({ ...creds }) - .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) - .setExpirationTime("90d") - .encrypt(encKey()); + // Env-var mode — nothing to save + if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) return; - const jar = await cookies(); - jar.set("cf_creds", token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 60 * 60 * 24 * 90, - path: "/", - }); + const uid = await currentUserId(); + if (!uid) throw new Error("Not authenticated"); + + await redis.set(credsKey(uid), creds); } export async function loadCFCreds(): Promise { + // Env vars take priority (Vercel config / self-hosted) if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) { return { accountId: process.env.CLOUDFLARE_ACCOUNT_ID, apiToken: process.env.CLOUDFLARE_API_TOKEN, }; } - try { - const jar = await cookies(); - const raw = jar.get("cf_creds")?.value; - if (!raw) return null; - const { payload } = await jwtDecrypt(raw, encKey()); - return { - accountId: payload.accountId as string, - apiToken: payload.apiToken as string, - }; - } catch { - return null; - } + + const uid = await currentUserId(); + if (!uid) return null; + + return redis.get(credsKey(uid)); } export async function requireCFCreds(): Promise { const creds = await loadCFCreds(); if (!creds) { - throw new Error("Cloudflare credentials not configured. Visit /settings to set them up."); + throw new Error( + "Cloudflare credentials not configured. Visit /settings to set them up." + ); } return creds; } diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..793a7f4 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,6 @@ +import { Redis } from "@upstash/redis"; + +export const redis = new Redis({ + url: process.env.UPSTASH_REDIS_URL!, + token: process.env.UPSTASH_REDIS_TOKEN!, +});