/** * Google OAuth2 flow with token refresh. * * GET /authorize?space=X → redirect to Google * GET /callback → exchange code, store tokens, redirect back * POST /disconnect?space=X → revoke token * POST /refresh?space=X → refresh access token using refresh token */ import { Hono } from 'hono'; import * as Automerge from '@automerge/automerge'; import { connectionsDocId } from '../../modules/rnotes/schemas'; import type { ConnectionsDoc } from '../../modules/rnotes/schemas'; import type { SyncServer } from '../local-first/sync-server'; const googleOAuthRoutes = new Hono(); const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''; const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || ''; const GOOGLE_REDIRECT_URI = process.env.GOOGLE_REDIRECT_URI || ''; const SCOPES = [ 'https://www.googleapis.com/auth/documents', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/userinfo.email', ].join(' '); let _syncServer: SyncServer | null = null; export function setGoogleOAuthSyncServer(ss: SyncServer) { _syncServer = ss; } function ensureConnectionsDoc(space: string): ConnectionsDoc { if (!_syncServer) throw new Error('SyncServer not initialized'); const docId = connectionsDocId(space); let doc = _syncServer.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init connections', (d) => { d.meta = { module: 'notes', collection: 'connections', version: 1, spaceSlug: space, createdAt: Date.now(), }; }); _syncServer.setDoc(docId, doc); } return doc; } // GET /authorize — redirect to Google OAuth googleOAuthRoutes.get('/authorize', (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); if (!GOOGLE_CLIENT_ID) return c.json({ error: 'Google OAuth not configured' }, 500); const state = Buffer.from(JSON.stringify({ space })).toString('base64url'); const params = new URLSearchParams({ client_id: GOOGLE_CLIENT_ID, redirect_uri: GOOGLE_REDIRECT_URI, response_type: 'code', scope: SCOPES, access_type: 'offline', prompt: 'consent', state, }); return c.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); }); // GET /callback — exchange code for tokens googleOAuthRoutes.get('/callback', async (c) => { const code = c.req.query('code'); const stateParam = c.req.query('state'); if (!code || !stateParam) return c.json({ error: 'Missing code or state' }, 400); let state: { space: string }; try { state = JSON.parse(Buffer.from(stateParam, 'base64url').toString()); } catch { return c.json({ error: 'Invalid state parameter' }, 400); } // Exchange code for tokens const tokenRes = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, redirect_uri: GOOGLE_REDIRECT_URI, grant_type: 'authorization_code', }), }); if (!tokenRes.ok) { const err = await tokenRes.text(); return c.json({ error: `Token exchange failed: ${err}` }, 502); } const tokenData = await tokenRes.json() as any; // Get user email let email = ''; try { const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { 'Authorization': `Bearer ${tokenData.access_token}` }, }); if (userRes.ok) { const userData = await userRes.json() as any; email = userData.email || ''; } } catch { // Non-critical } // Store tokens ensureConnectionsDoc(state.space); const docId = connectionsDocId(state.space); _syncServer!.changeDoc(docId, 'Connect Google', (d) => { d.google = { refreshToken: tokenData.refresh_token || '', accessToken: tokenData.access_token, expiresAt: Date.now() + (tokenData.expires_in || 3600) * 1000, email, connectedAt: Date.now(), }; }); const redirectUrl = `/${state.space}/rnotes?connected=google`; return c.redirect(redirectUrl); }); // POST /disconnect — revoke and remove token googleOAuthRoutes.post('/disconnect', async (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); const docId = connectionsDocId(space); const doc = _syncServer?.getDoc(docId); if (doc?.google?.accessToken) { // Revoke token with Google try { await fetch(`https://oauth2.googleapis.com/revoke?token=${doc.google.accessToken}`, { method: 'POST', }); } catch { // Best-effort revocation } _syncServer!.changeDoc(docId, 'Disconnect Google', (d) => { delete d.google; }); } return c.json({ ok: true }); }); // POST /refresh — refresh access token googleOAuthRoutes.post('/refresh', async (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); const docId = connectionsDocId(space); const doc = _syncServer?.getDoc(docId); if (!doc?.google?.refreshToken) { return c.json({ error: 'No Google refresh token available' }, 400); } const tokenRes = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET, refresh_token: doc.google.refreshToken, grant_type: 'refresh_token', }), }); if (!tokenRes.ok) { return c.json({ error: 'Token refresh failed' }, 502); } const tokenData = await tokenRes.json() as any; _syncServer!.changeDoc(docId, 'Refresh Google token', (d) => { if (d.google) { d.google.accessToken = tokenData.access_token; d.google.expiresAt = Date.now() + (tokenData.expires_in || 3600) * 1000; } }); return c.json({ ok: true }); }); export { googleOAuthRoutes };