Skip to content

Commit

Permalink
feat(frontend): add oauth with microsoft (#71)
Browse files Browse the repository at this point in the history
This PR introduces authentication sequence for oauth based authentication.
  • Loading branch information
mawandm authored May 18, 2024
1 parent a3daa0c commit 047c81d
Show file tree
Hide file tree
Showing 28 changed files with 810 additions and 161 deletions.
13 changes: 8 additions & 5 deletions nesis/api/core/services/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ def create(self, **kwargs) -> UserSession:
self.__LOG.debug(f"Received session object {kwargs}")
email = user_session.get("email")
password = user_session.get("password")

# Hard code these to make it easier to test
session_oauth_token_value = user_session.get(
os.environ.get("NESIS_OAUTH_TOKEN_KEY")
os.environ.get("NESIS_OAUTH_TOKEN_KEY") or "___nesis_oauth_token_key___"
)
oauth_token_value = (
os.environ.get("NESIS_OAUTH_TOKEN_VALUE") or "___nesis_oauth_token_value___"
)
oauth_token_value = os.environ.get("NESIS_OAUTH_TOKEN_VALUE")

try:

Expand All @@ -85,7 +89,6 @@ def create(self, **kwargs) -> UserSession:
user_password = password.encode("utf-8")
if not bcrypt.checkpw(user_password, db_pass):
raise UnauthorizedAccess("Invalid email/password")
# update last login details.
db_user = users[0]

return self.__create_user_session(db_user)
Expand Down Expand Up @@ -114,15 +117,15 @@ def create(self, **kwargs) -> UserSession:

def __create_user_session(self, db_user: User):
user_dict = db_user.to_dict()
token = SG("[\l\d]{128}").render()
token = SG(r"[\l\d]{128}").render()
session_token = self.__cache_key(token)
expiry = (
self.__config["memcache"].get("session", {"expiry": 0}).get("expiry", 0)
)
if self.__cache.get(session_token) is None:
self.__cache.set(session_token, user_dict, time=expiry)
while self.__cache.get(session_token)["id"] != user_dict["id"]:
token = SG("[\l\d]{128}").render()
token = SG(r"[\l\d]{128}").render()
session_token = self.__cache_key(token)
self.__cache.set(session_token, user_dict, time=expiry)
return UserSession(token=token, expiry=expiry, user=db_user)
Expand Down
269 changes: 211 additions & 58 deletions nesis/frontend/client/package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion nesis/frontend/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@azure/msal-browser": "^3.14.0",
"@azure/msal-react": "^2.0.16",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.3",
Expand All @@ -19,6 +21,7 @@
"query-string": "^8.1.0",
"react": "^18.2.0",
"react-bootstrap": "^2.9.0",
"react-bootstrap-icons": "^1.11.4",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-icons": "^4.11.0",
Expand All @@ -27,7 +30,6 @@
"react-scripts": "5.0.1",
"react-select": "^5.7.7",
"styled-components": "^6.0.8",
"react-bootstrap-icons": "^1.0.1-alpha3",
"superagent": "^8.1.2",
"uuid": "^9.0.1",
"web-vitals": "^2.1.4"
Expand Down
31 changes: 31 additions & 0 deletions nesis/frontend/client/src/ConfigContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import useClient from './utils/useClient';

const ConfigContext = React.createContext({});

ConfigContextProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};

export default ConfigContext;

export function ConfigContextProvider({ children }) {
const client = useClient();
const [config, setConfig] = useState(null);
useEffect(() => {
client.get('config').then((res) => {
setConfig(JSON.parse(res.text));
});
}, [setConfig, client]);
return (
<ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
);
}

export function useConfig() {
return useContext(ConfigContext);
}
32 changes: 21 additions & 11 deletions nesis/frontend/client/src/SessionContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { clearToken } from './utils/tokenStorage';
import { useHistory } from 'react-router-dom';
import useSessionStorage from './utils/useSessionStorage';
import apiClient from './utils/httpClient';
import { PublicClientApplication } from '@azure/msal-browser';

const SessionContext = React.createContext({
session: null,
setSession: () => '',
Expand All @@ -24,7 +26,7 @@ SessionProvider.propTypes = {

export default SessionContext;

const SESSION_KEY = 'AMETNES_CURRENT_SESSION';
const SESSION_KEY = 'NESIS_CURRENT_SESSION';

export function SessionProvider({ children }) {
const [session, setSession] = useSessionStorage(SESSION_KEY);
Expand Down Expand Up @@ -55,32 +57,40 @@ export function useCurrentUser() {
return context && context.user;
}

export function useSignOut(client) {
export function useSignOut(client, config) {
const context = useContext(SessionContext);
const setSession = context?.setSession;
const history = useHistory();
return useCallback(
function () {
logoutFromGoogle();
logoutFromOptimAI(client);
logoutNesis(client);
clearToken();
setSession(null);
logoutMicrosoft(config);
history.push('/signin');
},
[setSession, history],
);
}

function logoutFromGoogle() {
if (window.gapi && window.gapi.auth2) {
const auth2 = window.gapi.auth2.getAuthInstance();
if (auth2 != null) {
auth2.signOut().then(auth2.disconnect());
}
async function logoutMicrosoft(config) {
try {
const msalInstance = new PublicClientApplication({
auth: {
clientId: config?.auth?.OAUTH_AZURE_CLIENT_ID,
authority: config?.auth?.OAUTH_AZURE_AUTHORITY,
redirectUri: config?.auth?.OAUTH_AZURE_REDIRECTURI,
postLogoutRedirectUri: 'http://localhost:3000/',
},
});
await msalInstance.initialize();
msalInstance.logoutRedirect();
} catch (e) {
/* ignored */
}
}

function logoutFromOptimAI(client) {
function logoutNesis(client) {
if (!client) {
return;
}
Expand Down
70 changes: 70 additions & 0 deletions nesis/frontend/client/src/components/AzureButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useContext, useEffect, useState } from 'react';
import useClient from '../utils/useClient';
import { useConfig } from '../ConfigContext';
import parseApiErrorMessage from '../utils/parseApiErrorMessage';
import { PublicClientApplication } from '@azure/msal-browser';
import MicrosoftIcon from '../images/MicrosoftIcon.png';
import classes from '../styles/SignInPage.module.css';

export default function AzureButton({ onFailure, onSuccess }) {
const config = useConfig();
const client = useClient();
const msalInstance = new PublicClientApplication({
auth: {
clientId: config?.auth?.OAUTH_AZURE_CLIENT_ID,
authority: config?.auth?.OAUTH_AZURE_AUTHORITY,
redirectUri: config?.auth?.OAUTH_AZURE_REDIRECTURI,
navigateToLoginRequestUrl: true,
},
cache: {
cacheLocation: config?.auth?.OAUTH_AZURE_CACHELOCATION,
storeAuthStateInCookie: config?.auth?.OAUTH_AZURE_STOREAUTHSTATEINCOOKIE,
},
});

useEffect(() => {
const initialize = async () => {
await msalInstance.initialize();
await msalInstance
.handleRedirectPromise()
.then((response) => {
if (response?.accessToken) {
client
.post('sessions', {
azure: response,
})
.then((response) => {
onSuccess(response?.body?.email, response);
})
.catch((error) => {
onFailure(parseApiErrorMessage(error));
});
}
})
.catch((error) => {
onFailure(parseApiErrorMessage(error));
});
};
try {
initialize();
} catch (e) {
onFailure('Error log in in with Azure');
}
}, []);

const handleLogin = async (evt) => {
evt.preventDefault();

await msalInstance.initialize();
await msalInstance.loginRedirect();
};

return (
<>
<button className={`${classes.orloginbutton} my-3`} onClick={handleLogin}>
<img className={`${classes.loginorimg} mx-1`} src={MicrosoftIcon} />
Sign in with Microsoft
</button>
</>
);
}
11 changes: 11 additions & 0 deletions nesis/frontend/client/src/components/AzureButton.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';
import { renderWithContext, renderWithRouter } from '../utils/testUtils';
import AzureButton from './AzureButton';

describe('<AzureButton>', () => {
it('render azure authenticate option on login and register screen', async () => {
const { getByText } = renderWithContext(<AzureButton />);
const buttonComponent = getByText('Continue With Microsoft');
expect(buttonComponent).toBeInTheDocument();
});
});
9 changes: 6 additions & 3 deletions nesis/frontend/client/src/components/Menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import styled from 'styled-components/macro';
import { NavLink } from 'react-router-dom';
import { device } from '../utils/breakpoints';
import { useSignOut } from '../SessionContext';
import { useConfig } from '../ConfigContext';
import MenuHeader from './MenuHeader';
import { GearFill, FileEarmarkPost, ArrowBarLeft } from 'react-bootstrap-icons';
import client from '../utils/httpClient';

const sideMenuWidth = 300;

Expand Down Expand Up @@ -56,7 +58,7 @@ const MainContainer = styled.div`
flex-direction: column;
`;

function Optim({ children, className }) {
function Nesis({ children, className }) {
const [mobileModalVisible, setModalVisible] = useState(false);
const background = 'white';

Expand Down Expand Up @@ -133,7 +135,8 @@ const MenuTitle = styled.div`
`;

function SideMenu({ onClose }) {
const signOut = useSignOut();
const config = useConfig();
const signOut = useSignOut(client, config);

function closeMenu() {
if (onClose) {
Expand Down Expand Up @@ -170,4 +173,4 @@ function SideMenu({ onClose }) {
);
}

export default Optim;
export default Nesis;
9 changes: 4 additions & 5 deletions nesis/frontend/client/src/components/MenuHeader.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ReactComponent as Logo } from '../images/Nesis.svg';
import styled from 'styled-components/macro';
import { device } from '../utils/breakpoints';
import client from '../utils/httpClient';
import { useConfig } from '../ConfigContext';
import { LightSquareButton } from './inputs/SquareButton';

const Main = styled.div`
Expand Down Expand Up @@ -78,7 +79,8 @@ const HeaderToolBarIcon = styled.div`

export default function MenuHeader({ onMobileMenuClick }) {
const history = useHistory();
const signOut = useSignOut(client);
const config = useConfig();
const signOut = useSignOut(client, config);
const session = useCurrentSession();
const iconSize = 20;

Expand All @@ -94,10 +96,7 @@ export default function MenuHeader({ onMobileMenuClick }) {
{session && (
<UserControlsRow>
<HeaderToolBarIcon>
<a
href="https://github.com/ametnes/nesis/blob/main/docs/README.md"
target="_blank"
>
<a href="https://ametnes.github.io/nesis/" target="_blank">
<QuestionSquare size={iconSize} className="help-icon" /> Help
</a>
</HeaderToolBarIcon>
Expand Down
Binary file added nesis/frontend/client/src/images/MicrosoftIcon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions nesis/frontend/client/src/images/MicrosoftIcon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion nesis/frontend/client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import { ThemeProvider } from 'styled-components/macro';
import theme from './utils/theme';
import { SessionProvider } from './SessionContext';
import { ToasterContextProvider } from './ToasterContext';
import { ConfigContextProvider } from './ConfigContext';

ReactDOM.render(
<SessionProvider>
<ToasterContextProvider>
<ThemeProvider theme={theme}>
<Router basename={process.env.PUBLIC_URL}>
<Route path="/" component={App} />
<ConfigContextProvider>
<Route path="/" component={App} />
</ConfigContextProvider>
</Router>
</ThemeProvider>
</ToasterContextProvider>
Expand Down
7 changes: 3 additions & 4 deletions nesis/frontend/client/src/pages/DocumentGPT/ChatPage.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import { useHistory, Route, Switch, useRouteMatch } from 'react-router-dom';
import { GearFill } from 'react-bootstrap-icons';
import Optim from '../../components/Menu';
import Nesis from '../../components/Menu';
import styled from 'styled-components/macro';
import { FileEarmarkPost } from 'react-bootstrap-icons';
import {
Expand Down Expand Up @@ -140,7 +139,7 @@ const ChatPage = () => {
};

return (
<Optim>
<Nesis>
<Heading>
<FileEarmarkPost size={25} color="white" className="mr-2" /> Chat with
your Documents
Expand Down Expand Up @@ -220,7 +219,7 @@ const ChatPage = () => {
</Box>
</Container>
</div>
</Optim>
</Nesis>
);
};

Expand Down
6 changes: 3 additions & 3 deletions nesis/frontend/client/src/pages/Settings/SettingPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { GearFill } from 'react-bootstrap-icons';
import DatasourcesPage from './Datasources/DatasourcesPage';
import UsersPage from './Users/UsersPage';
import RolesPage from './Roles/RolesPage';
import Optim from '../../components/Menu';
import Nesis from '../../components/Menu';
import styled from 'styled-components/macro';

const Heading = styled.h1`
Expand All @@ -23,7 +23,7 @@ const SettingsPage = () => {
const history = useHistory();
const match = useRouteMatch();
return (
<Optim>
<Nesis>
<Heading>
<GearFill size={25} className="mr-2" /> Settings
</Heading>
Expand Down Expand Up @@ -68,7 +68,7 @@ const SettingsPage = () => {
<Route exact path={`${match.path}/roles`} component={RolesPage} />
</Switch>
</div>
</Optim>
</Nesis>
);
};

Expand Down
Loading

0 comments on commit 047c81d

Please sign in to comment.