From d4e6a350802afb27ab45b6498a14821a0c057318 Mon Sep 17 00:00:00 2001 From: jacknbeans Date: Thu, 18 Jun 2026 23:56:32 -0600 Subject: [PATCH 1/3] feat(oidc): added role based authorization A claim can be used to acquire roles for the user. These roles are then verified with the authorizing role to grant access. --- server/lib/settings/index.ts | 10 +++ server/routes/auth.ts | 42 ++++++++++--- .../Settings/EditOidcModal/index.tsx | 63 ++++++++++++++++--- .../Settings/SettingsOidc/index.tsx | 4 +- 4 files changed, 101 insertions(+), 18 deletions(-) diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 9f2c723298..5951813fca 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; }; @@ -702,6 +704,8 @@ class Settings { originalLanguage: '', blocklistedTags: '', blocklistedTagsLimit: 50, + blocklistRegion: '', + blocklistLanguage: '', mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, @@ -755,6 +759,7 @@ class Settings { requireTls: false, allowSelfSigned: false, senderName: 'Seerr', + usePublicLogo: false, }, }, discord: { @@ -765,6 +770,8 @@ class Settings { webhookUrl: '', webhookRoleId: '', enableMentions: true, + locale: 'en', + useUserLocale: true, }, }, slack: { @@ -773,6 +780,7 @@ class Settings { types: 0, options: { webhookUrl: '', + locale: 'en', }, }, telegram: { @@ -827,6 +835,7 @@ class Settings { url: '', token: '', priority: 0, + locale: 'en', }, }, ntfy: { @@ -837,6 +846,7 @@ class Settings { url: '', topic: '', priority: 3, + locale: 'en', }, }, }, 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}
+ )} +
+