pull/3746/merge
lenaxia 2 weeks ago committed by GitHub
commit fc87988538
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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-';

@ -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

@ -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",

@ -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 }));

@ -37,6 +37,8 @@ export interface PublicSettingsResponse {
locale: string;
emailEnabled: boolean;
newPlexLogin: boolean;
oidcLogin: boolean;
oidcName: string;
}
export interface CacheItem {

@ -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,
};
}

@ -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(),

@ -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<OIDCJwtPayload>(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<ReturnType<typeof createJwtSchema>> =
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;

@ -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;
}

@ -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 (
<span className="block w-full rounded-md shadow-sm">
<button
className="plex-button bg-indigo-500 hover:bg-indigo-600"
onClick={handleClick}
>
<ArrowLeftOnRectangleIcon />
<span>
{loading
? intl.formatMessage(globalMessages.loading)
: intl.formatMessage(messages.signinwithoidc, {
provider: oidcName,
})}
</span>
</button>
</span>
);
}
export default OIDCLoginButton;

@ -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 = () => {
<Accordion single atLeastOne>
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 focus:outline-none sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${
settings.currentSettings.localLogin &&
'hover:cursor-pointer hover:bg-gray-700'
}`}
onClick={() => handleClick(0)}
disabled={!settings.currentSettings.localLogin}
>
{intl.formatMessage(messages.signinwithplex)}
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8">
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
{settings.currentSettings.newPlexLogin && (
<div>
<button
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
}`}
onClick={() => handleClick(0)}
>
{intl.formatMessage(messages.signinwithplex)}
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8">
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
</div>
</AccordionContent>
</div>
</AccordionContent>
)}
{settings.currentSettings.localLogin && (
<div>
<button
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg'
openIndexes.includes(1) ? 'text-indigo-500' : ''
}`}
onClick={() => handleClick(1)}
>
@ -160,6 +160,30 @@ const Login = () => {
</AccordionContent>
</div>
)}
{settings.currentSettings.oidcLogin && (
<div>
<button
className={`w-full cursor-default bg-gray-800 bg-opacity-70 py-2 text-center text-sm font-bold text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
openIndexes.includes(2)
? 'text-indigo-500'
: 'sm:rounded-b-lg'
}`}
onClick={() => handleClick(2)}
>
{intl.formatMessage(messages.signinwithoidcaccount, {
oidcName: settings.currentSettings.oidcName,
})}
</button>
<AccordionContent isOpen={openIndexes.includes(2)}>
<div className="px-10 py-8">
<OIDCLoginButton
revalidate={revalidate}
oidcName={settings.currentSettings.oidcName}
/>
</div>
</AccordionContent>
</div>
)}
</>
)}
</Accordion>

@ -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 (
<Form className="section">
<div className="form-row">
@ -140,6 +188,100 @@ const SettingsUsers = () => {
/>
</div>
</div>
<div className="form-row">
<label htmlFor="oidcLogin" className="checkbox-label">
{intl.formatMessage(messages.oidcLogin)}
<span className="label-tip">
{intl.formatMessage(messages.oidcLoginTip)}
</span>
</label>
<div className="form-input-area">
<Field
type="checkbox"
id="oidcLogin"
name="oidcLogin"
onChange={() => {
setFieldValue('oidcLogin', !values.oidcLogin);
}}
/>
</div>
</div>
{values.oidcLogin ? (
<>
<div className="form-row">
<label htmlFor="oidcName" className="group-label">
{intl.formatMessage(messages.oidcName)}
<span className="label-tip">
{intl.formatMessage(messages.oidcNameTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="oidcName" name="oidcName" type="text" />
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="oidcDomain" className="text-label">
{intl.formatMessage(messages.oidcDomain)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="oidcDomain"
name="oidcDomain"
type="text"
/>
</div>
{errors.oidcDomain && touched.oidcDomain && (
<div className="error">{errors.oidcDomain}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="oidcClientId" className="text-label">
{intl.formatMessage(messages.oidcClientId)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
id="oidcClientId"
name="oidcClientId"
type="text"
/>
</div>
{errors.oidcClientId && touched.oidcDomain && (
<div className="error">{errors.oidcClientId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="oidcClientSecret" className="text-label">
{intl.formatMessage(messages.oidcClientSecret)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<SensitiveInput
type="password"
id="oidcClientSecret"
className="rounded-l-only"
defaultValue={data?.oidcClientSecret}
onChange={(e) => {
setFieldValue('oidcClientSecret', e.target.value);
}}
autoComplete="off"
/>
</div>
{errors.oidcClientSecret &&
touched.oidcClientSecret && (
<div className="error">
{errors.oidcClientSecret}
</div>
)}
</div>
</div>
</>
) : null}
<div className="form-row">
<label htmlFor="applicationTitle" className="text-label">
{intl.formatMessage(messages.movieRequestLimitLabel)}

@ -24,6 +24,8 @@ const defaultSettings = {
locale: 'en',
emailEnabled: false,
newPlexLogin: true,
oidcLogin: false,
oidcName: '',
};
export const SettingsContext = React.createContext<SettingsContextProps>({

@ -223,6 +223,8 @@
"components.Login.signin": "Sign In",
"components.Login.signingin": "Signing In…",
"components.Login.signinheader": "Sign in to continue",
"components.Login.signinwithoidc": "Sign In With {provider}",
"components.Login.signinwithoidcaccount": "Use your {oidcName} account",
"components.Login.signinwithoverseerr": "Use your {applicationTitle} account",
"components.Login.signinwithplex": "Use your Plex account",
"components.Login.validationemailrequired": "You must provide a valid email address",
@ -826,6 +828,13 @@
"components.Settings.SettingsUsers.movieRequestLimitLabel": "Global Movie Request Limit",
"components.Settings.SettingsUsers.newPlexLogin": "Enable New Plex Sign-In",
"components.Settings.SettingsUsers.newPlexLoginTip": "Allow Plex users to sign in without first being imported",
"components.Settings.SettingsUsers.oidcClientId": "OIDC Client ID",
"components.Settings.SettingsUsers.oidcClientSecret": "OIDC Client Secret",
"components.Settings.SettingsUsers.oidcDomain": "OIDC Domain",
"components.Settings.SettingsUsers.oidcLogin": "Enable OIDC Sign-In",
"components.Settings.SettingsUsers.oidcLoginTip": "Allow users to sign in using an OIDC provider",
"components.Settings.SettingsUsers.oidcName": "OIDC Provider Name",
"components.Settings.SettingsUsers.oidcNameTip": "The string used as name on the login page",
"components.Settings.SettingsUsers.toastSettingsFailure": "Something went wrong while saving settings.",
"components.Settings.SettingsUsers.toastSettingsSuccess": "User settings saved successfully!",
"components.Settings.SettingsUsers.tvRequestLimitLabel": "Global Series Request Limit",

@ -198,6 +198,8 @@ CoreApp.getInitialProps = async (initialProps) => {
locale: 'en',
emailEnabled: false,
newPlexLogin: true,
oidcLogin: false,
oidcName: '',
};
if (ctx.res) {

@ -0,0 +1,45 @@
import PopupWindow from './popupWindow';
class OIDCAuth extends PopupWindow {
public async preparePopup(): Promise<void> {
this.openPopup({
title: 'OIDC Auth',
path: '/api/v1/auth/oidc-login',
w: 600,
h: 700,
});
return this.pollLoginState();
}
private async pollLoginState(): Promise<void> {
const executePoll = async (
resolve: () => void,
reject: (e: Error) => void
) => {
try {
if (!this.popup) {
throw new Error('Unable to poll when popup is not initialized.');
}
const response = await fetch('/api/v1/auth/me');
if (response.ok) {
this.closePopup();
resolve();
} else if (!response.ok && !this.popup?.closed) {
setTimeout(executePoll, 1000, resolve, reject);
} else {
reject(new Error('Popup closed without completing login'));
}
} catch (e) {
this.closePopup();
reject(e);
}
};
return new Promise(executePoll);
}
}
export default OIDCAuth;

@ -1,5 +1,6 @@
import axios from 'axios';
import Bowser from 'bowser';
import PopupWindow from './popupWindow';
interface PlexHeaders extends Record<string, string> {
Accept: string;
@ -33,12 +34,9 @@ const uuidv4 = (): string => {
);
};
class PlexOAuth {
class PlexOAuth extends PopupWindow {
private plexHeaders?: PlexHeaders;
private pin?: PlexPin;
private popup?: Window;
private authToken?: string;
public initializeHeaders(): void {
@ -85,11 +83,10 @@ class PlexOAuth {
);
this.pin = { id: response.data.id, code: response.data.code };
return this.pin;
}
public preparePopup(): void {
public async preparePopup(): Promise<void> {
this.openPopup({ title: 'Plex Auth', w: 600, h: 700 });
}
@ -159,63 +156,6 @@ class PlexOAuth {
return new Promise(executePoll);
}
private closePopup(): void {
this.popup?.close();
this.popup = undefined;
}
private openPopup({
title,
w,
h,
}: {
title: string;
w: number;
h: number;
}): Window | void {
if (!window) {
throw new Error(
'Window is undefined. Are you running this in the browser?'
);
}
// Fixes dual-screen position Most browsers Firefox
const dualScreenLeft =
window.screenLeft != undefined ? window.screenLeft : window.screenX;
const dualScreenTop =
window.screenTop != undefined ? window.screenTop : window.screenY;
const width = window.innerWidth
? window.innerWidth
: document.documentElement.clientWidth
? document.documentElement.clientWidth
: screen.width;
const height = window.innerHeight
? window.innerHeight
: document.documentElement.clientHeight
? document.documentElement.clientHeight
: screen.height;
const left = width / 2 - w / 2 + dualScreenLeft;
const top = height / 2 - h / 2 + dualScreenTop;
//Set url to login/plex/loading so browser doesn't block popup
const newWindow = window.open(
'/login/plex/loading',
title,
'scrollbars=yes, width=' +
w +
', height=' +
h +
', top=' +
top +
', left=' +
left
);
if (newWindow) {
newWindow.focus();
this.popup = newWindow;
return this.popup;
}
}
private encodeData(data: Record<string, string>): string {
return Object.keys(data)
.map(function (key) {

@ -0,0 +1,66 @@
abstract class PopupWindow {
protected popup?: Window;
public abstract preparePopup(): Promise<void>;
protected closePopup(): void {
this.popup?.close();
this.popup = undefined;
}
protected openPopup({
title,
path,
w,
h,
}: {
title: string;
path?: string;
w: number;
h: number;
}): Window | void {
if (!window) {
throw new Error(
'Window is undefined. Are you running this in the browser?'
);
}
// Fixes dual-screen position Most browsers Firefox
const dualScreenLeft =
window.screenLeft != undefined ? window.screenLeft : window.screenX;
const dualScreenTop =
window.screenTop != undefined ? window.screenTop : window.screenY;
const width = window.innerWidth
? window.innerWidth
: document.documentElement.clientWidth
? document.documentElement.clientWidth
: screen.width;
const height = window.innerHeight
? window.innerHeight
: document.documentElement.clientHeight
? document.documentElement.clientHeight
: screen.height;
const left = width / 2 - w / 2 + dualScreenLeft;
const top = height / 2 - h / 2 + dualScreenTop;
//Set url to login/popup/loading so browser doesn't block popup
const newWindow = window.open(
path || '/login/popup/loading',
title,
'scrollbars=yes, width=' +
w +
', height=' +
h +
', top=' +
top +
', left=' +
left
);
if (newWindow) {
newWindow.focus();
this.popup = newWindow;
return this.popup;
}
}
}
export default PopupWindow;

@ -8817,6 +8817,11 @@ jws@^4.0.0:
jwa "^2.0.0"
safe-buffer "^5.0.1"
jwt-decode@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59"
integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==
kind-of@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"

Loading…
Cancel
Save