import { betterAuth } from 'better-auth'; import { hash as argon2Hash, verify as argon2Verify } from '@node-rs/argon2'; import { kyselyAdapter } from '@better-auth/kysely-adapter'; import { Kysely, PostgresDialect } from 'kysely'; import { Pool } from 'pg'; import { SignJWT, importPKCS8 } from 'jose'; import { readFileSync } from 'fs'; import { resolve } from 'path'; import { sendEmail, verificationEmailHtml, resetPasswordEmailHtml } from './email.js'; /** * Generate Apple's "client secret" — actually a JWT signed with the .p8 * private key from the Apple Developer portal. Apple caps the validity at * 180 days. We regenerate at server boot and again every ~150 days while * the process is running, so neither restarts nor uptime can let it expire. * * Required env vars (web sign-in only — iOS native flow doesn't need any): * APPLE_TEAM_ID — 10-char Team ID from developer.apple.com * APPLE_KEY_ID — 10-char Key ID from the Sign in with Apple key * APPLE_CLIENT_ID — Services ID, e.g. com.wahwa.labwise.web * APPLE_PRIVATE_KEY_PATH — Path to the AuthKey_XXXXXXXXXX.p8 file, * relative to the server/ directory. * e.g. APPLE_PRIVATE_KEY_PATH=AuthKey_ABCD123456.p8 */ async function generateAppleClientSecret(): Promise { const teamId = process.env.APPLE_TEAM_ID; const keyId = process.env.APPLE_KEY_ID; const clientId = process.env.APPLE_CLIENT_ID; const keyPath = process.env.APPLE_PRIVATE_KEY_PATH; if (!teamId || !keyId || !clientId || !keyPath) return ''; // Resolve relative to the server/ directory (where the process runs from). const absolutePath = resolve(process.cwd(), keyPath); const pem = readFileSync(absolutePath, 'utf8'); const privateKey = await importPKCS8(pem, 'ES256'); return await new SignJWT({}) .setProtectedHeader({ alg: 'ES256', kid: keyId }) .setIssuedAt() .setIssuer(teamId) .setAudience('https://appleid.apple.com') .setSubject(clientId) // 180 days is the maximum Apple allows. .setExpirationTime('180d') .sign(privateKey); } // Apple's `clientSecret` lives on this object. Better Auth captures the // reference at startup (see @better-auth/core context init) and reads // `options.clientSecret` lazily inside createAuthorizationCodeRequest, so // mutating this property in-place is enough to rotate the JWT — no auth // reinitialisation required. const appleProviderConfig = { clientId: process.env.APPLE_CLIENT_ID || '', clientSecret: await generateAppleClientSecret(), appBundleIdentifier: process.env.APPLE_APP_BUNDLE_ID || '', audience: [ process.env.APPLE_CLIENT_ID, process.env.APPLE_APP_BUNDLE_ID, ].filter((v): v is string => !!v), }; if (process.env.APPLE_CLIENT_ID && !appleProviderConfig.clientSecret) { console.warn( '[auth] APPLE_CLIENT_ID is set but APPLE_TEAM_ID / APPLE_KEY_ID / APPLE_PRIVATE_KEY_PATH is missing. ' + 'Web Sign in with Apple will not work until all four are provided. (iOS native sign-in works without them.)' ); } // Self-rotating refresher: check once per day and regenerate the JWT once // it crosses ~150 days old. Apple's hard cap is 180 days, so this leaves a // month of head-room. The check is cheap (a single ECDSA sign) and only // does real work on the day a rotation is due. // // Node's setInterval max delay is ~24.8 days (32-bit signed int) so we // can't schedule a single 150-day timer; a daily check is the simplest // way to stay under that limit. if (appleProviderConfig.clientSecret) { const ONE_DAY_MS = 24 * 60 * 60 * 1000; const REFRESH_AFTER_MS = 150 * ONE_DAY_MS; let lastRotatedAt = Date.now(); const timer = setInterval(async () => { if (Date.now() - lastRotatedAt < REFRESH_AFTER_MS) return; try { const fresh = await generateAppleClientSecret(); if (fresh) { appleProviderConfig.clientSecret = fresh; lastRotatedAt = Date.now(); console.log('[auth] Apple client secret rotated (150-day refresh).'); } } catch (err) { console.error('[auth] Failed to rotate Apple client secret:', err); } }, ONE_DAY_MS); // Don't keep the event loop alive solely for this timer. timer.unref(); } const db = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL || 'postgresql://labwise:labwise_dev_pw@localhost:5432/labwise_db', }), }), }); export const auth = betterAuth({ database: kyselyAdapter(db, { type: 'postgres' }), baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3001', secret: process.env.BETTER_AUTH_SECRET || 'dev-secret-change-in-production-min32chars!!', rateLimit: { enabled: false, // TODO: re-enable in production }, emailAndPassword: { enabled: true, requireEmailVerification: true, password: { hash: (password) => argon2Hash(password, { memoryCost: 65536, timeCost: 3, parallelism: 4, algorithm: 2 }), verify: ({ hash, password }) => argon2Verify(hash, password), }, sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, subject: 'Reset your LabWise password', html: resetPasswordEmailHtml(url), }); }, }, emailVerification: { sendVerificationEmail: async ({ user, url }) => { await sendEmail({ to: user.email, subject: 'Verify your LabWise email', html: verificationEmailHtml(url), }); }, }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID || '', clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', }, // Sign in with Apple — see appleProviderConfig above for the rotating // clientSecret. Both flows (web redirect, iOS native id-token) hit the // same provider; `audience` lists both the Services ID and the iOS // bundle ID so whichever token a client presents is accepted. apple: appleProviderConfig, }, trustedOrigins: [ 'http://localhost:5173', 'https://labwise.wahwa.com', // iOS native app callback — allows Better Auth to honour the // https://labwise.wahwa.com/api/ios-callback callbackURL 'labwise://', ], });