feat: support login with OpenID Connect

This commit is contained in:
Michael Thomas
2026-02-09 00:18:37 -05:00
parent f12a6b2994
commit 86f1b4452d
6 changed files with 494 additions and 2 deletions

View File

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

21
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string> = {
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;