← 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]);
}