mirror of
https://github.com/qlik-oss/nebula.js.git
synced 2025-12-19 17:58:43 -05:00
fix(cli-serve): OAuth instance cache issue (#1492)
* fix: client id cache issue * fix: clean cached auth state when deauthorizing * chore: move auth routes into specific router module * chore: update redirect link for OAuth in connection guid * feat: error page for old /login/callback url
This commit is contained in:
121
commands/serve/lib/oauth-router.js
Normal file
121
commands/serve/lib/oauth-router.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const express = require('express');
|
||||
const { Auth, AuthType } = require('@qlik/sdk');
|
||||
|
||||
let prevHost = null;
|
||||
let prevClientId = null;
|
||||
let authInstance = null;
|
||||
|
||||
const getAuthInstance = (returnToOrigin, host, clientId) => {
|
||||
if (authInstance && prevHost === host && prevClientId === clientId) {
|
||||
return authInstance;
|
||||
}
|
||||
|
||||
prevHost = host;
|
||||
prevClientId = clientId;
|
||||
|
||||
authInstance = new Auth({
|
||||
authType: AuthType.OAuth2,
|
||||
host,
|
||||
clientId,
|
||||
redirectUri: `${returnToOrigin}/auth/login/callback`,
|
||||
});
|
||||
return authInstance;
|
||||
};
|
||||
|
||||
const OAuthRouter = ({ originUrl }) => {
|
||||
const router = express.Router();
|
||||
|
||||
let cachedHost = null;
|
||||
let cachedClientId = null;
|
||||
|
||||
router.get('/oauth', async (req, res) => {
|
||||
const { host: qHost, clientId: qClientId } = req.query;
|
||||
if (!cachedHost && !cachedClientId) {
|
||||
cachedHost = qHost;
|
||||
cachedClientId = qClientId;
|
||||
}
|
||||
|
||||
const returnTo = `${req.protocol}://${req.get('host')}`;
|
||||
const instacne = getAuthInstance(returnTo, qHost, qClientId);
|
||||
const isAuthorized = await instacne.isAuthorized();
|
||||
if (!isAuthorized) {
|
||||
const { url: redirectUrl } = await instacne.generateAuthorizationUrl();
|
||||
res.status(200).json({ redirectUrl });
|
||||
} else {
|
||||
const reqHost = req.get('host');
|
||||
const redirectUrl = `${req.protocol}://${reqHost}/app-list?engine_url=wss://${cachedHost}&qlik-client-id=${cachedClientId}&shouldFetchAppList=true`;
|
||||
|
||||
cachedHost = null;
|
||||
cachedClientId = null;
|
||||
|
||||
res.redirect(redirectUrl);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/login/callback', async (req, res) => {
|
||||
const authLink = new URL(req.url, `http://${req.headers.host}`).href;
|
||||
try {
|
||||
// TODO:
|
||||
// this is a temp fix in nebula side
|
||||
// (temp workaround of not presisting origin while backend tries to authorize user)
|
||||
// they need to handle this in qlik-sdk-typescript repo
|
||||
// and will notify us about when they got fixed it,
|
||||
// but until then, we need to take care of it here!
|
||||
authInstance.rest.interceptors.request.use((_req) => {
|
||||
// eslint-disable-next-line no-param-reassign, dot-notation
|
||||
_req[1]['headers'] = { origin: originUrl };
|
||||
return _req;
|
||||
});
|
||||
await authInstance.authorize(authLink);
|
||||
res.redirect(301, `/auth/oauth?host=${cachedHost}&clientId=${cachedClientId}`);
|
||||
} catch (err) {
|
||||
console.log({ err });
|
||||
res.status(401).send({
|
||||
message: 'Auth failed in callback redirecting, based on the provided auth link!',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/deauthorize', async (req, res) => {
|
||||
try {
|
||||
prevHost = null;
|
||||
prevClientId = null;
|
||||
authInstance = null;
|
||||
|
||||
cachedHost = null;
|
||||
cachedClientId = null;
|
||||
await authInstance?.deauthorize();
|
||||
|
||||
res.status(200).json({
|
||||
deauthorize: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/isAuthorized', async (req, res) => {
|
||||
if (!authInstance) {
|
||||
res.status(200).json({
|
||||
isAuthorized: false,
|
||||
});
|
||||
} else {
|
||||
const isAuthorized = await authInstance.isAuthorized();
|
||||
res.status(200).json({
|
||||
isAuthorized,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/getSocketUrl/:appId', async (req, res) => {
|
||||
const { appId } = req.params;
|
||||
const webSocketUrl = await authInstance.generateWebsocketUrl(appId, true);
|
||||
res.status(200).json({ webSocketUrl });
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
const getAvailableAuthInstance = () => authInstance;
|
||||
|
||||
module.exports = { OAuthRouter, getAvailableAuthInstance };
|
||||
@@ -4,7 +4,6 @@ const fs = require('fs');
|
||||
const homedir = require('os').homedir();
|
||||
const chalk = require('chalk');
|
||||
const express = require('express');
|
||||
const { Auth, AuthType } = require('@qlik/sdk');
|
||||
|
||||
const webpack = require('webpack');
|
||||
const WebpackDevServer = require('webpack-dev-server');
|
||||
@@ -12,22 +11,11 @@ const WebpackDevServer = require('webpack-dev-server');
|
||||
const snapshooterFn = require('./snapshot-server');
|
||||
const snapshotRouter = require('./snapshot-router');
|
||||
|
||||
const { OAuthRouter, getAvailableAuthInstance } = require('./oauth-router');
|
||||
|
||||
const httpsKeyPath = path.join(homedir, '.certs/key.pem');
|
||||
const httpsCertPath = path.join(homedir, '.certs/cert.pem');
|
||||
|
||||
let authInstance = null;
|
||||
const getAuthInstance = (returnToOrigin, host, clientId) => {
|
||||
if (authInstance) return authInstance;
|
||||
|
||||
authInstance = new Auth({
|
||||
authType: AuthType.OAuth2,
|
||||
host,
|
||||
clientId,
|
||||
redirectUri: `${returnToOrigin}/login/callback`,
|
||||
});
|
||||
return authInstance;
|
||||
};
|
||||
|
||||
module.exports = async ({
|
||||
host,
|
||||
port,
|
||||
@@ -65,6 +53,7 @@ module.exports = async ({
|
||||
snapshooter.storeSnapshot(s);
|
||||
});
|
||||
|
||||
const authRouter = OAuthRouter({ originUrl: url });
|
||||
const snapRouter = snapshotRouter({
|
||||
base: `${url}${snapshotRoute}`,
|
||||
snapshotUrl: `${url}/eRender.html`,
|
||||
@@ -118,6 +107,8 @@ module.exports = async ({
|
||||
},
|
||||
onBeforeSetupMiddleware(devServer) {
|
||||
const { app } = devServer;
|
||||
|
||||
app.use('/auth', authRouter);
|
||||
app.use(snapshotRoute, snapRouter);
|
||||
|
||||
if (entryWatcher) {
|
||||
@@ -173,87 +164,9 @@ module.exports = async ({
|
||||
});
|
||||
});
|
||||
|
||||
let cachedHost = null;
|
||||
let cachedClientId = null;
|
||||
|
||||
app.get('/oauth', async (req, res) => {
|
||||
const { host: qHost, clientId: qClientId } = req.query;
|
||||
if (!cachedHost && !cachedClientId) {
|
||||
cachedHost = qHost;
|
||||
cachedClientId = qClientId;
|
||||
}
|
||||
|
||||
const returnTo = `${req.protocol}://${req.get('host')}`;
|
||||
const instacne = getAuthInstance(returnTo, qHost, qClientId);
|
||||
const isAuthorized = await instacne.isAuthorized();
|
||||
if (!isAuthorized) {
|
||||
const { url: redirectUrl } = await instacne.generateAuthorizationUrl();
|
||||
res.status(200).json({ redirectUrl });
|
||||
} else {
|
||||
const redirectUrl = `${req.protocol}://${req.get(
|
||||
'host'
|
||||
)}/app-list?engine_url=wss://${cachedHost}&qlik-client-id=${cachedClientId}&shouldFetchAppList=true`;
|
||||
cachedHost = null;
|
||||
cachedClientId = null;
|
||||
res.redirect(redirectUrl);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/login/callback', async (req, res) => {
|
||||
const authLink = new URL(req.url, `http://${req.headers.host}`).href;
|
||||
try {
|
||||
// TODO:
|
||||
// this is a temp fix in front end side
|
||||
// (temp workaround of not presisting origin while backend tries to authorize user)
|
||||
// they need to handle this in qlik-sdk-typescript repo
|
||||
// and will notify us about when they got fixed it,
|
||||
// but until then, we need to take care of it here!
|
||||
authInstance.rest.interceptors.request.use((_req) => {
|
||||
// eslint-disable-next-line no-param-reassign, dot-notation
|
||||
_req[1]['headers'] = { origin: url };
|
||||
return _req;
|
||||
});
|
||||
await authInstance.authorize(authLink);
|
||||
res.redirect(301, '/oauth/');
|
||||
} catch (err) {
|
||||
console.log({ err });
|
||||
res.status(401).send(JSON.stringify(err, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/getSocketUrl/:appId', async (req, res) => {
|
||||
const { appId } = req.params;
|
||||
const webSocketUrl = await authInstance.generateWebsocketUrl(appId, true);
|
||||
res.status(200).json({ webSocketUrl });
|
||||
});
|
||||
|
||||
app.get('/deauthorize', async (req, res) => {
|
||||
try {
|
||||
await authInstance.deauthorize();
|
||||
res.status(200).json({
|
||||
deauthorize: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/isAuthorized', async (req, res) => {
|
||||
if (!authInstance) {
|
||||
res.status(200).json({
|
||||
isAuthorized: false,
|
||||
});
|
||||
} else {
|
||||
const isAuthorized = await authInstance.isAuthorized();
|
||||
res.status(200).json({
|
||||
isAuthorized,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/apps', async (req, res) => {
|
||||
const appsListUrl = `/items?resourceType=app&limit=30&sort=-updatedAt`;
|
||||
const { data = [] } = await (await authInstance.rest(appsListUrl)).json();
|
||||
const { data = [] } = await (await getAvailableAuthInstance().rest(appsListUrl)).json();
|
||||
res.status(200).json(
|
||||
data.map((d) => ({
|
||||
qDocId: d.resourceId,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
import { ThemeWrapper } from '../../ThemeWrapper';
|
||||
import { ContentWrapper } from '../styles';
|
||||
|
||||
/**
|
||||
*
|
||||
* Since we updated our OAuth redirect link to include `/auth/` part in it,
|
||||
* we are currently showing this error page for old client-ids that
|
||||
* are still trying to redirect to the /login/callback url
|
||||
* the point is to give them a quick guid about update their
|
||||
* redirect url in tenant
|
||||
*/
|
||||
const OAuthRedirectLinkError = () => {
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<ContentWrapper marginTop={5}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Error!
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
OAUTH REDIRECT LINK ERROR!
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
We updated our route which was previously had the responsibility to handle the redirected link including the
|
||||
correct credentials to have{' '}
|
||||
<code>
|
||||
<b>/auth/</b>
|
||||
</code>{' '}
|
||||
part within it. This change is now impacting your redirect links if you already using your old client-id.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Here is what you need to do:
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" paragraph>
|
||||
<b>1.</b> Go to your tenant that you created your client id
|
||||
<br />
|
||||
<br />
|
||||
<b>2.</b> Open Managment console
|
||||
<br />
|
||||
<br />
|
||||
<b>3.</b> Click on OAuth menu in left panel (if you dont see that menu it means you dont have access to it and
|
||||
you need to ask a person who has the proper access)
|
||||
<br />
|
||||
<br />
|
||||
<b>4.</b> Find your Client id and from ... menu, click on edit
|
||||
<br />
|
||||
<br />
|
||||
<b>5.</b> There is a section called "Add redirect URLs", find it and add your new link. The only
|
||||
difference is that your new link will include `/auth/` part in it, for example, if you had this redirect link
|
||||
previously:
|
||||
<code>http://localhost:8000/login/callback</code>
|
||||
<br />
|
||||
now it will be like:
|
||||
<code>
|
||||
http://localhost:8000/<b>auth/</b>login/callback
|
||||
</code>
|
||||
<br />
|
||||
<br />
|
||||
<b>6.</b> Click on save and you are set!
|
||||
<br />
|
||||
</Typography>
|
||||
</ContentWrapper>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthRedirectLinkError;
|
||||
@@ -51,7 +51,7 @@ const ConnectionGuid = ({ showGuid }) => (
|
||||
<br />
|
||||
Example: <code>wss://qlik.eu.qlikcloud.com?qlik-client-id=xxx</code>
|
||||
<br />
|
||||
Redirect URL form: http://localhost:8000/login/callback
|
||||
Redirect URL form: <code>http://localhost:8000/auth/login/callback</code>
|
||||
<br />
|
||||
The <code>qlik-web-integration-id</code> <b>OR</b> <code>qlik-client-id</code> must be present in order for QCS to
|
||||
confirm that the request originates from a whitelisted domain.
|
||||
|
||||
@@ -9,10 +9,12 @@ import ConnectionHistory from './ConnectionHistory';
|
||||
import ConnectionOptions from './ConnectionOptions';
|
||||
import { ContentWrapper } from '../styles';
|
||||
import { useRootContext } from '../../../contexts/RootContext';
|
||||
import { useDeauthorizePrevOAuthInstance } from '../../../hooks';
|
||||
|
||||
const SelectEngine = () => {
|
||||
const { cachedConnectionsData } = useRootContext();
|
||||
const [showGuid, setShowGuid] = useState(false);
|
||||
useDeauthorizePrevOAuthInstance();
|
||||
|
||||
useEffect(() => {
|
||||
setShowGuid(!cachedConnectionsData.cachedConnections.length);
|
||||
|
||||
@@ -3,11 +3,12 @@ import { styled } from '@mui/system';
|
||||
import Stepper from '@mui/material/Stepper';
|
||||
import StepLabel from '@mui/material/StepLabel';
|
||||
|
||||
export const ContentWrapper = styled(Box)(({ theme }) => ({
|
||||
export const ContentWrapper = styled(Box)(({ theme, marginTop = 0 }) => ({
|
||||
position: 'relative',
|
||||
padding: theme.spacing(2, 2),
|
||||
backgroundColor: 'white',
|
||||
borderRadius: theme.spacing(1),
|
||||
marginTop: theme.spacing(marginTop),
|
||||
}));
|
||||
|
||||
export const StepperWrapper = styled(Stepper)(({ theme }) => ({
|
||||
|
||||
@@ -6,6 +6,7 @@ import HubLayout from './Layouts/HubLayout';
|
||||
import SelectEngine from './Hub/SelectEngine/SelectEngine';
|
||||
import AppList from './Hub/AppList';
|
||||
import Visualize from './Visualize/Visualize';
|
||||
import OAuthRedirectLinkError from './Hub/Errors/OAuthRedirectLinkError';
|
||||
|
||||
export const Root = () => (
|
||||
<BrowserRouter>
|
||||
@@ -16,6 +17,7 @@ export const Root = () => (
|
||||
<Route path="/app-list" element={<AppList />} />
|
||||
</Route>
|
||||
<Route path="/dev" element={<Visualize />} />
|
||||
<Route path="/login/callback" element={<OAuthRedirectLinkError />} />
|
||||
</Routes>
|
||||
</RootContextProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -136,9 +136,9 @@ const connect = async () => {
|
||||
} = await getConnectionInfo();
|
||||
|
||||
// if no clientId + user is already authorized -> deAuthorize user
|
||||
const { isAuthorized } = await (await fetch('/isAuthorized')).json();
|
||||
const { isAuthorized } = await (await fetch('/auth/isAuthorized')).json();
|
||||
if (!clientId && isAuthorized) {
|
||||
await (await fetch('/deauthorize')).json();
|
||||
await (await fetch('/auth/deauthorize')).json();
|
||||
}
|
||||
|
||||
if (webIntegrationId) {
|
||||
@@ -159,7 +159,8 @@ const connect = async () => {
|
||||
if (clientId) {
|
||||
return {
|
||||
getDocList: async () => {
|
||||
const resp = await (await fetch(`/oauth?host=${host}&clientId=${clientId}`)).json();
|
||||
const URL = `/auth/oauth?host=${host}&clientId=${clientId}`;
|
||||
const resp = await (await fetch(URL)).json();
|
||||
if (resp.redirectUrl) window.location.href = resp.redirectUrl;
|
||||
},
|
||||
getConfiguration: async () => ({}),
|
||||
@@ -191,7 +192,7 @@ const openApp = async (id) => {
|
||||
const authInstance = await getAuthInstance({ webIntegrationId, host });
|
||||
url = await authInstance.generateWebsocketUrl(id);
|
||||
} else if (clientId) {
|
||||
const { webSocketUrl } = await (await fetch(`/getSocketUrl/${id}`)).json();
|
||||
const { webSocketUrl } = await (await fetch(`/auth/getSocketUrl/${id}`)).json();
|
||||
url = webSocketUrl;
|
||||
} else {
|
||||
url = SenseUtilities.buildUrl(enigmaInfo);
|
||||
|
||||
@@ -3,3 +3,4 @@ export { useConnection } from './useConnection';
|
||||
export { useAppList } from './useAppList';
|
||||
export { useOpenApp } from './useOpenApp';
|
||||
export { useCachedConnections } from './useCachedConnections';
|
||||
export { useDeauthorizePrevOAuthInstance } from './useDeauthorizePrevOAuthInstance';
|
||||
|
||||
21
commands/serve/web/hooks/useDeauthorizePrevOAuthInstance.js
Normal file
21
commands/serve/web/hooks/useDeauthorizePrevOAuthInstance.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useRootContext } from '../contexts/RootContext';
|
||||
|
||||
const { useEffect } = require('react');
|
||||
|
||||
export const useDeauthorizePrevOAuthInstance = () => {
|
||||
const { cachedConnectionsData } = useRootContext();
|
||||
|
||||
useEffect(() => {
|
||||
// deuathorize any oauth state if any was available
|
||||
// this is b/c we need to cleaning up any previous state of Auth instance
|
||||
const handleDeauthorization = async () => {
|
||||
try {
|
||||
await (await fetch('/auth/deauthorize')).json();
|
||||
} catch (error) {
|
||||
console.error('deauthorization failed: ', error);
|
||||
}
|
||||
};
|
||||
|
||||
handleDeauthorization();
|
||||
}, [cachedConnectionsData.cachedConnections.length]);
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export const useOpenApp = ({ info }) => {
|
||||
const authInstance = await getAuthInstance({ webIntegrationId, host });
|
||||
url = await authInstance.generateWebsocketUrl(info?.enigma.appId);
|
||||
} else if (clientId) {
|
||||
const { webSocketUrl } = await (await fetch(`/getSocketUrl/${info?.enigma.appId}`)).json();
|
||||
const { webSocketUrl } = await (await fetch(`/auth/getSocketUrl/${info?.enigma.appId}`)).json();
|
||||
url = webSocketUrl;
|
||||
} else {
|
||||
url = SenseUtilities.buildUrl(enigmaInfo);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const checkIfAuthorized = async () => {
|
||||
const { isAuthorized } = await (await fetch('/isAuthorized')).json();
|
||||
const { isAuthorized } = await (await fetch('/auth/isAuthorized')).json();
|
||||
return { isAuthorized };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user