Skip to content

Commit a825e82

Browse files
authored
feat: Encrypt API keys on the server before saving (#392)
1 parent 003d6a0 commit a825e82

File tree

4 files changed

+315
-20
lines changed

4 files changed

+315
-20
lines changed

apps/web-v2/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,9 @@ SUPABASE_JWT_SECRET=""
3333

3434
# Client side id for feature flags
3535
NEXT_PUBLIC_LD_CLIENT_SIDE_ID=""
36+
37+
# Encryption key for secrets (e.g. API keys)
38+
# Generate with `openssl rand -hex 32`.
39+
# This value must be the same across all instances
40+
# of the app, and where the secrets are read.
41+
SECRETS_ENCRYPTION_KEY=""

apps/web-v2/src/app/api/settings/api-keys/route.ts

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,44 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { getSupabaseClient } from "@/lib/auth/supabase-client";
33
import { decodeJWT } from "@/lib/jwt-utils";
4+
import { decryptSecret, encryptSecret } from "@/lib/crypto";
5+
6+
function encryptApiKeys(
7+
apiKeys: Record<string, string>,
8+
): Record<string, string> {
9+
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
10+
if (!encryptionKey) {
11+
throw new Error("Encryption key not found");
12+
}
13+
14+
const encryptedApiKeys = Object.fromEntries(
15+
Object.entries(apiKeys).map(([key, value]) => {
16+
return [key, encryptSecret(value, encryptionKey)];
17+
}),
18+
);
19+
return encryptedApiKeys;
20+
}
21+
22+
function decryptApiKeys(
23+
apiKeys: Record<string, string>,
24+
): Record<string, string> {
25+
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
26+
if (!encryptionKey) {
27+
throw new Error("Encryption key not found");
28+
}
29+
30+
const decryptedApiKeys = Object.fromEntries(
31+
Object.entries(apiKeys).map(([key, value]) => {
32+
return [key, decryptSecret(value, encryptionKey)];
33+
}),
34+
);
35+
return decryptedApiKeys;
36+
}
37+
38+
function isTokenExpired(exp: number): boolean {
39+
const currentTime = Date.now() / 1000;
40+
return currentTime > exp;
41+
}
442

543
export async function POST(request: NextRequest) {
644
try {
@@ -18,7 +56,7 @@ export async function POST(request: NextRequest) {
1856
}
1957

2058
const payload = decodeJWT(accessToken, jwtSecret);
21-
if (!payload || !payload.sub) {
59+
if (!payload || !payload.sub || isTokenExpired(payload.exp)) {
2260
return NextResponse.json(
2361
{ error: "Invalid or expired token" },
2462
{ status: 401 },
@@ -39,10 +77,11 @@ export async function POST(request: NextRequest) {
3977

4078
// Filter out null, undefined, or empty string values
4179
const nonNullApiKeys = Object.fromEntries(
42-
Object.entries(apiKeys).filter(([_, value]) => {
80+
Object.entries<string>(apiKeys).filter(([_, value]) => {
4381
return value && typeof value === "string" && value.trim() !== "";
4482
}),
4583
);
84+
const encryptedApiKeys = encryptApiKeys(nonNullApiKeys);
4685

4786
await supabase.auth.setSession({
4887
access_token: accessToken,
@@ -52,7 +91,7 @@ export async function POST(request: NextRequest) {
5291
const { error: upsertError } = await supabase.from("users_config").upsert(
5392
{
5493
user_id: userId,
55-
api_keys: nonNullApiKeys,
94+
api_keys: encryptedApiKeys,
5695
} as any,
5796
{
5897
onConflict: "user_id",
@@ -82,3 +121,75 @@ export async function POST(request: NextRequest) {
82121
);
83122
}
84123
}
124+
125+
export async function GET(request: NextRequest) {
126+
try {
127+
const supabase = getSupabaseClient();
128+
129+
const accessToken = request.headers.get("x-access-token");
130+
const refreshToken = request.headers.get("x-refresh-token");
131+
const jwtSecret = process.env.SUPABASE_JWT_SECRET;
132+
133+
if (!accessToken || !refreshToken || !jwtSecret) {
134+
return NextResponse.json(
135+
{ error: "Authentication required" },
136+
{ status: 401 },
137+
);
138+
}
139+
140+
const payload = decodeJWT(accessToken, jwtSecret);
141+
if (!payload || !payload.sub || isTokenExpired(payload.exp)) {
142+
return NextResponse.json(
143+
{ error: "Invalid or expired token" },
144+
{ status: 401 },
145+
);
146+
}
147+
148+
const userId = payload.sub;
149+
150+
await supabase.auth.setSession({
151+
access_token: accessToken,
152+
refresh_token: refreshToken,
153+
});
154+
155+
const { data, error } = await supabase
156+
.from("users_config")
157+
.select("api_keys")
158+
.eq("user_id", userId)
159+
.single();
160+
161+
if (error && error.code === "PGRST116") {
162+
return NextResponse.json({ error: "No API keys found" }, { status: 404 });
163+
}
164+
165+
if (!data || error) {
166+
return NextResponse.json(
167+
{ error: "Failed to fetch API keys" },
168+
{ status: 500 },
169+
);
170+
}
171+
172+
if (!("api_keys" in data)) {
173+
return NextResponse.json(
174+
{ error: "API keys not found" },
175+
{ status: 404 },
176+
);
177+
}
178+
179+
const encryptedApiKeys = (data as Record<string, any>).api_keys;
180+
const decryptedApiKeys = decryptApiKeys(encryptedApiKeys);
181+
182+
return NextResponse.json(
183+
{
184+
apiKeys: decryptedApiKeys,
185+
},
186+
{ status: 200 },
187+
);
188+
} catch (error) {
189+
console.error("API keys save error:", error);
190+
return NextResponse.json(
191+
{ error: "Internal server error" },
192+
{ status: 500 },
193+
);
194+
}
195+
}

apps/web-v2/src/features/settings/index.tsx

Lines changed: 64 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,66 @@
11
"use client";
22

3-
import React, { useState } from "react";
3+
import React, { useEffect, useRef, useState } from "react";
44
import { Settings, Loader2 } from "lucide-react";
55
import { Separator } from "@/components/ui/separator";
66
import { PasswordInput } from "@/components/ui/password-input";
77
import { Label } from "@/components/ui/label";
88
import { Button } from "@/components/ui/button";
9-
import { useLocalStorage } from "@/hooks/use-local-storage";
109
import { toast } from "sonner";
1110
import { useAuthContext } from "@/providers/Auth";
11+
import { Session } from "@/lib/auth/types";
12+
13+
async function getSavedApiKeys(
14+
session: Session,
15+
): Promise<Record<string, string>> {
16+
try {
17+
if (!session.accessToken || !session.refreshToken) {
18+
toast.error("No session found", { richColors: true });
19+
return {};
20+
}
21+
22+
const response = await fetch("/api/settings/api-keys", {
23+
method: "GET",
24+
headers: {
25+
"Content-Type": "application/json",
26+
"x-access-token": session.accessToken,
27+
"x-refresh-token": session.refreshToken,
28+
},
29+
});
30+
31+
if (response.status === 404) {
32+
const errorData = await response.json();
33+
if (errorData.error === "No API keys found") {
34+
return {};
35+
}
36+
}
37+
38+
if (!response.ok) {
39+
throw new Error("Failed to fetch API keys");
40+
}
41+
42+
const data = await response.json();
43+
return data.apiKeys;
44+
} catch (error) {
45+
console.error("Error fetching API keys:", error);
46+
toast.error("Failed to fetch API keys", { richColors: true });
47+
return {};
48+
}
49+
}
1250

1351
/**
1452
* The Settings interface component containing API Keys configuration.
1553
*/
1654
export default function SettingsInterface(): React.ReactNode {
17-
// Use localStorage hooks for each API key
18-
const [openaiApiKey, setOpenaiApiKey] = useLocalStorage<string>(
19-
"lg:settings:openaiApiKey",
20-
"",
21-
);
22-
const [anthropicApiKey, setAnthropicApiKey] = useLocalStorage<string>(
23-
"lg:settings:anthropicApiKey",
24-
"",
25-
);
26-
const [googleApiKey, setGoogleApiKey] = useLocalStorage<string>(
27-
"lg:settings:googleApiKey",
28-
"",
29-
);
30-
55+
const [loading, setLoading] = useState(true);
3156
const [isSaving, setIsSaving] = useState(false);
3257

58+
const [openaiApiKey, setOpenaiApiKey] = useState("");
59+
const [anthropicApiKey, setAnthropicApiKey] = useState("");
60+
const [googleApiKey, setGoogleApiKey] = useState("");
61+
3362
const { session } = useAuthContext();
63+
3464
const handleSaveApiKeys = async () => {
3565
if (!session?.accessToken || !session?.refreshToken) {
3666
toast.error("You must be logged in to save API keys");
@@ -80,6 +110,20 @@ export default function SettingsInterface(): React.ReactNode {
80110
}
81111
};
82112

113+
const hasRequestedInitialApiKeys = useRef(false);
114+
useEffect(() => {
115+
if (!session || !session.accessToken || !session.refreshToken) return;
116+
if (hasRequestedInitialApiKeys.current) return;
117+
getSavedApiKeys(session)
118+
.then((apiKeys) => {
119+
setOpenaiApiKey(apiKeys.OPENAI_API_KEY || "");
120+
setAnthropicApiKey(apiKeys.ANTHROPIC_API_KEY || "");
121+
setGoogleApiKey(apiKeys.GOOGLE_API_KEY || "");
122+
})
123+
.finally(() => setLoading(false));
124+
hasRequestedInitialApiKeys.current = true;
125+
}, [session]);
126+
83127
return (
84128
<div className="flex w-full flex-col gap-4 p-6">
85129
<div className="flex w-full items-center justify-start gap-6">
@@ -102,6 +146,7 @@ export default function SettingsInterface(): React.ReactNode {
102146
placeholder="Enter your OpenAI API key"
103147
value={openaiApiKey}
104148
onChange={(e) => setOpenaiApiKey(e.target.value)}
149+
disabled={loading || isSaving}
105150
/>
106151
</div>
107152

@@ -113,6 +158,7 @@ export default function SettingsInterface(): React.ReactNode {
113158
placeholder="Enter your Anthropic API key"
114159
value={anthropicApiKey}
115160
onChange={(e) => setAnthropicApiKey(e.target.value)}
161+
disabled={loading || isSaving}
116162
/>
117163
</div>
118164

@@ -124,6 +170,7 @@ export default function SettingsInterface(): React.ReactNode {
124170
placeholder="Enter your Google Gen AI API key"
125171
value={googleApiKey}
126172
onChange={(e) => setGoogleApiKey(e.target.value)}
173+
disabled={loading || isSaving}
127174
/>
128175
</div>
129176
</div>
@@ -132,7 +179,7 @@ export default function SettingsInterface(): React.ReactNode {
132179
<div className="flex justify-end">
133180
<Button
134181
onClick={handleSaveApiKeys}
135-
disabled={isSaving}
182+
disabled={isSaving || loading}
136183
className="min-w-[120px]"
137184
>
138185
{isSaving ? (

0 commit comments

Comments
 (0)