feat: Upstash Redis for per-user CF credential storage
This commit is contained in:
parent
18b4bb853d
commit
948fe3935e
@ -13,6 +13,11 @@ CLOUDFLARE_API_TOKEN=your_api_token_here
|
|||||||
CF_SERVICE_CLIENT_ID=
|
CF_SERVICE_CLIENT_ID=
|
||||||
CF_SERVICE_CLIENT_SECRET=
|
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)
|
# GitHub OAuth (create at https://github.com/settings/developers)
|
||||||
# Callback URL: https://your-domain.vercel.app/api/auth/callback/github
|
# Callback URL: https://your-domain.vercel.app/api/auth/callback/github
|
||||||
GITHUB_CLIENT_ID=
|
GITHUB_CLIENT_ID=
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "cf-ssh-launcher",
|
"name": "cf-ssh-launcher",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@upstash/redis": "^1.34.9",
|
||||||
"jose": "^5.10.0",
|
"jose": "^5.10.0",
|
||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"next": "^15.3.1",
|
"next": "^15.3.1",
|
||||||
@ -1026,6 +1027,15 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001787",
|
"version": "1.0.30001787",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
|
||||||
@ -1828,6 +1838,12 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
"lucide-react": "^0.487.0",
|
"lucide-react": "^0.487.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"jose": "^5.10.0",
|
"jose": "^5.10.0",
|
||||||
"next-auth": "^4.24.11"
|
"next-auth": "^4.24.11",
|
||||||
|
"@upstash/redis": "^1.34.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
|
|||||||
@ -1,58 +1,56 @@
|
|||||||
import { cookies } from "next/headers";
|
import { getServerSession } from "next-auth";
|
||||||
import { jwtDecrypt, EncryptJWT } from "jose";
|
import { authOptions } from "@/lib/authOptions";
|
||||||
|
import { redis } from "@/lib/redis";
|
||||||
|
|
||||||
export interface CFCreds {
|
export interface CFCreds {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encKey(): Uint8Array {
|
/** Redis key for a given user's CF credentials */
|
||||||
const s = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? "dev-secret-change-me";
|
function credsKey(userId: string) {
|
||||||
const buf = Buffer.from(s.padEnd(32, "0").slice(0, 32), "utf8");
|
return `cf_creds:${userId}`;
|
||||||
return new Uint8Array(buf);
|
}
|
||||||
|
|
||||||
|
/** Derive a stable user key from the next-auth session */
|
||||||
|
async function currentUserId(): Promise<string | null> {
|
||||||
|
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<void> {
|
export async function saveCFCreds(creds: CFCreds): Promise<void> {
|
||||||
const token = await new EncryptJWT({ ...creds })
|
// Env-var mode — nothing to save
|
||||||
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) return;
|
||||||
.setExpirationTime("90d")
|
|
||||||
.encrypt(encKey());
|
|
||||||
|
|
||||||
const jar = await cookies();
|
const uid = await currentUserId();
|
||||||
jar.set("cf_creds", token, {
|
if (!uid) throw new Error("Not authenticated");
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
await redis.set(credsKey(uid), creds);
|
||||||
sameSite: "lax",
|
|
||||||
maxAge: 60 * 60 * 24 * 90,
|
|
||||||
path: "/",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadCFCreds(): Promise<CFCreds | null> {
|
export async function loadCFCreds(): Promise<CFCreds | null> {
|
||||||
|
// Env vars take priority (Vercel config / self-hosted)
|
||||||
if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) {
|
if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) {
|
||||||
return {
|
return {
|
||||||
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||||
apiToken: process.env.CLOUDFLARE_API_TOKEN,
|
apiToken: process.env.CLOUDFLARE_API_TOKEN,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const jar = await cookies();
|
const uid = await currentUserId();
|
||||||
const raw = jar.get("cf_creds")?.value;
|
if (!uid) return null;
|
||||||
if (!raw) return null;
|
|
||||||
const { payload } = await jwtDecrypt(raw, encKey());
|
return redis.get<CFCreds>(credsKey(uid));
|
||||||
return {
|
|
||||||
accountId: payload.accountId as string,
|
|
||||||
apiToken: payload.apiToken as string,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireCFCreds(): Promise<CFCreds> {
|
export async function requireCFCreds(): Promise<CFCreds> {
|
||||||
const creds = await loadCFCreds();
|
const creds = await loadCFCreds();
|
||||||
if (!creds) {
|
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;
|
return creds;
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/lib/redis.ts
Normal file
6
src/lib/redis.ts
Normal file
@ -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!,
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user