diff --git a/docs/extending-overseerr/reverse-proxy.md b/docs/extending-overseerr/reverse-proxy.md index 84752f7c..ec456fdb 100644 --- a/docs/extending-overseerr/reverse-proxy.md +++ b/docs/extending-overseerr/reverse-proxy.md @@ -141,7 +141,7 @@ location ^~ /overseerr { sub_filter '\/_next' '\/$app\/_next'; sub_filter '/_next' '/$app/_next'; sub_filter '/api/v1' '/$app/api/v1'; - sub_filter '/login/plex/loading' '/$app/login/plex/loading'; + sub_filter '/login/popup/loading' '/$app/login/popup/loading'; sub_filter '/images/' '/$app/images/'; sub_filter '/android-' '/$app/android-'; sub_filter '/apple-' '/$app/apple-'; diff --git a/overseerr-api.yml b/overseerr-api.yml index c4c1e97b..3b59afe8 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3435,6 +3435,80 @@ paths: type: string required: - password + /auth/oidc-login: + get: + security: [] + summary: Redirect to the OpenID Connect provider + description: Constructs the redirect URL to the OpenID Connect provider, and redirects the user to it. + tags: + - auth + responses: + '302': + description: Redirect to the authentication url for the OpenID Connect provider + headers: + Location: + schema: + type: string + example: https://example.com/auth/oidc/callback?response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Foidc%2Fcallback&scope=openid%20email&state=state + Set-Cookie: + schema: + type: string + example: 'oidc-state=123456789; HttpOnly; max-age=60000; Secure' + /auth/oidc-callback: + get: + security: [] + summary: The callback endpoint for the OpenID Connect provider redirect + description: Takes the `code` and `state` parameters from the OpenID Connect provider, and exchanges them for a token. + parameters: + - in: query + name: code + required: true + schema: + type: string + example: '123456789' + - in: query + name: state + required: true + schema: + type: string + example: '123456789' + - in: query + name: scope + required: false + allowReserved: true + schema: + type: string + example: 'openid email profile' + - in: query + name: error + required: false + schema: + type: string + - in: query + name: error_description + required: false + schema: + type: string + - in: cookie + name: oidc-state + required: true + schema: + type: string + example: '123456789' + tags: + - auth + responses: + '302': + description: A redirect to the home page if successful or back to the login page if not + headers: + Location: + schema: + type: string + example: / + Set-Cookie: + schema: + type: string + example: 'oidc-state=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' /user: get: summary: Get all users diff --git a/package.json b/package.json index 8b82e45d..92aae6dc 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "formik": "2.2.9", "gravatar-url": "3.1.0", "intl": "1.2.5", + "jwt-decode": "^3.1.2", "lodash": "4.17.21", "next": "12.3.4", "node-cache": "5.1.2", diff --git a/server/index.ts b/server/index.ts index 10ca1032..8073fc99 100644 --- a/server/index.ts +++ b/server/index.ts @@ -31,6 +31,7 @@ import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; import type { Store } from 'express-session'; import session from 'express-session'; +import _ from 'lodash'; import next from 'next'; import path from 'path'; import swaggerUi from 'swagger-ui-express'; @@ -43,6 +44,39 @@ const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); +const logMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Enhanced sanitization function with explicit return type + const sanitize = (input: unknown, depth = 0): string => { + if (depth > 2) { + return typeof input === 'string' ? _.escape(input) : '[Complex Object]'; + } + if (typeof input === 'string') { + return _.escape(_.truncate(input, { length: 200 })); + } else if (_.isObject(input)) { + return _.mapValues(input, (value) => + sanitize(value, depth + 1) + ) as unknown as string; + } + return input as string; + }; + + // Function to selectively log properties with explicit parameter types + const safeLog = (key: string, value: unknown): unknown => { + const sensitiveKeys = ['authorization', 'cookie', 'set-cookie']; + if (sensitiveKeys.includes(key.toLowerCase())) { + return '[Filtered]'; + } + return sanitize(value); + }; + + logger.debug(`Request Method: ${sanitize(req.method)}`); + logger.debug(`Request URL: ${sanitize(req.originalUrl)}`); + logger.debug(`Request Headers: ${JSON.stringify(req.headers, safeLog)}`); + logger.debug(`Request Body: ${JSON.stringify(req.body, safeLog)}`); + + next(); +}; + app .prepare() .then(async () => { @@ -104,6 +138,7 @@ app if (settings.main.trustProxy) { server.enable('trust proxy'); } + server.use(logMiddleware); server.use(cookieParser()); server.use(express.json()); server.use(express.urlencoded({ extended: true })); diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 0cd2f171..191d0ff1 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -37,6 +37,8 @@ export interface PublicSettingsResponse { locale: string; emailEnabled: boolean; newPlexLogin: boolean; + oidcLogin: boolean; + oidcName: string; } export interface CacheItem { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 10213a04..6c53f09d 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -99,6 +99,11 @@ export interface MainSettings { hideAvailable: boolean; localLogin: boolean; newPlexLogin: boolean; + oidcLogin: boolean; + oidcName: string; + oidcClientId: string; + oidcClientSecret: string; + oidcDomain: string; region: string; originalLanguage: string; trustProxy: boolean; @@ -126,6 +131,8 @@ interface FullPublicSettings extends PublicSettings { locale: string; emailEnabled: boolean; newPlexLogin: boolean; + oidcLogin: boolean; + oidcName: string; } export interface NotificationAgentConfig { @@ -295,6 +302,11 @@ class Settings { hideAvailable: false, localLogin: true, newPlexLogin: true, + oidcLogin: false, + oidcName: '', + oidcClientId: '', + oidcClientSecret: '', + oidcDomain: '', region: '', originalLanguage: '', trustProxy: false, @@ -508,6 +520,8 @@ class Settings { locale: this.data.main.locale, emailEnabled: this.data.notifications.agents.email.enabled, newPlexLogin: this.data.main.newPlexLogin, + oidcLogin: this.data.main.oidcLogin, + oidcName: this.data.main.oidcName, }; } diff --git a/server/logger.ts b/server/logger.ts index d5809a0e..fa10e890 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -26,7 +26,7 @@ const hformat = winston.format.printf( ); const logger = winston.createLogger({ - level: process.env.LOG_LEVEL?.toLowerCase() || 'debug', + level: process.env.LOG_LEVEL?.toLowerCase() || 'info', format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), diff --git a/server/routes/auth.ts b/server/routes/auth.ts index cb6db87c..2e55416b 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,7 +6,18 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; +import type { OIDCJwtPayload } from '@server/utils/oidc'; +import { + createJwtSchema, + getOIDCRedirectUrl, + getOIDCWellknownConfiguration, +} from '@server/utils/oidc'; +import { randomBytes } from 'crypto'; +import type { Request } from 'express'; import { Router } from 'express'; +import gravatarUrl from 'gravatar-url'; +import decodeJwt from 'jwt-decode'; +import type { InferType } from 'yup'; const authRoutes = Router(); @@ -419,4 +430,202 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => { return res.status(200).json({ status: 'ok' }); }); +authRoutes.get('/oidc-login', async (req, res, next) => { + try { + const state = randomBytes(32).toString('hex'); + const redirectUrl = await getOIDCRedirectUrl(req, state); + + res.cookie('oidc-state', state, { + maxAge: 60000, + httpOnly: true, + secure: req.protocol === 'https', + }); + + logger.debug('OIDC login initiated', { + path: '/oidc-login', + redirectUrl: redirectUrl, + state: state, + }); + + return res.redirect(redirectUrl); + } catch (error) { + logger.error('Failed to initiate OIDC login', { + path: '/oidc-login', + error: error.message, + }); + next(error); // Or handle the error as appropriate for your application + } +}); + +authRoutes.get('/oidc-callback', async (req, res, next) => { + try { + const logRequestInfo = (req: Request) => { + const remoteIp = + req.headers['x-real-ip'] || + req.headers['x-forwarded-for'] || + req.connection.remoteAddress; + const requestInfo = { + method: req.method, + url: req.url, + headers: req.headers, + remoteIp: remoteIp, + }; + return requestInfo; + }; + + logger.info('OIDC callback initiated', { req: logRequestInfo(req) }); + + const settings = getSettings(); + const { oidcDomain, oidcClientId, oidcClientSecret } = settings.main; + + if (!settings.main.oidcLogin) { + return res.status(500).json({ error: 'OIDC sign-in is disabled.' }); + } + + const cookieState = req.cookies['oidc-state']; + const url = new URL(req.url, `${req.protocol}://${req.hostname}`); + const state = url.searchParams.get('state'); + const scope = url.searchParams.get('scope'); // Optional scope parameter + + if (scope) { + logger.info('OIDC callback with scope', { scope }); + } else { + logger.info('OIDC callback without scope'); + } + + // Check that the request belongs to the correct state + if (state && cookieState === state) { + res.clearCookie('oidc-state'); + } else { + logger.info('Failed OIDC login attempt', { + cause: 'Invalid state', + ip: req.ip, + state: state, + cookieState: cookieState, + }); + return res.redirect('/login'); + } + + const code = url.searchParams.get('code'); + if (!code) { + logger.info('Failed OIDC login attempt', { + cause: 'Invalid code', + ip: req.ip, + code: code, + }); + return res.redirect('/login'); + } + + const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); + + const callbackUrl = new URL( + '/api/v1/auth/oidc-callback', + `${req.protocol}://${req.headers.host}` + ); + const formData = new URLSearchParams(); + formData.append('client_secret', oidcClientSecret); + formData.append('grant_type', 'authorization_code'); + formData.append('redirect_uri', callbackUrl.toString()); + formData.append('client_id', oidcClientId); + formData.append('code', code); + if (scope) { + formData.append('scope', scope); + } + + const response = await fetch(wellKnownInfo.token_endpoint, { + method: 'POST', + headers: new Headers([ + ['Content-Type', 'application/x-www-form-urlencoded'], + ]), + body: formData, + }); + + const body = (await response.json()) as + | { id_token: string; error: never } + | { error: string }; + + if (body.error) { + logger.info('Failed OIDC login attempt', { + cause: `Invalid token response: ${body.error}`, + ip: req.ip, + body: body, + }); + return res.redirect('/login'); + } + + const { id_token: idToken } = body as Extract< + typeof body, + { id_token: string } + >; + try { + const decoded = decodeJwt(idToken); + const jwtSchema = createJwtSchema({ + oidcClientId: oidcClientId, + oidcDomain: oidcDomain, + }); + + await jwtSchema.validate(decoded); + } catch (error) { + logger.info('Failed OIDC login attempt', { + cause: `Invalid JWT: ${error.message}`, + ip: req.ip, + idToken: idToken, + }); + return res.redirect('/login'); + } + + // Check that email is verified and map email to user + const decoded: InferType> = + decodeJwt(idToken); + + if (!decoded.email_verified) { + logger.info('Failed OIDC login attempt', { + cause: 'Email not verified', + ip: req.ip, + email: decoded.email, + }); + return res.redirect('/login'); + } + + const userRepository = getRepository(User); + let user = await userRepository.findOne({ + where: { email: decoded.email }, + }); + + // Create user if it doesn't exist + if (!user) { + logger.info(`Creating user for ${decoded.email}`, { + ip: req.ip, + email: decoded.email, + }); + const avatar = gravatarUrl(decoded.email, { default: 'mm', size: 200 }); + user = new User({ + avatar: avatar, + username: decoded.email, + email: decoded.email, + permissions: settings.main.defaultPermissions, + plexToken: '', + userType: UserType.LOCAL, + }); + await userRepository.save(user); + } + + // Set logged in session and return + if (req.session) { + req.session.userId = user.id; + } + return res.redirect('/'); + } catch (error) { + // Log the error details + logger.error('Error in OIDC callback', { + path: '/oidc-callback', + error: error.message, + stack: error.stack, // Include the error stack trace for debugging + }); + + // Handle the error as appropriate for your application + next(error); + } +}); + export default authRoutes; diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts new file mode 100644 index 00000000..b79ba30b --- /dev/null +++ b/server/utils/oidc.ts @@ -0,0 +1,143 @@ +import { getSettings } from '@server/lib/settings'; +import type { Request } from 'express'; +import * as yup from 'yup'; + +/** Fetch the oidc configuration blob */ +export async function getOIDCWellknownConfiguration(domain: string) { + // remove trailing slash from url if it exists and add /.well-known/openid-configuration path + const wellKnownUrl = new URL( + `https://${domain}`.replace(/\/$/, '') + '/.well-known/openid-configuration' + ).toString(); + const wellKnownInfo: WellKnownConfiguration = await fetch(wellKnownUrl, { + headers: new Headers([['Content-Type', 'application/json']]), + }).then((r) => r.json()); + + return wellKnownInfo; +} + +export async function getOIDCRedirectUrl(req: Request, state: string) { + const settings = getSettings(); + const { oidcDomain, oidcClientId } = settings.main; + + const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); + const url = new URL(wellKnownInfo.authorization_endpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', oidcClientId); + + // Use X-Forwarded-Proto if available, otherwise fall back to req.protocol + const protocol = req.headers['x-forwarded-proto'] || req.protocol; + const callbackUrl = new URL('/api/v1/auth/oidc-callback', `${protocol}://${req.headers.host}`).toString(); + url.searchParams.set('redirect_uri', callbackUrl); + url.searchParams.set('scope', 'openid profile email'); + url.searchParams.set('state', state); + + return url.toString(); +} + + +export const createJwtSchema = ({ + oidcDomain, + oidcClientId, +}: { + oidcDomain: string; + oidcClientId: string; +}) => { + return yup.object().shape({ + iss: yup + .string() + .oneOf( + [`https://${oidcDomain}`, `https://${oidcDomain}/`], + `The token iss value doesn't match the oidc_DOMAIN (${oidcDomain})` + ) + .required("The token didn't come with an iss value."), + + aud: yup + .mixed() + .test( + 'aud-test', + `The token aud value doesn't match the oidc_CLIENT_ID (${oidcClientId})`, + (value) => { + const audience = Array.isArray(value) ? value : [value]; + return audience.includes(oidcClientId); + } + ) + .required("The token didn't come with an aud value."), + + exp: yup + .number() + .required() + .test( + 'is_before_date', + 'Token exp value is before current time.', + (value) => { + // Check if 'value' is undefined + if (value === undefined) return false; + return value >= Math.ceil(Date.now() / 1000); + } + ), + + iat: yup + .number() + .required() + .test( + 'is_before_one_day', + 'Token was issued before one day ago and is now invalid.', + (value) => { + // Check if 'value' is undefined + if (value === undefined) return false; + const oneDayAgo = Math.ceil(Number(new Date()) / 1000) - 86400; + return value >= oneDayAgo; + } + ), + + email: yup.string().email().required(), + email_verified: yup.boolean().required(), + }); +}; + +export interface WellKnownConfiguration { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + device_authorization_endpoint: string; + userinfo_endpoint: string; + mfa_challenge_endpoint: string; + jwks_uri: string; + registration_endpoint: string; + revocation_endpoint: string; + scopes_supported: string[]; + response_types_supported: string[]; + code_challenge_methods_supported: string[]; + response_modes_supported: string[]; + subject_types_supported: string[]; + id_token_signing_alg_values_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + claims_supported: string[]; + request_uri_parameter_supported: boolean; +} + +export interface OIDCJwtPayload { + // Standard OIDC Claims + iss: string; // Issuer Identifier + sub: string; // Subject Identifier + aud: string | string[]; // Audience + exp: number; // Expiration time + iat: number; // Issued at time + auth_time?: number; // Time when the authentication occurred (optional) + nonce?: string; // String value used to associate a Client session with an ID Token (optional) + + // Commonly used OIDC Claims + email?: string; // User's email address (optional) + email_verified?: boolean; // Whether the user's email address has been verified (optional) + name?: string; // User's full name (optional) + given_name?: string; // User's given name(s) or first name(s) (optional) + family_name?: string; // User's surname(s) or last name(s) (optional) + preferred_username?: string; // Shorthand name by which the user wishes to be referred to (optional) + locale?: string; // User's locale (optional) + zoneinfo?: string; // User's time zone (optional) + + // Other possible custom claims (these depend on your OIDC provider) + // Include any additional fields that your OIDC provider might use + [additionalClaim: string]: unknown; +} + diff --git a/src/components/Login/OIDCLoginButton.tsx b/src/components/Login/OIDCLoginButton.tsx new file mode 100644 index 00000000..1bb5ef31 --- /dev/null +++ b/src/components/Login/OIDCLoginButton.tsx @@ -0,0 +1,53 @@ +import globalMessages from '@app/i18n/globalMessages'; +import OIDCAuth from '@app/utils/oidc'; +import { ArrowLeftOnRectangleIcon} from '@heroicons/react/24/outline'; +import { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + signinwithoidc: 'Sign In With {provider}', +}); + +type Props = { + revalidate: () => void; + oidcName: string; +}; + +const oidcAuth = new OIDCAuth(); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function OIDCLoginButton({ revalidate, oidcName }: Props) { + const intl = useIntl(); + const [loading, setLoading] = useState(false); + const handleClick = async () => { + setLoading(true); + try { + await oidcAuth.preparePopup(); + } catch (e) { + setLoading(false); + return; + } finally { + setLoading(false); + } + }; + + return ( + + + + ); +} + +export default OIDCLoginButton; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index fe92629a..a54f86ef 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -3,6 +3,7 @@ import ImageFader from '@app/components/Common/ImageFader'; import PageTitle from '@app/components/Common/PageTitle'; import LanguagePicker from '@app/components/Layout/LanguagePicker'; import LocalLogin from '@app/components/Login/LocalLogin'; +import OIDCLoginButton from '@app/components/Login/OIDCLoginButton'; import PlexLoginButton from '@app/components/PlexLoginButton'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; @@ -19,6 +20,7 @@ const messages = defineMessages({ signinheader: 'Sign in to continue', signinwithplex: 'Use your Plex account', signinwithoverseerr: 'Use your {applicationTitle} account', + signinwithoidcaccount: 'Use your {oidcName} account', }); const Login = () => { @@ -118,33 +120,31 @@ const Login = () => { {({ openIndexes, handleClick, AccordionContent }) => ( <> - - -
- setAuthToken(authToken)} - /> + {settings.currentSettings.newPlexLogin && ( +
+ + +
+ setAuthToken(authToken)} + /> +
+
- + )} {settings.currentSettings.localLogin && (
)} + {settings.currentSettings.oidcLogin && ( +
+ + +
+ +
+
+
+ )} )} diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index bc752384..d9cc13dc 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -1,6 +1,7 @@ import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; import PermissionEdit from '@app/components/PermissionEdit'; import QuotaSelector from '@app/components/QuotaSelector'; import globalMessages from '@app/i18n/globalMessages'; @@ -11,6 +12,7 @@ import { Field, Form, Formik } from 'formik'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; +import * as yup from 'yup'; const messages = defineMessages({ users: 'Users', @@ -27,9 +29,44 @@ const messages = defineMessages({ tvRequestLimitLabel: 'Global Series Request Limit', defaultPermissions: 'Default Permissions', defaultPermissionsTip: 'Initial permissions assigned to new users', + oidcLogin: 'Enable OIDC Sign-In', + oidcLoginTip: 'Allow users to sign in using an OIDC provider', + oidcName: 'OIDC Provider Name', + oidcNameTip: 'The string used as name on the login page', + oidcClientId: 'OIDC Client ID', + oidcClientSecret: 'OIDC Client Secret', + oidcDomain: 'OIDC Domain', }); -const SettingsUsers = () => { +const validationSchema = yup.object().shape({ + oidcLogin: yup.boolean(), + oidcClientId: yup.string().when('oidcLogin', { + is: true, + then: yup.string().required(), + }), + oidcClientSecret: yup.string().when('oidcLogin', { + is: true, + then: yup.string().required(), + }), + oidcDomain: yup.string().when('oidcLogin', { + is: true, + then: yup + .string() + .required() + .test({ + message: 'Must be a valid domain', + test: (val) => { + return ( + !!val && + // Any HTTPS domain without query string + /^([a-zA-Z0-9-_]+\.)[^?]+$/i.test(val) + ); + }, + }), + }), +}); + +const SettingsUsers: React.FC = () => { const { addToast } = useToasts(); const intl = useIntl(); const { @@ -61,18 +98,29 @@ const SettingsUsers = () => { initialValues={{ localLogin: data?.localLogin, newPlexLogin: data?.newPlexLogin, + oidcName: data?.oidcName, + oidcLogin: data?.oidcLogin, + oidcClientId: data?.oidcClientId, + oidcClientSecret: data?.oidcClientSecret, + oidcDomain: data?.oidcDomain, movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0, movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7, tvQuotaLimit: data?.defaultQuotas.tv.quotaLimit ?? 0, tvQuotaDays: data?.defaultQuotas.tv.quotaDays ?? 7, defaultPermissions: data?.defaultPermissions ?? 0, }} + validationSchema={validationSchema} enableReinitialize onSubmit={async (values) => { try { await axios.post('/api/v1/settings/main', { localLogin: values.localLogin, newPlexLogin: values.newPlexLogin, + oidcLogin: values.oidcLogin, + oidcClientId: values.oidcClientId, + oidcClientSecret: values.oidcClientSecret, + oidcDomain: values.oidcDomain, + oidcName: values.oidcName, defaultQuotas: { movie: { quotaLimit: values.movieQuotaLimit, @@ -101,7 +149,7 @@ const SettingsUsers = () => { } }} > - {({ isSubmitting, values, setFieldValue }) => { + {({ isSubmitting, values, setFieldValue, errors, touched }) => { return (
@@ -140,6 +188,100 @@ const SettingsUsers = () => { />
+
+ +
+ { + setFieldValue('oidcLogin', !values.oidcLogin); + }} + /> +
+
+ {values.oidcLogin ? ( + <> +
+ +
+
+ +
+
+
+
+ +
+
+ +
+ {errors.oidcDomain && touched.oidcDomain && ( +
{errors.oidcDomain}
+ )} +
+
+
+ +
+
+ +
+ {errors.oidcClientId && touched.oidcDomain && ( +
{errors.oidcClientId}
+ )} +
+
+
+ +
+
+ { + setFieldValue('oidcClientSecret', e.target.value); + }} + autoComplete="off" + /> +
+ {errors.oidcClientSecret && + touched.oidcClientSecret && ( +
+ {errors.oidcClientSecret} +
+ )} +
+
+ + ) : null}