const crypto = require('crypto'); const { get, run } = require('./postgres'); const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod'; const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys'; const APPLE_ISSUER = 'https://appleid.apple.com'; const APPLE_AUDIENCE = ( process.env.APPLE_CLIENT_ID || process.env.APPLE_BUNDLE_ID || process.env.EXPO_PUBLIC_APPLE_CLIENT_ID || process.env.IOS_BUNDLE_ID || 'com.greenlens.app' ).trim(); let appleJwksCache = { keys: [], expiresAt: 0 }; // ─── Minimal JWT (HS256, no external deps) ───────────────────────────────── const b64url = (input) => { const str = typeof input === 'string' ? input : JSON.stringify(input); return Buffer.from(str).toString('base64url'); }; const b64urlDecode = (str) => Buffer.from(str, 'base64url').toString(); const signJwt = (payload) => { const header = b64url({ alg: 'HS256', typ: 'JWT' }); const body = b64url(payload); const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url'); return `${header}.${body}.${sig}`; }; const verifyJwt = (token) => { if (!token || typeof token !== 'string') return null; const parts = token.split('.'); if (parts.length !== 3) return null; const [header, body, sig] = parts; const expected = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url'); if (sig !== expected) return null; try { const payload = JSON.parse(b64urlDecode(body)); if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) return null; return payload; } catch { return null; } }; const issueToken = (userId, email, name) => signJwt({ sub: userId, email, name, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS, }); // ─── Password hashing ────────────────────────────────────────────────────── const hashPassword = (password) => crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex'); const parseJwtPart = (value) => JSON.parse(b64urlDecode(value)); const getAppleJwks = async () => { const now = Date.now(); if (appleJwksCache.keys.length > 0 && appleJwksCache.expiresAt > now) { return appleJwksCache.keys; } const response = await fetch(APPLE_JWKS_URL); if (!response.ok) { const error = new Error('Could not load Apple public keys.'); error.code = 'APPLE_AUTH_UNAVAILABLE'; error.status = 503; throw error; } const payload = await response.json(); appleJwksCache = { keys: Array.isArray(payload.keys) ? payload.keys : [], expiresAt: now + 6 * 60 * 60 * 1000, }; return appleJwksCache.keys; }; const verifyAppleIdentityToken = async (identityToken) => { if (!identityToken || typeof identityToken !== 'string') { const error = new Error('Apple identityToken is required.'); error.code = 'BAD_REQUEST'; error.status = 400; throw error; } const parts = identityToken.split('.'); if (parts.length !== 3) { const error = new Error('Apple identityToken is malformed.'); error.code = 'APPLE_AUTH_INVALID'; error.status = 401; throw error; } const [encodedHeader, encodedPayload, encodedSignature] = parts; let header; let claims; try { header = parseJwtPart(encodedHeader); claims = parseJwtPart(encodedPayload); } catch { const error = new Error('Apple identityToken is malformed.'); error.code = 'APPLE_AUTH_INVALID'; error.status = 401; throw error; } if (header.alg !== 'RS256' || !header.kid) { const error = new Error('Apple identityToken has an unsupported signature.'); error.code = 'APPLE_AUTH_INVALID'; error.status = 401; throw error; } const keys = await getAppleJwks(); const jwk = keys.find((key) => key.kid === header.kid); if (!jwk) { const error = new Error('Apple public key not found.'); error.code = 'APPLE_AUTH_INVALID'; error.status = 401; throw error; } const verifier = crypto.createVerify('RSA-SHA256'); verifier.update(`${encodedHeader}.${encodedPayload}`); verifier.end(); const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' }); const validSignature = verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url')); const nowSeconds = Math.floor(Date.now() / 1000); const expectedAudiences = new Set([APPLE_AUDIENCE, 'com.greenlens.app'].filter(Boolean)); if ( !validSignature || claims.iss !== APPLE_ISSUER || !expectedAudiences.has(claims.aud) || !claims.sub || (claims.exp && nowSeconds > Number(claims.exp)) ) { const error = new Error('Apple identityToken could not be verified.'); error.code = 'APPLE_AUTH_INVALID'; error.status = 401; throw error; } return claims; }; // ─── Schema ──────────────────────────────────────────────────────────────── const ensureAuthSchema = async (db) => { await run( db, `CREATE TABLE IF NOT EXISTS auth_users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', password_hash TEXT, auth_provider TEXT NOT NULL DEFAULT 'email', apple_subject TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() )`, ); await run(db, "ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'email'"); await run(db, 'ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS apple_subject TEXT'); await run(db, 'ALTER TABLE auth_users ALTER COLUMN password_hash DROP NOT NULL'); await run( db, `CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_users_apple_subject ON auth_users (apple_subject) WHERE apple_subject IS NOT NULL`, ); }; // ─── Operations ─────────────────────────────────────────────────────────── const signUp = async (db, email, name, password) => { const normalizedEmail = email.trim().toLowerCase(); const existing = await get(db, 'SELECT id FROM auth_users WHERE LOWER(email) = LOWER($1)', [normalizedEmail]); if (existing) { const err = new Error('Email already in use.'); err.code = 'EMAIL_TAKEN'; err.status = 409; throw err; } const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES ($1, $2, $3, $4)', [ id, normalizedEmail, name.trim(), hashPassword(password), ]); return { id, email: normalizedEmail, name: name.trim() }; }; const login = async (db, email, password) => { const normalizedEmail = email.trim().toLowerCase(); const user = await get( db, 'SELECT id, email, name, password_hash FROM auth_users WHERE LOWER(email) = LOWER($1)', [normalizedEmail], ); if (!user) { const err = new Error('No account found for this email.'); err.code = 'USER_NOT_FOUND'; err.status = 401; throw err; } if (!user.password_hash) { const err = new Error('This account uses Apple Sign-In.'); err.code = 'USE_APPLE_LOGIN'; err.status = 401; throw err; } if (user.password_hash !== hashPassword(password)) { const err = new Error('Wrong password.'); err.code = 'WRONG_PASSWORD'; err.status = 401; throw err; } return { id: user.id, email: user.email, name: user.name }; }; const signInWithApple = async (db, identityToken, profile = {}) => { const claims = await verifyAppleIdentityToken(identityToken); const appleSubject = String(claims.sub); const emailFromToken = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : ''; const emailFromProfile = typeof profile.email === 'string' ? profile.email.trim().toLowerCase() : ''; const normalizedEmail = emailFromToken || emailFromProfile; const profileName = typeof profile.name === 'string' ? profile.name.trim() : ''; const existingByApple = await get( db, 'SELECT id, email, name FROM auth_users WHERE apple_subject = $1', [appleSubject], ); if (existingByApple) return existingByApple; if (!normalizedEmail) { const err = new Error('Apple did not return an email for this account.'); err.code = 'APPLE_EMAIL_MISSING'; err.status = 400; throw err; } const existingByEmail = await get( db, 'SELECT id, email, name FROM auth_users WHERE LOWER(email) = LOWER($1)', [normalizedEmail], ); if (existingByEmail) { const nextName = existingByEmail.name || profileName || normalizedEmail.split('@')[0] || 'GreenLens User'; await run( db, 'UPDATE auth_users SET apple_subject = $1, auth_provider = $2, name = $3 WHERE id = $4', [appleSubject, 'apple', nextName, existingByEmail.id], ); return { ...existingByEmail, name: nextName }; } const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; const name = profileName || normalizedEmail.split('@')[0] || 'GreenLens User'; await run( db, `INSERT INTO auth_users (id, email, name, password_hash, auth_provider, apple_subject) VALUES ($1, $2, $3, NULL, $4, $5)`, [id, normalizedEmail, name, 'apple', appleSubject], ); return { id, email: normalizedEmail, name }; }; module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken };