From 9f65e3d415286229040aecc183539d53d13b1a4b Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 3 Jul 2026 00:03:56 +0900 Subject: [PATCH] fix(auth): surface OAuth callback errors on desktop deeplink (#4881) (#4894) * fix(auth): handle OAuth callback errors on desktop deeplink (#4881) The Tauri deeplink OAuth handler only parsed the URL hash for an access_token, so error callbacks were silently swallowed and the login screen froze with no feedback. This is how an expired Apple provider secret (GoTrue "Unable to exchange external code") surfaced to users as a dead login screen on macOS and Linux. Extract a pure, tested parseOAuthCallbackUrl() that reads both the hash (implicit-flow tokens) and the query string (provider/GoTrue errors), and route errors to /auth/error before the token branch, matching the web callback page. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(scripts): add Apple client secret generator (#4881) Apple caps the "Sign in with Apple" client secret JWT at 6 months, so the web OAuth flow (macOS non-store and Linux) breaks with "Unable to exchange external code" when it expires. This script regenerates the ES256 JWT using Node built-in crypto (no new dependency) for pasting into the Supabase Apple provider config. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../scripts/generate-apple-client-secret.mjs | 104 ++++++++++++++++++ .../src/__tests__/helpers/auth.test.ts | 50 ++++++++- apps/readest-app/src/app/auth/page.tsx | 34 +++--- apps/readest-app/src/helpers/auth.ts | 27 +++++ 4 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 apps/readest-app/scripts/generate-apple-client-secret.mjs diff --git a/apps/readest-app/scripts/generate-apple-client-secret.mjs b/apps/readest-app/scripts/generate-apple-client-secret.mjs new file mode 100644 index 00000000..aafa0a60 --- /dev/null +++ b/apps/readest-app/scripts/generate-apple-client-secret.mjs @@ -0,0 +1,104 @@ +// Generate the "Sign in with Apple" client secret JWT for the Supabase Apple +// OAuth provider. Apple caps this secret's lifetime at 6 months, so the web +// OAuth flow (macOS non-store .dmg + Linux) breaks with +// "server_error ... Unable to exchange external code" +// whenever it expires. Regenerate here and paste the output into +// Supabase -> Authentication -> Providers -> Apple -> Secret Key (for OAuth) +// +// The native flow (iOS + macOS App Store, signInWithIdToken) does NOT use this +// secret, which is why those keep working while the web flow fails. +// +// Usage (flags override env; env vars mirror the CI naming): +// node scripts/generate-apple-client-secret.mjs \ +// --team-id J5W48D69VR \ +// --client-id com.bilingify.readest.signin \ # the Services ID, NOT the app bundle id +// --key-id ABCDE12345 \ +// --key-path ../private_keys/AuthKey_ABCDE12345.p8 \ +// [--days 180] +// +// Env fallbacks: APPLE_TEAM_ID, APPLE_CLIENT_ID (or APPLE_SERVICES_ID), +// APPLE_KEY_ID, APPLE_KEY_PATH, APPLE_SECRET_DAYS +// +// The signed JWT is printed to stdout (pipe-friendly); everything else to stderr. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { createPrivateKey, sign } from 'node:crypto'; + +const AUD = 'https://appleid.apple.com'; +const MAX_DAYS = 180; // Apple rejects secrets with exp more than ~6 months out + +const parseArgs = (argv) => { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith('--')) continue; + const eq = arg.indexOf('='); + if (eq !== -1) { + args[arg.slice(2, eq)] = arg.slice(eq + 1); + } else if (argv[i + 1] === undefined || argv[i + 1].startsWith('--')) { + args[arg.slice(2)] = true; // boolean flag, e.g. --help + } else { + args[arg.slice(2)] = argv[++i]; + } + } + return args; +}; + +const die = (msg) => { + console.error(`Error: ${msg}`); + console.error('Run with --help for usage.'); + process.exit(1); +}; + +const args = parseArgs(process.argv.slice(2)); + +if (args.help || args.h) { + console.error(readFileSync(new URL(import.meta.url)).toString().split('\n') + .filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n')); + process.exit(0); +} + +const teamId = args['team-id'] ?? process.env.APPLE_TEAM_ID; +const clientId = + args['client-id'] ?? process.env.APPLE_CLIENT_ID ?? process.env.APPLE_SERVICES_ID; +const keyId = args['key-id'] ?? process.env.APPLE_KEY_ID; +const keyPath = args['key-path'] ?? process.env.APPLE_KEY_PATH; +const days = Number(args.days ?? process.env.APPLE_SECRET_DAYS ?? MAX_DAYS); + +if (!teamId) die('missing --team-id (Apple Developer Team ID)'); +if (!clientId) die('missing --client-id (the Services ID / OAuth Client ID)'); +if (!keyId) die('missing --key-id (the "Sign in with Apple" key ID)'); +if (!keyPath) die('missing --key-path (path to the AuthKey_*.p8 file)'); +if (!Number.isFinite(days) || days <= 0) die(`invalid --days: ${args.days}`); +if (days > MAX_DAYS) die(`--days ${days} exceeds Apple's ${MAX_DAYS}-day maximum`); + +let privateKey; +try { + privateKey = createPrivateKey(readFileSync(resolve(keyPath))); +} catch (err) { + die(`could not read private key at ${keyPath}: ${err.message}`); +} + +const base64url = (input) => + Buffer.from(typeof input === 'string' ? input : JSON.stringify(input)).toString('base64url'); + +const now = Math.floor(Date.now() / 1000); +const exp = now + days * 24 * 60 * 60; + +const header = { alg: 'ES256', kid: keyId, typ: 'JWT' }; +const payload = { iss: teamId, iat: now, exp, aud: AUD, sub: clientId }; + +const signingInput = `${base64url(header)}.${base64url(payload)}`; +// dsaEncoding 'ieee-p1363' emits the raw R||S signature JOSE/JWT requires (not DER). +const signature = sign('sha256', Buffer.from(signingInput), { + key: privateKey, + dsaEncoding: 'ieee-p1363', +}).toString('base64url'); + +const jwt = `${signingInput}.${signature}`; + +console.error(`Generated Apple client secret for Services ID "${clientId}"`); +console.error(` Team ID: ${teamId} Key ID: ${keyId}`); +console.error(` Expires: ${new Date(exp * 1000).toISOString()} (${days} days)`); +console.error('Paste the JWT below into Supabase -> Auth -> Providers -> Apple -> Secret Key:\n'); +console.log(jwt); diff --git a/apps/readest-app/src/__tests__/helpers/auth.test.ts b/apps/readest-app/src/__tests__/helpers/auth.test.ts index b9d17763..d4f61c2a 100644 --- a/apps/readest-app/src/__tests__/helpers/auth.test.ts +++ b/apps/readest-app/src/__tests__/helpers/auth.test.ts @@ -14,7 +14,55 @@ vi.mock('@/utils/supabase', () => ({ }, })); -import { handleAuthCallback } from '@/helpers/auth'; +import { handleAuthCallback, parseOAuthCallbackUrl } from '@/helpers/auth'; + +describe('parseOAuthCallbackUrl', () => { + it('should extract error params from a failed deeplink callback', () => { + // Real-world failing callback from issue #4881 + const url = + 'readest://auth-callback?error=server_error&error_code=unexpected_failure&error_description=Unable+to+exchange+external+code%3A+c578' + + '#error=server_error&error_code=unexpected_failure&error_description=Unable+to+exchange+external+code%253A+c578&sb='; + + const params = parseOAuthCallbackUrl(url); + + expect(params.error).toBe('server_error'); + expect(params.errorCode).toBe('unexpected_failure'); + expect(params.errorDescription).toContain('Unable to exchange external code'); + expect(params.accessToken).toBeNull(); + expect(params.refreshToken).toBeNull(); + }); + + it('should extract tokens from a successful implicit-flow callback', () => { + const url = + 'readest://auth-callback#access_token=abc123&refresh_token=def456&type=magiclink&next=/user'; + + const params = parseOAuthCallbackUrl(url); + + expect(params.accessToken).toBe('abc123'); + expect(params.refreshToken).toBe('def456'); + expect(params.type).toBe('magiclink'); + expect(params.next).toBe('/user'); + expect(params.error).toBeNull(); + }); + + it('should extract error params when present only in the query string', () => { + const url = + 'readest://auth-callback?error=access_denied&error_code=denied&error_description=nope'; + + const params = parseOAuthCallbackUrl(url); + + expect(params.error).toBe('access_denied'); + expect(params.errorCode).toBe('denied'); + expect(params.accessToken).toBeNull(); + }); + + it('should return all-null params for a callback with no data', () => { + const params = parseOAuthCallbackUrl('readest://auth-callback'); + + expect(params.accessToken).toBeNull(); + expect(params.error).toBeNull(); + }); +}); describe('handleAuthCallback', () => { let mockLogin: ReturnType void>>; diff --git a/apps/readest-app/src/app/auth/page.tsx b/apps/readest-app/src/app/auth/page.tsx index 1a6e473c..d18e5c1e 100644 --- a/apps/readest-app/src/app/auth/page.tsx +++ b/apps/readest-app/src/app/auth/page.tsx @@ -22,7 +22,7 @@ import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { start, cancel, onUrl, onInvalidUrl } from '@fabianlars/tauri-plugin-oauth'; import { openUrl } from '@tauri-apps/plugin-opener'; import { invoke } from '@tauri-apps/api/core'; -import { handleAuthCallback } from '@/helpers/auth'; +import { handleAuthCallback, parseOAuthCallbackUrl } from '@/helpers/auth'; import { getUserProfilePlan } from '@/utils/access'; import { getAppleIdAuth, Scope } from './utils/appleIdAuth'; import { authWithCustomTab, authWithSafari } from './utils/nativeAuth'; @@ -163,20 +163,26 @@ export default function AuthPage() { const handleOAuthUrl = async (url: string) => { console.log('Handle OAuth URL:', url); - const hashMatch = url.match(/#(.*)/); - if (hashMatch) { - const hash = hashMatch[1]; - const params = new URLSearchParams(hash); - const accessToken = params.get('access_token'); - const refreshToken = params.get('refresh_token'); - const type = params.get('type'); - if (accessToken) { - let next = params.get('next') ?? '/'; - if (getUserProfilePlan(accessToken) === 'free') { - next = '/user'; - } - handleAuthCallback({ accessToken, refreshToken, type, next, login, navigate: router.push }); + const { accessToken, refreshToken, type, next, error, errorCode, errorDescription } = + parseOAuthCallbackUrl(url); + if (error) { + console.error('OAuth callback error:', error, errorCode, errorDescription); + handleAuthCallback({ error, errorCode, errorDescription, login, navigate: router.push }); + return; + } + if (accessToken) { + let nextPath = next ?? '/'; + if (getUserProfilePlan(accessToken) === 'free') { + nextPath = '/user'; } + handleAuthCallback({ + accessToken, + refreshToken, + type, + next: nextPath, + login, + navigate: router.push, + }); } }; diff --git a/apps/readest-app/src/helpers/auth.ts b/apps/readest-app/src/helpers/auth.ts index 322831eb..ac5a2b3a 100644 --- a/apps/readest-app/src/helpers/auth.ts +++ b/apps/readest-app/src/helpers/auth.ts @@ -13,6 +13,33 @@ interface UseAuthCallbackOptions { errorDescription?: string | null; } +export interface OAuthCallbackParams { + accessToken: string | null; + refreshToken: string | null; + type: string | null; + next: string | null; + error: string | null; + errorCode: string | null; + errorDescription: string | null; +} + +// OAuth callbacks may carry data in the URL fragment (implicit flow tokens) or +// the query string (provider/GoTrue errors), so we read from both. +export function parseOAuthCallbackUrl(url: string): OAuthCallbackParams { + const hashParams = new URLSearchParams(url.match(/#(.*)/)?.[1] ?? ''); + const queryParams = new URLSearchParams(url.match(/\?([^#]*)/)?.[1] ?? ''); + const getParam = (key: string) => hashParams.get(key) ?? queryParams.get(key); + return { + accessToken: getParam('access_token'), + refreshToken: getParam('refresh_token'), + type: getParam('type'), + next: getParam('next'), + error: getParam('error'), + errorCode: getParam('error_code'), + errorDescription: getParam('error_description'), + }; +} + export function handleAuthCallback({ accessToken, refreshToken,