diff --git a/apps/readest-app/src-tauri/Info-ios.plist b/apps/readest-app/src-tauri/Info-ios.plist
index e3cb94a5..07b9b261 100644
--- a/apps/readest-app/src-tauri/Info-ios.plist
+++ b/apps/readest-app/src-tauri/Info-ios.plist
@@ -53,6 +53,18 @@
readest
+
+
+ CFBundleURLName
+ com.googleusercontent.apps.gdrive-oauth
+ CFBundleURLSchemes
+
+ com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq
+
+
diff --git a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml
index c810e504..3e2476c4 100644
--- a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml
+++ b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml
@@ -95,6 +95,16 @@
+
+
+
+
+
+
+
+
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
index 5ba2c937..c4f060ac 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt
@@ -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.:/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())
}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
index 5f4f98f6..36e4ef6f 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift
@@ -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 }
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthAndroid.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthAndroid.test.ts
new file mode 100644
index 00000000..b7167446
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthAndroid.test.ts
@@ -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);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthIos.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthIos.test.ts
new file mode 100644
index 00000000..fa8556e9
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/oauthIos.test.ts
@@ -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);
+ });
+});
diff --git a/apps/readest-app/src/app/auth/utils/nativeAuth.ts b/apps/readest-app/src/app/auth/utils/nativeAuth.ts
index 3e3cb218..77b1dba3 100644
--- a/apps/readest-app/src/app/auth/utils/nativeAuth.ts
+++ b/apps/readest-app/src/app/auth/utils/nativeAuth.ts
@@ -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 {
diff --git a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx
index 8c2d765b..c566ef3d 100644
--- a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx
+++ b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx
@@ -290,7 +290,7 @@ const IntegrationsPanel: React.FC = () => {
onOpen={() => setSubPage('webdav')}
activateLabel={_('Use WebDAV')}
/>
- {appService?.isDesktopApp && (
+ {(appService?.isDesktopApp || appService?.isAndroidApp || appService?.isIOSApp) && (
:/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 => {
+ 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((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),
+ });
+};
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthIos.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthIos.ts
new file mode 100644
index 00000000..c24b5006
--- /dev/null
+++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/oauthIos.ts
@@ -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.:/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 => {
+ 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.:/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((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),
+ });
+};
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 90948fb0..b5630ace 100644
--- a/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts
+++ b/apps/readest-app/src/services/sync/providers/gdrive/googleDriveConnect.ts
@@ -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) => {
+ 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 => {
const clientId = getGoogleClientId();
if (!clientId) {
@@ -27,13 +51,13 @@ export const runGoogleDriveConnect = async (): Promise
}
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(),
});
};