From 8d64b6f219b7c00ea514dbf942680bbe2a89528f Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 10 Jul 2022 22:33:03 -0400 Subject: [PATCH 01/58] feat: support OIDC authentication --- overseerr-api.yml | 14 ++ package.json | 5 + server/interfaces/api/settingsInterfaces.ts | 4 + server/lib/oidc.ts | 33 ++++ server/lib/settings.ts | 16 ++ server/middleware/auth.ts | 31 +++ server/routes/auth.ts | 62 +++++- src/components/Layout/UserDropdown/index.tsx | 7 + src/components/Login/OidcLogin.tsx | 75 ++++++++ src/components/Login/index.tsx | 158 ++++++++++------ .../Settings/SettingsUsers/index.tsx | 82 +++++++- src/context/SettingsContext.tsx | 4 + src/pages/_app.tsx | 79 +++++--- yarn.lock | 177 ++++++++++++++++++ 14 files changed, 655 insertions(+), 92 deletions(-) create mode 100644 server/lib/oidc.ts create mode 100644 src/components/Login/OidcLogin.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 551f7dd91..2993339fe 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3308,6 +3308,20 @@ paths: required: - username - password + /auth/oidc: + get: + summary: Sign in using a OIDC access token + description: Sign in using an OIDC access token, requires an OIDC identity provider to be set up. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' /auth/local: post: summary: Sign in using a local account diff --git a/package.json b/package.json index 7d5bb637d..7f11415c9 100644 --- a/package.json +++ b/package.json @@ -39,18 +39,22 @@ "email-templates": "^8.0.10", "email-validator": "^2.0.4", "express": "^4.17.3", + "express-jwt": "^7.7.5", + "express-jwt-authz": "^2.4.1", "express-openapi-validator": "^4.13.6", "express-rate-limit": "^6.3.0", "express-session": "^1.17.2", "formik": "^2.2.9", "gravatar-url": "^3.1.0", "intl": "^1.2.5", + "jwks-rsa": "^2.1.4", "lodash": "^4.17.21", "next": "12.1.0", "node-cache": "^5.1.2", "node-gyp": "^9.0.0", "node-schedule": "^2.1.0", "nodemailer": "^6.7.2", + "oidc-client-ts": "^2.0.5", "openpgp": "^5.2.0", "plex-api": "^5.3.2", "pug": "^3.0.2", @@ -61,6 +65,7 @@ "react-intersection-observer": "^8.33.1", "react-intl": "5.24.7", "react-markdown": "^8.0.0", + "react-oidc-context": "^2.1.1", "react-select": "^5.2.2", "react-spring": "^9.4.4", "react-toast-notifications": "^2.5.1", diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index c486a1b46..056151649 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -28,6 +28,10 @@ export interface PublicSettingsResponse { applicationUrl: string; hideAvailable: boolean; localLogin: boolean; + oidcLogin: boolean; + oidcIssuer: string; + oidcProviderName: string; + oidcClientId: string; movie4kEnabled: boolean; series4kEnabled: boolean; region: string; diff --git a/server/lib/oidc.ts b/server/lib/oidc.ts new file mode 100644 index 000000000..4dff20d33 --- /dev/null +++ b/server/lib/oidc.ts @@ -0,0 +1,33 @@ +import axios from 'axios'; + +interface OidcInfo { + issuer: string; + jwksUri: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + introspectionEndpoint: string; + revocationEndpoint: string; +} + +const oidcConfigurationEndpoint = '.well-known/openid-configuration'; + +export const getOidcInfo = async (oidcIssuerUrl: string): Promise => { + const oidcConfigurationUrl = new URL( + oidcConfigurationEndpoint, + oidcIssuerUrl + ); + const oidcConfiguration: Record = ( + await axios.get(oidcConfigurationUrl.href) + ).data; + const oidcInfo: OidcInfo = { + issuer: oidcConfiguration.issuer, + jwksUri: oidcConfiguration.jwks_uri, + authorizationEndpoint: oidcConfiguration.authorization_endpoint, + tokenEndpoint: oidcConfiguration.token_endpoint, + userinfoEndpoint: oidcConfiguration.userinfo_endpoint, + introspectionEndpoint: oidcConfiguration.introspection_endpoint, + revocationEndpoint: oidcConfiguration.revocation_endpoint, + }; + return oidcInfo; +}; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 53fe864c1..e876dfaf1 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -104,6 +104,10 @@ export interface MainSettings { hideAvailable: boolean; localLogin: boolean; newPlexLogin: boolean; + oidcLogin: boolean; + oidcIssuer: string; + oidcProviderName: string; + oidcClientId: string; region: string; originalLanguage: string; trustProxy: boolean; @@ -121,6 +125,10 @@ interface FullPublicSettings extends PublicSettings { applicationUrl: string; hideAvailable: boolean; localLogin: boolean; + oidcLogin: boolean; + oidcIssuer: string; + oidcProviderName: string; + oidcClientId: string; movie4kEnabled: boolean; series4kEnabled: boolean; region: string; @@ -305,6 +313,10 @@ class Settings { hideAvailable: false, localLogin: true, newPlexLogin: true, + oidcLogin: false, + oidcIssuer: '', + oidcProviderName: 'OpenID Connect', + oidcClientId: '', region: '', originalLanguage: '', trustProxy: false, @@ -516,6 +528,10 @@ class Settings { applicationUrl: this.data.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, localLogin: this.data.main.localLogin, + oidcLogin: this.data.main.oidcLogin, + oidcIssuer: this.data.main.oidcIssuer, + oidcProviderName: this.data.main.oidcProviderName, + oidcClientId: this.data.main.oidcClientId, movie4kEnabled: this.data.radarr.some( (radarr) => radarr.is4k && radarr.isDefault ), diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 68869222f..8dd4b3777 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,5 +1,8 @@ +import { expressjwt as jwt, GetVerificationKey } from 'express-jwt'; +import jwksRsa from 'jwks-rsa'; import { getRepository } from 'typeorm'; import { User } from '../entity/User'; +import { getOidcInfo } from '../lib/oidc'; import { Permission, PermissionCheckOptions } from '../lib/permissions'; import { getSettings } from '../lib/settings'; @@ -53,3 +56,31 @@ export const isAuthenticated = ( }; return authMiddleware; }; + +// checking the JWT +export const checkJwt = (): Middleware => { + const settings = getSettings(); + settings.load(); + + const getSecret: GetVerificationKey = async function (req, token) { + const oidcInfo = await getOidcInfo(settings.fullPublicSettings.oidcIssuer); + + const secret = ( + jwksRsa.expressJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: oidcInfo.jwksUri, + }) as GetVerificationKey + )(req, token); + + return secret; + }; + + return jwt({ + // Dynamically provide a signing key based on the kid in the header and the signing keys provided by the JWKS endpoint + secret: getSecret, + issuer: settings.fullPublicSettings.oidcIssuer, + algorithms: ['RS256'], + }); +}; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index ba8926a3f..1ca0146d4 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,15 +1,18 @@ +import axios from 'axios'; +import * as EmailValidator from 'email-validator'; import { Router } from 'express'; +import gravatarUrl from 'gravatar-url'; import { getRepository } from 'typeorm'; import JellyfinAPI from '../api/jellyfin'; import PlexTvAPI from '../api/plextv'; import { MediaServerType } from '../constants/server'; import { UserType } from '../constants/user'; import { User } from '../entity/User'; +import { getOidcInfo } from '../lib/oidc'; import { Permission } from '../lib/permissions'; import { getSettings } from '../lib/settings'; import logger from '../logger'; -import { isAuthenticated } from '../middleware/auth'; -import * as EmailValidator from 'email-validator'; +import { checkJwt, isAuthenticated } from '../middleware/auth'; const authRoutes = Router(); @@ -513,6 +516,61 @@ authRoutes.post('/local', async (req, res, next) => { } }); +authRoutes.get('/oidc', checkJwt(), async (req, res, next) => { + const settings = getSettings(); + const userRepository = getRepository(User); + + try { + const oidcInfo = await getOidcInfo(settings.fullPublicSettings.oidcIssuer); + + const authHeader = req.headers.authorization ?? ''; + + const response = await axios.get(oidcInfo.userinfoEndpoint, { + headers: { Authorization: authHeader }, + }); + const { name, email } = response.data; + + const existingUser = await userRepository + .createQueryBuilder('user') + .select(['user.id', 'user.password']) + .where('user.email = :email', { email: email }) + .getOne(); + + // If user exist, set logged in session + if (existingUser && req.session) { + req.session.userId = existingUser.id; + return res.status(200).json(existingUser?.filter() ?? {}); + } + + // create new user + const avatar = gravatarUrl(email, { default: 'mm', size: 200 }); + const user = new User({ + avatar: avatar, + username: name, + email: email, + permissions: settings.main.defaultPermissions, + plexToken: '', + userType: UserType.LOCAL, + }); + await userRepository.save(user); + + // Set logged in session for newly created user + if (user && req.session) { + req.session.userId = user.id; + } + return res.status(200).json(user?.filter() ?? {}); + } catch (e) { + logger.error('Something went wrong while attempting to authenticate.', { + label: 'Auth', + error: e.message, + }); + return next({ + status: 500, + message: 'Something went wrong.', + }); + } +}); + authRoutes.post('/logout', (req, res, next) => { req.session?.destroy((err) => { if (err) { diff --git a/src/components/Layout/UserDropdown/index.tsx b/src/components/Layout/UserDropdown/index.tsx index e51fcabf5..25e961e29 100644 --- a/src/components/Layout/UserDropdown/index.tsx +++ b/src/components/Layout/UserDropdown/index.tsx @@ -4,7 +4,9 @@ import axios from 'axios'; import Link from 'next/link'; import React, { useRef, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { useAuth } from 'react-oidc-context'; import useClickOutside from '../../../hooks/useClickOutside'; +import useSettings from '../../../hooks/useSettings'; import { useUser } from '../../../hooks/useUser'; import Transition from '../../Transition'; @@ -15,7 +17,9 @@ const messages = defineMessages({ }); const UserDropdown: React.FC = () => { + const settings = useSettings(); const intl = useIntl(); + const auth = useAuth(); const dropdownRef = useRef(null); const { user, revalidate } = useUser(); const [isDropdownOpen, setDropdownOpen] = useState(false); @@ -23,6 +27,9 @@ const UserDropdown: React.FC = () => { const logout = async () => { const response = await axios.post('/api/v1/auth/logout'); + if (settings.currentSettings.oidcLogin && auth.isAuthenticated) { + await auth.signoutRedirect(); + } if (response.data?.status === 'ok') { revalidate(); diff --git a/src/components/Login/OidcLogin.tsx b/src/components/Login/OidcLogin.tsx new file mode 100644 index 000000000..fec071cec --- /dev/null +++ b/src/components/Login/OidcLogin.tsx @@ -0,0 +1,75 @@ +import { LoginIcon } from '@heroicons/react/outline'; +import axios from 'axios'; +import React, { useEffect } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useAuth } from 'react-oidc-context'; +import useSettings from '../../hooks/useSettings'; +import globalMessages from '../../i18n/globalMessages'; +import Button from '../Common/Button'; + +const messages = defineMessages({ + signinwithoidc: 'Sign in with {OIDCProvider}', + signingin: 'Signing in…', + loginerror: 'Something went wrong while trying to sign in.', +}); + +interface OidcLoginProps { + revalidate: () => void; + setError: (message: string) => void; + isProcessing: boolean; + setProcessing: (state: boolean) => void; +} + +const OidcLogin: React.FC = ({ + revalidate, + setError, + isProcessing, + setProcessing, +}) => { + const intl = useIntl(); + const auth = useAuth(); + const settings = useSettings(); + + useEffect(() => { + const login = async () => { + setProcessing(true); + try { + const token = auth.user?.access_token; + // eslint-disable-next-line + const response = await axios.get('/api/v1/auth/oidc', { + headers: { Authorization: `Bearer ${token}` }, + }); + } catch (e) { + setError(intl.formatMessage(messages.loginerror)); + setProcessing(false); + } finally { + revalidate(); + } + }; + if (auth.isAuthenticated) { + login(); + } + }, [auth, revalidate, intl, setProcessing, setError]); + + return ( + + + + ); +}; + +export default OidcLogin; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index eb8f368bd..0c51f5eff 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -1,5 +1,6 @@ import { XCircleIcon } from '@heroicons/react/solid'; import axios from 'axios'; +import getConfig from 'next/config'; import { useRouter } from 'next/dist/client/router'; import React, { useEffect, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -9,13 +10,14 @@ import useSettings from '../../hooks/useSettings'; import { useUser } from '../../hooks/useUser'; import Accordion from '../Common/Accordion'; import ImageFader from '../Common/ImageFader'; +import LoadingSpinner from '../Common/LoadingSpinner'; import PageTitle from '../Common/PageTitle'; import LanguagePicker from '../Layout/LanguagePicker'; import PlexLoginButton from '../PlexLoginButton'; import Transition from '../Transition'; import JellyfinLogin from './JellyfinLogin'; import LocalLogin from './LocalLogin'; -import getConfig from 'next/config'; +import OidcLogin from './OidcLogin'; const messages = defineMessages({ signin: 'Sign In', @@ -23,6 +25,8 @@ const messages = defineMessages({ signinwithplex: 'Use your Plex account', signinwithjellyfin: 'Use your {mediaServerName} account', signinwithoverseerr: 'Use your {applicationTitle} account', + signinwithoidc: 'Use your {OIDCProvider} account', + authprocessing: 'Authentication in progress...', }); const Login: React.FC = () => { @@ -119,67 +123,99 @@ const Login: React.FC = () => { - - {({ openIndexes, handleClick, AccordionContent }) => ( - <> - - -
+ {isProcessing ? ( +
+

+ {intl.formatMessage(messages.authprocessing)} +

+ +
+ ) : ( + + {({ openIndexes, handleClick, AccordionContent }) => ( + <> +
-
- {settings.currentSettings.localLogin && ( -
- - -
- -
-
-
- )} - - )} -
+ MediaServerType.PLEX + ? intl.formatMessage(messages.signinwithplex) + : intl.formatMessage(messages.signinwithjellyfin, { + mediaServerName: + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' + ? 'Emby' + : 'Jellyfin', + })} + + +
+ {settings.currentSettings.mediaServerType == + MediaServerType.PLEX ? ( + setAuthToken(authToken)} + /> + ) : ( + + )} +
+
+ {settings.currentSettings.oidcLogin && ( +
+ + +
+ +
+
+
+ )} + {settings.currentSettings.localLogin && ( +
+ + +
+ +
+
+
+ )} + + )} + + )} diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 89c89673d..0f6121c8e 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -1,6 +1,7 @@ import { SaveIcon } from '@heroicons/react/outline'; import axios from 'axios'; import { Field, Form, Formik } from 'formik'; +import getConfig from 'next/config'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; @@ -14,7 +15,6 @@ import LoadingSpinner from '../../Common/LoadingSpinner'; import PageTitle from '../../Common/PageTitle'; import PermissionEdit from '../../PermissionEdit'; import QuotaSelector from '../../QuotaSelector'; -import getConfig from 'next/config'; const messages = defineMessages({ users: 'Users', @@ -28,6 +28,15 @@ const messages = defineMessages({ newPlexLogin: 'Enable New {mediaServerName} Sign-In', newPlexLoginTip: 'Allow {mediaServerName} users to sign in without first being imported', + oidcLogin: 'Enable OIDC Sign-In', + oidcLoginTip: 'Allow users to sign in using an OIDC identity provider', + oidcIssuer: 'OIDC Issuer URL', + oidcIssuerTip: "The base URL of the identity provider's OIDC endpoint", + oidcProviderName: 'OIDC Provider Name', + oidcProviderNameTip: + 'Name of the OIDC Provider which appears on the login screen', + oidcClientId: 'OIDC Client ID', + oidcClientIdTip: 'The OIDC Client ID assigned to Jellyseerr', movieRequestLimitLabel: 'Global Movie Request Limit', tvRequestLimitLabel: 'Global Series Request Limit', defaultPermissions: 'Default Permissions', @@ -68,6 +77,10 @@ const SettingsUsers: React.FC = () => { initialValues={{ localLogin: data?.localLogin, newPlexLogin: data?.newPlexLogin, + oidcLogin: data?.oidcLogin, + oidcIssuer: data?.oidcIssuer, + oidcProviderName: data?.oidcProviderName, + oidcClientId: data?.oidcClientId, movieQuotaLimit: data?.defaultQuotas.movie.quotaLimit ?? 0, movieQuotaDays: data?.defaultQuotas.movie.quotaDays ?? 7, tvQuotaLimit: data?.defaultQuotas.tv.quotaLimit ?? 0, @@ -80,6 +93,10 @@ const SettingsUsers: React.FC = () => { await axios.post('/api/v1/settings/main', { localLogin: values.localLogin, newPlexLogin: values.newPlexLogin, + oidcLogin: values.oidcLogin, + oidcIssuer: values.oidcIssuer, + oidcProviderName: values.oidcProviderName, + oidcClientId: values.oidcClientId, defaultQuotas: { movie: { quotaLimit: values.movieQuotaLimit, @@ -163,6 +180,69 @@ const SettingsUsers: React.FC = () => { /> +
+ +
+ { + setFieldValue('oidcLogin', !values.oidcLogin); + }} + /> +
+
+ {values.oidcLogin && ( + <> +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ + )}
diff --git a/src/components/Setup/index.tsx b/src/components/Setup/index.tsx index d2b049c28..d83f8d09a 100644 --- a/src/components/Setup/index.tsx +++ b/src/components/Setup/index.tsx @@ -119,17 +119,16 @@ const Setup: React.FC = () => { )} {currentStep === 2 && (
- {mediaServerType === MediaServerType.PLEX ? ( + {mediaServerType === MediaServerType.PLEX && ( setMediaServerSettingsComplete(true)} /> - ) : mediaServerType === MediaServerType.JELLYFIN ? ( + )} + {mediaServerType === MediaServerType.JELLYFIN && ( setMediaServerSettingsComplete(true)} /> - ) : ( -

This should not happen

)}
diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index 4880a6315..bd7a523a6 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -20,7 +20,7 @@ export interface User { email: string; avatar: string; permissions: number; - userType: number; + userType: UserType; createdAt: Date; updatedAt: Date; requestCount: number; From 2e7c0be01d5dd7e14044108657e326ab50e24a28 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 25 Sep 2022 13:45:35 -0400 Subject: [PATCH 05/58] fix: do not require a server restart to update OIDC issuer --- server/middleware/auth.ts | 6 +++--- server/routes/auth.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index ba6f00a26..516d529ba 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -61,7 +61,7 @@ export const isAuthenticated = ( }; // checking the JWT -export const checkJwt = (): Middleware => { +export const checkJwt: Middleware = (req, res, next) => { const settings = getSettings(); settings.load(); @@ -80,10 +80,10 @@ export const checkJwt = (): Middleware => { return secret; }; - return jwt({ + jwt({ // Dynamically provide a signing key based on the kid in the header and the signing keys provided by the JWKS endpoint secret: getSecret, issuer: settings.fullPublicSettings.oidcIssuer, algorithms: ['RS256'], - }); + })(req, res, next); }; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b3cedb545..b2b1093fd 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -519,7 +519,7 @@ authRoutes.post('/local', async (req, res, next) => { } }); -authRoutes.get('/oidc', checkJwt(), async (req, res, next) => { +authRoutes.get('/oidc', checkJwt, async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); @@ -564,7 +564,7 @@ authRoutes.get('/oidc', checkJwt(), async (req, res, next) => { return res.status(200).json(user?.filter() ?? {}); } catch (e) { logger.error('Something went wrong while attempting to authenticate.', { - label: 'Auth', + label: 'auth', error: e.message, }); return next({ From 88dfb99a8da5060d6d49f193155599394c3dc8db Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 25 Sep 2022 14:09:06 -0400 Subject: [PATCH 06/58] fix: ignore trailing slashes in oidc issuer url --- server/lib/oidc.ts | 2 +- server/middleware/auth.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/server/lib/oidc.ts b/server/lib/oidc.ts index 4dff20d33..3ccd0b106 100644 --- a/server/lib/oidc.ts +++ b/server/lib/oidc.ts @@ -15,7 +15,7 @@ const oidcConfigurationEndpoint = '.well-known/openid-configuration'; export const getOidcInfo = async (oidcIssuerUrl: string): Promise => { const oidcConfigurationUrl = new URL( oidcConfigurationEndpoint, - oidcIssuerUrl + oidcIssuerUrl.slice(-1) == '/' ? oidcIssuerUrl : oidcIssuerUrl + '/' ); const oidcConfiguration: Record = ( await axios.get(oidcConfigurationUrl.href) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 516d529ba..b25b59d49 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -65,8 +65,13 @@ export const checkJwt: Middleware = (req, res, next) => { const settings = getSettings(); settings.load(); + const oidcIssuer = + settings.fullPublicSettings.oidcIssuer.slice(-1) == '/' + ? settings.fullPublicSettings.oidcIssuer.slice(0, -1) + : settings.fullPublicSettings.oidcIssuer; + const getSecret: GetVerificationKey = async function (req, token) { - const oidcInfo = await getOidcInfo(settings.fullPublicSettings.oidcIssuer); + const oidcInfo = await getOidcInfo(oidcIssuer); const secret = ( jwksRsa.expressJwtSecret({ @@ -83,7 +88,7 @@ export const checkJwt: Middleware = (req, res, next) => { jwt({ // Dynamically provide a signing key based on the kid in the header and the signing keys provided by the JWKS endpoint secret: getSecret, - issuer: settings.fullPublicSettings.oidcIssuer, + issuer: oidcIssuer, algorithms: ['RS256'], })(req, res, next); }; From 88ac5af4ec53d020b44f77bf87dee7f4060500e1 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 25 Sep 2022 14:10:09 -0400 Subject: [PATCH 07/58] fix: don't get stuck in an infinite loop if oidc server validation fails --- src/components/Login/OidcLogin.tsx | 12 +++++++----- src/components/Login/index.tsx | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/Login/OidcLogin.tsx b/src/components/Login/OidcLogin.tsx index b1e0e208a..d58d15c1d 100644 --- a/src/components/Login/OidcLogin.tsx +++ b/src/components/Login/OidcLogin.tsx @@ -16,16 +16,18 @@ const messages = defineMessages({ interface OidcLoginProps { revalidate: () => void; - setError: (message: string) => void; isProcessing: boolean; setProcessing: (state: boolean) => void; + hasError: boolean; + onError?: (message: string) => void; } const OidcLogin: React.FC = ({ revalidate, - setError, isProcessing, setProcessing, + hasError, + onError, }) => { const intl = useIntl(); const auth = useAuth(); @@ -41,16 +43,16 @@ const OidcLogin: React.FC = ({ headers: { Authorization: `Bearer ${token}` }, }); } catch (e) { - setError(intl.formatMessage(messages.loginerror)); + if (onError) onError(intl.formatMessage(messages.loginerror)); setProcessing(false); } finally { revalidate(); } }; - if (auth.isAuthenticated) { + if (auth.isAuthenticated && !hasError) { login(); } - }, [auth, revalidate, intl, setProcessing, setError]); + }, [auth, revalidate, intl, setProcessing, onError, hasError]); return ( diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 3a1e522c0..f378de16e 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -137,7 +137,8 @@ const Login = () => {
From 1c8829aaa12bc8701f14de8cdebc4a70223e6800 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Tue, 25 Oct 2022 22:43:49 -0400 Subject: [PATCH 08/58] fix: do not remove trailing slash from OIDC issuer URL as some providers require it --- server/middleware/auth.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index b25b59d49..9f8037918 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -65,10 +65,7 @@ export const checkJwt: Middleware = (req, res, next) => { const settings = getSettings(); settings.load(); - const oidcIssuer = - settings.fullPublicSettings.oidcIssuer.slice(-1) == '/' - ? settings.fullPublicSettings.oidcIssuer.slice(0, -1) - : settings.fullPublicSettings.oidcIssuer; + const oidcIssuer = settings.fullPublicSettings.oidcIssuer; const getSecret: GetVerificationKey = async function (req, token) { const oidcInfo = await getOidcInfo(oidcIssuer); From 617653c361afd3d11702c232c377235f73b2bd69 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 30 Oct 2022 11:03:10 -0400 Subject: [PATCH 09/58] fix: use OIDC id_token to validate user --- overseerr-api.yml | 16 +++++++++++++++- server/middleware/auth.ts | 15 +++++++++++++-- server/routes/auth.ts | 7 +++---- src/components/Login/OidcLogin.tsx | 7 +++---- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index ea085cdba..4835b6f8c 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3321,7 +3321,7 @@ paths: - username - password /auth/oidc: - get: + post: summary: Sign in using a OIDC access token description: Sign in using an OIDC access token, requires an OIDC identity provider to be set up. security: [] @@ -3334,6 +3334,20 @@ paths: application/json: schema: $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + idToken: + type: string + accessToken: + type: string + required: + - idToken + - accessToken /auth/local: post: summary: Sign in using a local account diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 9f8037918..5aa20f9f6 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -6,7 +6,11 @@ import type { PermissionCheckOptions, } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; -import { expressjwt as jwt, type GetVerificationKey } from 'express-jwt'; +import { + expressjwt as jwt, + type GetVerificationKey, + type TokenGetter, +} from 'express-jwt'; import jwksRsa from 'jwks-rsa'; export const checkUser: Middleware = async (req, _res, next) => { @@ -82,10 +86,17 @@ export const checkJwt: Middleware = (req, res, next) => { return secret; }; + const getToken: TokenGetter = (req) => { + const body = req.body as { idToken?: string; accessToken?: string }; + return body.idToken ?? ''; + }; + jwt({ - // Dynamically provide a signing key based on the kid in the header and the signing keys provided by the JWKS endpoint + // Dynamically provide a signing key based on the kid in the header + // and the signing keys provided by the JWKS endpoint secret: getSecret, issuer: oidcIssuer, + getToken, algorithms: ['RS256'], })(req, res, next); }; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b2b1093fd..9360a9e96 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -519,17 +519,16 @@ authRoutes.post('/local', async (req, res, next) => { } }); -authRoutes.get('/oidc', checkJwt, async (req, res, next) => { +authRoutes.post('/oidc', checkJwt, async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); + const body = req.body as { idToken?: string; accessToken?: string }; try { const oidcInfo = await getOidcInfo(settings.fullPublicSettings.oidcIssuer); - const authHeader = req.headers.authorization ?? ''; - const response = await axios.get(oidcInfo.userinfoEndpoint, { - headers: { Authorization: authHeader }, + headers: { Authorization: `Bearer ${body.accessToken}` }, }); const { name, email } = response.data; diff --git a/src/components/Login/OidcLogin.tsx b/src/components/Login/OidcLogin.tsx index d58d15c1d..b14eb9d32 100644 --- a/src/components/Login/OidcLogin.tsx +++ b/src/components/Login/OidcLogin.tsx @@ -37,10 +37,9 @@ const OidcLogin: React.FC = ({ const login = async () => { setProcessing(true); try { - const token = auth.user?.access_token; - // eslint-disable-next-line - const response = await axios.get('/api/v1/auth/oidc', { - headers: { Authorization: `Bearer ${token}` }, + await axios.post('/api/v1/auth/oidc', { + idToken: auth.user?.id_token, + accessToken: auth.user?.access_token, }); } catch (e) { if (onError) onError(intl.formatMessage(messages.loginerror)); From 83876e046660e9e25b16cd501463413d82c44e24 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 30 Oct 2022 14:53:20 -0400 Subject: [PATCH 10/58] perf: cache oidc provider endpoint configuration --- server/api/oidc.ts | 49 +++++++++++++++++++++++++++++++++++++++ server/lib/cache.ts | 7 +++++- server/lib/oidc.ts | 33 -------------------------- server/middleware/auth.ts | 7 +++--- server/routes/auth.ts | 8 ++++--- 5 files changed, 64 insertions(+), 40 deletions(-) create mode 100644 server/api/oidc.ts delete mode 100644 server/lib/oidc.ts diff --git a/server/api/oidc.ts b/server/api/oidc.ts new file mode 100644 index 000000000..af09a9e5f --- /dev/null +++ b/server/api/oidc.ts @@ -0,0 +1,49 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; +import logger from '@server/logger'; + +interface OidcInfo { + issuer: string; + jwks_uri: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + introspection_endpoint: string; + revocation_endpoint: string; +} + +class OidcAPI extends ExternalAPI { + constructor(oidcIssuerUrl: string) { + super( + oidcIssuerUrl, + {}, + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('oidc').data, + } + ); + } + + public async getOidcInfo(): Promise { + try { + const data = await this.get( + '/.well-known/openid-configuration' + ); + + return data; + } catch (e) { + // put an error in the log + logger.warn( + 'Failed to retrieve data from OIDC discovery endpoint. The OpenID Connect issuer configuration may be invalid.', + { label: 'OIDC', errorMessage: e.message } + ); + + throw e; + } + } +} + +export default OidcAPI; diff --git a/server/lib/cache.ts b/server/lib/cache.ts index e81466629..0fcc06699 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -7,7 +7,8 @@ export type AvailableCacheIds = | 'rt' | 'github' | 'plexguid' - | 'plextv'; + | 'plextv' + | 'oidc'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -63,6 +64,10 @@ class CacheManager { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60, }), + oidc: new Cache('oidc', 'Open ID Connect Provider API', { + stdTtl: 86400 * 7, // 1 week cache + checkPeriod: 60 * 30, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/lib/oidc.ts b/server/lib/oidc.ts deleted file mode 100644 index 3ccd0b106..000000000 --- a/server/lib/oidc.ts +++ /dev/null @@ -1,33 +0,0 @@ -import axios from 'axios'; - -interface OidcInfo { - issuer: string; - jwksUri: string; - authorizationEndpoint: string; - tokenEndpoint: string; - userinfoEndpoint: string; - introspectionEndpoint: string; - revocationEndpoint: string; -} - -const oidcConfigurationEndpoint = '.well-known/openid-configuration'; - -export const getOidcInfo = async (oidcIssuerUrl: string): Promise => { - const oidcConfigurationUrl = new URL( - oidcConfigurationEndpoint, - oidcIssuerUrl.slice(-1) == '/' ? oidcIssuerUrl : oidcIssuerUrl + '/' - ); - const oidcConfiguration: Record = ( - await axios.get(oidcConfigurationUrl.href) - ).data; - const oidcInfo: OidcInfo = { - issuer: oidcConfiguration.issuer, - jwksUri: oidcConfiguration.jwks_uri, - authorizationEndpoint: oidcConfiguration.authorization_endpoint, - tokenEndpoint: oidcConfiguration.token_endpoint, - userinfoEndpoint: oidcConfiguration.userinfo_endpoint, - introspectionEndpoint: oidcConfiguration.introspection_endpoint, - revocationEndpoint: oidcConfiguration.revocation_endpoint, - }; - return oidcInfo; -}; diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 5aa20f9f6..a2e28a059 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,6 +1,6 @@ +import OidcAPI from '@server/api/oidc'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; -import { getOidcInfo } from '@server/lib/oidc'; import type { Permission, PermissionCheckOptions, @@ -70,16 +70,17 @@ export const checkJwt: Middleware = (req, res, next) => { settings.load(); const oidcIssuer = settings.fullPublicSettings.oidcIssuer; + const oidcApi = new OidcAPI(oidcIssuer); const getSecret: GetVerificationKey = async function (req, token) { - const oidcInfo = await getOidcInfo(oidcIssuer); + const oidcInfo = await oidcApi.getOidcInfo(); const secret = ( jwksRsa.expressJwtSecret({ cache: true, rateLimit: true, jwksRequestsPerMinute: 5, - jwksUri: oidcInfo.jwksUri, + jwksUri: oidcInfo.jwks_uri, }) as GetVerificationKey )(req, token); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 9360a9e96..259954f8c 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -1,11 +1,11 @@ import JellyfinAPI from '@server/api/jellyfin'; +import OidcAPI from '@server/api/oidc'; import PlexTvAPI from '@server/api/plextv'; import { MediaServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; -import { getOidcInfo } from '@server/lib/oidc'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -522,12 +522,14 @@ authRoutes.post('/local', async (req, res, next) => { authRoutes.post('/oidc', checkJwt, async (req, res, next) => { const settings = getSettings(); const userRepository = getRepository(User); + const oidcApi = new OidcAPI(settings.fullPublicSettings.oidcIssuer); + const body = req.body as { idToken?: string; accessToken?: string }; try { - const oidcInfo = await getOidcInfo(settings.fullPublicSettings.oidcIssuer); + const oidcInfo = await oidcApi.getOidcInfo(); - const response = await axios.get(oidcInfo.userinfoEndpoint, { + const response = await axios.get(oidcInfo.userinfo_endpoint, { headers: { Authorization: `Bearer ${body.accessToken}` }, }); const { name, email } = response.data; From 70c6eaf2ae121d9a06b16e1c6de3f4d8457c2e3f Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 14 Nov 2022 17:03:04 -0500 Subject: [PATCH 11/58] fix: restore rounded corners & fix styling bugs --- src/components/Login/JellyfinLogin.tsx | 2 +- src/components/Login/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 848c5f9bd..6f2f1ebef 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -237,7 +237,7 @@ const JellyfinLogin: React.FC = ({ return ( <>
-
+
diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index f378de16e..ef26bdb01 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -71,7 +71,7 @@ const Login = () => {
<> From 32c8b722ed37263d6e19b657ea5d1f8f53e3720c Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 14 Nov 2022 17:09:10 -0500 Subject: [PATCH 12/58] fix: ensure correct login page sections are visible --- src/components/Login/index.tsx | 149 +++++++++++++++------------------ 1 file changed, 68 insertions(+), 81 deletions(-) diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index ef26bdb01..f4baf882b 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -50,6 +50,57 @@ const Login = () => { revalidateOnFocus: false, }); + const loginSections = [ + { + // Media Server Login + title: + settings.currentSettings.mediaServerType == MediaServerType.PLEX + ? intl.formatMessage(messages.useplexaccount) + : intl.formatMessage(messages.usejellyfinaccount, { + mediaServerName: + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' + ? 'Emby' + : 'Jellyfin', + }), + enabled: settings.currentSettings.newPlexLogin, + content: + settings.currentSettings.mediaServerType == MediaServerType.PLEX ? ( + setError(err)} + /> + ) : ( + + ), + }, + { + // Local Login + title: intl.formatMessage(messages.useoverseeerraccount, { + applicationTitle: settings.currentSettings.applicationTitle, + }), + enabled: settings.currentSettings.localLogin, + content: , + }, + { + // OIDC Login + title: intl.formatMessage(messages.useoidcaccount, { + OIDCProvider: settings.currentSettings.oidcProviderName, + }), + enabled: settings.currentSettings.oidcLogin, + content: ( + + ), + }, + ]; + return (
@@ -87,87 +138,23 @@ const Login = () => { {({ openIndexes, handleClick, AccordionContent }) => ( <> - - -
- {settings.currentSettings.mediaServerType == - MediaServerType.PLEX ? ( - setError(err)} - /> - ) : ( - - )} -
-
- {settings.currentSettings.oidcLogin && ( -
- - -
- -
-
-
- )} - {settings.currentSettings.localLogin && ( -
- - -
- -
-
-
- )} + {loginSections + .filter((section) => section.enabled) + .map((section, i) => ( +
+ + +
{section.content}
+
+
+ ))} )}
From 67b0c730a796a0617e028a7205dd8f356247b013 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Wed, 16 Nov 2022 13:48:39 -0500 Subject: [PATCH 13/58] fix: separate option for enabling/disabling media server login --- overseerr-api.yml | 15 ++ server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/settings.ts | 4 + server/routes/settings/index.ts | 17 +- src/components/LabeledCheckbox/index.tsx | 41 +++++ src/components/Login/index.tsx | 2 +- .../Settings/SettingsUsers/index.tsx | 157 ++++++++++-------- src/context/SettingsContext.tsx | 1 + src/pages/_app.tsx | 1 + 9 files changed, 169 insertions(+), 70 deletions(-) create mode 100644 src/components/LabeledCheckbox/index.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 4835b6f8c..9b55a0cc7 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -130,6 +130,21 @@ components: localLogin: type: boolean example: true + mediaServerLogin: + type: boolean + example: true + oidcLogin: + type: boolean + example: true + oidcIssuer: + type: string + example: https://auth.example.com + oidcProviderName: + type: string + example: Keycloak + oidcClientId: + type: string + example: jellyseerr mediaServerType: type: number example: 1 diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index c1e9360b3..43c5aa9d5 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -28,6 +28,7 @@ export interface PublicSettingsResponse { applicationUrl: string; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; oidcLogin: boolean; oidcIssuer: string; oidcProviderName: string; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 038698b37..a58e0673a 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -103,6 +103,7 @@ export interface MainSettings { }; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; newPlexLogin: boolean; oidcLogin: boolean; oidcIssuer: string; @@ -125,6 +126,7 @@ interface FullPublicSettings extends PublicSettings { applicationUrl: string; hideAvailable: boolean; localLogin: boolean; + mediaServerLogin: boolean; oidcLogin: boolean; oidcIssuer: string; oidcProviderName: string; @@ -313,6 +315,7 @@ class Settings { }, hideAvailable: false, localLogin: true, + mediaServerLogin: true, newPlexLogin: true, oidcLogin: false, oidcIssuer: '', @@ -532,6 +535,7 @@ class Settings { applicationUrl: this.data.main.applicationUrl, hideAvailable: this.data.main.hideAvailable, localLogin: this.data.main.localLogin, + mediaServerLogin: this.data.main.mediaServerLogin, oidcLogin: this.data.main.oidcLogin, oidcIssuer: this.data.main.oidcIssuer, oidcProviderName: this.data.main.oidcProviderName, diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 3a7cb6dfc..d324ba1e6 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -63,10 +63,23 @@ settingsRoutes.get('/main', (req, res, next) => { res.status(200).json(filteredMainSettings(req.user, settings.main)); }); -settingsRoutes.post('/main', (req, res) => { +settingsRoutes.post('/main', (req, res, next) => { const settings = getSettings(); - settings.main = merge(settings.main, req.body); + const newSettings: MainSettings = merge(settings.main, req.body); + + if ( + !newSettings.oidcLogin && + !newSettings.mediaServerLogin && + !newSettings.localLogin + ) { + return next({ + status: 500, + message: 'At least one authentication method must be enabled.', + }); + } + + settings.main = newSettings; settings.save(); return res.status(200).json(settings.main); diff --git a/src/components/LabeledCheckbox/index.tsx b/src/components/LabeledCheckbox/index.tsx new file mode 100644 index 000000000..646665dd5 --- /dev/null +++ b/src/components/LabeledCheckbox/index.tsx @@ -0,0 +1,41 @@ +import { Field } from 'formik'; + +interface LabeledCheckboxProps { + id: string; + label: string; + description: string; + onChange: () => void; + children?: React.ReactNode; +} + +const LabeledCheckbox: React.FC = ({ + id, + label, + description, + onChange, + children, +}) => { + return ( + <> +
+
+ +
+
+ +
+
+ { + /* can hold child checkboxes */ + children &&
{children}
+ } + + ); +}; + +export default LabeledCheckbox; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index f4baf882b..882b8ac60 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -62,7 +62,7 @@ const Login = () => { ? 'Emby' : 'Jellyfin', }), - enabled: settings.currentSettings.newPlexLogin, + enabled: settings.currentSettings.mediaServerLogin, content: settings.currentSettings.mediaServerType == MediaServerType.PLEX ? ( { return ; } + const mediaServerName = + publicRuntimeConfig.JELLYFIN_TYPE == 'emby' + ? 'Emby' + : settings.currentSettings.mediaServerType === MediaServerType.PLEX + ? 'Plex' + : 'Jellyfin'; + return ( <> { initialValues={{ localLogin: data?.localLogin, newPlexLogin: data?.newPlexLogin, + mediaServerLogin: data?.mediaServerLogin, oidcLogin: data?.oidcLogin, oidcIssuer: data?.oidcIssuer, oidcProviderName: data?.oidcProviderName, @@ -92,6 +106,7 @@ const SettingsUsers = () => { await axios.post('/api/v1/settings/main', { localLogin: values.localLogin, newPlexLogin: values.newPlexLogin, + mediaServerLogin: values.mediaServerLogin, oidcLogin: values.oidcLogin, oidcIssuer: values.oidcIssuer, oidcProviderName: values.oidcProviderName, @@ -127,74 +142,60 @@ const SettingsUsers = () => { {({ isSubmitting, values, setFieldValue }) => { return ( -
- -
- { - setFieldValue('localLogin', !values.localLogin); - }} - /> -
-
-
- -
- { - setFieldValue('newPlexLogin', !values.newPlexLogin); - }} - /> -
-
-
-
@@ -202,9 +195,12 @@ const OidcModal = ({ { - setFieldValue('oidcMatchUsername', !values.oidcMatchUsername); + setFieldValue( + 'oidc.matchJellyfinUsername', + !values.matchJellyfinUsername + ); }} />
diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 62b4c3dac..5a11b7dd0 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -5,7 +5,9 @@ import FormErrorNotification from '@app/components/FormErrorNotification'; import LabeledCheckbox from '@app/components/LabeledCheckbox'; import PermissionEdit from '@app/components/PermissionEdit'; import QuotaSelector from '@app/components/QuotaSelector'; -import OidcModal from '@app/components/Settings/OidcModal'; +import OidcModal, { + oidcSettingsSchema, +} from '@app/components/Settings/OidcModal'; import useSettings from '@app/hooks/useSettings'; import globalMessages from '@app/i18n/globalMessages'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; @@ -16,7 +18,7 @@ import axios from 'axios'; import { Field, Form, Formik } from 'formik'; import getConfig from 'next/config'; import { useState } from 'react'; -import { defineMessages, useIntl } from 'react-intl'; +import { defineMessages, useIntl, type IntlShape } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR, { mutate } from 'swr'; import * as yup from 'yup'; @@ -46,55 +48,33 @@ const messages = defineMessages({ defaultPermissionsTip: 'Initial permissions assigned to new users', }); -const validationSchema = yup - .object() - .shape({ - localLogin: yup.boolean(), - mediaServerLogin: yup.boolean(), - oidcLogin: yup.boolean(), - oidcName: yup.string().when('oidcLogin', { - is: true, - then: yup.string().required(), - }), - 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 without query string parameters.', - test: (val) => { - return ( - !!val && - // Any HTTP(S) domain without query string - /^(https?:\/\/)([A-Za-z0-9-_.!~*'():]*)(((?!\?).)*$)/i.test(val) - ); - }, - }), - }), - }) - .test({ - name: 'atLeastOneAuth', - test: function (values) { - const isValid = ['localLogin', 'mediaServerLogin', 'oidcLogin'].some( - (field) => !!values[field] - ); +const createValidationSchema = (intl: IntlShape) => { + return yup + .object() + .shape({ + localLogin: yup.boolean(), + mediaServerLogin: yup.boolean(), + oidcLogin: yup.boolean(), + oidc: yup.object().when('oidcLogin', { + is: true, + then: oidcSettingsSchema(intl), + }), + }) + .test({ + name: 'atLeastOneAuth', + test: function (values) { + const isValid = ['localLogin', 'mediaServerLogin', 'oidcLogin'].some( + (field) => !!values[field] + ); - if (isValid) return true; - return this.createError({ - path: 'localLogin | mediaServerLogin | oidcLogin', - message: 'At least one authentication method must be selected.', - }); - }, - }); + if (isValid) return true; + return this.createError({ + path: 'localLogin | mediaServerLogin | oidcLogin', + message: 'At least one authentication method must be selected.', + }); + }, + }); +}; const SettingsUsers = () => { const { addToast } = useToasts(); @@ -144,18 +124,14 @@ const SettingsUsers = () => { newPlexLogin: data?.newPlexLogin, mediaServerLogin: data?.mediaServerLogin, oidcLogin: data?.oidcLogin, - oidcName: data?.oidcName, - oidcClientId: data?.oidcClientId, - oidcClientSecret: data?.oidcClientSecret, - oidcDomain: data?.oidcDomain, - oidcMatchUsername: data?.oidcMatchUsername, + oidc: data?.oidc ?? {}, 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} + validationSchema={() => createValidationSchema(intl)} enableReinitialize onSubmit={async (values) => { try { @@ -164,11 +140,7 @@ const SettingsUsers = () => { newPlexLogin: values.newPlexLogin, mediaServerLogin: values.mediaServerLogin, oidcLogin: values.oidcLogin, - oidcName: values.oidcName, - oidcClientId: values.oidcClientId, - oidcClientSecret: values.oidcClientSecret, - oidcDomain: values.oidcDomain, - oidcMatchUsername: values.oidcMatchUsername, + oidc: values.oidc, defaultQuotas: { movie: { quotaLimit: values.movieQuotaLimit, @@ -277,10 +249,10 @@ const SettingsUsers = () => {
- {values.oidcLogin && showOidcDialog[0] && ( + {values.oidcLogin && values.oidc && showOidcDialog[0] && ( setShowOidcDialog([false, false])} diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 6bc3185be..bb88cccec 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -56,6 +56,7 @@ const globalMessages = defineMessages({ noresults: 'No results.', open: 'Open', resolved: 'Resolved', + fieldRequired: '{fieldName} is required', }); export default globalMessages; From 847d0e2de827ac70afa5d36b0772c31eae14f271 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 2 Oct 2023 09:29:43 -0400 Subject: [PATCH 39/58] fix(OidcSettings): simplify settings modal and improve behavior --- .../Settings/SettingsUsers/index.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 5a11b7dd0..66fe4c9d1 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -87,10 +87,7 @@ const SettingsUsers = () => { const settings = useSettings(); const { publicRuntimeConfig } = getConfig(); // [showDialog, isFirstOpen] - const [showOidcDialog, setShowOidcDialog] = useState<[boolean, boolean]>([ - false, - false, - ]); + const [showOidcDialog, setShowOidcDialog] = useState(false); if (!data && !error) { return ; @@ -236,31 +233,27 @@ const SettingsUsers = () => { onChange={() => { const newValue = !values.oidcLogin; setFieldValue('oidcLogin', newValue); - if (newValue) setShowOidcDialog([true, true]); + if (newValue) setShowOidcDialog(true); }} />
setShowOidcDialog([true, false])} + onClick={() => setShowOidcDialog(true)} />
- {values.oidcLogin && values.oidc && showOidcDialog[0] && ( + {values.oidcLogin && values.oidc && showOidcDialog && ( setShowOidcDialog([false, false])} - onClose={ - showOidcDialog[1] - ? () => setFieldValue('oidcLogin', false) - : undefined - } + onOk={() => setShowOidcDialog(false)} + onClose={() => setFieldValue('oidcLogin', false)} /> )}
From 324e76e4d11d7c058e668678801619c08e08242f Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 2 Oct 2023 09:54:11 -0400 Subject: [PATCH 40/58] fix(User): allow FindOperator queries for email --- server/entity/User.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/entity/User.ts b/server/entity/User.ts index e4c8314c3..2cf06c58a 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -24,6 +24,7 @@ import { PrimaryGeneratedColumn, RelationCount, UpdateDateColumn, + type FindOperator, } from 'typeorm'; import Issue from './Issue'; import { MediaRequest } from './MediaRequest'; @@ -51,7 +52,12 @@ export class User { unique: true, transformer: { from: (value: string): string => (value ?? '').toLowerCase(), - to: (value: string): string => (value ?? '').toLowerCase(), + to: ( + value: string | FindOperator + ): string | FindOperator => { + if (typeof value === 'string') return (value ?? '').toLowerCase(); + return value; + }, }, }) public email: string; From 729acb55011dd1e1557d4e04abbd5fad009d56eb Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 2 Oct 2023 09:55:03 -0400 Subject: [PATCH 41/58] fix: update public settings and usages --- server/interfaces/api/settingsInterfaces.ts | 6 ++---- src/components/Login/OidcLogin.tsx | 2 +- src/components/Login/index.tsx | 2 +- src/context/SettingsContext.tsx | 4 ++-- src/pages/_app.tsx | 2 +- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 069521516..8d8b90a02 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -30,10 +30,8 @@ export interface PublicSettingsResponse { hideAvailable: boolean; localLogin: boolean; mediaServerLogin: boolean; - oidc: { - enabled: boolean; - providerName: string; - }; + oidcLogin: boolean; + oidcProviderName: string; movie4kEnabled: boolean; series4kEnabled: boolean; region: string; diff --git a/src/components/Login/OidcLogin.tsx b/src/components/Login/OidcLogin.tsx index 51a6d2f7d..99a3fef3f 100644 --- a/src/components/Login/OidcLogin.tsx +++ b/src/components/Login/OidcLogin.tsx @@ -59,7 +59,7 @@ const OidcLogin: React.FC = ({ {isProcessing ? intl.formatMessage(globalMessages.loading) : intl.formatMessage(messages.signinwithoidc, { - OIDCProvider: settings.currentSettings.oidcName, + OIDCProvider: settings.currentSettings.oidcProviderName, })} diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 7995b33d3..882b8ac60 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -86,7 +86,7 @@ const Login = () => { { // OIDC Login title: intl.formatMessage(messages.useoidcaccount, { - OIDCProvider: settings.currentSettings.oidcName, + OIDCProvider: settings.currentSettings.oidcProviderName, }), enabled: settings.currentSettings.oidcLogin, content: ( diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 1a19bd6c8..54c24a999 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -8,7 +8,7 @@ export interface SettingsContextProps { children?: React.ReactNode; } -const defaultSettings = { +const defaultSettings: PublicSettingsResponse = { initialized: false, applicationTitle: 'Overseerr', applicationUrl: '', @@ -16,7 +16,7 @@ const defaultSettings = { localLogin: true, mediaServerLogin: true, oidcLogin: false, - oidcName: 'OpenID Connect', + oidcProviderName: 'OpenID Connect', movie4kEnabled: false, series4kEnabled: false, region: '', diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index bc9ce2691..2053e2544 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -182,7 +182,7 @@ CoreApp.getInitialProps = async (initialProps) => { localLogin: true, mediaServerLogin: true, oidcLogin: false, - oidcName: 'OpenID Connect', + oidcProviderName: 'OpenID Connect', region: '', originalLanguage: '', mediaServerType: MediaServerType.NOT_CONFIGURED, From 897dbdabe5743e5b9d5a673e79c263426eea0b5d Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 2 Oct 2023 09:55:33 -0400 Subject: [PATCH 42/58] fix(auth): modify error message --- server/routes/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 24ac15e8f..a3840596b 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -679,7 +679,7 @@ authRoutes.get('/oidc-login', async (req, res, next) => { }); return next({ status: 500, - message: 'Failed to fetch OpenID Connect redirect url.', + message: 'Configuration error.', }); } From 06ab0d4f4af1d891e520730351fa63d1d436388b Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 2 Oct 2023 09:55:51 -0400 Subject: [PATCH 43/58] chore(callback): fix lint error --- src/pages/login/oidc/callback.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/login/oidc/callback.tsx b/src/pages/login/oidc/callback.tsx index 587eaec8e..b298310b1 100644 --- a/src/pages/login/oidc/callback.tsx +++ b/src/pages/login/oidc/callback.tsx @@ -35,7 +35,7 @@ const OidcCallback = () => { useEffect(() => { login(); - }, []); + }); return (
From 510869975e482bef6b81619fb1f116d6f8f0d013 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 14:42:37 -0500 Subject: [PATCH 44/58] feat: display new settings in advanced section of modal --- src/components/Settings/OidcModal/index.tsx | 296 ++++++++++++++------ 1 file changed, 203 insertions(+), 93 deletions(-) diff --git a/src/components/Settings/OidcModal/index.tsx b/src/components/Settings/OidcModal/index.tsx index 9f12aebb0..12b11b0e7 100644 --- a/src/components/Settings/OidcModal/index.tsx +++ b/src/components/Settings/OidcModal/index.tsx @@ -1,6 +1,8 @@ +import Accordion from '@app/components/Common/Accordion'; import Modal from '@app/components/Common/Modal'; import globalMessages from '@app/i18n/globalMessages'; import { Transition } from '@headlessui/react'; +import { ChevronDownIcon } from '@heroicons/react/24/solid'; import type { MainSettings } from '@server/lib/settings'; import { ErrorMessage, @@ -26,6 +28,14 @@ const messages = defineMessages({ oidcClientIdTip: 'The OIDC Client ID assigned to Jellyseerr', oidcClientSecret: 'Client Secret', oidcClientSecretTip: 'The OIDC Client Secret assigned to Jellyseerr', + oidcScopes: 'Scopes', + oidcScopesTip: 'The scopes to request from the identity provider.', + oidcIdentificationClaims: 'Identification Claims', + oidcIdentificationClaimsTip: + 'OIDC claims to use as unique identifiers for the given user. Will be matched ' + + "against the user's email and, optionally, their media server username.", + oidcRequiredClaims: 'Required Claims', + oidcRequiredClaimsTip: 'Claims that are required for a user to log in.', oidcMatchUsername: 'Allow {mediaServerName} Usernames', oidcMatchUsernameTip: 'Match OIDC users with their {mediaServerName} accounts by username', @@ -67,6 +77,12 @@ export const oidcSettingsSchema = (intl: IntlShape) => { clientSecret: yup .string() .required(requiredMessage(messages.oidcClientSecret)), + scopes: yup.string().required(requiredMessage(messages.oidcScopes)), + userIdentifier: yup + .string() + .required(requiredMessage(messages.oidcIdentificationClaims)), + requiredClaims: yup.string(), + matchJellyfinUsername: yup.boolean(), }); }; @@ -107,104 +123,198 @@ const OidcModal = ({ onOk={onOk} title={intl.formatMessage(messages.configureoidc)} > -
-
- -
- - -
-
-
- -
- - +
+
+
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- - +
+ +
+ + +
-
-
- -
- { - setFieldValue( - 'oidc.matchJellyfinUsername', - !values.matchJellyfinUsername - ); - }} - /> +
+ +
+ + +
+ + {({ openIndexes, handleClick, AccordionContent }) => ( + <> + + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ { + setFieldValue( + 'oidc.matchJellyfinUsername', + !values.matchJellyfinUsername + ); + }} + /> +
+
+
+
+ + )} +
From dc8251f99660f3f71fef4f9bd1d7d6ec88961b61 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 15:01:49 -0500 Subject: [PATCH 45/58] style: clean up auth routes & types --- server/routes/auth.ts | 29 +++++++++-------------------- server/utils/oidc.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index a3840596b..aaf52fb02 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -740,12 +740,10 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { const wellKnownInfo = await getOIDCWellknownConfiguration(oidc.providerUrl); // Fetch the token data - const body = (await fetchOIDCTokenData(req, wellKnownInfo, code)) as - | { id_token: string; access_token: string; error: never } - | { error: string }; + const body = await fetchOIDCTokenData(req, wellKnownInfo, code); // Validate that the token response is valid and not manipulated - if (body.error) { + if ('error' in body) { logger.info('Failed OIDC login attempt', { cause: 'Invalid token response', ip: req.ip, @@ -758,27 +756,18 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { } // Extract the ID token and access token - const { id_token: idToken, access_token: accessToken } = body as Extract< - typeof body, - { id_token: string; access_token: string } - >; + const { id_token: idToken, access_token: accessToken } = body; - // Attempt to decode ID token jwt, catch and return any errors - const tryDecodeJwt = (): [IdTokenClaims | null, unknown] => { - try { - const decoded: IdTokenClaims = decodeJwt(idToken); - return [decoded, null]; - } catch (error) { - return [null, error]; - } - }; - const [decoded, err] = tryDecodeJwt(); - - if (err != null) { + // Attempt to decode ID token jwt + let decoded: IdTokenClaims; + try { + decoded = decodeJwt(idToken); + } catch (err) { logger.info('Failed OIDC login attempt', { cause: 'Invalid jwt', ip: req.ip, idToken: idToken, + err, }); return next({ status: 400, diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 306288253..7165314fc 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -46,12 +46,16 @@ export async function getOIDCRedirectUrl(req: Request, state: string) { return url.toString(); } +type OIDCTokenResponse = + | { id_token: string; access_token: string } + | { error: string }; + /** Exchange authorization code for token data */ export async function fetchOIDCTokenData( req: Request, wellKnownInfo: WellKnownConfiguration, code: string -) { +): Promise { const settings = getSettings(); const { oidc } = settings.main; @@ -68,7 +72,7 @@ export async function fetchOIDCTokenData( formData.append('code', code); return await axios - .post(wellKnownInfo.token_endpoint, formData) + .post(wellKnownInfo.token_endpoint, formData) .then((r) => r.data); } From d89ce7045ecd27493bda51a8fd7e316337c7d817 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 15:25:33 -0500 Subject: [PATCH 46/58] fix(OidcModal): properly rotate accordion dropdown --- src/components/Settings/OidcModal/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Settings/OidcModal/index.tsx b/src/components/Settings/OidcModal/index.tsx index 12b11b0e7..f93e95a0e 100644 --- a/src/components/Settings/OidcModal/index.tsx +++ b/src/components/Settings/OidcModal/index.tsx @@ -208,8 +208,8 @@ const OidcModal = ({ Advanced From 863d537516e66d5f49f26c90e87bd8f1e4c783b7 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 17:03:10 -0500 Subject: [PATCH 47/58] feat(oidc): add automatic login option --- overseerr-api.yml | 15 ++++ server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/settings.ts | 4 + server/routes/auth.ts | 17 ++++ server/utils/oidc.ts | 77 ++++++++++++++----- src/components/Layout/UserDropdown/index.tsx | 11 +++ src/components/Login/OidcLogin.tsx | 8 ++ src/components/Settings/OidcModal/index.tsx | 29 +++++++ .../Settings/SettingsUsers/index.tsx | 19 ++++- 9 files changed, 162 insertions(+), 19 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 5ce74cc82..286d352c4 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3762,6 +3762,21 @@ paths: schema: type: string example: 'oidc-state=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' + /auth/oidc-logout: + get: + security: [] + summary: Redirect to the OpenID Connect provider logout page + description: Determines the logout redirect URL for to the OpenID Connect provider, and redirects the user to it. + tags: + - auth + responses: + '302': + description: Redirect to the logout url for the OpenID Connect provider + headers: + Location: + schema: + type: string + example: https://example.com/auth/oidc/invalidate_session /user: get: summary: Get all users diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 8d8b90a02..485e2a481 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -32,6 +32,7 @@ export interface PublicSettingsResponse { mediaServerLogin: boolean; oidcLogin: boolean; oidcProviderName: string; + oidcAutomaticLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; region: string; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index c033afa33..b5391c8e1 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -118,6 +118,7 @@ export interface MainSettings { requiredClaims: string; scopes: string; matchJellyfinUsername: boolean; + automaticLogin: boolean; }; region: string; originalLanguage: string; @@ -139,6 +140,7 @@ interface FullPublicSettings extends PublicSettings { mediaServerLogin: boolean; oidcLogin: boolean; oidcProviderName: string; + oidcAutomaticLogin: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; region: string; @@ -339,6 +341,7 @@ class Settings { requiredClaims: 'email_verified', scopes: 'email openid profile', matchJellyfinUsername: false, + automaticLogin: false, }, region: '', originalLanguage: '', @@ -564,6 +567,7 @@ class Settings { mediaServerLogin: this.data.main.mediaServerLogin, oidcLogin: this.data.main.oidcLogin, oidcProviderName: this.data.main.oidc.providerName, + oidcAutomaticLogin: this.data.main.oidc.automaticLogin, movie4kEnabled: this.data.radarr.some( (radarr) => radarr.is4k && radarr.isDefault ), diff --git a/server/routes/auth.ts b/server/routes/auth.ts index aaf52fb02..b8326985e 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -891,4 +891,21 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { } }); +authRoutes.get('/oidc-logout', async (req, res, next) => { + const settings = getSettings(); + + if (!settings.main.oidcLogin || !settings.main.oidc.automaticLogin) { + return next({ + status: 403, + message: 'OpenID Connect sign-in is disabled.', + }); + } + + const oidcEndpoints = await getOIDCWellknownConfiguration( + settings.main.oidc.providerUrl + ); + + return res.redirect(oidcEndpoints.end_session_endpoint); +}); + export default authRoutes; diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 7165314fc..55cb257bb 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -225,22 +225,63 @@ export type FullUserInfo = IdTokenClaims & Mandatory; 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; + issuer: string; // REQUIRED + + authorization_endpoint: string; // REQUIRED + + token_endpoint: string; // REQUIRED + + token_endpoint_auth_methods_supported?: string[]; // OPTIONAL + + token_endpoint_auth_signing_alg_values_supported?: string[]; // OPTIONAL + + userinfo_endpoint: string; // RECOMMENDED + + check_session_iframe: string; // REQUIRED + + end_session_endpoint: string; // REQUIRED + + jwks_uri: string; // REQUIRED + + registration_endpoint: string; // RECOMMENDED + + scopes_supported: string[]; // RECOMMENDED + + response_types_supported: string[]; // REQUIRED + + acr_values_supported?: string[]; // OPTIONAL + + subject_types_supported: string[]; // REQUIRED + + request_object_signing_alg_values_supported?: string[]; // OPTIONAL + + display_values_supported?: string[]; // OPTIONAL + + claim_types_supported?: string[]; // OPTIONAL + + claims_supported: string[]; // RECOMMENDED + + claims_parameter_supported?: boolean; // OPTIONAL + + service_documentation?: string; // OPTIONAL + + ui_locales_supported?: string[]; // OPTIONAL + + revocation_endpoint: string; // REQUIRED + + introspection_endpoint: string; // REQUIRED + + frontchannel_logout_supported?: boolean; // OPTIONAL + + frontchannel_logout_session_supported?: boolean; // OPTIONAL + + backchannel_logout_supported?: boolean; // OPTIONAL + + backchannel_logout_session_supported?: boolean; // OPTIONAL + + grant_types_supported?: string[]; // OPTIONAL + + response_modes_supported?: string[]; // OPTIONAL + + code_challenge_methods_supported?: string[]; // OPTIONAL } diff --git a/src/components/Layout/UserDropdown/index.tsx b/src/components/Layout/UserDropdown/index.tsx index 6d3fe7b98..e92371099 100644 --- a/src/components/Layout/UserDropdown/index.tsx +++ b/src/components/Layout/UserDropdown/index.tsx @@ -1,4 +1,5 @@ import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay'; +import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; import { Menu, Transition } from '@headlessui/react'; import { @@ -36,11 +37,21 @@ ForwardedLink.displayName = 'ForwardedLink'; const UserDropdown = () => { const intl = useIntl(); + const settings = useSettings(); const { user, revalidate } = useUser(); const logout = async () => { const response = await axios.post('/api/v1/auth/logout'); + // redirect to OIDC provider logout page if automatic login is enabled + if ( + settings.currentSettings.oidcLogin && + settings.currentSettings.oidcAutomaticLogin + ) { + window.location.href = '/api/v1/auth/oidc-logout'; + return; + } + if (response.data?.status === 'ok') { revalidate(); } diff --git a/src/components/Login/OidcLogin.tsx b/src/components/Login/OidcLogin.tsx index 99a3fef3f..a81e43232 100644 --- a/src/components/Login/OidcLogin.tsx +++ b/src/components/Login/OidcLogin.tsx @@ -4,6 +4,7 @@ import globalMessages from '@app/i18n/globalMessages'; import OIDCAuth from '@app/utils/oidc'; import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; import type React from 'react'; +import { useEffect } from 'react'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ @@ -32,6 +33,13 @@ const OidcLogin: React.FC = ({ const intl = useIntl(); const settings = useSettings(); + useEffect(() => { + const { oidcLogin, oidcAutomaticLogin } = settings.currentSettings; + if (oidcLogin && oidcAutomaticLogin) + // redirect to login page + window.location.href = '/api/v1/auth/oidc-login'; + }, [settings.currentSettings]); + const handleClick = async () => { setProcessing(true); try { diff --git a/src/components/Settings/OidcModal/index.tsx b/src/components/Settings/OidcModal/index.tsx index f93e95a0e..808194a52 100644 --- a/src/components/Settings/OidcModal/index.tsx +++ b/src/components/Settings/OidcModal/index.tsx @@ -39,6 +39,10 @@ const messages = defineMessages({ oidcMatchUsername: 'Allow {mediaServerName} Usernames', oidcMatchUsernameTip: 'Match OIDC users with their {mediaServerName} accounts by username', + oidcAutomaticLogin: 'Automatic Login', + oidcAutomaticLoginTip: + 'Automatically navigate to the OIDC login and logout pages. This functionality ' + + 'only supported when OIDC is the exclusive login method.', }); type OidcSettings = MainSettings['oidc']; @@ -83,6 +87,7 @@ export const oidcSettingsSchema = (intl: IntlShape) => { .required(requiredMessage(messages.oidcIdentificationClaims)), requiredClaims: yup.string(), matchJellyfinUsername: yup.boolean(), + automaticLogin: yup.boolean(), }); }; @@ -310,6 +315,30 @@ const OidcModal = ({ />
+
+ +
+ { + setFieldValue( + 'oidc.automaticLogin', + !values.automaticLogin + ); + }} + /> +
+
diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 66fe4c9d1..bb96ff28c 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -73,6 +73,22 @@ const createValidationSchema = (intl: IntlShape) => { message: 'At least one authentication method must be selected.', }); }, + }) + .test({ + name: 'automaticLoginExclusive', + test: function (values) { + const isValid = + !values.oidcLogin || + !values.oidc.automaticLogin || + !['localLogin', 'mediaServerLogin'].some((field) => !!values[field]); + + if (isValid) return true; + return this.createError({ + path: 'localLogin | mediaServerLogin | oidcLogin', + message: + 'Only OIDC login may be enabled when automatic login is enabled.', + }); + }, }); }; @@ -197,7 +213,8 @@ const SettingsUsers = () => { id="localLogin" label={intl.formatMessage(messages.localLogin)} description={intl.formatMessage( - messages.localLoginTip + messages.localLoginTip, + { mediaServerName } )} onChange={() => setFieldValue('localLogin', !values.localLogin) From b30803a4eb0571762bd6046f7cd50065fa8c8b50 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 17:41:19 -0500 Subject: [PATCH 48/58] chore: update API documentation --- overseerr-api.yml | 52 ++++++++++++++++++++++++++++++----------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 2ff30eb35..841fafeac 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -132,6 +132,36 @@ components: type: string originalLanguage: type: string + OidcSettings: + type: object + properties: + providerName: + type: string + example: Keycloak + providerUrl: + type: string + example: https://auth.example.com + clientId: + type: string + example: your-client-id + clientSecret: + type: string + example: your-client-secret + userIdentifier: + type: string + example: email + requiredClaims: + type: string + example: email_verified + scopes: + type: string + example: id email + matchJellyfinUsername: + type: boolean + example: false + automaticLogin: + type: boolean + example: false MainSettings: type: object properties: @@ -168,21 +198,8 @@ components: oidcLogin: type: boolean example: true - oidcName: - type: string - example: Keycloak - oidcClientId: - type: string - example: your-client-id - oidcClientSecret: - type: string - example: your-client-secret - oidcDomain: - type: string - example: https://auth.example.com - oidcMatchUsername: - type: boolean - example: true + oidc: + $ref: '#/components/schemas/OidcSettings' mediaServerType: type: number example: 1 @@ -389,9 +406,6 @@ components: externalHostname: type: string example: 'http://my.jellyfin.host' - jellyfinForgotPasswordUrl: - type: string - example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html' adminUser: type: string example: 'admin' @@ -3780,7 +3794,7 @@ paths: schema: type: string example: https://example.com/auth/oidc/invalidate_session - /user: + /ucser: get: summary: Get all users description: Returns all users in a JSON object. From 970685343b942f6aedfa2442a1e5eb7a78224164 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 17:43:35 -0500 Subject: [PATCH 49/58] chore(api-spec): remove extraneous changes --- overseerr-api.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 841fafeac..751bddf1b 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -406,6 +406,9 @@ components: externalHostname: type: string example: 'http://my.jellyfin.host' + jellyfinForgotPasswordUrl: + type: string + example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html' adminUser: type: string example: 'admin' @@ -3794,7 +3797,7 @@ paths: schema: type: string example: https://example.com/auth/oidc/invalidate_session - /ucser: + /user: get: summary: Get all users description: Returns all users in a JSON object. From 6b7dc5c2f517e435517249be5356fbb65f145e3c Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 18:17:03 -0500 Subject: [PATCH 50/58] style: move oidc types into interfaces file --- server/interfaces/api/oidcInterfaces.ts | 110 ++++++++++++++++++++++++ server/utils/oidc.ts | 78 ++--------------- 2 files changed, 117 insertions(+), 71 deletions(-) diff --git a/server/interfaces/api/oidcInterfaces.ts b/server/interfaces/api/oidcInterfaces.ts index d6ac8deab..aa635fd37 100644 --- a/server/interfaces/api/oidcInterfaces.ts +++ b/server/interfaces/api/oidcInterfaces.ts @@ -3,6 +3,74 @@ */ export type Mandatory = Required> & Omit; +/** + * Standard OpenID Connect discovery document. + * + * @public + * @see https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata + */ +export interface OidcProviderMetadata { + issuer: string; // REQUIRED + + authorization_endpoint: string; // REQUIRED + + token_endpoint: string; // REQUIRED + + token_endpoint_auth_methods_supported?: string[]; // OPTIONAL + + token_endpoint_auth_signing_alg_values_supported?: string[]; // OPTIONAL + + userinfo_endpoint: string; // RECOMMENDED + + check_session_iframe: string; // REQUIRED + + end_session_endpoint: string; // REQUIRED + + jwks_uri: string; // REQUIRED + + registration_endpoint: string; // RECOMMENDED + + scopes_supported: string[]; // RECOMMENDED + + response_types_supported: string[]; // REQUIRED + + acr_values_supported?: string[]; // OPTIONAL + + subject_types_supported: string[]; // REQUIRED + + request_object_signing_alg_values_supported?: string[]; // OPTIONAL + + display_values_supported?: string[]; // OPTIONAL + + claim_types_supported?: string[]; // OPTIONAL + + claims_supported: string[]; // RECOMMENDED + + claims_parameter_supported?: boolean; // OPTIONAL + + service_documentation?: string; // OPTIONAL + + ui_locales_supported?: string[]; // OPTIONAL + + revocation_endpoint: string; // REQUIRED + + introspection_endpoint: string; // REQUIRED + + frontchannel_logout_supported?: boolean; // OPTIONAL + + frontchannel_logout_session_supported?: boolean; // OPTIONAL + + backchannel_logout_supported?: boolean; // OPTIONAL + + backchannel_logout_session_supported?: boolean; // OPTIONAL + + grant_types_supported?: string[]; // OPTIONAL + + response_modes_supported?: string[]; // OPTIONAL + + code_challenge_methods_supported?: string[]; // OPTIONAL +} + /** * Standard OpenID Connect address claim. * The Address Claim represents a physical mailing address. @@ -127,3 +195,45 @@ export interface IdTokenClaims * */ sid?: string; } + +type OidcTokenSuccessResponse = { + /** + * REQUIRED. ID Token value associated with the authenticated session. + * + * @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken + */ + id_token: string; + /** + * REQUIRED. The access token issued by the authorization server. + */ + access_token: string; + /** + * REQUIRED. The type of the token issued as described in + * Section 7.1. Value is case insensitive. + * + * @see https://datatracker.ietf.org/doc/html/rfc6749#section-7.1 + */ + token_type: string; + /** + * RECOMMENDED. The lifetime in seconds of the access token. For + * example, the value "3600" denotes that the access token will + * expire in one hour from the time the response was generated. + * If omitted, the authorization server SHOULD provide the + * expiration time via other means or document the default value. + */ + expires_in?: number; +}; + +type OidcTokenErrorResponse = { + error: string; +}; + +/** + * Standard response from the OpenID Connect token request endpoint. + * + * @public + * @see https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + */ +export type OidcTokenResponse = + | OidcTokenSuccessResponse + | OidcTokenErrorResponse; diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 55cb257bb..ec23c7755 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -1,7 +1,9 @@ import type { IdTokenClaims, Mandatory, + OidcProviderMetadata, OidcStandardClaims, + OidcTokenResponse, } from '@server/interfaces/api/oidcInterfaces'; import { getSettings } from '@server/lib/settings'; import axios from 'axios'; @@ -15,7 +17,7 @@ export async function getOIDCWellknownConfiguration(domain: string) { domain.replace(/\/$/, '') + '/.well-known/openid-configuration' ).toString(); - const wellKnownInfo: WellKnownConfiguration = await axios + const wellKnownInfo: OidcProviderMetadata = await axios .get(wellKnownUrl, { headers: { 'Content-Type': 'application/json', @@ -46,16 +48,12 @@ export async function getOIDCRedirectUrl(req: Request, state: string) { return url.toString(); } -type OIDCTokenResponse = - | { id_token: string; access_token: string } - | { error: string }; - /** Exchange authorization code for token data */ export async function fetchOIDCTokenData( req: Request, - wellKnownInfo: WellKnownConfiguration, + wellKnownInfo: OidcProviderMetadata, code: string -): Promise { +): Promise { const settings = getSettings(); const { oidc } = settings.main; @@ -72,12 +70,12 @@ export async function fetchOIDCTokenData( formData.append('code', code); return await axios - .post(wellKnownInfo.token_endpoint, formData) + .post(wellKnownInfo.token_endpoint, formData) .then((r) => r.data); } export async function getOIDCUserInfo( - wellKnownInfo: WellKnownConfiguration, + wellKnownInfo: OidcProviderMetadata, authToken: string ) { const userInfo = await axios @@ -223,65 +221,3 @@ export const createIdTokenSchema = ({ export type FullUserInfo = IdTokenClaims & Mandatory; - -export interface WellKnownConfiguration { - issuer: string; // REQUIRED - - authorization_endpoint: string; // REQUIRED - - token_endpoint: string; // REQUIRED - - token_endpoint_auth_methods_supported?: string[]; // OPTIONAL - - token_endpoint_auth_signing_alg_values_supported?: string[]; // OPTIONAL - - userinfo_endpoint: string; // RECOMMENDED - - check_session_iframe: string; // REQUIRED - - end_session_endpoint: string; // REQUIRED - - jwks_uri: string; // REQUIRED - - registration_endpoint: string; // RECOMMENDED - - scopes_supported: string[]; // RECOMMENDED - - response_types_supported: string[]; // REQUIRED - - acr_values_supported?: string[]; // OPTIONAL - - subject_types_supported: string[]; // REQUIRED - - request_object_signing_alg_values_supported?: string[]; // OPTIONAL - - display_values_supported?: string[]; // OPTIONAL - - claim_types_supported?: string[]; // OPTIONAL - - claims_supported: string[]; // RECOMMENDED - - claims_parameter_supported?: boolean; // OPTIONAL - - service_documentation?: string; // OPTIONAL - - ui_locales_supported?: string[]; // OPTIONAL - - revocation_endpoint: string; // REQUIRED - - introspection_endpoint: string; // REQUIRED - - frontchannel_logout_supported?: boolean; // OPTIONAL - - frontchannel_logout_session_supported?: boolean; // OPTIONAL - - backchannel_logout_supported?: boolean; // OPTIONAL - - backchannel_logout_session_supported?: boolean; // OPTIONAL - - grant_types_supported?: string[]; // OPTIONAL - - response_modes_supported?: string[]; // OPTIONAL - - code_challenge_methods_supported?: string[]; // OPTIONAL -} From 6ade3e40c56b236bab800af43441bbd5ce6e9ef6 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 18:18:24 -0500 Subject: [PATCH 51/58] fix(client): add automatic login to default settings --- src/context/SettingsContext.tsx | 1 + src/pages/_app.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 54c24a999..d5fde1b90 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -17,6 +17,7 @@ const defaultSettings: PublicSettingsResponse = { mediaServerLogin: true, oidcLogin: false, oidcProviderName: 'OpenID Connect', + oidcAutomaticLogin: false, movie4kEnabled: false, series4kEnabled: false, region: '', diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 2053e2544..7cbb6bb55 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -183,6 +183,7 @@ CoreApp.getInitialProps = async (initialProps) => { mediaServerLogin: true, oidcLogin: false, oidcProviderName: 'OpenID Connect', + oidcAutomaticLogin: false, region: '', originalLanguage: '', mediaServerType: MediaServerType.NOT_CONFIGURED, From d3e477d159e7b5c740b590607f6c3cad11da5b4a Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 20:47:43 -0500 Subject: [PATCH 52/58] fix: properly validate custom user identifiers and required claims --- server/routes/auth.ts | 10 +++++++--- server/utils/oidc.ts | 27 ++++++++++++++++++--------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b8326985e..7fd0cce18 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -702,6 +702,9 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { }); } + const identifierClaims = oidc.userIdentifier.split(' ').filter((s) => !!s); + const requiredClaims = oidc.requiredClaims.split(' ').filter((s) => !!s); + const cookieState = req.cookies['oidc-state']; const url = new URL(req.url, `${req.protocol}://${req.hostname}`); const state = url.searchParams.get('state'); @@ -784,6 +787,8 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { const idTokenSchema = createIdTokenSchema({ oidcClientId: oidc.clientId, oidcDomain: oidc.providerUrl, + identifierClaims, + requiredClaims, }); await idTokenSchema.validate(fullUserInfo); } catch (err) { @@ -802,8 +807,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { // Validate user identifier let identifiers: string[]; try { - const identificationKeys = oidc.userIdentifier.split(' '); - identifiers = tryGetUserIdentifiers(fullUserInfo, identificationKeys); + identifiers = tryGetUserIdentifiers(fullUserInfo, identifierClaims); } catch { logger.info('Failed OIDC login attempt', { cause: 'User identifier not found in userinfo payload.', @@ -818,7 +822,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { // Check that email is verified try { - validateUserClaims(fullUserInfo); + validateUserClaims(fullUserInfo, requiredClaims); } catch (error) { logger.info('Failed OIDC login attempt', { cause: 'Failed to validate required claims', diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index ec23c7755..31ab3535d 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -133,11 +133,10 @@ export function tryGetUserInfoKey( return userInfo[key] as TypeFromName; } -export function validateUserClaims(userInfo: FullUserInfo) { - const settings = getSettings(); - const { requiredClaims: requiredClaimsString } = settings.main.oidc; - const requiredClaims = requiredClaimsString.split(' '); - +export function validateUserClaims( + userInfo: FullUserInfo, + requiredClaims: string[] +) { requiredClaims.some((claim) => { const value = tryGetUserInfoKey(userInfo, claim, 'boolean'); if (!value) @@ -149,9 +148,13 @@ export function validateUserClaims(userInfo: FullUserInfo) { export const createIdTokenSchema = ({ oidcDomain, oidcClientId, + identifierClaims, + requiredClaims, }: { oidcDomain: string; oidcClientId: string; + identifierClaims: string[]; + requiredClaims: string[]; }) => { return yup.object().shape({ iss: yup @@ -212,10 +215,16 @@ export const createIdTokenSchema = ({ return true; } ), - // these should exist because we set the scope to `openid profile email` - name: yup.string().required(), - email: yup.string().email().required(), - email_verified: yup.boolean().required(), + // ensure all identifier claims are present and are strings + ...identifierClaims.reduce( + (a, v) => ({ ...a, [v]: yup.string().required() }), + {} + ), + // ensure all required claims are present and are booleans + ...requiredClaims.reduce( + (a, v) => ({ ...a, [v]: yup.boolean().required() }), + {} + ), }); }; From 6b9c7747cd5742bac717e00c7c8c0844668a8a9e Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sat, 9 Dec 2023 20:48:23 -0500 Subject: [PATCH 53/58] fix(callback): show correct error message when OIDC errors occur ensures that the `useEffect` hook does not run more than once --- src/pages/login/oidc/callback.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/login/oidc/callback.tsx b/src/pages/login/oidc/callback.tsx index b298310b1..2da5b70b9 100644 --- a/src/pages/login/oidc/callback.tsx +++ b/src/pages/login/oidc/callback.tsx @@ -35,7 +35,8 @@ const OidcCallback = () => { useEffect(() => { login(); - }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return (
From ddde1d90985b620bc3da996e4771256810bb9b1c Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 10 Dec 2023 10:16:10 -0500 Subject: [PATCH 54/58] fix: don't assume the email claim is present for user registration --- server/interfaces/api/oidcInterfaces.ts | 2 +- server/routes/auth.ts | 11 ++++++++++- server/utils/oidc.ts | 4 +--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/server/interfaces/api/oidcInterfaces.ts b/server/interfaces/api/oidcInterfaces.ts index aa635fd37..68d444b56 100644 --- a/server/interfaces/api/oidcInterfaces.ts +++ b/server/interfaces/api/oidcInterfaces.ts @@ -1,7 +1,7 @@ /** * @internal */ -export type Mandatory = Required> & Omit; +type Mandatory = Required> & Omit; /** * Standard OpenID Connect discovery document. diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 7fd0cce18..244e2eb28 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -856,7 +856,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { } // Create user if one doesn't already exist - if (!user) { + if (!user && fullUserInfo.email != null) { logger.info(`Creating user for ${fullUserInfo.email}`, { ip: req.ip, email: fullUserInfo.email, @@ -873,6 +873,15 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { userType: UserType.LOCAL, }); await userRepository.save(user); + } else if (!user) { + logger.debug('Failed OIDC sign-up attempt', { + cause: + 'User did not have an account, and was missing an associated email address.', + }); + return next({ + status: 400, + message: 'Unable to create new user account. Missing email address.', + }); } // Set logged in session and return diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 31ab3535d..a3940e0dd 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -1,6 +1,5 @@ import type { IdTokenClaims, - Mandatory, OidcProviderMetadata, OidcStandardClaims, OidcTokenResponse, @@ -228,5 +227,4 @@ export const createIdTokenSchema = ({ }); }; -export type FullUserInfo = IdTokenClaims & - Mandatory; +export type FullUserInfo = IdTokenClaims & OidcStandardClaims; From a911f958d8590eb41eccb2637715eb10a8020712 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Sun, 10 Dec 2023 10:20:24 -0500 Subject: [PATCH 55/58] fix(OidcModal): tweak advanced settings collapse ui adds a background to make it more obvious when the advanced settings section is open --- src/components/Settings/OidcModal/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/Settings/OidcModal/index.tsx b/src/components/Settings/OidcModal/index.tsx index 808194a52..ce271d8b8 100644 --- a/src/components/Settings/OidcModal/index.tsx +++ b/src/components/Settings/OidcModal/index.tsx @@ -207,7 +207,9 @@ const OidcModal = ({ {({ openIndexes, handleClick, AccordionContent }) => ( <>