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 {