Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .cortex/design-docs/auth-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,21 @@ click path, control shape, sanitized redirect metadata, and token-storage
presence under the `icloud-oauth` scope; debug from those entries before
rewriting the provider flow.

When reproducing production auth from the shell, include the browser origin:

```sh
curl -H 'Origin: https://nokey.sh' \
'https://api.apple-cloudkit.com/database/1/iCloud.metasecret.project.com/production/public/users/current?ckAPIToken=...'
```

Without the `Origin` header, Apple may report `AUTHENTICATION_FAILED` even when
the same token is valid for the registered web origin. With the registered
origin, unauthenticated production requests should return
`AUTHENTICATION_REQUIRED` plus a `redirectURL`. If CloudKit JS wraps that
challenge as `UNKNOWN_ERROR` after the real Apple click, Nook falls back to the
Web Services challenge, opens the returned Apple sign-in URL, and listens for
the `ckWebAuthToken` postMessage.

Preferred future options:

| Option | Summary | Trade-off |
Expand Down
173 changes: 169 additions & 4 deletions nook-app/nook-web/src/lib/icloud-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ type CloudKitAuthErrorDetails = {
uuidPresent?: boolean
}

type CloudKitAuthChallenge = {
reason?: string
redirectURL?: string
serverErrorCode?: string
uuid?: string
}

type CloudKitContainer = {
setUpAuth: (options?: {
grabAuthToken?: boolean
Expand All @@ -82,8 +89,14 @@ type CloudKitGlobal = {
apiTokenAuth: {
apiToken: string
persist: boolean
signInButton: { id: string }
signOutButton: { id: string }
signInButton: {
id: string
theme?: 'black' | 'white' | 'white-with-outline'
}
signOutButton: {
id: string
theme?: 'black' | 'white' | 'white-with-outline'
}
}
}>
services?: {
Expand All @@ -107,6 +120,26 @@ function tokenDiagnostics(token: string | undefined): {
}
}

function sanitizedURLDiagnostics(url: string | undefined): {
present: boolean
origin?: string
pathname?: string
} {
if (!url) {
return { present: false }
}
try {
const parsed = new URL(url)
return {
present: true,
origin: parsed.origin,
pathname: parsed.pathname,
}
} catch {
return { present: true }
}
}

function currentBrowserDiagnostics(): {
origin: string
hostname: string
Expand Down Expand Up @@ -652,8 +685,8 @@ export async function initICloudAuth(): Promise<void> {
apiTokenAuth: {
apiToken: ICLOUD_API_TOKEN,
persist: true,
signInButton: { id: CLOUDKIT_SIGN_IN_BUTTON_ID },
signOutButton: { id: CLOUDKIT_SIGN_OUT_BUTTON_ID },
signInButton: { id: CLOUDKIT_SIGN_IN_BUTTON_ID, theme: 'black' },
signOutButton: { id: CLOUDKIT_SIGN_OUT_BUTTON_ID, theme: 'black' },
},
},
],
Expand Down Expand Up @@ -779,6 +812,129 @@ function requireStoredWebAuthToken(
: { accessToken: token }
}

function cloudKitCurrentUserURL(): string {
const container = encodeURIComponent(ICLOUD_CONTAINER_ID)
const environment = encodeURIComponent(ICLOUD_ENVIRONMENT)
const apiToken = encodeURIComponent(ICLOUD_API_TOKEN)
return `https://api.apple-cloudkit.com/database/1/${container}/${environment}/public/users/current?ckAPIToken=${apiToken}`
}

async function fetchCloudKitWebAuthChallenge(): Promise<CloudKitAuthChallenge> {
const response = await fetch(cloudKitCurrentUserURL(), {
method: 'GET',
headers: { Accept: 'application/json' },
})
const body = (await response
.json()
.catch(() => ({}))) as CloudKitAuthChallenge
log.info('CloudKit direct web auth challenge received', {
status: response.status,
ok: response.ok,
serverErrorCode: body.serverErrorCode,
reason: body.reason,
redirectURL: sanitizedURLDiagnostics(body.redirectURL),
uuidPresent: Boolean(body.uuid),
})
if (body.serverErrorCode === 'AUTHENTICATION_REQUIRED' && body.redirectURL) {
return body
}
if (body.serverErrorCode === 'AUTHENTICATION_FAILED') {
throw new Error(
'Apple rejected the iCloud API token for this container. Check the CloudKit production API token and allowed origin https://nokey.sh.',
)
}
throw new Error(
body.reason ??
body.serverErrorCode ??
`Apple CloudKit auth challenge failed with HTTP ${response.status}.`,
)
}
Comment on lines +815 to +851

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Challenge fetch has no timeout guard.

fetchCloudKitWebAuthChallenge awaits fetch() with no timeout/abort. The timeoutMs passed into requestDirectCloudKitWebAuthToken only guards the popup/message-listening Promise created afterward (lines 895-935); if this initial fetch stalls (slow/broken network, hung connection), the whole fallback hangs indefinitely with no way for waitForCloudKitSignIn's caller to recover via the configured timeout.

🔧 Proposed fix using AbortController
-async function fetchCloudKitWebAuthChallenge(): Promise<CloudKitAuthChallenge> {
-  const response = await fetch(cloudKitCurrentUserURL(), {
-    method: 'GET',
-    headers: { Accept: 'application/json' },
-  })
+async function fetchCloudKitWebAuthChallenge(
+  timeoutMs: number,
+): Promise<CloudKitAuthChallenge> {
+  const controller = new AbortController()
+  const timer = setTimeout(() => controller.abort(), timeoutMs)
+  const response = await fetch(cloudKitCurrentUserURL(), {
+    method: 'GET',
+    headers: { Accept: 'application/json' },
+    signal: controller.signal,
+  }).finally(() => clearTimeout(timer))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function cloudKitCurrentUserURL(): string {
const container = encodeURIComponent(ICLOUD_CONTAINER_ID)
const environment = encodeURIComponent(ICLOUD_ENVIRONMENT)
const apiToken = encodeURIComponent(ICLOUD_API_TOKEN)
return `https://api.apple-cloudkit.com/database/1/${container}/${environment}/public/users/current?ckAPIToken=${apiToken}`
}
async function fetchCloudKitWebAuthChallenge(): Promise<CloudKitAuthChallenge> {
const response = await fetch(cloudKitCurrentUserURL(), {
method: 'GET',
headers: { Accept: 'application/json' },
})
const body = (await response
.json()
.catch(() => ({}))) as CloudKitAuthChallenge
log.info('CloudKit direct web auth challenge received', {
status: response.status,
ok: response.ok,
serverErrorCode: body.serverErrorCode,
reason: body.reason,
redirectURL: sanitizedURLDiagnostics(body.redirectURL),
uuidPresent: Boolean(body.uuid),
})
if (body.serverErrorCode === 'AUTHENTICATION_REQUIRED' && body.redirectURL) {
return body
}
if (body.serverErrorCode === 'AUTHENTICATION_FAILED') {
throw new Error(
'Apple rejected the iCloud API token for this container. Check the CloudKit production API token and allowed origin https://nokey.sh.',
)
}
throw new Error(
body.reason ??
body.serverErrorCode ??
`Apple CloudKit auth challenge failed with HTTP ${response.status}.`,
)
}
function cloudKitCurrentUserURL(): string {
const container = encodeURIComponent(ICLOUD_CONTAINER_ID)
const environment = encodeURIComponent(ICLOUD_ENVIRONMENT)
const apiToken = encodeURIComponent(ICLOUD_API_TOKEN)
return `https://api.apple-cloudkit.com/database/1/${container}/${environment}/public/users/current?ckAPIToken=${apiToken}`
}
async function fetchCloudKitWebAuthChallenge(
timeoutMs: number,
): Promise<CloudKitAuthChallenge> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const response = await fetch(cloudKitCurrentUserURL(), {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal,
}).finally(() => clearTimeout(timer))
const body = (await response
.json()
.catch(() => ({}))) as CloudKitAuthChallenge
log.info('CloudKit direct web auth challenge received', {
status: response.status,
ok: response.ok,
serverErrorCode: body.serverErrorCode,
reason: body.reason,
redirectURL: sanitizedURLDiagnostics(body.redirectURL),
uuidPresent: Boolean(body.uuid),
})
if (body.serverErrorCode === 'AUTHENTICATION_REQUIRED' && body.redirectURL) {
return body
}
if (body.serverErrorCode === 'AUTHENTICATION_FAILED') {
throw new Error(
'Apple rejected the iCloud API token for this container. Check the CloudKit production API token and allowed origin https://nokey.sh.',
)
}
throw new Error(
body.reason ??
body.serverErrorCode ??
`Apple CloudKit auth challenge failed with HTTP ${response.status}.`,
)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nook-app/nook-web/src/lib/icloud-oauth.ts` around lines 815 - 851, The
initial CloudKit auth challenge request can hang indefinitely because
fetchCloudKitWebAuthChallenge() performs a bare fetch with no abort path. Add
timeout handling with AbortController in fetchCloudKitWebAuthChallenge, and
thread the timeoutMs from requestDirectCloudKitWebAuthToken into that call so
the direct-web fallback fails fast when the network stalls. Make sure the
timeout is enforced before the popup/message-listening Promise path continues,
and keep the existing error handling/logging around the CloudKit challenge
response.


function webAuthTokenFromMessageData(data: unknown): string | undefined {
if (typeof data === 'string') {
try {
return webAuthTokenFromMessageData(JSON.parse(data))
} catch {
return undefined
}
}
if (data == undefined || typeof data !== 'object') {
return undefined
}
const record = data as Record<string, unknown>
for (const key of ['ckWebAuthToken', 'webAuthToken', 'authToken', 'token']) {
const candidate = record[key]
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim()
}
}
return undefined
}

async function requestDirectCloudKitWebAuthToken(
timeoutMs = ICLOUD_SIGN_IN_TIMEOUT_MS,
): Promise<string> {
log.info('CloudKit direct web auth fallback started', {
timeoutMs,
browser: currentBrowserDiagnostics(),
})
const challenge = await fetchCloudKitWebAuthChallenge()
const authWindow = window.open(
challenge.redirectURL,
'nook-icloud-auth',
'popup,width=520,height=720',
)
if (!authWindow) {
log.warn('CloudKit direct web auth popup blocked', {
redirectURL: sanitizedURLDiagnostics(challenge.redirectURL),
})
throw new Error(
'Apple sign-in popup was blocked. Allow popups and try again.',
)
}
return new Promise((resolve, reject) => {
Comment on lines +874 to +895

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Does calling window.open() after multiple chained awaits get blocked in Safari and Chrome even with recent user activation?

💡 Result:

Yes, calling window.open after multiple chained awaits is frequently blocked in both Safari and Chrome [1][2][3]. While modern browser "User Activation" models (like Chrome's User Activation v2) aim to propagate activation states through some asynchronous operations, they are not universally reliable for window.open [2][4]. Browser vendors maintain strict security policies to prevent spam, and they often require window.open to be called synchronously within the same execution context as the user gesture (e.g., a click event) to reliably bypass popup blockers [2][5]. The common technical constraints and workarounds include: 1. The "Await" Problem: When you await an asynchronous operation (like a network request), the JavaScript execution is paused and resumed outside the original synchronous call stack of the user gesture [2]. Even if the browser's activation token is technically still active, many implementations will treat the subsequent window.open call as untrusted if it is not directly linked to the user's action [2][3]. 2. The Standard Workaround: The widely accepted solution for this behavior is to open a blank window synchronously at the moment of the user gesture, and then update its location asynchronously once your data is ready [1][5]. Example of the safe pattern: const newWindow = window.open('', '_blank'); // Open synchronously during click try { const data = await fetch('/some-api'); // Perform async work const url = await data.json; if (newWindow) { newWindow.location.href = url; // Redirect the existing window } } catch (e) { newWindow?.close; // Clean up if operation fails window.location.href = errorUrl; // Fallback to current tab } This approach ensures the initial window creation is tied to the user gesture, which browsers permit, while the subsequent navigation is allowed because the window context is already established [1][5]. Using this pattern prevents the silent failures that occur when simply calling window.open after an await [1][2].

Citations:


Open the popup synchronously before the awaits
window.open() runs after await fetchCloudKitWebAuthChallenge(), so the user activation is no longer guaranteed and Safari/Chrome can block the popup. Open a blank placeholder window from the original click handler, then set location.href once the challenge URL is ready.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nook-app/nook-web/src/lib/icloud-oauth.ts` around lines 874 - 895, Open the
auth popup synchronously before any awaits in requestDirectCloudKitWebAuthToken,
because the current await fetchCloudKitWebAuthChallenge() can clear user
activation and cause Safari/Chrome to block window.open(). Adjust the flow so a
blank placeholder window is opened immediately, then after the challenge is
fetched set that window’s location to challenge.redirectURL, and keep the
existing popup-blocked handling using
currentBrowserDiagnostics/sanitizedURLDiagnostics.

let settled = false
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
const cleanup = () => {
settled = true
window.removeEventListener('message', handleMessage)
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}
const handleMessage = (event: MessageEvent<unknown>) => {
const token = webAuthTokenFromMessageData(event.data)
log.info('CloudKit direct web auth message received', {
origin: event.origin,
token: tokenDiagnostics(token),
})
if (!token || settled) {
return
}
cleanup()
storeCloudKitWebAuthToken(ICLOUD_CONTAINER_ID, token)
try {
authWindow.close()
} catch {
// Ignore browser-specific popup close failures.
}
resolve(token)
}
window.addEventListener('message', handleMessage)
Comment on lines +905 to +923

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant symbols and files.
rg -n "webAuthTokenFromMessageData|storeCloudKitWebAuthToken|handleMessage|addEventListener\\('message'|event.origin|redirectURL|CloudKit direct web auth" nook-app/nook-web/src/lib/icloud-oauth.ts nook-app/nook-web/src/lib -S

# Show the relevant section around the listener.
sed -n '840,980p' nook-app/nook-web/src/lib/icloud-oauth.ts

# Show surrounding declarations for redirect/origin computation if present.
sed -n '1,220p' nook-app/nook-web/src/lib/icloud-oauth.ts

Repository: meta-secret/nook

Length of output: 17379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the challenge-fetching flow and any origin-related handling.
sed -n '800,900p' nook-app/nook-web/src/lib/icloud-oauth.ts
sed -n '520,590p' nook-app/nook-web/src/lib/icloud-oauth.ts

# Search for CloudKit redirect/origin assumptions elsewhere in the repo.
rg -n "allowed origin|redirectURLOrigin|redirectURLPathname|CloudKit direct web auth|postMessage\\(|message listener|ckWebAuthToken|webAuthToken" nook-app -S

Repository: meta-secret/nook

Length of output: 11540


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the iCloud config docs and the direct web auth tests.
sed -n '1,120p' nook-app/nook-web/src/lib/icloud-oauth-config.ts
sed -n '320,390p' nook-app/nook-web/tests/unit/lib/icloud-oauth.test.ts

# Show the direct-web-auth related test block with surrounding context.
sed -n '300,380p' nook-app/nook-web/tests/unit/lib/icloud-oauth.test.ts

Repository: meta-secret/nook

Length of output: 5731


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the iCloud config docs and the direct web auth tests.
sed -n '1,120p' nook-app/nook-web/src/lib/icloud-oauth-config.ts
sed -n '300,390p' nook-app/nook-web/tests/unit/lib/icloud-oauth.test.ts

Repository: meta-secret/nook

Length of output: 3673


Validate the CloudKit popup origin before accepting auth messages.
handleMessage trusts any payload that looks like a token; a window with a reference to this page can inject a forged auth token that gets stored and treated as a successful sign-in. Restrict this handler to the expected Apple auth origin used by the popup (https://idmsa.apple.com in the current flow).

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 906-909: Avoid logging sensitive data
Context: log.info('CloudKit direct web auth message received', {
origin: event.origin,
token: tokenDiagnostics(token),
})
Note: [CWE-532] Insertion of Sensitive Information into Log File.

(log-sensitive-data-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nook-app/nook-web/src/lib/icloud-oauth.ts` around lines 905 - 923, The
CloudKit auth message handler in handleMessage currently accepts any window
message that parses as a token, so add an origin check before token handling and
ignore messages not from the expected Apple auth origin. Use the existing
event.origin in the message listener around webAuthTokenFromMessageData and only
proceed with cleanup, storeCloudKitWebAuthToken, authWindow.close, and resolve
when the origin matches https://idmsa.apple.com.

timeoutId = setTimeout(() => {
if (settled) {
return
}
cleanup()
log.warn('CloudKit direct web auth fallback timed out', {
timeoutMs,
storage: webAuthTokenStorageDiagnostics(),
})
reject(cloudKitSignInTimeoutError())
}, timeoutMs)
})
Comment on lines +924 to +935

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Popup window is left open after a timeout failure.

The timeout handler calls cleanup() (removes listener, clears timer) but never calls authWindow.close(), unlike the success path (line 917). On timeout the Apple sign-in popup stays open indefinitely even though the promise has already rejected, leaving a stray/confusing window for the user.

🧹 Proposed fix
     timeoutId = setTimeout(() => {
       if (settled) {
         return
       }
       cleanup()
+      try {
+        authWindow.close()
+      } catch {
+        // Ignore browser-specific popup close failures.
+      }
       log.warn('CloudKit direct web auth fallback timed out', {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
timeoutId = setTimeout(() => {
if (settled) {
return
}
cleanup()
log.warn('CloudKit direct web auth fallback timed out', {
timeoutMs,
storage: webAuthTokenStorageDiagnostics(),
})
reject(cloudKitSignInTimeoutError())
}, timeoutMs)
})
timeoutId = setTimeout(() => {
if (settled) {
return
}
cleanup()
try {
authWindow.close()
} catch {
// Ignore browser-specific popup close failures.
}
log.warn('CloudKit direct web auth fallback timed out', {
timeoutMs,
storage: webAuthTokenStorageDiagnostics(),
})
reject(cloudKitSignInTimeoutError())
}, timeoutMs)
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nook-app/nook-web/src/lib/icloud-oauth.ts` around lines 924 - 935, The
timeout branch in the CloudKit direct web auth flow leaves the Apple sign-in
popup open; update the timeout handler in the auth flow around the
cleanup/reject logic to also close the popup window just like the success path
does. Use the same auth window handle used by the sign-in promise, and make sure
it is closed before rejecting after timeout so the UI is cleaned up
consistently.

}

async function waitForCloudKitSignIn(
container: CloudKitContainer,
timeoutMs = ICLOUD_SIGN_IN_TIMEOUT_MS,
Expand All @@ -792,6 +948,7 @@ async function waitForCloudKitSignIn(
control: cloudKitSignInControlDiagnostics(),
})
const tokenPromise = waitForStoredWebAuthToken(timeoutMs)
let sawExpectedSignInFailure = false
const signInPromise = container
.whenUserSignsIn()
.then((userIdentity) => {
Expand All @@ -805,6 +962,7 @@ async function waitForCloudKitSignIn(
})
.catch((error: unknown) => {
if (isExpectedSignInSetupFailure(error)) {
sawExpectedSignInFailure = true
log.info('CloudKit sign-in callback waiting for web auth token', {
details: cloudKitAuthErrorDetails(error),
hasSignInMount: hasCloudKitSignInControl(),
Expand Down Expand Up @@ -835,6 +993,13 @@ async function waitForCloudKitSignIn(
})
return authSetupUserIdentity ?? {}
}
if (sawExpectedSignInFailure) {
await requestDirectCloudKitWebAuthToken(timeoutMs)
log.info('CloudKit sign-in succeeded through direct fallback', {
token: tokenDiagnostics(readStoredWebAuthToken()),
})
return authSetupUserIdentity ?? {}
}
await tokenPromise
log.info('CloudKit sign-in succeeded after token wait', {
signedIn: Boolean(authSetupUserIdentity),
Expand Down
88 changes: 88 additions & 0 deletions nook-app/nook-web/tests/unit/lib/icloud-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,94 @@ describe('icloud-oauth', () => {
})
})

it('falls back to CloudKit web auth redirect when CloudKit JS hides the auth challenge', async () => {
const setUpAuth = vi.fn().mockResolvedValue(undefined)
const whenUserSignsIn = vi.fn().mockRejectedValue({
_reason: 'UNKNOWN_ERROR',
})
vi.mocked(window.CloudKit!.getDefaultContainer).mockReturnValue({
setUpAuth,
whenUserSignsIn,
})
const close = vi.fn()
const open = vi.fn().mockReturnValue({ close })
vi.stubGlobal('open', open)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
uuid: 'challenge-id',
serverErrorCode: 'AUTHENTICATION_REQUIRED',
reason: 'request needs authorization',
redirectURL:
'https://idmsa.apple.com/IDMSWebAuth/auth?oauth_token=test',
}),
{ status: 421, headers: { 'content-type': 'application/json' } },
),
),
)

await prepareICloudSignInControl()
const pending = requestPreparedICloudWebAuthToken({
clickSignInControl: false,
signInTimeoutMs: 5000,
})

await vi.waitFor(() => {
expect(open).toHaveBeenCalledWith(
'https://idmsa.apple.com/IDMSWebAuth/auth?oauth_token=test',
'nook-icloud-auth',
'popup,width=520,height=720',
)
})
window.dispatchEvent(
new MessageEvent('message', {
origin: 'https://idmsa.apple.com',
data: { ckWebAuthToken: 'direct-web-auth-token' },
}),
)

await expect(pending).resolves.toEqual({
accessToken: 'direct-web-auth-token',
})
expect(close).toHaveBeenCalledOnce()
})

it('surfaces an invalid CloudKit API token from the direct auth challenge', async () => {
const setUpAuth = vi.fn().mockResolvedValue(undefined)
const whenUserSignsIn = vi.fn().mockRejectedValue({
_reason: 'UNKNOWN_ERROR',
})
vi.mocked(window.CloudKit!.getDefaultContainer).mockReturnValue({
setUpAuth,
whenUserSignsIn,
})
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
uuid: 'invalid-token-id',
serverErrorCode: 'AUTHENTICATION_FAILED',
reason:
'Authentication failed, please check you have the correct API Token for this container',
}),
{ status: 401, headers: { 'content-type': 'application/json' } },
),
),
)

await prepareICloudSignInControl()

await expect(
requestPreparedICloudWebAuthToken({
clickSignInControl: false,
signInTimeoutMs: 5000,
}),
).rejects.toThrow('Apple rejected the iCloud API token')
})

it('fails when CloudKit sign-in never completes', async () => {
const setUpAuth = vi.fn().mockResolvedValue(undefined)
const whenUserSignsIn = vi.fn().mockReturnValue(new Promise(() => {}))
Expand Down