mirror of
https://github.com/apache/superset.git
synced 2026-05-15 12:55:08 +00:00
Compare commits
3 Commits
work-pr-39
...
fix-oauth-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9af82715d2 | ||
|
|
bb840a7720 | ||
|
|
e518c293d1 |
@@ -20,7 +20,7 @@
|
||||
import * as reduxHooks from 'react-redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createStore } from 'redux';
|
||||
import { render, fireEvent, waitFor } from 'spec/helpers/testing-library';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core';
|
||||
import { reRunQuery } from 'src/SqlLab/actions/sqlLab';
|
||||
import { triggerQuery } from 'src/components/Chart/chartAction';
|
||||
@@ -58,25 +58,33 @@ jest.mock('src/dashboard/actions/dashboardState', () => ({
|
||||
const mockDispatch = jest.fn();
|
||||
jest.spyOn(reduxHooks, 'useDispatch').mockReturnValue(mockDispatch);
|
||||
|
||||
// Mock global window functions
|
||||
const mockOpen = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
const mockAddEventListener = jest.spyOn(window, 'addEventListener');
|
||||
const mockRemoveEventListener = jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
// Mock window.postMessage
|
||||
const originalPostMessage = window.postMessage;
|
||||
// Capture the channel instance created by the component so tests can drive its
|
||||
// onmessage handler and assert it gets closed on unmount.
|
||||
let capturedChannel: {
|
||||
onmessage: ((event: any) => void) | null;
|
||||
close: jest.Mock;
|
||||
};
|
||||
const channelCloseMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
window.postMessage = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
capturedChannel = { onmessage: null, close: channelCloseMock };
|
||||
(global as any).BroadcastChannel = jest
|
||||
.fn()
|
||||
.mockImplementation(() => capturedChannel);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.postMessage = originalPostMessage;
|
||||
});
|
||||
function simulateBroadcastMessage(data: any) {
|
||||
capturedChannel.onmessage?.({ data });
|
||||
}
|
||||
|
||||
function simulateMessageEvent(data: any, origin: string) {
|
||||
const messageEvent = new MessageEvent('message', { data, origin });
|
||||
window.dispatchEvent(messageEvent);
|
||||
function simulateStorageMessage(data: any) {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: 'oauth2_auth_complete',
|
||||
newValue: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
@@ -108,27 +116,36 @@ describe('OAuth2RedirectMessage Component', () => {
|
||||
expect(getByText(/provide authorization/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens a new window with the correct URL when the link is clicked', () => {
|
||||
test('renders the authorization link pointing at the OAuth2 URL', () => {
|
||||
const { getByText } = render(setup());
|
||||
|
||||
const linkElement = getByText(/provide authorization/i);
|
||||
fireEvent.click(linkElement);
|
||||
|
||||
expect(mockOpen).toHaveBeenCalledWith('https://example.com', '_blank');
|
||||
const linkElement = getByText(/provide authorization/i).closest('a');
|
||||
expect(linkElement).toHaveAttribute('href', 'https://example.com');
|
||||
expect(linkElement).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
test('cleans up the message event listener on unmount', () => {
|
||||
test('closes the BroadcastChannel on unmount', () => {
|
||||
const { unmount } = render(setup());
|
||||
|
||||
expect(mockAddEventListener).toHaveBeenCalled();
|
||||
expect((global as any).BroadcastChannel).toHaveBeenCalledWith('oauth');
|
||||
unmount();
|
||||
expect(mockRemoveEventListener).toHaveBeenCalled();
|
||||
expect(channelCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dispatches reRunQuery action when a message with correct tab ID is received for SQL Lab', async () => {
|
||||
render(setup());
|
||||
|
||||
simulateMessageEvent({ tabId: 'tabId' }, 'https://redirect.example.com');
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
|
||||
render(setup());
|
||||
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
|
||||
@@ -138,7 +155,7 @@ describe('OAuth2RedirectMessage Component', () => {
|
||||
test('dispatches triggerQuery action for explore source upon receiving a correct message', async () => {
|
||||
render(setup({ source: 'explore' }));
|
||||
|
||||
simulateMessageEvent({ tabId: 'tabId' }, 'https://redirect.example.com');
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(triggerQuery).toHaveBeenCalledWith(true, 123);
|
||||
@@ -148,11 +165,19 @@ describe('OAuth2RedirectMessage Component', () => {
|
||||
test('dispatches onRefresh action for dashboard source upon receiving a correct message', async () => {
|
||||
render(setup({ source: 'dashboard' }));
|
||||
|
||||
simulateMessageEvent({ tabId: 'tabId' }, 'https://redirect.example.com');
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
// Chart IDs are converted to numbers by the component via chartList.map(Number)
|
||||
expect(onRefresh).toHaveBeenCalledWith([1, 2], true, 0, 'dashboard-id');
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores messages with a mismatched tab ID', () => {
|
||||
render(setup());
|
||||
|
||||
simulateBroadcastMessage({ tabId: 'someOtherTab' });
|
||||
|
||||
expect(reRunQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useRef, MouseEvent } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
@@ -31,10 +31,12 @@ import { QueryResponse } from '@superset-ui/core';
|
||||
import type { ErrorMessageComponentProps } from './types';
|
||||
import { ErrorAlert } from './ErrorAlert';
|
||||
|
||||
const OAUTH_CHANNEL_NAME = 'oauth';
|
||||
const OAUTH_STORAGE_EVENT_KEY = 'oauth2_auth_complete';
|
||||
|
||||
interface OAuth2RedirectExtra {
|
||||
url: string;
|
||||
tab_id: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -52,29 +54,20 @@ interface OAuth2RedirectExtra {
|
||||
* be used in subsequent connections. If a refresh token is also present in the response,
|
||||
* it will also be stored.
|
||||
*
|
||||
* After the token has been stored, the opened tab will send a message to the original
|
||||
* tab and close itself. This component, running on the original tab, will listen for
|
||||
* message events, and once it receives the success message from the opened tab it will
|
||||
* re-run the query for the user, be it in SQL Lab, Explore, or a dashboard. In order to
|
||||
* communicate securely, both tabs share a "tab ID", which is a UUID that is generated
|
||||
* by the backend and sent from the opened tab to the original tab. For extra security,
|
||||
* we also check that the source of the message is the opened tab via a ref.
|
||||
* After the token has been stored, the opened tab will broadcast a message to the
|
||||
* original tab and close itself. This component, running on the original tab, listens
|
||||
* on a same-origin BroadcastChannel and re-runs the query for the user once it
|
||||
* receives the success message — be it in SQL Lab, Explore, or a dashboard. Both tabs
|
||||
* share a "tab ID" (a UUID generated by the backend) which is echoed back through the
|
||||
* channel so the original tab only reacts to its own OAuth2 flow.
|
||||
*/
|
||||
export function OAuth2RedirectMessage({
|
||||
error,
|
||||
source,
|
||||
closable,
|
||||
}: ErrorMessageComponentProps<OAuth2RedirectExtra>) {
|
||||
const oAuthTab = useRef<Window | null>(null);
|
||||
const { extra, level } = error;
|
||||
|
||||
// store a reference to the OAuth2 browser tab, so we can check that the success
|
||||
// message is coming from it
|
||||
const handleOAuthClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
oAuthTab.current = window.open(extra.url, '_blank');
|
||||
};
|
||||
|
||||
// state needed for re-running the SQL Lab query
|
||||
const queries = useSelector<
|
||||
SqlLabRootState,
|
||||
@@ -107,43 +100,50 @@ export function OAuth2RedirectMessage({
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
/* Listen for messages from the OAuth2 tab.
|
||||
*
|
||||
* After OAuth2 is successful the opened tab will send a message before
|
||||
* closing itself. Once we receive the message we can retrigger the
|
||||
* original query in SQL Lab, explore, or in a dashboard.
|
||||
*/
|
||||
const redirectUrl = new URL(extra.redirect_uri);
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (
|
||||
event.origin === redirectUrl.origin &&
|
||||
event.data.tabId === extra.tab_id &&
|
||||
event.source === oAuthTab.current
|
||||
) {
|
||||
if (source === 'sqllab' && query) {
|
||||
dispatch(reRunQuery(query));
|
||||
} else if (source === 'explore' && chartId) {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
} else if (source === 'dashboard') {
|
||||
dispatch(onRefresh(chartList.map(Number), true, 0, dashboardId));
|
||||
}
|
||||
const handleOAuthComplete = (tabId?: string) => {
|
||||
if (tabId !== extra.tab_id) {
|
||||
return;
|
||||
}
|
||||
if (source === 'sqllab' && query) {
|
||||
dispatch(reRunQuery(query));
|
||||
} else if (source === 'explore' && chartId) {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
} else if (source === 'dashboard') {
|
||||
dispatch(onRefresh(chartList.map(Number), true, 0, dashboardId));
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
const channel =
|
||||
typeof BroadcastChannel !== 'undefined'
|
||||
? new BroadcastChannel(OAUTH_CHANNEL_NAME)
|
||||
: null;
|
||||
|
||||
if (channel) {
|
||||
channel.onmessage = event => {
|
||||
handleOAuthComplete(event.data?.tabId);
|
||||
};
|
||||
}
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== OAUTH_STORAGE_EVENT_KEY || !event.newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(event.newValue) as { tabId?: string };
|
||||
handleOAuthComplete(message.tabId);
|
||||
} catch {
|
||||
// ignore malformed storage payloads
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
channel?.close();
|
||||
};
|
||||
}, [
|
||||
source,
|
||||
extra.redirect_uri,
|
||||
extra.tab_id,
|
||||
dispatch,
|
||||
query,
|
||||
chartId,
|
||||
chartList,
|
||||
dashboardId,
|
||||
]);
|
||||
}, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]);
|
||||
|
||||
const body = (
|
||||
<p>
|
||||
@@ -155,12 +155,7 @@ export function OAuth2RedirectMessage({
|
||||
const subtitle = (
|
||||
<>
|
||||
{t('You need to')}{' '}
|
||||
<a
|
||||
href={extra.url}
|
||||
onClick={handleOAuthClick}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<a href={extra.url} target="_blank" rel="noreferrer">
|
||||
{t('provide authorization')}
|
||||
</a>{' '}
|
||||
{t('in order to run this operation.')}
|
||||
|
||||
@@ -22,8 +22,15 @@ under the License.
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
window.opener.postMessage({ tabId: '{{ tab_id }}' });
|
||||
<script nonce="{{ get_nonce() }}">
|
||||
const message = { tabId: '{{ tab_id }}' };
|
||||
if (typeof BroadcastChannel !== 'undefined') {
|
||||
const channel = new BroadcastChannel('oauth');
|
||||
channel.postMessage(message);
|
||||
channel.close();
|
||||
}
|
||||
localStorage.setItem('oauth2_auth_complete', JSON.stringify(message));
|
||||
localStorage.removeItem('oauth2_auth_complete');
|
||||
window.close();
|
||||
</script>
|
||||
<p>You can close this window and re-run the query.</p>
|
||||
|
||||
Reference in New Issue
Block a user