diff --git a/src/auth.spec.ts b/src/auth.spec.ts index 2245d084..68cddece 100644 --- a/src/auth.spec.ts +++ b/src/auth.spec.ts @@ -539,6 +539,119 @@ describe('Unit test for auth', () => { }); }); + describe('doSSOAuth on IAMv2 (Okta) clusters', () => { + afterEach(() => { + SessionService.resetCachedPreauthInfo(); + delete global.window; + global.window = Object.create(originalWindow); + global.window.open = jest.fn(); + global.fetch = window.fetch; + }); + + it('SAMLRedirect: after Okta redirect, treats SSO marker as successful auth (no isactive call)', async () => { + Object.defineProperty(window, 'location', { + value: { + href: `asd.com#?tsSSOMarker=${authInstance.SSO_REDIRECTION_MARKER_GUID}`, + hash: `?tsSSOMarker=${authInstance.SSO_REDIRECTION_MARKER_GUID}`, + }, + }); + jest.spyOn(SessionService, 'getPreauthInfo').mockResolvedValue({ + config: { oktaEnabled: true }, + } as any); + const isActiveSpy = jest.spyOn(tokenAuthService, 'isActiveService'); + + await authInstance.doSamlAuth(embedConfig.doSamlAuth); + + expect(window.location.hash).toBe(''); + expect(authInstance.loggedInStatus).toBe(true); + // The whole point of the fix: never call the cookie probe on IAMv2. + expect(isActiveSpy).not.toHaveBeenCalled(); + }); + + it('SAMLRedirect: first load redirects to SSO endpoint without calling isactive', async () => { + Object.defineProperty(window, 'location', { value: { href: '', hash: '' } }); + jest.spyOn(SessionService, 'getPreauthInfo').mockResolvedValue({ + config: { oktaEnabled: true }, + } as any); + const isActiveSpy = jest.spyOn(tokenAuthService, 'isActiveService'); + + await authInstance.doSamlAuth(embedConfig.doSamlAuth); + + expect(global.window.location.href).toBe(samalLoginUrl); + expect(isActiveSpy).not.toHaveBeenCalled(); + }); + + it('OIDCRedirect: after Okta redirect, treats SSO marker as successful auth', async () => { + Object.defineProperty(window, 'location', { + value: { + href: `asd.com#?tsSSOMarker=${authInstance.SSO_REDIRECTION_MARKER_GUID}`, + hash: `?tsSSOMarker=${authInstance.SSO_REDIRECTION_MARKER_GUID}`, + }, + }); + jest.spyOn(SessionService, 'getPreauthInfo').mockResolvedValue({ + config: { oktaEnabled: true }, + } as any); + const isActiveSpy = jest.spyOn(tokenAuthService, 'isActiveService'); + + await authInstance.doOIDCAuth(embedConfig.doOidcAuth); + + expect(window.location.hash).toBe(''); + expect(authInstance.loggedInStatus).toBe(true); + expect(isActiveSpy).not.toHaveBeenCalled(); + }); + + it('preserves IAMv1 behavior when oktaEnabled is false', async () => { + Object.defineProperty(window, 'location', { + value: { + href: `asd.com#?tsSSOMarker=${authInstance.SSO_REDIRECTION_MARKER_GUID}`, + hash: `?tsSSOMarker=${authInstance.SSO_REDIRECTION_MARKER_GUID}`, + }, + }); + jest.spyOn(SessionService, 'getPreauthInfo').mockResolvedValue({ + config: { oktaEnabled: false }, + } as any); + // IAMv1 path: cookie probe is the authoritative session check. + const isActiveSpy = jest + .spyOn(tokenAuthService, 'isActiveService') + .mockResolvedValue(false); + + await authInstance.doSamlAuth(embedConfig.doSamlAuth); + + expect(window.location.hash).toBe(''); + expect(authInstance.loggedInStatus).toBe(false); + expect(isActiveSpy).toHaveBeenCalled(); + }); + + it('falls back to IAMv1 behavior when getPreauthInfo fails', async () => { + Object.defineProperty(window, 'location', { value: { href: '', hash: '' } }); + jest.spyOn(SessionService, 'getPreauthInfo').mockRejectedValue(new Error('boom')); + jest.spyOn(tokenAuthService, 'isActiveService').mockResolvedValue(false); + + await authInstance.doSamlAuth(embedConfig.doSamlAuth); + + // Fell through to the IAMv1 redirect branch. + expect(global.window.location.href).toBe(samalLoginUrl); + }); + + it('SAMLRedirect with inPopup: runs popup flow and sets loggedInStatus from cachedAuthToken (no isactive call)', async () => { + Object.defineProperty(window, 'location', { value: { href: '', hash: '' } }); + jest.spyOn(SessionService, 'getPreauthInfo').mockResolvedValue({ + config: { oktaEnabled: true }, + } as any); + const isActiveSpy = jest.spyOn(tokenAuthService, 'isActiveService'); + // Pre-resolve so samlPopupFlow returns immediately. + (authInstance as any).samlCompletionPromise = Promise.resolve(); + global.window.open = jest.fn(); + checkReleaseVersionInBetaInstance.storeValueInWindow('cachedAuthToken', 'iamv2-popup-token'); + + await authInstance.doSamlAuth({ ...embedConfig.doSamlAuthNoRedirect }); + + expect(authInstance.loggedInStatus).toBe(true); + // Critical IAMv2 invariant: the cookie probe is never called. + expect(isActiveSpy).not.toHaveBeenCalled(); + }); + }); + it('authenticate: when authType is SSO', async () => { jest.spyOn(authInstance, 'doSamlAuth'); await authInstance.authenticate(embedConfig.SSOAuth); diff --git a/src/auth.ts b/src/auth.ts index 15c36837..8848269a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -283,6 +283,25 @@ async function isLoggedIn(thoughtSpotHost: string): Promise { } } +/** + * Detect whether the cluster has IAMv2 (Okta) enabled. + * + * On IAMv2 clusters Callosum sets JSESSIONID on the cluster domain after OIDC + * callback, but browsers commonly block it from being sent on cross-origin + * requests from the parent app (third-party cookie blocking, nested iframes). + * `/callosum/v1/session/isactive` therefore returns 401 and is unusable for + * detecting a logged-in state — we use `config.oktaEnabled` from + * `/prism/preauth/info` as the IAMv2 signal instead. + */ +async function isIAMv2Enabled(): Promise { + try { + const preauth = await getPreauthInfo(); + return Boolean(preauth?.config?.oktaEnabled); + } catch (e) { + return false; + } +} + /** * Services to be called after the login is successful, * This should be called after the cookie is set for cookie auth or @@ -487,6 +506,36 @@ async function samlPopupFlow(ssoURL: string, triggerContainer: DOMSelector, trig */ const doSSOAuth = async (embedConfig: EmbedConfig, ssoEndPoint: string): Promise => { const { thoughtSpotHost } = embedConfig; + // On IAMv2 (Okta) clusters, the cookie-based isLoggedIn() probe returns + // 401 from cross-origin parent apps under third-party cookie blocking, + // so it cannot be used to detect auth state. Detect Okta upfront and + // branch around it. + const iamV2 = await isIAMv2Enabled(); + + if (iamV2) { + // After Okta redirects back to the parent app, the SSO marker is the + // authoritative signal that the IdP completed authentication. The + // embed iframe re-establishes the in-iframe session on load. + if (isAtSSORedirectUrl()) { + removeSSORedirectUrlMarker(); + loggedInStatus = true; + return; + } + // First-load on IAMv2: skip the broken cookie probe, redirect to Okta. + const iamV2SsoURL = `${thoughtSpotHost}${ssoEndPoint}`; + if (embedConfig.inPopup) { + await samlPopupFlow( + iamV2SsoURL, + embedConfig.authTriggerContainer, + embedConfig.authTriggerText, + ); + loggedInStatus = Boolean(getCacheAuthToken()); + return; + } + window.location.href = iamV2SsoURL; + return; + } + const loggedIn = await isLoggedIn(thoughtSpotHost); if (loggedIn) { if (isAtSSORedirectUrl()) { diff --git a/src/utils/authService/tokenizedAuthService.spec.ts b/src/utils/authService/tokenizedAuthService.spec.ts index 74128f20..46210e6c 100644 --- a/src/utils/authService/tokenizedAuthService.spec.ts +++ b/src/utils/authService/tokenizedAuthService.spec.ts @@ -79,16 +79,22 @@ describe('fetchPreauthInfoService', () => { }); it('fetchPreauthInfoService if fetch fails', async () => { const mockFetch = jest.spyOn(tokenizedFetchModule, 'tokenizedFetch'); - // Prevent logger.error from reaching console.error (which throws in test env) + // Prevent logger.error from reaching console.error (which throws in + // test env) jest.spyOn(logger, 'error').mockImplementation(() => {}); - mockFetch.mockResolvedValueOnce({ + // Mock for fetchPreauthInfoService — include clone() so the failure + // logger can read body via r.clone().text() without consuming the + // original Response's body stream. + const mockResponse: any = { ok: false, status: 500, statusText: 'Internal Server Error', json: jest.fn().mockResolvedValue({}), text: jest.fn().mockResolvedValue('Internal Server Error'), - } as any); + }; + mockResponse.clone = jest.fn().mockReturnValue(mockResponse); + mockFetch.mockResolvedValueOnce(mockResponse); const response = await fetchPreauthInfoService(thoughtspotHost); expect(response.ok).toBe(false); @@ -97,4 +103,31 @@ describe('fetchPreauthInfoService', () => { expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith(`${thoughtspotHost}${EndPoints.PREAUTH_INFO}`, {}); }); + + it('failure logging reads body from a clone, leaving the original intact for the caller', async () => { + jest.spyOn(logger, 'error').mockImplementation(() => {}); + const mockFetch = jest.spyOn(tokenizedFetchModule, 'tokenizedFetch'); + const originalJson = jest.fn().mockResolvedValue({ config: { oktaEnabled: true } }); + const cloneText = jest.fn().mockResolvedValue('{"config":{"oktaEnabled":true}}'); + const cloneResponse: any = { text: cloneText }; + const mockResponse: any = { + ok: false, + status: 401, + statusText: 'Unauthorized', + headers: new Headers({ 'content-type': 'application/json' }), + json: originalJson, + clone: jest.fn().mockReturnValue(cloneResponse), + }; + mockFetch.mockResolvedValueOnce(mockResponse); + + const result = await fetchPreauthInfoService(thoughtspotHost); + + // Failure logger consumed the clone's body, not the original's. + expect(mockResponse.clone).toHaveBeenCalled(); + expect(cloneText).toHaveBeenCalled(); + // Original Response body remains parseable by the caller. + const body = await result.json(); + expect(body).toEqual({ config: { oktaEnabled: true } }); + expect(originalJson).toHaveBeenCalled(); + }); }); diff --git a/src/utils/authService/tokenizedAuthService.ts b/src/utils/authService/tokenizedAuthService.ts index 5574ad9c..3a38898c 100644 --- a/src/utils/authService/tokenizedAuthService.ts +++ b/src/utils/authService/tokenizedAuthService.ts @@ -10,7 +10,12 @@ import { EndPoints } from './authService'; function tokenizedFailureLoggedFetch(url: string, options: RequestInit = {}): Promise { return tokenizedFetch(url, options).then(async (r) => { if (!r.ok && r.type !== 'opaqueredirect' && r.type !== 'opaque') { - logger.error(`Failed to fetch ${url}`, await r.text?.()); + // Clone before reading so the original Response body remains + // intact for the caller. Some endpoints (e.g. /prism/preauth/info + // on Okta-enabled clusters) return non-OK statuses with a valid + // JSON body that callers still need to parse. + const forLogging = typeof r.clone === 'function' ? r.clone() : r; + logger.error(`Failed to fetch ${url}`, await forLogging.text?.()); } return r; });