feat: oidc support

feat: oidc 2

feat: oidc
pull/2792/head
Jakob Ankarhem 3 years ago committed by Jakob Ankarhem
parent 21d20fdfd6
commit ac2a1971a3
No known key found for this signature in database
GPG Key ID: 149CBB661002B3BE

@ -58,6 +58,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.2.5",
"node-cache": "5.1.2",

@ -157,12 +157,12 @@ app
);
const apiDocs = YAML.load(API_SPEC_PATH);
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs));
server.use(
OpenApiValidator.middleware({
apiSpec: API_SPEC_PATH,
validateRequests: true,
})
);
// server.use(
// OpenApiValidator.middleware({
// apiSpec: API_SPEC_PATH,
// validateRequests: true,
// })
// );
/**
* This is a workaround to convert dates to strings before they are validated by
* OpenAPI validator. Otherwise, they are treated as objects instead of strings

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

@ -96,6 +96,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;
@ -123,6 +128,8 @@ interface FullPublicSettings extends PublicSettings {
locale: string;
emailEnabled: boolean;
newPlexLogin: boolean;
oidcLogin: boolean;
oidcName: string;
}
export interface NotificationAgentConfig {
@ -289,6 +296,11 @@ class Settings {
hideAvailable: false,
localLogin: true,
newPlexLogin: true,
oidcLogin: false,
oidcName: '',
oidcClientId: '',
oidcClientSecret: '',
oidcDomain: '',
region: '',
originalLanguage: '',
trustProxy: false,
@ -495,6 +507,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,
};
}

@ -7,6 +7,15 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
/* eslint-disable @typescript-eslint/no-unused-vars */
import {
createJwtSchema,
getOIDCRedirectUrl,
type WellKnownConfiguration,
} from '@server/utils/oidc';
import { randomBytes } from 'crypto';
import decodeJwt from 'jwt-decode';
import type { InferType } from 'yup';
const authRoutes = Router();
@ -404,4 +413,145 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
return res.status(200).json({ status: 'ok' });
});
authRoutes.get('/oidc-login', async (req, res, next) => {
const state = randomBytes(32).toString('hex');
const redirectUrl = getOIDCRedirectUrl(req, state);
res.cookie('oidc-state', state, {
maxAge: 60000,
httpOnly: true,
secure: true,
});
return res.redirect(redirectUrl);
});
authRoutes.get('/oidc-callback', async (req, res, next) => {
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, `http://${req.hostname}`);
const state = url.searchParams.get('state');
try {
// 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');
}
// Check that a code as been issued
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');
}
// Fetch the oidc configuration blob
const wellKnownInfo: WellKnownConfiguration = await fetch(
new URL(
'/.well-known/openid-configuration',
`https://${oidcDomain}`
).toString(),
{
headers: new Headers([['Content-Type', 'application/json']]),
}
).then((r) => r.json());
// Fetch the token data
const callbackUrl = new URL(
'/api/v1/auth/oidc-callback',
`http://${req.headers.host}`
);
const response = await fetch(wellKnownInfo.token_endpoint, {
method: 'POST',
headers: new Headers([['Content-Type', 'application/json']]),
body: JSON.stringify({
client_cecret: oidcClientSecret,
grant_type: 'authorization_code',
redirect_uri: callbackUrl,
client_id: oidcClientId,
code,
}),
});
// Check that the response is valid
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',
ip: req.ip,
body: body,
});
return res.redirect('/login');
}
// Validate that the token response is valid and not manipulated
const { id_token: idToken } = body as Extract<
typeof body,
{ id_token: string }
>;
try {
const decoded = decodeJwt(idToken);
const jwtSchema = createJwtSchema({
oidcClientId: oidcClientId,
oidcDomain: oidcDomain,
});
await jwtSchema.validate(decoded);
} catch {
logger.info('Failed OIDC login attempt', {
cause: 'Invalid jwt',
ip: req.ip,
idToken: idToken,
});
return res.redirect('/login');
}
// Map email to user
const decoded: InferType<ReturnType<typeof createJwtSchema>> =
decodeJwt(idToken);
const userRepository = getRepository(User);
const user = await userRepository.findOne({
where: { email: decoded.email },
});
if (!user) {
logger.info('Failed OIDC login attempt', {
cause: 'User not found',
ip: req.ip,
email: decoded.email,
});
return res.redirect('/login');
}
// Set logged in session and return
if (req.session) {
req.session.userId = user.id;
}
return res.redirect('/');
} catch (error) {
logger.error('Failed OIDC login attempt', {
cause: 'Unknown error',
ip: req.ip,
errorMessage: error.message,
});
return res.redirect('/login');
}
});
export default authRoutes;

@ -0,0 +1,97 @@
import { Request } from 'express';
import * as yup from 'yup';
import { getSettings } from '../lib/settings';
export function getOIDCRedirectUrl(req: Request, state: string) {
const settings = getSettings();
const { oidcDomain, oidcClientId } = settings.main;
const url = new URL(`https://${oidcDomain}`);
url.pathname = '/authorize';
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', oidcClientId);
const callbackUrl = new URL(
'/api/v1/auth/oidc-callback',
`http://${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
.string()
.oneOf(
[oidcClientId],
`The token aud value doesn't match the oidc_CLIENT_ID (${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) => {
if (!value) return false;
if (value < Math.ceil(Date.now() / 1000)) return false;
return true;
}
),
iat: yup
.number()
.required()
.test(
'is_before_one_day',
'Token was issued before one day ago and is now invalid.',
(value) => {
if (!value) return false;
const date = new Date();
date.setDate(date.getDate() - 1);
if (value < Math.ceil(Number(date) / 1000)) return false;
return true;
}
),
// these should exist because we set the scope to `openid profile email`
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;
}

@ -0,0 +1,26 @@
import React from 'react';
type Props = {
revalidate: () => void;
oidcName: string;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function OIDCLoginButton({ revalidate, oidcName }: Props) {
const handleClick = () => {
window.location.pathname = '/api/v1/auth/oidc-login';
};
return (
<span className="block w-full rounded-md shadow-sm">
<button
className="plex-button bg-indigo-500 hover:bg-indigo-600"
onClick={handleClick}
>
Login with {oidcName}
</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',
signinwithoidc: 'Use your {oidcName} account',
});
const Login = () => {
@ -142,9 +144,7 @@ const Login = () => {
<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 ? (
<>
<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.signinwithoidc, {
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>
</>
) : null}
</>
)}
</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';
@ -27,6 +28,13 @@ 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 = () => {
@ -61,6 +69,11 @@ 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,
@ -73,6 +86,11 @@ const SettingsUsers = () => {
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,
@ -140,6 +158,88 @@ 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>
</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>
</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>
</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>({

@ -7708,6 +7708,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