← back to NationalPaperHangers

routes/auth-google.js

194 lines

// Buyer-side Google OAuth (sign-in only).
//
// Distinct from installer auth (lib/auth.js) — buyers are *not* installers.
// We persist them in consumer_accounts and stash req.session.consumerAccountId.
//
// Flow:
//   GET  /auth/google/start?next=/foo  → redirect to Google
//   GET  /auth/google/callback?code=…  → exchange code, upsert account, redirect to `next`
//
// Uses NPH_GOOGLE_OAUTH_CLIENT_ID / NPH_GOOGLE_OAUTH_CLIENT_SECRET. If those
// are missing we render a friendly degraded page instead of erroring.

const express = require('express');
const crypto = require('crypto');
const https = require('https');
const querystring = require('querystring');
const db = require('../lib/db');

const router = express.Router();

// Match the installer-auth pattern in claim.js — regenerate session on sign-in
// so a hijacked pre-auth session ID can't ride through to the authenticated
// identity (session-fixation defense).
function regenerateSession(req) {
  return new Promise((resolve, reject) => {
    req.session.regenerate(err => err ? reject(err) : resolve());
  });
}

const CLIENT_ID = process.env.NPH_GOOGLE_OAUTH_CLIENT_ID || '';
const CLIENT_SECRET = process.env.NPH_GOOGLE_OAUTH_CLIENT_SECRET || '';
const SCOPE = 'openid email profile';

function publicBase(req) {
  return process.env.PUBLIC_URL || (req.protocol + '://' + req.get('host'));
}
function callbackUrl(req) {
  return publicBase(req).replace(/\/+$/, '') + '/auth/google/callback';
}

function safeNext(n) {
  if (!n || typeof n !== 'string') return '/';
  // Only allow same-origin paths.
  if (!n.startsWith('/') || n.startsWith('//')) return '/';
  return n.slice(0, 500);
}

function postForm(host, path, formObj) {
  const body = querystring.stringify(formObj);
  return new Promise((resolve, reject) => {
    const req = https.request({
      method: 'POST',
      host, path,
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
        'content-length': Buffer.byteLength(body)
      }
    }, res => {
      let chunks = '';
      res.on('data', c => chunks += c);
      res.on('end', () => {
        try { resolve({ status: res.statusCode, json: JSON.parse(chunks) }); }
        catch (e) { reject(new Error('OAUTH_TOKEN_BAD_JSON')); }
      });
    });
    req.on('error', reject);
    req.write(body);
    req.end();
  });
}

function getJson(host, path, accessToken) {
  return new Promise((resolve, reject) => {
    const req = https.request({
      method: 'GET',
      host, path,
      headers: { 'authorization': 'Bearer ' + accessToken }
    }, res => {
      let chunks = '';
      res.on('data', c => chunks += c);
      res.on('end', () => {
        try { resolve({ status: res.statusCode, json: JSON.parse(chunks) }); }
        catch (e) { reject(new Error('OAUTH_USERINFO_BAD_JSON')); }
      });
    });
    req.on('error', reject);
    req.end();
  });
}

router.get('/auth/google/start', (req, res) => {
  if (!CLIENT_ID) {
    return res.status(503).render('public/error', {
      title: 'Sign-in unavailable',
      message: 'Google sign-in is not yet configured. Please continue without sign-in, or email info@nationalpaperhangers.com.',
      path: req.path
    });
  }
  const state = crypto.randomBytes(24).toString('base64url');
  const nonce = crypto.randomBytes(24).toString('base64url');
  req.session.gOAuth = { state, nonce, next: safeNext(req.query.next) };
  const params = querystring.stringify({
    client_id: CLIENT_ID,
    redirect_uri: callbackUrl(req),
    response_type: 'code',
    scope: SCOPE,
    state,
    nonce,
    prompt: 'select_account',
    access_type: 'online'
  });
  res.redirect('https://accounts.google.com/o/oauth2/v2/auth?' + params);
});

router.get('/auth/google/callback', async (req, res, next) => {
  try {
    if (!CLIENT_ID || !CLIENT_SECRET) {
      return res.status(503).render('public/error', {
        title: 'Sign-in unavailable', message: 'Google sign-in is not configured.', path: req.path
      });
    }
    const sessionState = req.session.gOAuth && req.session.gOAuth.state;
    const sessionNext = (req.session.gOAuth && req.session.gOAuth.next) || '/';
    if (!sessionState || sessionState !== req.query.state) {
      return res.status(400).render('public/error', { title: 'Sign-in error', message: 'Invalid state.', path: req.path });
    }
    if (req.query.error) {
      return res.status(400).render('public/error', {
        title: 'Sign-in canceled',
        message: 'Sign-in was canceled. You can try again or continue without an account.',
        path: req.path
      });
    }
    const code = String(req.query.code || '');
    if (!code) {
      return res.status(400).render('public/error', { title: 'Sign-in error', message: 'No code returned.', path: req.path });
    }

    // Token exchange.
    const tokenRes = await postForm('oauth2.googleapis.com', '/token', {
      code,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      redirect_uri: callbackUrl(req),
      grant_type: 'authorization_code'
    });
    if (tokenRes.status !== 200 || !tokenRes.json.access_token) {
      return res.status(502).render('public/error', { title: 'Sign-in failed', message: 'Could not exchange Google code.', path: req.path });
    }

    // Userinfo (we trust openid sub from this endpoint since it's a server-to-server fetch).
    const ui = await getJson('openidconnect.googleapis.com', '/v1/userinfo', tokenRes.json.access_token);
    if (ui.status !== 200 || !ui.json.sub) {
      return res.status(502).render('public/error', { title: 'Sign-in failed', message: 'Could not read Google profile.', path: req.path });
    }
    const profile = ui.json;

    // Upsert by google_sub.
    const existing = await db.one('SELECT id FROM consumer_accounts WHERE google_sub = $1', [profile.sub]);
    let id;
    if (existing) {
      await db.query(
        `UPDATE consumer_accounts
            SET email = $2, email_verified = $3, name = $4, picture_url = $5, last_login_at = now()
          WHERE id = $1`,
        [existing.id, profile.email, !!profile.email_verified, profile.name || null, profile.picture || null]
      );
      id = existing.id;
    } else {
      const ins = await db.one(
        `INSERT INTO consumer_accounts (google_sub, email, email_verified, name, picture_url, last_login_at)
         VALUES ($1, $2, $3, $4, $5, now()) RETURNING id`,
        [profile.sub, profile.email, !!profile.email_verified, profile.name || null, profile.picture || null]
      );
      id = ins.id;
    }
    // Regenerate before assigning the authenticated identity (session fixation).
    await regenerateSession(req);
    req.session.consumerAccountId = id;
    req.session.consumer = { id, email: profile.email, name: profile.name || null, picture_url: profile.picture || null };
    // Explicit save: under load the PG session-store write can lag the 302,
    // landing the client on /book pre-persistence and looking signed-out.
    req.session.save(err => err ? next(err) : res.redirect(sessionNext));
  } catch (err) { next(err); }
});

router.post('/auth/google/logout', (req, res) => {
  req.session.consumerAccountId = null;
  req.session.consumer = null;
  res.redirect(req.body.next && req.body.next.startsWith('/') ? req.body.next : '/');
});

module.exports = router;