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:
@@ -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');
|
||||
});
|
||||
});
|
||||
+69
@@ -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('/');
|
||||
});
|
||||
});
|
||||
+29
@@ -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();
|
||||
});
|
||||
});
|
||||
+27
-2
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user