diff --git a/.cortex/design-docs/auth-providers.md b/.cortex/design-docs/auth-providers.md index a829e957b..57efbb11b 100644 --- a/.cortex/design-docs/auth-providers.md +++ b/.cortex/design-docs/auth-providers.md @@ -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 | diff --git a/nook-app/nook-web/src/lib/icloud-oauth.ts b/nook-app/nook-web/src/lib/icloud-oauth.ts index 4e0e8b626..2a6245eef 100644 --- a/nook-app/nook-web/src/lib/icloud-oauth.ts +++ b/nook-app/nook-web/src/lib/icloud-oauth.ts @@ -61,6 +61,13 @@ type CloudKitAuthErrorDetails = { uuidPresent?: boolean } +type CloudKitAuthChallenge = { + reason?: string + redirectURL?: string + serverErrorCode?: string + uuid?: string +} + type CloudKitContainer = { setUpAuth: (options?: { grabAuthToken?: boolean @@ -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?: { @@ -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 @@ -652,8 +685,8 @@ export async function initICloudAuth(): Promise { 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' }, }, }, ], @@ -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 { + 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 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 + 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 { + 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) => { + let settled = false + let timeoutId: ReturnType | undefined = undefined + const cleanup = () => { + settled = true + window.removeEventListener('message', handleMessage) + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + } + const handleMessage = (event: MessageEvent) => { + 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) + timeoutId = setTimeout(() => { + if (settled) { + return + } + cleanup() + log.warn('CloudKit direct web auth fallback timed out', { + timeoutMs, + storage: webAuthTokenStorageDiagnostics(), + }) + reject(cloudKitSignInTimeoutError()) + }, timeoutMs) + }) +} + async function waitForCloudKitSignIn( container: CloudKitContainer, timeoutMs = ICLOUD_SIGN_IN_TIMEOUT_MS, @@ -792,6 +948,7 @@ async function waitForCloudKitSignIn( control: cloudKitSignInControlDiagnostics(), }) const tokenPromise = waitForStoredWebAuthToken(timeoutMs) + let sawExpectedSignInFailure = false const signInPromise = container .whenUserSignsIn() .then((userIdentity) => { @@ -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(), @@ -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), diff --git a/nook-app/nook-web/tests/unit/lib/icloud-oauth.test.ts b/nook-app/nook-web/tests/unit/lib/icloud-oauth.test.ts index 53fdbbcdc..6ab7ece3e 100644 --- a/nook-app/nook-web/tests/unit/lib/icloud-oauth.test.ts +++ b/nook-app/nook-web/tests/unit/lib/icloud-oauth.test.ts @@ -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(() => {}))