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..f9e931e 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,348 @@ 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. + throw new Error('Authentication failed after two-factor verification. Please sign in again.'); + } + 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); + } + } + // 2FA was cancelled by the user or no handler was available. + // Throw instead of returning {} so altsign.js doesn't try to process + // an invalid empty response (which would crash with atob(undefined)). + throw new Error('Two-factor authentication was cancelled. Please sign in again.'); + } + + 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 +475,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 +546,56 @@ 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'); + // 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(); + 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.'); } - submitCode(normalized); - }); - }, - ); + throw reauth2; + } + } else { + throw e; + } + } log('Login stage: fetching team/certificates/devices...'); const team = await api.fetchTeam(session); @@ -191,7 +620,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 +663,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 { 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 {