From ac2a1971a3f4c97b6b6c5b230a77ff54c10bf4d6 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Sun, 29 May 2022 19:56:29 +0200 Subject: [PATCH 01/33] feat: oidc support feat: oidc 2 feat: oidc --- package.json | 1 + server/index.ts | 12 +- server/interfaces/api/settingsInterfaces.ts | 2 + server/lib/settings.ts | 14 ++ server/routes/auth.ts | 150 ++++++++++++++++++ server/utils/oidc.ts | 97 +++++++++++ src/components/Login/OIDCLoginButton.tsx | 26 +++ src/components/Login/index.tsx | 30 +++- .../Settings/SettingsUsers/index.tsx | 100 ++++++++++++ src/context/SettingsContext.tsx | 2 + yarn.lock | 5 + 11 files changed, 430 insertions(+), 9 deletions(-) create mode 100644 server/utils/oidc.ts create mode 100644 src/components/Login/OIDCLoginButton.tsx diff --git a/package.json b/package.json index 3a1d60de..733c7e97 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/index.ts b/server/index.ts index e78d5a57..246b0729 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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 diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 0e5ab45a..09ba387f 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -37,6 +37,8 @@ export interface PublicSettingsResponse { locale: string; emailEnabled: boolean; newPlexLogin: boolean; + oidcLogin: boolean; + oidcName: string; } export interface CacheItem { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 5a2d2b8a..bcf83202 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -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, }; } diff --git a/server/routes/auth.ts b/server/routes/auth.ts index cf4a4e86..c3e3007b 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -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> = + 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; diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts new file mode 100644 index 00000000..4cd54653 --- /dev/null +++ b/server/utils/oidc.ts @@ -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; +} diff --git a/src/components/Login/OIDCLoginButton.tsx b/src/components/Login/OIDCLoginButton.tsx new file mode 100644 index 00000000..e661fe4a --- /dev/null +++ b/src/components/Login/OIDCLoginButton.tsx @@ -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 ( + + + + ); +} + +export default OIDCLoginButton; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 2dc177a1..5b3b64ff 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -3,6 +3,7 @@ import ImageFader from '@app/components/Common/ImageFader'; import PageTitle from '@app/components/Common/PageTitle'; import LanguagePicker from '@app/components/Layout/LanguagePicker'; import LocalLogin from '@app/components/Login/LocalLogin'; +import OIDCLoginButton from '@app/components/Login/OIDCLoginButton'; import PlexLoginButton from '@app/components/PlexLoginButton'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; @@ -19,6 +20,7 @@ const messages = defineMessages({ signinheader: 'Sign in to continue', signinwithplex: 'Use your Plex account', signinwithoverseerr: 'Use your {applicationTitle} account', + signinwithoidc: 'Use your {oidcName} account', }); const Login = () => { @@ -142,9 +144,7 @@ const Login = () => {
)} + {settings.currentSettings.oidcLogin ? ( + <> + + +
+ +
+
+ + ) : null} )} diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index fb01ba48..d3a80587 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -1,6 +1,7 @@ import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; import PermissionEdit from '@app/components/PermissionEdit'; import QuotaSelector from '@app/components/QuotaSelector'; import globalMessages from '@app/i18n/globalMessages'; @@ -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 = () => { /> +
+ +
+ { + setFieldValue('oidcLogin', !values.oidcLogin); + }} + /> +
+
+ {values.oidcLogin ? ( + <> +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ { + setFieldValue('oidcClientSecret', e.target.value); + }} + autoComplete="off" + /> +
+
+
+ + ) : null}
@@ -217,6 +251,9 @@ const SettingsUsers = () => { type="text" />
+ {errors.oidcClientId && touched.oidcDomain && ( +
{errors.oidcClientId}
+ )}
@@ -236,6 +273,12 @@ const SettingsUsers = () => { autoComplete="off" />
+ {errors.oidcClientSecret && + touched.oidcClientSecret && ( +
+ {errors.oidcClientSecret} +
+ )} From c74b9dcfe77f78a41fa1975a0e008c65e8e82db5 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 31 May 2022 19:20:08 +0200 Subject: [PATCH 07/33] fix: add missing initial values to public settings --- src/pages/_app.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d467e61b..152e8946 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -182,6 +182,8 @@ CoreApp.getInitialProps = async (initialProps) => { locale: 'en', emailEnabled: false, newPlexLogin: true, + oidcLogin: false, + oidcName: '', }; if (ctx.res) { From 079b84c5eadcabeb18faacf1e30a9b8481d1fb0c Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 31 May 2022 19:52:07 +0200 Subject: [PATCH 08/33] fix(docs): fix typo in docs --- overseerr-api.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index 14fa9c06..5d0aa100 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3236,7 +3236,7 @@ paths: required: true schema: type: string - example: '0sJj7IUW2h6aB3U6o-flioB0ARc7nW2E3PFItYd6xPKf5' + example: '123456789' - in: query name: state required: true @@ -3262,7 +3262,7 @@ paths: Set-Cookie: schema: type: string - example: 'token=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' + example: 'oidc-state=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' /user: get: summary: Get all users From afeefec9e18afd2d7a4a939f730e11e5c2775d32 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Wed, 1 Jun 2022 09:14:02 +0200 Subject: [PATCH 09/33] fix(oidc): invalidate when email not verified --- server/routes/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index d8466e8f..bada2922 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -514,6 +514,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { oidcClientId: oidcClientId, oidcDomain: oidcDomain, }); + await jwtSchema.validate(decoded); } catch { logger.info('Failed OIDC login attempt', { @@ -534,6 +535,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { ip: req.ip, email: decoded.email, }); + return res.redirect('/login'); } const userRepository = getRepository(User); From ef24e3064f9bbc636d24affae9ce3feb1a74ce74 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Sat, 1 Oct 2022 18:42:13 +0200 Subject: [PATCH 10/33] fix: review comments --- server/routes/auth.ts | 21 +++++++++++-------- src/components/Login/index.tsx | 4 ++-- .../Settings/SettingsUsers/index.tsx | 5 ++--- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index bada2922..f0dc9ddc 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -476,18 +476,21 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { // Fetch the token data const callbackUrl = new URL( '/api/v1/auth/oidc-callback', - `http://${req.headers.host}` + `${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); 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, - }), + headers: new Headers([ + ['Content-Type', 'application/x-www-form-urlencoded'], + ]), + body: formData, }); // Check that the response is valid diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 5b3b64ff..3edafeea 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -20,7 +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', + signinwithoidcaccount: 'Use your {oidcName} account', }); const Login = () => { @@ -170,7 +170,7 @@ const Login = () => { }`} onClick={() => handleClick(2)} > - {intl.formatMessage(messages.signinwithoidc, { + {intl.formatMessage(messages.signinwithoidcaccount, { oidcName: settings.currentSettings.oidcName, })} diff --git a/src/components/Settings/SettingsUsers/index.tsx b/src/components/Settings/SettingsUsers/index.tsx index 8ea1ca4d..3905bf56 100644 --- a/src/components/Settings/SettingsUsers/index.tsx +++ b/src/components/Settings/SettingsUsers/index.tsx @@ -58,9 +58,8 @@ const validationSchema = yup.object().shape({ test: (val) => { return ( !!val && - /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9][a-zA-Z0-9-_]+\.[a-zA-Z]{2,11}?$/.test( - val - ) + // Any HTTPS domain without query string + /^([a-zA-Z0-9-_]+\.)[^?]+$/i.test(val) ); }, }), From 49a0ba48b739b7a21c63a07c679fae77a3246d8c Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Sat, 1 Oct 2022 19:44:54 +0200 Subject: [PATCH 11/33] fix(imports): fix invalid imports --- src/components/Login/OIDCLoginButton.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Login/OIDCLoginButton.tsx b/src/components/Login/OIDCLoginButton.tsx index 765bc3d2..e47dfa18 100644 --- a/src/components/Login/OIDCLoginButton.tsx +++ b/src/components/Login/OIDCLoginButton.tsx @@ -1,8 +1,8 @@ +import globalMessages from '@app/i18n/globalMessages'; +import OIDCAuth from '@app/utils/oidc'; import { LoginIcon } from '@heroicons/react/outline'; -import React, { useState } from 'react'; +import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import globalMessages from '../../i18n/globalMessages'; -import OIDCAuth from '../../utils/oidc'; const messages = defineMessages({ signinwithoidc: 'Sign In With {provider}', From 1db3644f88baa9bbb58f4226a334e5d210669c00 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Sat, 1 Oct 2022 19:46:15 +0200 Subject: [PATCH 12/33] fix(i18n): update extract --- src/i18n/locale/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index a1538de8..fa21648f 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -133,6 +133,7 @@ "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", From 3dfc67a32b33daa48e7cebd948172d8eebd0b299 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Sat, 1 Oct 2022 19:52:42 +0200 Subject: [PATCH 13/33] fix: invalid import --- server/utils/oidc.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 4cd54653..355f3ee8 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -1,6 +1,6 @@ -import { Request } from 'express'; +import { getSettings } from '@server/lib/settings'; +import type { Request } from 'express'; import * as yup from 'yup'; -import { getSettings } from '../lib/settings'; export function getOIDCRedirectUrl(req: Request, state: string) { const settings = getSettings(); From 5886f83bed8881d8900972c021aa234922414b70 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 11 Oct 2022 14:29:04 +0200 Subject: [PATCH 14/33] fix(oidc): use wellknown authorization endpoint --- server/routes/auth.ts | 15 +++------------ server/utils/oidc.ts | 21 ++++++++++++++++++--- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index f0dc9ddc..013b5b03 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -11,7 +11,7 @@ import { Router } from 'express'; import { createJwtSchema, getOIDCRedirectUrl, - type WellKnownConfiguration, + getOIDCWellknownConfiguration, } from '@server/utils/oidc'; import { randomBytes } from 'crypto'; import gravatarUrl from 'gravatar-url'; @@ -416,7 +416,7 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => { authRoutes.get('/oidc-login', async (req, res, next) => { const state = randomBytes(32).toString('hex'); - const redirectUrl = getOIDCRedirectUrl(req, state); + const redirectUrl = await getOIDCRedirectUrl(req, state); res.cookie('oidc-state', state, { maxAge: 60000, @@ -462,16 +462,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { 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()); + const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); // Fetch the token data const callbackUrl = new URL( diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 355f3ee8..346c9a19 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -2,12 +2,27 @@ import { getSettings } from '@server/lib/settings'; import type { Request } from 'express'; import * as yup from 'yup'; -export function getOIDCRedirectUrl(req: Request, state: string) { +/** Fetch the oidc configuration blob */ +export async function getOIDCWellknownConfiguration(domain: string) { + const wellKnownInfo: WellKnownConfiguration = await fetch( + new URL( + '/.well-known/openid-configuration', + `https://${domain}` + ).toString(), + { + 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 url = new URL(`https://${oidcDomain}`); - url.pathname = '/authorize'; + const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); + const url = new URL(wellKnownInfo.authorization_endpoint); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', oidcClientId); From cefe1fe1ba1c9f361a220e83ab534187ac1508e1 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 11 Oct 2022 14:37:47 +0200 Subject: [PATCH 15/33] fix(oidc login): handle reset loading state --- src/components/Login/OIDCLoginButton.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Login/OIDCLoginButton.tsx b/src/components/Login/OIDCLoginButton.tsx index e47dfa18..8cddd67b 100644 --- a/src/components/Login/OIDCLoginButton.tsx +++ b/src/components/Login/OIDCLoginButton.tsx @@ -21,7 +21,12 @@ function OIDCLoginButton({ revalidate, oidcName }: Props) { const [loading, setLoading] = useState(false); const handleClick = async () => { setLoading(true); - await oidcAuth.preparePopup(); + try { + await oidcAuth.preparePopup(); + } catch (e) { + setLoading(false); + return; + } setLoading(false); }; From 6e899480d55bd5b0a81731b77d8783262433ebeb Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 11 Oct 2022 18:25:34 +0200 Subject: [PATCH 16/33] fix(oidc): allow paths in wellknown lookup --- server/utils/oidc.ts | 16 +++++++--------- src/components/Login/OIDCLoginButton.tsx | 3 ++- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 346c9a19..a24cef3a 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -4,15 +4,13 @@ import * as yup from 'yup'; /** Fetch the oidc configuration blob */ export async function getOIDCWellknownConfiguration(domain: string) { - const wellKnownInfo: WellKnownConfiguration = await fetch( - new URL( - '/.well-known/openid-configuration', - `https://${domain}` - ).toString(), - { - headers: new Headers([['Content-Type', 'application/json']]), - } - ).then((r) => r.json()); + // 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; } diff --git a/src/components/Login/OIDCLoginButton.tsx b/src/components/Login/OIDCLoginButton.tsx index 8cddd67b..81dbd1ee 100644 --- a/src/components/Login/OIDCLoginButton.tsx +++ b/src/components/Login/OIDCLoginButton.tsx @@ -26,8 +26,9 @@ function OIDCLoginButton({ revalidate, oidcName }: Props) { } catch (e) { setLoading(false); return; + } finally { + setLoading(false); } - setLoading(false); }; return ( From 66fac20465f39b866b5bd9d51b85a546670b6c58 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 11 Oct 2022 21:30:53 +0200 Subject: [PATCH 17/33] fix(oidc): allow insecure state cookie when using http --- 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 013b5b03..1ddfaa3b 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -421,7 +421,7 @@ authRoutes.get('/oidc-login', async (req, res, next) => { res.cookie('oidc-state', state, { maxAge: 60000, httpOnly: true, - secure: true, + secure: req.protocol === 'https', }); return res.redirect(redirectUrl); }); From 765c38b88599ad3e869bc973a3a7246a1da7c4c0 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Wed, 12 Oct 2022 17:46:01 +0200 Subject: [PATCH 18/33] fix(oidc): look at protocol to decide callback protocol --- server/routes/auth.ts | 2 +- server/utils/oidc.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 1ddfaa3b..f73595f2 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -434,7 +434,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { 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 url = new URL(req.url, `${req.protocol}://${req.hostname}`); const state = url.searchParams.get('state'); try { diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index a24cef3a..45339958 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -26,7 +26,7 @@ export async function getOIDCRedirectUrl(req: Request, state: string) { const callbackUrl = new URL( '/api/v1/auth/oidc-callback', - `http://${req.headers.host}` + `${req.protocol}://${req.headers.host}` ).toString(); url.searchParams.set('redirect_uri', callbackUrl); url.searchParams.set('scope', 'openid profile email'); From 1a9022a45ed144b521c561c6ceaf627fe6dd4754 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Tue, 2 Jan 2024 23:40:58 +0000 Subject: [PATCH 19/33] got oidc working for authelia --- server/routes/auth.ts | 128 ++++++++++++++++++++++++++++++++++++++---- server/utils/oidc.ts | 58 ++++++++++++++----- 2 files changed, 163 insertions(+), 23 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index d42f62d6..e4b3803f 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -12,6 +12,7 @@ import { createJwtSchema, getOIDCRedirectUrl, getOIDCWellknownConfiguration, + OIDCJwtPayload, } from '@server/utils/oidc'; import { randomBytes } from 'crypto'; import gravatarUrl from 'gravatar-url'; @@ -453,6 +454,37 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { const state = url.searchParams.get('state'); 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'); @@ -466,7 +498,6 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { 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', { @@ -479,26 +510,103 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); - // Fetch the token data - const callbackUrl = new URL( - '/api/v1/auth/oidc-callback', - `${req.protocol}://${req.headers.host}` - ); - + 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'], - ]), + 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; + try { + const decoded = decodeJwt(idToken); + const jwtSchema = createJwtSchema({ + oidcClientId: oidcClientId, + oidcDomain: oidcDomain, + }); + + await jwtSchema.validate(decoded); + } catch (error) { + logger.info('Failed OIDC login attempt', { + cause: `Invalid JWT: ${error.message}`, + ip: req.ip, + idToken: idToken, + }); + return res.redirect('/login'); + } + + // Check that email is verified and map email to user + const decoded: InferType> = + decodeJwt(idToken); + + if (!decoded.email_verified) { + logger.info('Failed OIDC login attempt', { + cause: 'Email not verified', + ip: req.ip, + email: decoded.email, + }); + return res.redirect('/login'); + } + + const userRepository = getRepository(User); + let user = await userRepository.findOne({ + where: { email: decoded.email }, + }); + + // Create user if it doesn't exist + if (!user) { + logger.info(`Creating user for ${decoded.email}`, { + ip: req.ip, + email: decoded.email, + }); + const avatar = gravatarUrl(decoded.email, { default: 'mm', size: 200 }); + user = new User({ + avatar: avatar, + username: decoded.email, + email: decoded.email, + permissions: settings.main.defaultPermissions, + plexToken: '', + userType: UserType.LOCAL, + }); + await userRepository.save(user); + } + + // Set logged in session and return + if (req.session) { + req.session.userId = user.id; + } + return res.redirect('/'); + } catch (error) { + // Log the error details + logger.error('Error in OIDC callback', { + path: '/oidc-callback', + error: error.message, + stack: error.stack, // Include the error stack trace for debugging + }); + // Check that the response is valid const body = (await response.json()) as | { id_token: string; error: never } diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 45339958..911c54a1 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -49,13 +49,19 @@ export const createJwtSchema = ({ `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})` + .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() @@ -63,11 +69,12 @@ export const createJwtSchema = ({ '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; + // Check if 'value' is undefined + if (value === undefined) return false; + return value >= Math.ceil(Date.now() / 1000); } ), + iat: yup .number() .required() @@ -75,14 +82,13 @@ export const createJwtSchema = ({ '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; + // Check if 'value' is undefined + if (value === undefined) return false; + const oneDayAgo = Math.ceil(Number(new Date()) / 1000) - 86400; + return value >= oneDayAgo; } ), - // these should exist because we set the scope to `openid profile email` + email: yup.string().email().required(), email_verified: yup.boolean().required(), }); @@ -108,3 +114,29 @@ export interface WellKnownConfiguration { 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; +} + From 21e64f00944ace4c3bf7d289039db10c0387ff10 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Tue, 2 Jan 2024 19:59:16 +0000 Subject: [PATCH 20/33] made proggress on oidc with better try catch --- server/routes/auth.ts | 316 ++++++++++++++++-------------------------- 1 file changed, 123 insertions(+), 193 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index e4b3803f..bdc6604e 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -6,7 +6,7 @@ import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; -import { Router } from 'express'; +import { Router, Request } from 'express'; /* eslint-disable @typescript-eslint/no-unused-vars */ import { createJwtSchema, @@ -443,16 +443,6 @@ authRoutes.get('/oidc-login', async (req, res, next) => { }); 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, `${req.protocol}://${req.hostname}`); - const state = url.searchParams.get('state'); - try { const logRequestInfo = (req: Request) => { const remoteIp = req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress; @@ -473,7 +463,6 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { 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'); @@ -485,120 +474,142 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { 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'); - } + 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'); + } - const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); + // 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'); + } - 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 wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); - const response = await fetch(wellKnownInfo.token_endpoint, { - method: 'POST', - headers: new Headers([['Content-Type', 'application/x-www-form-urlencoded']]), - body: formData, - }); + // Fetch the token data + const callbackUrl = new URL( + '/api/v1/auth/oidc-callback', + `${req.protocol}://${req.headers.host}` + ); - const body = (await response.json()) as - | { id_token: string; error: never } - | { error: string }; + 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); + // Append scope if available + if (scope) { + formData.append('scope', scope); + } - if (body.error) { - logger.info('Failed OIDC login attempt', { - cause: `Invalid token response: ${body.error}`, - ip: req.ip, - body: body, + const response = await fetch(wellKnownInfo.token_endpoint, { + method: 'POST', + headers: new Headers([ + ['Content-Type', 'application/x-www-form-urlencoded'], + ]), + body: formData, }); - return res.redirect('/login'); - } - const { id_token: idToken } = body as Extract; - try { - const decoded = decodeJwt(idToken); - const jwtSchema = createJwtSchema({ - oidcClientId: oidcClientId, - oidcDomain: oidcDomain, - }); + // 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'); + } - 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'); - } + // 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, + }); - // Check that email is verified and map email to user - const decoded: InferType> = - decodeJwt(idToken); + await jwtSchema.validate(decoded); + } catch { + logger.info('Failed OIDC login attempt', { + cause: 'Invalid jwt', + ip: req.ip, + idToken: idToken, + }); + return res.redirect('/login'); + } - if (!decoded.email_verified) { - logger.info('Failed OIDC login attempt', { - cause: 'Email not verified', - ip: req.ip, - email: decoded.email, + // Check that email is verified and map email to user + const decoded: InferType> = + decodeJwt(idToken); + + if (!decoded.email_verified) { + logger.info('Failed OIDC login attempt', { + cause: 'Email not verified', + ip: req.ip, + email: decoded.email, + }); + return res.redirect('/login'); + } + + const userRepository = getRepository(User); + let user = await userRepository.findOne({ + where: { email: decoded.email }, }); - 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); + } - // Create user if it doesn't exist - if (!user) { - logger.info(`Creating user for ${decoded.email}`, { + // 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, - 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, + errorMessage: error.message, }); - await userRepository.save(user); - } - - // Set logged in session and return - if (req.session) { - req.session.userId = user.id; + return res.redirect('/login'); } - return res.redirect('/'); } catch (error) { // Log the error details logger.error('Error in OIDC callback', { @@ -607,89 +618,8 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { stack: error.stack, // Include the error stack trace for debugging }); - // 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'); - } - - // Check that email is verified and map email to user - const decoded: InferType> = - decodeJwt(idToken); - - if (!decoded.email_verified) { - logger.info('Failed OIDC login attempt', { - cause: 'Email not verified', - ip: req.ip, - email: decoded.email, - }); - return res.redirect('/login'); - } - - const userRepository = getRepository(User); - let user = await userRepository.findOne({ - where: { email: decoded.email }, - }); - - // Create user if it doesn't exist - if (!user) { - logger.info(`Creating user for ${decoded.email}`, { - ip: req.ip, - email: decoded.email, - }); - const avatar = gravatarUrl(decoded.email, { default: 'mm', size: 200 }); - user = new User({ - avatar: avatar, - username: decoded.email, - email: decoded.email, - permissions: settings.main.defaultPermissions, - plexToken: '', - userType: UserType.LOCAL, - }); - await userRepository.save(user); - } - - // Set logged in session and return - if (req.session) { - req.session.userId = user.id; - } - return res.redirect('/'); - } catch (error) { - logger.error('Failed OIDC login attempt', { - cause: 'Unknown error', - ip: req.ip, - errorMessage: error.message, - }); - return res.redirect('/login'); + // Handle the error as appropriate for your application + next(error); } }); From f1b2c91229b54fa8adb778ea050342589ca7a9b7 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Tue, 2 Jan 2024 17:19:00 +0000 Subject: [PATCH 21/33] blah --- overseerr-api.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/overseerr-api.yml b/overseerr-api.yml index ce625310..ea800b70 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -3466,6 +3466,23 @@ paths: 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 From 38beba953b34970bc9b0dba09537969c81d594a8 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Fri, 29 Dec 2023 05:10:05 +0000 Subject: [PATCH 22/33] oidc callback logging --- server/routes/auth.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index bdc6604e..14560de6 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -443,6 +443,25 @@ authRoutes.get('/oidc-login', async (req, res, next) => { }); authRoutes.get('/oidc-callback', async (req, res, next) => { + logger.info('OIDC callback initiated', { 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 + + // Optional logging for scope parameter + if (scope) { + logger.info('OIDC callback with scope', { scope }); + } else { + logger.info('OIDC callback without scope'); + } + try { const logRequestInfo = (req: Request) => { const remoteIp = req.headers['x-real-ip'] || req.headers['x-forwarded-for'] || req.connection.remoteAddress; From 6e1b6b3a1309a321f2919a2dd1fa01412c548155 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Fri, 29 Dec 2023 03:20:04 +0000 Subject: [PATCH 23/33] okay trying again with no error handling changes. Just logging and scope support --- server/routes/auth.ts | 268 ++++++++++++++++++------------------------ 1 file changed, 113 insertions(+), 155 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 14560de6..331714fa 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -463,182 +463,140 @@ 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.' }); + // 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 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 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'); } - 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'); - } + const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); - // 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'); - } - - const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); + // Fetch the token data + const callbackUrl = new URL( + '/api/v1/auth/oidc-callback', + `${req.protocol}://${req.headers.host}` + ); - // Fetch the token data - 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); + // Append scope if available + if (scope) { + formData.append('scope', scope); + } - 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); - // Append scope if available - 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 response = await fetch(wellKnownInfo.token_endpoint, { - method: 'POST', - headers: new Headers([ - ['Content-Type', 'application/x-www-form-urlencoded'], - ]), - body: formData, + // 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'); + } - // 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'); - } + // 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, + }); - // Check that email is verified and map email to user - const decoded: InferType> = - decodeJwt(idToken); + await jwtSchema.validate(decoded); + } catch { + logger.info('Failed OIDC login attempt', { + cause: 'Invalid jwt', + ip: req.ip, + idToken: idToken, + }); + return res.redirect('/login'); + } - if (!decoded.email_verified) { - logger.info('Failed OIDC login attempt', { - cause: 'Email not verified', - ip: req.ip, - email: decoded.email, - }); - return res.redirect('/login'); - } + // Check that email is verified and map email to user + const decoded: InferType> = + decodeJwt(idToken); - const userRepository = getRepository(User); - let user = await userRepository.findOne({ - where: { email: decoded.email }, + if (!decoded.email_verified) { + logger.info('Failed OIDC login attempt', { + cause: 'Email not verified', + ip: req.ip, + email: decoded.email, }); + return res.redirect('/login'); + } - // 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); - } + const userRepository = getRepository(User); + let user = await userRepository.findOne({ + where: { email: decoded.email }, + }); - // 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', + // Create user if it doesn't exist + if (!user) { + logger.info(`Creating user for ${decoded.email}`, { ip: req.ip, - errorMessage: error.message, + email: decoded.email, }); - return res.redirect('/login'); + 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 + logger.error('Failed OIDC login attempt', { + cause: 'Unknown error', + ip: req.ip, + errorMessage: error.message, }); - - // Handle the error as appropriate for your application - next(error); + return res.redirect('/login'); } }); From ca534fc075064b8560da157c69d53ba39c514b41 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Fri, 29 Dec 2023 03:00:29 +0000 Subject: [PATCH 24/33] scope missing is not a fatal error --- server/routes/auth.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 331714fa..85242cda 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -453,14 +453,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { 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 - - // Optional logging for scope parameter - if (scope) { - logger.info('OIDC callback with scope', { scope }); - } else { - logger.info('OIDC callback without scope'); - } + const scope = url.searchParams.get('scope'); // Handling 'scope' parameter try { // Check that the request belongs to the correct state @@ -501,8 +494,7 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { formData.append('redirect_uri', callbackUrl.toString()); formData.append('client_id', oidcClientId); formData.append('code', code); - // Append scope if available - if (scope) { + if (scope) { // Append 'scope' only if it's provided formData.append('scope', scope); } From 76f4461fcda87eecf3c6f324601521f22b58c895 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Fri, 29 Dec 2023 02:59:28 +0000 Subject: [PATCH 25/33] add scope support for oidc --- server/routes/auth.ts | 118 +++++++++++++----------------------------- 1 file changed, 35 insertions(+), 83 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 85242cda..5f98602c 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -443,60 +443,48 @@ authRoutes.get('/oidc-login', async (req, res, next) => { }); authRoutes.get('/oidc-callback', async (req, res, next) => { - logger.info('OIDC callback initiated', { req }); const settings = getSettings(); const { oidcDomain, oidcClientId, oidcClientSecret } = settings.main; + // Log the initial OIDC callback request + logger.info('Received OIDC callback', { ip: req.ip }); + if (!settings.main.oidcLogin) { + logger.warn('OIDC sign-in is disabled', { ip: req.ip }); 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'); // Handling 'scope' parameter + const scope = url.searchParams.get('scope'); // Handling additional 'scope' parameter 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, - }); + // State validation + if (!state || cookieState !== state) { + logger.warn('OIDC state mismatch', { ip: req.ip, state, cookieState }); return res.redirect('/login'); } + res.clearCookie('oidc-state'); - // Check that a code as been issued + // Code validation const code = url.searchParams.get('code'); if (!code) { - logger.info('Failed OIDC login attempt', { - cause: 'Invalid code', - ip: req.ip, - code: code, - }); + logger.warn('OIDC code missing', { ip: req.ip }); return res.redirect('/login'); } const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); - // Fetch the token data - const callbackUrl = new URL( - '/api/v1/auth/oidc-callback', - `${req.protocol}://${req.headers.host}` - ); - + // Token request + 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) { // Append 'scope' only if it's provided - formData.append('scope', scope); - } + formData.append('scope', scope); // Include the 'scope' in the token request const response = await fetch(wellKnownInfo.token_endpoint, { method: 'POST', @@ -506,90 +494,54 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { body: formData, }); - // Check that the response is valid - const body = (await response.json()) as - | { id_token: string; error: never } - | { error: string }; + // Validate response + const body = await response.json(); if (body.error) { - logger.info('Failed OIDC login attempt', { - cause: 'Invalid token response', - ip: req.ip, - body: body, - }); + logger.warn('Invalid token response', { ip: req.ip, error: body.error }); 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 } - >; + // Token validation + const { id_token: idToken } = body; + let decoded; try { - const decoded = decodeJwt(idToken); - const jwtSchema = createJwtSchema({ - oidcClientId: oidcClientId, - oidcDomain: oidcDomain, - }); - + decoded = decodeJwt(idToken); + const jwtSchema = createJwtSchema({ oidcClientId, oidcDomain }); await jwtSchema.validate(decoded); - } catch { - logger.info('Failed OIDC login attempt', { - cause: 'Invalid jwt', - ip: req.ip, - idToken: idToken, - }); + } catch (error) { + logger.warn('JWT validation failed', { ip: req.ip, error: error.message }); return res.redirect('/login'); } - // Check that email is verified and map email to user - const decoded: InferType> = - decodeJwt(idToken); - + // Email verification if (!decoded.email_verified) { - logger.info('Failed OIDC login attempt', { - cause: 'Email not verified', - ip: req.ip, - email: decoded.email, - }); + logger.warn('Email not verified', { ip: req.ip, email: decoded.email }); return res.redirect('/login'); } + // User handling const userRepository = getRepository(User); - let user = await userRepository.findOne({ - where: { email: decoded.email }, - }); + 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, - }); + logger.info('Creating new user', { 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, - }); + user = new User({ 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 + // Session handling if (req.session) { req.session.userId = user.id; } + logger.info('User logged in successfully', { ip: req.ip, userId: user.id }); return res.redirect('/'); } catch (error) { - logger.error('Failed OIDC login attempt', { - cause: 'Unknown error', - ip: req.ip, - errorMessage: error.message, - }); + logger.error('OIDC login error', { ip: req.ip, errorMessage: error.message }); return res.redirect('/login'); } }); + export default authRoutes; From 4f0bc49d7f9d47507800e7640d31c8d65163f789 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Thu, 28 Dec 2023 06:43:33 +0000 Subject: [PATCH 26/33] added additional logging and error handling to try to debug a scope parameter failure --- server/routes/auth.ts | 136 +++++++++++++++++++++++++++--------------- 1 file changed, 87 insertions(+), 49 deletions(-) diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 5f98602c..73cc7c89 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -431,60 +431,81 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => { }); authRoutes.get('/oidc-login', async (req, res, next) => { - 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', - }); - return res.redirect(redirectUrl); -}); + try { + const state = randomBytes(32).toString('hex'); + const redirectUrl = await getOIDCRedirectUrl(req, state); -authRoutes.get('/oidc-callback', async (req, res, next) => { - const settings = getSettings(); - const { oidcDomain, oidcClientId, oidcClientSecret } = settings.main; + res.cookie('oidc-state', state, { + maxAge: 60000, + httpOnly: true, + secure: req.protocol === 'https', + }); - // Log the initial OIDC callback request - logger.info('Received OIDC callback', { ip: req.ip }); + logger.debug('OIDC login initiated', { + path: '/oidc-login', + redirectUrl: redirectUrl, + state: state, + }); - if (!settings.main.oidcLogin) { - logger.warn('OIDC sign-in is disabled', { ip: req.ip }); - return res.status(500).json({ error: 'OIDC sign-in is disabled.' }); + 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 } +}); - 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'); // Handling additional 'scope' parameter +authRoutes.get('/oidc-callback', async (req, res, next) => { try { - // State validation - if (!state || cookieState !== state) { - logger.warn('OIDC state mismatch', { ip: req.ip, state, cookieState }); - return res.redirect('/login'); + const settings = getSettings(); + const { oidcDomain, oidcClientId, oidcClientSecret } = settings.main; + + if (!settings.main.oidcLogin) { + logger.warn('OIDC sign-in is disabled', { path: '/oidc-callback' }); + return res.status(500).json({ error: 'OIDC sign-in is disabled.' }); } - res.clearCookie('oidc-state'); - // Code validation + const cookieState = req.cookies['oidc-state']; + const url = new URL(req.url, `${req.protocol}://${req.hostname}`); + const state = url.searchParams.get('state'); const code = url.searchParams.get('code'); - if (!code) { - logger.warn('OIDC code missing', { ip: req.ip }); + + logger.debug('OIDC callback received', { + path: '/oidc-callback', + state: state, + code: code, + cookieState: cookieState, + }); + + if (!state || state !== cookieState || !code) { + logger.warn('OIDC callback state or code mismatch or missing', { + path: '/oidc-callback', + state: state, + code: code, + cookieState: cookieState, + }); return res.redirect('/login'); } + res.clearCookie('oidc-state'); + + res.clearCookie('oidc-state'); const wellKnownInfo = await getOIDCWellknownConfiguration(oidcDomain); - // Token request - const callbackUrl = new URL('/api/v1/auth/oidc-callback', `${req.protocol}://${req.headers.host}`); + const callbackUrl = new URL( + '/api/v1/auth/oidc-callback', + `${req.protocol}://${req.headers.host}` + ).toString(); + const formData = new URLSearchParams(); formData.append('client_secret', oidcClientSecret); formData.append('grant_type', 'authorization_code'); - formData.append('redirect_uri', callbackUrl.toString()); + formData.append('redirect_uri', callbackUrl); formData.append('client_id', oidcClientId); formData.append('code', code); - formData.append('scope', scope); // Include the 'scope' in the token request const response = await fetch(wellKnownInfo.token_endpoint, { method: 'POST', @@ -494,28 +515,41 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { body: formData, }); - // Validate response const body = await response.json(); + if (body.error) { - logger.warn('Invalid token response', { ip: req.ip, error: body.error }); + logger.warn('Failed OIDC token exchange', { + path: '/oidc-callback', + error: body.error, + state: state, + code: code, + }); return res.redirect('/login'); } - // Token validation const { id_token: idToken } = body; - let decoded; + const decoded = decodeJwt(idToken); + const jwtSchema = createJwtSchema({ + oidcClientId: oidcClientId, + oidcDomain: oidcDomain, + }); + try { - decoded = decodeJwt(idToken); - const jwtSchema = createJwtSchema({ oidcClientId, oidcDomain }); await jwtSchema.validate(decoded); } catch (error) { - logger.warn('JWT validation failed', { ip: req.ip, error: error.message }); + logger.warn('Invalid JWT in OIDC callback', { + path: '/oidc-callback', + error: error.message, + idToken: idToken, + }); return res.redirect('/login'); } - // Email verification if (!decoded.email_verified) { - logger.warn('Email not verified', { ip: req.ip, email: decoded.email }); + logger.warn('Email not verified in OIDC callback', { + path: '/oidc-callback', + email: decoded.email, + }); return res.redirect('/login'); } @@ -523,23 +557,27 @@ authRoutes.get('/oidc-callback', async (req, res, next) => { 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 new user', { ip: req.ip, email: decoded.email }); + logger.info(`Creating new user from OIDC callback for ${decoded.email}`, { + path: '/oidc-callback', + email: decoded.email, + }); const avatar = gravatarUrl(decoded.email, { default: 'mm', size: 200 }); user = new User({ avatar, username: decoded.email, email: decoded.email, permissions: settings.main.defaultPermissions, plexToken: '', userType: UserType.LOCAL }); await userRepository.save(user); } - // Session handling if (req.session) { req.session.userId = user.id; } - logger.info('User logged in successfully', { ip: req.ip, userId: user.id }); + return res.redirect('/'); } catch (error) { - logger.error('OIDC login error', { ip: req.ip, errorMessage: error.message }); - return res.redirect('/login'); + logger.error('Error in OIDC callback processing', { + path: '/oidc-callback', + error: error.message, + }); + next(error); } }); From 42033c75b5cc046b6ce1801e573963fe9dd1768f Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Thu, 28 Dec 2023 06:15:46 +0000 Subject: [PATCH 27/33] fix the redirect uri generation to use x-forward-proto to allow handling of reverse proxies. --- server/utils/oidc.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts index 911c54a1..b79ba30b 100644 --- a/server/utils/oidc.ts +++ b/server/utils/oidc.ts @@ -24,16 +24,17 @@ export async function getOIDCRedirectUrl(req: Request, state: string) { url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', oidcClientId); - const callbackUrl = new URL( - '/api/v1/auth/oidc-callback', - `${req.protocol}://${req.headers.host}` - ).toString(); + // 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, From fe627509d55111e9b7d16cb2bf7eadd8d18f34f1 Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Wed, 10 Jan 2024 05:49:57 +0000 Subject: [PATCH 28/33] add middleare logger back in and debug should not be default logging level --- server/index.ts | 12 ++++ server/logger.ts | 2 +- server/routes/auth.ts | 133 +++++++++++++++++++++++++++--------------- 3 files changed, 98 insertions(+), 49 deletions(-) diff --git a/server/index.ts b/server/index.ts index 10ca1032..3e61d9e9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -43,6 +43,17 @@ const dev = process.env.NODE_ENV !== 'production'; const app = next({ dev }); const handle = app.getRequestHandler(); +const logMiddleware = (req: Request, res: Response, next: NextFunction) => { + // Log information about the incoming request + logger.debug(`Request Method: ${req.method}`); + logger.debug(`Request URL: ${req.url}`); + logger.debug(`Request Headers: ${JSON.stringify(req.headers)}`); + logger.debug(`Request Body: ${JSON.stringify(req.body)}`); + + // Continue processing the request + next(); +}; + app .prepare() .then(async () => { @@ -104,6 +115,7 @@ app if (settings.main.trustProxy) { server.enable('trust proxy'); } + server.use(logMiddleware); server.use(cookieParser()); server.use(express.json()); server.use(express.urlencoded({ extended: true })); diff --git a/server/logger.ts b/server/logger.ts index d5809a0e..fa10e890 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -26,7 +26,7 @@ const hformat = winston.format.printf( ); const logger = winston.createLogger({ - level: process.env.LOG_LEVEL?.toLowerCase() || 'debug', + level: process.env.LOG_LEVEL?.toLowerCase() || 'info', format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 73cc7c89..0fe59a51 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -460,123 +460,160 @@ authRoutes.get('/oidc-login', async (req, res, next) => { 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) { - logger.warn('OIDC sign-in is disabled', { path: '/oidc-callback' }); 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 code = url.searchParams.get('code'); + const scope = url.searchParams.get('scope'); // Optional scope parameter - logger.debug('OIDC callback received', { - path: '/oidc-callback', - state: state, - code: code, - cookieState: cookieState, - }); + if (scope) { + logger.info('OIDC callback with scope', { scope }); + } else { + logger.info('OIDC callback without scope'); + } - if (!state || state !== cookieState || !code) { - logger.warn('OIDC callback state or code mismatch or missing', { - path: '/oidc-callback', + // 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, - code: code, cookieState: cookieState, }); return res.redirect('/login'); } - res.clearCookie('oidc-state'); - res.clearCookie('oidc-state'); + 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}` - ).toString(); - + 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); + 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'], - ]), + headers: new Headers([['Content-Type', 'application/x-www-form-urlencoded']]), body: formData, }); - const body = await response.json(); + const body = (await response.json()) as + | { id_token: string; error: never } + | { error: string }; if (body.error) { - logger.warn('Failed OIDC token exchange', { - path: '/oidc-callback', - error: body.error, - state: state, - code: code, + 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; - const decoded = decodeJwt(idToken); - const jwtSchema = createJwtSchema({ - oidcClientId: oidcClientId, - oidcDomain: oidcDomain, - }); - + const { id_token: idToken } = body as Extract; try { + const decoded = decodeJwt(idToken); + const jwtSchema = createJwtSchema({ + oidcClientId: oidcClientId, + oidcDomain: oidcDomain, + }); + await jwtSchema.validate(decoded); } catch (error) { - logger.warn('Invalid JWT in OIDC callback', { - path: '/oidc-callback', - error: error.message, + logger.info('Failed OIDC login attempt', { + cause: `Invalid JWT: ${error.message}`, + ip: req.ip, idToken: idToken, }); return res.redirect('/login'); } + // Check that email is verified and map email to user + const decoded: InferType> = + decodeJwt(idToken); + if (!decoded.email_verified) { - logger.warn('Email not verified in OIDC callback', { - path: '/oidc-callback', + logger.info('Failed OIDC login attempt', { + cause: 'Email not verified', + ip: req.ip, email: decoded.email, }); return res.redirect('/login'); } - // User handling const userRepository = getRepository(User); - let user = await userRepository.findOne({ where: { email: decoded.email } }); + let user = await userRepository.findOne({ + where: { email: decoded.email }, + }); + // Create user if it doesn't exist if (!user) { - logger.info(`Creating new user from OIDC callback for ${decoded.email}`, { - path: '/oidc-callback', + 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, username: decoded.email, email: decoded.email, permissions: settings.main.defaultPermissions, plexToken: '', userType: UserType.LOCAL }); + 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) { - logger.error('Error in OIDC callback processing', { + // 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); } }); From a0ba2fe3c565bd819aa3e85f1386d55930a68d9e Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Wed, 3 Jan 2024 01:52:59 +0000 Subject: [PATCH 29/33] icon fixes for heroicons v2 --- src/components/Login/OIDCLoginButton.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Login/OIDCLoginButton.tsx b/src/components/Login/OIDCLoginButton.tsx index 81dbd1ee..1bb5ef31 100644 --- a/src/components/Login/OIDCLoginButton.tsx +++ b/src/components/Login/OIDCLoginButton.tsx @@ -1,6 +1,6 @@ import globalMessages from '@app/i18n/globalMessages'; import OIDCAuth from '@app/utils/oidc'; -import { LoginIcon } from '@heroicons/react/outline'; +import { ArrowLeftOnRectangleIcon} from '@heroicons/react/24/outline'; import { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -37,7 +37,7 @@ function OIDCLoginButton({ revalidate, oidcName }: Props) { className="plex-button bg-indigo-500 hover:bg-indigo-600" onClick={handleClick} > - + {loading ? intl.formatMessage(globalMessages.loading) From 7b1fb3e90a59d6eb7d63136ae2500e92f90e628a Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Wed, 10 Jan 2024 07:19:35 +0000 Subject: [PATCH 30/33] Fix New Plex Login option being tied to localLogin settings, and also being disabled unecessarily --- src/components/Login/index.tsx | 46 +++++++++++++++++----------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index d9f3a626..6efbc17e 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -120,26 +120,26 @@ const Login = () => { {({ openIndexes, handleClick, AccordionContent }) => ( <> - - -
- setAuthToken(authToken)} - /> + {settings.currentSettings.newPlexLogin && ( +
+ + +
+ setAuthToken(authToken)} + /> +
+
- + )} {settings.currentSettings.localLogin && (
)} - {settings.currentSettings.oidcLogin ? ( - <> + {settings.currentSettings.oidcLogin && ( +
- - ) : null} +
+ )} )}
From c11c11b283801c031b243a254e9a73df39450b8a Mon Sep 17 00:00:00 2001 From: Mike Kao Date: Wed, 10 Jan 2024 07:26:36 +0000 Subject: [PATCH 31/33] rounded corners for plex --- src/components/Login/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 6efbc17e..a54f86ef 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -123,7 +123,7 @@ const Login = () => { {settings.currentSettings.newPlexLogin && (