← back to Ventura Corridor

src/server/appointments/oauth.ts

168 lines

/**
 * Google OAuth helpers — fetch-based, no googleapis dep.
 *
 * Used by the appointment-booking widget so a CALENDAR OWNER (e.g. the
 * Ventura Corridor concierge, or any business listed in the directory)
 * can connect their own Google Calendar.  END-USERS who book a slot do
 * NOT see this flow.  Only the owner of the calendar does.
 *
 * Branding rule (per global standing instructions): never call this
 * "Google Calendar" in user-visible copy.  We pitch it as "Smart
 * Scheduling" — Google only appears on the Google-hosted consent screen
 * where it is unavoidable.
 */
import crypto from 'node:crypto';
import { query } from '../../../db/pool.ts';

const TOKEN_ENDPOINT  = 'https://oauth2.googleapis.com/token';
const AUTH_ENDPOINT   = 'https://accounts.google.com/o/oauth2/v2/auth';
const REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';

export const SCOPES = [
  'openid',
  'email',
  'profile',
  'https://www.googleapis.com/auth/calendar.events',
  'https://www.googleapis.com/auth/calendar.readonly',
];

export function getOAuthConfig() {
  const clientId     = process.env.GOOGLE_OAUTH_CLIENT_ID || process.env.GOOGLE_CLIENT_ID || '';
  const clientSecret = process.env.GOOGLE_OAUTH_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET || '';
  const redirectUri  = process.env.GOOGLE_OAUTH_REDIRECT_URI
    || 'http://127.0.0.1:9780/api/appointments/oauth/callback';
  return { clientId, clientSecret, redirectUri };
}

export function isOAuthConfigured(): boolean {
  const { clientId, clientSecret } = getOAuthConfig();
  return Boolean(clientId && clientSecret);
}

/** Build the Google consent URL the owner must visit. */
export async function buildAuthUrl(ownerEmail: string): Promise<string> {
  const { clientId, redirectUri } = getOAuthConfig();
  const state = crypto.randomBytes(16).toString('hex');
  await query(
    `INSERT INTO appt_oauth_state (state, owner_email) VALUES ($1, $2)
     ON CONFLICT (state) DO NOTHING`,
    [state, ownerEmail],
  );
  const params = new URLSearchParams({
    client_id:     clientId,
    redirect_uri:  redirectUri,
    response_type: 'code',
    scope:         SCOPES.join(' '),
    access_type:   'offline',
    prompt:        'consent',                // forces refresh_token issuance
    state,
    login_hint:    ownerEmail,
  });
  return `${AUTH_ENDPOINT}?${params.toString()}`;
}

/** Exchange a callback ?code for tokens, and persist them. */
export async function exchangeCodeAndStore(code: string, state: string): Promise<string> {
  const r = await query<{ owner_email: string }>(
    `SELECT owner_email FROM appt_oauth_state WHERE state = $1`,
    [state],
  );
  if (!r.rowCount) throw new Error('Unknown or expired OAuth state');
  const ownerEmail = r.rows[0].owner_email;

  const { clientId, clientSecret, redirectUri } = getOAuthConfig();
  const body = new URLSearchParams({
    code,
    client_id:     clientId,
    client_secret: clientSecret,
    redirect_uri:  redirectUri,
    grant_type:    'authorization_code',
  });
  const tok = await fetch(TOKEN_ENDPOINT, {
    method:  'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body:    body.toString(),
  });
  if (!tok.ok) {
    const t = await tok.text();
    throw new Error(`Google token exchange failed: ${tok.status} ${t}`);
  }
  const j: any = await tok.json();
  const expiresAt = new Date(Date.now() + (Number(j.expires_in || 3600) * 1000));

  await query(
    `INSERT INTO appt_oauth_tokens
       (owner_email, access_token, refresh_token, expires_at, scope, token_type, updated_at)
     VALUES ($1, $2, $3, $4, $5, $6, NOW())
     ON CONFLICT (owner_email) DO UPDATE
       SET access_token  = EXCLUDED.access_token,
           refresh_token = COALESCE(EXCLUDED.refresh_token, appt_oauth_tokens.refresh_token),
           expires_at    = EXCLUDED.expires_at,
           scope         = EXCLUDED.scope,
           token_type    = EXCLUDED.token_type,
           updated_at    = NOW()`,
    [ownerEmail, j.access_token, j.refresh_token || null, expiresAt, j.scope || null, j.token_type || 'Bearer'],
  );

  // single-use nonce
  await query(`DELETE FROM appt_oauth_state WHERE state = $1`, [state]);
  return ownerEmail;
}

/** Returns a valid access_token (refreshing if needed) or null if no tokens stored. */
export async function getValidAccessToken(ownerEmail: string): Promise<string | null> {
  const r = await query<{
    access_token: string; refresh_token: string | null; expires_at: string; scope: string | null;
  }>(
    `SELECT access_token, refresh_token, expires_at, scope
       FROM appt_oauth_tokens WHERE owner_email = $1`,
    [ownerEmail],
  );
  if (!r.rowCount) return null;

  const row = r.rows[0];
  const exp = new Date(row.expires_at).getTime();
  // refresh ~2 minutes before expiry
  if (exp - Date.now() > 120_000) return row.access_token;
  if (!row.refresh_token) return null;

  const { clientId, clientSecret } = getOAuthConfig();
  const body = new URLSearchParams({
    client_id:     clientId,
    client_secret: clientSecret,
    grant_type:    'refresh_token',
    refresh_token: row.refresh_token,
  });
  const tok = await fetch(TOKEN_ENDPOINT, {
    method:  'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body:    body.toString(),
  });
  if (!tok.ok) {
    console.error('[appointments] refresh failed', tok.status, await tok.text());
    return null;
  }
  const j: any = await tok.json();
  const newExpires = new Date(Date.now() + (Number(j.expires_in || 3600) * 1000));
  await query(
    `UPDATE appt_oauth_tokens
        SET access_token = $1, expires_at = $2, updated_at = NOW()
      WHERE owner_email = $3`,
    [j.access_token, newExpires, ownerEmail],
  );
  return j.access_token;
}

/** Revoke + drop. Used by the skill / admin to wipe a stale connection. */
export async function revoke(ownerEmail: string): Promise<void> {
  const tok = await getValidAccessToken(ownerEmail);
  if (tok) {
    try {
      await fetch(`${REVOKE_ENDPOINT}?token=${encodeURIComponent(tok)}`, { method: 'POST' });
    } catch (e) {
      console.error('[appointments] revoke error (continuing):', e);
    }
  }
  await query(`DELETE FROM appt_oauth_tokens WHERE owner_email = $1`, [ownerEmail]);
}