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,