mirror of
https://github.com/lbr77/SideImpactor.git
synced 2026-05-06 11:14:01 -04:00
Compare commits
4 Commits
6f3d8fdd76
...
8ea1b9c0bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ea1b9c0bc | ||
|
|
34be882f88 | ||
|
|
a31181ea95 | ||
|
|
27ad1001a7 |
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { DirectUsbMuxClient } from 'webmuxd';
|
import type { DirectUsbMuxClient } from 'webmuxd';
|
||||||
import type { AnisetteData } from './anisette-service';
|
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 { Header, type AppPage } from './components/Header';
|
||||||
import { LoginPage } from './components/LoginPage';
|
import { LoginPage } from './components/LoginPage';
|
||||||
@@ -80,9 +80,13 @@ export function App() {
|
|||||||
const [loginContext, setLoginContext] = useState<AppleDeveloperContext | null>(null);
|
const [loginContext, setLoginContext] = useState<AppleDeveloperContext | null>(null);
|
||||||
const [savedAccounts, setSavedAccounts] = useState<StoredAccountSummary[]>(() => loadStoredAccountList());
|
const [savedAccounts, setSavedAccounts] = useState<StoredAccountSummary[]>(() => loadStoredAccountList());
|
||||||
const accountContextMapRef = useRef<Map<string, AppleDeveloperContext>>(new Map());
|
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
|
// Login modal
|
||||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Device
|
// Device
|
||||||
const [pairedDeviceInfo, setPairedDeviceInfo] = useState<PairedDeviceInfo | null>(null);
|
const [pairedDeviceInfo, setPairedDeviceInfo] = useState<PairedDeviceInfo | null>(null);
|
||||||
@@ -114,9 +118,12 @@ export function App() {
|
|||||||
const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED);
|
const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED);
|
||||||
const [twoFactor, setTwoFactor] = useState<{
|
const [twoFactor, setTwoFactor] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
submit: ((code: string) => void) | null;
|
ctx: TwoFactorContext | null;
|
||||||
error: string | 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
|
// Derived
|
||||||
const isWebUsbSupported = useMemo(() => {
|
const isWebUsbSupported = useMemo(() => {
|
||||||
@@ -136,7 +143,7 @@ export function App() {
|
|||||||
|
|
||||||
const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
|
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;
|
const trustOpen = trustState !== TRUST_MODAL_CLOSED;
|
||||||
|
|
||||||
// ---- log + progress plumbing ----
|
// ---- log + progress plumbing ----
|
||||||
@@ -183,6 +190,7 @@ export function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const restored = restorePersistedAccountContexts();
|
const restored = restorePersistedAccountContexts();
|
||||||
accountContextMapRef.current = restored;
|
accountContextMapRef.current = restored;
|
||||||
|
setAccountCacheVersion((v) => v + 1);
|
||||||
const summary = loadStoredAccountSummary();
|
const summary = loadStoredAccountSummary();
|
||||||
if (summary) {
|
if (summary) {
|
||||||
const active = restored.get(accountKey(summary.appleId, summary.teamId));
|
const active = restored.get(accountKey(summary.appleId, summary.teamId));
|
||||||
@@ -332,13 +340,17 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBusy((prev) => ({ ...prev, loginSign: true }));
|
setBusy((prev) => ({ ...prev, loginSign: true }));
|
||||||
|
twoFactorCancelledRef.current = false;
|
||||||
|
setLoginError(null);
|
||||||
let twoFactorOpened = false;
|
let twoFactorOpened = false;
|
||||||
let twoFactorErrorShown = false;
|
let twoFactorErrorShown = false;
|
||||||
try {
|
try {
|
||||||
saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId);
|
saveText(APPLE_ID_STORAGE_KEY, trimmedAppleId);
|
||||||
addLog('login: initializing anisette...');
|
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);
|
setAnisetteData(nextAnisette);
|
||||||
setAnisetteProvisioned(true);
|
setAnisetteProvisioned(true);
|
||||||
addLog('login: anisette ready, authenticating...');
|
addLog('login: anisette ready, authenticating...');
|
||||||
@@ -348,17 +360,19 @@ export function App() {
|
|||||||
password,
|
password,
|
||||||
anisetteData: nextAnisette,
|
anisetteData: nextAnisette,
|
||||||
log: addLog,
|
log: addLog,
|
||||||
onTwoFactorRequired: (submit) => {
|
onTwoFactorRequired: (ctx) => {
|
||||||
twoFactorOpened = true;
|
twoFactorOpened = true;
|
||||||
setTwoFactor({ open: true, submit, error: null });
|
setTwoFactor({ open: true, ctx, error: null });
|
||||||
addLog('login: 2FA required, opening verification dialog');
|
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}`);
|
addLog(`login: authenticated as ${context.appleId} / ${context.team.identifier}`);
|
||||||
const key = accountKey(context.appleId, context.team.identifier);
|
const key = accountKey(context.appleId, context.team.identifier);
|
||||||
accountContextMapRef.current.set(key, context);
|
accountContextMapRef.current.set(key, context);
|
||||||
|
setAccountCacheVersion((v) => v + 1);
|
||||||
persistAccountSummary(context);
|
persistAccountSummary(context);
|
||||||
persistAccountSession(context, nextAnisette);
|
persistAccountSession(context, nextAnisette);
|
||||||
addLog('login: session persisted');
|
addLog('login: session persisted');
|
||||||
@@ -371,36 +385,43 @@ export function App() {
|
|||||||
addLog('login: done, navigating to sign page');
|
addLog('login: done, navigating to sign page');
|
||||||
navigateToPage('sign');
|
navigateToPage('sign');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('[login] caught error:', error);
|
||||||
const msg = formatError(error);
|
const msg = formatError(error);
|
||||||
addLog(`login failed: ${msg}`);
|
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 }));
|
setTwoFactor((prev) => ({ ...prev, error: msg }));
|
||||||
twoFactorErrorShown = true;
|
twoFactorErrorShown = true;
|
||||||
|
} else if (!twoFactorOpened) {
|
||||||
|
// Pre-2FA failure (wrong password, rate limit, etc.) — show in login modal.
|
||||||
|
setLoginError(msg);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!twoFactorErrorShown) {
|
if (!twoFactorErrorShown) {
|
||||||
setTwoFactor({ open: false, submit: null, error: null });
|
setTwoFactor({ open: false, ctx: null, error: null });
|
||||||
}
|
}
|
||||||
setBusy((prev) => ({ ...prev, loginSign: false }));
|
setBusy((prev) => ({ ...prev, loginSign: false }));
|
||||||
}
|
}
|
||||||
}, [addLog, anisetteData, appleId, clearPrepared, navigateToPage, password]);
|
}, [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 handleTwoFactorCancel = useCallback(() => {
|
||||||
const submit = twoFactor.submit;
|
const ctx = twoFactor.ctx;
|
||||||
setTwoFactor({ open: false, submit: null, error: null });
|
twoFactorCancelledRef.current = true;
|
||||||
if (submit) {
|
setTwoFactor({ open: false, ctx: null, error: null });
|
||||||
|
if (ctx) {
|
||||||
addLog('login: 2FA canceled');
|
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 ----
|
// ---- sign flow ----
|
||||||
const handleSign = useCallback(async () => {
|
const handleSign = useCallback(async () => {
|
||||||
@@ -534,6 +555,7 @@ export function App() {
|
|||||||
// Remove from session map
|
// Remove from session map
|
||||||
removeStoredAccountSession(summary.appleId, summary.teamId);
|
removeStoredAccountSession(summary.appleId, summary.teamId);
|
||||||
accountContextMapRef.current.delete(key);
|
accountContextMapRef.current.delete(key);
|
||||||
|
setAccountCacheVersion((v) => v + 1);
|
||||||
|
|
||||||
// Remove from account list
|
// Remove from account list
|
||||||
const list = loadStoredAccountList().filter(
|
const list = loadStoredAccountList().filter(
|
||||||
@@ -598,7 +620,7 @@ export function App() {
|
|||||||
<main className="min-h-screen bg-bg">
|
<main className="min-h-screen bg-bg">
|
||||||
<Header currentPage={currentPage} onNavigate={navigateToPage} />
|
<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' ? (
|
{currentPage === 'login' ? (
|
||||||
<LoginPage
|
<LoginPage
|
||||||
loggedIn={!!loginContext}
|
loggedIn={!!loginContext}
|
||||||
@@ -642,14 +664,15 @@ export function App() {
|
|||||||
|
|
||||||
<LoginModal
|
<LoginModal
|
||||||
open={loginModalOpen}
|
open={loginModalOpen}
|
||||||
onClose={() => setLoginModalOpen(false)}
|
onClose={() => { setLoginModalOpen(false); setLoginError(null); }}
|
||||||
appleId={appleId}
|
appleId={appleId}
|
||||||
password={password}
|
password={password}
|
||||||
busyLoginSign={busy.loginSign}
|
busyLoginSign={busy.loginSign}
|
||||||
canSubmit={loginCanSubmit}
|
canSubmit={loginCanSubmit}
|
||||||
onAppleIdChange={setAppleId}
|
error={loginError}
|
||||||
|
onAppleIdChange={(v) => { setAppleId(v); setLoginError(null); }}
|
||||||
onAppleIdBlur={handleAppleIdBlur}
|
onAppleIdBlur={handleAppleIdBlur}
|
||||||
onPasswordChange={setPassword}
|
onPasswordChange={(v) => { setPassword(v); setLoginError(null); }}
|
||||||
onSubmit={handleLogin}
|
onSubmit={handleLogin}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -668,9 +691,10 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
<TwoFactorModal
|
<TwoFactorModal
|
||||||
open={twoFactor.open}
|
open={twoFactor.open}
|
||||||
onSubmit={handleTwoFactorSubmit}
|
ctx={twoFactor.ctx}
|
||||||
onCancel={handleTwoFactorCancel}
|
onCancel={handleTwoFactorCancel}
|
||||||
serverError={twoFactor.error}
|
serverError={twoFactor.error}
|
||||||
|
onRetry={handleTwoFactorRetry}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,11 +57,29 @@ export interface AppleDeveloperContext {
|
|||||||
devices: Device[];
|
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 {
|
export interface AppleDeveloperLoginRequest {
|
||||||
anisetteData: AnisetteData;
|
anisetteData: AnisetteData;
|
||||||
credentials: AppleSigningCredentials;
|
credentials: AppleSigningCredentials;
|
||||||
onLog?: (message: string) => void;
|
onLog?: (message: string) => void;
|
||||||
onTwoFactorRequired?: (submitCode: (code: string) => void) => void;
|
onTwoFactorRequired?: (ctx: TwoFactorContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppleSigningWithContextRequest {
|
export interface AppleSigningWithContextRequest {
|
||||||
@@ -101,8 +119,348 @@ interface AltsignModule {
|
|||||||
}): Promise<{ data: Uint8Array }>;
|
}): 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;
|
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.
|
||||||
|
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<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
|
* Keep `altsign.js` and its transitive `zsign-wasm` bundle out of the initial
|
||||||
@@ -117,14 +475,17 @@ async function loadAltsignModule(): Promise<AltsignModule> {
|
|||||||
return await altsignModulePromise;
|
return await altsignModulePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAppleApi(): Promise<AppleAPI> {
|
async function getAppleApi(log?: (msg: string) => void): Promise<{ api: AppleAPI; fetch: AltsignFetch }> {
|
||||||
if (appleApiInstance) {
|
const uiLog = log ?? (() => undefined);
|
||||||
return appleApiInstance;
|
|
||||||
}
|
|
||||||
const { AppleAPI, Fetch } = await loadAltsignModule();
|
const { AppleAPI, Fetch } = await loadAltsignModule();
|
||||||
const appleFetch = new Fetch(initLibcurl, async (url, options) => {
|
if (!appleFetchInstance) {
|
||||||
|
const doFetch = async (url: string, options: {
|
||||||
|
method?: string;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
body?: BodyInit | null;
|
||||||
|
}): Promise<Response> => {
|
||||||
const libcurl = requireLibcurl();
|
const libcurl = requireLibcurl();
|
||||||
const response = await libcurl.fetch(url, {
|
const libcurlOpts = {
|
||||||
method: options.method,
|
method: options.method,
|
||||||
headers: options.headers,
|
headers: options.headers,
|
||||||
body: options.body,
|
body: options.body,
|
||||||
@@ -132,11 +493,47 @@ async function getAppleApi(): Promise<AppleAPI> {
|
|||||||
insecure: true,
|
insecure: true,
|
||||||
verbose: 4,
|
verbose: 4,
|
||||||
_libcurl_http_version: 1.1,
|
_libcurl_http_version: 1.1,
|
||||||
} as never);
|
} 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;
|
return response;
|
||||||
});
|
};
|
||||||
appleApiInstance = new AppleAPI(appleFetch);
|
const f = new Fetch(initLibcurl, doFetch);
|
||||||
return appleApiInstance;
|
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> {
|
export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginRequest): Promise<AppleDeveloperContext> {
|
||||||
@@ -149,24 +546,56 @@ export async function loginAppleDeveloperAccount(request: AppleDeveloperLoginReq
|
|||||||
const log = request.onLog ?? (() => undefined);
|
const log = request.onLog ?? (() => undefined);
|
||||||
log(`Login stage: authenticating Apple account ${maskEmail(appleId)}...`);
|
log(`Login stage: authenticating Apple account ${maskEmail(appleId)}...`);
|
||||||
|
|
||||||
const api = await getAppleApi();
|
// Reset the shared fetch instance so each login attempt starts with a fresh
|
||||||
const { session } = await api.authenticate(
|
// libcurl TCP connection — prevents state bleed between accounts and between
|
||||||
appleId,
|
// a cancelled-then-retried login on the same account.
|
||||||
password,
|
appleFetchInstance = null;
|
||||||
request.anisetteData,
|
|
||||||
(submitCode: (code: string) => void) => {
|
const verificationCallback = (submitCode: (code: string) => void) => {
|
||||||
if (!request.onTwoFactorRequired) {
|
if (!request.onTwoFactorRequired) {
|
||||||
throw new Error('2FA required but no in-page handler provided');
|
throw new Error('2FA required but no in-page handler provided');
|
||||||
}
|
}
|
||||||
request.onTwoFactorRequired((code) => {
|
// The patched handleTwoFactor passes a TwoFactorContext; the raw altsign.js
|
||||||
const normalized = code.trim();
|
// VerificationHandler signature still receives `submitCode` as first arg,
|
||||||
if (normalized.length === 0) {
|
// but our patch replaces that with the full context object.
|
||||||
throw new Error('2FA code is required');
|
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.');
|
||||||
|
}
|
||||||
|
throw reauth2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
submitCode(normalized);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
log('Login stage: fetching team/certificates/devices...');
|
log('Login stage: fetching team/certificates/devices...');
|
||||||
const team = await api.fetchTeam(session);
|
const team = await api.fetchTeam(session);
|
||||||
@@ -191,7 +620,7 @@ export async function refreshAppleDeveloperContext(
|
|||||||
onLog?: (message: string) => void,
|
onLog?: (message: string) => void,
|
||||||
): Promise<AppleDeveloperContext> {
|
): Promise<AppleDeveloperContext> {
|
||||||
const log = onLog ?? (() => undefined);
|
const log = onLog ?? (() => undefined);
|
||||||
const api = await getAppleApi();
|
const { api } = await getAppleApi();
|
||||||
log('Signing stage: refreshing team/certificates/devices...');
|
log('Signing stage: refreshing team/certificates/devices...');
|
||||||
const team = await api.fetchTeam(context.session);
|
const team = await api.fetchTeam(context.session);
|
||||||
const [certificates, devices] = await Promise.all([
|
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');
|
throw new Error('Cannot sign IPA: bundle identifier is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = await getAppleApi();
|
const { api } = await getAppleApi();
|
||||||
const team = context.team;
|
const team = context.team;
|
||||||
onLog(`Signing stage: using team ${team.identifier} (${team.name}).`);
|
onLog(`Signing stage: using team ${team.identifier} (${team.name}).`);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface LoginModalProps {
|
|||||||
password: string;
|
password: string;
|
||||||
busyLoginSign: boolean;
|
busyLoginSign: boolean;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
|
error?: string | null;
|
||||||
onAppleIdChange: (value: string) => void;
|
onAppleIdChange: (value: string) => void;
|
||||||
onAppleIdBlur: () => void;
|
onAppleIdBlur: () => void;
|
||||||
onPasswordChange: (value: string) => void;
|
onPasswordChange: (value: string) => void;
|
||||||
@@ -22,6 +23,7 @@ export function LoginModal({
|
|||||||
password,
|
password,
|
||||||
busyLoginSign,
|
busyLoginSign,
|
||||||
canSubmit,
|
canSubmit,
|
||||||
|
error,
|
||||||
onAppleIdChange,
|
onAppleIdChange,
|
||||||
onAppleIdBlur,
|
onAppleIdBlur,
|
||||||
onPasswordChange,
|
onPasswordChange,
|
||||||
@@ -31,7 +33,7 @@ export function LoginModal({
|
|||||||
|
|
||||||
return (
|
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="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">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<h2 className="text-[18px] font-semibold tracking-tight text-ink">Add Account</h2>
|
<h2 className="text-[18px] font-semibold tracking-tight text-ink">Add Account</h2>
|
||||||
<button
|
<button
|
||||||
@@ -83,24 +85,31 @@ export function LoginModal({
|
|||||||
<p className="text-[11.5px] text-muted">
|
<p className="text-[11.5px] text-muted">
|
||||||
Your credentials are stored locally in this browser and are sent directly to Apple.
|
Your credentials are stored locally in this browser and are sent directly to Apple.
|
||||||
</p>
|
</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.
|
Verify that you trust the server hosting this page. A compromised server can intercept your credentials.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex justify-end">
|
<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
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
busy={busyLoginSign}
|
busy={busyLoginSign}
|
||||||
busyLabel="Signing In…"
|
busyLabel="Signing In…"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
className="min-w-[140px]"
|
className="min-w-35"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,122 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Modal } from './ui/Modal';
|
import { Modal } from './ui/Modal';
|
||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
|
import type { TrustedPhoneNumber, TwoFactorContext } from '../apple-signing';
|
||||||
|
|
||||||
|
type Mode = 'device' | 'sms';
|
||||||
|
|
||||||
interface TwoFactorModalProps {
|
interface TwoFactorModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onSubmit: (code: string) => void;
|
ctx: TwoFactorContext | null;
|
||||||
onCancel: () => void;
|
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;
|
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 [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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
const displayError = serverError || error;
|
|
||||||
|
|
||||||
|
const displayError = serverError || localError;
|
||||||
|
const phones = ctx?.trustedPhoneNumbers ?? [];
|
||||||
|
|
||||||
|
// Reset state on open/close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
setMode('device');
|
||||||
setCode('');
|
setCode('');
|
||||||
setError(null);
|
setLocalError(null);
|
||||||
|
setSmsSent(false);
|
||||||
|
setSmsBusy(false);
|
||||||
|
setVerifyBusy(false);
|
||||||
|
setSelectedPhone(phones.length > 0 ? phones[0] : null);
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
inputRef.current?.select();
|
inputRef.current?.select();
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open]);
|
}, [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();
|
const trimmed = code.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
setError('Please enter verification code.');
|
setLocalError('Please enter the verification code.');
|
||||||
return;
|
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 (
|
return (
|
||||||
@@ -42,8 +124,11 @@ export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFac
|
|||||||
<h2 id="two-factor-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
<h2 id="two-factor-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
||||||
Two-Factor Authentication
|
Two-Factor Authentication
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
{mode === 'device' ? (
|
||||||
|
<>
|
||||||
<p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
|
<p className="mt-1.5 text-[13px] leading-[1.55] text-muted">
|
||||||
Enter the verification code from your trusted Apple device.
|
Enter the verification code from your trusted Apple device or Mac.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
|
<label htmlFor="two-factor-code" className="mt-5 mb-1.5 block text-[12.5px] font-medium text-muted">
|
||||||
@@ -59,27 +144,132 @@ export function TwoFactorModal({ open, onSubmit, onCancel, serverError }: TwoFac
|
|||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
|
className="field-input font-mono text-center text-[18px] tracking-[0.3em]"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => {
|
onChange={(e) => { setCode(e.target.value); setLocalError(null); }}
|
||||||
setCode(e.target.value);
|
onKeyDown={handleKeyDown}
|
||||||
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>
|
<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">
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
<Button variant="ghost" onClick={onCancel}>
|
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||||
Cancel
|
{serverError ? (
|
||||||
|
<Button variant="primary" onClick={onRetry}>
|
||||||
|
Retry Login
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onClick={handleSubmit} disabled={!!serverError}>
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
busy={verifyBusy}
|
||||||
|
busyLabel="Verifying…"
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={verifyBusy}
|
||||||
|
>
|
||||||
Verify
|
Verify
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AnisetteData } from '../anisette-service';
|
import type { AnisetteData } from '../anisette-service';
|
||||||
import type { AppleDeveloperContext } from '../apple-signing';
|
import type { AppleDeveloperContext, TwoFactorContext } from '../apple-signing';
|
||||||
import { shortToken } from '../lib/ids';
|
import { shortToken } from '../lib/ids';
|
||||||
|
|
||||||
type AnisetteService = typeof import('../anisette-service');
|
type AnisetteService = typeof import('../anisette-service');
|
||||||
@@ -61,7 +61,7 @@ export interface LoginRequest {
|
|||||||
password: string;
|
password: string;
|
||||||
anisetteData: AnisetteData;
|
anisetteData: AnisetteData;
|
||||||
log: (msg: string) => void;
|
log: (msg: string) => void;
|
||||||
onTwoFactorRequired: (submit: (code: string) => void) => void;
|
onTwoFactorRequired: (ctx: TwoFactorContext) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginAccount(req: LoginRequest): Promise<AppleDeveloperContext> {
|
export async function loginAccount(req: LoginRequest): Promise<AppleDeveloperContext> {
|
||||||
|
|||||||
1177
sms-2fa.patch
Normal file
1177
sms-2fa.patch
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user