Compare commits

...
Author SHA1 Message Date
Maksim Eltyshev 0c1c7e2192 chore: Update version 2024-02-01 00:34:31 +01:00
Maksim Eltyshev b8d262f745 feat: Add ability to enforce SSO
Closes #543, closes #545
2024-02-01 00:31:15 +01:00
Maksim Eltyshev a1fd694248 chore: Update version 2024-01-31 15:58:57 +01:00
Maksim Eltyshev 634d6ceab1 feat: Add ability to map OIDC attributes and ignore username
Closes #554
2024-01-25 23:01:59 +01:00
23 changed files with 198 additions and 124 deletions
+2 -2
View File
@@ -15,13 +15,13 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.15 version: 0.1.17
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "1.15.4" appVersion: "1.15.6"
dependencies: dependencies:
- alias: postgresql - alias: postgresql
+1 -1
View File
@@ -1 +1 @@
REACT_APP_VERSION=1.15.4 REACT_APP_VERSION=1.15.6
+14 -4
View File
@@ -68,6 +68,7 @@ const Login = React.memo(
isSubmittingUsingOidc, isSubmittingUsingOidc,
error, error,
withOidc, withOidc,
isOidcEnforced,
onAuthenticate, onAuthenticate,
onAuthenticateUsingOidc, onAuthenticateUsingOidc,
onMessageDismiss, onMessageDismiss,
@@ -107,8 +108,10 @@ const Login = React.memo(
}, [onAuthenticate, data]); }, [onAuthenticate, data]);
useEffect(() => { useEffect(() => {
if (!isOidcEnforced) {
emailOrUsernameField.current.focus(); emailOrUsernameField.current.focus();
}, []); }
}, [isOidcEnforced]);
useEffect(() => { useEffect(() => {
if (wasSubmitting && !isSubmitting && error) { if (wasSubmitting && !isSubmitting && error) {
@@ -159,6 +162,7 @@ const Login = React.memo(
onDismiss={onMessageDismiss} onDismiss={onMessageDismiss}
/> />
)} )}
{!isOidcEnforced && (
<Form size="large" onSubmit={handleSubmit}> <Form size="large" onSubmit={handleSubmit}>
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
<div className={styles.inputLabel}>{t('common.emailOrUsername')}</div> <div className={styles.inputLabel}>{t('common.emailOrUsername')}</div>
@@ -195,15 +199,20 @@ const Login = React.memo(
disabled={isSubmitting || isSubmittingUsingOidc} disabled={isSubmitting || isSubmittingUsingOidc}
/> />
</Form> </Form>
)}
{withOidc && ( {withOidc && (
<Button <Button
type="button" type="button"
fluid={isOidcEnforced}
primary={isOidcEnforced}
size={isOidcEnforced ? 'large' : undefined}
icon={isOidcEnforced ? 'right arrow' : undefined}
labelPosition={isOidcEnforced ? 'right' : undefined}
content={t('action.logInWithSSO')}
loading={isSubmittingUsingOidc} loading={isSubmittingUsingOidc}
disabled={isSubmitting || isSubmittingUsingOidc} disabled={isSubmitting || isSubmittingUsingOidc}
onClick={onAuthenticateUsingOidc} onClick={onAuthenticateUsingOidc}
> />
{t('action.logInWithSSO')}
</Button>
)} )}
</div> </div>
</div> </div>
@@ -242,6 +251,7 @@ Login.propTypes = {
isSubmittingUsingOidc: PropTypes.bool.isRequired, isSubmittingUsingOidc: PropTypes.bool.isRequired,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
withOidc: PropTypes.bool.isRequired, withOidc: PropTypes.bool.isRequired,
isOidcEnforced: PropTypes.bool.isRequired,
onAuthenticate: PropTypes.func.isRequired, onAuthenticate: PropTypes.func.isRequired,
onAuthenticateUsingOidc: PropTypes.func.isRequired, onAuthenticateUsingOidc: PropTypes.func.isRequired,
onMessageDismiss: PropTypes.func.isRequired, onMessageDismiss: PropTypes.func.isRequired,
@@ -24,6 +24,7 @@ const AccountPane = React.memo(
organization, organization,
language, language,
isLocked, isLocked,
isUsernameLocked,
isAvatarUpdating, isAvatarUpdating,
usernameUpdateForm, usernameUpdateForm,
emailUpdateForm, emailUpdateForm,
@@ -104,7 +105,7 @@ const AccountPane = React.memo(
value={language || 'auto'} value={language || 'auto'}
onChange={handleLanguageChange} onChange={handleLanguageChange}
/> />
{!isLocked && ( {(!isLocked || !isUsernameLocked) && (
<> <>
<Divider horizontal section> <Divider horizontal section>
<Header as="h4"> <Header as="h4">
@@ -113,13 +114,14 @@ const AccountPane = React.memo(
})} })}
</Header> </Header>
</Divider> </Divider>
{!isUsernameLocked && (
<div className={styles.action}> <div className={styles.action}>
<UserUsernameEditPopup <UserUsernameEditPopup
usePasswordConfirmation
defaultData={usernameUpdateForm.data} defaultData={usernameUpdateForm.data}
username={username} username={username}
isSubmitting={usernameUpdateForm.isSubmitting} isSubmitting={usernameUpdateForm.isSubmitting}
error={usernameUpdateForm.error} error={usernameUpdateForm.error}
usePasswordConfirmation={!isLocked} // FIXME: hack
onUpdate={onUsernameUpdate} onUpdate={onUsernameUpdate}
onMessageDismiss={onUsernameUpdateMessageDismiss} onMessageDismiss={onUsernameUpdateMessageDismiss}
> >
@@ -130,6 +132,9 @@ const AccountPane = React.memo(
</Button> </Button>
</UserUsernameEditPopup> </UserUsernameEditPopup>
</div> </div>
)}
{!isLocked && (
<>
<div className={styles.action}> <div className={styles.action}>
<UserEmailEditPopup <UserEmailEditPopup
usePasswordConfirmation usePasswordConfirmation
@@ -165,6 +170,8 @@ const AccountPane = React.memo(
</div> </div>
</> </>
)} )}
</>
)}
</Tab.Pane> </Tab.Pane>
); );
}, },
@@ -179,6 +186,7 @@ AccountPane.propTypes = {
organization: PropTypes.string, organization: PropTypes.string,
language: PropTypes.string, language: PropTypes.string,
isLocked: PropTypes.bool.isRequired, isLocked: PropTypes.bool.isRequired,
isUsernameLocked: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired, isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */ /* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired, usernameUpdateForm: PropTypes.object.isRequired,
@@ -18,6 +18,7 @@ const UserSettingsModal = React.memo(
language, language,
subscribeToOwnCards, subscribeToOwnCards,
isLocked, isLocked,
isUsernameLocked,
isAvatarUpdating, isAvatarUpdating,
usernameUpdateForm, usernameUpdateForm,
emailUpdateForm, emailUpdateForm,
@@ -50,6 +51,7 @@ const UserSettingsModal = React.memo(
organization={organization} organization={organization}
language={language} language={language}
isLocked={isLocked} isLocked={isLocked}
isUsernameLocked={isUsernameLocked}
isAvatarUpdating={isAvatarUpdating} isAvatarUpdating={isAvatarUpdating}
usernameUpdateForm={usernameUpdateForm} usernameUpdateForm={usernameUpdateForm}
emailUpdateForm={emailUpdateForm} emailUpdateForm={emailUpdateForm}
@@ -108,6 +110,7 @@ UserSettingsModal.propTypes = {
language: PropTypes.string, language: PropTypes.string,
subscribeToOwnCards: PropTypes.bool.isRequired, subscribeToOwnCards: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired, isLocked: PropTypes.bool.isRequired,
isUsernameLocked: PropTypes.bool.isRequired,
isAvatarUpdating: PropTypes.bool.isRequired, isAvatarUpdating: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */ /* eslint-disable react/forbid-prop-types */
usernameUpdateForm: PropTypes.object.isRequired, usernameUpdateForm: PropTypes.object.isRequired,
@@ -136,13 +136,15 @@ const ActionsStep = React.memo(
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
{!user.isLocked && ( {!user.isUsernameLocked && (
<>
<Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}> <Menu.Item className={styles.menuItem} onClick={handleEditUsernameClick}>
{t('action.editUsername', { {t('action.editUsername', {
context: 'title', context: 'title',
})} })}
</Menu.Item> </Menu.Item>
)}
{!user.isLocked && (
<>
<Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}> <Menu.Item className={styles.menuItem} onClick={handleEditEmailClick}>
{t('action.editEmail', { {t('action.editEmail', {
context: 'title', context: 'title',
@@ -19,6 +19,7 @@ const Item = React.memo(
isAdmin, isAdmin,
isLocked, isLocked,
isRoleLocked, isRoleLocked,
isUsernameLocked,
isDeletionLocked, isDeletionLocked,
emailUpdateForm, emailUpdateForm,
passwordUpdateForm, passwordUpdateForm,
@@ -61,6 +62,7 @@ const Item = React.memo(
phone, phone,
isAdmin, isAdmin,
isLocked, isLocked,
isUsernameLocked,
isDeletionLocked, isDeletionLocked,
emailUpdateForm, emailUpdateForm,
passwordUpdateForm, passwordUpdateForm,
@@ -95,6 +97,7 @@ Item.propTypes = {
isAdmin: PropTypes.bool.isRequired, isAdmin: PropTypes.bool.isRequired,
isLocked: PropTypes.bool.isRequired, isLocked: PropTypes.bool.isRequired,
isRoleLocked: PropTypes.bool.isRequired, isRoleLocked: PropTypes.bool.isRequired,
isUsernameLocked: PropTypes.bool.isRequired,
isDeletionLocked: PropTypes.bool.isRequired, isDeletionLocked: PropTypes.bool.isRequired,
/* eslint-disable react/forbid-prop-types */ /* eslint-disable react/forbid-prop-types */
emailUpdateForm: PropTypes.object.isRequired, emailUpdateForm: PropTypes.object.isRequired,
@@ -10,6 +10,7 @@ import Item from './Item';
const UsersModal = React.memo( const UsersModal = React.memo(
({ ({
items, items,
canAdd,
onUpdate, onUpdate,
onUsernameUpdate, onUsernameUpdate,
onUsernameUpdateMessageDismiss, onUsernameUpdateMessageDismiss,
@@ -112,6 +113,7 @@ const UsersModal = React.memo(
isAdmin={item.isAdmin} isAdmin={item.isAdmin}
isLocked={item.isLocked} isLocked={item.isLocked}
isRoleLocked={item.isRoleLocked} isRoleLocked={item.isRoleLocked}
isUsernameLocked={item.isUsernameLocked}
isDeletionLocked={item.isDeletionLocked} isDeletionLocked={item.isDeletionLocked}
emailUpdateForm={item.emailUpdateForm} emailUpdateForm={item.emailUpdateForm}
passwordUpdateForm={item.passwordUpdateForm} passwordUpdateForm={item.passwordUpdateForm}
@@ -129,11 +131,13 @@ const UsersModal = React.memo(
</Table.Body> </Table.Body>
</Table> </Table>
</Modal.Content> </Modal.Content>
{canAdd && (
<Modal.Actions> <Modal.Actions>
<UserAddPopupContainer> <UserAddPopupContainer>
<Button positive content={t('action.addUser')} /> <Button positive content={t('action.addUser')} />
</UserAddPopupContainer> </UserAddPopupContainer>
</Modal.Actions> </Modal.Actions>
)}
</Modal> </Modal>
); );
}, },
@@ -141,6 +145,7 @@ const UsersModal = React.memo(
UsersModal.propTypes = { UsersModal.propTypes = {
items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types items: PropTypes.array.isRequired, // eslint-disable-line react/forbid-prop-types
canAdd: PropTypes.bool.isRequired,
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
onUsernameUpdate: PropTypes.func.isRequired, onUsernameUpdate: PropTypes.func.isRequired,
onUsernameUpdateMessageDismiss: PropTypes.func.isRequired, onUsernameUpdateMessageDismiss: PropTypes.func.isRequired,
+1
View File
@@ -20,6 +20,7 @@ const mapStateToProps = (state) => {
isSubmittingUsingOidc, isSubmittingUsingOidc,
error, error,
withOidc: !!oidcConfig, withOidc: !!oidcConfig,
isOidcEnforced: oidcConfig && oidcConfig.isEnforced,
}; };
}; };
@@ -16,6 +16,7 @@ const mapStateToProps = (state) => {
language, language,
subscribeToOwnCards, subscribeToOwnCards,
isLocked, isLocked,
isUsernameLocked,
isAvatarUpdating, isAvatarUpdating,
emailUpdateForm, emailUpdateForm,
passwordUpdateForm, passwordUpdateForm,
@@ -32,6 +33,7 @@ const mapStateToProps = (state) => {
language, language,
subscribeToOwnCards, subscribeToOwnCards,
isLocked, isLocked,
isUsernameLocked,
isAvatarUpdating, isAvatarUpdating,
emailUpdateForm, emailUpdateForm,
passwordUpdateForm, passwordUpdateForm,
@@ -6,10 +6,12 @@ import entryActions from '../entry-actions';
import UsersModal from '../components/UsersModal'; import UsersModal from '../components/UsersModal';
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const oidcConfig = selectors.selectOidcConfig(state);
const users = selectors.selectUsersExceptCurrent(state); const users = selectors.selectUsersExceptCurrent(state);
return { return {
items: users, items: users,
canAdd: !oidcConfig || !oidcConfig.isEnforced,
}; };
}; };
+1
View File
@@ -46,6 +46,7 @@ export default class extends BaseModel {
isAdmin: attr(), isAdmin: attr(),
isLocked: attr(), isLocked: attr(),
isRoleLocked: attr(), isRoleLocked: attr(),
isUsernameLocked: attr(),
isDeletionLocked: attr(), isDeletionLocked: attr(),
deletedAt: attr(), deletedAt: attr(),
createdAt: attr({ createdAt: attr({
+5
View File
@@ -44,8 +44,13 @@ services:
# - OIDC_CLIENT_SECRET= # - OIDC_CLIENT_SECRET=
# - OIDC_SCOPES=openid email profile # - OIDC_SCOPES=openid email profile
# - OIDC_ADMIN_ROLES=admin # - OIDC_ADMIN_ROLES=admin
# - OIDC_EMAIL_ATTRIBUTE=email
# - OIDC_NAME_ATTRIBUTE=name
# - OIDC_USERNAME_ATTRIBUTE=preferred_username
# - OIDC_ROLES_ATTRIBUTE=groups # - OIDC_ROLES_ATTRIBUTE=groups
# - OIDC_IGNORE_USERNAME=true
# - OIDC_IGNORE_ROLES=true # - OIDC_IGNORE_ROLES=true
# - OIDC_ENFORCED=true
depends_on: depends_on:
- postgres - postgres
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "planka", "name": "planka",
"version": "1.15.4", "version": "1.15.6",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "planka", "name": "planka",
"version": "1.15.4", "version": "1.15.6",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "planka", "name": "planka",
"version": "1.15.4", "version": "1.15.6",
"private": true, "private": true,
"homepage": "https://plankanban.github.io/planka", "homepage": "https://plankanban.github.io/planka",
"repository": { "repository": {
+5
View File
@@ -27,8 +27,13 @@ SECRET_KEY=notsecretkey
# OIDC_CLIENT_SECRET= # OIDC_CLIENT_SECRET=
# OIDC_SCOPES=openid email profile # OIDC_SCOPES=openid email profile
# OIDC_ADMIN_ROLES=admin # OIDC_ADMIN_ROLES=admin
# OIDC_EMAIL_ATTRIBUTE=email
# OIDC_NAME_ATTRIBUTE=name
# OIDC_USERNAME_ATTRIBUTE=preferred_username
# OIDC_ROLES_ATTRIBUTE=groups # OIDC_ROLES_ATTRIBUTE=groups
# OIDC_IGNORE_USERNAME=true
# OIDC_IGNORE_ROLES=true # OIDC_IGNORE_ROLES=true
# OIDC_ENFORCED=true
## Do not edit this ## Do not edit this
@@ -46,8 +46,11 @@ module.exports = {
}, },
async fn(inputs) { async fn(inputs) {
const remoteAddress = getRemoteAddress(this.req); if (sails.config.custom.oidcEnforced) {
throw Errors.USE_SINGLE_SIGN_ON;
}
const remoteAddress = getRemoteAddress(this.req);
const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername); const user = await sails.helpers.users.getOneByEmailOrUsername(inputs.emailOrUsername);
if (!user) { if (!user) {
+1
View File
@@ -10,6 +10,7 @@ module.exports = {
response_mode: 'fragment', response_mode: 'fragment',
}), }),
endSessionUrl: oidcClient.issuer.end_session_endpoint ? oidcClient.endSessionUrl({}) : null, endSessionUrl: oidcClient.issuer.end_session_endpoint ? oidcClient.endSessionUrl({}) : null,
isEnforced: sails.config.custom.oidcEnforced,
}; };
} }
+10
View File
@@ -1,6 +1,9 @@
const zxcvbn = require('zxcvbn'); const zxcvbn = require('zxcvbn');
const Errors = { const Errors = {
NOT_ENOUGH_RIGHTS: {
notEnoughRights: 'Not enough rights',
},
EMAIL_ALREADY_IN_USE: { EMAIL_ALREADY_IN_USE: {
emailAlreadyInUse: 'Email already in use', emailAlreadyInUse: 'Email already in use',
}, },
@@ -56,6 +59,9 @@ module.exports = {
}, },
exits: { exits: {
notEnoughRights: {
responseType: 'forbidden',
},
emailAlreadyInUse: { emailAlreadyInUse: {
responseType: 'conflict', responseType: 'conflict',
}, },
@@ -65,6 +71,10 @@ module.exports = {
}, },
async fn(inputs) { async fn(inputs) {
if (sails.config.custom.oidcEnforced) {
throw Errors.NOT_ENOUGH_RIGHTS;
}
const values = _.pick(inputs, [ const values = _.pick(inputs, [
'email', 'email',
'password', 'password',
@@ -53,11 +53,7 @@ module.exports = {
async fn(inputs) { async fn(inputs) {
const { currentUser } = this.req; const { currentUser } = this.req;
if (inputs.id === currentUser.id) { if (inputs.id !== currentUser.id && !currentUser.isAdmin) {
if (!inputs.currentPassword) {
throw Errors.INVALID_CURRENT_PASSWORD;
}
} else if (!currentUser.isAdmin) {
throw Errors.USER_NOT_FOUND; // Forbidden throw Errors.USER_NOT_FOUND; // Forbidden
} }
@@ -67,16 +63,19 @@ module.exports = {
throw Errors.USER_NOT_FOUND; throw Errors.USER_NOT_FOUND;
} }
if (user.email === sails.config.custom.defaultAdminEmail || user.isSso) { if (user.email === sails.config.custom.defaultAdminEmail) {
throw Errors.NOT_ENOUGH_RIGHTS; throw Errors.NOT_ENOUGH_RIGHTS;
} }
if ( if (user.isSso) {
inputs.id === currentUser.id && if (!sails.config.custom.oidcIgnoreUsername) {
!bcrypt.compareSync(inputs.currentPassword, user.password) throw Errors.NOT_ENOUGH_RIGHTS;
) { }
} else if (inputs.id === currentUser.id) {
if (!inputs.currentPassword || !bcrypt.compareSync(inputs.currentPassword, user.password)) {
throw Errors.INVALID_CURRENT_PASSWORD; throw Errors.INVALID_CURRENT_PASSWORD;
} }
}
const values = _.pick(inputs, ['username']); const values = _.pick(inputs, ['username']);
@@ -38,7 +38,10 @@ module.exports = {
throw 'invalidCodeOrNonce'; throw 'invalidCodeOrNonce';
} }
if (!userInfo.email || !userInfo.name) { if (
!userInfo[sails.config.custom.oidcEmailAttribute] ||
!userInfo[sails.config.custom.oidcNameAttribute]
) {
throw 'missingValues'; throw 'missingValues';
} }
@@ -56,12 +59,14 @@ module.exports = {
const values = { const values = {
isAdmin, isAdmin,
email: userInfo.email, email: userInfo[sails.config.custom.oidcEmailAttribute],
isSso: true, isSso: true,
name: userInfo.name, name: userInfo[sails.config.custom.oidcNameAttribute],
username: userInfo.preferred_username,
subscribeToOwnCards: false, subscribeToOwnCards: false,
}; };
if (!sails.config.custom.oidcIgnoreUsername) {
values.username = userInfo[sails.config.custom.oidcUsernameAttribute];
}
let user; let user;
// This whole block technically needs to be executed in a transaction // This whole block technically needs to be executed in a transaction
@@ -95,7 +100,10 @@ module.exports = {
}); });
} }
const updateFieldKeys = ['email', 'isSso', 'name', 'username']; const updateFieldKeys = ['email', 'isSso', 'name'];
if (!sails.config.custom.oidcIgnoreUsername) {
updateFieldKeys.push('username');
}
if (!sails.config.custom.oidcIgnoreRoles) { if (!sails.config.custom.oidcIgnoreRoles) {
updateFieldKeys.push('isAdmin'); updateFieldKeys.push('isAdmin');
} }
+1
View File
@@ -116,6 +116,7 @@ module.exports = {
..._.omit(this, ['password', 'isSso', 'avatar', 'passwordChangedAt']), ..._.omit(this, ['password', 'isSso', 'avatar', 'passwordChangedAt']),
isLocked: this.isSso || isDefaultAdmin, isLocked: this.isSso || isDefaultAdmin,
isRoleLocked: (this.isSso && !sails.config.custom.oidcIgnoreRoles) || isDefaultAdmin, isRoleLocked: (this.isSso && !sails.config.custom.oidcIgnoreRoles) || isDefaultAdmin,
isUsernameLocked: (this.isSso && !sails.config.custom.oidcIgnoreUsername) || isDefaultAdmin,
isDeletionLocked: isDefaultAdmin, isDeletionLocked: isDefaultAdmin,
avatarUrl: avatarUrl:
this.avatar && this.avatar &&
+5
View File
@@ -38,8 +38,13 @@ module.exports.custom = {
oidcClientSecret: process.env.OIDC_CLIENT_SECRET, oidcClientSecret: process.env.OIDC_CLIENT_SECRET,
oidcScopes: process.env.OIDC_SCOPES || 'openid email profile', oidcScopes: process.env.OIDC_SCOPES || 'openid email profile',
oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [], oidcAdminRoles: process.env.OIDC_ADMIN_ROLES ? process.env.OIDC_ADMIN_ROLES.split(',') : [],
oidcEmailAttribute: process.env.OIDC_EMAIL_ATTRIBUTE || 'email',
oidcNameAttribute: process.env.OIDC_NAME_ATTRIBUTE || 'name',
oidcUsernameAttribute: process.env.OIDC_USERNAME_ATTRIBUTE || 'preferred_username',
oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups', oidcRolesAttribute: process.env.OIDC_ROLES_ATTRIBUTE || 'groups',
oidcIgnoreUsername: process.env.OIDC_IGNORE_USERNAME === 'true',
oidcIgnoreRoles: process.env.OIDC_IGNORE_ROLES === 'true', oidcIgnoreRoles: process.env.OIDC_IGNORE_ROLES === 'true',
oidcEnforced: process.env.OIDC_ENFORCED === 'true',
// TODO: move client base url to environment variable? // TODO: move client base url to environment variable?
oidcRedirectUri: `${ oidcRedirectUri: `${