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:
Ahmad Mirzaei
2024-03-04 14:41:29 +01:00
committed by GitHub
parent 681d7f7552
commit 7506aa28f4
12 changed files with 234 additions and 101 deletions

View 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 };

View File

@@ -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,

View File

@@ -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 &quot;Add redirect URLs&quot;, 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;

View File

@@ -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.

View File

@@ -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);

View File

@@ -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 }) => ({

View File

@@ -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>

View File

@@ -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);

View File

@@ -3,3 +3,4 @@ export { useConnection } from './useConnection';
export { useAppList } from './useAppList';
export { useOpenApp } from './useOpenApp';
export { useCachedConnections } from './useCachedConnections';
export { useDeauthorizePrevOAuthInstance } from './useDeauthorizePrevOAuthInstance';

View 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]);
};

View File

@@ -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);

View File

@@ -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 };
};