← 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,
};