diff --git a/src/plugins/hapoalim/__tests__/api.login.test.js b/src/plugins/hapoalim/__tests__/api.login.test.js index 40cfc58dd..342f13906 100644 --- a/src/plugins/hapoalim/__tests__/api.login.test.js +++ b/src/plugins/hapoalim/__tests__/api.login.test.js @@ -1,5 +1,34 @@ /* eslint-disable @typescript-eslint/no-var-requires */ +const WEB_LOGIN_URL = 'https://login.bankhapoalim.co.il/cgi-bin/poalwwwc?reqName=getLogonPage' +const PORTAL_URL = 'https://login.bankhapoalim.co.il/portalserver/HomePage' +const ACCOUNTS_URL = 'https://login.bankhapoalim.co.il/ServerServices/general/accounts?lang=he' +const STATIC_CHALLENGE_URL = 'https://static.example.com/challenge' + +function createWebView (cookieJarByUrl = {}) { + return { + cookieJar: { + getCookieString: jest.fn(async (url) => cookieJarByUrl[url] || '') + } + } +} + +async function runInterceptSequence (intercept, requests, webView) { + return await new Promise((resolve, reject) => { + const close = (error, closeResult) => error ? reject(error) : resolve(closeResult) + webView.close = close + ;(async () => { + for (const request of requests) { + const result = await intercept.call({ close }, request, webView) + if (result) { + resolve(result) + return + } + } + })().catch(reject) + }) +} + describe('hapoalim api login', () => { let login let openWebViewAndInterceptRequestMock @@ -14,47 +43,67 @@ describe('hapoalim api login', () => { jest.spyOn(console, method).mockImplementation(() => {}) ) - openWebViewAndInterceptRequestMock = jest.fn(async ({ url, intercept }) => { - expect(url).toBe('https://login.bankhapoalim.co.il/cgi-bin/poalwwwc?reqName=getLogonPage') - - const apiRequestResult = intercept({ - url: 'https://login.bankhapoalim.co.il/ServerServices/general/accounts?lang=he', - headers: { - cookie: 'TS=api-cookie; XSRF-TOKEN=api-xsrf' + openWebViewAndInterceptRequestMock = jest.fn(async ({ url, configure, intercept }) => { + expect(url).toBe(WEB_LOGIN_URL) + let portalCookiesEnabled = false + const webView = { + cookieJar: { + getCookieString: jest.fn(async (requestUrl) => { + return portalCookiesEnabled && requestUrl === PORTAL_URL + ? 'TS=portal-cookie; SMSESSION=portal-session; XSRF-TOKEN=portal-xsrf' + : '' + }) } - }) - expect(apiRequestResult).toBeNull() + } + if (configure) { + await configure(webView) + } return await new Promise((resolve, reject) => { - const portalRequestResult = intercept.call({ - close: (error, closeResult) => error ? reject(error) : resolve(closeResult) - }, { - url: 'https://login.bankhapoalim.co.il/portalserver/HomePage', - headers: { - cookie: 'TS=portal-cookie; SMSESSION=portal-session; XSRF-TOKEN=portal-xsrf' - } - }) - - expect(portalRequestResult).toBeNull() + const close = (error, closeResult) => error ? reject(error) : resolve(closeResult) + webView.close = close + ;(async () => { + const apiRequestResult = await intercept.call({ close }, { + url: ACCOUNTS_URL, + headers: { + cookie: 'TS=api-cookie; XSRF-TOKEN=api-xsrf' + } + }, webView) + expect(apiRequestResult).toBeNull() + + portalCookiesEnabled = true + + const portalRequestResult = await intercept.call({ close }, { + url: PORTAL_URL, + headers: { + cookie: 'TS=portal-cookie; SMSESSION=portal-session; XSRF-TOKEN=portal-xsrf' + } + }, webView) + expect(portalRequestResult).toBeNull() + })().catch(reject) }) }) fetchMock = jest.fn().mockResolvedValue({ status: 200, - url: 'https://login.bankhapoalim.co.il/portalserver/HomePage', + url: PORTAL_URL, headers: {}, body: 'window.bnhpApp = { restContext: "/pib" }' }) fetchJsonMock = jest.fn().mockResolvedValue({ status: 200, - url: 'https://login.bankhapoalim.co.il/ServerServices/general/accounts?lang=he', + url: ACCOUNTS_URL, headers: {}, body: [{ accountNumber: '1' }, { accountNumber: '2' }] }) global.ZenMoney = { + features: { + webViewConfiguration: true + }, openWebView: jest.fn(), - getCookies: jest.fn().mockResolvedValue([]) + getCookies: jest.fn().mockResolvedValue([]), + saveCookies: jest.fn().mockResolvedValue(undefined) } jest.doMock('../../../common/network', () => ({ @@ -74,10 +123,10 @@ describe('hapoalim api login', () => { } }) - it('waits for an authenticated portal request, restores rest context and refreshes cookies from response headers', async () => { + it('probes the authenticated portal page after configured WebView login', async () => { fetchMock.mockResolvedValue({ status: 200, - url: 'https://login.bankhapoalim.co.il/portalserver/HomePage', + url: PORTAL_URL, headers: { 'set-cookie': 'TS=rotated-cookie; Path=/, XSRF-TOKEN=rotated-xsrf; Path=/' }, @@ -89,7 +138,7 @@ describe('hapoalim api login', () => { expect(openWebViewAndInterceptRequestMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( - 'https://login.bankhapoalim.co.il/portalserver/HomePage', + PORTAL_URL, expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ @@ -97,105 +146,71 @@ describe('hapoalim api login', () => { }) }) ) - expect(auth).toMatchObject({ - xsrfToken: 'rotated-xsrf', - restContext: 'pib' - }) - expect(auth.cookieHeader).toContain('TS=rotated-cookie') + expect(auth.xsrfToken).toBeTruthy() expect(auth.cookieHeader).toContain('SMSESSION=portal-session') - expect(auth.cookieHeader).toContain('XSRF-TOKEN=rotated-xsrf') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=') }) - it('completes web login from ZenMoney cookie store when intercepted request has no cookie header', async () => { - global.ZenMoney.getCookies.mockResolvedValue([ - { domain: '.bankhapoalim.co.il', name: 'SMSESSION', value: 'store-session' }, - { domain: 'login.bankhapoalim.co.il', name: 'XSRF-TOKEN', value: 'store-xsrf' }, - { domain: 'example.com', name: 'ignored', value: 'ignored' } - ]) - openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ intercept }) => { - return await new Promise((resolve, reject) => { - const result = intercept.call({ - close: (error, closeResult) => error ? reject(error) : resolve(closeResult) - }, { - url: 'https://login.bankhapoalim.co.il/ng-portals/rb/he/homepage', - headers: {} - }) - - if (result) { - resolve(result) - } + it('completes configured WebView login from the WebView cookie jar when request headers are empty', async () => { + openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ configure, intercept }) => { + const webView = createWebView({ + [PORTAL_URL]: 'SMSESSION=jar-session; XSRF-TOKEN=jar-xsrf' }) - }) + if (configure) { + await configure(webView) + } - const auth = await login() - - expect(global.ZenMoney.getCookies).toHaveBeenCalled() - expect(auth).toMatchObject({ - xsrfToken: 'store-xsrf', - restContext: 'pib' + return await runInterceptSequence(intercept, [ + { + url: PORTAL_URL, + headers: {} + } + ], webView) }) - expect(auth.cookieHeader).toContain('SMSESSION=store-session') - expect(auth.cookieHeader).toContain('XSRF-TOKEN=store-xsrf') - expect(auth.cookieHeader).not.toContain('ignored=ignored') - }) - - it('recovers web login from ZenMoney cookie store after the WebView is closed', async () => { - global.ZenMoney.getCookies.mockResolvedValue([ - { domain: '.bankhapoalim.co.il', name: 'SMSESSION', value: 'closed-session' }, - { domain: '.bankhapoalim.co.il', name: 'XSRF-TOKEN', value: 'closed-xsrf' } - ]) - openWebViewAndInterceptRequestMock.mockRejectedValueOnce(new Error('WebView closed')) const auth = await login() - expect(global.ZenMoney.getCookies).toHaveBeenCalled() expect(auth).toMatchObject({ - xsrfToken: 'closed-xsrf', + xsrfToken: 'jar-xsrf', restContext: 'pib' }) - expect(auth.cookieHeader).toContain('SMSESSION=closed-session') - expect(auth.cookieHeader).toContain('XSRF-TOKEN=closed-xsrf') + expect(auth.cookieHeader).toContain('SMSESSION=jar-session') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=jar-xsrf') + expect(global.ZenMoney.getCookies).not.toHaveBeenCalled() }) - it('closes web login from cookie store polling without a success url', async () => { + it('closes configured WebView login from cookie-jar polling without a success url', async () => { const originalSetTimeout = global.setTimeout const originalClearTimeout = global.clearTimeout const scheduledCallbacks = [] + global.setTimeout = jest.fn((callback) => { scheduledCallbacks.push(callback) return scheduledCallbacks.length }) global.clearTimeout = jest.fn() - global.ZenMoney.getCookies - .mockResolvedValueOnce([ - { domain: '.bankhapoalim.co.il', name: 'visid_incap_2405249', value: 'anti-bot' } - ]) - .mockResolvedValueOnce([ - { domain: '.bankhapoalim.co.il', name: 'SMSESSION', value: 'poll-session' }, - { domain: '.bankhapoalim.co.il', name: 'XSRF-TOKEN', value: 'poll-xsrf' } - ]) + openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ configure, intercept }) => { + const webView = createWebView({ + [ACCOUNTS_URL]: 'TS=poll-ts; XSRF-TOKEN=poll-xsrf' + }) + if (configure) { + await configure(webView) + } - openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ intercept }) => { return await new Promise((resolve, reject) => { - const result = intercept.call({ - close: (error, closeResult) => error ? reject(error) : resolve(closeResult) - }, { - url: 'https://login.bankhapoalim.co.il/ng-portals/auth/he/', + const close = (error, closeResult) => error ? reject(error) : resolve(closeResult) + webView.close = close + Promise.resolve(intercept.call({ close }, { + url: STATIC_CHALLENGE_URL, headers: {} - }) - - if (result) { - resolve(result) - } + }, webView)).catch(reject) }) }) try { const authPromise = login() - expect(scheduledCallbacks).toHaveLength(1) - await scheduledCallbacks.shift()() expect(scheduledCallbacks).toHaveLength(1) await scheduledCallbacks.shift()() @@ -204,7 +219,7 @@ describe('hapoalim api login', () => { xsrfToken: 'poll-xsrf', restContext: 'pib' }) - expect(auth.cookieHeader).toContain('SMSESSION=poll-session') + expect(auth.cookieHeader).toContain('TS=poll-ts') expect(auth.cookieHeader).toContain('XSRF-TOKEN=poll-xsrf') expect(fetchMock).toHaveBeenCalledTimes(1) } finally { @@ -213,72 +228,220 @@ describe('hapoalim api login', () => { } }) - it('does not recover web login from unauthenticated anti-bot cookies only', async () => { - global.ZenMoney.getCookies.mockResolvedValue([ - { domain: '.bankhapoalim.co.il', name: 'visid_incap_2405249', value: 'anti-bot' }, - { domain: '.bankhapoalim.co.il', name: 'incap_ses_3302_2405249', value: 'anti-bot' } - ]) - openWebViewAndInterceptRequestMock.mockRejectedValueOnce(new Error('WebView closed')) + it('recovers configured WebView login after native close when auth was already captured from the cookie jar', async () => { + openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ configure, intercept }) => { + const webView = createWebView({ + [PORTAL_URL]: 'TS=captured-ts; XSRF-TOKEN=captured-xsrf' + }) + if (configure) { + await configure(webView) + } - await expect(login()).rejects.toMatchObject({ - message: 'Could not complete Bank Hapoalim web login. Finish the bank login in the opened page and retry sync.' + await intercept.call({ close: jest.fn() }, { + url: PORTAL_URL, + headers: {} + }, webView) + + throw new Error('WebView closed') }) - expect(global.ZenMoney.getCookies).toHaveBeenCalled() - expect(fetchMock).not.toHaveBeenCalled() + const auth = await login() + + expect(auth.cookieHeader).toContain('TS=captured-ts') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=captured-xsrf') + expect(global.ZenMoney.getCookies).not.toHaveBeenCalled() }) - it('does not recover web login from session cookies until accounts access is verified', async () => { - global.ZenMoney.getCookies.mockResolvedValue([ - { domain: '.bankhapoalim.co.il', name: 'SMSESSION', value: 'stale-session' }, - { domain: '.bankhapoalim.co.il', name: 'XSRF-TOKEN', value: 'stale-xsrf' } - ]) + it('retries configured WebView auth after close before falling back to the global cookie store', async () => { + const originalSetTimeout = global.setTimeout + global.setTimeout = jest.fn((callback) => { + callback() + return 1 + }) + + fetchJsonMock + .mockResolvedValueOnce({ + status: 403, + url: ACCOUNTS_URL, + headers: {}, + body: { error: { errCode: 'STEPUPOTP' } } + }) + .mockResolvedValueOnce({ + status: 403, + url: ACCOUNTS_URL, + headers: {}, + body: { error: { errCode: 'STEPUPOTP' } } + }) + .mockResolvedValue({ + status: 200, + url: ACCOUNTS_URL, + headers: {}, + body: [{ accountNumber: '1' }] + }) + + openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ configure, intercept }) => { + const webView = createWebView({ + [PORTAL_URL]: 'TS=retry-ts; XSRF-TOKEN=retry-xsrf' + }) + if (configure) { + await configure(webView) + } + + await intercept.call({ close: jest.fn() }, { + url: PORTAL_URL, + headers: {} + }, webView) + + throw new Error('WebView closed') + }) + + try { + const auth = await login() + + expect(auth.cookieHeader).toContain('TS=retry-ts') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=retry-xsrf') + expect(fetchJsonMock).toHaveBeenCalledTimes(3) + expect(global.ZenMoney.getCookies).not.toHaveBeenCalled() + } finally { + global.setTimeout = originalSetTimeout + } + }) + + it('keeps configured WebView cookie-jar auth for recovery when first accounts probe is not ready', async () => { + fetchJsonMock + .mockResolvedValueOnce({ + status: 403, + url: ACCOUNTS_URL, + headers: {}, + body: { error: { errCode: 'STEPUPOTP' } } + }) + .mockResolvedValue({ + status: 200, + url: ACCOUNTS_URL, + headers: {}, + body: [{ accountNumber: '1' }] + }) + + openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ configure, intercept }) => { + const webView = createWebView({ + [ACCOUNTS_URL]: 'TS=late-ts; XSRF-TOKEN=late-xsrf' + }) + if (configure) { + await configure(webView) + } + + await intercept.call({ close: jest.fn() }, { + url: STATIC_CHALLENGE_URL, + headers: {} + }, webView) + + throw new Error('WebView closed') + }) + + const auth = await login() + + expect(auth.cookieHeader).toContain('TS=late-ts') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=late-xsrf') + expect(fetchJsonMock).toHaveBeenCalledTimes(2) + expect(global.ZenMoney.getCookies).not.toHaveBeenCalled() + }) + + it('does not accept configured WebView cookies until accounts access is verified', async () => { fetchJsonMock.mockResolvedValue({ status: 403, - url: 'https://login.bankhapoalim.co.il/ServerServices/general/accounts?lang=he', + url: ACCOUNTS_URL, headers: {}, body: { error: { errCode: 'STEPUPOTP' } } }) - openWebViewAndInterceptRequestMock.mockRejectedValueOnce(new Error('WebView closed')) + + openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ configure, intercept }) => { + const webView = createWebView({ + [PORTAL_URL]: 'SMSESSION=request-session; XSRF-TOKEN=request-xsrf' + }) + if (configure) { + await configure(webView) + } + + await intercept.call({ close: jest.fn() }, { + url: PORTAL_URL, + headers: {} + }, webView) + + throw new Error('WebView still open') + }) await expect(login()).rejects.toMatchObject({ message: 'Could not complete Bank Hapoalim web login. Finish the bank login in the opened page and retry sync.' }) - expect(global.ZenMoney.getCookies).toHaveBeenCalled() - expect(fetchJsonMock).toHaveBeenCalledTimes(1) + expect(fetchJsonMock).toHaveBeenCalled() expect(fetchMock).not.toHaveBeenCalled() }) - it('does not complete request-cookie login until accounts access is verified', async () => { - global.ZenMoney.getCookies.mockResolvedValue([]) + it('logs configured WebView diagnostics when login fails', async () => { fetchJsonMock.mockResolvedValue({ status: 403, - url: 'https://login.bankhapoalim.co.il/ServerServices/general/accounts?lang=he', + url: ACCOUNTS_URL, headers: {}, body: { error: { errCode: 'STEPUPOTP' } } }) - openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ intercept }) => { - const result = intercept.call({ - close: () => { - throw new Error('WebView should not close before accounts access is verified') - } - }, { - url: 'https://login.bankhapoalim.co.il/ng-portals/rb/he/homepage', - headers: { - cookie: 'SMSESSION=request-session; XSRF-TOKEN=request-xsrf' - } + + openWebViewAndInterceptRequestMock.mockImplementationOnce(async ({ configure, intercept }) => { + const webView = createWebView({ + [PORTAL_URL]: 'TS=failed-ts; XSRF-TOKEN=failed-xsrf' }) - expect(result).toBeNull() - await Promise.resolve() - throw new Error('WebView still open') + if (configure) { + await configure(webView) + } + + await intercept.call({ close: jest.fn() }, { + url: PORTAL_URL, + headers: {} + }, webView) + + throw new Error('WebView closed') }) await expect(login()).rejects.toMatchObject({ message: 'Could not complete Bank Hapoalim web login. Finish the bank login in the opened page and retry sync.' }) - expect(fetchJsonMock).toHaveBeenCalledTimes(2) - expect(fetchMock).not.toHaveBeenCalled() + expect(console.warn).toHaveBeenCalledWith('interactive web login failed', expect.objectContaining({ + flow: 'configured-webview', + sawAuthenticatedPortalRequest: true, + auth: expect.objectContaining({ + cookieNames: expect.arrayContaining(['TS', 'XSRF-TOKEN']) + }), + error: 'WebView closed' + })) + }) + + it('falls back to the global cookie store when configured WebView login closes before cookie-jar capture', async () => { + global.ZenMoney.getCookies.mockResolvedValue([ + { domain: '.bankhapoalim.co.il', name: 'SMSESSION', value: 'store-session' }, + { domain: '.bankhapoalim.co.il', name: 'XSRF-TOKEN', value: 'store-xsrf' } + ]) + openWebViewAndInterceptRequestMock.mockRejectedValueOnce(new Error('WebView closed')) + + const auth = await login() + + expect(global.ZenMoney.getCookies).toHaveBeenCalled() + expect(auth.cookieHeader).toContain('SMSESSION=store-session') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=store-xsrf') + }) + + it('keeps the legacy WebView path for older app versions', async () => { + global.ZenMoney.features = {} + global.ZenMoney.getCookies.mockResolvedValue([ + { domain: 'login.bankhapoalim.co.il', name: 'TS', value: 'legacy-ts' }, + { domain: 'login.bankhapoalim.co.il', name: 'XSRF-TOKEN', value: 'legacy-xsrf' } + ]) + openWebViewAndInterceptRequestMock.mockRejectedValueOnce(new Error('WebView closed')) + + const auth = await login() + + expect(openWebViewAndInterceptRequestMock.mock.calls[0][0]).not.toHaveProperty('configure') + expect(auth.cookieHeader).toContain('TS=legacy-ts') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=legacy-xsrf') }) }) diff --git a/src/plugins/hapoalim/__tests__/api.runtimeHarness.test.js b/src/plugins/hapoalim/__tests__/api.runtimeHarness.test.js new file mode 100644 index 000000000..c33a59b1f --- /dev/null +++ b/src/plugins/hapoalim/__tests__/api.runtimeHarness.test.js @@ -0,0 +1,193 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ + +describe('hapoalim api runtime harness', () => { + let login + let fetchMock + let fetchJsonMock + let consoleSpies + + beforeEach(() => { + jest.resetModules() + jest.clearAllMocks() + consoleSpies = ['debug', 'info', 'log', 'warn', 'error'].map(method => + jest.spyOn(console, method).mockImplementation(() => {}) + ) + + fetchMock = jest.fn().mockResolvedValue({ + status: 200, + url: 'https://login.bankhapoalim.co.il/portalserver/HomePage', + headers: {}, + body: 'window.bnhpApp = { restContext: "/pib" }' + }) + fetchJsonMock = jest.fn().mockResolvedValue({ + status: 200, + url: 'https://login.bankhapoalim.co.il/ServerServices/general/accounts?lang=he', + headers: {}, + body: [{ accountNumber: '1' }, { accountNumber: '2' }] + }) + + jest.doMock('../../../common/network', () => { + const actual = jest.requireActual('../../../common/network') + return { + ...actual, + fetch: fetchMock, + fetchJson: fetchJsonMock, + ParseError: class ParseError extends Error {} + } + }) + }) + + afterEach(() => { + for (const spy of consoleSpies) { + spy.mockRestore() + } + }) + + it('closes the configured WebView from cookie-jar polling through the actual network helper', async () => { + const originalSetTimeout = global.setTimeout + const originalClearTimeout = global.clearTimeout + const scheduledCallbacks = [] + let cookieJarReads = 0 + + global.setTimeout = jest.fn((callback) => { + scheduledCallbacks.push(callback) + return scheduledCallbacks.length + }) + global.clearTimeout = jest.fn() + + global.ZenMoney = { + features: { + webViewConfiguration: true + }, + getCookies: jest.fn().mockResolvedValue([]), + saveCookies: jest.fn().mockResolvedValue(undefined), + openWebView: jest.fn((url, headers, onRequest, onComplete, options) => { + const webView = { + cookieJar: { + getCookieString: jest.fn(async (cookieUrl) => { + if (cookieUrl.includes('/ServerServices/general/accounts')) { + cookieJarReads++ + return cookieJarReads >= 2 + ? 'TS=poll-ts; XSRF-TOKEN=poll-xsrf' + : '' + } + return '' + }) + } + } + + Promise.resolve(options.configure(webView)) + .then(async () => { + const mode = await onRequest({ + url: 'https://static.example.com/challenge', + headers: {} + }, (error, result) => onComplete(error, result)) + expect(mode).toBeUndefined() + }) + .catch(error => onComplete(error)) + }) + } + + login = require('../api').login + + try { + const authPromise = login() + + expect(scheduledCallbacks).toHaveLength(1) + await scheduledCallbacks.shift()() + expect(scheduledCallbacks).toHaveLength(1) + await scheduledCallbacks.shift()() + + const auth = await authPromise + expect(fetchJsonMock).toHaveBeenCalled() + expect(global.ZenMoney.getCookies).not.toHaveBeenCalled() + expect(auth.cookieHeader).toContain('TS=poll-ts') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=poll-xsrf') + expect(auth.restContext).toBe('pib') + } finally { + global.setTimeout = originalSetTimeout + global.clearTimeout = originalClearTimeout + } + }) + + it('recovers configured WebView auth after native close when the cookie jar was already captured', async () => { + global.ZenMoney = { + features: { + webViewConfiguration: true + }, + getCookies: jest.fn().mockResolvedValue([]), + saveCookies: jest.fn().mockResolvedValue(undefined), + openWebView: jest.fn((url, headers, onRequest, onComplete, options) => { + const webView = { + cookieJar: { + getCookieString: jest.fn(async (cookieUrl) => { + if (cookieUrl.includes('/portalserver/HomePage')) { + return 'TS=jar-ts; XSRF-TOKEN=jar-xsrf' + } + return '' + }) + } + } + + Promise.resolve(options.configure(webView)) + .then(async () => { + await onRequest({ + url: 'https://login.bankhapoalim.co.il/portalserver/HomePage', + headers: {} + }, () => {}) + onComplete(new Error('WebView closed')) + }) + .catch(error => onComplete(error)) + }) + } + + login = require('../api').login + + const auth = await login() + + expect(global.ZenMoney.getCookies).not.toHaveBeenCalled() + expect(fetchJsonMock).toHaveBeenCalled() + expect(auth.cookieHeader).toContain('TS=jar-ts') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=jar-xsrf') + expect(auth.restContext).toBe('pib') + }) + + it('keeps the legacy cookie-store recovery path for older app versions', async () => { + const originalSetTimeout = global.setTimeout + + global.setTimeout = jest.fn((callback) => { + callback() + return 1 + }) + + global.ZenMoney = { + features: {}, + getCookies: jest.fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { domain: '.bankhapoalim.co.il', name: 'SMSESSION', value: 'closed-session' }, + { domain: '.bankhapoalim.co.il', name: 'XSRF-TOKEN', value: 'closed-xsrf' } + ]), + saveCookies: jest.fn().mockResolvedValue(undefined), + openWebView: jest.fn((url, headers, onRequest, onComplete) => { + onComplete(new Error('WebView closed')) + }) + } + + login = require('../api').login + + try { + const auth = await login() + + expect(global.ZenMoney.saveCookies).toHaveBeenCalled() + expect(global.ZenMoney.getCookies).toHaveBeenCalledTimes(3) + expect(fetchJsonMock).toHaveBeenCalled() + expect(auth.cookieHeader).toContain('SMSESSION=closed-session') + expect(auth.cookieHeader).toContain('XSRF-TOKEN=closed-xsrf') + expect(auth.restContext).toBe('pib') + } finally { + global.setTimeout = originalSetTimeout + } + }) +}) diff --git a/src/plugins/hapoalim/api.js b/src/plugins/hapoalim/api.js index 051b6c8cf..1654e218a 100644 --- a/src/plugins/hapoalim/api.js +++ b/src/plugins/hapoalim/api.js @@ -54,6 +54,18 @@ const OFFICIAL_TRANSACTION_PAGE_UUID = '/current-account/transactions' const OFFICIAL_TRANSACTION_LIMIT = 1000 const COOKIE_STORE_POLL_INTERVAL_MS = 1000 const COOKIE_STORE_POLL_TIMEOUT_MS = 10 * 60 * 1000 +const COOKIE_STORE_RECOVERY_RETRY_COUNT = 5 +const COOKIE_STORE_RECOVERY_RETRY_DELAY_MS = 250 +const OFFICIAL_AUTH_COOKIE_NAMES = new Set([ + 'SMSESSION', + 'XSRF-TOKEN', + 'TS' +]) +const OFFICIAL_COOKIE_PROBE_URLS = [ + WEB_LOGIN_URL, + `${OFFICIAL_BASE_URL}/ServerServices/general/accounts?lang=he`, + ...PORTAL_PAGE_PATHS.map(path => `${OFFICIAL_BASE_URL}${path}`) +] function summarizeResponse (response) { return response @@ -202,18 +214,65 @@ function getCookieHeaderNames (cookieHeader) { function hasLikelyOfficialAuthCookie (cookieHeader) { const cookies = parseCookieHeader(cookieHeader) - return Boolean(cookies.SMSESSION || cookies['XSRF-TOKEN']) + return Boolean(cookies.SMSESSION || cookies['XSRF-TOKEN'] || cookies.TS) } function hasOfficialSessionCookie (cookieHeader) { return Boolean(parseCookieHeader(cookieHeader).SMSESSION) } +function isOfficialCookieStoreEntry (cookie) { + if (typeof cookie?.name !== 'string' || cookie.name === '') { + return false + } + if (typeof cookie?.value !== 'string' || cookie.value === '') { + return false + } + if (isOfficialCookieDomain(cookie?.domain)) { + return true + } + return (cookie?.domain == null || cookie.domain === '') && OFFICIAL_AUTH_COOKIE_NAMES.has(cookie.name) +} + +function getCookieDomainPriority (domain) { + if (typeof domain !== 'string' || domain === '') { + return 0 + } + const normalizedDomain = domain.toLowerCase() + if (normalizedDomain === 'login.bankhapoalim.co.il') { + return 5 + } + if (normalizedDomain === '.login.bankhapoalim.co.il') { + return 4 + } + if (normalizedDomain === 'bankhapoalim.co.il') { + return 3 + } + if (normalizedDomain === '.bankhapoalim.co.il') { + return 2 + } + return normalizedDomain.endsWith('.bankhapoalim.co.il') ? 1 : 0 +} + +function compareCookieStoreEntries (leftCookie, rightCookie) { + const domainPriorityDiff = getCookieDomainPriority(rightCookie?.domain) - getCookieDomainPriority(leftCookie?.domain) + if (domainPriorityDiff !== 0) { + return domainPriorityDiff + } + + const leftPathLength = typeof leftCookie?.path === 'string' ? leftCookie.path.length : 0 + const rightPathLength = typeof rightCookie?.path === 'string' ? rightCookie.path.length : 0 + const pathPriorityDiff = rightPathLength - leftPathLength + if (pathPriorityDiff !== 0) { + return pathPriorityDiff + } + + return String(leftCookie?.name || '').localeCompare(String(rightCookie?.name || '')) +} + function summarizeOfficialCookieStore (cookies) { return (Array.isArray(cookies) ? cookies : []) - .filter(cookie => isOfficialCookieDomain(cookie?.domain)) - .filter(cookie => typeof cookie?.name === 'string' && cookie.name !== '') - .filter(cookie => typeof cookie?.value === 'string' && cookie.value !== '') + .filter(isOfficialCookieStoreEntry) .map(cookie => `${cookie.domain || ''}:${cookie.name}`) .sort() } @@ -223,14 +282,114 @@ function buildCookieHeaderFromCookieStore (cookies) { return '' } - return cookies - .filter(cookie => isOfficialCookieDomain(cookie?.domain)) - .filter(cookie => typeof cookie?.name === 'string' && cookie.name !== '') - .filter(cookie => typeof cookie?.value === 'string' && cookie.value !== '') - .map(cookie => `${cookie.name}=${cookie.value}`) + const selectedCookiesByName = (cookies || []) + .filter(isOfficialCookieStoreEntry) + .sort(compareCookieStoreEntries) + .reduce((result, cookie) => { + if (!result.has(cookie.name)) { + result.set(cookie.name, cookie.value) + } + return result + }, new Map()) + + return [...selectedCookiesByName.entries()] + .map(([name, value]) => `${name}=${value}`) .join('; ') } +function isOfficialUrl (url) { + return typeof url === 'string' && url.indexOf(OFFICIAL_BASE_URL) === 0 +} + +function buildWebViewCookieProbeUrls (requestUrl) { + const probeUrls = [] + if (isOfficialUrl(requestUrl)) { + probeUrls.push(requestUrl) + } + for (const probeUrl of OFFICIAL_COOKIE_PROBE_URLS) { + if (!probeUrls.includes(probeUrl)) { + probeUrls.push(probeUrl) + } + } + return probeUrls +} + +function hasWebViewCookieJar (webView) { + return typeof webView?.cookieJar?.getCookieString === 'function' +} + +async function buildCookieHeaderFromWebViewCookieJar (webView, requestUrl, { logErrors = true } = {}) { + if (!hasWebViewCookieJar(webView)) { + return '' + } + + let cookieHeader = '' + for (const probeUrl of buildWebViewCookieProbeUrls(requestUrl)) { + try { + cookieHeader = mergeCookieHeaders(cookieHeader, await webView.cookieJar.getCookieString(probeUrl)) + } catch (error) { + if (logErrors) { + console.warn('failed to read Bank Hapoalim cookies from WebView cookie jar', { + url: sanitizeOfficialUrlForLog(probeUrl), + message: error?.message || error + }) + } + } + } + return cookieHeader +} + +async function updateAuthFromWebViewCookieJar ( + auth, + webView, + { + requestUrl = null, + requireSessionCookie = false, + logMissingAuth = true, + logErrors = true + } = {} +) { + const cookieHeader = await buildCookieHeaderFromWebViewCookieJar(webView, requestUrl, { logErrors }) + if (!cookieHeader) { + if (logMissingAuth) { + console.warn('Bank Hapoalim WebView cookie jar has no official cookies') + } + return auth + } + + const cookieNames = getCookieHeaderNames(cookieHeader) + const hasLikelyAuthCookie = hasLikelyOfficialAuthCookie(cookieHeader) + if (!hasLikelyAuthCookie) { + if (logMissingAuth) { + console.warn('Bank Hapoalim WebView cookie jar has no likely auth cookies', { + cookieNames + }) + } + return auth + } + if (requireSessionCookie && !hasOfficialSessionCookie(cookieHeader)) { + if (logMissingAuth) { + console.warn('Bank Hapoalim WebView cookie jar has no session cookie', { + cookieNames + }) + } + return auth + } + + const nextAuth = { ...auth } + nextAuth.cookieHeader = mergeCookieHeaders(nextAuth.cookieHeader, cookieHeader) + nextAuth.xsrfToken = getCookieValue(nextAuth.cookieHeader, 'XSRF-TOKEN') || nextAuth.xsrfToken + nextAuth.restContext = nextAuth.restContext || extractRestContextFromUrl(requestUrl) + if (nextAuth.cookieHeader !== auth.cookieHeader || nextAuth.xsrfToken !== auth.xsrfToken) { + console.log('Bank Hapoalim WebView cookie jar auth snapshot', { + requestUrl: sanitizeOfficialUrlForLog(requestUrl), + cookieNames, + hasXsrfToken: Boolean(nextAuth.xsrfToken) + }) + } + return nextAuth +} + function normalizeRestContext (value) { if (typeof value !== 'string') { return null @@ -306,6 +465,14 @@ async function updateAuthFromCookieStore (auth, { requireSessionCookie = false, } try { + if (ZenMoney?.saveCookies) { + try { + await ZenMoney.saveCookies() + } catch (error) { + console.warn('failed to flush Bank Hapoalim cookies before reading cookie store', error?.message || error) + } + } + const cookies = await ZenMoney.getCookies() const officialCookieStore = summarizeOfficialCookieStore(cookies) const cookieHeader = buildCookieHeaderFromCookieStore(cookies) @@ -352,6 +519,98 @@ async function updateAuthFromCookieStore (auth, { requireSessionCookie = false, } } +function waitForMs (delayMs) { + if (!(delayMs > 0) || typeof setTimeout !== 'function') { + return Promise.resolve() + } + return new Promise(resolve => setTimeout(resolve, delayMs)) +} + +function summarizeAuthSnapshot (auth) { + return { + cookieNames: getCookieHeaderNames(auth?.cookieHeader), + hasXsrfToken: Boolean(auth?.xsrfToken), + hasSessionCookie: hasOfficialSessionCookie(auth?.cookieHeader), + restContext: auth?.restContext || null + } +} + +async function recoverVerifiedAuth ( + auth, + { + attempts = 1, + delayMs = 0, + source = 'current-auth' + } = {} +) { + const nextAuth = auth + + for (let attempt = 0; attempt < attempts; attempt++) { + if (nextAuth.cookieHeader !== '' && await hasAuthenticatedAccountsAccess(nextAuth)) { + if (attempt > 0) { + console.log('Bank Hapoalim auth verification recovered after retry', { + source, + attempt: attempt + 1, + ...summarizeAuthSnapshot(nextAuth) + }) + } + return { + auth: nextAuth, + verified: true + } + } + if (attempt < attempts - 1) { + await waitForMs(delayMs) + } + } + + if (nextAuth.cookieHeader !== '') { + console.warn('Bank Hapoalim auth verification retries exhausted', { + source, + attempts, + ...summarizeAuthSnapshot(nextAuth) + }) + } + + return { + auth: nextAuth, + verified: false + } +} + +async function recoverVerifiedAuthFromCookieStore ( + auth, + { + attempts = 1, + delayMs = 0, + requireSessionCookie = false, + logMissingAuth = true + } = {} +) { + let nextAuth = auth + + for (let attempt = 0; attempt < attempts; attempt++) { + nextAuth = await updateAuthFromCookieStore(nextAuth, { + requireSessionCookie, + logMissingAuth: logMissingAuth && attempt === attempts - 1 + }) + if (nextAuth.cookieHeader !== '' && await hasAuthenticatedAccountsAccess(nextAuth)) { + return { + auth: nextAuth, + verified: true + } + } + if (attempt < attempts - 1) { + await waitForMs(delayMs) + } + } + + return { + auth: nextAuth, + verified: false + } +} + async function hasAuthenticatedAccountsAccess (auth) { try { const response = await fetchOfficialJson('/ServerServices/general/accounts?lang=he', auth, { log: false }) @@ -511,7 +770,329 @@ function getCurrentAccountBasePath (auth) { : '/ServerServices/current-account' } -async function captureOfficialSessionFromWebView () { +function createWebViewLoginDiagnostics (flow) { + return { + flow, + sawAnyInterceptedRequest: false, + sawOfficialRequest: false, + sawAuthenticatedPortalRequest: false, + lastInterceptedUrl: null, + cookieJarPollAttemptCount: 0, + cookieJarPollWithoutCloseCount: 0, + lastCookieJarCookieNames: [], + lastCompletionSource: null, + lastAccountsAccessVerified: false, + webViewCloseRequested: false + } +} + +function summarizeWebViewLoginDiagnostics (diagnostics, auth, error) { + return { + ...diagnostics, + lastInterceptedUrl: sanitizeOfficialUrlForLog(diagnostics.lastInterceptedUrl) || diagnostics.lastInterceptedUrl, + auth: summarizeAuthSnapshot(auth), + error: error?.message || error || null + } +} + +async function captureOfficialSessionFromConfiguredWebView () { + if (!ZenMoney?.openWebView) { + throw new TemporaryError('Bank Hapoalim login requires WebView support in the ZenMoney app.') + } + + let auth = createEmptyAuth() + const diagnostics = createWebViewLoginDiagnostics('configured-webview') + let authCaptureInFlight = false + let cookieJarPollingStarted = false + let cookieJarPollingStopped = false + let cookieJarPollingTimeoutId = null + let cookieJarPollingStartedAt = 0 + let webViewCloseRequested = false + let closeWebView = null + + function stopCookieJarPolling () { + cookieJarPollingStopped = true + if (cookieJarPollingTimeoutId != null && typeof clearTimeout === 'function') { + clearTimeout(cookieJarPollingTimeoutId) + } + cookieJarPollingTimeoutId = null + } + + function closeWebViewWithAuth (close, nextAuth, source) { + if (webViewCloseRequested) { + return + } + webViewCloseRequested = true + diagnostics.webViewCloseRequested = true + diagnostics.lastCompletionSource = source + auth = nextAuth + stopCookieJarPolling() + console.log('Bank Hapoalim WebView auth captured', { + source, + cookieNames: getCookieHeaderNames(auth.cookieHeader), + hasXsrfToken: Boolean(auth.xsrfToken) + }) + close(null, { auth }) + } + + async function tryCloseWithVerifiedAuth ({ close, nextAuth, verifyAccountsAccess, source }) { + if (authCaptureInFlight || webViewCloseRequested) { + return false + } + + authCaptureInFlight = true + try { + if (nextAuth.cookieHeader === '') { + return false + } + if (verifyAccountsAccess) { + diagnostics.lastAccountsAccessVerified = await hasAuthenticatedAccountsAccess(nextAuth) + if (!diagnostics.lastAccountsAccessVerified) { + diagnostics.lastCompletionSource = `${source}:accounts-access-not-ready` + return false + } + } + if (!verifyAccountsAccess && !hasOfficialSessionCookie(nextAuth.cookieHeader)) { + return false + } + closeWebViewWithAuth(close, nextAuth, source) + return true + } catch (error) { + console.warn('failed to complete Bank Hapoalim WebView login from verified auth', error?.message || error) + return false + } finally { + authCaptureInFlight = false + } + } + + async function tryCompleteFromWebViewCookieJar ({ + close, + webView, + requestUrl, + requireSessionCookie, + verifyAccountsAccess, + source + }) { + if (authCaptureInFlight || webViewCloseRequested) { + return false + } + + if (source === 'webview-cookie-jar-poll') { + diagnostics.cookieJarPollAttemptCount++ + } + if (!close) { + diagnostics.cookieJarPollWithoutCloseCount++ + return false + } + + const nextAuth = await updateAuthFromWebViewCookieJar(auth, webView, { + requestUrl, + requireSessionCookie, + logMissingAuth: source !== 'webview-cookie-jar-poll', + logErrors: source !== 'webview-cookie-jar-poll' + }) + diagnostics.lastCookieJarCookieNames = getCookieHeaderNames(nextAuth.cookieHeader) + if (nextAuth.cookieHeader !== '') { + auth = nextAuth + } + const isComplete = requireSessionCookie + ? hasOfficialSessionCookie(nextAuth.cookieHeader) + : nextAuth.cookieHeader !== '' + if (!isComplete) { + return false + } + + return await tryCloseWithVerifiedAuth({ + close, + nextAuth, + verifyAccountsAccess, + source + }) + } + + function scheduleCookieJarPoll (webView) { + if (cookieJarPollingStopped || webViewCloseRequested || typeof setTimeout !== 'function' || !hasWebViewCookieJar(webView)) { + return + } + if (Date.now() - cookieJarPollingStartedAt > COOKIE_STORE_POLL_TIMEOUT_MS) { + console.warn('Bank Hapoalim WebView cookie jar polling timed out', summarizeWebViewLoginDiagnostics(diagnostics, auth)) + return + } + + cookieJarPollingTimeoutId = setTimeout(async () => { + try { + cookieJarPollingTimeoutId = null + const isComplete = await tryCompleteFromWebViewCookieJar({ + close: closeWebView, + webView, + requireSessionCookie: false, + verifyAccountsAccess: true, + source: 'webview-cookie-jar-poll' + }) + if (!isComplete) { + scheduleCookieJarPoll(webView) + } + } catch (error) { + console.warn('Bank Hapoalim WebView cookie jar polling failed', summarizeWebViewLoginDiagnostics(diagnostics, auth, error)) + } + }, COOKIE_STORE_POLL_INTERVAL_MS) + } + + function startCookieJarPolling (webView) { + if (cookieJarPollingStarted || !hasWebViewCookieJar(webView)) { + return + } + cookieJarPollingStarted = true + cookieJarPollingStartedAt = Date.now() + scheduleCookieJarPoll(webView) + } + + try { + const result = await openWebViewAndInterceptRequest({ + url: WEB_LOGIN_URL, + log: false, + sanitizeRequestLog: { + headers: { + Cookie: true, + cookie: true, + 'X-XSRF-TOKEN': true + } + }, + configure: async (webView) => { + startCookieJarPolling(webView) + }, + intercept: async function (request, webView) { + try { + diagnostics.sawAnyInterceptedRequest = true + diagnostics.lastInterceptedUrl = request?.url || null + const close = typeof this?.close === 'function' ? this.close.bind(this) : closeWebView + if (close) { + closeWebView = close + } + if (hasWebViewCookieJar(webView)) { + startCookieJarPolling(webView) + } + if (isOfficialUrl(request?.url)) { + diagnostics.sawOfficialRequest = true + auth = updateAuthFromRequest(auth, request) + if (hasWebViewCookieJar(webView)) { + auth = await updateAuthFromWebViewCookieJar(auth, webView, { + requestUrl: request?.url, + logMissingAuth: false, + logErrors: false + }) + } + } + + const isAuthenticatedPortalRequest = WEB_SUCCESS_PATTERNS.some(pattern => pattern.test(request?.url || '')) + if (isAuthenticatedPortalRequest) { + diagnostics.sawAuthenticatedPortalRequest = true + console.log('Bank Hapoalim WebView reached authenticated portal request', { + url: sanitizeOfficialUrlForLog(request?.url), + hasRequestCookies: getCookieHeaderNames(getHeaderValue(request?.headers, 'cookie') || getHeaderValue(request?.headers, 'Cookie')).length > 0, + authCookieNames: getCookieHeaderNames(auth.cookieHeader) + }) + } + if (auth.cookieHeader !== '' && isAuthenticatedPortalRequest) { + if (!close) { + return { auth } + } + const isComplete = await tryCloseWithVerifiedAuth({ + close, + nextAuth: auth, + verifyAccountsAccess: true, + source: hasWebViewCookieJar(webView) + ? 'authenticated-portal-webview-cookie-jar' + : 'authenticated-portal-request' + }) + if (isComplete) { + return null + } + } + + if (hasWebViewCookieJar(webView) && !authCaptureInFlight) { + await tryCompleteFromWebViewCookieJar({ + close, + webView, + requestUrl: request?.url, + requireSessionCookie: false, + verifyAccountsAccess: true, + source: isAuthenticatedPortalRequest + ? 'authenticated-portal-webview-cookie-jar' + : 'webview-cookie-jar-request' + }) + } + + return null + } catch (error) { + console.warn('Bank Hapoalim WebView intercept handling failed', summarizeWebViewLoginDiagnostics(diagnostics, auth, error)) + throw error + } + } + }) + + stopCookieJarPolling() + auth = restoreAuth(result?.auth) || auth + let hasVerifiedAccountsAccessFromResult = auth.cookieHeader !== '' && await hasAuthenticatedAccountsAccess(auth) + if (!hasVerifiedAccountsAccessFromResult) { + const recoveredConfiguredResultAuth = await recoverVerifiedAuth(auth, { + attempts: COOKIE_STORE_RECOVERY_RETRY_COUNT, + delayMs: COOKIE_STORE_RECOVERY_RETRY_DELAY_MS, + source: 'configured-webview-result-auth' + }) + auth = recoveredConfiguredResultAuth.auth + hasVerifiedAccountsAccessFromResult = recoveredConfiguredResultAuth.verified + } + if (!hasVerifiedAccountsAccessFromResult) { + const recoveredConfiguredResultCookieStoreAuth = await recoverVerifiedAuthFromCookieStore(auth, { + attempts: COOKIE_STORE_RECOVERY_RETRY_COUNT, + delayMs: COOKIE_STORE_RECOVERY_RETRY_DELAY_MS, + requireSessionCookie: false, + logMissingAuth: false + }) + auth = recoveredConfiguredResultCookieStoreAuth.auth + hasVerifiedAccountsAccessFromResult = recoveredConfiguredResultCookieStoreAuth.verified + } + ensure(auth.cookieHeader !== '', 'web login did not produce an authenticated cookie snapshot') + ensure(hasVerifiedAccountsAccessFromResult || await hasAuthenticatedAccountsAccess(auth), 'web login did not produce authenticated API access') + await ensureRestContext(auth) + return auth + } catch (error) { + stopCookieJarPolling() + let hasVerifiedAccountsAccessAfterClose = false + const recoveredConfiguredCloseAuth = await recoverVerifiedAuth(auth, { + attempts: COOKIE_STORE_RECOVERY_RETRY_COUNT, + delayMs: COOKIE_STORE_RECOVERY_RETRY_DELAY_MS, + source: 'configured-webview-close-auth' + }) + auth = recoveredConfiguredCloseAuth.auth + hasVerifiedAccountsAccessAfterClose = recoveredConfiguredCloseAuth.verified + if (hasVerifiedAccountsAccessAfterClose) { + await ensureRestContext(auth) + return auth + } + + const recoveredConfiguredCloseCookieStoreAuth = await recoverVerifiedAuthFromCookieStore(auth, { + attempts: COOKIE_STORE_RECOVERY_RETRY_COUNT, + delayMs: COOKIE_STORE_RECOVERY_RETRY_DELAY_MS, + requireSessionCookie: false + }) + auth = recoveredConfiguredCloseCookieStoreAuth.auth + hasVerifiedAccountsAccessAfterClose = recoveredConfiguredCloseCookieStoreAuth.verified + if (hasVerifiedAccountsAccessAfterClose) { + await ensureRestContext(auth) + return auth + } + + if (error instanceof TemporaryError) { + throw error + } + console.warn('interactive web login failed', summarizeWebViewLoginDiagnostics(diagnostics, auth, error)) + throw new TemporaryError(WEB_LOGIN_INCOMPLETE_MESSAGE) + } +} + +async function captureOfficialSessionFromLegacyWebView () { if (!ZenMoney?.openWebView) { throw new TemporaryError('Bank Hapoalim login requires WebView support in the ZenMoney app.') } @@ -554,12 +1135,15 @@ async function captureOfficialSessionFromWebView () { authCaptureInFlight = true try { - if (!hasOfficialSessionCookie(nextAuth.cookieHeader)) { + if (nextAuth.cookieHeader === '') { return false } if (verifyAccountsAccess && !await hasAuthenticatedAccountsAccess(nextAuth)) { return false } + if (!verifyAccountsAccess && !hasOfficialSessionCookie(nextAuth.cookieHeader)) { + return false + } closeWebViewWithAuth(close, nextAuth, source) return true } catch (error) { @@ -604,15 +1188,19 @@ async function captureOfficialSessionFromWebView () { } cookieStorePollingTimeoutId = setTimeout(async () => { - cookieStorePollingTimeoutId = null - const isComplete = await tryCompleteFromCookieStore({ - close, - requireSessionCookie: true, - verifyAccountsAccess: true, - source: 'cookie-store-poll' - }) - if (!isComplete) { - scheduleCookieStorePoll(close) + try { + cookieStorePollingTimeoutId = null + const isComplete = await tryCompleteFromCookieStore({ + close, + requireSessionCookie: false, + verifyAccountsAccess: true, + source: 'cookie-store-poll' + }) + if (!isComplete) { + scheduleCookieStorePoll(close) + } + } catch (error) { + console.warn('Bank Hapoalim cookie store polling failed', error?.message || error) } }, COOKIE_STORE_POLL_INTERVAL_MS) } @@ -639,10 +1227,10 @@ async function captureOfficialSessionFromWebView () { }, intercept (request) { const close = typeof this?.close === 'function' ? this.close.bind(this) : null - if (typeof request?.url === 'string' && request.url.indexOf(OFFICIAL_BASE_URL) === 0) { - if (close) { - startCookieStorePolling(close) - } + if (close) { + startCookieStorePolling(close) + } + if (isOfficialUrl(request?.url)) { auth = updateAuthFromRequest(auth, request) } @@ -673,7 +1261,7 @@ async function captureOfficialSessionFromWebView () { if (close && isAuthenticatedPortalRequest && ZenMoney?.getCookies && !authCaptureInFlight) { tryCompleteFromCookieStore({ close, - requireSessionCookie: true, + requireSessionCookie: false, verifyAccountsAccess: true, source: 'authenticated-portal-cookie-store' }) @@ -688,19 +1276,55 @@ async function captureOfficialSessionFromWebView () { stopCookieStorePolling() auth = restoreAuth(result?.auth) || auth - auth = await updateAuthFromCookieStore(auth) - if (!hasOfficialSessionCookie(auth.cookieHeader)) { - throw new Error('web login did not produce an authenticated session') + let hasVerifiedAccountsAccessFromResult = false + const recoveredLegacyResultAuth = await recoverVerifiedAuth(auth, { + attempts: COOKIE_STORE_RECOVERY_RETRY_COUNT, + delayMs: COOKIE_STORE_RECOVERY_RETRY_DELAY_MS, + source: 'legacy-webview-result-auth' + }) + auth = recoveredLegacyResultAuth.auth + hasVerifiedAccountsAccessFromResult = recoveredLegacyResultAuth.verified + if (!hasVerifiedAccountsAccessFromResult) { + const recoveredLegacyResultCookieStoreAuth = await recoverVerifiedAuthFromCookieStore(auth, { + attempts: COOKIE_STORE_RECOVERY_RETRY_COUNT, + delayMs: COOKIE_STORE_RECOVERY_RETRY_DELAY_MS, + requireSessionCookie: false, + logMissingAuth: false + }) + auth = recoveredLegacyResultCookieStoreAuth.auth + hasVerifiedAccountsAccessFromResult = recoveredLegacyResultCookieStoreAuth.verified } - if (!await hasAuthenticatedAccountsAccess(auth)) { + if (auth.cookieHeader === '') { + throw new Error('web login did not produce an authenticated cookie snapshot') + } + if (!hasVerifiedAccountsAccessFromResult && !await hasAuthenticatedAccountsAccess(auth)) { throw new Error('web login did not produce authenticated API access') } await ensureRestContext(auth) return auth } catch (error) { stopCookieStorePolling() - auth = await updateAuthFromCookieStore(auth, { requireSessionCookie: true }) - if (hasOfficialSessionCookie(auth.cookieHeader) && await hasAuthenticatedAccountsAccess(auth)) { + let hasVerifiedAccountsAccessAfterClose = false + const recoveredLegacyCloseAuth = await recoverVerifiedAuth(auth, { + attempts: COOKIE_STORE_RECOVERY_RETRY_COUNT, + delayMs: COOKIE_STORE_RECOVERY_RETRY_DELAY_MS, + source: 'legacy-webview-close-auth' + }) + auth = recoveredLegacyCloseAuth.auth + hasVerifiedAccountsAccessAfterClose = recoveredLegacyCloseAuth.verified + if (hasVerifiedAccountsAccessAfterClose) { + await ensureRestContext(auth) + return auth + } + + const recoveredLegacyCloseCookieStoreAuth = await recoverVerifiedAuthFromCookieStore(auth, { + attempts: COOKIE_STORE_RECOVERY_RETRY_COUNT, + delayMs: COOKIE_STORE_RECOVERY_RETRY_DELAY_MS, + requireSessionCookie: false + }) + auth = recoveredLegacyCloseCookieStoreAuth.auth + hasVerifiedAccountsAccessAfterClose = recoveredLegacyCloseCookieStoreAuth.verified + if (hasVerifiedAccountsAccessAfterClose) { await ensureRestContext(auth) return auth } @@ -708,13 +1332,19 @@ async function captureOfficialSessionFromWebView () { if (error instanceof TemporaryError) { throw error } - console.warn('interactive web login failed', error?.message || error) + console.warn('interactive web login failed', { + flow: 'legacy-webview', + auth: summarizeAuthSnapshot(auth), + error: error?.message || error + }) throw new TemporaryError(WEB_LOGIN_INCOMPLETE_MESSAGE) } } export async function login () { - return await captureOfficialSessionFromWebView() + return await (ZenMoney?.features?.webViewConfiguration + ? captureOfficialSessionFromConfiguredWebView() + : captureOfficialSessionFromLegacyWebView()) } async function fetchMainAccountDetails (auth, mainAccount, accountId) {