1
0
Fork 0
mirror of https://github.com/plankanban/planka.git synced 2025-07-18 20:59:44 +02:00

feat: OIDC with PKCE flow (#491)

This commit is contained in:
gorrilla10101 2023-09-04 10:06:59 -05:00 committed by GitHub
parent e254443272
commit 6941500c7b
24 changed files with 805 additions and 22 deletions

View file

@ -30,6 +30,7 @@
"react-i18next": "^12.0.0",
"react-input-mask": "^2.0.4",
"react-markdown": "^8.0.3",
"react-oidc-context": "^2.2.2",
"react-photoswipe-gallery": "^2.2.2",
"react-redux": "^8.0.5",
"react-router-dom": "^6.4.3",
@ -7189,6 +7190,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
"integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==",
"peer": true
},
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -17035,6 +17042,19 @@
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
},
"node_modules/oidc-client-ts": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.2.4.tgz",
"integrity": "sha512-nOZwIomju+AmXObl5Oq5PjrES/qTt8bLsENJCIydVgi9TEWk7SCkOU6X3RNkY7yfySRM1OJJvDKdREZdmnDT2g==",
"peer": true,
"dependencies": {
"crypto-js": "^4.1.1",
"jwt-decode": "^3.1.2"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -19276,6 +19296,18 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
"integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
},
"node_modules/react-oidc-context": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-2.2.2.tgz",
"integrity": "sha512-rke8goKuxxQhSAR11h8wVn56m7kEa1mcAfYDlIycsIgmbZOFzKA0Un0y0RodHZ/M/CG6u0JD1I8RKZHqRJjWnA==",
"engines": {
"node": ">=12.13.0"
},
"peerDependencies": {
"oidc-client-ts": "^2.2.1",
"react": ">=16.8.0"
}
},
"node_modules/react-onclickoutside": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz",
@ -28767,6 +28799,12 @@
"which": "^2.0.1"
}
},
"crypto-js": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz",
"integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==",
"peer": true
},
"crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -35933,6 +35971,16 @@
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
},
"oidc-client-ts": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.2.4.tgz",
"integrity": "sha512-nOZwIomju+AmXObl5Oq5PjrES/qTt8bLsENJCIydVgi9TEWk7SCkOU6X3RNkY7yfySRM1OJJvDKdREZdmnDT2g==",
"peer": true,
"requires": {
"crypto-js": "^4.1.1",
"jwt-decode": "^3.1.2"
}
},
"on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -37371,6 +37419,12 @@
}
}
},
"react-oidc-context": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-2.2.2.tgz",
"integrity": "sha512-rke8goKuxxQhSAR11h8wVn56m7kEa1mcAfYDlIycsIgmbZOFzKA0Un0y0RodHZ/M/CG6u0JD1I8RKZHqRJjWnA==",
"requires": {}
},
"react-onclickoutside": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.2.tgz",

View file

@ -77,6 +77,7 @@
"react-i18next": "^12.0.0",
"react-input-mask": "^2.0.4",
"react-markdown": "^8.0.3",
"react-oidc-context": "^2.2.2",
"react-photoswipe-gallery": "^2.2.2",
"react-redux": "^8.0.5",
"react-router-dom": "^6.4.3",

View file

@ -4,6 +4,8 @@ import socket from './socket';
/* Actions */
const createAccessToken = (data, headers) => http.post('/access-tokens', data, headers);
const exchangeOidcToken = (accessToken, headers) =>
http.post('/access-tokens/exchange', { token: accessToken }, headers);
const deleteCurrentAccessToken = (headers) =>
socket.delete('/access-tokens/me', undefined, headers);
@ -11,4 +13,5 @@ const deleteCurrentAccessToken = (headers) =>
export default {
createAccessToken,
deleteCurrentAccessToken,
exchangeOidcToken,
};

View file

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { Button, Icon, Menu } from 'semantic-ui-react';
import { useAuth } from 'react-oidc-context';
import { usePopup } from '../../lib/popup';
import Paths from '../../constants/Paths';
@ -29,6 +30,7 @@ const Header = React.memo(
onUserSettingsClick,
onLogout,
}) => {
const auth = useAuth();
const handleProjectSettingsClick = useCallback(() => {
if (canEditProject) {
onProjectSettingsClick();
@ -38,6 +40,11 @@ const Header = React.memo(
const NotificationsPopup = usePopup(NotificationsStep, POPUP_PROPS);
const UserPopup = usePopup(UserStep, POPUP_PROPS);
const onFullLogout = () => {
auth.signoutSilent();
onLogout();
};
return (
<div className={styles.wrapper}>
{!project && (
@ -88,7 +95,7 @@ const Header = React.memo(
<UserPopup
isLogouting={isLogouting}
onSettingsClick={onUserSettingsClick}
onLogout={onLogout}
onLogout={onFullLogout}
>
<Menu.Item className={classNames(styles.item, styles.itemHoverable)}>
{user.name}

View file

@ -1,5 +1,6 @@
import isEmail from 'validator/lib/isEmail';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useAuth } from 'react-oidc-context';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
@ -48,6 +49,7 @@ const createMessage = (error) => {
const Login = React.memo(
({ defaultData, isSubmitting, error, onAuthenticate, onMessageDismiss }) => {
const auth = useAuth();
const [t] = useTranslation();
const wasSubmitting = usePrevious(isSubmitting);
@ -171,6 +173,9 @@ const Login = React.memo(
disabled={isSubmitting}
/>
</Form>
<Form.Button type="button" onClick={() => auth.signinRedirect()}>
Log in with SSO
</Form.Button>
</div>
</div>
</Grid.Column>

View file

@ -0,0 +1,19 @@
import { useAuth } from 'react-oidc-context';
import PropTypes from 'prop-types';
import React from 'react';
let isLoggingIn = true;
const OidcLogin = React.memo(({ onAuthenticate }) => {
const auth = useAuth();
if (isLoggingIn && auth.user) {
isLoggingIn = false;
const { user } = auth;
onAuthenticate(user);
}
});
OidcLogin.propTypes = {
onAuthenticate: PropTypes.func.isRequired,
};
export default OidcLogin;

View file

@ -0,0 +1,3 @@
import OidcLogin from './OidcLogin';
export default OidcLogin;

View file

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { AuthProvider } from 'react-oidc-context';
import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom';
import { ReduxRouter } from '../lib/redux-router';
@ -13,30 +14,41 @@ import 'react-datepicker/dist/react-datepicker.css';
import 'photoswipe/dist/photoswipe.css';
import 'easymde/dist/easymde.min.css';
import '../lib/custom-ui/styles.css';
import '../styles.module.scss';
import OidcLoginContainer from '../containers/OidcLoginContainer';
function Root({ store, history }) {
function Root({ store, history, config }) {
return (
<Provider store={store}>
<ReduxRouter history={history}>
<Routes>
<Route path={Paths.LOGIN} element={<LoginContainer />} />
<Route path={Paths.ROOT} element={<CoreContainer />} />
<Route path={Paths.PROJECTS} element={<CoreContainer />} />
<Route path={Paths.BOARDS} element={<CoreContainer />} />
<Route path={Paths.CARDS} element={<CoreContainer />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ReduxRouter>
</Provider>
<AuthProvider
authority={config.authority}
client_id={config.clientId}
redirect_uri={config.redirectUri}
scope={config.scopes}
onSigninCallback={() => {
window.history.replaceState({}, document.title, window.location.pathname);
}}
>
<Provider store={store}>
<ReduxRouter history={history}>
<Routes>
<Route path={Paths.LOGIN} element={<LoginContainer />} />
<Route path={Paths.OIDC_LOGIN} element={<OidcLoginContainer />} />
<Route path={Paths.ROOT} element={<CoreContainer />} />
<Route path={Paths.PROJECTS} element={<CoreContainer />} />
<Route path={Paths.BOARDS} element={<CoreContainer />} />
<Route path={Paths.CARDS} element={<CoreContainer />} />
<Route path="*" element={<NotFound />} />
</Routes>
</ReduxRouter>
</Provider>
</AuthProvider>
);
}
Root.propTypes = {
/* eslint-disable react/forbid-prop-types */
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
config: PropTypes.object.isRequired,
/* eslint-enable react/forbid-prop-types */
};

View file

@ -2,6 +2,7 @@ import Config from './Config';
const ROOT = `${Config.BASE_PATH}/`;
const LOGIN = `${Config.BASE_PATH}/login`;
const OIDC_LOGIN = `${Config.BASE_PATH}/oidclogin`;
const PROJECTS = `${Config.BASE_PATH}/projects/:id`;
const BOARDS = `${Config.BASE_PATH}/boards/:id`;
const CARDS = `${Config.BASE_PATH}/cards/:id`;
@ -12,4 +13,5 @@ export default {
PROJECTS,
BOARDS,
CARDS,
OIDC_LOGIN,
};

View file

@ -0,0 +1,26 @@
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import entryActions from '../entry-actions';
import OidcLogin from '../components/OIDC';
const mapStateToProps = ({
ui: {
authenticateForm: { data: defaultData, isSubmitting, error },
},
}) => ({
defaultData,
isSubmitting,
error,
});
const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{
onAuthenticate: entryActions.authenticate,
onMessageDismiss: entryActions.clearAuthenticateError,
},
dispatch,
);
export default connect(mapStateToProps, mapDispatchToProps)(OidcLogin);

View file

@ -1,11 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import Config from './constants/Config';
import store from './store';
import history from './history';
import Root from './components/Root';
import './i18n';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(Root, { store, history }));
fetch(`${Config.SERVER_BASE_URL}/api/appconfig`).then((response) => {
response.json().then((config) => {
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(Root, { store, history, config }));
});
});

View file

@ -7,9 +7,13 @@ import { setAccessToken } from '../../../utils/access-token-storage';
export function* authenticate(data) {
yield put(actions.authenticate(data));
let accessToken;
let accessToken = data.access_token;
try {
({ item: accessToken } = yield call(api.createAccessToken, data));
if (accessToken) {
({ item: accessToken } = yield call(api.exchangeOidcToken, accessToken));
} else {
({ item: accessToken } = yield call(api.createAccessToken, data));
}
} catch (error) {
yield put(actions.authenticate.failure(error));
return;