← back to AbramsOS

routes/auth.js

82 lines

const express = require('express');
const crypto = require('crypto');
const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
const { encrypt } = require('../lib/crypto');
const { authUrl, exchange, SCOPES } = require('../lib/google-oauth');

const router = express.Router();

const DEV_USER_ID = 'user_steve';

router.get('/auth/google/start', (req, res) => {
  try {
    const state = crypto.randomBytes(16).toString('hex');
    req.session = req.session || {};
    req.session.oauthState = state;
    res.redirect(authUrl(state));
  } catch (err) {
    res.status(500).render('error', { error: err.message });
  }
});

router.get('/auth/google/callback', async (req, res) => {
  const { code, state, error } = req.query;
  if (error) return res.status(400).render('error', { error: `Google returned: ${error}` });
  if (!code) return res.status(400).render('error', { error: 'Missing authorization code' });
  if (req.session?.oauthState && req.session.oauthState !== state) {
    return res.status(400).render('error', { error: 'OAuth state mismatch (possible CSRF)' });
  }

  try {
    const tokens = await exchange(code);
    if (!tokens.refreshToken) {
      return res.status(400).render('error', {
        error: 'Google did not return a refresh token. Revoke the app from your Google Account → Security → Third-party access, then try again.',
      });
    }

    const consentId = id('consent');
    const connectorId = id('connector');

    const enc = encrypt(tokens.refreshToken);

    await db.tx(async (client) => {
      await client.query(
        `INSERT INTO consent_grant (id, user_id, connector_type, scopes_json)
         VALUES ($1, $2, $3, $4)`,
        [consentId, DEV_USER_ID, 'gmail', JSON.stringify(SCOPES)]
      );
      await client.query(
        `INSERT INTO connector_account
           (id, user_id, consent_grant_id, provider, external_subject_id,
            refresh_token_encrypted, refresh_token_iv, refresh_token_tag)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
         ON CONFLICT (provider, external_subject_id) DO UPDATE SET
           refresh_token_encrypted = EXCLUDED.refresh_token_encrypted,
           refresh_token_iv = EXCLUDED.refresh_token_iv,
           refresh_token_tag = EXCLUDED.refresh_token_tag,
           consent_grant_id = EXCLUDED.consent_grant_id`,
        [connectorId, DEV_USER_ID, consentId, 'google', tokens.email, enc.ciphertext, enc.iv, enc.tag]
      );
    });

    await audit.log({
      actorType: 'user',
      actorId: DEV_USER_ID,
      objectType: 'connector_account',
      objectId: connectorId,
      eventType: 'oauth_grant',
      metadata: { provider: 'google', email: tokens.email, scopes: SCOPES },
    });

    res.redirect('/connectors');
  } catch (err) {
    console.error('[oauth callback]', err);
    res.status(500).render('error', { error: err.message });
  }
});

module.exports = router;