Files
Greenlens/server/lib/auth.js
2026-04-29 21:17:49 +02:00

295 lines
10 KiB
JavaScript

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();
const APPLE_ALLOWED_AUDIENCES = [
APPLE_AUDIENCE,
...(process.env.APPLE_ALLOWED_AUDIENCES || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
];
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_ALLOWED_AUDIENCES.filter(Boolean));
if (
!validSignature
|| claims.iss !== APPLE_ISSUER
|| !expectedAudiences.has(claims.aud)
|| !claims.sub
|| (claims.exp && nowSeconds > Number(claims.exp))
) {
console.warn('Apple identityToken verification failed.', {
validSignature,
issuer: claims.iss,
audience: claims.aud,
expectedAudiences: Array.from(expectedAudiences),
hasSubject: Boolean(claims.sub),
expired: Boolean(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, isNewUser: false };
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, isNewUser: false };
}
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, isNewUser: true };
};
module.exports = { ensureAuthSchema, signUp, login, signInWithApple, issueToken, verifyJwt, verifyAppleIdentityToken };