From 86f1b4452df555661fb2a2429b6c19f190cb95c8 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 9 Feb 2026 00:18:37 -0500 Subject: [PATCH] feat: support login with OpenID Connect --- package.json | 1 + pnpm-lock.yaml | 21 +++ seerr-api.yml | 105 +++++++++++ server/constants/error.ts | 4 + server/index.ts | 8 +- server/routes/auth.ts | 357 +++++++++++++++++++++++++++++++++++++- 6 files changed, 494 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 419b8c2ea..2598e9073 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "node-gyp": "9.3.1", "node-schedule": "2.1.1", "nodemailer": "7.0.12", + "openid-client": "^6.8.2", "openpgp": "6.3.0", "pg": "8.17.2", "pug": "3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be456f18a..8f87ec80e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: nodemailer: specifier: 7.0.12 version: 7.0.12 + openid-client: + specifier: ^6.8.2 + version: 6.8.2 openpgp: specifier: 6.3.0 version: 6.3.0 @@ -6191,6 +6194,9 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -7130,6 +7136,9 @@ packages: nullthrows@1.1.1: resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + ob1@0.80.12: resolution: {integrity: sha512-VMArClVT6LkhUGpnuEoBuyjG9rzUyEzg4PDkav6wK1cLhOK02gPCYFxoiB4mqVnrMhDpIzJcrGNAMVi9P+hXrw==} engines: {node: '>=18'} @@ -7207,6 +7216,9 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + openid-client@6.8.2: + resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} + openpgp@6.3.0: resolution: {integrity: sha512-pLzCU8IgyKXPSO11eeharQkQ4GzOKNWhXq79pQarIRZEMt1/ssyr+MIuWBv1mNoenJLg04gvPx+fi4gcKZ4bag==} engines: {node: '>= 18.0.0'} @@ -17653,6 +17665,8 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jose@6.1.3: {} + js-stringify@1.0.2: {} js-tokens@4.0.0: {} @@ -18867,6 +18881,8 @@ snapshots: nullthrows@1.1.1: {} + oauth4webapi@3.8.5: {} + ob1@0.80.12: dependencies: flow-enums-runtime: 0.0.6 @@ -18950,6 +18966,11 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openid-client@6.8.2: + dependencies: + jose: 6.1.3 + oauth4webapi: 3.8.5 + openpgp@6.3.0: {} optionator@0.9.4: diff --git a/seerr-api.yml b/seerr-api.yml index 92be2d489..40593a208 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4046,6 +4046,111 @@ paths: required: - email - password + /auth/oidc/login/{slug}: + get: + summary: Initiate OpenID Connect login + description: Initiates the OpenID Connect authorization code flow with PKCE for the specified provider. Returns a redirect URL to the provider's authorization endpoint. + security: [] + tags: + - auth + parameters: + - in: path + name: slug + required: true + schema: + type: string + description: Provider slug + - in: query + name: returnUrl + required: false + allowReserved: true + schema: + type: string + format: uri + description: URL to redirect to after login. Defaults to /login if not specified. + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + redirectUrl: + type: string + '403': + description: OpenID Connect sign-in is disabled or provider not found + /auth/oidc/callback/{slug}: + post: + summary: Handle OpenID Connect callback + description: Handles the authorization code callback from the OpenID Connect provider. Exchanges the code for tokens, validates claims, and either logs in an existing user, links the account to the currently logged-in user, or creates a new user if allowed. + security: [] + tags: + - auth + parameters: + - in: path + name: slug + required: true + schema: + type: string + description: Provider slug + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - callbackUrl + properties: + callbackUrl: + type: string + format: uri + description: The full callback URL including the authorization code and any other parameters returned by the OIDC provider (e.g. https://example.com/login?code=xxx). + example: 'https://example.com/login?code=xxx' + responses: + '204': + description: Authentication successful. No response body. + '400': + description: Unable to create account (e.g. missing email address) + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'OIDC_MISSING_EMAIL' + '403': + description: OpenID Connect sign-in is disabled, provider not found, or user does not meet required claims + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'UNAUTHORIZED' + '409': + description: The OIDC account is already linked to a different user + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'OIDC_ACCOUNT_ALREADY_LINKED' + '500': + description: An error occurred (e.g. provider discovery failed, authorization failed) + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'OIDC_AUTHORIZATION_FAILED' /auth/logout: post: summary: Sign out and clear session cookie diff --git a/server/constants/error.ts b/server/constants/error.ts index daa02f1a1..60fa7c98b 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -5,6 +5,10 @@ export enum ApiErrorCode { InvalidEmail = 'INVALID_EMAIL', NotAdmin = 'NOT_ADMIN', NoAdminUser = 'NO_ADMIN_USER', + OidcProviderDiscoveryFailed = 'OIDC_PROVIDER_DISCOVERY_FAILED', + OidcAuthorizationFailed = 'OIDC_AUTHORIZATION_FAILED', + OidcMissingEmail = 'OIDC_MISSING_EMAIL', + OidcAccountAlreadyLinked = 'OIDC_ACCOUNT_ALREADY_LINKED', SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS', SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', Unauthorized = 'UNAUTHORIZED', diff --git a/server/index.ts b/server/index.ts index cd34d7d52..1295be1cb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -247,7 +247,12 @@ app server.get('*', (req, res) => handle(req, res)); server.use( ( - err: { status: number; message: string; errors: string[] }, + err: { + status: number; + message: string; + errors: string[]; + error?: string; + }, _req: Request, res: Response, // We must provide a next function for the function signature here even though its not used @@ -258,6 +263,7 @@ app res.status(err.status || 500).json({ message: err.message, errors: err.errors, + ...(err.error != null && { error: err.error }), }); } ); diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 66b18224a..ad03f3de4 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -4,6 +4,7 @@ import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType, ServerType } from '@server/constants/server'; import { UserType } from '@server/constants/user'; import { getRepository } from '@server/datasource'; +import { LinkedAccount } from '@server/entity/LinkedAccount'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; import { Permission } from '@server/lib/permissions'; @@ -15,8 +16,10 @@ import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; import { getHostname } from '@server/utils/getHostname'; import axios from 'axios'; -import { Router } from 'express'; +import { Router, type Request } from 'express'; +import gravatarUrl from 'gravatar-url'; import net from 'net'; +import * as openIdClient from 'openid-client'; import validator from 'validator'; const authRoutes = Router(); @@ -645,6 +648,358 @@ authRoutes.post('/local', async (req, res, next) => { } }); +const getOidcRedirectUrl = (req: Request) => { + const returnUrl = + typeof req.query.returnUrl === 'string' ? req.query.returnUrl : '/login'; + return new URL( + returnUrl, + getSettings().main.applicationUrl || `${req.protocol}://${req.headers.host}` + ); +}; + +authRoutes.get('/oidc/login/:slug', async (req, res, next) => { + const settings = getSettings(); + const provider = settings.oidc.providers.find( + (p) => p.slug === req.params.slug + ); + + if (!settings.main.oidcLogin || !provider) { + return next({ + status: 403, + error: ApiErrorCode.Unauthorized, + }); + } + + let config: openIdClient.Configuration; + try { + config = await openIdClient.discovery( + new URL(provider.issuerUrl), + provider.clientId, + provider.clientSecret, + undefined, + { + execute: + process.env.OIDC_ALLOW_INSECURE === 'true' + ? [openIdClient.allowInsecureRequests] + : [], + } + ); + } catch (error) { + logger.error('Failed OIDC provider discovery', { + label: 'Auth', + provider: provider.name, + ip: req.ip, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return next({ + status: 500, + error: ApiErrorCode.OidcProviderDiscoveryFailed, + }); + } + + const code_verifier = openIdClient.randomPKCECodeVerifier(); + const code_challenge = + await openIdClient.calculatePKCECodeChallenge(code_verifier); + res.cookie('oidc-code-verifier', code_verifier, { + maxAge: 60000, + httpOnly: true, + secure: req.protocol === 'https', + }); + + const callbackUrl = getOidcRedirectUrl(req); + + const parameters: Record = { + redirect_uri: callbackUrl.toString(), + scope: provider.scopes ?? 'openid profile email', + code_challenge, + code_challenge_method: 'S256', + }; + + /** + * We cannot be sure the server supports PKCE so we're going to use state too. + * Use of PKCE is backwards compatible even if the AS doesn't support it which + * is why we're using it regardless. Like PKCE, random state must be generated + * for every redirect to the authorization_endpoint. + */ + if (!config.serverMetadata().supportsPKCE()) { + const state = openIdClient.randomState(); + parameters.state = state; + res.cookie('oidc-state', state, { + maxAge: 60000, + httpOnly: true, + secure: req.protocol === 'https', + }); + } + + let redirectUrl: URL; + try { + redirectUrl = openIdClient.buildAuthorizationUrl(config, parameters); + } catch (error) { + logger.error('Failed to build OIDC authorization URL', { + label: 'Auth', + provider: provider.name, + ip: req.ip, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return next({ + status: 500, + error: ApiErrorCode.OidcAuthorizationFailed, + }); + } + + return res.status(200).json({ + redirectUrl, + }); +}); + +authRoutes.post( + '/oidc/callback/:slug', + async ( + req: Request<{ slug: string }, never, { callbackUrl: string }>, + res, + next + ) => { + const settings = getSettings(); + const provider = settings.oidc.providers.find( + (p) => p.slug === req.params.slug + ); + + if (!settings.main.oidcLogin || !provider) { + return next({ + status: 403, + error: ApiErrorCode.Unauthorized, + }); + } + + let config: openIdClient.Configuration; + try { + config = await openIdClient.discovery( + new URL(provider.issuerUrl), + provider.clientId, + provider.clientSecret, + undefined, + { + execute: + process.env.OIDC_ALLOW_INSECURE === 'true' + ? [openIdClient.allowInsecureRequests] + : [], + } + ); + } catch (error) { + logger.error('Failed OIDC provider discovery', { + label: 'Auth', + provider: provider.name, + ip: req.ip, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return next({ + status: 500, + error: ApiErrorCode.OidcProviderDiscoveryFailed, + }); + } + + const pkceCodeVerifier = req.cookies['oidc-code-verifier']; + const expectedState = req.cookies['oidc-state']; + + const redirectUrl = new URL(req.body.callbackUrl); + + let tokens: openIdClient.TokenEndpointResponse & + openIdClient.TokenEndpointResponseHelpers; + try { + tokens = await openIdClient.authorizationCodeGrant(config, redirectUrl, { + pkceCodeVerifier, + expectedState, + }); + } catch (error) { + logger.error('Failed OIDC authorization code grant', { + label: 'Auth', + provider: provider.name, + ip: req.ip, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return next({ + status: 500, + error: ApiErrorCode.OidcAuthorizationFailed, + }); + } + + const claims = tokens.claims(); + if (claims == null) { + logger.info('Failed OIDC login attempt', { + cause: + 'Missing ID token in response. Provider does not support OpenID Connect.', + ip: req.ip, + provider: provider.name, + }); + + return next({ + status: 500, + error: ApiErrorCode.OidcAuthorizationFailed, + }); + } + + const requiredClaims = (provider.requiredClaims ?? '') + .split(' ') + .filter((s) => !!s); + + let fullUserInfo: openIdClient.IDToken & openIdClient.UserInfoResponse = + claims; + + if (config.serverMetadata().userinfo_endpoint) { + try { + const userInfo = await openIdClient.fetchUserInfo( + config, + tokens.access_token, + claims.sub + ); + fullUserInfo = { ...claims, ...userInfo }; + } catch (error) { + logger.error('Failed to fetch OIDC user info', { + label: 'Auth', + provider: provider.name, + ip: req.ip, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return next({ + status: 500, + error: ApiErrorCode.OidcAuthorizationFailed, + }); + } + } + + // Validate that user meets required claims + const hasRequiredClaims = requiredClaims.every((claim) => { + const value = fullUserInfo[claim]; + return value === true; + }); + + if (!hasRequiredClaims) { + logger.info('Failed OIDC login attempt', { + cause: 'Failed to validate required claims', + ip: req.ip, + requiredClaims: provider.requiredClaims, + }); + return next({ + status: 403, + error: ApiErrorCode.Unauthorized, + }); + } + + // Map identifier to linked account + const userRepository = getRepository(User); + const linkedAccountsRepository = getRepository(LinkedAccount); + + const linkedAccount = await linkedAccountsRepository.findOne({ + relations: { + user: true, + }, + where: { + provider: provider.slug, + sub: fullUserInfo.sub, + }, + }); + let user = linkedAccount?.user; + + // If there is already a user logged in, handle account linking + if (req.user != null) { + // Check if this OIDC account is already linked to a different user + if (linkedAccount != null && linkedAccount.user.id !== req.user.id) { + logger.warn('Failed OIDC account linking attempt', { + cause: 'Account is already linked to a different user', + ip: req.ip, + provider: provider.slug, + currentUserId: req.user.id, + linkedUserId: linkedAccount.user.id, + }); + return next({ + status: 409, + error: ApiErrorCode.OidcAccountAlreadyLinked, + }); + } + + // If no linked account exists, link the account + if (linkedAccount == null) { + const newLinkedAccount = new LinkedAccount({ + user: req.user, + provider: provider.slug, + sub: fullUserInfo.sub, + username: fullUserInfo.preferred_username ?? req.user.displayName, + }); + + await linkedAccountsRepository.save(newLinkedAccount); + } + + return res.sendStatus(204); + } + + // Create user if one doesn't already exist + if (!user && fullUserInfo.email != null && provider.newUserLogin) { + // Check if a user with this email already exists + const existingUser = await userRepository.findOne({ + where: { email: fullUserInfo.email }, + }); + + if (existingUser) { + return next({ + status: 403, + error: ApiErrorCode.Unauthorized, + }); + } + + logger.info(`Creating user for ${fullUserInfo.email}`, { + ip: req.ip, + email: fullUserInfo.email, + }); + + const avatar = + fullUserInfo.picture ?? + gravatarUrl(fullUserInfo.email, { default: 'mm', size: 200 }); + user = new User({ + avatar: avatar, + username: fullUserInfo.preferred_username, + email: fullUserInfo.email, + permissions: settings.main.defaultPermissions, + plexToken: '', + userType: UserType.LOCAL, + }); + await userRepository.save(user); + + const linkedAccount = new LinkedAccount({ + user, + provider: provider.slug, + sub: fullUserInfo.sub, + username: fullUserInfo.preferred_username ?? fullUserInfo.email, + }); + await linkedAccountsRepository.save(linkedAccount); + + user.linkedAccounts = [linkedAccount]; + await userRepository.save(user); + } + + if (!user) { + logger.debug('Failed OIDC sign-up attempt', { + cause: provider.newUserLogin + ? 'User did not have an account, and was missing an associated email address.' + : 'User did not have an account, and new user login was disabled.', + }); + return next({ + status: provider.newUserLogin ? 400 : 403, + error: provider.newUserLogin + ? ApiErrorCode.OidcMissingEmail + : ApiErrorCode.Unauthorized, + }); + } + + // Set logged in session and return + if (req.session) { + req.session.userId = user.id; + } + + // Success! + return res.sendStatus(204); + } +); + authRoutes.post('/logout', async (req, res, next) => { try { const userId = req.session?.userId;