From 18b4bb853d753aa17dc4eedd9ced79c15601ba83 Mon Sep 17 00:00:00 2001 From: chunzhimoe <60135925+chunzhimoe@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:29:59 +0800 Subject: [PATCH] feat: GitHub OAuth + encrypted CF token storage + settings page + fix input theme --- .env.example | 10 ++ package-lock.json | 172 +++++++++++++++++++++ package.json | 3 +- src/app/api/auth/[...nextauth]/route.ts | 5 + src/app/api/settings/route.ts | 30 ++++ src/app/create/page.tsx | 12 +- src/app/login/page.tsx | 32 ++++ src/app/providers.tsx | 9 +- src/app/settings/page.tsx | 195 ++++++++++++++++++++++++ src/components/Navigation.tsx | 48 +++++- src/lib/authOptions.ts | 23 +++ src/lib/cloudflare.ts | 39 +++-- src/lib/credentials.ts | 58 +++++++ 13 files changed, 599 insertions(+), 37 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/settings/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/app/settings/page.tsx create mode 100644 src/lib/authOptions.ts create mode 100644 src/lib/credentials.ts diff --git a/.env.example b/.env.example index deef035..28cf29e 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,13 @@ CLOUDFLARE_API_TOKEN=your_api_token_here # (Create one in Zero Trust > Settings > Service Auth) CF_SERVICE_CLIENT_ID= CF_SERVICE_CLIENT_SECRET= + +# GitHub OAuth (create at https://github.com/settings/developers) +# Callback URL: https://your-domain.vercel.app/api/auth/callback/github +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Required for next-auth session encryption (run: openssl rand -base64 32) +NEXTAUTH_SECRET= +# Optional on Vercel — set to your production URL if self-hosting +# NEXTAUTH_URL=https://your-domain.vercel.app diff --git a/package-lock.json b/package-lock.json index 68b66e1..5eb60dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "jose": "^5.10.0", "lucide-react": "^0.487.0", "next": "^15.3.1", + "next-auth": "^4.24.11", "next-themes": "^0.4.6", "react": "^19.1.0", "react-dom": "^19.1.0" @@ -37,6 +38,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", @@ -697,6 +707,15 @@ "node": ">= 10" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1033,6 +1052,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1351,6 +1379,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/lucide-react": { "version": "0.487.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz", @@ -1440,6 +1480,47 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -1478,6 +1559,54 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1513,6 +1642,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.29.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz", + "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -1677,6 +1834,21 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" } } } diff --git a/package.json b/package.json index bdb655e..8f71263 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "react-dom": "^19.1.0", "lucide-react": "^0.487.0", "next-themes": "^0.4.6", - "jose": "^5.10.0" + "jose": "^5.10.0", + "next-auth": "^4.24.11" }, "devDependencies": { "@types/node": "^22.14.1", diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..5b183f2 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,5 @@ +import NextAuth from "next-auth"; +import { authOptions } from "@/lib/authOptions"; + +const handler = NextAuth(authOptions); +export { handler as GET, handler as POST }; diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..7e7a061 --- /dev/null +++ b/src/app/api/settings/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/authOptions"; +import { saveCFCreds, loadCFCreds } from "@/lib/credentials"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const creds = await loadCFCreds(); + if (!creds) return NextResponse.json({ configured: false }); + return NextResponse.json({ + configured: true, + accountId: creds.accountId, + apiTokenMasked: `${creds.apiToken.slice(0, 6)}${"*".repeat(20)}`, + }); +} + +export async function PUT(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { accountId, apiToken } = await req.json(); + if (!accountId?.trim() || !apiToken?.trim()) { + return NextResponse.json({ error: "accountId and apiToken are required" }, { status: 400 }); + } + + await saveCFCreds({ accountId: accountId.trim(), apiToken: apiToken.trim() }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/create/page.tsx b/src/app/create/page.tsx index 9179a8d..63a2031 100644 --- a/src/app/create/page.tsx +++ b/src/app/create/page.tsx @@ -306,20 +306,20 @@ export default function CreatePage() { .form-input { width: 100%; border-radius: 0.75rem; - border: 1px solid rgb(39 39 42); - background: rgb(24 24 27 / 0.8); + border: 1px solid var(--border); + background: var(--bg-subtle); padding: 0.625rem 0.875rem; font-size: 0.875rem; - color: rgb(228 228 231); + color: var(--text); outline: none; transition: all 0.15s; } .form-input:focus { - border-color: rgb(16 185 129 / 0.5); - box-shadow: 0 0 0 2px rgb(16 185 129 / 0.1); + border-color: var(--accent-border); + box-shadow: 0 0 0 2px var(--accent-dim); } .form-input::placeholder { - color: rgb(113 113 122); + color: var(--text-faint); } `} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..3007f88 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { signIn } from "next-auth/react"; +import { Monitor, Github } from "lucide-react"; + +export default function LoginPage() { + return ( +
+
+
+
+ +
+

SSH Launcher

+

+ Sign in to manage your Cloudflare Zero Trust SSH hosts +

+ +

+ Your Cloudflare credentials are stored encrypted in your browser session. +

+
+
+
+ ); +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 492da1d..137fa30 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,11 +1,14 @@ "use client"; import { ThemeProvider } from "next-themes"; +import { SessionProvider } from "next-auth/react"; export default function Providers({ children }: { children: React.ReactNode }) { return ( - - {children} - + + + {children} + + ); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx new file mode 100644 index 0000000..50b160a --- /dev/null +++ b/src/app/settings/page.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Settings, Key, Save, Check, AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; + +interface SettingsState { + configured: boolean; + accountId?: string; + apiTokenMasked?: string; +} + +export default function SettingsPage() { + const [state, setState] = useState(null); + const [accountId, setAccountId] = useState(""); + const [apiToken, setApiToken] = useState(""); + const [showToken, setShowToken] = useState(false); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((d) => { + setState(d); + if (d.accountId) setAccountId(d.accountId); + }) + .catch(() => setState({ configured: false })); + }, []); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const res = await fetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, apiToken }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setSaved(true); + setState({ configured: true, accountId, apiTokenMasked: `${apiToken.slice(0, 6)}${"*".repeat(20)}` }); + setApiToken(""); + setTimeout(() => setSaved(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+
+ +
+
+

Settings

+

+ Cloudflare API credentials +

+
+
+ + {/* Current status */} + {state && ( +
+ {state.configured ? ( + + ) : ( + + )} +
+ {state.configured ? ( + <> +

Credentials configured

+

+ Account: {state.accountId}
+ Token: {state.apiTokenMasked} +

+ + ) : ( + <> +

Not configured

+

+ Enter your Cloudflare API credentials below. They'll be stored + encrypted in your session cookie (90 days). +

+ + )} +
+
+ )} + + {/* Form */} +
+
+ + setAccountId(e.target.value)} + placeholder="Your Cloudflare Account ID" + className="settings-input" + required + /> +

+ Found in Cloudflare Dashboard → right sidebar +

+
+ +
+ +
+ setApiToken(e.target.value)} + placeholder={state?.configured ? "Enter new token to update" : "Your Cloudflare API Token"} + className="settings-input pr-10" + required={!state?.configured} + /> + +
+

+ Needs: Access Write · Tunnel Edit · DNS Edit · Zone Read +

+
+ + {error && ( +

+ + {error} +

+ )} + + +
+
+ + +
+ ); +} diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index 9acacae..1a45782 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -4,12 +4,14 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; -import { Monitor, PlusCircle, Server, Sun, Moon, User } from "lucide-react"; +import { useSession, signIn, signOut } from "next-auth/react"; +import { Monitor, PlusCircle, Server, Sun, Moon, Settings, LogOut, LogIn } from "lucide-react"; const links = [ { href: "/", label: "Hosts", icon: Monitor }, { href: "/create", label: "Deploy", icon: PlusCircle }, { href: "/tunnels", label: "Tunnels", icon: Server }, + { href: "/settings", label: "Settings", icon: Settings }, ]; function ThemeToggle() { @@ -28,6 +30,45 @@ function ThemeToggle() { ); } +function UserWidget() { + const { data: session, status } = useSession(); + if (status === "loading") return
; + + if (!session) { + return ( + + ); + } + + return ( +
+ {session.user?.image ? ( + {session.user.name + ) : null} + + {session.user?.name ?? session.user?.email} + + +
+ ); +} + export default function Navigation() { const pathname = usePathname(); @@ -66,10 +107,7 @@ export default function Navigation() {
-
- - CF Access -
+
diff --git a/src/lib/authOptions.ts b/src/lib/authOptions.ts new file mode 100644 index 0000000..076dd95 --- /dev/null +++ b/src/lib/authOptions.ts @@ -0,0 +1,23 @@ +import type { NextAuthOptions } from "next-auth"; +import GithubProvider from "next-auth/providers/github"; + +export const authOptions: NextAuthOptions = { + providers: [ + GithubProvider({ + clientId: process.env.GITHUB_CLIENT_ID ?? "", + clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "", + }), + ], + secret: process.env.NEXTAUTH_SECRET, + pages: { + signIn: "/login", + }, + callbacks: { + async session({ session, token }) { + if (session.user && token.sub) { + (session.user as { id?: string }).id = token.sub; + } + return session; + }, + }, +}; diff --git a/src/lib/cloudflare.ts b/src/lib/cloudflare.ts index 07014f4..e6ab7cc 100644 --- a/src/lib/cloudflare.ts +++ b/src/lib/cloudflare.ts @@ -74,20 +74,15 @@ export interface SetupResult { } // ─── Credentials ───────────────────────────────────────────────────────────── +// Reads from env vars first, falls back to encrypted cookie set via /settings +import { requireCFCreds } from "@/lib/credentials"; -function getCredentials() { - const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; - const apiToken = process.env.CLOUDFLARE_API_TOKEN; - if (!accountId || !apiToken) { - throw new Error( - "Missing CLOUDFLARE_ACCOUNT_ID or CLOUDFLARE_API_TOKEN env vars" - ); - } - return { accountId, apiToken }; +async function getCredentials() { + return requireCFCreds(); } async function cfGet(path: string, revalidateSec = 60) { - const { apiToken } = getCredentials(); + const { apiToken } = await getCredentials(); const res = await fetch(`${CF_API_BASE}${path}`, { headers: { Authorization: `Bearer ${apiToken}` }, next: { revalidate: revalidateSec }, @@ -100,7 +95,7 @@ async function cfGet(path: string, revalidateSec = 60) { } async function cfPost(path: string, body: unknown) { - const { apiToken } = getCredentials(); + const { apiToken } = await getCredentials(); const res = await fetch(`${CF_API_BASE}${path}`, { method: "POST", headers: { @@ -119,7 +114,7 @@ async function cfPost(path: string, body: unknown) { } async function cfPut(path: string, body: unknown) { - const { apiToken } = getCredentials(); + const { apiToken } = await getCredentials(); const res = await fetch(`${CF_API_BASE}${path}`, { method: "PUT", headers: { @@ -138,7 +133,7 @@ async function cfPut(path: string, body: unknown) { } async function cfDelete(path: string) { - const { apiToken } = getCredentials(); + const { apiToken } = await getCredentials(); const res = await fetch(`${CF_API_BASE}${path}`, { method: "DELETE", headers: { Authorization: `Bearer ${apiToken}` }, @@ -155,7 +150,7 @@ async function cfDelete(path: string) { // ─── Access Applications ───────────────────────────────────────────────────── export async function listAccessApps(): Promise { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); const allApps: AccessApp[] = []; let page = 1; const perPage = 50; @@ -216,7 +211,7 @@ export async function getSshAppById(id: string): Promise { // ─── Tunnels ───────────────────────────────────────────────────────────────── export async function listTunnels(): Promise { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); const json = await cfGet( `/accounts/${accountId}/cfd_tunnel?is_deleted=false&per_page=50`, 30 @@ -225,7 +220,7 @@ export async function listTunnels(): Promise { } export async function createTunnel(name: string): Promise { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); const tunnelSecret = Buffer.from( crypto.getRandomValues(new Uint8Array(32)) ).toString("base64"); @@ -238,12 +233,12 @@ export async function createTunnel(name: string): Promise { } export async function deleteTunnel(tunnelId: string): Promise { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); await cfDelete(`/accounts/${accountId}/cfd_tunnel/${tunnelId}`); } export async function getTunnelToken(tunnelId: string): Promise { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); const json = await cfGet( `/accounts/${accountId}/cfd_tunnel/${tunnelId}/token`, 0 @@ -256,7 +251,7 @@ export async function configureTunnel( hostname: string, sshPort: number ): Promise { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, { config: { ingress: [ @@ -311,7 +306,7 @@ export async function createAccessApp( appName: string, allowedEmails: string[] ): Promise<{ appId: string; metricsHostname: string }> { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); const policies = []; @@ -353,7 +348,7 @@ export async function createAccessApp( export async function getOrCreateCaCert( appId: string ): Promise { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); try { // Try to get existing CA const json = await cfGet( @@ -425,7 +420,7 @@ export async function runFullSetup(input: SetupInput): Promise { // Step 3: Configure tunnel ingress (SSH + metrics HTTP) const metricsDomain = `metrics.${input.hostname}`; try { - const { accountId } = getCredentials(); + const { accountId } = await getCredentials(); await cfPut(`/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, { config: { ingress: [ diff --git a/src/lib/credentials.ts b/src/lib/credentials.ts new file mode 100644 index 0000000..26ddb14 --- /dev/null +++ b/src/lib/credentials.ts @@ -0,0 +1,58 @@ +import { cookies } from "next/headers"; +import { jwtDecrypt, EncryptJWT } from "jose"; + +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); +} + +export async function saveCFCreds(creds: CFCreds): Promise { + const token = await new EncryptJWT({ ...creds }) + .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) + .setExpirationTime("90d") + .encrypt(encKey()); + + 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: "/", + }); +} + +export async function loadCFCreds(): Promise { + 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; + } +} + +export async function requireCFCreds(): Promise { + const creds = await loadCFCreds(); + if (!creds) { + throw new Error("Cloudflare credentials not configured. Visit /settings to set them up."); + } + return creds; +}