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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-03 00:03:56 +09:00
committed by GitHub
parent 4d645befde
commit 9f65e3d415
4 changed files with 200 additions and 15 deletions
@@ -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);
@@ -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<typeof vi.fn<(accessToken: string, user: User) => void>>;
+20 -14
View File
@@ -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,
});
}
};
+27
View File
@@ -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,