fix: OIDC finalization and refactoring
parent
aae69cb5e4
commit
8e0c60f5be
@ -0,0 +1,9 @@
|
|||||||
|
import http from './http';
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
|
||||||
|
const getConfig = (headers) => http.get('/config', undefined, headers);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getConfig,
|
||||||
|
};
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Loader } from 'semantic-ui-react';
|
||||||
|
|
||||||
|
import LoginContainer from '../containers/LoginContainer';
|
||||||
|
|
||||||
|
const LoginWrapper = React.memo(({ isInitializing }) => {
|
||||||
|
if (isInitializing) {
|
||||||
|
return <Loader active size="massive" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LoginContainer />;
|
||||||
|
});
|
||||||
|
|
||||||
|
LoginWrapper.propTypes = {
|
||||||
|
isInitializing: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginWrapper;
|
||||||
@ -1,19 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
import OidcLogin from './OidcLogin';
|
|
||||||
|
|
||||||
export default OidcLogin;
|
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import selectors from '../selectors';
|
||||||
|
import LoginWrapper from '../components/LoginWrapper';
|
||||||
|
|
||||||
|
const mapStateToProps = (state) => {
|
||||||
|
const isInitializing = selectors.selectIsInitializing(state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isInitializing,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(LoginWrapper);
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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,16 +1,10 @@
|
|||||||
import EntryActionTypes from '../constants/EntryActionTypes';
|
import EntryActionTypes from '../constants/EntryActionTypes';
|
||||||
|
|
||||||
const initializeCore = () => ({
|
|
||||||
type: EntryActionTypes.CORE_INITIALIZE,
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const logout = () => ({
|
const logout = () => ({
|
||||||
type: EntryActionTypes.LOGOUT,
|
type: EntryActionTypes.LOGOUT,
|
||||||
payload: {},
|
payload: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
initializeCore,
|
|
||||||
logout,
|
logout,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
|
||||||
import Config from './constants/Config';
|
|
||||||
import store from './store';
|
import store from './store';
|
||||||
import history from './history';
|
import history from './history';
|
||||||
import Root from './components/Root';
|
import Root from './components/Root';
|
||||||
|
|
||||||
import './i18n';
|
import './i18n';
|
||||||
|
|
||||||
fetch(`${Config.SERVER_BASE_URL}/api/appconfig`).then((response) => {
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
response.json().then((config) => {
|
root.render(React.createElement(Root, { store, history }));
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
||||||
root.render(React.createElement(Root, { store, history, config }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -0,0 +1,34 @@
|
|||||||
|
import ActionTypes from '../constants/ActionTypes';
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
isInitializing: true,
|
||||||
|
config: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line default-param-last
|
||||||
|
export default (state = initialState, { type, payload }) => {
|
||||||
|
switch (type) {
|
||||||
|
case ActionTypes.LOGIN_INITIALIZE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isInitializing: false,
|
||||||
|
config: payload.config,
|
||||||
|
};
|
||||||
|
case ActionTypes.AUTHENTICATE__SUCCESS:
|
||||||
|
case ActionTypes.WITH_OIDC_AUTHENTICATE__SUCCESS:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isInitializing: true,
|
||||||
|
};
|
||||||
|
case ActionTypes.CORE_INITIALIZE:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isInitializing: false,
|
||||||
|
...(payload.config && {
|
||||||
|
config: payload.config,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
export const selectIsInitializing = ({ root: { isInitializing } }) => isInitializing;
|
||||||
|
|
||||||
|
export const selectConfig = ({ root: { config } }) => config;
|
||||||
|
|
||||||
|
export const selectOidcConfig = (state) => selectConfig(state).oidc;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
selectIsInitializing,
|
||||||
|
selectConfig,
|
||||||
|
selectOidcConfig,
|
||||||
|
};
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
import { UserManager } from 'oidc-client-ts';
|
||||||
|
|
||||||
|
export default class OidcManager {
|
||||||
|
constructor(config) {
|
||||||
|
this.userManager = new UserManager({
|
||||||
|
...config,
|
||||||
|
authority: config.issuer,
|
||||||
|
client_id: config.clientId,
|
||||||
|
redirect_uri: config.redirectUri,
|
||||||
|
scope: config.scopes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
login() {
|
||||||
|
return this.userManager.signinRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
loginCallback() {
|
||||||
|
return this.userManager.signinRedirectCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
return this.userManager.signoutSilent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createOidcManager = (config) => new OidcManager(config);
|
||||||
@ -1,11 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
async fn() {
|
|
||||||
const config = {
|
|
||||||
authority: sails.config.custom.oidcIssuer,
|
|
||||||
clientId: sails.config.custom.oidcClientId,
|
|
||||||
redirectUri: sails.config.custom.oidcredirectUri,
|
|
||||||
scopes: sails.config.custom.oidcScopes,
|
|
||||||
};
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
fn() {
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
oidc: sails.config.custom.oidcIssuer
|
||||||
|
? {
|
||||||
|
issuer: sails.config.custom.oidcIssuer,
|
||||||
|
clientId: sails.config.custom.oidcClientId,
|
||||||
|
redirectUri: sails.config.custom.oidcRedirectUri,
|
||||||
|
scopes: sails.config.custom.oidcScopes,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const jwksClient = require('jwks-rsa');
|
||||||
|
const openidClient = require('openid-client');
|
||||||
|
|
||||||
|
const jwks = jwksClient({
|
||||||
|
jwksUri: sails.config.custom.oidcJwksUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyOidcToken = async (oidcToken) => {
|
||||||
|
const signingKeys = await jwks.getSigningKeys();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
issuer: sails.config.custom.oidcIssuer,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sails.config.custom.oidcAudience) {
|
||||||
|
options.audience = sails.config.custom.oidcAudience;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = null;
|
||||||
|
signingKeys.some((signingKey) => {
|
||||||
|
try {
|
||||||
|
const publicKey = signingKey.getPublicKey();
|
||||||
|
payload = jwt.verify(oidcToken, publicKey, options);
|
||||||
|
} catch (error) {
|
||||||
|
sails.log.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserInfo = async (oidcToken) => {
|
||||||
|
const issuer = await openidClient.Issuer.discover(sails.config.custom.oidcIssuer);
|
||||||
|
|
||||||
|
const client = new issuer.Client({
|
||||||
|
client_id: 'irrelevant',
|
||||||
|
});
|
||||||
|
|
||||||
|
return client.userinfo(oidcToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
inputs: {
|
||||||
|
token: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
exits: {
|
||||||
|
invalidToken: {},
|
||||||
|
missingValues: {},
|
||||||
|
emailAlreadyInUse: {},
|
||||||
|
usernameAlreadyInUse: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
async fn(inputs) {
|
||||||
|
const oidcUser = await verifyOidcToken(inputs.token);
|
||||||
|
|
||||||
|
if (!oidcUser) {
|
||||||
|
throw 'invalidToken';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sails.config.custom.oidcSkipUserInfo) {
|
||||||
|
const userInfo = await getUserInfo(inputs.token);
|
||||||
|
Object.assign(oidcUser, userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oidcUser.email || !oidcUser.name) {
|
||||||
|
throw 'missingValues';
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAdmin = false;
|
||||||
|
if (sails.config.custom.oidcAdminRoles.includes('*')) {
|
||||||
|
isAdmin = true;
|
||||||
|
} else {
|
||||||
|
const roles = oidcUser[sails.config.custom.oidcRolesAttribute];
|
||||||
|
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
isAdmin = sails.config.custom.oidcAdminRoles.some((role) => roles.includes(role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
isAdmin,
|
||||||
|
email: oidcUser.email,
|
||||||
|
isSso: true,
|
||||||
|
name: oidcUser.name,
|
||||||
|
username: oidcUser.preferred_username,
|
||||||
|
subscribeToOwnCards: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let user;
|
||||||
|
/* eslint-disable no-constant-condition, no-await-in-loop */
|
||||||
|
while (true) {
|
||||||
|
let identityProviderUser = await IdentityProviderUser.findOne({
|
||||||
|
issuer: oidcUser.iss,
|
||||||
|
sub: oidcUser.sub,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (identityProviderUser) {
|
||||||
|
user = await sails.helpers.users.getOne(identityProviderUser.userId);
|
||||||
|
} else {
|
||||||
|
while (true) {
|
||||||
|
user = await sails.helpers.users.getOne({
|
||||||
|
email: values.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
user = await sails.helpers.users
|
||||||
|
.createOne(values)
|
||||||
|
.tolerate('emailAlreadyInUse')
|
||||||
|
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identityProviderUser = await IdentityProviderUser.create({
|
||||||
|
userId: user.id,
|
||||||
|
issuer: oidcUser.iss,
|
||||||
|
sub: oidcUser.sub,
|
||||||
|
}).tolerate('E_UNIQUE');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (identityProviderUser) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* eslint-enable no-constant-condition, no-await-in-loop */
|
||||||
|
|
||||||
|
const updateFieldKeys = ['email', 'isAdmin', 'isSso', 'name', 'username'];
|
||||||
|
|
||||||
|
const updateValues = updateFieldKeys.reduce((result, fieldKey) => {
|
||||||
|
if (values[fieldKey] === user[fieldKey]) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
[fieldKey]: values[fieldKey],
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
if (Object.keys(updateValues).length > 0) {
|
||||||
|
user = await sails.helpers.users
|
||||||
|
.updateOne(user, updateValues, {}) // FIXME: hack for last parameter
|
||||||
|
.intercept('emailAlreadyInUse', 'emailAlreadyInUse')
|
||||||
|
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -1,24 +0,0 @@
|
|||||||
module.exports.up = (knex) =>
|
|
||||||
knex.schema.createTable('identity_provider_user', (table) => {
|
|
||||||
/* Columns */
|
|
||||||
|
|
||||||
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
|
|
||||||
table.timestamp('created_at', true);
|
|
||||||
table.timestamp('updated_at', true);
|
|
||||||
|
|
||||||
table
|
|
||||||
.bigInteger('user_id')
|
|
||||||
.notNullable()
|
|
||||||
.references('id')
|
|
||||||
.inTable('user_account')
|
|
||||||
.onDelete('CASCADE');
|
|
||||||
|
|
||||||
table.text('issuer').notNullable();
|
|
||||||
table.text('sub').notNullable();
|
|
||||||
|
|
||||||
/* Indexes */
|
|
||||||
|
|
||||||
table.index('user_id');
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports.down = (knex) => knex.schema.dropTable('identity_provider_user');
|
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
module.exports.up = async (knex) => {
|
||||||
|
await knex.schema.createTable('identity_provider_user', (table) => {
|
||||||
|
/* Columns */
|
||||||
|
|
||||||
|
table.bigInteger('id').primary().defaultTo(knex.raw('next_id()'));
|
||||||
|
|
||||||
|
table.bigInteger('user_id').notNullable();
|
||||||
|
|
||||||
|
table.text('issuer').notNullable();
|
||||||
|
table.text('sub').notNullable();
|
||||||
|
|
||||||
|
table.timestamp('created_at', true);
|
||||||
|
table.timestamp('updated_at', true);
|
||||||
|
|
||||||
|
/* Indexes */
|
||||||
|
|
||||||
|
table.unique(['issuer', 'sub']);
|
||||||
|
table.index('user_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
await knex.schema.table('user_account', (table) => {
|
||||||
|
/* Columns */
|
||||||
|
|
||||||
|
table.boolean('is_sso').notNullable().default(false);
|
||||||
|
|
||||||
|
/* Modifications */
|
||||||
|
|
||||||
|
table.setNullable('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
return knex.schema.alterTable('user_account', (table) => {
|
||||||
|
table.boolean('is_sso').notNullable().alter();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.down = async (knex) => {
|
||||||
|
await knex.schema.dropTable('identity_provider_user');
|
||||||
|
|
||||||
|
return knex.schema.table('user_account', (table) => {
|
||||||
|
table.dropColumn('is_sso');
|
||||||
|
|
||||||
|
table.dropNullable('password');
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,11 +0,0 @@
|
|||||||
module.exports.up = async (knex) => {
|
|
||||||
return knex.schema.table('user_account', (table) => {
|
|
||||||
table.setNullable('password');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports.down = async (knex) => {
|
|
||||||
return knex.schema.table('user_account', (table) => {
|
|
||||||
table.dropNullable('password');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
module.exports.up = async (knex) => {
|
|
||||||
return knex.schema.table('user_account', (table) => {
|
|
||||||
table.boolean('locked').default(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports.down = async (knex) => {
|
|
||||||
return knex.schema.table('user_account', (table) => {
|
|
||||||
table.dropColumn('locked');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
Loading…
Reference in New Issue