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:
parent
e254443272
commit
6941500c7b
24 changed files with 805 additions and 22 deletions
54
client/package-lock.json
generated
54
client/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
19
client/src/components/OIDC/OidcLogin.jsx
Normal file
19
client/src/components/OIDC/OidcLogin.jsx
Normal 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;
|
3
client/src/components/OIDC/index.js
Normal file
3
client/src/components/OIDC/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import OidcLogin from './OidcLogin';
|
||||
|
||||
export default OidcLogin;
|
|
@ -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 */
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
26
client/src/containers/OidcLoginContainer.js
Normal file
26
client/src/containers/OidcLoginContainer.js
Normal 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);
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue