Hard paywall
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
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 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) ─────────────────────────────────
|
||||
|
||||
@@ -47,8 +57,100 @@ const issueToken = (userId, email, name) =>
|
||||
|
||||
// ─── Password hashing ──────────────────────────────────────────────────────
|
||||
|
||||
const hashPassword = (password) =>
|
||||
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -59,10 +161,22 @@ const ensureAuthSchema = async (db) => {
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
@@ -98,14 +212,68 @@ const login = async (db, email, password) => {
|
||||
err.code = 'USER_NOT_FOUND';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
if (user.password_hash !== hashPassword(password)) {
|
||||
}
|
||||
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 };
|
||||
};
|
||||
|
||||
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user