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(), }); };