feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK) (#620)

* feat(google-docs): managed OAuth-redirect Picker (no API key, no BYOK)

Adds the managed (rowboat-mode) Google Docs picker via Google's trigger_onepick
flow. The Rowboat backend runs a standalone drive.file OAuth with the company
client, renders the Picker inside the browser consent screen, and deep-links the
selection back; the desktop downloads the picked doc with the fresh drive.file
token the backend returns. No Picker API key, appId, or BYOK credentials on the
desktop.

- core: importGoogleDocWithToken downloads a picked doc with an explicit token;
  fetch/metadata helpers take an optional Drive client and share writeDocxAndLink.
  claimPickedFilesViaBackend claims the parked file ids + token from the api.
- main: google-picker-managed.ts opens the backend start URL and resolves on the
  rowboat://oauth/google/picker/done deep link; deeplink.ts routes that completion.
- ipc: google-docs:pickViaManaged.
- renderer: the picker dialog gates on Rowboat sign-in (the picker grants
  drive.file per-file, so no pre-existing connection or scope is required).

Backend contract: rowboatlabs/rowboatx-backend#7
(GET /oauth/google/picker/{start,callback}, POST /v1/google-oauth/claim-picked).

* chore(google-docs): remove the dead API-key/system-browser Picker

The managed picker replaced the only consumer (the picker dialog), so the
experimental API-key Picker is now unused. Removes:
- main: google-docs:openPicker handler (system-browser loopback Picker)
- shared: google-docs:openPicker + google-docs:getAccessToken IPC schemas
- core: getGoogleAccessToken (token plumbing for the client-side Picker)
- renderer: lib/google-picker.ts (Picker JS SDK loader)

Kept GoogleClientIdModal / google-credentials-store — still used by the
general BYOK Google connect in onboarding, connectors, and settings.
This commit is contained in:
gagan 2026-06-22 13:57:37 -07:00 committed by GitHub
parent 875b65d279
commit 67b521489c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 314 additions and 447 deletions

View file

@ -107,6 +107,29 @@ export async function claimTokensViaBackend(state: string): Promise<OAuthTokens>
return toOAuthTokens(body);
}
/**
* Claim what the user selected in the managed OAuth-redirect Picker, parked
* under `session` by the webapp picker callback. Returns the picked file ids
* plus a fresh drive.file access token the picker runs a standalone
* drive.file authorization (the main connection doesn't carry drive.file), so
* the desktop downloads the picked files with this token, not the main one.
*/
export async function claimPickedFilesViaBackend(
session: string,
): Promise<{ fileIds: string[]; accessToken: string }> {
const res = await postWithBearer("/v1/google-oauth/claim-picked", { session });
if (!res.ok) {
const err = await readError(res);
throw new Error(`claim picked files failed: ${res.status} ${err.error ?? ""}`.trim());
}
const body = (await res.json()) as { fileIds?: unknown; tokens?: { access_token?: unknown } };
const fileIds = Array.isArray(body.fileIds)
? body.fileIds.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
const accessToken = typeof body.tokens?.access_token === "string" ? body.tokens.access_token : "";
return { fileIds, accessToken };
}
/**
* Refresh an access token via the api. Preserves caller's `refreshToken` and
* `existingScopes` when Google omits them on the refresh response.

View file

@ -127,27 +127,43 @@ async function getDriveClient() {
return google.drive({ version: 'v3', auth });
}
// Build a Drive client from a raw OAuth access token, bypassing the stored
// connection. Used by the OAuth-redirect Picker (trigger_onepick), which runs
// its own standalone drive.file authorization and hands back a fresh token —
// no stored connection, Picker API key, or appId involved. Read/export only;
// the token is short-lived and used immediately, so no refresh is wired up.
function driveClientFromToken(accessToken: string) {
const auth = new google.auth.OAuth2();
auth.setCredentials({ access_token: accessToken });
return google.drive({ version: 'v3', auth });
}
// Get the file as .docx bytes: a native Google Doc is exported; an uploaded
// Word file is downloaded as-is.
async function fetchAsDocx(fileId: string, mimeType: string | undefined): Promise<Buffer> {
const driveClient = await getDriveClient();
// Word file is downloaded as-is. Pass `driveClient` to use a specific token
// (e.g. the OAuth-redirect Picker's); omit it to use the stored connection.
async function fetchAsDocx(
fileId: string,
mimeType: string | undefined,
driveClient?: drive.Drive,
): Promise<Buffer> {
const dc = driveClient ?? await getDriveClient();
if (!mimeType || mimeType === GOOGLE_DOC_MIME) {
const result = await driveClient.files.export(
const result = await dc.files.export(
{ fileId, mimeType: DOCX_MIME },
{ responseType: 'arraybuffer' },
);
return Buffer.from(result.data as ArrayBuffer);
}
const result = await driveClient.files.get(
const result = await dc.files.get(
{ fileId, alt: 'media', supportsAllDrives: true },
{ responseType: 'arraybuffer' },
);
return Buffer.from(result.data as ArrayBuffer);
}
async function getDocMetadata(fileId: string): Promise<GoogleDocListItem> {
const driveClient = await getDriveClient();
const result = await driveClient.files.get({
async function getDocMetadata(fileId: string, driveClient?: drive.Drive): Promise<GoogleDocListItem> {
const dc = driveClient ?? await getDriveClient();
const result = await dc.files.get({
fileId,
fields: 'id,name,webViewLink,modifiedTime,mimeType,owners(displayName,emailAddress)',
supportsAllDrives: true,
@ -195,21 +211,26 @@ export async function getGoogleDocsConnectionStatus(): Promise<{
return GoogleClientFactory.getCredentialStatus([...GOOGLE_DOC_SCOPES]);
}
/**
* The live Google OAuth access token, for the renderer to drive the Google
* Picker (file selection happens client-side; the app never lists Drive).
*/
export async function getGoogleAccessToken(): Promise<string | null> {
// getClient() refreshes an expired token when it can. If it returns a token
// that's still past expiry, the refresh failed (e.g. no refresh_token) — hand
// back null rather than a dead token, so the UI prompts a reconnect instead
// of silently passing an expired token to the Picker (which 403s on it).
const auth = await GoogleClientFactory.getClient();
const token = auth?.credentials?.access_token ?? null;
if (!token) return null;
const expiry = auth?.credentials?.expiry_date;
if (typeof expiry === 'number' && expiry <= Date.now()) return null;
return token;
// Write the exported .docx bytes into the knowledge folder and record the
// Drive link. Shared by both import paths (stored connection / explicit token).
async function writeDocxAndLink(
doc: GoogleDocListItem,
bytes: Buffer,
targetFolder: string,
): Promise<string> {
const relPath = await uniqueDocxPath(targetFolder, doc.name);
const absPath = resolveWorkspacePath(relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, bytes);
await setLink(relPath, {
id: doc.id,
url: doc.url,
title: doc.name,
syncedAt: new Date().toISOString(),
mimeType: doc.mimeType,
remoteModifiedTime: doc.modifiedTime ?? undefined,
});
return relPath;
}
/** Import a Google Doc as a local .docx and register the link. */
@ -223,18 +244,26 @@ export async function importGoogleDoc(fileId: string, targetFolder: string): Pro
const doc = await getDocMetadata(fileId);
const bytes = await fetchAsDocx(fileId, doc.mimeType);
const relPath = await uniqueDocxPath(targetFolder, doc.name);
const absPath = resolveWorkspacePath(relPath);
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, bytes);
await setLink(relPath, {
id: doc.id,
url: doc.url,
title: doc.name,
syncedAt: new Date().toISOString(),
mimeType: doc.mimeType,
remoteModifiedTime: doc.modifiedTime ?? undefined,
});
const relPath = await writeDocxAndLink(doc, bytes, targetFolder);
return { path: relPath, doc };
}
/**
* Import a Google Doc using an explicit OAuth access token instead of the
* stored Google connection. Powers the OAuth-redirect Picker (trigger_onepick):
* that flow runs its own standalone drive.file authorization, so the picked
* file is granted to a fresh token that has no other scopes and isn't persisted
* as the user's main connection. No Picker API key or appId is involved.
*/
export async function importGoogleDocWithToken(
fileId: string,
targetFolder: string,
accessToken: string,
): Promise<{ path: string; doc: GoogleDocListItem }> {
const driveClient = driveClientFromToken(accessToken);
const doc = await getDocMetadata(fileId, driveClient);
const bytes = await fetchAsDocx(fileId, doc.mimeType, driveClient);
const relPath = await writeDocxAndLink(doc, bytes, targetFolder);
return { path: relPath, doc };
}

View file

@ -650,27 +650,6 @@ const ipcSchemas = {
missingScopes: z.array(z.string()),
}),
},
// Live Google OAuth access token for driving the Google Picker in the renderer.
'google-docs:getAccessToken': {
req: z.null(),
res: z.object({
accessToken: z.string().nullable(),
}),
},
// Open a Google Picker in a dedicated BrowserWindow (avoids session-cookie
// issues when running the Picker widget inside the renderer iframe).
'google-docs:openPicker': {
req: z.object({
accessToken: z.string(),
apiKey: z.string().optional(),
appId: z.string().optional(),
}),
res: z.object({
id: z.string(),
name: z.string(),
mimeType: z.string(),
}).nullable(),
},
'google-docs:import': {
req: z.object({
fileId: z.string().min(1),
@ -687,6 +666,24 @@ const ipcSchemas = {
}),
}),
},
// Managed OAuth-redirect Picker: the Rowboat backend runs the pick with the
// company Google client; the desktop opens the start URL, waits for the deep
// link, and imports with the existing managed token. No API key or BYOK creds.
'google-docs:pickViaManaged': {
req: z.object({
targetFolder: RelPath,
}),
res: z.object({
path: RelPath,
doc: z.object({
id: z.string(),
name: z.string(),
url: z.string(),
modifiedTime: z.string().nullable(),
owner: z.string().nullable(),
}),
}).nullable(),
},
'google-docs:refreshSnapshot': {
req: z.object({
path: RelPath,