Skip to content

Commit f330624

Browse files
committed
chore: store provider accessToken into secure cookie
1 parent 30a745f commit f330624

File tree

1 file changed

+137
-0
lines changed

1 file changed

+137
-0
lines changed

src/lib/auth.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { BetterAuthOptions } from "better-auth";
22
import { betterAuth } from "better-auth";
33
import { genericOAuth } from "better-auth/plugins";
4+
import crypto from "crypto";
5+
import { cookies } from "next/headers";
46

57
const OIDC_PROVIDER_ID = process.env.OIDC_PROVIDER_ID || "oidc";
68
const OIDC_ISSUER = process.env.OIDC_ISSUER || "";
79
const BASE_URL = process.env.BETTER_AUTH_URL || "http://localhost:3000";
10+
const ENCRYPTION_KEY =
11+
process.env.BETTER_AUTH_SECRET || "build-time-placeholder";
812

913
const trustedOrigins = process.env.TRUSTED_ORIGINS
1014
? process.env.TRUSTED_ORIGINS.split(",").map((s) => s.trim())
@@ -14,6 +18,56 @@ if (!trustedOrigins.includes(BASE_URL)) {
1418
trustedOrigins.push(BASE_URL);
1519
}
1620

21+
/**
22+
* Encrypts data using AES-256-GCM (AEAD).
23+
* Provides both confidentiality and integrity/authentication.
24+
*/
25+
function encrypt(text: string): string {
26+
const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32);
27+
const iv = crypto.randomBytes(12); // GCM recommends 12 bytes
28+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
29+
30+
const encrypted = Buffer.concat([
31+
cipher.update(text, "utf8"),
32+
cipher.final(),
33+
]);
34+
35+
const authTag = cipher.getAuthTag();
36+
37+
return [
38+
iv.toString("hex"),
39+
authTag.toString("hex"),
40+
encrypted.toString("hex"),
41+
].join(":");
42+
}
43+
44+
/**
45+
* Decrypts data encrypted with AES-256-GCM.
46+
* Verifies authenticity before decryption.
47+
*/
48+
function decrypt(payload: string): string {
49+
const key = crypto.scryptSync(ENCRYPTION_KEY, "salt", 32);
50+
const [ivHex, tagHex, encryptedHex] = payload.split(":");
51+
52+
if (!ivHex || !tagHex || !encryptedHex) {
53+
throw new Error("Invalid encrypted data format");
54+
}
55+
56+
const iv = Buffer.from(ivHex, "hex");
57+
const authTag = Buffer.from(tagHex, "hex");
58+
const encrypted = Buffer.from(encryptedHex, "hex");
59+
60+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
61+
decipher.setAuthTag(authTag);
62+
63+
const decrypted = Buffer.concat([
64+
decipher.update(encrypted),
65+
decipher.final(),
66+
]);
67+
68+
return decrypted.toString("utf8");
69+
}
70+
1771
export const auth = betterAuth({
1872
secret: process.env.BETTER_AUTH_SECRET || "build-time-placeholder",
1973
baseURL: BASE_URL,
@@ -38,4 +92,87 @@ export const auth = betterAuth({
3892
],
3993
}),
4094
],
95+
// Use databaseHooks to save tokens in HTTP-only cookie after account creation
96+
databaseHooks: {
97+
account: {
98+
create: {
99+
after: async (account) => {
100+
if (account.accessToken && account.userId) {
101+
const expiresAt = account.accessTokenExpiresAt
102+
? new Date(account.accessTokenExpiresAt).getTime()
103+
: Date.now() + 3600000;
104+
105+
const tokenData = JSON.stringify({
106+
accessToken: account.accessToken,
107+
refreshToken: account.refreshToken || undefined,
108+
expiresAt,
109+
userId: account.userId,
110+
});
111+
112+
const encrypted = encrypt(tokenData);
113+
const cookieStore = await cookies();
114+
115+
cookieStore.set("oidc_token", encrypted, {
116+
httpOnly: true,
117+
secure: process.env.NODE_ENV === "production",
118+
sameSite: "lax",
119+
maxAge: 60 * 60 * 24 * 7, // 7 days
120+
path: "/",
121+
});
122+
}
123+
},
124+
},
125+
},
126+
},
41127
} as BetterAuthOptions);
128+
129+
/**
130+
* Retrieves the OIDC provider access token from HTTP-only cookie.
131+
* Returns null if token not found, expired, or belongs to different user.
132+
*/
133+
export async function getOidcProviderAccessToken(
134+
userId: string,
135+
): Promise<string | null> {
136+
try {
137+
const cookieStore = await cookies();
138+
const encryptedCookie = cookieStore.get("oidc_token");
139+
140+
if (!encryptedCookie?.value) {
141+
return null;
142+
}
143+
144+
const decrypted = decrypt(encryptedCookie.value);
145+
const tokenData = JSON.parse(decrypted) as {
146+
accessToken: string;
147+
refreshToken?: string;
148+
expiresAt: number;
149+
userId: string;
150+
};
151+
152+
// Verify the token belongs to the current user
153+
if (tokenData.userId !== userId) {
154+
return null;
155+
}
156+
157+
// Check if token is expired
158+
const now = Date.now();
159+
if (tokenData.expiresAt < now) {
160+
// Clear expired cookie
161+
cookieStore.delete("oidc_token");
162+
return null;
163+
}
164+
165+
return tokenData.accessToken;
166+
} catch (error) {
167+
console.error("[Auth] Error reading OIDC token from cookie:", error);
168+
return null;
169+
}
170+
}
171+
172+
/**
173+
* Clears the OIDC token cookie (useful for logout).
174+
*/
175+
export async function clearOidcProviderToken(): Promise<void> {
176+
const cookieStore = await cookies();
177+
cookieStore.delete("oidc_token");
178+
}

0 commit comments

Comments
 (0)