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 <[email protected]> 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: [email protected] For additional commands, e-mail: [email protected]
