205 lines
5.9 KiB
TypeScript
205 lines
5.9 KiB
TypeScript
/**
|
|
* 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<ConnectionsDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<ConnectionsDoc>(), '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<ConnectionsDoc>(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<ConnectionsDoc>(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<ConnectionsDoc>(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<ConnectionsDoc>(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<ConnectionsDoc>(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 };
|