← back to Professional Directory
agents/api-agent/auth.js
121 lines
/**
* Auth helpers for pd-api.
*
* - passport-google-oauth20 strategy (idempotent upsert into users on login)
* - serialize/deserialize via users.id
* - requireRole(...roles) middleware factory
* - requireTier(tier) middleware factory (admin always passes)
* - loopbackSyntheticAdmin middleware — gives Steve admin on 127.0.0.1
*
* No clinical/HIPAA-protected data flows through these routes (Phase 1
* non-clinical scope per ~/.claude/plans/we-are-selling-websites-...md).
*/
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const { query } = require('../shared/db');
const CALLBACK_URL = process.env.GOOGLE_OAUTH_CALLBACK || '/auth/google/callback';
const CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID || '';
const CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET || '';
if (CLIENT_ID && CLIENT_SECRET) {
passport.use(new GoogleStrategy({
clientID: CLIENT_ID,
clientSecret: CLIENT_SECRET,
callbackURL: CALLBACK_URL,
}, async (_accessToken, _refreshToken, profile, done) => {
try {
const email = (profile.emails?.[0]?.value || '').toLowerCase();
if (!email) return done(new Error('Google profile has no email'));
const r = await query(`
INSERT INTO users (email, google_sub, display_name, avatar_url, email_verified_at, last_login_at)
VALUES ($1, $2, $3, $4, now(), now())
ON CONFLICT (email) DO UPDATE SET
google_sub = COALESCE(users.google_sub, EXCLUDED.google_sub),
display_name = COALESCE(users.display_name, EXCLUDED.display_name),
avatar_url = COALESCE(users.avatar_url, EXCLUDED.avatar_url),
last_login_at = now()
RETURNING *
`, [email, profile.id, profile.displayName || null, profile.photos?.[0]?.value || null]);
done(null, r.rows[0]);
} catch (e) { done(e); }
}));
passport.serializeUser((u, cb) => cb(null, u.id));
passport.deserializeUser(async (id, cb) => {
try {
const r = await query('SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL', [id]);
cb(null, r.rows[0] || null);
} catch (e) { cb(e); }
});
}
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'auth required' });
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'forbidden' });
next();
};
}
function requireTier(tier) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ error: 'auth required' });
if (req.user.role === 'admin') return next();
if (req.user.tier !== tier) return res.status(402).json({ error: 'paid tier required', upgrade: '/subscriptions/checkout' });
next();
};
}
/**
* Loopback synthetic-admin: when ADMIN_LOOPBACK_TRUST=1 (set in pm2 ecosystem)
* and the request originates from 127.0.0.1, inject a synthetic admin user.
* Lets Steve drive the existing dashboard at http://127.0.0.1:9874 without
* going through Google OAuth. Remote callers (via tunnel or pd-preview) get
* NO bypass — they go through the standard passport flow.
*/
// Resolve the admin user_id ONCE at boot from the steve@designerwallcoverings.com
// row that we seed on Phase 1 install. Falls back to 1 if lookup fails (the seed
// row should always be id=1 as the first INSERT into users).
let _adminUserId = Number(process.env.ADMIN_USER_ID) || null;
async function _ensureAdminUserId() {
if (_adminUserId) return _adminUserId;
try {
const r = await query("SELECT id FROM users WHERE email = $1", ['steve@designerwallcoverings.com']);
_adminUserId = r.rows[0]?.id || 1;
} catch (_) { _adminUserId = 1; }
return _adminUserId;
}
async function loopbackSyntheticAdmin(req, _res, next) {
if (process.env.ADMIN_LOOPBACK_TRUST !== '1') return next();
if (req.user) return next();
// SECURITY: codex found that req.ip honors X-Forwarded-For when trust-proxy is on,
// letting any tunneled caller spoof 127.0.0.1. We MUST check the raw socket.
const sockIp = req.socket?.remoteAddress || '';
const isLoopback = sockIp === '127.0.0.1' || sockIp === '::1' || sockIp === '::ffff:127.0.0.1';
if (!isLoopback) return next();
const id = await _ensureAdminUserId();
req.user = {
id,
email: 'steve@designerwallcoverings.com',
role: 'admin',
tier: 'paid',
display_name: 'Steve (loopback)',
_synthetic: true,
};
next();
}
function authConfigured() {
return Boolean(CLIENT_ID && CLIENT_SECRET);
}
module.exports = {
passport,
requireRole,
requireTier,
loopbackSyntheticAdmin,
authConfigured,
};