diff --git a/frontend/src/apple-signing.ts b/frontend/src/apple-signing.ts index c2bf307..f9e931e 100644 --- a/frontend/src/apple-signing.ts +++ b/frontend/src/apple-signing.ts @@ -581,7 +581,17 @@ export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginReq api = fresh.api; const { getAnisetteData } = await import('./anisette-service'); const freshAnisette = await getAnisetteData(); - ({ session } = await api.authenticate(appleId, password, freshAnisette, verificationCallback)); + try { + ({ session } = await api.authenticate(appleId, password, freshAnisette, verificationCallback)); + } catch (reauth2) { + // If the fresh SRP exchange also hits the apptokens 2FA gate (rare), + // REAUTH_SENTINEL escapes here. Surface it as a human-readable error + // instead of exposing the internal sentinel string. + if (reauth2 instanceof Error && reauth2.message === REAUTH_SENTINEL) { + throw new Error('Authentication failed after two-factor verification. Please sign in again.'); + } + throw reauth2; + } } else { throw e; } diff --git a/sms-2fa.patch b/sms-2fa.patch new file mode 100644 index 0000000..4e66fec --- /dev/null +++ b/sms-2fa.patch @@ -0,0 +1,1177 @@ +diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx +index 8f9a0e0..3ce9144 100644 +--- a/frontend/src/App.tsx ++++ b/frontend/src/App.tsx +@@ -1,7 +1,7 @@ + import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + import type { DirectUsbMuxClient } from 'webmuxd'; + import type { AnisetteData } from './anisette-service'; +-import type { AppleDeveloperContext } from './apple-signing'; ++import type { AppleDeveloperContext, TwoFactorContext } from './apple-signing'; + + import { Header, type AppPage } from './components/Header'; + import { LoginPage } from './components/LoginPage'; +@@ -80,9 +80,13 @@ export function App() { + const [loginContext, setLoginContext] = useState(null); + const [savedAccounts, setSavedAccounts] = useState(() => loadStoredAccountList()); + const accountContextMapRef = useRef>(new Map()); ++ // Version counter so cachedAccountKeys useMemo re-runs when the map changes. ++ // useRef mutations don't trigger re-renders; this state variable does. ++ const [accountCacheVersion, setAccountCacheVersion] = useState(0); + + // Login modal + const [loginModalOpen, setLoginModalOpen] = useState(false); ++ const [loginError, setLoginError] = useState(null); + + // Device + const [pairedDeviceInfo, setPairedDeviceInfo] = useState(null); +@@ -114,9 +118,12 @@ export function App() { + const [trustState, setTrustState] = useState(TRUST_MODAL_CLOSED); + const [twoFactor, setTwoFactor] = useState<{ + open: boolean; +- submit: ((code: string) => void) | null; ++ ctx: TwoFactorContext | null; + error: string | null; +- }>({ open: false, submit: null, error: null }); ++ }>({ open: false, ctx: null, error: null }); ++ // Set to true by handleTwoFactorCancel so handleLogin's catch can distinguish ++ // a user-cancelled 2FA from a genuine verification failure. ++ const twoFactorCancelledRef = useRef(false); + + // Derived + const isWebUsbSupported = useMemo(() => { +@@ -136,7 +143,7 @@ export function App() { + + const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null; + +- const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]); ++ const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [accountCacheVersion]); + const trustOpen = trustState !== TRUST_MODAL_CLOSED; + + // ---- log + progress plumbing ---- +@@ -183,6 +190,7 @@ export function App() { + useEffect(() => { + const restored = restorePersistedAccountContexts(); + accountContextMapRef.current = restored; ++ setAccountCacheVersion((v) => v + 1); + const summary = loadStoredAccountSummary(); + if (summary) { + const active = restored.get(accountKey(summary.appleId, summary.teamId)); +@@ -332,13 +340,17 @@ export function App() { + } + + setBusy((prev) => ({ ...prev, loginSign: true })); ++ twoFactorCancelledRef.current = false; ++ setLoginError(null); + let twoFactorOpened = false; + let twoFactorErrorShown = false; + try { + saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId); + addLog('login: initializing anisette...'); + +- const { anisetteData: nextAnisette } = await ensureAnisetteData(anisetteData, addLog); ++ // Always fetch a fresh anisette OTP for login — the OTP is one-time-use ++ // and the cached value is consumed after any previous attempt (even failed). ++ const { anisetteData: nextAnisette } = await ensureAnisetteData(null, addLog); + setAnisetteData(nextAnisette); + setAnisetteProvisioned(true); + addLog('login: anisette ready, authenticating...'); +@@ -348,17 +360,19 @@ export function App() { + password, + anisetteData: nextAnisette, + log: addLog, +- onTwoFactorRequired: (submit) => { ++ onTwoFactorRequired: (ctx) => { + twoFactorOpened = true; +- setTwoFactor({ open: true, submit, error: null }); ++ setTwoFactor({ open: true, ctx, error: null }); + addLog('login: 2FA required, opening verification dialog'); + }, + }); + +- setTwoFactor({ open: false, submit: null, error: null }); ++ setTwoFactor({ open: false, ctx: null, error: null }); ++ setLoginError(null); + addLog(`login: authenticated as ${context.appleId} / ${context.team.identifier}`); + const key = accountKey(context.appleId, context.team.identifier); + accountContextMapRef.current.set(key, context); ++ setAccountCacheVersion((v) => v + 1); + persistAccountSummary(context); + persistAccountSession(context, nextAnisette); + addLog('login: session persisted'); +@@ -371,36 +385,43 @@ export function App() { + addLog('login: done, navigating to sign page'); + navigateToPage('sign'); + } catch (error) { ++ console.error('[login] caught error:', error); + const msg = formatError(error); + addLog(`login failed: ${msg}`); +- if (twoFactorOpened) { ++ // Only show the error inside the 2FA modal if it's still open and the user ++ // did NOT cancel — a cancellation is intentional, not an error. ++ if (twoFactorOpened && !twoFactorCancelledRef.current) { + setTwoFactor((prev) => ({ ...prev, error: msg })); + twoFactorErrorShown = true; ++ } else if (!twoFactorOpened) { ++ // Pre-2FA failure (wrong password, rate limit, etc.) — show in login modal. ++ setLoginError(msg); + } + } finally { + if (!twoFactorErrorShown) { +- setTwoFactor({ open: false, submit: null, error: null }); ++ setTwoFactor({ open: false, ctx: null, error: null }); + } + setBusy((prev) => ({ ...prev, loginSign: false })); + } + }, [addLog, anisetteData, appleId, clearPrepared, navigateToPage, password]); + +- const handleTwoFactorSubmit = useCallback( +- (code: string) => { +- const submit = twoFactor.submit; +- if (submit) submit(code); +- }, +- [twoFactor.submit], +- ); +- + const handleTwoFactorCancel = useCallback(() => { +- const submit = twoFactor.submit; +- setTwoFactor({ open: false, submit: null, error: null }); +- if (submit) { ++ const ctx = twoFactor.ctx; ++ twoFactorCancelledRef.current = true; ++ setTwoFactor({ open: false, ctx: null, error: null }); ++ if (ctx) { + addLog('login: 2FA canceled'); +- submit('__CANCELLED__'); ++ // Signal cancellation via the device-code path with a sentinel value. ++ ctx.submitDeviceCode('__CANCELLED__'); + } +- }, [addLog, twoFactor.submit]); ++ }, [addLog, twoFactor.ctx]); ++ ++ const handleTwoFactorRetry = useCallback(() => { ++ // Close the modal (clearing the stale ctx + error) then re-run the full ++ // login flow so the user doesn't have to re-type their password. ++ setTwoFactor({ open: false, ctx: null, error: null }); ++ void handleLogin(); ++ }, [handleLogin]); + + // ---- sign flow ---- + const handleSign = useCallback(async () => { +@@ -534,6 +555,7 @@ export function App() { + // Remove from session map + removeStoredAccountSession(summary.appleId, summary.teamId); + accountContextMapRef.current.delete(key); ++ setAccountCacheVersion((v) => v + 1); + + // Remove from account list + const list = loadStoredAccountList().filter( +@@ -598,7 +620,7 @@ export function App() { +
+
+ +-
++
+ {currentPage === 'login' ? ( + setLoginModalOpen(false)} ++ onClose={() => { setLoginModalOpen(false); setLoginError(null); }} + appleId={appleId} + password={password} + busyLoginSign={busy.loginSign} + canSubmit={loginCanSubmit} +- onAppleIdChange={setAppleId} ++ error={loginError} ++ onAppleIdChange={(v) => { setAppleId(v); setLoginError(null); }} + onAppleIdBlur={handleAppleIdBlur} +- onPasswordChange={setPassword} ++ onPasswordChange={(v) => { setPassword(v); setLoginError(null); }} + onSubmit={handleLogin} + /> + +@@ -668,9 +691,10 @@ export function App() { + /> + +
+ ); +diff --git a/frontend/src/apple-signing.ts b/frontend/src/apple-signing.ts +index b9c98a4..3c9bc24 100644 +--- a/frontend/src/apple-signing.ts ++++ b/frontend/src/apple-signing.ts +@@ -57,11 +57,29 @@ export interface AppleDeveloperContext { + devices: Device[]; + } + ++export interface TrustedPhoneNumber { ++ id: number; ++ numberWithDialCode: string; ++ obfuscatedNumber: string; ++ pushMode: string; ++} ++ ++export interface TwoFactorContext { ++ /** Submit a 6-digit code that was pushed to a trusted device. */ ++ submitDeviceCode: (code: string) => void; ++ /** Available trusted phone numbers for SMS fallback. Empty when none. */ ++ trustedPhoneNumbers: TrustedPhoneNumber[]; ++ /** Request an SMS be sent to the given phone id, then call submitSmsCode. */ ++ requestSms: (phoneId: number) => Promise; ++ /** Submit the code received via SMS for the given phone id. */ ++ submitSmsCode: (phoneId: number, code: string) => Promise; ++} ++ + export interface AppleDeveloperLoginRequest { + anisetteData: AnisetteData; + credentials: AppleSigningCredentials; + onLog?: (message: string) => void; +- onTwoFactorRequired?: (submitCode: (code: string) => void) => void; ++ onTwoFactorRequired?: (ctx: TwoFactorContext) => void; + } + + export interface AppleSigningWithContextRequest { +@@ -101,8 +119,346 @@ interface AltsignModule { + }): Promise<{ data: Uint8Array }>; + } + +-let appleApiInstance: AppleAPI | null = null; ++/** Minimal type alias for the Fetch instance used in the SMS patch. */ ++type AltsignFetch = { ++ get(url: string, headers?: Record): Promise<{ text(): Promise; ok: boolean }>; ++ request( ++ method: string, ++ url: string, ++ headers: Record, ++ body?: string, ++ ): Promise<{ text(): Promise; ok: boolean }>; ++}; ++ + let altsignModulePromise: Promise | null = null; ++/** Cached fetch wrapper — the libcurl WASM underneath is expensive to re-init. */ ++let appleFetchInstance: AltsignFetch | null = null; ++ ++// ---------- Apple GSA SMS 2FA endpoints ---------- ++ ++const GSA_TRUSTED_DEVICE_URL = 'https://gsa.apple.com/auth/verify/trusteddevice'; ++const GSA_VALIDATE_URL = 'https://gsa.apple.com/grandslam/GsService2/validate'; ++/** Request an SMS be sent to a phone number (PUT, no `/put` suffix). */ ++const GSA_PHONE_URL = 'https://gsa.apple.com/auth/verify/phone'; ++const GSA_PHONE_CODE_URL = 'https://gsa.apple.com/auth/verify/phone/securitycode'; ++ ++/** Replace the private handleTwoFactor method on the auth sub-object so we get ++ * the trusted-phone list and can drive SMS verification from the UI. */ ++function patchHandleTwoFactor(api: AppleAPI, fetch: AltsignFetch): void { ++ // eslint-disable-next-line @typescript-eslint/no-explicit-any ++ const auth: any = (api as any).auth; ++ if (!auth || typeof auth.handleTwoFactor !== 'function') return; ++ ++ auth.handleTwoFactor = async function ( ++ dsid: string, ++ idmsToken: string, ++ anisetteData: AnisetteData, ++ verificationHandler: ((ctx: TwoFactorContext) => void) | undefined, ++ ): Promise { ++ if (!verificationHandler) return false; ++ try { ++ const identityToken = btoa(`${dsid}:${idmsToken}`); ++ const headers: Record = { ++ 'Content-Type': 'text/x-xml-plist', ++ 'User-Agent': 'Xcode', ++ Accept: 'text/x-xml-plist', ++ 'Accept-Language': 'en-us', ++ 'X-Apple-App-Info': 'com.apple.gs.xcode.auth', ++ 'X-Xcode-Version': '11.2 (11B41)', ++ 'X-Apple-Identity-Token': identityToken, ++ 'X-Apple-I-MD-M': anisetteData.machineID, ++ 'X-Apple-I-MD': anisetteData.oneTimePassword, ++ 'X-Apple-I-MD-LU': anisetteData.localUserID, ++ 'X-Apple-I-MD-RINFO': String(anisetteData.routingInfo), ++ 'X-Mme-Device-Id': anisetteData.deviceUniqueIdentifier, ++ 'X-MMe-Client-Info': anisetteData.deviceDescription, ++ 'X-Apple-I-Client-Time': auth.formatDate(anisetteData.date), ++ 'X-Apple-Locale': anisetteData.locale, ++ 'X-Apple-I-TimeZone': anisetteData.timeZone, ++ }; ++ ++ // Trigger push to trusted devices AND get trusted phone numbers. ++ let trustedPhoneNumbers: TrustedPhoneNumber[] = []; ++ try { ++ const deviceResp = await fetch.get(GSA_TRUSTED_DEVICE_URL, headers); ++ const deviceText = await deviceResp.text(); ++ trustedPhoneNumbers = parseTrustedPhones(deviceText); ++ console.log('[2FA] parsed trustedPhoneNumbers:', trustedPhoneNumbers); ++ if (trustedPhoneNumbers.length === 0) { ++ // Log key fragments to diagnose the response format ++ const snippets = [ ++ deviceText.match(/"trustedPhoneNumbers"\s*:\s*(\[[\s\S]{0,300}?\])/)?.[1], ++ deviceText.match(/phoneNumbers[\s\S]{0,200}/)?.[0], ++ // Apple auth HTML embeds data in various script tags ++ deviceText.match(/window\.AUTH_INIT_DATA\s*=\s*({[\s\S]{0,400}?});/)?.[1], ++ deviceText.match(/var\s+bootstrap\s*=\s*({[\s\S]{0,400}?});/)?.[1], ++ deviceText.match(/]*id="boot_args"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1], ++ deviceText.match(/]*type="text\/x-apple-plist"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1], ++ deviceText.match(/]*type="application\/json"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1], ++ ].filter(Boolean); ++ console.log('[2FA] phone fragments:', snippets); ++ } ++ } catch (e) { ++ console.warn('[2FA] trusteddevice request failed (non-fatal):', e); ++ } ++ ++ return await new Promise((resolve) => { ++ const jsonHeaders = { ++ ...headers, ++ 'Content-Type': 'application/json', ++ Accept: 'application/json', ++ }; ++ ++ const submitDeviceCode = async (code: string) => { ++ const trimmed = code.trim(); ++ // '__CANCELLED__' sentinel: user dismissed the modal — skip network request. ++ if (!trimmed || trimmed === '__CANCELLED__') { resolve(false); return; } ++ try { ++ const verifyHeaders = { ...headers, 'security-code': trimmed }; ++ const resp = await fetch.get(GSA_VALIDATE_URL, verifyHeaders); ++ const text = await resp.text(); ++ const plist = parsePlistSimple(text); ++ resolve((plist as Record)['ec'] === 0); ++ } catch { resolve(false); } ++ }; ++ ++ const requestSms = async (phoneId: number): Promise => { ++ const body = JSON.stringify({ phoneNumber: { id: phoneId }, mode: 'sms' }); ++ console.log('[2FA] requestSms → PUT', GSA_PHONE_URL, 'phoneId:', phoneId); ++ const resp = await fetch.request('PUT', GSA_PHONE_URL, jsonHeaders, body); ++ const respText = await resp.text(); ++ console.log('[2FA] requestSms response ok:', resp.ok, 'body:', respText.slice(0, 200)); ++ if (!resp.ok) throw new Error(`Failed to send SMS (${respText.slice(0, 100)})`); ++ }; ++ ++ const submitSmsCode = async (phoneId: number, code: string): Promise => { ++ const body = JSON.stringify({ ++ phoneNumber: { id: phoneId }, ++ securityCode: { code: code.trim() }, ++ mode: 'sms', ++ }); ++ const resp = await fetch.request('POST', GSA_PHONE_CODE_URL, jsonHeaders, body); ++ const text = await resp.text(); ++ console.log('[2FA] submitSmsCode response ok:', resp.ok, 'body:', text.slice(0, 200)); ++ let ok = resp.ok; ++ try { ++ const parsed = JSON.parse(text) as Record; ++ if (parsed['ec'] !== undefined) ok = parsed['ec'] === 0; ++ } catch { /* plist fallback */ } ++ if (!ok) throw new Error('SMS verification failed'); ++ // SMS 2FA is complete — resolve(true) so altsign.js retries authenticate() ++ // with fresh anisette (handled by patchAuthenticateRetry). ++ resolve(true); ++ }; ++ ++ try { ++ verificationHandler({ submitDeviceCode, trustedPhoneNumbers, requestSms, submitSmsCode }); ++ } catch { resolve(false); } ++ }); ++ } catch { ++ return false; ++ } ++ }; ++} ++ ++/** patchAuthenticateRetry: on the post-2FA re-call, swap in a fresh anisette OTP ++ * so the SRP exchange doesn't crash on a stale one-time password. ++ * ++ * patchSendAuthRequest: Apple may return au:"secondaryAuth" instead of ++ * au:"trustedDeviceSecondaryAuth" for accounts without a trusted device (SMS-only ++ * 2FA). altsign.js only checks for the latter, so it silently falls through to ++ * fetchAuthToken which then crashes with atob(undefined). We normalize the value ++ * so altsign.js's existing 2FA path fires correctly. ++ * ++ * For accounts where Apple signals 2FA ONLY at the apptokens stage (no au in the ++ * SRP complete response), we intercept the empty apptokens response, trigger 2FA, ++ * then throw a sentinel so the authenticate wrapper performs a full fresh SRP ++ * exchange (Apple invalidates the SRP session after 2FA at this stage). */ ++const REAUTH_SENTINEL = '__SIDEIMPACTOR_NEEDS_FULL_REAUTH__'; ++ ++function patchAuthenticateRetry(api: AppleAPI, _apiFetch: AltsignFetch): void { ++ // eslint-disable-next-line @typescript-eslint/no-explicit-any ++ const auth: any = (api as any).auth; ++ if (!auth || typeof auth.authenticate !== 'function') return; ++ ++ // Store the verificationHandler from the current authenticate() call so the ++ // sendAuthRequest patch can forward it to handleTwoFactor when needed. ++ let storedVerificationHandler: ((ctx: TwoFactorContext) => void) | undefined; ++ // After 2FA completes, mark it done so we don't re-trigger if the second ++ // apptokens attempt also returns empty (shouldn't happen, but guards loops). ++ let twoFactorDone = false; ++ ++ // --- patch sendAuthRequest --- ++ if (typeof auth.sendAuthRequest === 'function') { ++ const origSend: (...a: unknown[]) => Promise> = ++ auth.sendAuthRequest.bind(auth); ++ auth.sendAuthRequest = async function (...args: unknown[]): Promise> { ++ const params = args[0] as Record; ++ const anisetteArg = args[1] as AnisetteData; ++ ++ // Wrap origSend so we can intercept apptokens 2FA signals. ++ // Apple uses two different conventions to signal "2FA required at apptokens": ++ // (a) Older: returns a non-plist / empty body → parsePlist yields {} (no Status) ++ // (b) Newer: returns ec=-22421 "This action could not be completed. Try again." ++ // which altsign.js converts into a thrown Error before returning to us. ++ // We catch case (b) here and fall through to the shared 2FA-gate logic below. ++ let response: Record; ++ try { ++ response = await origSend(...args); ++ } catch (e) { ++ const isApptokens = params?.['o'] === 'apptokens'; ++ const is22421 = e instanceof Error && e.message.includes('(-22421)'); ++ if (isApptokens && is22421 && !twoFactorDone) { ++ console.log('[auth] apptokens threw -22421 — treating as 2FA gate (Apple new-style signal)'); ++ response = {}; // fall through to 2FA handling below ++ } else { ++ throw e; ++ } ++ } ++ ++ const status = response['Status'] as Record | undefined; ++ const au = status?.['au']; ++ console.log('[auth] sendAuthRequest o=%s au=%s keys=%o', params?.['o'], au, Object.keys(response)); ++ ++ // Normalize legacy au value ++ if (au === 'secondaryAuth') { ++ console.log('[auth] normalizing au: secondaryAuth → trustedDeviceSecondaryAuth'); ++ (status as Record)['au'] = 'trustedDeviceSecondaryAuth'; ++ return response; ++ } ++ ++ // Detect 2FA required at apptokens stage. ++ // Covers both empty response (case a) and the -22421 catch above (case b). ++ if (params?.['o'] === 'apptokens' && Object.keys(response).length === 0) { ++ if (twoFactorDone) { ++ // 2FA was already completed — this is a genuine failure, not a gate. ++ console.error('[auth] apptokens still empty/erroring after 2FA — authentication failed'); ++ return response; ++ } ++ console.log('[auth] apptokens 2FA gate detected — triggering verification'); ++ const adsid = params['u'] as string; ++ const idmsToken = params['t'] as string; ++ if (adsid && idmsToken && storedVerificationHandler) { ++ const success = await auth.handleTwoFactor( ++ adsid, idmsToken, anisetteArg, storedVerificationHandler, ++ ); ++ if (success) { ++ // Apple invalidates the SRP session after 2FA at this stage. ++ // Throw a sentinel so loginAppleDeveloperAccount starts a fresh SRP exchange. ++ console.log('[auth] 2FA done — signalling full re-authentication'); ++ twoFactorDone = true; ++ throw new Error(REAUTH_SENTINEL); ++ } ++ } ++ console.error('[auth] apptokens 2FA failed or no handler available'); ++ } ++ ++ return response; ++ }; ++ } else { ++ console.warn('[auth] sendAuthRequest not found on auth — patch skipped'); ++ } ++ ++ // --- patch authenticate --- ++ // Responsibilities: ++ // 1. Capture verificationHandler so sendAuthRequest can pass it to handleTwoFactor. ++ // 2. On post-2FA retry (callCount > 1), swap in fresh anisette. ++ // 3. If REAUTH_SENTINEL is thrown from the apptokens path, re-throw it unchanged ++ // so loginAppleDeveloperAccount can catch it and do a clean fresh login with a ++ // new AppleAPI instance (avoids state-pollution in the current auth object). ++ const original: (...args: unknown[]) => Promise = auth.authenticate.bind(auth); ++ let callCount = 0; ++ ++ auth.authenticate = async function (...args: unknown[]): Promise { ++ callCount++; ++ storedVerificationHandler = args[3] as ((ctx: TwoFactorContext) => void) | undefined; ++ console.log(`[auth] authenticate call #${callCount}`); ++ ++ // Always use fresh anisette on any retry call (callCount > 1). ++ if (callCount > 1) { ++ try { ++ const { getAnisetteData } = await import('./anisette-service'); ++ const freshAnisette = await getAnisetteData(); ++ args = [...args]; ++ args[2] = freshAnisette; ++ console.log('[auth] post-2FA retry: swapped in fresh anisette OTP'); ++ } catch (e) { ++ console.warn('[auth] post-2FA retry: could not fetch fresh anisette, proceeding with original', e); ++ } ++ } ++ ++ try { ++ return await original(...args); ++ } catch (e) { ++ const msg = e instanceof Error ? e.message : String(e); ++ // Bubble the sentinel unchanged — loginAppleDeveloperAccount handles it ++ // by creating a fresh AppleAPI instance for a clean re-auth. ++ if (msg === REAUTH_SENTINEL) { ++ throw e; ++ } ++ console.error(`[auth] authenticate call #${callCount} threw:`, e); ++ throw e; ++ } ++ }; ++} ++ ++/** Extract `ec` from an Apple GSA response (JSON or XML plist). */ ++function parsePlistSimple(text: string): unknown { ++ const trimmed = text.trim(); ++ if (trimmed.startsWith('{')) { ++ try { return JSON.parse(trimmed); } catch { /* fall through */ } ++ } ++ // XML plist — reuse the file's own parser ++ return parseXmlPlist(trimmed); ++} ++ ++function parseTrustedPhones(text: string): TrustedPhoneNumber[] { ++ const trimmed = text.trim(); ++ ++ // Path 1: plain JSON response { "trustedPhoneNumbers": [...] } ++ if (trimmed.startsWith('{') || trimmed.startsWith('[')) { ++ try { ++ const data = JSON.parse(trimmed) as Record; ++ const phones = data['trustedPhoneNumbers']; ++ if (Array.isArray(phones)) return mapPhones(phones as Record[]); ++ } catch { /* fall through */ } ++ } ++ ++ // Path 2: Apple Auth HTML — phone numbers are in ++ // ++ const bootArgsMatch = trimmed.match( ++ /]+class="boot_args"[^>]*>([\s\S]*?)<\/script>/i, ++ ); ++ if (bootArgsMatch) { ++ try { ++ const boot = JSON.parse(bootArgsMatch[1]) as Record; ++ const tdv = (boot['direct'] as Record | undefined)?.['trustedDeviceVerification'] as Record | undefined; ++ const pnv = tdv?.['phoneNumberVerification'] as Record | undefined; ++ const phones = pnv?.['trustedPhoneNumbers']; ++ if (Array.isArray(phones)) return mapPhones(phones as Record[]); ++ } catch { /* fall through */ } ++ } ++ ++ // Path 3: XML plist (older Apple endpoints) ++ if (trimmed.startsWith(')['trustedPhoneNumbers']; ++ if (Array.isArray(phones)) return mapPhones(phones as Record[]); ++ } ++ } ++ ++ return []; ++} ++ ++function mapPhones(raw: Record[]): TrustedPhoneNumber[] { ++ return raw.map((p) => ({ ++ id: typeof p['id'] === 'number' ? p['id'] : Number(p['id']), ++ numberWithDialCode: typeof p['numberWithDialCode'] === 'string' ? p['numberWithDialCode'] : '', ++ obfuscatedNumber: typeof p['obfuscatedNumber'] === 'string' ? p['obfuscatedNumber'] : '', ++ pushMode: typeof p['pushMode'] === 'string' ? p['pushMode'] : 'sms', ++ })); ++} + + /** + * Keep `altsign.js` and its transitive `zsign-wasm` bundle out of the initial +@@ -117,26 +473,65 @@ async function loadAltsignModule(): Promise { + return await altsignModulePromise; + } + +-async function getAppleApi(): Promise { +- if (appleApiInstance) { +- return appleApiInstance; +- } ++async function getAppleApi(log?: (msg: string) => void): Promise<{ api: AppleAPI; fetch: AltsignFetch }> { ++ const uiLog = log ?? (() => undefined); + const { AppleAPI, Fetch } = await loadAltsignModule(); +- const appleFetch = new Fetch(initLibcurl, async (url, options) => { +- const libcurl = requireLibcurl(); +- const response = await libcurl.fetch(url, { +- method: options.method, +- headers: options.headers, +- body: options.body, +- redirect: 'manual', +- insecure: true, +- verbose: 4, +- _libcurl_http_version: 1.1, +- } as never); +- return response; +- }); +- appleApiInstance = new AppleAPI(appleFetch); +- return appleApiInstance; ++ if (!appleFetchInstance) { ++ const doFetch = async (url: string, options: { ++ method?: string; ++ headers?: HeadersInit; ++ body?: BodyInit | null; ++ }): Promise => { ++ const libcurl = requireLibcurl(); ++ const libcurlOpts = { ++ method: options.method, ++ headers: options.headers, ++ body: options.body, ++ redirect: 'manual', ++ insecure: true, ++ verbose: 4, ++ _libcurl_http_version: 1.1, ++ } as never; ++ let response = await libcurl.fetch(url, libcurlOpts) as Response; ++ // Auto-retry on Apple GSA 503 (transient rate-limit / overload). ++ // Use exponential backoff: 5s, 10s, 20s, 40s — up to ~75s total. ++ if (url.includes('gsa.apple.com') && response.status === 503) { ++ const delays = [5000, 10000, 20000, 40000]; ++ for (let attempt = 0; attempt < delays.length; attempt++) { ++ const waitSec = delays[attempt] / 1000; ++ const msg = `login: Apple server busy (503) — retrying in ${waitSec}s (${attempt + 1}/${delays.length})...`; ++ console.warn(`[fetch] ${msg}`); ++ uiLog(msg); ++ await new Promise((r) => setTimeout(r, delays[attempt])); ++ response = await libcurl.fetch(url, libcurlOpts) as Response; ++ if (response.status !== 503) break; ++ } ++ if (response.status === 503) { ++ throw new Error('Apple authentication server is temporarily unavailable (503). Please wait a few minutes and try again.'); ++ } ++ } ++ // Log raw response body for Apple auth endpoint calls (diagnosis aid). ++ if (url.includes('gsa.apple.com')) { ++ const origText = response.text.bind(response); ++ response.text = async () => { ++ const text = await origText(); ++ console.log('[fetch] raw text from %s status=%d (%d chars): %s', ++ url.split('?')[0], response.status, text.length, text.slice(0, 300)); ++ return text; ++ }; ++ } ++ return response; ++ }; ++ const f = new Fetch(initLibcurl, doFetch); ++ appleFetchInstance = f as AltsignFetch; ++ } ++ // Always create a fresh AppleAPI per call — it holds no expensive state, ++ // but reusing it across separate authenticate() calls causes -22421 because ++ // the anisette OTP embedded in the previous call is already consumed. ++ const api = new AppleAPI(appleFetchInstance); ++ patchHandleTwoFactor(api, appleFetchInstance); ++ patchAuthenticateRetry(api, appleFetchInstance); ++ return { api, fetch: appleFetchInstance }; + } + + export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginRequest): Promise { +@@ -149,24 +544,46 @@ export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginReq + const log = request.onLog ?? (() => undefined); + log(`Login stage: authenticating Apple account ${maskEmail(appleId)}...`); + +- const api = await getAppleApi(); +- const { session } = await api.authenticate( +- appleId, +- password, +- request.anisetteData, +- (submitCode: (code: string) => void) => { +- if (!request.onTwoFactorRequired) { +- throw new Error('2FA required but no in-page handler provided'); +- } +- request.onTwoFactorRequired((code) => { +- const normalized = code.trim(); +- if (normalized.length === 0) { +- throw new Error('2FA code is required'); +- } +- submitCode(normalized); +- }); +- }, +- ); ++ // Reset the shared fetch instance so each login attempt starts with a fresh ++ // libcurl TCP connection — prevents state bleed between accounts and between ++ // a cancelled-then-retried login on the same account. ++ appleFetchInstance = null; ++ ++ const verificationCallback = (submitCode: (code: string) => void) => { ++ if (!request.onTwoFactorRequired) { ++ throw new Error('2FA required but no in-page handler provided'); ++ } ++ // The patched handleTwoFactor passes a TwoFactorContext; the raw altsign.js ++ // VerificationHandler signature still receives `submitCode` as first arg, ++ // but our patch replaces that with the full context object. ++ const ctx = submitCode as unknown as TwoFactorContext; ++ request.onTwoFactorRequired(ctx); ++ }; ++ ++ let { api } = await getAppleApi(log); ++ ++ let session: AppleDeveloperSession; ++ try { ++ ({ session } = await api.authenticate(appleId, password, request.anisetteData, verificationCallback)); ++ } catch (e) { ++ // After 2FA at the apptokens stage, Apple invalidates the SRP session. ++ // The sendAuthRequest patch throws REAUTH_SENTINEL to signal this. ++ // We create a completely fresh AppleAPI (new auth state, new patches) and ++ // retry the full SRP exchange with fresh anisette. ++ if (e instanceof Error && e.message === REAUTH_SENTINEL) { ++ log('Login stage: 2FA verified — restarting SRP with fresh session...'); ++ // Reset the shared fetch instance so the re-auth uses a fresh libcurl ++ // TCP connection to Apple — eliminates connection-state as a failure cause. ++ appleFetchInstance = null; ++ const fresh = await getAppleApi(log); ++ api = fresh.api; ++ const { getAnisetteData } = await import('./anisette-service'); ++ const freshAnisette = await getAnisetteData(); ++ ({ session } = await api.authenticate(appleId, password, freshAnisette, verificationCallback)); ++ } else { ++ throw e; ++ } ++ } + + log('Login stage: fetching team/certificates/devices...'); + const team = await api.fetchTeam(session); +@@ -191,7 +608,7 @@ export async function refreshAppleDeveloperContext( + onLog?: (message: string) => void, + ): Promise { + const log = onLog ?? (() => undefined); +- const api = await getAppleApi(); ++ const { api } = await getAppleApi(); + log('Signing stage: refreshing team/certificates/devices...'); + const team = await api.fetchTeam(context.session); + const [certificates, devices] = await Promise.all([ +@@ -234,7 +651,7 @@ export async function signIpaWithAppleContext(request: AppleSigningWithContextRe + throw new Error('Cannot sign IPA: bundle identifier is missing'); + } + +- const api = await getAppleApi(); ++ const { api } = await getAppleApi(); + const team = context.team; + onLog(`Signing stage: using team ${team.identifier} (${team.name}).`); + +diff --git a/frontend/src/components/LoginModal.tsx b/frontend/src/components/LoginModal.tsx +index 7348eda..0b54926 100644 +--- a/frontend/src/components/LoginModal.tsx ++++ b/frontend/src/components/LoginModal.tsx +@@ -9,6 +9,7 @@ interface LoginModalProps { + password: string; + busyLoginSign: boolean; + canSubmit: boolean; ++ error?: string | null; + onAppleIdChange: (value: string) => void; + onAppleIdBlur: () => void; + onPasswordChange: (value: string) => void; +@@ -22,6 +23,7 @@ export function LoginModal({ + password, + busyLoginSign, + canSubmit, ++ error, + onAppleIdChange, + onAppleIdBlur, + onPasswordChange, +@@ -31,7 +33,7 @@ export function LoginModal({ + + return ( +
+-
++
+
+

Add Account

+
+ +-
+- ++
++ {error && ( ++

++ {error} ++

++ )} ++
++ ++
+
+
+
+diff --git a/frontend/src/components/TwoFactorModal.tsx b/frontend/src/components/TwoFactorModal.tsx +index da36943..eaa1a28 100644 +--- a/frontend/src/components/TwoFactorModal.tsx ++++ b/frontend/src/components/TwoFactorModal.tsx +@@ -1,40 +1,122 @@ + import { useEffect, useRef, useState } from 'react'; + import { Modal } from './ui/Modal'; + import { Button } from './ui/Button'; ++import type { TrustedPhoneNumber, TwoFactorContext } from '../apple-signing'; ++ ++type Mode = 'device' | 'sms'; + + interface TwoFactorModalProps { + open: boolean; +- onSubmit: (code: string) => void; ++ ctx: TwoFactorContext | null; + onCancel: () => void; +- /** Server-side error (e.g. wrong code). Shown when the login flow rejects after 2FA submit. */ ++ /** Server-side error passed back from the login flow after a failed verify. */ + serverError?: string | null; ++ /** Called when the user wants to retry the entire login after a server error. */ ++ onRetry?: () => void; + } + +-export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFactorModalProps) { ++export function TwoFactorModal({ open, ctx, onCancel, serverError, onRetry }: TwoFactorModalProps) { ++ const [mode, setMode] = useState('device'); ++ const [selectedPhone, setSelectedPhone] = useState(null); + const [code, setCode] = useState(''); +- const [error, setError] = useState(null); ++ const [localError, setLocalError] = useState(null); ++ const [smsSent, setSmsSent] = useState(false); ++ const [smsBusy, setSmsBusy] = useState(false); ++ const [verifyBusy, setVerifyBusy] = useState(false); + const inputRef = useRef(null); +- const displayError = serverError || error; + ++ const displayError = serverError || localError; ++ const phones = ctx?.trustedPhoneNumbers ?? []; ++ ++ // Reset state on open/close + useEffect(() => { + if (open) { ++ setMode('device'); + setCode(''); +- setError(null); ++ setLocalError(null); ++ setSmsSent(false); ++ setSmsBusy(false); ++ setVerifyBusy(false); ++ setSelectedPhone(phones.length > 0 ? phones[0] : null); + const timer = window.setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + return () => window.clearTimeout(timer); + } ++ // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + +- const handleSubmit = () => { ++ // Focus input when switching modes or after SMS sent ++ useEffect(() => { ++ if (open && (mode === 'device' || smsSent)) { ++ const timer = window.setTimeout(() => inputRef.current?.focus(), 50); ++ return () => window.clearTimeout(timer); ++ } ++ }, [mode, smsSent, open]); ++ ++ const switchToSms = () => { ++ setMode('sms'); ++ setCode(''); ++ setLocalError(null); ++ setSmsSent(false); ++ }; ++ ++ const switchToDevice = () => { ++ setMode('device'); ++ setCode(''); ++ setLocalError(null); ++ }; ++ ++ const handleRequestSms = async () => { ++ if (!ctx || !selectedPhone) return; ++ setSmsBusy(true); ++ setLocalError(null); ++ try { ++ await ctx.requestSms(selectedPhone.id); ++ setSmsSent(true); ++ } catch (err) { ++ setLocalError(err instanceof Error ? err.message : 'Failed to send SMS'); ++ } finally { ++ setSmsBusy(false); ++ } ++ }; ++ ++ const handleSubmit = async () => { + const trimmed = code.trim(); + if (trimmed.length === 0) { +- setError('Please enter verification code.'); ++ setLocalError('Please enter the verification code.'); + return; + } +- onSubmit(trimmed); ++ if (!ctx) return; ++ ++ setVerifyBusy(true); ++ setLocalError(null); ++ try { ++ if (mode === 'device') { ++ ctx.submitDeviceCode(trimmed); ++ // submitDeviceCode resolves the outer Promise — no async result here ++ } else { ++ if (!selectedPhone) return; ++ await ctx.submitSmsCode(selectedPhone.id, trimmed); ++ } ++ } catch (err) { ++ setLocalError(err instanceof Error ? err.message : 'Verification failed'); ++ setVerifyBusy(false); ++ } ++ // On success the parent modal will close; don't clear busy so button stays ++ // disabled until the modal is removed. ++ }; ++ ++ const handleKeyDown = (e: React.KeyboardEvent) => { ++ if (e.key === 'Enter') { ++ e.preventDefault(); ++ if (mode === 'sms' && !smsSent) { ++ void handleRequestSms(); ++ } else { ++ void handleSubmit(); ++ } ++ } + }; + + return ( +@@ -42,44 +124,152 @@ export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFac +

+ Two-Factor Authentication +

+-

+- Enter the verification code from your trusted Apple device. +-

+- +- +- { +- setCode(e.target.value); +- if (error) setError(null); +- }} +- onKeyDown={(e) => { +- if (e.key === 'Enter') { +- e.preventDefault(); +- handleSubmit(); +- } +- }} +- /> +-

{displayError ?? ''}

+- +-
+- +- +-
++ ++ {mode === 'device' ? ( ++ <> ++

++ Enter the verification code from your trusted Apple device or Mac. ++

++ ++ ++ { setCode(e.target.value); setLocalError(null); }} ++ onKeyDown={handleKeyDown} ++ /> ++

{displayError ?? ''}

++ ++
++ ++ {serverError ? ( ++ ++ ) : ( ++ ++ )} ++
++ ++ {phones.length > 0 && ( ++ ++ )} ++ ++ ) : ( ++ <> ++

++ {smsSent ++ ? 'Enter the code sent via SMS.' ++ : 'Choose a phone number to receive a verification code.'} ++

++ ++ {/* Phone picker */} ++ {!smsSent && phones.length > 1 && ( ++
++ {phones.map((phone) => ( ++ ++ ))} ++
++ )} ++ ++ {!smsSent && phones.length === 1 && ( ++

++ {phones[0].numberWithDialCode || phones[0].obfuscatedNumber} ++

++ )} ++ ++

{localError ?? ''}

++ ++ {!smsSent ? ( ++
++ ++ ++
++ ) : ( ++ <> ++ ++ { setCode(e.target.value); setLocalError(null); }} ++ onKeyDown={handleKeyDown} ++ /> ++

{displayError ?? ''}

++
++ ++ {serverError ? ( ++ ++ ) : ( ++ ++ )} ++
++ ++ )} ++ ++ )} + + ); + } +diff --git a/frontend/src/flows/login.ts b/frontend/src/flows/login.ts +index f70177e..306787b 100644 +--- a/frontend/src/flows/login.ts ++++ b/frontend/src/flows/login.ts +@@ -1,5 +1,5 @@ + import type { AnisetteData } from '../anisette-service'; +-import type { AppleDeveloperContext } from '../apple-signing'; ++import type { AppleDeveloperContext, TwoFactorContext } from '../apple-signing'; + import { shortToken } from '../lib/ids'; + + type AnisetteService = typeof import('../anisette-service'); +@@ -61,7 +61,7 @@ export interface LoginRequest { + password: string; + anisetteData: AnisetteData; + log: (msg: string) => void; +- onTwoFactorRequired: (submit: (code: string) => void) => void; ++ onTwoFactorRequired: (ctx: TwoFactorContext) => void; + } + + export async function loginAccount(req: LoginRequest): Promise {