From dd2755d6e2ba86f806518072719bf86cc14355b2 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 9 Feb 2026 00:09:23 -0500 Subject: [PATCH 1/8] feat: implement OpenID Connect provider settings --- package.json | 1 + pnpm-lock.yaml | 8 ++ seerr-api.yml | 124 ++++++++++++++++++++ server/interfaces/api/settingsInterfaces.ts | 2 + server/lib/settings/index.ts | 41 +++++++ server/routes/settings/index.ts | 69 +++++++++++ src/context/SettingsContext.tsx | 3 +- src/pages/_app.tsx | 1 + 8 files changed, 248 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 30eec037a2..4b2f67afc6 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", + "oauth4webapi": "^3.8.5", "openpgp": "6.3.0", "pg": "8.17.2", "pug": "3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0968c05748..440c17522e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: nodemailer: specifier: 7.0.12 version: 7.0.12 + oauth4webapi: + specifier: ^3.8.5 + version: 3.8.5 openpgp: specifier: 6.3.0 version: 6.3.0 @@ -7040,6 +7043,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'} @@ -17962,6 +17968,8 @@ snapshots: nullthrows@1.1.1: {} + oauth4webapi@3.8.5: {} + ob1@0.80.12: dependencies: flow-enums-runtime: 0.0.6 diff --git a/seerr-api.yml b/seerr-api.yml index 99ef16cc3c..3b67dbaba5 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -254,6 +254,41 @@ components: enableSpecialEpisodes: type: boolean example: false + OidcProvider: + type: object + properties: + slug: + type: string + readOnly: true + name: + type: string + issuerUrl: + type: string + clientId: + type: string + clientSecret: + type: string + logo: + type: string + requiredClaims: + type: string + scopes: + type: string + newUserLogin: + type: boolean + required: + - slug + - name + - issuerUrl + - clientId + - clientSecret + OidcSettings: + type: object + properties: + providers: + type: array + items: + $ref: '#/components/schemas/OidcProvider' NetworkSettings: type: object properties: @@ -2215,6 +2250,95 @@ paths: application/json: schema: $ref: '#/components/schemas/MainSettings' + /settings/oidc: + get: + summary: Get OpenID Connect settings + description: Retrieves all OpenID Connect settings in a JSON object. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/OidcSettings' + /settings/oidc/{provider}: + put: + summary: Update OpenID Connect provider + description: Updates an existing OpenID Connect provider with the provided values. + tags: + - settings + parameters: + - in: path + name: provider + required: true + schema: + type: string + description: Provider slug + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OidcProvider' + responses: + '200': + description: 'Radarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + delete: + summary: Delete OpenID Connect provider + description: Deletes an existing OpenID Connect provider based on the provider slug parameter. + tags: + - settings + parameters: + - in: path + name: provider + required: true + schema: + type: string + description: Provider slug + responses: + '200': + description: 'OpenID Connect provider deleted' + content: + application/json: + schema: + $ref: '#/components/schemas/OidcSettings' + /settings/oidc/test: + post: + summary: Test OpenID Connect provider + description: Tests OpenID Connect provider settings by attempting to discover its configuration from the issuer URL. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - issuerUrl + properties: + issuerUrl: + type: string + example: 'https://accounts.google.com' + responses: + '204': + description: 'Provider configuration successfully discovered' + '500': + description: 'Failed to connect to provider' + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Failed to connect to provider' /settings/network: get: summary: Get network settings diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index ea08d4e61d..5e64c53d15 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -1,3 +1,4 @@ +import type { PublicOidcProvider } from '@server/lib/settings'; import type { DnsEntries, DnsStats } from 'dns-caching'; import type { PaginatedResponse } from './common'; @@ -48,6 +49,7 @@ export interface PublicSettingsResponse { emailEnabled: boolean; newPlexLogin: boolean; youtubeUrl: string; + openIdProviders: PublicOidcProvider[]; } export interface CacheItem { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 779413ca66..bdcc31848c 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -55,6 +55,25 @@ export interface JellyfinSettings { serverId: string; apiKey: string; } + +export type OidcProvider = { + slug: string; + name: string; + issuerUrl: string; + clientId: string; + clientSecret: string; + logo?: string; + requiredClaims?: string; + scopes?: string; + newUserLogin?: boolean; +}; + +export type PublicOidcProvider = Pick; + +export interface OidcSettings { + providers: OidcProvider[]; +} + export interface TautulliSettings { hostname?: string; port?: number; @@ -142,6 +161,7 @@ export interface MainSettings { hideBlocklisted: boolean; localLogin: boolean; mediaServerLogin: boolean; + oidcLogin: boolean; newPlexLogin: boolean; discoverRegion: string; streamingRegion: string; @@ -211,6 +231,7 @@ interface FullPublicSettings extends PublicSettings { userEmailRequired: boolean; newPlexLogin: boolean; youtubeUrl: string; + openIdProviders: PublicOidcProvider[]; } export interface NotificationAgentConfig { @@ -365,6 +386,7 @@ export interface AllSettings { main: MainSettings; plex: PlexSettings; jellyfin: JellyfinSettings; + oidc: OidcSettings; tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; @@ -403,6 +425,7 @@ class Settings { hideBlocklisted: false, localLogin: true, mediaServerLogin: true, + oidcLogin: false, newPlexLogin: true, discoverRegion: '', streamingRegion: '', @@ -434,6 +457,9 @@ class Settings { serverId: '', apiKey: '', }, + oidc: { + providers: [], + }, tautulli: {}, metadataSettings: { tv: MetadataProviderType.TMDB, @@ -638,6 +664,14 @@ class Settings { this.data.jellyfin = mergeSettings(this.data.jellyfin, data); } + get oidc(): OidcSettings { + return this.data.oidc; + } + + set oidc(data: OidcSettings) { + this.data.oidc = data; + } + get tautulli(): TautulliSettings { return this.data.tautulli; } @@ -713,6 +747,13 @@ class Settings { this.data.notifications.agents.email.options.userEmailRequired, newPlexLogin: this.data.main.newPlexLogin, youtubeUrl: this.data.main.youtubeUrl, + openIdProviders: this.data.main.oidcLogin + ? this.data.oidc.providers.map((p) => ({ + slug: p.slug, + name: p.name, + logo: p.logo, + })) + : [], }; } diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 12b5746595..dae4c06080 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -36,6 +36,7 @@ import rateLimit from 'express-rate-limit'; import fs from 'fs'; import { escapeRegExp, merge, omit, set, sortBy } from 'lodash'; import { rescheduleJob } from 'node-schedule'; +import * as oauth from 'oauth4webapi'; import path from 'path'; import semver from 'semver'; import { URL } from 'url'; @@ -109,6 +110,74 @@ settingsRoutes.post('/main/regenerate', async (req, res, next) => { return res.status(200).json(filteredMainSettings(req.user, main)); }); +settingsRoutes.get('/oidc', async (req, res) => { + const settings = getSettings(); + + return res.status(200).json(settings.oidc); +}); + +settingsRoutes.put('/oidc/:slug', async (req, res) => { + const settings = getSettings(); + let provider = settings.oidc.providers.findIndex( + (p) => p.slug === req.params.slug + ); + + if (provider !== -1) { + Object.assign(settings.oidc.providers[provider], req.body); + } else { + settings.oidc.providers.push({ slug: req.params.slug, ...req.body }); + provider = settings.oidc.providers.length - 1; + } + + await settings.save(); + + return res.status(200).json(settings.oidc.providers[provider]); +}); + +settingsRoutes.delete('/oidc/:slug', async (req, res) => { + const settings = getSettings(); + const provider = settings.oidc.providers.findIndex( + (p) => p.slug === req.params.slug + ); + + if (provider === -1) + return res.status(404).json({ message: 'Provider not found' }); + + settings.oidc.providers.splice(provider, 1); + await settings.save(); + + return res.status(200).json(settings.oidc); +}); + +settingsRoutes.post('/oidc/test', async (req, res) => { + const { issuerUrl } = req.body as { issuerUrl: string }; + + try { + const issuer = new URL(issuerUrl); + const discoveryResponse = await oauth.discoveryRequest( + issuer, + process.env.OIDC_ALLOW_INSECURE === 'true' + ? { [oauth.allowInsecureRequests]: true } + : {} + ); + await oauth.processDiscoveryResponse(issuer, discoveryResponse); + + return res.status(204).send(); + } catch (error) { + logger.error('OIDC provider test failed', { + label: 'API', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }); + + return res.status(500).json({ + message: + error instanceof Error + ? error.message + : 'Failed to connect to provider', + }); + } +}); + settingsRoutes.get('/plex', (_req, res) => { const settings = getSettings(); diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index f6c9d3870c..a8de287620 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -8,7 +8,7 @@ export interface SettingsContextProps { children?: React.ReactNode; } -const defaultSettings = { +const defaultSettings: PublicSettingsResponse = { initialized: false, applicationTitle: 'Seerr', applicationUrl: '', @@ -31,6 +31,7 @@ const defaultSettings = { emailEnabled: false, newPlexLogin: true, youtubeUrl: '', + openIdProviders: [], }; export const SettingsContext = React.createContext({ diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index aaa80c709b..91372c1035 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -254,6 +254,7 @@ CoreApp.getInitialProps = async (initialProps) => { emailEnabled: false, newPlexLogin: true, youtubeUrl: '', + openIdProviders: [], }; if (ctx.res) { From d58e79a804292705424048cf61d7b8d4bb82c88a Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 9 Feb 2026 00:17:02 -0500 Subject: [PATCH 2/8] feat: support linking OpenID Connect accounts to users --- seerr-api.yml | 68 ++++++++++++++++++ server/entity/LinkedAccount.ts | 27 +++++++ server/entity/User.ts | 4 ++ .../interfaces/api/userSettingsInterfaces.ts | 13 +++- .../1742858617989-AddLinkedAccount.ts | 21 ++++++ .../sqlite/1742858484395-AddLinkedAccounts.ts | 15 ++++ server/routes/settings/index.ts | 16 ++++- server/routes/user/usersettings.ts | 71 ++++++++++++++++++- 8 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 server/entity/LinkedAccount.ts create mode 100644 server/migration/postgres/1742858617989-AddLinkedAccount.ts create mode 100644 server/migration/sqlite/1742858484395-AddLinkedAccounts.ts diff --git a/seerr-api.yml b/seerr-api.yml index 3b67dbaba5..2c7bcafa5c 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -282,6 +282,20 @@ components: - issuerUrl - clientId - clientSecret + PublicOidcProvider: + type: object + readOnly: true + properties: + slug: + type: string + name: + type: string + logo: + type: string + required: + - slug + - name + - logo OidcSettings: type: object properties: @@ -289,6 +303,15 @@ components: type: array items: $ref: '#/components/schemas/OidcProvider' + LinkedAccount: + type: object + properties: + id: + type: number + username: + type: string + provider: + $ref: '#/components/schemas/PublicOidcProvider' NetworkSettings: type: object properties: @@ -5059,6 +5082,29 @@ paths: responses: '204': description: User password updated + /user/{userId}/settings/linked-accounts: + get: + summary: Lists the user's linked OpenID Connect accounts + description: Lists the user's linked OpenID Connect accounts + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: List of linked accounts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LinkedAccount' + '403': + description: Invalid credentials /user/{userId}/settings/linked-accounts/plex: post: summary: Link the provided Plex account to the current user @@ -5157,6 +5203,28 @@ paths: description: Unlink request invalid '404': description: User does not exist + /user/{userId}/settings/linked-accounts/{acctId}: + delete: + summary: Remove a linked account for a user + description: Removes the linked account with the given ID for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + - in: path + name: acctId + required: true + schema: + type: number + responses: + '204': + description: Unlinking account succeeded + '404': + description: User or linked account does not exist /user/{userId}/settings/notifications: get: summary: Get notification settings for a user diff --git a/server/entity/LinkedAccount.ts b/server/entity/LinkedAccount.ts new file mode 100644 index 0000000000..fba4edbc8a --- /dev/null +++ b/server/entity/LinkedAccount.ts @@ -0,0 +1,27 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from './User'; + +@Entity('linked_accounts') +export class LinkedAccount { + constructor(options: Omit) { + Object.assign(this, options); + } + + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.linkedAccounts, { onDelete: 'CASCADE' }) + user: User; + + /** Slug of the OIDC provider. */ + @Column({ type: 'varchar', length: 255 }) + provider: string; + + /** Unique ID from the OAuth provider */ + @Column({ type: 'varchar', length: 255 }) + sub: string; + + /** Account username from the OAuth provider */ + @Column() + username: string; +} diff --git a/server/entity/User.ts b/server/entity/User.ts index 1255ca895e..03bc14d53a 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -25,6 +25,7 @@ import { RelationCount, } from 'typeorm'; import Issue from './Issue'; +import { LinkedAccount } from './LinkedAccount'; import { MediaRequest } from './MediaRequest'; import SeasonRequest from './SeasonRequest'; import { UserPushSubscription } from './UserPushSubscription'; @@ -100,6 +101,9 @@ export class User { @Column({ type: 'varchar', nullable: true, select: false }) public plexToken?: string | null; + @OneToMany(() => LinkedAccount, (link) => link.user) + public linkedAccounts: LinkedAccount[]; + @Column({ type: 'integer', default: 0 }) public permissions = 0; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 327764618e..40e82ba3d8 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -1,4 +1,7 @@ -import type { NotificationAgentKey } from '@server/lib/settings'; +import type { + NotificationAgentKey, + PublicOidcProvider, +} from '@server/lib/settings'; export interface UserSettingsGeneralResponse { username?: string; @@ -39,3 +42,11 @@ export interface UserSettingsNotificationsResponse { webPushEnabled?: boolean; notificationTypes: Partial; } + +export type UserSettingsLinkedAccount = { + id: number; + username: string; + provider: PublicOidcProvider; +}; + +export type UserSettingsLinkedAccountResponse = UserSettingsLinkedAccount[]; diff --git a/server/migration/postgres/1742858617989-AddLinkedAccount.ts b/server/migration/postgres/1742858617989-AddLinkedAccount.ts new file mode 100644 index 0000000000..c24acc3888 --- /dev/null +++ b/server/migration/postgres/1742858617989-AddLinkedAccount.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLinkedAccount1742858617989 implements MigrationInterface { + name = 'AddLinkedAccount1742858617989'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "linked_accounts" ("id" SERIAL NOT NULL, "provider" character varying(255) NOT NULL, "sub" character varying(255) NOT NULL, "username" character varying NOT NULL, "userId" integer, CONSTRAINT "PK_445bf7a50aeeb7f0084052935a6" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `ALTER TABLE "linked_accounts" ADD CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "linked_accounts" DROP CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64"` + ); + await queryRunner.query(`DROP TABLE "linked_accounts"`); + } +} diff --git a/server/migration/sqlite/1742858484395-AddLinkedAccounts.ts b/server/migration/sqlite/1742858484395-AddLinkedAccounts.ts new file mode 100644 index 0000000000..6161394fee --- /dev/null +++ b/server/migration/sqlite/1742858484395-AddLinkedAccounts.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLinkedAccounts1742858484395 implements MigrationInterface { + name = 'AddLinkedAccounts1742858484395'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "linked_accounts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "provider" varchar(255) NOT NULL, "sub" varchar(255) NOT NULL, "username" varchar NOT NULL, "userId" integer, CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "linked_accounts"`); + } +} diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index dae4c06080..4801c1a38d 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -4,6 +4,7 @@ import PlexTvAPI from '@server/api/plextv'; import TautulliAPI from '@server/api/tautulli'; import { ApiErrorCode } from '@server/constants/error'; import { getRepository } from '@server/datasource'; +import { LinkedAccount } from '@server/entity/LinkedAccount'; import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; @@ -136,9 +137,8 @@ settingsRoutes.put('/oidc/:slug', async (req, res) => { settingsRoutes.delete('/oidc/:slug', async (req, res) => { const settings = getSettings(); - const provider = settings.oidc.providers.findIndex( - (p) => p.slug === req.params.slug - ); + const { slug } = req.params; + const provider = settings.oidc.providers.findIndex((p) => p.slug === slug); if (provider === -1) return res.status(404).json({ message: 'Provider not found' }); @@ -146,6 +146,16 @@ settingsRoutes.delete('/oidc/:slug', async (req, res) => { settings.oidc.providers.splice(provider, 1); await settings.save(); + // Try to clean up orphaned linked accounts + try { + await getRepository(LinkedAccount).delete({ provider: slug }); + } catch (e) { + logger.error('Linked account cleanup failed', { + label: 'API', + errorMessage: e.message, + }); + } + return res.status(200).json(settings.oidc); }); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index bd5af746f5..62993888bf 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -4,10 +4,13 @@ import { ApiErrorCode } from '@server/constants/error'; import { MediaServerType } 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 { UserSettings } from '@server/entity/UserSettings'; import type { UserSettingsGeneralResponse, + UserSettingsLinkedAccount, + UserSettingsLinkedAccountResponse, UserSettingsNotificationsResponse, } from '@server/interfaces/api/userSettingsInterfaces'; import { Permission } from '@server/lib/permissions'; @@ -22,7 +25,7 @@ import { } from '@server/utils/profileMiddleware'; import { Router } from 'express'; import net from 'net'; -import { Not } from 'typeorm'; +import { In, Not, type FindOptionsWhere } from 'typeorm'; import { canMakePermissionsChange } from '.'; const userSettingsRoutes = Router({ mergeParams: true }); @@ -518,6 +521,72 @@ userSettingsRoutes.delete<{ id: string }>( } ); +userSettingsRoutes.get<{ id: string }, UserSettingsLinkedAccountResponse>( + '/linked-accounts', + isOwnProfileOrAdmin(), + async (req, res) => { + const settings = getSettings(); + if (!settings.main.oidcLogin) { + // don't show any linked accounts if OIDC login is disabled + return res.status(200).json([]); + } + + const activeProviders = settings.oidc.providers.map((p) => p.slug); + const linkedAccountsRepository = getRepository(LinkedAccount); + + const linkedAccounts = await linkedAccountsRepository.find({ + relations: { + user: true, + }, + where: { + provider: In(activeProviders), + user: { + id: Number(req.params.id), + }, + }, + }); + + const linkedAccountInfo = linkedAccounts.map((acc) => { + const provider = settings.oidc.providers.find( + (p) => p.slug === acc.provider + )!; + + return { + id: acc.id, + username: acc.username, + provider: { + slug: provider.slug, + name: provider.name, + logo: provider.logo, + }, + } satisfies UserSettingsLinkedAccount; + }); + + return res.status(200).json(linkedAccountInfo); + } +); + +userSettingsRoutes.delete<{ id: string; acctId: string }>( + '/linked-accounts/:acctId', + isOwnProfileOrAdmin(), + async (req, res) => { + const linkedAccountsRepository = getRepository(LinkedAccount); + const condition: FindOptionsWhere = { + id: Number(req.params.acctId), + user: { + id: Number(req.params.id), + }, + }; + + if (await linkedAccountsRepository.exist({ where: condition })) { + await linkedAccountsRepository.delete(condition); + return res.status(204).send(); + } else { + return res.status(404).send(); + } + } +); + userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( '/notifications', isOwnProfileOrAdmin(), From 9e852c3519c9dca6e871efc1416afe8c4b105000 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Mon, 9 Feb 2026 00:18:37 -0500 Subject: [PATCH 3/8] feat: support login with OpenID Connect --- package.json | 1 + pnpm-lock.yaml | 16 ++ seerr-api.yml | 105 ++++++++++++ server/constants/error.ts | 4 + server/index.ts | 8 +- server/routes/auth.ts | 350 +++++++++++++++++++++++++++++++++++++- 6 files changed, 482 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4b2f67afc6..eddc1bc8b9 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "node-schedule": "2.1.1", "nodemailer": "7.0.12", "oauth4webapi": "^3.8.5", + "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 440c17522e..3899cfed16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: oauth4webapi: specifier: ^3.8.5 version: 3.8.5 + openid-client: + specifier: ^6.8.2 + version: 6.8.2 openpgp: specifier: 6.3.0 version: 6.3.0 @@ -6104,6 +6107,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==} @@ -7123,6 +7129,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'} @@ -16754,6 +16763,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: {} @@ -18053,6 +18064,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 2c7bcafa5c..6d1fb2f46b 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4159,6 +4159,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 daa02f1a1c..60fa7c98b5 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 1ee4722c3d..f6684689db 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 ee352ca926..ba9d6de93f 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,351 @@ 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, 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; From f6ac3864bd1279f7c0fff77190f032353aedc93e Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Wed, 18 Feb 2026 17:35:48 -0500 Subject: [PATCH 4/8] test(auth): add tests for OpenID Connect endpoints --- package.json | 2 + pnpm-lock.yaml | 37 ++- server/lib/settings/index.ts | 486 ++++++++++++++++++----------------- server/routes/auth.test.ts | 389 +++++++++++++++++++++++++++- 4 files changed, 671 insertions(+), 243 deletions(-) diff --git a/package.json b/package.json index eddc1bc8b9..231e56a9d2 100644 --- a/package.json +++ b/package.json @@ -166,9 +166,11 @@ "eslint-plugin-prettier": "4.2.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-hooks": "7.0.1", + "fetch-mock": "^12.6.0", "globals": "^17.3.0", "husky": "8.0.3", "jiti": "^2.6.1", + "jose": "^6.1.3", "lint-staged": "13.1.2", "nodemon": "3.1.11", "postcss": "^8.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3899cfed16..243b95efc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -403,6 +403,9 @@ importers: eslint-plugin-react-hooks: specifier: 7.0.1 version: 7.0.1(eslint@9.39.3(jiti@2.6.1)) + fetch-mock: + specifier: ^12.6.0 + version: 12.6.0 globals: specifier: ^17.3.0 version: 17.4.0 @@ -412,6 +415,9 @@ importers: jiti: specifier: ^2.6.1 version: 2.6.1 + jose: + specifier: ^6.1.3 + version: 6.1.3 lint-staged: specifier: 13.1.2 version: 13.1.2(enquirer@2.4.1) @@ -3157,6 +3163,9 @@ packages: '@types/express@4.17.17': resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==} + '@types/glob-to-regexp@0.4.4': + resolution: {integrity: sha512-nDKoaKJYbnn1MZxUY0cA1bPmmgZbg0cTq7Rh13d0KWYNOiKbqoR+2d89SnRPszGh7ROzSwZ/GOjZ4jPbmmZ6Eg==} + '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} @@ -5117,6 +5126,10 @@ packages: fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + fetch-mock@12.6.0: + resolution: {integrity: sha512-oAy0OqAvjAvduqCeWveBix7LLuDbARPqZZ8ERYtBcCURA3gy7EALA3XWq0tCNxsSg+RmmJqyaeeZlOCV9abv6w==} + engines: {node: '>=18.11.0'} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -5346,6 +5359,9 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -7926,6 +7942,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + regexpu-core@6.4.0: resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} engines: {node: '>=4'} @@ -13071,6 +13091,8 @@ snapshots: '@types/qs': 6.9.15 '@types/serve-static': 1.15.7 + '@types/glob-to-regexp@0.4.4': {} + '@types/hast@2.3.10': dependencies: '@types/unist': 2.0.10 @@ -15448,6 +15470,13 @@ snapshots: fecha@4.2.3: {} + fetch-mock@12.6.0: + dependencies: + '@types/glob-to-regexp': 0.4.4 + dequal: 2.0.3 + glob-to-regexp: 0.4.1 + regexparam: 3.0.0 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -15723,6 +15752,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + glob@10.5.0: dependencies: foreground-child: 3.2.1 @@ -17846,7 +17877,7 @@ snapshots: node-dir@0.1.17: dependencies: - minimatch: 3.1.2 + minimatch: 3.1.5 node-fetch@2.7.0(encoding@0.1.13): dependencies: @@ -18985,6 +19016,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + regexparam@3.0.0: {} + regexpu-core@6.4.0: dependencies: regenerate: 1.4.2 @@ -19796,7 +19829,7 @@ snapshots: dependencies: '@istanbuljs/schema': 0.1.3 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 3.1.5 optional: true text-extensions@1.9.0: {} diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index bdcc31848c..9f2c723298 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -7,8 +7,12 @@ import { mergeWith } from 'lodash'; import path from 'path'; import webpush from 'web-push'; +type DeepPartial = { + [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; +}; + // Prevents stale array entries when incoming data has fewer elements -const mergeSettings = (current: T, incoming: Partial): T => +const mergeSettings = (current: T, incoming: DeepPartial): T => mergeWith({}, current, incoming, (_objValue, srcValue) => Array.isArray(srcValue) ? srcValue : undefined ) as T; @@ -407,237 +411,8 @@ class Settings { private saveLock: Promise = Promise.resolve(); constructor(initialSettings?: AllSettings) { - this.data = { - clientId: randomUUID(), - vapidPrivate: '', - vapidPublic: '', - main: { - apiKey: '', - applicationTitle: 'Seerr', - applicationUrl: '', - cacheImages: false, - defaultPermissions: Permission.REQUEST, - defaultQuotas: { - movie: {}, - tv: {}, - }, - hideAvailable: false, - hideBlocklisted: false, - localLogin: true, - mediaServerLogin: true, - oidcLogin: false, - newPlexLogin: true, - discoverRegion: '', - streamingRegion: '', - originalLanguage: '', - blocklistedTags: '', - blocklistedTagsLimit: 50, - mediaServerType: MediaServerType.NOT_CONFIGURED, - partialRequestsEnabled: true, - enableSpecialEpisodes: false, - locale: 'en', - youtubeUrl: '', - }, - plex: { - name: '', - ip: '', - port: 32400, - useSsl: false, - libraries: [], - }, - jellyfin: { - name: '', - ip: '', - port: 8096, - useSsl: false, - urlBase: '', - externalHostname: '', - jellyfinForgotPasswordUrl: '', - libraries: [], - serverId: '', - apiKey: '', - }, - oidc: { - providers: [], - }, - tautulli: {}, - metadataSettings: { - tv: MetadataProviderType.TMDB, - anime: MetadataProviderType.TMDB, - }, - radarr: [], - sonarr: [], - public: { - initialized: false, - }, - notifications: { - agents: { - email: { - enabled: false, - embedPoster: true, - options: { - userEmailRequired: false, - emailFrom: '', - smtpHost: '', - smtpPort: 587, - secure: false, - ignoreTls: false, - requireTls: false, - allowSelfSigned: false, - senderName: 'Seerr', - }, - }, - discord: { - enabled: false, - embedPoster: true, - types: 0, - options: { - webhookUrl: '', - webhookRoleId: '', - enableMentions: true, - }, - }, - slack: { - enabled: false, - embedPoster: true, - types: 0, - options: { - webhookUrl: '', - }, - }, - telegram: { - enabled: false, - embedPoster: true, - types: 0, - options: { - botAPI: '', - chatId: '', - messageThreadId: '', - sendSilently: false, - }, - }, - pushbullet: { - enabled: false, - embedPoster: false, - types: 0, - options: { - accessToken: '', - }, - }, - pushover: { - enabled: false, - embedPoster: true, - types: 0, - options: { - accessToken: '', - userToken: '', - sound: '', - }, - }, - webhook: { - enabled: false, - embedPoster: true, - types: 0, - options: { - webhookUrl: '', - jsonPayload: - 'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', - }, - }, - webpush: { - enabled: false, - embedPoster: true, - options: {}, - }, - gotify: { - enabled: false, - embedPoster: false, - types: 0, - options: { - url: '', - token: '', - priority: 0, - }, - }, - ntfy: { - enabled: false, - embedPoster: true, - types: 0, - options: { - url: '', - topic: '', - priority: 3, - }, - }, - }, - }, - jobs: { - 'plex-recently-added-scan': { - schedule: '0 */5 * * * *', - }, - 'plex-full-scan': { - schedule: '0 0 3 * * *', - }, - 'plex-watchlist-sync': { - schedule: '0 */3 * * * *', - }, - 'plex-refresh-token': { - schedule: '0 0 5 * * *', - }, - 'radarr-scan': { - schedule: '0 0 4 * * *', - }, - 'sonarr-scan': { - schedule: '0 30 4 * * *', - }, - 'availability-sync': { - schedule: '0 0 5 * * *', - }, - 'download-sync': { - schedule: '0 * * * * *', - }, - 'download-sync-reset': { - schedule: '0 0 1 * * *', - }, - 'jellyfin-recently-added-scan': { - schedule: '0 */5 * * * *', - }, - 'jellyfin-full-scan': { - schedule: '0 0 3 * * *', - }, - 'image-cache-cleanup': { - schedule: '0 0 5 * * *', - }, - 'process-blocklisted-tags': { - schedule: '0 30 1 */7 * *', - }, - }, - network: { - csrfProtection: false, - forceIpv4First: false, - trustProxy: false, - proxy: { - enabled: false, - hostname: '', - port: 8080, - useSsl: false, - user: '', - password: '', - bypassFilter: '', - bypassLocalAddresses: true, - }, - dnsCache: { - enabled: false, - forceMinTtl: 0, - forceMaxTtl: -1, - }, - apiRequestTimeout: 10000, - }, - migrations: [], - }; - if (initialSettings) { - this.data = mergeSettings(this.data, initialSettings); - } + this.reset(); + if (initialSettings) this.load(initialSettings); } get main(): MainSettings { @@ -825,11 +600,23 @@ class Settings { * values */ public async load( - overrideSettings?: AllSettings, + overrideSettings?: DeepPartial, + raw?: false + ): Promise; + public async load( + overrideSettings: AllSettings | undefined, + raw: true + ): Promise; + public async load( + overrideSettings?: DeepPartial, raw = false ): Promise { if (overrideSettings) { - this.data = overrideSettings; + if (raw) { + this.data = overrideSettings as AllSettings; + } else { + this.data = mergeSettings(this.data, overrideSettings); + } return this; } @@ -888,6 +675,237 @@ class Settings { return savePromise; } + + public reset() { + this.data = { + clientId: randomUUID(), + vapidPrivate: '', + vapidPublic: '', + main: { + apiKey: '', + applicationTitle: 'Seerr', + applicationUrl: '', + cacheImages: false, + defaultPermissions: Permission.REQUEST, + defaultQuotas: { + movie: {}, + tv: {}, + }, + hideAvailable: false, + hideBlocklisted: false, + localLogin: true, + mediaServerLogin: true, + oidcLogin: false, + newPlexLogin: true, + discoverRegion: '', + streamingRegion: '', + originalLanguage: '', + blocklistedTags: '', + blocklistedTagsLimit: 50, + mediaServerType: MediaServerType.NOT_CONFIGURED, + partialRequestsEnabled: true, + enableSpecialEpisodes: false, + locale: 'en', + youtubeUrl: '', + }, + plex: { + name: '', + ip: '', + port: 32400, + useSsl: false, + libraries: [], + }, + jellyfin: { + name: '', + ip: '', + port: 8096, + useSsl: false, + urlBase: '', + externalHostname: '', + jellyfinForgotPasswordUrl: '', + libraries: [], + serverId: '', + apiKey: '', + }, + oidc: { + providers: [], + }, + tautulli: {}, + metadataSettings: { + tv: MetadataProviderType.TMDB, + anime: MetadataProviderType.TMDB, + }, + radarr: [], + sonarr: [], + public: { + initialized: false, + }, + notifications: { + agents: { + email: { + enabled: false, + embedPoster: true, + options: { + userEmailRequired: false, + emailFrom: '', + smtpHost: '', + smtpPort: 587, + secure: false, + ignoreTls: false, + requireTls: false, + allowSelfSigned: false, + senderName: 'Seerr', + }, + }, + discord: { + enabled: false, + embedPoster: true, + types: 0, + options: { + webhookUrl: '', + webhookRoleId: '', + enableMentions: true, + }, + }, + slack: { + enabled: false, + embedPoster: true, + types: 0, + options: { + webhookUrl: '', + }, + }, + telegram: { + enabled: false, + embedPoster: true, + types: 0, + options: { + botAPI: '', + chatId: '', + messageThreadId: '', + sendSilently: false, + }, + }, + pushbullet: { + enabled: false, + embedPoster: false, + types: 0, + options: { + accessToken: '', + }, + }, + pushover: { + enabled: false, + embedPoster: true, + types: 0, + options: { + accessToken: '', + userToken: '', + sound: '', + }, + }, + webhook: { + enabled: false, + embedPoster: true, + types: 0, + options: { + webhookUrl: '', + jsonPayload: + 'IntcbiAgXCJub3RpZmljYXRpb25fdHlwZVwiOiBcInt7bm90aWZpY2F0aW9uX3R5cGV9fVwiLFxuICBcImV2ZW50XCI6IFwie3tldmVudH19XCIsXG4gIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gIFwibWVzc2FnZVwiOiBcInt7bWVzc2FnZX19XCIsXG4gIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgXCJ7e21lZGlhfX1cIjoge1xuICAgIFwibWVkaWFfdHlwZVwiOiBcInt7bWVkaWFfdHlwZX19XCIsXG4gICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgXCJzdGF0dXM0a1wiOiBcInt7bWVkaWFfc3RhdHVzNGt9fVwiXG4gIH0sXG4gIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgXCJyZXF1ZXN0ZWRCeV9lbWFpbFwiOiBcInt7cmVxdWVzdGVkQnlfZW1haWx9fVwiLFxuICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVxdWVzdGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcInJlcXVlc3RlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tyZXF1ZXN0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2lzc3VlfX1cIjoge1xuICAgIFwiaXNzdWVfaWRcIjogXCJ7e2lzc3VlX2lkfX1cIixcbiAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgIFwiaXNzdWVfc3RhdHVzXCI6IFwie3tpc3N1ZV9zdGF0dXN9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9lbWFpbFwiOiBcInt7cmVwb3J0ZWRCeV9lbWFpbH19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcInJlcG9ydGVkQnlfYXZhdGFyXCI6IFwie3tyZXBvcnRlZEJ5X2F2YXRhcn19XCIsXG4gICAgXCJyZXBvcnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc19kaXNjb3JkSWR9fVwiLFxuICAgIFwicmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZFwiOiBcInt7cmVwb3J0ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2NvbW1lbnR9fVwiOiB7XG4gICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgXCJjb21tZW50ZWRCeV9lbWFpbFwiOiBcInt7Y29tbWVudGVkQnlfZW1haWx9fVwiLFxuICAgIFwiY29tbWVudGVkQnlfdXNlcm5hbWVcIjogXCJ7e2NvbW1lbnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX2Rpc2NvcmRJZFwiOiBcInt7Y29tbWVudGVkQnlfc2V0dGluZ3NfZGlzY29yZElkfX1cIixcbiAgICBcImNvbW1lbnRlZEJ5X3NldHRpbmdzX3RlbGVncmFtQ2hhdElkXCI6IFwie3tjb21tZW50ZWRCeV9zZXR0aW5nc190ZWxlZ3JhbUNoYXRJZH19XCJcbiAgfSxcbiAgXCJ7e2V4dHJhfX1cIjogW11cbn0i', + }, + }, + webpush: { + enabled: false, + embedPoster: true, + options: {}, + }, + gotify: { + enabled: false, + embedPoster: false, + types: 0, + options: { + url: '', + token: '', + priority: 0, + }, + }, + ntfy: { + enabled: false, + embedPoster: true, + types: 0, + options: { + url: '', + topic: '', + priority: 3, + }, + }, + }, + }, + jobs: { + 'plex-recently-added-scan': { + schedule: '0 */5 * * * *', + }, + 'plex-full-scan': { + schedule: '0 0 3 * * *', + }, + 'plex-watchlist-sync': { + schedule: '0 */3 * * * *', + }, + 'plex-refresh-token': { + schedule: '0 0 5 * * *', + }, + 'radarr-scan': { + schedule: '0 0 4 * * *', + }, + 'sonarr-scan': { + schedule: '0 30 4 * * *', + }, + 'availability-sync': { + schedule: '0 0 5 * * *', + }, + 'download-sync': { + schedule: '0 * * * * *', + }, + 'download-sync-reset': { + schedule: '0 0 1 * * *', + }, + 'jellyfin-recently-added-scan': { + schedule: '0 */5 * * * *', + }, + 'jellyfin-full-scan': { + schedule: '0 0 3 * * *', + }, + 'image-cache-cleanup': { + schedule: '0 0 5 * * *', + }, + 'process-blocklisted-tags': { + schedule: '0 30 1 */7 * *', + }, + }, + network: { + csrfProtection: false, + forceIpv4First: false, + trustProxy: false, + proxy: { + enabled: false, + hostname: '', + port: 8080, + useSsl: false, + user: '', + password: '', + bypassFilter: '', + bypassLocalAddresses: true, + }, + dnsCache: { + enabled: false, + forceMinTtl: 0, + forceMaxTtl: -1, + }, + apiRequestTimeout: 10000, + }, + migrations: [], + }; + } } let settings: Settings | undefined; diff --git a/server/routes/auth.test.ts b/server/routes/auth.test.ts index 0e7981a23d..68ce0af230 100644 --- a/server/routes/auth.test.ts +++ b/server/routes/auth.test.ts @@ -1,15 +1,27 @@ import assert from 'node:assert/strict'; -import { before, beforeEach, describe, it, mock } from 'node:test'; - +import { + after, + afterEach, + before, + beforeEach, + describe, + it, + mock, +} from 'node:test'; + +import { ApiErrorCode } from '@server/constants/error'; import { getRepository } from '@server/datasource'; +import { LinkedAccount } from '@server/entity/LinkedAccount'; import { User } from '@server/entity/User'; import PreparedEmail from '@server/lib/email'; import { getSettings } from '@server/lib/settings'; import { checkUser } from '@server/middleware/auth'; import { setupTestDb } from '@server/test/db'; +import cookieParser from 'cookie-parser'; import type { Express } from 'express'; import express from 'express'; import session from 'express-session'; +import fetchMock from 'fetch-mock'; import request from 'supertest'; import authRoutes from './auth'; @@ -22,6 +34,7 @@ let app: Express; function createApp() { const app = express(); app.use(express.json()); + app.use(cookieParser()); app.use( session({ secret: 'test-secret', @@ -31,19 +44,21 @@ function createApp() { ); app.use(checkUser); app.use('/auth', authRoutes); - // Error handler matching how next({ status, message }) calls are handled + // Error handler matching how next({ status, error, message }) calls are handled app.use( ( - err: { status?: number; message?: string }, + err: { status?: number; error?: string; message?: string }, _req: express.Request, res: express.Response, // We must provide a next function for the function signature here even though its not used // eslint-disable-next-line @typescript-eslint/no-unused-vars _next: express.NextFunction ) => { - res - .status(err.status ?? 500) - .json({ status: err.status ?? 500, message: err.message }); + res.status(err.status ?? 500).json({ + status: err.status ?? 500, + error: err.error, + message: err.message, + }); } ); return app; @@ -53,6 +68,10 @@ before(async () => { app = createApp(); }); +afterEach(() => { + getSettings().reset(); +}); + setupTestDb(); /** Create a supertest agent that is logged in as the given user. */ @@ -395,3 +414,359 @@ describe('POST /auth/reset-password/:guid', () => { assert.strictEqual(second.status, 500); }); }); + +describe('OpenID Connect', () => { + const OIDC_REDIRECT_URL = 'https://jellyseerr.example.com/login'; + + // Default claims for new user registration tests + const DEFAULT_CLAIMS = { + sub: 'new-user-sub', + email: 'newuser@example.com', + }; + + // Claims for existing seeded user (friend@seerr.dev) + const EXISTING_USER_CLAIMS = { + sub: 'friend-oidc-sub', + email: 'friend@seerr.dev', + }; + + function buildMockWellKnown(options?: { supportsPKCE?: boolean }) { + return { + issuer: 'https://example.com', + authorization_endpoint: 'https://example.com/oauth/authorize', + token_endpoint: 'https://example.com/oauth/token', + userinfo_endpoint: 'https://example.com/userinfo', + jwks_uri: 'https://example.com/.well-known/jwks.json', + response_types_supported: [ + 'code', + 'token', + 'id_token', + 'code token', + 'code id_token', + 'token id_token', + 'code token id_token', + 'none', + ], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + scopes_supported: ['openid', 'email', 'profile'], + ...(options?.supportsPKCE + ? { code_challenge_methods_supported: ['S256'] } + : {}), + }; + } + + /** + * Performs the login + callback flow and returns the callback response. + */ + async function performOidcCallback() { + const loginResponse = await request(app) + .get('/auth/oidc/login/test') + .set('Accept', 'application/json'); + + assert.strictEqual(loginResponse.status, 200); + + const redirectUrl = new URL(loginResponse.body.redirectUrl); + const state = redirectUrl.searchParams.get('state'); + + const cookies = loginResponse.get('Set-Cookie'); + assert.notStrictEqual(cookies, undefined); + const cookieHeader = cookies!.map((c) => c.split(';')[0]).join('; '); + + const callbackUrl = new URL(OIDC_REDIRECT_URL); + callbackUrl.searchParams.set('code', '123456'); + if (state) callbackUrl.searchParams.set('state', state); + + const response = await request(app) + .post('/auth/oidc/callback/test') + .set('Accept', 'application/json') + .set('Cookie', cookieHeader) + .send({ callbackUrl: callbackUrl.toString() }); + + return response; + } + + let mockJwks: { keys: object[] }; + let signIdToken: (claims?: Record) => Promise; + + before(async () => { + const { generateKeyPair, exportJWK, SignJWT } = await import('jose'); + const { privateKey, publicKey } = await generateKeyPair('RS256'); + const jwk = await exportJWK(publicKey); + jwk.kid = 'test-key'; + jwk.alg = 'RS256'; + jwk.use = 'sig'; + mockJwks = { keys: [jwk] }; + + signIdToken = (claims?: Record) => + new SignJWT({ ...DEFAULT_CLAIMS, ...claims }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuer('https://example.com') + .setAudience('jellyseerr') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); + }); + + beforeEach(() => { + // configure test provider settings + getSettings().load({ + main: { + oidcLogin: true, + applicationUrl: new URL(OIDC_REDIRECT_URL).origin, + }, + oidc: { + providers: [ + { + slug: 'test', + name: 'Test Provider', + clientId: 'jellyseerr', + clientSecret: 'abcdefg', + issuerUrl: 'https://example.com', + newUserLogin: true, + }, + ], + }, + }); + }); + + async function setupFetchMock(options?: { + supportsPKCE?: boolean; + userinfoResponse?: Record; + idTokenClaims?: Record; + }) { + const wellKnown = buildMockWellKnown(options); + const userinfo = options?.userinfoResponse ?? DEFAULT_CLAIMS; + const idTokenClaims = options?.idTokenClaims; + const idToken = await signIdToken(idTokenClaims); + const tokenResponse = { + access_token: 'abcdefg', + token_type: 'Bearer', + expires_in: 3600, + id_token: idToken, + }; + + fetchMock.mockGlobal(); + + fetchMock.route( + 'https://example.com/.well-known/openid-configuration', + wellKnown + ); + fetchMock.route('https://example.com/.well-known/jwks.json', mockJwks); + fetchMock.route('https://example.com/oauth/token', tokenResponse); + fetchMock.route('https://example.com/userinfo', userinfo); + } + + describe('without PKCE support (uses state)', function () { + before(async () => { + await setupFetchMock({ supportsPKCE: false }); + }); + + after(() => { + fetchMock.hardReset(); + }); + + it('login endpoint produces correct redirect URL', async function () { + const response = await request(app) + .get('/auth/oidc/login/test') + .set('Accept', 'application/json'); + + assert.match(response.headers['content-type'], /json/); + assert.strictEqual(response.status, 200); + assert.match( + response.body.redirectUrl, + /^https:\/\/example.com\/oauth\/authorize\?/ + ); + + const params = new URL(response.body.redirectUrl); + assert.strictEqual(params.searchParams.get('response_type'), 'code'); + assert.strictEqual(params.searchParams.get('client_id'), 'jellyseerr'); + assert.strictEqual( + params.searchParams.get('scope'), + 'openid profile email' + ); + assert.strictEqual( + params.searchParams.get('redirect_uri'), + OIDC_REDIRECT_URL + ); + assert.ok(params.searchParams.get('state')); + }); + + it('callback endpoint successfully authorizes existing user', async function () { + // Link the seeded friend user to the OIDC provider + const userRepo = getRepository(User); + const linkedAccountRepo = getRepository(LinkedAccount); + + const user = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + + const linkedAccount = new LinkedAccount({ + user, + provider: 'test', + sub: EXISTING_USER_CLAIMS.sub, + username: 'friend', + }); + await linkedAccountRepo.save(linkedAccount); + + // Setup mock to return the existing user's claims + await setupFetchMock({ + supportsPKCE: false, + idTokenClaims: EXISTING_USER_CLAIMS, + userinfoResponse: EXISTING_USER_CLAIMS, + }); + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 204); + }); + }); + + describe('with PKCE support (no state)', function () { + before(async () => { + await setupFetchMock({ supportsPKCE: true }); + }); + + after(() => { + fetchMock.hardReset(); + }); + + it('login endpoint does not include state parameter', async function () { + const response = await request(app) + .get('/auth/oidc/login/test') + .set('Accept', 'application/json'); + + assert.strictEqual(response.status, 200); + + const params = new URL(response.body.redirectUrl); + assert.strictEqual(params.searchParams.get('state'), null); + assert.ok(params.searchParams.get('code_challenge')); + assert.strictEqual( + params.searchParams.get('code_challenge_method'), + 'S256' + ); + }); + + it('callback endpoint successfully authorizes existing user', async function () { + // Link the seeded friend user to the OIDC provider + const userRepo = getRepository(User); + const linkedAccountRepo = getRepository(LinkedAccount); + + const user = await userRepo.findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + + const linkedAccount = new LinkedAccount({ + user, + provider: 'test', + sub: EXISTING_USER_CLAIMS.sub, + username: 'friend', + }); + await linkedAccountRepo.save(linkedAccount); + + // Setup mock to return the existing user's claims + await setupFetchMock({ + supportsPKCE: true, + idTokenClaims: EXISTING_USER_CLAIMS, + userinfoResponse: EXISTING_USER_CLAIMS, + }); + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 204); + }); + }); + + describe('new user registration', function () { + before(async () => { + await setupFetchMock({ supportsPKCE: false }); + }); + + after(() => { + fetchMock.hardReset(); + }); + + it('creates a new user when newUserLogin is enabled', async function () { + const settings = getSettings(); + settings.oidc.providers[0].newUserLogin = true; + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 204); + + // Verify user was created in the database + const userRepo = getRepository(User); + const createdUser = await userRepo.findOne({ + where: { email: DEFAULT_CLAIMS.email }, + }); + assert.notStrictEqual(createdUser, null); + assert.strictEqual(createdUser!.email, DEFAULT_CLAIMS.email); + + // Verify linked account was created + const linkedAccountRepo = getRepository(LinkedAccount); + const createdLink = await linkedAccountRepo.findOne({ + where: { provider: 'test', sub: DEFAULT_CLAIMS.sub }, + }); + assert.notStrictEqual(createdLink, null); + }); + + it('rejects new user when newUserLogin is disabled', async function () { + const settings = getSettings(); + settings.oidc.providers[0].newUserLogin = false; + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 403); + assert.strictEqual(response.body.error, ApiErrorCode.Unauthorized); + + // Verify no new user was created (only seeded users should exist) + const userRepo = getRepository(User); + const newUser = await userRepo.findOne({ + where: { email: DEFAULT_CLAIMS.email }, + }); + assert.strictEqual(newUser, null); + }); + + it('rejects new user when email is missing', async function () { + fetchMock.hardReset(); + + const settings = getSettings(); + settings.oidc.providers[0].newUserLogin = true; + + // Setup mock without email in claims (explicitly set email to undefined to override DEFAULT_CLAIMS) + await setupFetchMock({ + supportsPKCE: false, + idTokenClaims: { sub: 'no-email-sub', email: undefined }, + userinfoResponse: { sub: 'no-email-sub' }, + }); + + const response = await performOidcCallback(); + + assert.strictEqual(response.status, 400); + assert.strictEqual(response.body.error, ApiErrorCode.OidcMissingEmail); + }); + }); + + describe('error handling', function () { + it('returns Unauthorized when OIDC login is disabled', async function () { + const settings = getSettings(); + settings.main.oidcLogin = false; + + const response = await request(app) + .get('/auth/oidc/login/test') + .set('Accept', 'application/json'); + + assert.strictEqual(response.status, 403); + assert.strictEqual(response.body.error, ApiErrorCode.Unauthorized); + }); + + it('returns Unauthorized for unknown provider', async function () { + const response = await request(app) + .get('/auth/oidc/login/unknown-provider') + .set('Accept', 'application/json'); + + assert.strictEqual(response.status, 403); + assert.strictEqual(response.body.error, ApiErrorCode.Unauthorized); + }); + }); +}); From d47336b662076757f78dab772cfdcd066284f2b7 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Wed, 18 Feb 2026 22:45:04 -0500 Subject: [PATCH 5/8] feat(client): support login & account linking with OpenID Connect --- public/images/openid.svg | 57 +++++++++ src/components/Common/Button/index.tsx | 2 +- src/components/Login/LoginButton.tsx | 35 ++++++ src/components/Login/OidcLoginButton.tsx | 79 +++++++++++++ src/components/Login/PlexLoginButton.tsx | 17 +-- src/components/Login/index.tsx | 110 ++++++++++-------- .../UserLinkedAccountsSettings/index.tsx | 107 ++++++++++++++--- src/i18n/locale/en.json | 8 +- src/pages/_app.tsx | 6 +- src/utils/oidc.ts | 96 +++++++++++++++ 10 files changed, 440 insertions(+), 77 deletions(-) create mode 100644 public/images/openid.svg create mode 100644 src/components/Login/LoginButton.tsx create mode 100644 src/components/Login/OidcLoginButton.tsx create mode 100644 src/utils/oidc.ts diff --git a/public/images/openid.svg b/public/images/openid.svg new file mode 100644 index 0000000000..644f473ca6 --- /dev/null +++ b/public/images/openid.svg @@ -0,0 +1,57 @@ + + + + diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index eb422f2248..753aedeb33 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -31,7 +31,7 @@ type BaseProps

= { ) => void; }; -type ButtonProps

= { +export type ButtonProps

= { as?: P; } & MergeElementProps>; diff --git a/src/components/Login/LoginButton.tsx b/src/components/Login/LoginButton.tsx new file mode 100644 index 0000000000..bf270034c6 --- /dev/null +++ b/src/components/Login/LoginButton.tsx @@ -0,0 +1,35 @@ +import Button, { type ButtonProps } from '@app/components/Common/Button'; +import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; +import { type PropsWithChildren } from 'react'; +import { twMerge } from 'tailwind-merge'; + +export type LoginButtonProps = ButtonProps<'button'> & + PropsWithChildren<{ + loading?: boolean; + }>; + +export default function LoginButton({ + loading, + className, + children, + ...buttonProps +}: LoginButtonProps) { + return ( + + ); +} diff --git a/src/components/Login/OidcLoginButton.tsx b/src/components/Login/OidcLoginButton.tsx new file mode 100644 index 0000000000..51eb1b8209 --- /dev/null +++ b/src/components/Login/OidcLoginButton.tsx @@ -0,0 +1,79 @@ +import { + clearOidcProviderSlug, + getOidcErrorMessage, + getOidcProviderSlug, + initiateOidcLogin, + processOidcCallback, +} from '@app/utils/oidc'; +import type { PublicOidcProvider } from '@server/lib/settings'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import LoginButton from './LoginButton'; + +type OidcLoginButtonProps = { + provider: PublicOidcProvider; + onError?: (message: string) => void; +}; + +export default function OidcLoginButton({ + provider, + onError, +}: OidcLoginButtonProps) { + const intl = useIntl(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const [loading, setLoading] = useState(false); + + const redirectToLogin = useCallback(async () => { + setLoading(true); + try { + await initiateOidcLogin(provider.slug, window.location.href); + } catch (e) { + setLoading(false); + const errorCode = (e as { response?: { data?: { error?: string } } }) + ?.response?.data?.error; + onError?.(getOidcErrorMessage(errorCode, provider.name, intl)); + } + }, [provider, intl, onError]); + + const handleCallback = useCallback(async () => { + setLoading(true); + const result = await processOidcCallback(provider.slug); + if (result.type === 'success') { + router.push('/'); + } else { + router.replace('/login'); + setLoading(false); + onError?.(getOidcErrorMessage(result.errorCode, provider.name, intl)); + } + }, [provider, intl, onError, router]); + + useEffect(() => { + if (loading) return; + const code = searchParams.get('code'); + + if (code != null && getOidcProviderSlug() === provider.slug) { + clearOidcProviderSlug(); + // OIDC provider has redirected back with an authorization code + handleCallback(); + } else if (code == null && searchParams.get('provider') === provider.slug) { + // Support direct redirect via ?provider=slug query param + redirectToLogin(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + redirectToLogin()}> + {/* eslint-disable-next-line @next/next/no-img-element */} + {provider.name} + {provider.name} + + ); +} diff --git a/src/components/Login/PlexLoginButton.tsx b/src/components/Login/PlexLoginButton.tsx index a048c1d7d0..a7f31af770 100644 --- a/src/components/Login/PlexLoginButton.tsx +++ b/src/components/Login/PlexLoginButton.tsx @@ -1,10 +1,9 @@ import PlexIcon from '@app/assets/services/plex.svg'; -import Button from '@app/components/Common/Button'; -import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import usePlexLogin from '@app/hooks/usePlexLogin'; import defineMessages from '@app/utils/defineMessages'; import { Fragment } from 'react'; import { FormattedMessage } from 'react-intl'; +import LoginButton from './LoginButton'; const messages = defineMessages('components.Login', { loginwithapp: 'Login with {appName}', @@ -26,18 +25,12 @@ const PlexLoginButton = ({ const { loading, login } = usePlexLogin({ onAuthToken, onError }); return ( - + ); }; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 6042970236..1a727029c1 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -1,12 +1,12 @@ import EmbyLogo from '@app/assets/services/emby-icon-only.svg'; import JellyfinLogo from '@app/assets/services/jellyfin-icon.svg'; import PlexLogo from '@app/assets/services/plex.svg'; -import Button from '@app/components/Common/Button'; import ImageFader from '@app/components/Common/ImageFader'; import PageTitle from '@app/components/Common/PageTitle'; import LanguagePicker from '@app/components/Layout/LanguagePicker'; import JellyfinLogin from '@app/components/Login/JellyfinLogin'; import LocalLogin from '@app/components/Login/LocalLogin'; +import OidcLoginButton from '@app/components/Login/OidcLoginButton'; import PlexLoginButton from '@app/components/Login/PlexLoginButton'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; @@ -17,10 +17,12 @@ import { MediaServerType } from '@server/constants/server'; import axios from 'axios'; import { useRouter } from 'next/dist/client/router'; import Image from 'next/image'; +import { useSearchParams } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import { CSSTransition, SwitchTransition } from 'react-transition-group'; import useSWR from 'swr'; +import LoginButton from './LoginButton'; const messages = defineMessages('components.Login', { signin: 'Sign In', @@ -33,6 +35,7 @@ const messages = defineMessages('components.Login', { const Login = () => { const intl = useIntl(); + const searchParams = useSearchParams(); const router = useRouter(); const settings = useSettings(); const { user, revalidate } = useUser(); @@ -70,10 +73,10 @@ const Login = () => { // Effect that is triggered whenever `useUser`'s user changes. If we get a new // valid user, we redirect the user to the home page as the login was successful. useEffect(() => { - if (user) { + if (user && !searchParams.has('code')) { router.push('/'); } - }, [user, router]); + }, [user, router, searchParams]); const { data: backdrops } = useSWR('/api/v1/backdrops', { refreshInterval: 0, @@ -121,10 +124,9 @@ const Login = () => { ) : ( settings.currentSettings.localLogin && (mediaServerLogin ? ( - + ) : ( - + )) )), + ...settings.currentSettings.openIdProviders.map((provider) => ( + + )), ].filter((o): o is JSX.Element => !!o); return ( @@ -197,46 +205,50 @@ const Login = () => {

- - { - loginRef.current?.addEventListener( - 'transitionend', - done, - false - ); - }} - onEntered={() => { - document - .querySelector('#email, #username') - ?.focus(); - }} - classNames={{ - appear: 'opacity-0', - appearActive: 'transition-opacity duration-500 opacity-100', - enter: 'opacity-0', - enterActive: 'transition-opacity duration-500 opacity-100', - exitActive: 'transition-opacity duration-0 opacity-0', - }} - > -
- {isJellyfin && - (mediaServerLogin || - !settings.currentSettings.localLogin) ? ( - - ) : ( - settings.currentSettings.localLogin && ( - - ) - )} -
-
-
+ {loginFormVisible && ( + + { + loginRef.current?.addEventListener( + 'transitionend', + done, + false + ); + }} + onEntered={() => { + document + .querySelector('#email, #username') + ?.focus(); + }} + classNames={{ + appear: 'opacity-0', + appearActive: + 'transition-opacity duration-500 opacity-100', + enter: 'opacity-0', + enterActive: + 'transition-opacity duration-500 opacity-100', + exitActive: 'transition-opacity duration-0 opacity-0', + }} + > +
+ {isJellyfin && + (mediaServerLogin || + !settings.currentSettings.localLogin) ? ( + + ) : ( + settings.currentSettings.localLogin && ( + + ) + )} +
+
+
+ )} {additionalLoginOptions.length > 0 && (loginFormVisible ? ( diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx index 9fcbe3cc3a..d4f8190c05 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -9,12 +9,23 @@ import useSettings from '@app/hooks/useSettings'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; +import { + clearOidcProviderSlug, + getOidcErrorMessage, + getOidcProviderSlug, + initiateOidcLogin, + processOidcCallback, +} from '@app/utils/oidc'; import PlexOAuth from '@app/utils/plex'; import { TrashIcon } from '@heroicons/react/24/solid'; import { MediaServerType } from '@server/constants/server'; +import type { + UserSettingsLinkedAccount, + UserSettingsLinkedAccountResponse, +} from '@server/interfaces/api/userSettingsInterfaces'; import axios from 'axios'; import { useRouter } from 'next/router'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; import LinkJellyfinModal from './LinkJellyfinModal'; @@ -42,12 +53,20 @@ enum LinkedAccountType { Plex = 'Plex', Jellyfin = 'Jellyfin', Emby = 'Emby', + OpenIdConnect = 'oidc', } -type LinkedAccount = { - type: LinkedAccountType; - username: string; -}; +type LinkedAccount = + | { + type: + | LinkedAccountType.Plex + | LinkedAccountType.Emby + | LinkedAccountType.Jellyfin; + username: string; + } + | ({ + type: LinkedAccountType.OpenIdConnect; + } & UserSettingsLinkedAccount); const UserLinkedAccountsSettings = () => { const intl = useIntl(); @@ -62,13 +81,46 @@ const UserLinkedAccountsSettings = () => { const { data: passwordInfo } = useSWR<{ hasPassword: boolean }>( user ? `/api/v1/user/${user?.id}/settings/password` : null ); + const { data: linkedOidcAccounts, mutate: revalidateLinkedAccounts } = + useSWR( + user ? `/api/v1/user/${user?.id}/settings/linked-accounts` : null + ); const [showJellyfinModal, setShowJellyfinModal] = useState(false); const [error, setError] = useState(null); + // Handle OIDC callback when the provider redirects back to this page + useEffect(() => { + if (!router.isReady) return; + const code = router.query.code; + const providerSlug = getOidcProviderSlug(); + if (typeof code !== 'string' || providerSlug == null) return; + clearOidcProviderSlug(); + + // Strip the OIDC params from the URL immediately + router.replace(router.pathname, undefined, { shallow: true }); + + processOidcCallback(providerSlug).then(async (result) => { + if (result.type === 'success') { + await revalidateLinkedAccounts(); + } else { + const providerName = + settings.currentSettings.openIdProviders.find( + (p) => p.slug === providerSlug + )?.name ?? providerSlug; + setError(getOidcErrorMessage(result.errorCode, providerName, intl)); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady]); + const applicationName = settings.currentSettings.applicationTitle; const accounts: LinkedAccount[] = useMemo(() => { - const accounts: LinkedAccount[] = []; + const accounts: LinkedAccount[] = + linkedOidcAccounts?.map((a) => ({ + type: LinkedAccountType.OpenIdConnect, + ...a, + })) ?? []; if (!user) return accounts; if (user.userType === UserType.PLEX && user.plexUsername) accounts.push({ @@ -86,7 +138,7 @@ const UserLinkedAccountsSettings = () => { username: user.jellyfinUsername, }); return accounts; - }, [user]); + }, [user, linkedOidcAccounts]); const linkPlexAccount = async () => { setError(null); @@ -138,6 +190,21 @@ const UserLinkedAccountsSettings = () => { settings.currentSettings.mediaServerType !== MediaServerType.EMBY || accounts.some((a) => a.type === LinkedAccountType.Emby), }, + ...settings.currentSettings.openIdProviders.map((p) => ({ + name: p.name, + action: async () => { + try { + await initiateOidcLogin(p.slug, window.location.href); + } catch (e) { + setError(intl.formatMessage(messages.errorUnknown)); + } + }, + hide: accounts.some( + (a) => + a.type === LinkedAccountType.OpenIdConnect && + a.provider.slug === p.slug + ), + })), ].filter((l) => !l.hide); const deleteRequest = async (account: string) => { @@ -150,6 +217,7 @@ const UserLinkedAccountsSettings = () => { } await revalidateUser(); + await revalidateLinkedAccounts(); }; if ( @@ -198,7 +266,7 @@ const UserLinkedAccountsSettings = () => {
{linkable.map(({ name, action }) => ( - + {name} ))} @@ -221,24 +289,37 @@ const UserLinkedAccountsSettings = () => {
) : acct.type === LinkedAccountType.Emby ? ( - ) : ( + ) : acct.type === LinkedAccountType.Jellyfin ? ( - )} + ) : acct.type === LinkedAccountType.OpenIdConnect ? ( + // eslint-disable-next-line @next/next/no-img-element + {acct.provider.name} + ) : null}
- {acct.type} + {acct.type === LinkedAccountType.OpenIdConnect + ? acct.provider.name + : acct.type}
{acct.username}
- {enableMediaServerUnlink && ( + {(acct.type === LinkedAccountType.OpenIdConnect || + enableMediaServerUnlink) && ( { deleteRequest( - acct.type === LinkedAccountType.Plex ? 'plex' : 'jellyfin' + acct.type === LinkedAccountType.OpenIdConnect + ? String(acct.id) + : acct.type === LinkedAccountType.Plex + ? 'plex' + : 'jellyfin' ); }} confirmText={intl.formatMessage(globalMessages.areyousure)} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 821b3210af..d46fe693a8 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1627,5 +1627,11 @@ "pages.pagenotfound": "Page Not Found", "pages.returnHome": "Return Home", "pages.serviceunavailable": "Service Unavailable", - "pages.somethingwentwrong": "Something Went Wrong" + "pages.somethingwentwrong": "Something Went Wrong", + "utils.oidc.oidcAccountAlreadyLinked": "This {provider} account is already linked to another user.", + "utils.oidc.oidcAuthorizationFailed": "Authorization with {provider} failed. Please try again.", + "utils.oidc.oidcLoginError": "An error occurred while logging in with {provider}.", + "utils.oidc.oidcMissingEmail": "Unable to create account. No email address was provided by {provider}.", + "utils.oidc.oidcProviderDiscoveryFailed": "Failed to connect to {provider}. Please try again later.", + "utils.oidc.oidcUnauthorized": "You do not have permission to sign in with {provider}." } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 91372c1035..b0d6b295db 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -292,7 +292,11 @@ CoreApp.getInitialProps = async (initialProps) => { ); user = response.data; - if (router.pathname.match(/(setup|login)/)) { + if ( + router.pathname.match(/(setup|login)/) && + // if code is set, we are in the callback of an OpenID Connect flow + router.query.code == null + ) { ctx.res.writeHead(307, { Location: '/', }); diff --git a/src/utils/oidc.ts b/src/utils/oidc.ts new file mode 100644 index 0000000000..ef268e568b --- /dev/null +++ b/src/utils/oidc.ts @@ -0,0 +1,96 @@ +import defineMessages from '@app/utils/defineMessages'; +import { ApiErrorCode } from '@server/constants/error'; +import axios, { isAxiosError } from 'axios'; +import type { useIntl } from 'react-intl'; + +const messages = defineMessages('utils.oidc', { + oidcLoginError: 'An error occurred while logging in with {provider}.', + oidcProviderDiscoveryFailed: + 'Failed to connect to {provider}. Please try again later.', + oidcAuthorizationFailed: + 'Authorization with {provider} failed. Please try again.', + oidcMissingEmail: + 'Unable to create account. No email address was provided by {provider}.', + oidcUnauthorized: 'You do not have permission to sign in with {provider}.', + oidcAccountAlreadyLinked: + 'This {provider} account is already linked to another user.', +}); + +const OIDC_PROVIDER_KEY = 'oidc-provider'; + +export function getOidcErrorMessage( + errorCode: string | undefined, + providerName: string, + intl: ReturnType +): string { + const values = { provider: providerName }; + switch (errorCode) { + case ApiErrorCode.OidcProviderDiscoveryFailed: + return intl.formatMessage(messages.oidcProviderDiscoveryFailed, values); + case ApiErrorCode.OidcAuthorizationFailed: + return intl.formatMessage(messages.oidcAuthorizationFailed, values); + case ApiErrorCode.OidcMissingEmail: + return intl.formatMessage(messages.oidcMissingEmail, values); + case ApiErrorCode.OidcAccountAlreadyLinked: + return intl.formatMessage(messages.oidcAccountAlreadyLinked, values); + case ApiErrorCode.Unauthorized: + return intl.formatMessage(messages.oidcUnauthorized, values); + default: + return intl.formatMessage(messages.oidcLoginError, values); + } +} + +/** + * Initiates the OIDC login flow by fetching the authorization URL from the + * server and redirecting the browser to the OIDC provider. Stores the provider + * slug in localStorage so the callback page can identify which provider is + * completing the flow. + */ +export async function initiateOidcLogin( + providerSlug: string, + returnUrl: string +): Promise { + localStorage.setItem(OIDC_PROVIDER_KEY, providerSlug); + const res = await axios.get<{ redirectUrl: string }>( + `/api/v1/auth/oidc/login/${encodeURIComponent(providerSlug)}`, + { params: { returnUrl } } + ); + window.location.href = res.data.redirectUrl; +} + +/** + * Returns the provider slug stored by initiateOidcLogin. + */ +export function getOidcProviderSlug(): string | null { + return localStorage.getItem(OIDC_PROVIDER_KEY); +} + +/** + * Clears the provider slug stored by initiateOidcLogin. + */ +export function clearOidcProviderSlug(): void { + localStorage.removeItem(OIDC_PROVIDER_KEY); +} + +/** + * Processes the OIDC authorization code callback by posting the current URL + * (which contains the code and any state params) to the server. + */ +export async function processOidcCallback( + providerSlug: string +): Promise< + { type: 'success' } | { type: 'error'; errorCode: string | undefined } +> { + try { + await axios.post( + `/api/v1/auth/oidc/callback/${encodeURIComponent(providerSlug)}`, + { callbackUrl: window.location.href } + ); + return { type: 'success' }; + } catch (e) { + return { + type: 'error', + errorCode: isAxiosError(e) ? e.response?.data?.error : undefined, + }; + } +} From af804d549c97c307271770c011ed282873788fe4 Mon Sep 17 00:00:00 2001 From: Michael Thomas Date: Wed, 18 Feb 2026 22:47:05 -0500 Subject: [PATCH 6/8] feat(client): support updating OpenID Connect provider settings --- .../Common/LabeledCheckbox/index.tsx | 13 +- .../Settings/EditOidcModal/index.tsx | 429 ++++++++++++++++++ .../Settings/SettingsOidc/index.tsx | 164 +++++++ .../Settings/SettingsUsers/index.tsx | 59 ++- src/i18n/locale/en.json | 28 ++ 5 files changed, 681 insertions(+), 12 deletions(-) create mode 100644 src/components/Settings/EditOidcModal/index.tsx create mode 100644 src/components/Settings/SettingsOidc/index.tsx diff --git a/src/components/Common/LabeledCheckbox/index.tsx b/src/components/Common/LabeledCheckbox/index.tsx index f37ccb41c9..3884d4aa39 100644 --- a/src/components/Common/LabeledCheckbox/index.tsx +++ b/src/components/Common/LabeledCheckbox/index.tsx @@ -1,31 +1,34 @@ import { Field } from 'formik'; +import { useId } from 'react'; import { twMerge } from 'tailwind-merge'; interface LabeledCheckboxProps { - id: string; + name: string; className?: string; label: string; description: string; - onChange: () => void; + onChange?: () => void; children?: React.ReactNode; } const LabeledCheckbox: React.FC = ({ - id, + name, className, label, description, onChange, children, }) => { + const id = useId(); + return ( <>
- +
-
+ {values.oidcLogin && ( + setShowOidcDialog(false)} + /> + )} +