You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pinot.apache.org by ap...@apache.org on 2021/07/16 18:17:37 UTC

[incubator-pinot] 01/01: Implemented OIDC auth workflow in UI

This is an automated email from the ASF dual-hosted git repository.

apucher pushed a commit to branch oidc-ui-temp
in repository https://gitbox.apache.org/repos/asf/incubator-pinot.git

commit 9e62f457a62bbac84920a4435dc40761c40a199c
Author: Gaurav Joshi <68...@users.noreply.github.com>
AuthorDate: Fri Jul 2 07:49:48 2021 -0700

    Implemented OIDC auth workflow in UI
---
 pinot-controller/src/main/resources/app/App.tsx    | 186 ++++++++++++++++-----
 .../src/main/resources/app/app_state.ts            |   4 +
 .../src/main/resources/app/interfaces/types.d.ts   |   6 +
 .../src/main/resources/app/pages/LoginPage.tsx     |   2 +
 .../main/resources/app/utils/PinotMethodUtils.ts   |  23 ++-
 .../src/main/resources/app/utils/axios-config.ts   |  15 +-
 6 files changed, 191 insertions(+), 45 deletions(-)

diff --git a/pinot-controller/src/main/resources/app/App.tsx b/pinot-controller/src/main/resources/app/App.tsx
index c11dfbe..28735bb 100644
--- a/pinot-controller/src/main/resources/app/App.tsx
+++ b/pinot-controller/src/main/resources/app/App.tsx
@@ -28,6 +28,7 @@ import PinotMethodUtils from './utils/PinotMethodUtils';
 import CustomNotification from './components/CustomNotification';
 import { NotificationContextProvider } from './components/Notification/NotificationContextProvider';
 import app_state from './app_state';
+import { AuthWorkflow } from 'Models';
 
 const useStyles = makeStyles(() =>
   createStyles({
@@ -42,7 +43,15 @@ const useStyles = makeStyles(() =>
 const App = () => {
   const [clusterName, setClusterName] = React.useState('');
   const [loading, setLoading] = React.useState(true);
+  const oidcSignInFormRef = React.useRef<HTMLFormElement>(null);
   const [isAuthenticated, setIsAuthenticated] = React.useState(null);
+  const [issuer, setIssuer] = React.useState(null);
+  const [redirectUri, setRedirectUri] = React.useState(null);
+  const [clientId, setClientId] = React.useState(null);
+  const [authWorkflow, setAuthWorkflow] = React.useState(null);
+  const [authorizationEndopoint, setAuthorizationEndopoint] = React.useState(
+    null
+  );
 
   const fetchClusterName = async () => {
     const clusterNameResponse = await PinotMethodUtils.getClusterName();
@@ -64,22 +73,92 @@ const App = () => {
   };
 
   const getAuthInfo = async () => {
-    const authInfoResponse = await PinotMethodUtils.getAuthInfo()
-    // If authInfoResponse has workflow set to anything but BASIC,
-    // it doesn't require authentication.
-    if(authInfoResponse?.workflow !== 'BASIC'){
-      setIsAuthenticated(true);
-    } else {
-      setLoading(false);
+    const authInfoResponse = await PinotMethodUtils.getAuthInfo();
+    // Issuer URL, if available
+    setIssuer(
+      authInfoResponse && authInfoResponse.issuer ? authInfoResponse.issuer : ''
+    );
+    // Redirect URI, if available
+    setRedirectUri(
+      authInfoResponse && authInfoResponse.redirectUri
+        ? authInfoResponse.redirectUri
+        : ''
+    );
+    // Client Id, if available
+    setClientId(
+      authInfoResponse && authInfoResponse.clientId
+        ? authInfoResponse.clientId
+        : ''
+    );
+    // Authentication workflow
+    setAuthWorkflow(
+      authInfoResponse && authInfoResponse.workflow
+        ? authInfoResponse.workflow
+        : AuthWorkflow.NONE
+    );
+  };
+
+  const initAuthWorkflow = async () => {
+    switch (authWorkflow) {
+      case AuthWorkflow.NONE: {
+        // No authentication required
+        setIsAuthenticated(true);
+
+        break;
+      }
+      case AuthWorkflow.BASIC: {
+        // Basic authentication, handled by login page
+        setLoading(false);
+
+        break;
+      }
+      case AuthWorkflow.OIDC: {
+        // OIDC authentication, check to see if access token is available in the URL
+        const accessToken = PinotMethodUtils.getAccessTokenFromHashParams();
+        if (accessToken) {
+          app_state.authWorkflow = AuthWorkflow.OIDC;
+          app_state.authToken = accessToken;
+
+          setIsAuthenticated(true);
+        } else {
+          // Get authorization endpoint
+          const openIdConfigResponse = await PinotMethodUtils.getWellKnownOpenIdConfiguration(
+            issuer
+          );
+          setAuthorizationEndopoint(
+            openIdConfigResponse && openIdConfigResponse.authorization_endpoint
+              ? openIdConfigResponse.authorization_endpoint
+              : ''
+          );
+
+          setLoading(false);
+        }
+
+        break;
+      }
+      default: {
+        // Empty
+      }
     }
-  }
+  };
 
-  React.useEffect(()=>{
+  React.useEffect(() => {
     getAuthInfo();
   }, []);
 
-  React.useEffect(()=>{
-    if(isAuthenticated){
+  React.useEffect(() => {
+    initAuthWorkflow();
+  }, [authWorkflow]);
+
+  React.useEffect(() => {
+    if (authorizationEndopoint && oidcSignInFormRef && oidcSignInFormRef.current) {
+      // Authorization endpoint available; submit signin form
+      oidcSignInFormRef.current.submit();
+    }
+  }, [authorizationEndopoint]);
+
+  React.useEffect(() => {
+    if (isAuthenticated) {
       fetchClusterConfig();
       fetchClusterName();
     }
@@ -109,37 +188,60 @@ const App = () => {
     <MuiThemeProvider theme={theme}>
       <NotificationContextProvider>
         <CustomNotification />
-        {
-          loading ?
-            <CircularProgress className={classes.loader} size={80}/>
-          :
-          <Router>
-            <Switch>
-              {getRouterData().map(({ path, Component }, key) => (
-                <Route
-                  exact
-                  path={path}
-                  key={key}
-                  render={props => {
-                    if(path === '/login'){
-                      return loginRender(Component, props);
-                    } else if(isAuthenticated){
-                      // default render
-                      return componentRender(Component, props);
-                    } else {
-                      return (
-                        <Redirect to="/login"/>
-                      );
-                    }
-                  }}
-                />
-              ))}
-              <Route path="*">
-                <Redirect to={app_state.queryConsoleOnlyView ? "/query" : "/"} />
-              </Route>
-            </Switch>
-          </Router>
-        }
+        {/* OIDC auth workflow */}
+        {authWorkflow && authWorkflow === AuthWorkflow.OIDC ? (
+          <>
+            {/* OIDC sign in form */}
+            <form
+              hidden
+              action={authorizationEndopoint}
+              method="post"
+              ref={oidcSignInFormRef}
+            >
+              <input readOnly name="client_id" value={clientId} />
+              <input readOnly name="redirect_uri" value={redirectUri} />
+              <input readOnly name="scope" value="email openid" />
+              <input readOnly name="state" value="true-redirect-uri" />
+              <input readOnly name="response_type" value="id_token token" />
+              <input readOnly name="nonce" value="random_string" />
+              <input type="submit" value="" />
+            </form>
+          </>
+        ) : (
+          <>
+            {/* Non-OIDC auth workflow */}
+            {loading ? (
+              <CircularProgress className={classes.loader} size={80} />
+            ) : (
+              <Router>
+                <Switch>
+                  {getRouterData().map(({ path, Component }, key) => (
+                    <Route
+                      exact
+                      path={path}
+                      key={key}
+                      render={(props) => {
+                        if (path === '/login') {
+                          return loginRender(Component, props);
+                        } else if (isAuthenticated) {
+                          // default render
+                          return componentRender(Component, props);
+                        } else {
+                          return <Redirect to="/login" />;
+                        }
+                      }}
+                    />
+                  ))}
+                  <Route path="*">
+                    <Redirect
+                      to={app_state.queryConsoleOnlyView ? '/query' : '/'}
+                    />
+                  </Route>
+                </Switch>
+              </Router>
+            )}
+          </>
+        )}
       </NotificationContextProvider>
     </MuiThemeProvider>
   );
diff --git a/pinot-controller/src/main/resources/app/app_state.ts b/pinot-controller/src/main/resources/app/app_state.ts
index 4b09aa6..caa27a1 100644
--- a/pinot-controller/src/main/resources/app/app_state.ts
+++ b/pinot-controller/src/main/resources/app/app_state.ts
@@ -16,8 +16,12 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+
+ import { AuthWorkflow } from "Models";
 class app_state {
   queryConsoleOnlyView: boolean;
+  authWorkflow: AuthWorkflow;
   authToken: string | null;
 }
+
 export default new app_state();
\ No newline at end of file
diff --git a/pinot-controller/src/main/resources/app/interfaces/types.d.ts b/pinot-controller/src/main/resources/app/interfaces/types.d.ts
index f79cc24..805d675 100644
--- a/pinot-controller/src/main/resources/app/interfaces/types.d.ts
+++ b/pinot-controller/src/main/resources/app/interfaces/types.d.ts
@@ -149,4 +149,10 @@ declare module 'Models' {
     tenantName: string,
     error: string
   }
+
+  export const enum AuthWorkflow {
+    NONE = 'NONE',
+    BASIC = 'BASIC',
+    OIDC = 'OIDC',
+  }
 }
diff --git a/pinot-controller/src/main/resources/app/pages/LoginPage.tsx b/pinot-controller/src/main/resources/app/pages/LoginPage.tsx
index 53e5737..320f404 100644
--- a/pinot-controller/src/main/resources/app/pages/LoginPage.tsx
+++ b/pinot-controller/src/main/resources/app/pages/LoginPage.tsx
@@ -23,6 +23,7 @@ import Logo from '../components/SvgIcons/Logo';
 import { useForm } from 'react-hook-form';
 import PinotMethodUtils from '../utils/PinotMethodUtils';
 import app_state from '../app_state';
+import { AuthWorkflow } from 'Models';
 
 interface FormData {
   username: string;
@@ -77,6 +78,7 @@ const LoginPage = (props) => {
     if(isUserAuthenticated){
       setInvalidToken(false);
       props.setIsAuthenticated(true);
+      app_state.authWorkflow = AuthWorkflow.BASIC;
       app_state.authToken = authToken;
       props.history.push(app_state.queryConsoleOnlyView ? '/query' : '/');
     } else {
diff --git a/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts b/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
index d67396b..d226466 100644
--- a/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
+++ b/pinot-controller/src/main/resources/app/utils/PinotMethodUtils.ts
@@ -68,6 +68,7 @@ import {
   getInfo,
   authenticateUser
 } from '../requests';
+import { baseApi } from './axios-config';
 import Utils from './Utils';
 const JSONbig = require('json-bigint')({'storeAsString': true})
 
@@ -758,12 +759,30 @@ const getAuthInfo = () => {
   });
 };
 
+const getWellKnownOpenIdConfiguration = (issuer) => {
+  return baseApi
+    .get(`${issuer}/.well-known/openid-configuration`)
+    .then((response) => {
+      return response.data;
+    });
+};
+
 const verifyAuth = (authToken) => {
   return authenticateUser(authToken).then((response)=>{
     return response.data;
   });
 };
 
+const getAccessTokenFromHashParams = () => {
+  let accessToken = '';
+  const urlSearchParams = new URLSearchParams(location.hash.substr(1));
+  if (urlSearchParams.has('access_token')) {
+    accessToken = urlSearchParams.get('access_token') as string;
+  }
+
+  return accessToken;
+};
+
 export default {
   getTenantsData,
   getAllInstances,
@@ -813,5 +832,7 @@ export default {
   getAllSchemaDetails,
   getTableState,
   getAuthInfo,
-  verifyAuth
+  getWellKnownOpenIdConfiguration,
+  verifyAuth,
+  getAccessTokenFromHashParams
 };
diff --git a/pinot-controller/src/main/resources/app/utils/axios-config.ts b/pinot-controller/src/main/resources/app/utils/axios-config.ts
index 0a34237..236d6e4 100644
--- a/pinot-controller/src/main/resources/app/utils/axios-config.ts
+++ b/pinot-controller/src/main/resources/app/utils/axios-config.ts
@@ -20,6 +20,7 @@
 /* eslint-disable no-console */
 
 import axios from 'axios';
+import { AuthWorkflow } from 'Models';
 import app_state from '../app_state';
 
 const isDev = process.env.NODE_ENV !== 'production';
@@ -39,12 +40,22 @@ const handleResponse = (response: any) => {
 };
 
 const handleConfig = (config: any) => {
-  if(app_state.authToken){
-    Object.assign(config.headers, {"Authorization": app_state.authToken});
+  // Attach auth token for basic auth
+  if (app_state.authWorkflow === AuthWorkflow.BASIC && app_state.authToken) {
+    Object.assign(config.headers, { Authorization: app_state.authToken });
   }
+
+  // Attach auth token for OIDC auth
+  if (app_state.authWorkflow === AuthWorkflow.OIDC && app_state.authToken) {
+    Object.assign(config.headers, {
+      Authorization: `Bearer ${app_state.authToken}`,
+    });
+  }
+
   if (isDev) {
     console.log(config);
   }
+
   return config;
 };
 

---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@pinot.apache.org
For additional commands, e-mail: commits-help@pinot.apache.org