Skip to content
Open
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
113 changes: 113 additions & 0 deletions src/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
49 changes: 49 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,25 @@ async function isLoggedIn(thoughtSpotHost: string): Promise<boolean> {
}
}

/**
* 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<boolean> {
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
Expand Down Expand Up @@ -487,6 +506,36 @@ async function samlPopupFlow(ssoURL: string, triggerContainer: DOMSelector, trig
*/
const doSSOAuth = async (embedConfig: EmbedConfig, ssoEndPoint: string): Promise<void> => {
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()) {
Expand Down
39 changes: 36 additions & 3 deletions src/utils/authService/tokenizedAuthService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
});
});
7 changes: 6 additions & 1 deletion src/utils/authService/tokenizedAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { EndPoints } from './authService';
function tokenizedFailureLoggedFetch(url: string, options: RequestInit = {}): Promise<Response> {
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;
});
Expand Down
Loading