This commit is contained in:
@@ -89,6 +89,10 @@ export function LoginForm({ onSignUp, onForgotPassword }: Props) {
|
|||||||
await signIn.social({ provider: 'google', callbackURL: window.location.origin, errorCallbackURL: window.location.origin });
|
await signIn.social({ provider: 'google', callbackURL: window.location.origin, errorCallbackURL: window.location.origin });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleApple() {
|
||||||
|
await signIn.social({ provider: 'apple', callbackURL: window.location.origin, errorCallbackURL: window.location.origin });
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToAbout() {
|
function scrollToAbout() {
|
||||||
aboutRef.current?.scrollIntoView({ behavior: 'smooth' });
|
aboutRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
@@ -204,6 +208,17 @@ export function LoginForm({ onSignUp, onForgotPassword }: Props) {
|
|||||||
Sign in with Google
|
Sign in with Google
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full flex items-center gap-3"
|
||||||
|
onClick={handleApple}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||||
|
<path d="M16.365 1.43c0 1.14-.42 2.23-1.16 3.06-.79.91-2.07 1.62-3.18 1.53-.13-1.12.42-2.27 1.13-3.04.8-.86 2.16-1.5 3.21-1.55zM20.5 17.27c-.55 1.27-.81 1.84-1.52 2.96-.99 1.56-2.39 3.5-4.12 3.51-1.54.02-1.94-1-4.03-.99-2.09.01-2.53 1.01-4.07.99-1.73-.02-3.06-1.78-4.05-3.34C-.04 16.04-.42 11.07 1.93 8.41c1.43-1.62 3.69-2.58 5.81-2.58 2.16 0 3.52 1.18 5.31 1.18 1.74 0 2.79-1.18 5.29-1.18 1.88 0 3.88 1.03 5.3 2.81-4.66 2.55-3.9 9.21-3.14 8.63z"/>
|
||||||
|
</svg>
|
||||||
|
Sign in with Apple
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<button
|
<button
|
||||||
onClick={onForgotPassword}
|
onClick={onForgotPassword}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"form-data": "^4.0.1",
|
"form-data": "^4.0.1",
|
||||||
|
"jose": "^6.2.2",
|
||||||
"kysely": "^0.28.15",
|
"kysely": "^0.28.15",
|
||||||
"mailgun.js": "^11.1.0",
|
"mailgun.js": "^11.1.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@types/pg": "^8.11.11",
|
"@types/pg": "^8.11.11",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,100 @@ import { hash as argon2Hash, verify as argon2Verify } from '@node-rs/argon2';
|
|||||||
import { kyselyAdapter } from '@better-auth/kysely-adapter';
|
import { kyselyAdapter } from '@better-auth/kysely-adapter';
|
||||||
import { Kysely, PostgresDialect } from 'kysely';
|
import { Kysely, PostgresDialect } from 'kysely';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
import { SignJWT, importPKCS8 } from 'jose';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { sendEmail, verificationEmailHtml, resetPasswordEmailHtml } from './email.js';
|
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({
|
const db = new Kysely({
|
||||||
dialect: new PostgresDialect({
|
dialect: new PostgresDialect({
|
||||||
pool: new Pool({
|
pool: new Pool({
|
||||||
@@ -58,6 +150,11 @@ export const auth = betterAuth({
|
|||||||
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
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: [
|
trustedOrigins: [
|
||||||
|
|||||||
Reference in New Issue
Block a user