feat(sync): Google Drive sign-in on Android + iOS (mobile OAuth) (#4823)

* feat(sync): Google Drive sign-in on Android (Custom Tab OAuth)

Add the Android OAuth runner so Drive can be connected on Android, reusing the
same provider / token store / connect flow as desktop.

- oauthAndroid.ts: runAndroidOAuth wires the DI OAuth flow to a Chrome Custom
  Tab via the existing authWithCustomTab native bridge (keeps the Tauri Activity
  foregrounded so the in-flight redirect survives). Headless-unit-tested.
- googleDriveConnect: dispatch the platform runner by OS (Android -> Custom Tab,
  desktop -> system browser deep link).
- IntegrationsPanel: show the Google Drive provider row on Android too.
- Native (device-verification pending — no Android toolchain in CI):
  NativeBridgePlugin.kt handleIntent now also resolves the reverse-DNS
  com.googleusercontent.apps.<id>:/oauthredirect redirect through the same
  pending invoke as the Supabase callback; a matching BROWSABLE intent-filter
  added to AndroidManifest.xml (mirrors the tauri.conf.json deep-link scheme).

Full suite 6475 green; lint + format clean. The native sign-in needs on-device
Android verification before this ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): Google Drive sign-in on iOS (ASWebAuthenticationSession OAuth)

Add the iOS OAuth runner so the Drive provider connects on iPhone/iPad,
mirroring the Android Custom Tab flow.

- oauthIos.ts: runIosOAuth drives the shared PKCE flow through
  authWithSafari, keyed to the client-id-derived reverse-DNS callback
  scheme so the web-auth session intercepts the redirect.
- nativeAuth.ts: AuthRequest gains an optional callbackScheme; the
  Supabase login keeps the native "readest" default.
- googleDriveConnect.ts: resolveOAuthRunner dispatches ios to runIosOAuth.
- IntegrationsPanel.tsx: show the Google Drive cloud-sync row on iOS.

Native (device-verify pending, no iOS toolchain in CI):
- auth_with_safari honors args.callbackScheme (default "readest").
- Info-ios.plist registers the reverse-DNS scheme in CFBundleURLTypes,
  mirroring the AndroidManifest gdrive-oauth filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-27 22:59:55 +08:00
committed by GitHub
parent 531f0b58ae
commit ae9fb05f2c
11 changed files with 316 additions and 8 deletions
+12
View File
@@ -53,6 +53,18 @@
<string>readest</string>
</array>
</dict>
<!-- Google Drive OAuth reverse-DNS redirect (iOS-type client). The
ASWebAuthenticationSession matches on this scheme; mirrors the
gdrive-oauth filter in AndroidManifest.xml and the deep-link.mobile
scheme in tauri.conf.json. -->
<dict>
<key>CFBundleURLName</key>
<string>com.googleusercontent.apps.gdrive-oauth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -95,6 +95,16 @@
<data android:scheme="readest" android:host="auth-callback" />
</intent-filter>
<!-- Google Drive OAuth reverse-DNS redirect (iOS-type client). Mirrors
the deep-link.mobile scheme in tauri.conf.json so the redirect
routes back even if the auto-generated section is regenerated. -->
<intent-filter android:label="gdrive-oauth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -189,7 +189,14 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
// OAuth callback uses a custom scheme on intent.data and is handled
// separately from any user-shared content.
intent.data?.let { uri ->
if (uri.scheme == "readest" && uri.host == "auth-callback") {
val scheme = uri.scheme ?: ""
val isReadestAuth = scheme == "readest" && uri.host == "auth-callback"
// Google Drive sign-in uses the reverse-DNS "iOS URL scheme"
// (com.googleusercontent.apps.<id>:/oauthredirect) registered as a
// BROWSABLE deep link; resolve it through the same pending invoke as
// the Supabase readest://auth-callback flow.
val isGoogleOAuth = scheme.startsWith("com.googleusercontent.apps.")
if (isReadestAuth || isGoogleOAuth) {
val result = JSObject().apply {
put("redirectUrl", uri.toString())
}
@@ -30,6 +30,10 @@ func getLocalizedDisplayName(familyName: String) -> String? {
class SafariAuthRequestArgs: Decodable {
let authUrl: String
// ASWebAuthenticationSession callback scheme. Defaults to "readest" (the
// Supabase login); the Google Drive flow passes its reverse-DNS scheme so the
// session intercepts that redirect instead.
let callbackScheme: String?
}
class UseBackgroundAudioRequestArgs: Decodable {
@@ -770,8 +774,9 @@ class NativeBridgePlugin: Plugin {
@objc public func auth_with_safari(_ invoke: Invoke) throws {
let args = try invoke.parseArgs(SafariAuthRequestArgs.self)
let authUrl = URL(string: args.authUrl)!
let callbackScheme = args.callbackScheme ?? "readest"
authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: "readest") {
authSession = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: callbackScheme) {
[weak self] callbackURL, error in
guard let strongSelf = self else { return }
@@ -0,0 +1,50 @@
import { describe, expect, test, vi } from 'vitest';
vi.mock('@/app/auth/utils/nativeAuth', () => ({
authWithCustomTab: vi.fn(async ({ authUrl }: { authUrl: string }) => {
// The native Custom Tab echoes the redirect, with the exact `state` the
// flow put on the consent URL.
const state = new URL(authUrl).searchParams.get('state');
return {
redirectUrl: `com.googleusercontent.apps.cid:/oauthredirect?code=CODE&state=${state}`,
};
}),
}));
import { runAndroidOAuth } from '@/services/sync/providers/gdrive/auth/oauthAndroid';
import { authWithCustomTab } from '@/app/auth/utils/nativeAuth';
import type { FetchFn } from '@/services/sync/providers/gdrive/auth/tokenStore';
const CLIENT_ID = 'cid.apps.googleusercontent.com';
const tokenJson = (): Response =>
new Response(JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
describe('runAndroidOAuth', () => {
test('opens a Custom Tab, captures the redirect, and exchanges the code', async () => {
const fetchFn = vi.fn(async () => tokenJson()) as unknown as FetchFn;
const tokens = await runAndroidOAuth({ clientId: CLIENT_ID, scope: 'drive.file' }, fetchFn);
expect(authWithCustomTab).toHaveBeenCalledTimes(1);
// The Custom Tab is handed the consent URL (with the PKCE challenge + state).
const authUrl = vi.mocked(authWithCustomTab).mock.calls[0]![0].authUrl;
expect(authUrl).toContain('code_challenge=');
expect(authUrl).toContain('accounts.google.com');
expect(tokens.accessToken).toBe('AT');
expect(tokens.refreshToken).toBe('RT');
expect(fetchFn).toHaveBeenCalledTimes(1);
});
test('propagates a CSRF state mismatch from the redirect', async () => {
vi.mocked(authWithCustomTab).mockResolvedValueOnce({
redirectUrl: 'com.googleusercontent.apps.cid:/oauthredirect?code=CODE&state=ATTACKER',
});
const fetchFn = vi.fn(async () => tokenJson()) as unknown as FetchFn;
await expect(
runAndroidOAuth({ clientId: CLIENT_ID, scope: 'drive.file' }, fetchFn),
).rejects.toThrow(/state mismatch/i);
});
});
@@ -0,0 +1,52 @@
import { describe, expect, test, vi } from 'vitest';
vi.mock('@/app/auth/utils/nativeAuth', () => ({
authWithSafari: vi.fn(async ({ authUrl }: { authUrl: string; callbackScheme?: string }) => {
// The native web-auth session echoes the redirect, with the exact `state`
// the flow put on the consent URL.
const state = new URL(authUrl).searchParams.get('state');
return {
redirectUrl: `com.googleusercontent.apps.cid:/oauthredirect?code=CODE&state=${state}`,
};
}),
}));
import { runIosOAuth } from '@/services/sync/providers/gdrive/auth/oauthIos';
import { authWithSafari } from '@/app/auth/utils/nativeAuth';
import type { FetchFn } from '@/services/sync/providers/gdrive/auth/tokenStore';
const CLIENT_ID = 'cid.apps.googleusercontent.com';
const tokenJson = (): Response =>
new Response(JSON.stringify({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600 }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
describe('runIosOAuth', () => {
test('opens a web-auth session keyed to the reverse-DNS scheme and exchanges the code', async () => {
const fetchFn = vi.fn(async () => tokenJson()) as unknown as FetchFn;
const tokens = await runIosOAuth({ clientId: CLIENT_ID, scope: 'drive.file' }, fetchFn);
expect(authWithSafari).toHaveBeenCalledTimes(1);
const arg = vi.mocked(authWithSafari).mock.calls[0]![0];
// The session is handed the consent URL (with the PKCE challenge + state)...
expect(arg.authUrl).toContain('code_challenge=');
expect(arg.authUrl).toContain('accounts.google.com');
// ...and the bare reverse-DNS callback scheme (no path) it must intercept.
expect(arg.callbackScheme).toBe('com.googleusercontent.apps.cid');
expect(tokens.accessToken).toBe('AT');
expect(tokens.refreshToken).toBe('RT');
expect(fetchFn).toHaveBeenCalledTimes(1);
});
test('propagates a CSRF state mismatch from the redirect', async () => {
vi.mocked(authWithSafari).mockResolvedValueOnce({
redirectUrl: 'com.googleusercontent.apps.cid:/oauthredirect?code=CODE&state=ATTACKER',
});
const fetchFn = vi.fn(async () => tokenJson()) as unknown as FetchFn;
await expect(
runIosOAuth({ clientId: CLIENT_ID, scope: 'drive.file' }, fetchFn),
).rejects.toThrow(/state mismatch/i);
});
});
@@ -4,6 +4,12 @@ import { type as osType } from '@tauri-apps/plugin-os';
export interface AuthRequest {
authUrl: string;
/**
* iOS `ASWebAuthenticationSession` callback scheme. Defaults to `readest`
* natively (the Supabase login); the Google Drive flow passes its reverse-DNS
* scheme so the session intercepts that redirect instead.
*/
callbackScheme?: string;
}
export interface AuthResponse {
@@ -290,7 +290,7 @@ const IntegrationsPanel: React.FC = () => {
onOpen={() => setSubPage('webdav')}
activateLabel={_('Use WebDAV')}
/>
{appService?.isDesktopApp && (
{(appService?.isDesktopApp || appService?.isAndroidApp || appService?.isIOSApp) && (
<CloudProviderRow
icon={RiGoogleLine}
title={_('Google Drive')}
@@ -0,0 +1,72 @@
/**
* Android wiring of the OAuth authorization-code + PKCE flow: a Chrome Custom Tab
* + reverse-DNS custom-scheme redirect.
*
* Android can't use a loopback redirect (the system browser can't hand a
* `http://127.0.0.1` back to the app). Instead we use the reverse-DNS scheme an
* iOS-type Google client issues — `com.googleusercontent.apps.<id>:/oauthredirect`
* — registered as a BROWSABLE deep-link intent-filter so the OS routes the
* redirect back to the app.
*
* Consent opens in a CHROME CUSTOM TAB via Readest's native bridge command
* `auth_with_custom_tab` (the same one the Supabase login uses), NOT an external
* browser: a separate browser app backgrounds the single Tauri Activity and the
* OS can destroy it under memory pressure, tearing down the in-flight promise +
* redirect listener (observed on-device: first attempt fails, second succeeds). A
* Custom Tab renders inside the host task and keeps the process at foreground
* importance; the redirect resolves through a native field that survives a WebView
* reload. The native command opens consent AND resolves with the redirect URL in
* one round trip, recognising it by the reverse-DNS scheme parsed from the auth
* URL's `redirect_uri`.
*
* The iOS-type client has NO secret and needs NO Android SHA-1 — Google validates
* the redirect by string-matching the client-id-derived scheme, and PKCE is the
* real client authentication. This holds only while App Check (iOS attestation)
* is off — an Android device can't produce an iOS attestation, so enabling it
* would break every user; keep it off.
*
* Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
* used with the author's explicit permission.
*/
import { authWithCustomTab } from '@/app/auth/utils/nativeAuth';
import { createPkcePair } from './pkce';
import { deriveReverseDnsRedirectUri } from './reverseDnsRedirect';
import { runOAuthFlow, type OAuthClientConfig } from './oauthFlow';
import { exchangeCode, type FetchFn, type TokenSet } from './tokenStore';
/**
* Run the Android Custom-Tab OAuth flow and return the resulting tokens. Wires
* {@link runOAuthFlow} with the Android mechanics: open consent in a Chrome
* Custom Tab and resolve with the redirect the native bridge captures.
*/
export const runAndroidOAuth = (config: OAuthClientConfig, fetchFn: FetchFn): Promise<TokenSet> => {
const redirectUri = deriveReverseDnsRedirectUri(config.clientId);
// `auth_with_custom_tab` opens consent AND resolves with the redirect URL in a
// single native round-trip — so it needs the auth URL, which `runOAuthFlow`
// only hands to `openUrl`. Bridge the two with a deferred: `openUrl` supplies
// the URL, and `awaitRedirect` (invoked first) waits for it before driving the
// Custom Tab. The native side recognises the redirect by its reverse-DNS
// scheme, so only the auth URL needs to cross over.
let provideAuthUrl!: (url: string) => void;
const authUrlReady = new Promise<string>((resolve) => {
provideAuthUrl = resolve;
});
return runOAuthFlow(config.scope, {
createPkcePair,
newState: () => crypto.randomUUID(),
clientId: config.clientId,
openUrl: async (url) => {
provideAuthUrl(url);
},
awaitRedirect: async () => {
const authUrl = await authUrlReady;
const { redirectUrl } = await authWithCustomTab({ authUrl });
return redirectUrl;
},
redirectUri,
exchange: ({ code, verifier, redirectUri: uri }) =>
exchangeCode({ code, verifier, clientId: config.clientId, redirectUri: uri }, fetchFn),
});
};
@@ -0,0 +1,70 @@
/**
* iOS wiring of the OAuth authorization-code + PKCE flow: an
* `ASWebAuthenticationSession` + reverse-DNS custom-scheme redirect.
*
* Like Android, iOS can't use a loopback redirect, so we use the reverse-DNS
* scheme the iOS-type Google client issues —
* `com.googleusercontent.apps.<id>:/oauthredirect` — registered in
* `Info-ios.plist` `CFBundleURLTypes` so the OS routes the redirect back.
*
* Consent opens in an `ASWebAuthenticationSession` via Readest's native bridge
* command `auth_with_safari` (the same one the Supabase login uses). That session
* intercepts the redirect by its `callbackURLScheme` — so unlike the desktop
* deep-link runner, no app-wide URL listener is needed: the native command opens
* consent AND resolves with the redirect URL in one round trip. The callback
* scheme is the bare reverse-DNS scheme derived from the client id (no path),
* which is exactly what `ASWebAuthenticationSession` matches on.
*
* The iOS-type client has NO secret — Google validates the redirect by
* string-matching the client-id-derived scheme, and PKCE is the real client
* authentication. This holds only while App Check (iOS attestation) is off.
*
* Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0),
* used with the author's explicit permission.
*/
import { authWithSafari } from '@/app/auth/utils/nativeAuth';
import { createPkcePair } from './pkce';
import { deriveReverseDnsRedirectScheme, deriveReverseDnsRedirectUri } from './reverseDnsRedirect';
import { runOAuthFlow, type OAuthClientConfig } from './oauthFlow';
import { exchangeCode, type FetchFn, type TokenSet } from './tokenStore';
/**
* Run the iOS `ASWebAuthenticationSession` OAuth flow and return the resulting
* tokens. Wires {@link runOAuthFlow} with the iOS mechanics: open consent in a
* web-auth session keyed to the reverse-DNS callback scheme and resolve with the
* redirect the native bridge captures.
*/
export const runIosOAuth = (config: OAuthClientConfig, fetchFn: FetchFn): Promise<TokenSet> => {
const redirectUri = deriveReverseDnsRedirectUri(config.clientId);
// ASWebAuthenticationSession matches on the bare scheme (no path); pass the
// client-id-derived reverse-DNS scheme so the native session recognises the
// `com.googleusercontent.apps.<id>:/oauthredirect?...` bounce-back.
const callbackScheme = deriveReverseDnsRedirectScheme(config.clientId);
// `auth_with_safari` opens consent AND resolves with the redirect URL in a
// single native round-trip — so it needs the auth URL, which `runOAuthFlow`
// only hands to `openUrl`. Bridge the two with a deferred: `openUrl` supplies
// the URL, and `awaitRedirect` (invoked first) waits for it before driving the
// web-auth session.
let provideAuthUrl!: (url: string) => void;
const authUrlReady = new Promise<string>((resolve) => {
provideAuthUrl = resolve;
});
return runOAuthFlow(config.scope, {
createPkcePair,
newState: () => crypto.randomUUID(),
clientId: config.clientId,
openUrl: async (url) => {
provideAuthUrl(url);
},
awaitRedirect: async () => {
const authUrl = await authUrlReady;
const { redirectUrl } = await authWithSafari({ authUrl, callbackScheme });
return redirectUrl;
},
redirectUri,
exchange: ({ code, verifier, redirectUri: uri }) =>
exchangeCode({ code, verifier, clientId: config.clientId, redirectUri: uri }, fetchFn),
});
};
@@ -1,14 +1,19 @@
/**
* Wire the platform OAuth runner + env client id + keychain into the
* {@link connectGoogleDrive} orchestration, so the settings UI's Connect button
* is a single call. Desktop only for now — Android / iOS runners land in later
* phases; the Drive row is hidden off-desktop.
* is a single call. The runner is platform-resolved (desktop deep-link, Android
* Custom Tab, iOS web-auth session); see {@link resolveOAuthRunner}.
*/
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 { createDriveTokenPersistence } from './driveTokenStore';
import { runDesktopDeepLinkOAuth } from './auth/oauthDesktop';
import { runAndroidOAuth } from './auth/oauthAndroid';
import { runIosOAuth } from './auth/oauthIos';
import type { OAuthClientConfig } from './auth/oauthFlow';
import type { TokenSet } from './auth/tokenStore';
import {
connectGoogleDrive,
disconnectGoogleDrive,
@@ -19,7 +24,26 @@ import type { FetchFn } from './GoogleDriveProvider';
const resolveFetch = (): FetchFn =>
(isTauriAppPlatform() ? tauriFetch : globalThis.fetch) as unknown as FetchFn;
/** Run the desktop Drive sign-in and return the connected account label. */
/**
* Pick the platform OAuth runner: Android uses a Chrome Custom Tab; iOS uses an
* `ASWebAuthenticationSession`; desktop (macOS/Windows/Linux) uses the system
* browser + deep-link.
*/
const resolveOAuthRunner = (): ((
config: OAuthClientConfig,
fetchFn: FetchFn,
) => Promise<TokenSet>) => {
try {
const os = osType();
if (os === 'android') return runAndroidOAuth;
if (os === 'ios') return runIosOAuth;
} catch {
// osType() is Tauri-only; off-Tauri this path isn't reached anyway.
}
return runDesktopDeepLinkOAuth;
};
/** Run the platform Drive sign-in and return the connected account label. */
export const runGoogleDriveConnect = async (): Promise<ConnectGoogleDriveResult> => {
const clientId = getGoogleClientId();
if (!clientId) {
@@ -27,13 +51,13 @@ export const runGoogleDriveConnect = async (): Promise<ConnectGoogleDriveResult>
}
const persistence = await createDriveTokenPersistence();
if (!persistence) {
throw new Error('Google Drive requires the desktop app with secure storage');
throw new Error('Google Drive requires a Readest app build with secure storage');
}
return connectGoogleDrive({
clientId,
fetchFn: resolveFetch(),
persistence,
runOAuth: runDesktopDeepLinkOAuth,
runOAuth: resolveOAuthRunner(),
});
};