Apple login
Some checks failed
Deploy to Server / deploy (push) Failing after 11s

This commit is contained in:
2026-04-10 00:31:44 -05:00
parent 9952d989b8
commit f1dfb1240f
3 changed files with 114 additions and 0 deletions

View File

@@ -17,6 +17,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"form-data": "^4.0.1",
"jose": "^6.2.2",
"kysely": "^0.28.15",
"mailgun.js": "^11.1.0",
"multer": "^2.0.0",
@@ -28,6 +29,7 @@
"@types/multer": "^2.1.0",
"@types/node": "^22.13.10",
"@types/pg": "^8.11.11",
"jsonwebtoken": "^9.0.3",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
}

View File

@@ -3,8 +3,100 @@ 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<string> {
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({
@@ -58,6 +150,11 @@ export const auth = betterAuth({
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: [