Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement OIDC auth workflow in UI #7121

Merged
merged 5 commits into from
Jan 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 137 additions & 42 deletions pinot-controller/src/main/resources/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 [authorizationEndpoint, setAuthorizationEndpoint] = React.useState(
null
);

const fetchClusterName = async () => {
const clusterNameResponse = await PinotMethodUtils.getClusterName();
Expand All @@ -64,22 +73,85 @@ 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 {
// Set authorization endpoint
setAuthorizationEndpoint(`${issuer}/auth`);

setLoading(false);
}

break;
}
default: {
// Empty
}
}
}
};

React.useEffect(()=>{
React.useEffect(() => {
getAuthInfo();
}, []);

React.useEffect(()=>{
if(isAuthenticated){
React.useEffect(() => {
initAuthWorkflow();
}, [authWorkflow]);

React.useEffect(() => {
if (authorizationEndpoint && oidcSignInFormRef && oidcSignInFormRef.current) {
// Authorization endpoint available; submit sign in form
oidcSignInFormRef.current.submit();
}
}, [authorizationEndpoint]);

React.useEffect(() => {
if (isAuthenticated) {
fetchClusterConfig();
fetchClusterName();
}
Expand Down Expand Up @@ -109,37 +181,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 && !isAuthenticated ? (
<>
{/* OIDC sign in form */}
<form
hidden
action={authorizationEndpoint}
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/authenticated 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>
);
Expand Down
4 changes: 4 additions & 0 deletions pinot-controller/src/main/resources/app/app_state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
6 changes: 6 additions & 0 deletions pinot-controller/src/main/resources/app/interfaces/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,10 @@ declare module 'Models' {
tenantName: string,
error: string
}

export const enum AuthWorkflow {
NONE = 'NONE',
BASIC = 'BASIC',
OIDC = 'OIDC',
}
}
2 changes: 2 additions & 0 deletions pinot-controller/src/main/resources/app/pages/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
authenticateUser,
getSegmentDebugInfo
} from '../requests';
import { baseApi } from './axios-config';
import Utils from './Utils';
const JSONbig = require('json-bigint')({'storeAsString': true})

Expand Down Expand Up @@ -811,12 +812,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,
Expand Down Expand Up @@ -867,5 +886,7 @@ export default {
getAllSchemaDetails,
getTableState,
getAuthInfo,
verifyAuth
getWellKnownOpenIdConfiguration,
verifyAuth,
getAccessTokenFromHashParams
};
15 changes: 13 additions & 2 deletions pinot-controller/src/main/resources/app/utils/axios-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
};

Expand Down