diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 9f2c723298..0c75e65122 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -69,6 +69,8 @@ export type OidcProvider = { logo?: string; requiredClaims?: string; scopes?: string; + roleClaim?: string; + userRoles?: string; newUserLogin?: boolean; }; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ba9d6de93f..2a92d4d6e6 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -721,15 +721,15 @@ authRoutes.get('/oidc/login/:slug', async (req, res, next) => { * is why we're using it regardless. Like PKCE, random state must be generated * for every redirect to the authorization_endpoint. */ - if (!config.serverMetadata().supportsPKCE()) { - const state = openIdClient.randomState(); - parameters.state = state; - res.cookie('oidc-state', state, { - maxAge: 60000, - httpOnly: true, - secure: req.protocol === 'https', - }); - } + // if (!config.serverMetadata().supportsPKCE()) { + const state = openIdClient.randomState(); + parameters.state = state; + res.cookie('oidc-state', state, { + maxAge: 60000, + httpOnly: true, + secure: req.protocol === 'https', + }); + // } let redirectUrl: URL; try { @@ -867,6 +867,30 @@ authRoutes.post('/oidc/callback/:slug', async (req, res, next) => { return value === true; }); + let hasUserRole = false; + const roleClaim = provider.roleClaim; + if (roleClaim) { + const providerUserRoles = provider.userRoles?.split(',') ?? []; + const userRoles = fullUserInfo[roleClaim]; + hasUserRole = providerUserRoles.some((providerUserRole) => + Array.isArray(userRoles) + ? userRoles.some((userRole) => providerUserRole === userRole) + : false + ); + + if (!hasUserRole) { + logger.info('Failed OIDC login attempt', { + cause: 'Failed to validate user role claims', + ip: req.ip, + userRoleClaims: provider.userRoles, + }); + return next({ + status: 403, + error: ApiErrorCode.Unauthorized, + }); + } + } + if (!hasRequiredClaims) { logger.info('Failed OIDC login attempt', { cause: 'Failed to validate required claims', diff --git a/src/components/Settings/EditOidcModal/index.tsx b/src/components/Settings/EditOidcModal/index.tsx index 9bfff92105..f4f990a369 100644 --- a/src/components/Settings/EditOidcModal/index.tsx +++ b/src/components/Settings/EditOidcModal/index.tsx @@ -2,6 +2,7 @@ import Accordion from '@app/components/Common/Accordion'; import Button from '@app/components/Common/Button'; import Modal from '@app/components/Common/Modal'; import SensitiveInput from '@app/components/Common/SensitiveInput'; +import useToasts from '@app/hooks/useToasts'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import { Transition } from '@headlessui/react'; @@ -13,7 +14,6 @@ import axios from 'axios'; import { Field, Formik, useFormikContext, type FieldAttributes } from 'formik'; import { useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; -import { useToasts } from 'react-toast-notifications'; import { twMerge } from 'tailwind-merge'; import * as Yup from 'yup'; @@ -41,6 +41,11 @@ const messages = defineMessages('settings.settings.SettingsOidc', { oidcRequiredClaims: 'Required Claims', oidcRequiredClaimsTip: 'Space-separated list of boolean claims that are required to log in', + oidcRoleClaim: 'Role Claim', + oidcRoleClaimTip: 'The claim that holds the user roles', + oidcUserRoles: 'User Roles', + oidcUserRolesTip: + 'Comma-separated list of roles that a user must have to authorize access', oidcNewUserLogin: 'Allow New Users', oidcNewUserLoginTip: 'Create accounts for new users logging in with this provider', @@ -98,6 +103,8 @@ export default function EditOidcModal(props: EditOidcModalProps) { logo: Yup.string(), requiredClaims: Yup.string(), scopes: Yup.string(), + roleClaim: Yup.string(), + userRoles: Yup.string(), newUserLogin: Yup.boolean(), }); @@ -111,7 +118,7 @@ export default function EditOidcModal(props: EditOidcModalProps) { }); props.onOk(); - } catch (e) { + } catch { addToast(intl.formatMessage(messages.saveError), { appearance: 'error', autoDismiss: true, @@ -128,10 +135,12 @@ export default function EditOidcModal(props: EditOidcModalProps) { issuerUrl: props.provider?.issuerUrl ?? '', clientId: props.provider?.clientId ?? '', clientSecret: props.provider?.clientSecret ?? '', - logo: props.provider?.logo, - requiredClaims: props.provider?.requiredClaims, - scopes: props.provider?.scopes, - newUserLogin: props.provider?.newUserLogin, + logo: props.provider?.logo ?? '', + requiredClaims: props.provider?.requiredClaims ?? '', + scopes: props.provider?.scopes ?? '', + roleClaim: props.provider?.roleClaim ?? '', + userRoles: props.provider?.userRoles ?? '', + newUserLogin: props.provider?.newUserLogin ?? false, }} validationSchema={oidcSettingsSchema} onSubmit={onSubmit} @@ -149,7 +158,7 @@ export default function EditOidcModal(props: EditOidcModalProps) { autoDismiss: true, appearance: 'success', }); - } catch (e) { + } catch { addToast(intl.formatMessage(messages.toastTestFailed), { autoDismiss: true, appearance: 'error', @@ -394,6 +403,46 @@ export default function EditOidcModal(props: EditOidcModalProps) { )} +
+ +
+ + {errors.roleClaim && + touched.roleClaim && + typeof errors.roleClaim === 'string' && ( +
{errors.roleClaim}
+ )} +
+
+
+ +
+ + {errors.userRoles && + touched.userRoles && + typeof errors.userRoles === 'string' && ( +
{errors.userRoles}
+ )} +
+