diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/WebDriveAuth.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/WebDriveAuth.test.ts new file mode 100644 index 00000000..4df40b1c --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/WebDriveAuth.test.ts @@ -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 => ({ + 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'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webRedirectFlow.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webRedirectFlow.test.ts new file mode 100644 index 00000000..ad8883d7 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webRedirectFlow.test.ts @@ -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('/'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts new file mode 100644 index 00000000..4d11aa44 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts @@ -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(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts index a16c7a2f..9a791f47 100644 --- a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/buildGoogleDriveProvider.test.ts @@ -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(); + }); }); diff --git a/apps/readest-app/src/app/gdrive-callback/page.tsx b/apps/readest-app/src/app/gdrive-callback/page.tsx new file mode 100644 index 00000000..a1b728e5 --- /dev/null +++ b/apps/readest-app/src/app/gdrive-callback/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx index 8bb7ea98..055fdb35 100644 --- a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx +++ b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx @@ -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())) && ( TokenSet | null = loadWebDriveToken, + private readonly now: () => number = () => Date.now(), + ) {} + + async getAccessToken(): Promise { + 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 { + 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; + } +} diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/webRedirectFlow.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/webRedirectFlow.ts new file mode 100644 index 00000000..0d7db8ee --- /dev/null +++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/webRedirectFlow.ts @@ -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 : '/'; +}; diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/webTokenStore.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/webTokenStore.ts new file mode 100644 index 00000000..d088086a --- /dev/null +++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/webTokenStore.ts @@ -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); +}; diff --git a/apps/readest-app/src/services/sync/providers/gdrive/buildGoogleDriveProvider.ts b/apps/readest-app/src/services/sync/providers/gdrive/buildGoogleDriveProvider.ts index b01eacf6..3a44cbec 100644 --- a/apps/readest-app/src/services/sync/providers/gdrive/buildGoogleDriveProvider.ts +++ b/apps/readest-app/src/services/sync/providers/gdrive/buildGoogleDriveProvider.ts @@ -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 => { + // 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; diff --git a/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts b/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts index b5630ace..9d348b66 100644 --- a/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts +++ b/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts @@ -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 => { + // 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(() => {}); + } + 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 /** Forget the stored Drive token (the settings flag is cleared by the caller). */ export const runGoogleDriveDisconnect = async (): Promise => { + if (isWebAppPlatform()) { + clearWebDriveToken(); + return; + } const persistence = await createDriveTokenPersistence(); if (persistence) await disconnectGoogleDrive(persistence); };