feat(sync): Google Drive on web via full-page redirect OAuth (#4843)

Brings the Google Drive provider to the web build. Native uses PKCE + a
reverse-DNS redirect + keychain refresh token; none of that works in a browser,
and the GIS popup token model is broken by the app's COOP `same-origin` header
(needed for Turso's SharedArrayBuffer) which severs the popup's opener handle and
fires `popup_closed` instantly. So web uses a full-page redirect, which doesn't
rely on `window.opener` and works under COOP.

- auth/webRedirectFlow.ts: builds the implicit (response_type=token) auth URL,
  begins the redirect (CSRF state + return path in sessionStorage), and parses
  the token from the callback fragment. Implicit flow because a secretless Web
  client can't do a code exchange.
- auth/webTokenStore.ts: sessionStorage-backed access-token store (no refresh
  token in this model; the token is short-lived).
- WebDriveAuth: browser DriveAuth — reads the stored token, fails AUTH_FAILED
  once expired (prompts a reconnect; no background refresh), accountLabel via
  about.get.
- app/gdrive-callback: OAuth return route — validates state, stores the token,
  marks Drive the active cloud provider (+ account label), routes back.
- buildGoogleDriveProvider: web branch builds the provider on WebDriveAuth +
  globalThis.fetch (Drive REST is CORS-enabled; streaming stays Tauri-only so web
  buffers). Official Web client id baked (NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID
  overrides). googleDriveConnect web Connect = redirect; Disconnect clears the
  token. Drive row shown on web.

No background token refresh: a secretless browser client gets no refresh token
and Google blocks hidden-iframe silent renewal, so the user reconnects per
session (a server-side token broker would be needed for auto-refresh; out of
scope). Tests cover the redirect helpers, token store, and WebDriveAuth.

Ops: add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback`
to the Web client's Authorized redirect URIs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-29 00:39:52 +08:00
committed by GitHub
parent 7da41a65ad
commit b87cbfa21a
11 changed files with 539 additions and 6 deletions
@@ -0,0 +1,57 @@
import { describe, expect, test, vi } from 'vitest';
import { WebDriveAuth } from '@/services/sync/providers/gdrive/WebDriveAuth';
import { FileSyncError } from '@/services/sync/file/provider';
import type { FetchFn } from '@/services/sync/providers/gdrive/GoogleDriveProvider';
import type { TokenSet } from '@/services/sync/providers/gdrive/auth/tokenStore';
const fetchStub = vi.fn(async () => new Response('{}')) as unknown as FetchFn;
const token = (overrides: Partial<TokenSet> = {}): TokenSet => ({
accessToken: 'AT',
expiresAt: 10_000,
...overrides,
});
describe('WebDriveAuth', () => {
test('returns the stored access token while it is valid', async () => {
const auth = new WebDriveAuth(
fetchStub,
() => token({ expiresAt: 10_000 }),
() => 0,
);
expect(await auth.getAccessToken()).toBe('AT');
});
test('throws AUTH_FAILED when the stored token has expired', async () => {
const auth = new WebDriveAuth(
fetchStub,
() => token({ expiresAt: 1_000 }),
() => 5_000,
);
const err = await auth.getAccessToken().catch((e: unknown) => e);
expect(err).toBeInstanceOf(FileSyncError);
expect((err as FileSyncError).code).toBe('AUTH_FAILED');
});
test('throws AUTH_FAILED when there is no stored token (needs reconnect)', async () => {
const auth = new WebDriveAuth(
fetchStub,
() => null,
() => 0,
);
const err = await auth.getAccessToken().catch((e: unknown) => e);
expect((err as FileSyncError).code).toBe('AUTH_FAILED');
});
test('accountLabel reads the email from about.get', async () => {
const fetchFn = vi.fn(
async () =>
new Response(JSON.stringify({ user: { emailAddress: 'me@example.com' } }), { status: 200 }),
) as unknown as FetchFn;
const auth = new WebDriveAuth(
fetchFn,
() => token(),
() => 0,
);
expect(await auth.accountLabel()).toBe('me@example.com');
});
});
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, test } from 'vitest';
import {
buildImplicitAuthUrl,
consumeOAuthState,
consumeReturnPath,
parseImplicitRedirect,
tokenSetFromRedirect,
} from '@/services/sync/providers/gdrive/auth/webRedirectFlow';
afterEach(() => {
window.sessionStorage.clear();
});
describe('buildImplicitAuthUrl', () => {
test('builds the implicit (token) authorization URL with the expected params', () => {
const url = new URL(
buildImplicitAuthUrl({
clientId: 'web.cid',
scope: 'https://www.googleapis.com/auth/drive.file',
redirectUri: 'http://localhost:3000/gdrive-callback',
state: 'STATE123',
}),
);
expect(url.origin + url.pathname).toBe('https://accounts.google.com/o/oauth2/v2/auth');
expect(url.searchParams.get('client_id')).toBe('web.cid');
expect(url.searchParams.get('response_type')).toBe('token');
expect(url.searchParams.get('redirect_uri')).toBe('http://localhost:3000/gdrive-callback');
expect(url.searchParams.get('scope')).toBe('https://www.googleapis.com/auth/drive.file');
expect(url.searchParams.get('state')).toBe('STATE123');
expect(url.searchParams.get('include_granted_scopes')).toBe('true');
});
});
describe('parseImplicitRedirect', () => {
test('parses a successful token fragment and computes a future expiry', () => {
const before = Date.now();
const r = parseImplicitRedirect('#access_token=AT&token_type=Bearer&expires_in=3600&state=S1');
expect(r.accessToken).toBe('AT');
expect(r.state).toBe('S1');
expect(r.error).toBeUndefined();
expect(r.expiresAt).toBeGreaterThan(before + 3000 * 1000);
expect(tokenSetFromRedirect(r)).toEqual({ accessToken: 'AT', expiresAt: r.expiresAt });
});
test('parses an error fragment and yields no token', () => {
const r = parseImplicitRedirect('#error=access_denied&state=S1');
expect(r.error).toBe('access_denied');
expect(r.accessToken).toBeUndefined();
expect(tokenSetFromRedirect(r)).toBeNull();
});
});
describe('consumeOAuthState / consumeReturnPath', () => {
test('state is read once then cleared', () => {
window.sessionStorage.setItem('gdrive_web_oauth_state', 'XYZ');
expect(consumeOAuthState()).toBe('XYZ');
expect(consumeOAuthState()).toBeNull();
});
test('return path falls back to "/" and rejects non-same-origin paths', () => {
expect(consumeReturnPath()).toBe('/');
window.sessionStorage.setItem('gdrive_web_oauth_return', '/library?q=1');
expect(consumeReturnPath()).toBe('/library?q=1');
window.sessionStorage.setItem('gdrive_web_oauth_return', '//evil.com');
expect(consumeReturnPath()).toBe('/');
window.sessionStorage.setItem('gdrive_web_oauth_return', 'https://evil.com');
expect(consumeReturnPath()).toBe('/');
});
});
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, test } from 'vitest';
import {
clearWebDriveToken,
loadWebDriveToken,
saveWebDriveToken,
} from '@/services/sync/providers/gdrive/auth/webTokenStore';
afterEach(() => {
window.sessionStorage.clear();
});
describe('webTokenStore', () => {
test('round-trips a token set through sessionStorage', () => {
expect(loadWebDriveToken()).toBeNull();
saveWebDriveToken({ accessToken: 'AT', expiresAt: 123 });
expect(loadWebDriveToken()).toEqual({ accessToken: 'AT', expiresAt: 123 });
});
test('clear removes the stored token', () => {
saveWebDriveToken({ accessToken: 'AT', expiresAt: 123 });
clearWebDriveToken();
expect(loadWebDriveToken()).toBeNull();
});
test('returns null for an unparseable stored value', () => {
window.sessionStorage.setItem('gdrive_web_token', 'not json');
expect(loadWebDriveToken()).toBeNull();
});
});
@@ -1,7 +1,10 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock('@/services/environment', () => ({ isTauriAppPlatform: vi.fn() }));
vi.mock('@/services/environment', () => ({
isTauriAppPlatform: vi.fn(),
isWebAppPlatform: vi.fn(),
}));
vi.mock('@/utils/bridge', () => ({
isSyncKeychainAvailable: vi.fn(),
getSecureItem: vi.fn(),
@@ -9,11 +12,12 @@ vi.mock('@/utils/bridge', () => ({
clearSecureItem: vi.fn(),
}));
import { isTauriAppPlatform } from '@/services/environment';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { isSyncKeychainAvailable } from '@/utils/bridge';
import {
buildGoogleDriveProvider,
getGoogleClientId,
getGoogleWebClientId,
} from '@/services/sync/providers/gdrive/buildGoogleDriveProvider';
const CLIENT_ID = 'cid.apps.googleusercontent.com';
@@ -60,4 +64,25 @@ describe('buildGoogleDriveProvider', () => {
expect(provider).not.toBeNull();
expect(provider?.rootPath).toBe('/');
});
test('web: falls back to the baked official web client id when the env override is unset', async () => {
vi.stubEnv('NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID', '');
vi.mocked(isWebAppPlatform).mockReturnValue(true);
expect(getGoogleWebClientId()).toMatch(/\.apps\.googleusercontent\.com$/);
const provider = await buildGoogleDriveProvider();
expect(provider).not.toBeNull();
expect(provider?.rootPath).toBe('/');
// The web path never touches the keychain.
expect(isSyncKeychainAvailable).not.toHaveBeenCalled();
});
test('web: the env override wins over the baked web default', async () => {
vi.stubEnv('NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID', 'forked-web.apps.googleusercontent.com');
vi.mocked(isWebAppPlatform).mockReturnValue(true);
expect(getGoogleWebClientId()).toBe('forked-web.apps.googleusercontent.com');
const provider = await buildGoogleDriveProvider();
expect(provider).not.toBeNull();
expect(provider?.rootPath).toBe('/');
expect(isSyncKeychainAvailable).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,85 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import { WebDriveAuth } from '@/services/sync/providers/gdrive/WebDriveAuth';
import type { FetchFn } from '@/services/sync/providers/gdrive/GoogleDriveProvider';
import { saveWebDriveToken } from '@/services/sync/providers/gdrive/auth/webTokenStore';
import {
consumeOAuthState,
consumeReturnPath,
parseImplicitRedirect,
tokenSetFromRedirect,
} from '@/services/sync/providers/gdrive/auth/webRedirectFlow';
import { withActiveCloudProvider } from '@/components/settings/integrations/cloudSync';
/**
* OAuth return route for the web Google Drive connect (full-page implicit flow).
* Google redirects here with the access token in the URL fragment; we validate
* the CSRF state, store the token, mark Drive the active cloud provider, then
* route back to where the user started. See `gdrive/auth/webRedirectFlow.ts`.
*/
export default function GDriveCallback() {
const router = useRouter();
const _ = useTranslation();
const { envConfig } = useEnv();
const setSettings = useSettingsStore((s) => s.setSettings);
useEffect(() => {
let cancelled = false;
(async () => {
const returnPath = consumeReturnPath();
const expectedState = consumeOAuthState();
const result = parseImplicitRedirect(window.location.hash);
try {
if (result.error) throw new Error(result.error);
if (!expectedState || result.state !== expectedState) throw new Error('state mismatch');
const tokens = tokenSetFromRedirect(result);
if (!tokens) throw new Error('no access token returned');
saveWebDriveToken(tokens);
const accountLabel = await new WebDriveAuth(
globalThis.fetch.bind(globalThis) as unknown as FetchFn,
)
.accountLabel()
.catch(() => null);
// Mark Drive the single active cloud provider (turns WebDAV off) and
// stamp the account label. Load + save through appService so this works
// even though the settings store may not be hydrated on this route.
const appService = await envConfig.getAppService();
const settings = await appService.loadSettings();
const base = withActiveCloudProvider(settings, 'gdrive');
const next = {
...base,
googleDrive: {
...base.googleDrive,
accountLabel: accountLabel ?? base.googleDrive?.accountLabel,
},
};
await appService.saveSettings(next);
setSettings(next);
eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') });
} catch (e) {
console.warn('[gdrive] web callback failed', e);
eventDispatcher.dispatch('toast', { type: 'error', message: _('Failed to connect') });
} finally {
if (!cancelled) router.replace(returnPath);
}
})();
return () => {
cancelled = true;
};
}, [envConfig, router, setSettings, _]);
return (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<span className='loading loading-infinity loading-xl w-20' />
</div>
);
}
@@ -23,6 +23,8 @@ import { useFileSyncStore } from '@/store/fileSyncStore';
import { CatalogManager } from '@/app/opds/components/CatalogManager';
import { saveSysSettings } from '@/helpers/settings';
import { isCloudSyncAllowed } from '@/utils/access';
import { isWebAppPlatform } from '@/services/environment';
import { getGoogleWebClientId } from '@/services/sync/providers/gdrive/buildGoogleDriveProvider';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import KOSyncForm from './integrations/KOSyncForm';
import ReadwiseForm from './integrations/ReadwiseForm';
@@ -292,7 +294,11 @@ const IntegrationsPanel: React.FC = () => {
onOpen={() => setSubPage('webdav')}
activateLabel={_('Use WebDAV')}
/>
{(appService?.isDesktopApp || appService?.isAndroidApp || appService?.isIOSApp) && (
{(appService?.isDesktopApp ||
appService?.isAndroidApp ||
appService?.isIOSApp ||
// Web: only when a Web-type GIS client id is configured for this build.
(isWebAppPlatform() && !!getGoogleWebClientId())) && (
<CloudProviderRow
icon={RiGoogleLine}
title={_('Google Drive')}
@@ -0,0 +1,40 @@
/**
* Browser {@link DriveAuth} for the web build — the counterpart of
* {@link PersistedDriveAuth} (which refreshes a keychain refresh token on native).
*
* The web OAuth model (full-page implicit redirect; see {@link webRedirectFlow})
* yields only a short-lived access token, kept in `sessionStorage` by
* {@link webTokenStore}. There is no refresh token, so this just reads the stored
* token and, once it expires, fails with `AUTH_FAILED` to prompt a reconnect — it
* cannot refresh in the background (a server-side token broker would be required).
*/
import { FileSyncError } from '@/services/sync/file/provider';
import type { DriveAuth, FetchFn } from './GoogleDriveProvider';
import type { TokenSet } from './auth/tokenStore';
import { aboutUrl } from './driveRest';
import { loadWebDriveToken } from './auth/webTokenStore';
export class WebDriveAuth implements DriveAuth {
constructor(
private readonly fetchFn: FetchFn,
private readonly loadToken: () => TokenSet | null = loadWebDriveToken,
private readonly now: () => number = () => Date.now(),
) {}
async getAccessToken(): Promise<string> {
const tokens = this.loadToken();
if (tokens && this.now() < tokens.expiresAt) return tokens.accessToken;
throw new FileSyncError('Google Drive session expired; reconnect in Settings', 'AUTH_FAILED');
}
/** Human-readable account label (email, falling back to display name) or null. */
async accountLabel(): Promise<string | null> {
const token = await this.getAccessToken();
const res = await this.fetchFn(aboutUrl(), {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const body = (await res.json()) as { user?: { emailAddress?: string; displayName?: string } };
return body.user?.emailAddress ?? body.user?.displayName ?? null;
}
}
@@ -0,0 +1,127 @@
/**
* Full-page redirect OAuth for the Readest **web** build (the implicit / token
* response, `response_type=token`).
*
* Why redirect and not a popup or GIS: Readest web serves
* `Cross-Origin-Opener-Policy: same-origin` (for Turso's SharedArrayBuffer), which
* severs a popup's opener handle — so GIS / any popup OAuth reports `popup_closed`
* instantly. A full-page redirect doesn't rely on `window.opener`, so it works
* under that COOP. The token comes back in the URL fragment of a dedicated
* `/gdrive-callback` route; the secretless Web client can't do a code exchange,
* so we use the implicit token response (same `response_type=token` GIS used).
*
* No refresh token in this model — the access token is short-lived and the user
* reconnects per session (a server-side token broker would be needed for
* background refresh; out of scope here).
*/
import { TOKEN_EXPIRY_SAFETY_MARGIN_SEC, type TokenSet } from './tokenStore';
/** Google's OAuth 2.0 authorization endpoint. */
const GOOGLE_AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
const MS_PER_SEC = 1000;
/** Fallback lifetime if Google omits `expires_in` (access tokens are ~1h). */
const DEFAULT_TOKEN_LIFETIME_SEC = 3600;
/** sessionStorage keys for the CSRF state + post-connect return path. */
const STATE_KEY = 'gdrive_web_oauth_state';
const RETURN_KEY = 'gdrive_web_oauth_return';
/** Path of the OAuth callback route (must match the registered redirect URI). */
export const WEB_OAUTH_CALLBACK_PATH = '/gdrive-callback';
/** The redirect URI for the current origin (register this on the Web client). */
export const webDriveRedirectUri = (): string =>
`${window.location.origin}${WEB_OAUTH_CALLBACK_PATH}`;
/**
* Build the Google authorization URL for the implicit (token) flow. `state` is a
* CSRF nonce validated on the callback; `include_granted_scopes` keeps any scopes
* the user already granted to Readest.
*/
export const buildImplicitAuthUrl = ({
clientId,
scope,
redirectUri,
state,
}: {
clientId: string;
scope: string;
redirectUri: string;
state: string;
}): string => {
const url = new URL(GOOGLE_AUTH_ENDPOINT);
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('response_type', 'token');
url.searchParams.set('scope', scope);
url.searchParams.set('state', state);
url.searchParams.set('include_granted_scopes', 'true');
return url.toString();
};
export interface ImplicitRedirectResult {
accessToken?: string;
/** Absolute expiry (epoch ms), margin-adjusted; present only with a token. */
expiresAt?: number;
state?: string;
error?: string;
}
/**
* Parse the implicit redirect's URL fragment (`#access_token=…&state=…` or
* `#error=…`). Returns the token + computed expiry, or the error.
*/
export const parseImplicitRedirect = (hash: string): ImplicitRedirectResult => {
const params = new URLSearchParams(hash.replace(/^#/, ''));
const accessToken = params.get('access_token') ?? undefined;
const state = params.get('state') ?? undefined;
const error = params.get('error') ?? undefined;
const expiresInSec = Number(params.get('expires_in') ?? DEFAULT_TOKEN_LIFETIME_SEC);
const lifetimeSec = Math.max(0, expiresInSec - TOKEN_EXPIRY_SAFETY_MARGIN_SEC);
const expiresAt = accessToken ? Date.now() + lifetimeSec * MS_PER_SEC : undefined;
return { accessToken, expiresAt, state, error };
};
/** Convenience: build a {@link TokenSet} from a successful parse, or null. */
export const tokenSetFromRedirect = (result: ImplicitRedirectResult): TokenSet | null =>
result.accessToken && result.expiresAt
? { accessToken: result.accessToken, expiresAt: result.expiresAt }
: null;
/**
* Begin the connect: stash the CSRF state + return path, then navigate the whole
* page to Google. The page unloads here; the token returns to the callback route.
*/
export const beginWebDriveRedirect = ({
clientId,
scope,
redirectUri,
returnPath,
}: {
clientId: string;
scope: string;
redirectUri: string;
returnPath: string;
}): void => {
const state = crypto.randomUUID();
window.sessionStorage.setItem(STATE_KEY, state);
window.sessionStorage.setItem(RETURN_KEY, returnPath);
window.location.assign(buildImplicitAuthUrl({ clientId, scope, redirectUri, state }));
};
/** Read and clear the stored CSRF state (one-shot). */
export const consumeOAuthState = (): string | null => {
const state = window.sessionStorage.getItem(STATE_KEY);
window.sessionStorage.removeItem(STATE_KEY);
return state;
};
/**
* Read and clear the stored return path. Falls back to `/`, and rejects anything
* that isn't a same-origin absolute path (open-redirect guard).
*/
export const consumeReturnPath = (): string => {
const path = window.sessionStorage.getItem(RETURN_KEY);
window.sessionStorage.removeItem(RETURN_KEY);
return path && path.startsWith('/') && !path.startsWith('//') ? path : '/';
};
@@ -0,0 +1,43 @@
/**
* Browser-side Google Drive token store for the web build.
*
* Unlike native (a refresh token in the OS keychain), the web OAuth model yields
* only a short-lived access token and no refresh token. It lives in
* `sessionStorage` so it survives the OAuth redirect round-trip and in-tab
* reloads, but is dropped when the tab closes — "connected" persists across
* sessions via the `googleDrive.enabled` settings flag, and a new session
* re-acquires the token by reconnecting.
*/
import type { TokenSet } from './tokenStore';
/** sessionStorage key holding the serialised {@link TokenSet}. */
const WEB_TOKEN_KEY = 'gdrive_web_token';
const getStorage = (): Storage | null => {
try {
return typeof window !== 'undefined' ? window.sessionStorage : null;
} catch {
return null;
}
};
/** Persist the access token (+ expiry) for this tab/session. */
export const saveWebDriveToken = (tokens: TokenSet): void => {
getStorage()?.setItem(WEB_TOKEN_KEY, JSON.stringify(tokens));
};
/** Load the stored token, or null when absent/unparseable. */
export const loadWebDriveToken = (): TokenSet | null => {
const raw = getStorage()?.getItem(WEB_TOKEN_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as TokenSet;
} catch {
return null;
}
};
/** Forget the stored token (Disconnect). */
export const clearWebDriveToken = (): void => {
getStorage()?.removeItem(WEB_TOKEN_KEY);
};
@@ -9,10 +9,11 @@
* half-built provider that would fail on first use.
*/
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { isTauriAppPlatform } from '@/services/environment';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import type { FileSyncProvider } from '@/services/sync/file/provider';
import { createGoogleDriveProvider, type FetchFn } from './GoogleDriveProvider';
import { PersistedDriveAuth } from './PersistedDriveAuth';
import { WebDriveAuth } from './WebDriveAuth';
import { createDriveTokenPersistence } from './driveTokenStore';
/**
@@ -31,11 +32,38 @@ const OFFICIAL_GOOGLE_CLIENT_ID =
export const getGoogleClientId = (): string | undefined =>
process.env['NEXT_PUBLIC_GOOGLE_CLIENT_ID'] || OFFICIAL_GOOGLE_CLIENT_ID;
/**
* The official Readest **Web-type** Google OAuth client id used by the browser
* GIS flow (its authorized JavaScript origins are `web.readest.com` + the
* localhost dev origin). Separate from the iOS-type
* {@link OFFICIAL_GOOGLE_CLIENT_ID}, which can't drive a browser token client.
* Not a secret — it ships in the web bundle. A forker overrides it via
* `NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID` and must register their own deploy origin
* on that client.
*/
const OFFICIAL_GOOGLE_WEB_CLIENT_ID =
'209390247301-585tc3dohg4c02588uvah5d32hg6dneq.apps.googleusercontent.com';
export const getGoogleWebClientId = (): string | undefined =>
process.env['NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID'] || OFFICIAL_GOOGLE_WEB_CLIENT_ID;
/** Native `fetch` bypasses the WebView CSP for the googleapis.com hosts. */
const resolveFetch = (): FetchFn =>
(isTauriAppPlatform() ? tauriFetch : globalThis.fetch) as unknown as FetchFn;
export const buildGoogleDriveProvider = async (): Promise<FileSyncProvider | null> => {
// Web: token from the full-page redirect flow, kept in sessionStorage, read by
// WebDriveAuth. Buffered I/O (createGoogleDriveProvider omits the Tauri-only
// streaming methods off-Tauri); the Drive REST API is CORS-enabled so plain
// fetch works. No keychain.
if (isWebAppPlatform()) {
if (!getGoogleWebClientId()) return null;
// Bind to the global so `this.fetchFn(...)` inside the provider doesn't call
// window.fetch with the wrong receiver ("Illegal invocation").
const fetchFn = globalThis.fetch.bind(globalThis) as unknown as FetchFn;
return createGoogleDriveProvider(new WebDriveAuth(fetchFn), fetchFn);
}
const clientId = getGoogleClientId();
if (!clientId) return null;
@@ -6,17 +6,20 @@
*/
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { type as osType } from '@tauri-apps/plugin-os';
import { isTauriAppPlatform } from '@/services/environment';
import { getGoogleClientId } from './buildGoogleDriveProvider';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
import { getGoogleClientId, getGoogleWebClientId } from './buildGoogleDriveProvider';
import { createDriveTokenPersistence } from './driveTokenStore';
import { runDesktopDeepLinkOAuth } from './auth/oauthDesktop';
import { runAndroidOAuth } from './auth/oauthAndroid';
import { runIosOAuth } from './auth/oauthIos';
import { beginWebDriveRedirect, webDriveRedirectUri } from './auth/webRedirectFlow';
import { clearWebDriveToken } from './auth/webTokenStore';
import type { OAuthClientConfig } from './auth/oauthFlow';
import type { TokenSet } from './auth/tokenStore';
import {
connectGoogleDrive,
disconnectGoogleDrive,
DRIVE_FILE_SCOPE,
type ConnectGoogleDriveResult,
} from './connectGoogleDrive';
import type { FetchFn } from './GoogleDriveProvider';
@@ -45,6 +48,23 @@ const resolveOAuthRunner = (): ((
/** Run the platform Drive sign-in and return the connected account label. */
export const runGoogleDriveConnect = async (): Promise<ConnectGoogleDriveResult> => {
// Web: full-page redirect to Google (the popup/GIS path is broken by the
// app's COOP). This navigates away and never resolves — the token returns to
// the /gdrive-callback route, which finalizes the connection and routes back.
if (isWebAppPlatform()) {
const webClientId = getGoogleWebClientId();
if (!webClientId) {
throw new Error('Google Drive is not configured for the web build');
}
beginWebDriveRedirect({
clientId: webClientId,
scope: DRIVE_FILE_SCOPE,
redirectUri: webDriveRedirectUri(),
returnPath: window.location.pathname + window.location.search,
});
return new Promise<ConnectGoogleDriveResult>(() => {});
}
const clientId = getGoogleClientId();
if (!clientId) {
throw new Error('Google Drive is not configured in this build');
@@ -63,6 +83,10 @@ export const runGoogleDriveConnect = async (): Promise<ConnectGoogleDriveResult>
/** Forget the stored Drive token (the settings flag is cleared by the caller). */
export const runGoogleDriveDisconnect = async (): Promise<void> => {
if (isWebAppPlatform()) {
clearWebDriveToken();
return;
}
const persistence = await createDriveTokenPersistence();
if (persistence) await disconnectGoogleDrive(persistence);
};