Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ The backend proxies the bag endpoint via `GET /api/bag?guid=<deviceId>` using No
- IndexedDB for credential storage (via `idb`)
- `libcurl.js` (WASM) for browser-side TLS 1.3 via Mbed TLS — connects through Wisp protocol
- `appleRequest()` in `frontend/src/apple/request.ts` wraps `libcurl.fetch` for all Apple API calls and forces HTTP/1.1 (`_libcurl_http_version: 1.1`)
- Bag endpoint (`frontend/src/apple/bag.ts`) uses backend proxy (`/api/bag`) and falls back to `https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate` when `authenticateAccount` is missing or bag fetch fails
- Bag endpoint (`frontend/src/apple/bag.ts`) uses backend proxy (`/api/bag`) and falls back to the native fast auth endpoint `https://auth.itunes.apple.com/auth/v1/native/fast` when `authenticateAccount` is missing or bag fetch fails. It prefers the top-level `authenticateAccount` over the `urlBag` entry, and appends the `/fast` sub-path to any `auth.itunes.apple.com` endpoint that lacks it (the native auth flow requires `/fast`)
- Authentication (`frontend/src/apple/authenticate.ts`) resolves bag endpoint, then sets `guid` via URL query manipulation to avoid duplicate/malformed query parameters
- Plist build/parse (`frontend/src/apple/plist.ts`) uses native XML builder and browser-native `DOMParser`
- Cookie helper (`frontend/src/apple/cookies.ts`) — `extractAndMergeCookies(rawHeaders, existingCookies)` replaces the repeated extract-and-merge pattern across all Apple protocol files
Expand Down
27 changes: 18 additions & 9 deletions frontend/src/apple/bag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,19 @@ export interface BagOutput {
authURL: string;
}

// The native "fast" auth endpoint. Apple's newer bag responses point auth at
// auth.itunes.apple.com, and the native auth flow requires the /fast sub-path.
export const defaultAuthURL =
"https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate";
"https://auth.itunes.apple.com/auth/v1/native/fast";

// Apple's newer auth endpoint base (auth.itunes.apple.com) requires the /fast
// sub-path. Append it when missing so the native auth flow resolves correctly.
function normalizeAuthURL(url: string): string {
if (url.includes("auth.itunes.apple.com") && !url.endsWith("/fast")) {
return `${url}/fast`;
}
return url;
}

// Fetches the bag via the backend proxy.
// The backend fetches it using Node.js native HTTPS.
Expand All @@ -27,14 +38,12 @@ export async function fetchBag(deviceId: string): Promise<BagOutput> {
const xml = await resp.text();
const dict = parsePlist(xml) as Record<string, any>;

// authenticateAccount may be at top level or inside a urlBag dict
let authURL: string | undefined;
const urlBag = dict.urlBag as Record<string, any> | undefined;
if (urlBag) {
authURL = urlBag.authenticateAccount as string | undefined;
}
// authenticateAccount may be at top level or inside a urlBag dict.
// Prefer the top-level value, falling back to the urlBag entry.
let authURL = dict.authenticateAccount as string | undefined;
if (!authURL) {
authURL = dict.authenticateAccount as string | undefined;
const urlBag = dict.urlBag as Record<string, any> | undefined;
authURL = urlBag?.authenticateAccount as string | undefined;
}

if (!authURL) {
Expand All @@ -44,7 +53,7 @@ export async function fetchBag(deviceId: string): Promise<BagOutput> {
return { authURL: defaultAuthURL };
}

return { authURL };
return { authURL: normalizeAuthURL(authURL) };
} catch (error) {
console.warn(
`[Bag] Failed to fetch/parse bag, using default auth endpoint: ${
Expand Down
61 changes: 61 additions & 0 deletions frontend/tests/apple/bag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,67 @@ describe("apple/bag", () => {
);
});

it("prefers the top-level authenticateAccount over urlBag", async () => {
const xml = buildPlist({
authenticateAccount:
"https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate",
urlBag: {
authenticateAccount: "https://example.com/should-not-be-used",
},
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: async () => xml,
}),
);

const result = await fetchBag("aabbccddeeff");

expect(result.authURL).toBe(
"https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate",
);
});

it("appends /fast to auth.itunes.apple.com endpoints", async () => {
const xml = buildPlist({
authenticateAccount: "https://auth.itunes.apple.com/auth/v1/native",
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: async () => xml,
}),
);

const result = await fetchBag("aabbccddeeff");

expect(result.authURL).toBe(
"https://auth.itunes.apple.com/auth/v1/native/fast",
);
});

it("does not double-append /fast when already present", async () => {
const xml = buildPlist({
authenticateAccount: "https://auth.itunes.apple.com/auth/v1/native/fast",
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
text: async () => xml,
}),
);

const result = await fetchBag("aabbccddeeff");

expect(result.authURL).toBe(
"https://auth.itunes.apple.com/auth/v1/native/fast",
);
});

it("falls back when authenticateAccount is missing", async () => {
const xml = buildPlist({
urlBag: {
Expand Down