mirror of
https://github.com/lbr77/SideImpactor.git
synced 2026-05-06 11:14:01 -04:00
1178 lines
48 KiB
Diff
1178 lines
48 KiB
Diff
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<AppleDeveloperContext | null>(null);
|
|
const [savedAccounts, setSavedAccounts] = useState<StoredAccountSummary[]>(() => loadStoredAccountList());
|
|
const accountContextMapRef = useRef<Map<string, AppleDeveloperContext>>(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<string | null>(null);
|
|
|
|
// Device
|
|
const [pairedDeviceInfo, setPairedDeviceInfo] = useState<PairedDeviceInfo | null>(null);
|
|
@@ -114,9 +118,12 @@ export function App() {
|
|
const [trustState, setTrustState] = useState<TrustModalState>(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() {
|
|
<main className="min-h-screen bg-bg">
|
|
<Header currentPage={currentPage} onNavigate={navigateToPage} />
|
|
|
|
- <section className="mx-auto max-w-[760px] px-5 py-10 sm:px-7">
|
|
+ <section className="mx-auto max-w-190 px-5 py-10 sm:px-7">
|
|
{currentPage === 'login' ? (
|
|
<LoginPage
|
|
loggedIn={!!loginContext}
|
|
@@ -642,14 +664,15 @@ export function App() {
|
|
|
|
<LoginModal
|
|
open={loginModalOpen}
|
|
- onClose={() => 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() {
|
|
/>
|
|
<TwoFactorModal
|
|
open={twoFactor.open}
|
|
- onSubmit={handleTwoFactorSubmit}
|
|
+ ctx={twoFactor.ctx}
|
|
onCancel={handleTwoFactorCancel}
|
|
serverError={twoFactor.error}
|
|
+ onRetry={handleTwoFactorRetry}
|
|
/>
|
|
</main>
|
|
);
|
|
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<void>;
|
|
+ /** Submit the code received via SMS for the given phone id. */
|
|
+ submitSmsCode: (phoneId: number, code: string) => Promise<void>;
|
|
+}
|
|
+
|
|
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<string, string>): Promise<{ text(): Promise<string>; ok: boolean }>;
|
|
+ request(
|
|
+ method: string,
|
|
+ url: string,
|
|
+ headers: Record<string, string>,
|
|
+ body?: string,
|
|
+ ): Promise<{ text(): Promise<string>; ok: boolean }>;
|
|
+};
|
|
+
|
|
let altsignModulePromise: Promise<AltsignModule> | 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<boolean> {
|
|
+ if (!verificationHandler) return false;
|
|
+ try {
|
|
+ const identityToken = btoa(`${dsid}:${idmsToken}`);
|
|
+ const headers: Record<string, string> = {
|
|
+ '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(/<script[^>]*id="boot_args"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
|
|
+ deviceText.match(/<script[^>]*type="text\/x-apple-plist"[^>]*>([\s\S]{0,400}?)<\/script>/i)?.[1],
|
|
+ deviceText.match(/<script[^>]*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<boolean>((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<string, unknown>)['ec'] === 0);
|
|
+ } catch { resolve(false); }
|
|
+ };
|
|
+
|
|
+ const requestSms = async (phoneId: number): Promise<void> => {
|
|
+ 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<void> => {
|
|
+ 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<string, unknown>;
|
|
+ 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<Record<string, unknown>> =
|
|
+ auth.sendAuthRequest.bind(auth);
|
|
+ auth.sendAuthRequest = async function (...args: unknown[]): Promise<Record<string, unknown>> {
|
|
+ const params = args[0] as Record<string, unknown>;
|
|
+ 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<string, unknown>;
|
|
+ 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<string, unknown> | 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<string, unknown>)['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<unknown> = auth.authenticate.bind(auth);
|
|
+ let callCount = 0;
|
|
+
|
|
+ auth.authenticate = async function (...args: unknown[]): Promise<unknown> {
|
|
+ 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<string, unknown>;
|
|
+ const phones = data['trustedPhoneNumbers'];
|
|
+ if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
|
|
+ } catch { /* fall through */ }
|
|
+ }
|
|
+
|
|
+ // Path 2: Apple Auth HTML — phone numbers are in
|
|
+ // <script type="application/json" class="boot_args">{ "direct": { "trustedDeviceVerification": { "phoneNumberVerification": { "trustedPhoneNumbers": [...] } } } }</script>
|
|
+ const bootArgsMatch = trimmed.match(
|
|
+ /<script[^>]+class="boot_args"[^>]*>([\s\S]*?)<\/script>/i,
|
|
+ );
|
|
+ if (bootArgsMatch) {
|
|
+ try {
|
|
+ const boot = JSON.parse(bootArgsMatch[1]) as Record<string, unknown>;
|
|
+ const tdv = (boot['direct'] as Record<string, unknown> | undefined)?.['trustedDeviceVerification'] as Record<string, unknown> | undefined;
|
|
+ const pnv = tdv?.['phoneNumberVerification'] as Record<string, unknown> | undefined;
|
|
+ const phones = pnv?.['trustedPhoneNumbers'];
|
|
+ if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
|
|
+ } catch { /* fall through */ }
|
|
+ }
|
|
+
|
|
+ // Path 3: XML plist (older Apple endpoints)
|
|
+ if (trimmed.startsWith('<?xml') || trimmed.startsWith('<plist')) {
|
|
+ const data = parseXmlPlist(trimmed);
|
|
+ if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
+ const phones = (data as Record<string, unknown>)['trustedPhoneNumbers'];
|
|
+ if (Array.isArray(phones)) return mapPhones(phones as Record<string, unknown>[]);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ return [];
|
|
+}
|
|
+
|
|
+function mapPhones(raw: Record<string, unknown>[]): 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<AltsignModule> {
|
|
return await altsignModulePromise;
|
|
}
|
|
|
|
-async function getAppleApi(): Promise<AppleAPI> {
|
|
- 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<Response> => {
|
|
+ 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<AppleDeveloperContext> {
|
|
@@ -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<AppleDeveloperContext> {
|
|
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 (
|
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 backdrop-blur-sm p-4 pt-[8vh]">
|
|
- <div className="w-full max-w-[440px] rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in">
|
|
+ <div className="w-full max-w-110 rounded-2xl border border-border bg-bg p-6 shadow-2xl anim-in">
|
|
<div className="mb-5 flex items-center justify-between">
|
|
<h2 className="text-[18px] font-semibold tracking-tight text-ink">Add Account</h2>
|
|
<button
|
|
@@ -83,22 +85,29 @@ export function LoginModal({
|
|
<p className="text-[11.5px] text-muted">
|
|
Your credentials are stored locally in this browser and are sent directly to Apple.
|
|
</p>
|
|
- <p className="text-[11.5px] text-[var(--color-danger)] underline underline-offset-2 decoration-[var(--color-danger)]/40">
|
|
+ <p className="text-[11.5px] text-danger underline underline-offset-2 decoration-danger/40">
|
|
Verify that you trust the server hosting this page. A compromised server can intercept your credentials.
|
|
</p>
|
|
</div>
|
|
|
|
- <div className="mt-5 flex justify-end">
|
|
- <Button
|
|
- variant="primary"
|
|
- busy={busyLoginSign}
|
|
- busyLabel="Signing In…"
|
|
- disabled={!canSubmit}
|
|
- onClick={onSubmit}
|
|
- className="min-w-[140px]"
|
|
- >
|
|
- Sign In
|
|
- </Button>
|
|
+ <div className="mt-5 flex flex-col gap-3">
|
|
+ {error && (
|
|
+ <p className="rounded-lg border border-danger/30 bg-danger-soft px-3 py-2 text-[12.5px] text-danger">
|
|
+ {error}
|
|
+ </p>
|
|
+ )}
|
|
+ <div className="flex justify-end">
|
|
+ <Button
|
|
+ variant="primary"
|
|
+ busy={busyLoginSign}
|
|
+ busyLabel="Signing In…"
|
|
+ disabled={!canSubmit}
|
|
+ onClick={onSubmit}
|
|
+ className="min-w-35"
|
|
+ >
|
|
+ Sign In
|
|
+ </Button>
|
|
+ </div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
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<Mode>('device');
|
|
+ const [selectedPhone, setSelectedPhone] = useState<TrustedPhoneNumber | null>(null);
|
|
const [code, setCode] = useState('');
|
|
- const [error, setError] = useState<string | null>(null);
|
|
+ const [localError, setLocalError] = useState<string | null>(null);
|
|
+ const [smsSent, setSmsSent] = useState(false);
|
|
+ const [smsBusy, setSmsBusy] = useState(false);
|
|
+ const [verifyBusy, setVerifyBusy] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(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
|
|
<h2 id="two-factor-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
|
Two-Factor Authentication
|
|
</h2>
|
|
- <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
|
|
- Enter the verification code from your trusted Apple device.
|
|
- </p>
|
|
-
|
|
- <label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
|
|
- Verification Code
|
|
- </label>
|
|
- <input
|
|
- ref={inputRef}
|
|
- id="two-factor-code"
|
|
- type="text"
|
|
- inputMode="numeric"
|
|
- autoComplete="one-time-code"
|
|
- maxLength={8}
|
|
- placeholder="123456"
|
|
- className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
|
|
- value={code}
|
|
- onChange={(e) => {
|
|
- setCode(e.target.value);
|
|
- if (error) setError(null);
|
|
- }}
|
|
- onKeyDown={(e) => {
|
|
- if (e.key === 'Enter') {
|
|
- e.preventDefault();
|
|
- handleSubmit();
|
|
- }
|
|
- }}
|
|
- />
|
|
- <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
|
|
-
|
|
- <div className="mt-4 grid grid-cols-2 gap-2">
|
|
- <Button variant="ghost" onClick={onCancel}>
|
|
- Cancel
|
|
- </Button>
|
|
- <Button variant="primary" onClick={handleSubmit} disabled={!!serverError}>
|
|
- Verify
|
|
- </Button>
|
|
- </div>
|
|
+
|
|
+ {mode === 'device' ? (
|
|
+ <>
|
|
+ <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
|
|
+ Enter the verification code from your trusted Apple device or Mac.
|
|
+ </p>
|
|
+
|
|
+ <label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
|
|
+ Verification Code
|
|
+ </label>
|
|
+ <input
|
|
+ ref={inputRef}
|
|
+ id="two-factor-code"
|
|
+ type="text"
|
|
+ inputMode="numeric"
|
|
+ autoComplete="one-time-code"
|
|
+ maxLength={8}
|
|
+ placeholder="123456"
|
|
+ className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
|
|
+ value={code}
|
|
+ onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
|
|
+ onKeyDown={handleKeyDown}
|
|
+ />
|
|
+ <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
|
|
+
|
|
+ <div className="mt-4 grid grid-cols-2 gap-2">
|
|
+ <Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
|
+ {serverError ? (
|
|
+ <Button variant="primary" onClick={onRetry}>
|
|
+ Retry Login
|
|
+ </Button>
|
|
+ ) : (
|
|
+ <Button
|
|
+ variant="primary"
|
|
+ busy={verifyBusy}
|
|
+ busyLabel="Verifying…"
|
|
+ onClick={() => void handleSubmit()}
|
|
+ disabled={verifyBusy}
|
|
+ >
|
|
+ Verify
|
|
+ </Button>
|
|
+ )}
|
|
+ </div>
|
|
+
|
|
+ {phones.length > 0 && (
|
|
+ <button
|
|
+ type="button"
|
|
+ onClick={switchToSms}
|
|
+ className="mt-3 w-full text-center text-[12px] text-muted underline underline-offset-2 hover:text-ink transition-colors"
|
|
+ >
|
|
+ Get SMS instead →
|
|
+ </button>
|
|
+ )}
|
|
+ </>
|
|
+ ) : (
|
|
+ <>
|
|
+ <p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
|
|
+ {smsSent
|
|
+ ? 'Enter the code sent via SMS.'
|
|
+ : 'Choose a phone number to receive a verification code.'}
|
|
+ </p>
|
|
+
|
|
+ {/* Phone picker */}
|
|
+ {!smsSent && phones.length > 1 && (
|
|
+ <div className="mt-4 space-y-1">
|
|
+ {phones.map((phone) => (
|
|
+ <button
|
|
+ key={phone.id}
|
|
+ type="button"
|
|
+ onClick={() => setSelectedPhone(phone)}
|
|
+ className={`w-full rounded-lg border px-3 py-2 text-left text-[13px] transition-colors ${
|
|
+ selectedPhone?.id === phone.id
|
|
+ ? 'border-blue-500 bg-blue-500/10 text-ink'
|
|
+ : 'border-border text-muted hover:border-blue-400 hover:text-ink'
|
|
+ }`}
|
|
+ >
|
|
+ {phone.numberWithDialCode || phone.obfuscatedNumber}
|
|
+ </button>
|
|
+ ))}
|
|
+ </div>
|
|
+ )}
|
|
+
|
|
+ {!smsSent && phones.length === 1 && (
|
|
+ <p className="mt-3 rounded-lg border border-border px-3 py-2 text-[13px] text-muted">
|
|
+ {phones[0].numberWithDialCode || phones[0].obfuscatedNumber}
|
|
+ </p>
|
|
+ )}
|
|
+
|
|
+ <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{localError ?? ''}</p>
|
|
+
|
|
+ {!smsSent ? (
|
|
+ <div className="mt-2 grid grid-cols-2 gap-2">
|
|
+ <Button variant="ghost" onClick={switchToDevice}>Back</Button>
|
|
+ <Button
|
|
+ variant="primary"
|
|
+ busy={smsBusy}
|
|
+ busyLabel="Sending…"
|
|
+ disabled={!selectedPhone || smsBusy}
|
|
+ onClick={() => void handleRequestSms()}
|
|
+ >
|
|
+ Send SMS
|
|
+ </Button>
|
|
+ </div>
|
|
+ ) : (
|
|
+ <>
|
|
+ <label htmlFor="sms-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
|
|
+ SMS Code
|
|
+ </label>
|
|
+ <input
|
|
+ ref={inputRef}
|
|
+ id="sms-code"
|
|
+ type="text"
|
|
+ inputMode="numeric"
|
|
+ autoComplete="one-time-code"
|
|
+ maxLength={8}
|
|
+ placeholder="123456"
|
|
+ className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
|
|
+ value={code}
|
|
+ onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
|
|
+ onKeyDown={handleKeyDown}
|
|
+ />
|
|
+ <p className="mt-2 min-h-[18px] text-[12px] text-[var(--color-danger)]">{displayError ?? ''}</p>
|
|
+ <div className="mt-4 grid grid-cols-2 gap-2">
|
|
+ <Button variant="ghost" onClick={() => { setSmsSent(false); setCode(''); }}>
|
|
+ Resend
|
|
+ </Button>
|
|
+ {serverError ? (
|
|
+ <Button variant="primary" onClick={onRetry}>
|
|
+ Retry Login
|
|
+ </Button>
|
|
+ ) : (
|
|
+ <Button
|
|
+ variant="primary"
|
|
+ busy={verifyBusy}
|
|
+ busyLabel="Verifying…"
|
|
+ onClick={() => void handleSubmit()}
|
|
+ disabled={verifyBusy}
|
|
+ >
|
|
+ Verify
|
|
+ </Button>
|
|
+ )}
|
|
+ </div>
|
|
+ </>
|
|
+ )}
|
|
+ </>
|
|
+ )}
|
|
</Modal>
|
|
);
|
|
}
|
|
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<AppleDeveloperContext> {
|