11import type { BetterAuthOptions } from "better-auth" ;
22import { betterAuth } from "better-auth" ;
33import { genericOAuth } from "better-auth/plugins" ;
4+ import crypto from "crypto" ;
5+ import { cookies } from "next/headers" ;
46
57const OIDC_PROVIDER_ID = process . env . OIDC_PROVIDER_ID || "oidc" ;
68const OIDC_ISSUER = process . env . OIDC_ISSUER || "" ;
79const BASE_URL = process . env . BETTER_AUTH_URL || "http://localhost:3000" ;
10+ const ENCRYPTION_KEY =
11+ process . env . BETTER_AUTH_SECRET || "build-time-placeholder" ;
812
913const 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+
1771export 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