diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml index f117b9a9dc..5af9ecbce3 100644 --- a/.github/workflows/cypress.yml +++ b/.github/workflows/cypress.yml @@ -71,7 +71,7 @@ jobs: with: install: false build: pnpm cypress:build - start: pnpm start + start: pnpm cypress:start wait-on: 'http://localhost:5055' record: true env: diff --git a/cypress.config.ts b/cypress.config.ts index 0fa88d2f9a..59c6a26b54 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,16 +1,48 @@ +import { ChildProcess, spawn } from 'child_process'; import { defineConfig } from 'cypress'; +import path from 'path'; + +let oidcServerProcess: ChildProcess | null = null; export default defineConfig({ projectId: 'onnqy3', e2e: { baseUrl: 'http://localhost:5055', video: true, + setupNodeEvents(on) { + on('task', { + startOidcServer() { + if (oidcServerProcess) return null; + const serverFile = path.join( + __dirname, + 'cypress/support/oidc-server.mts' + ); + oidcServerProcess = spawn('node', [serverFile], { + stdio: 'inherit', + detached: false, + }); + return new Promise((resolve) => + setTimeout(() => resolve(null), 2000) + ); + }, + stopOidcServer() { + if (oidcServerProcess) { + oidcServerProcess.kill(); + oidcServerProcess = null; + } + return null; + }, + }); + }, }, env: { ADMIN_EMAIL: 'admin@seerr.dev', ADMIN_PASSWORD: 'test1234', USER_EMAIL: 'friend@seerr.dev', USER_PASSWORD: 'test1234', + OIDC_ISSUER_URL: 'http://localhost:8092', + OIDC_CLIENT_ID: 'jellyseerr-test', + OIDC_CLIENT_SECRET: 'test-secret', }, retries: { runMode: 2, diff --git a/cypress/e2e/login.cy.ts b/cypress/e2e/login.cy.ts index 1c95541743..b137560cf1 100644 --- a/cypress/e2e/login.cy.ts +++ b/cypress/e2e/login.cy.ts @@ -1,4 +1,6 @@ -describe('Login Page', () => { +import { MediaServerType } from '../../server/constants/server'; + +describe('Login', () => { it('succesfully logs in as an admin', () => { cy.loginAsAdmin(); cy.visit('/'); @@ -11,3 +13,111 @@ describe('Login Page', () => { cy.contains('Trending'); }); }); + +/** + * Generates all permutations of boolean values of the given length. + * @param n Length + */ +function permutations(n: number): boolean[][] { + return Array.from({ length: 1 << n }, (_, i) => + Array.from({ length: n }, (_, j) => !!(i & (1 << (n - 1 - j)))) + ); +} + +describe('Login Page', () => { + const testProvider = { + slug: 'test-provider', + name: 'Test Provider', + issuerUrl: Cypress.env('OIDC_ISSUER_URL'), + clientId: Cypress.env('OIDC_CLIENT_ID'), + clientSecret: Cypress.env('OIDC_CLIENT_SECRET'), + newUserLogin: true, + }; + + const LOCAL_LOGIN_SELECTOR = '[data-testid^="seerr-login"]'; + const PLEX_LOGIN_SELECTOR = '[data-testid="plex-login-button"]'; + const MEDIA_SERVER_LOGIN_SELECTOR = '[data-testid^="mediaserver-login"]'; + const OIDC_LOGIN_SELECTOR = `[data-testid="oidc-login-${testProvider.slug}"]`; + + before(() => { + cy.loginAsAdmin(); + // Configure an OIDC Provider to show on the login screen + cy.request({ + method: 'POST', + url: '/api/v1/settings/main', + body: { + oidcLogin: true, + }, + }); + cy.configureOidcProvider(testProvider); + }); + + after(() => { + cy.loginAsAdmin(); + // Reset settings to defaults + cy.request({ + method: 'POST', + url: '/api/v1/settings/main', + body: { + localLogin: true, + mediaServerLogin: true, + oidcLogin: false, + mediaServerType: MediaServerType.PLEX, + }, + }); + // Remove created OIDC provider + cy.request({ + method: 'DELETE', + url: `/api/v1/settings/oidc/${testProvider.slug}`, + }); + }); + + for (const [ + localLogin, + mediaServerLogin, + oidcLogin, + enablePlex, + ] of permutations(4)) { + if (!localLogin && !mediaServerLogin && !oidcLogin) continue; + + const enabledFlags = Object.entries({ + localLogin, + mediaServerLogin, + oidcLogin, + }) + .filter(([_, v]) => v) + .map(([k, _]) => k) + .concat(enablePlex ? 'plex' : 'jellyfin') + .join(', '); + + it(`correct items are shown (${enabledFlags})`, () => { + cy.loginAsAdmin(); + // set settings + cy.request({ + method: 'POST', + url: '/api/v1/settings/main', + body: { + localLogin, + mediaServerLogin, + oidcLogin, + mediaServerType: enablePlex + ? MediaServerType.PLEX + : MediaServerType.JELLYFIN, + }, + }); + cy.then(Cypress.session.clearCurrentSessionData); + + cy.visit('/login'); + + // Ensure the right things are visible + cy.get(LOCAL_LOGIN_SELECTOR).should(localLogin ? 'exist' : 'not.exist'); + cy.get(MEDIA_SERVER_LOGIN_SELECTOR).should( + mediaServerLogin && !enablePlex ? 'exist' : 'not.exist' + ); + cy.get(PLEX_LOGIN_SELECTOR).should( + mediaServerLogin && enablePlex ? 'exist' : 'not.exist' + ); + cy.get(OIDC_LOGIN_SELECTOR).should(oidcLogin ? 'exist' : 'not.exist'); + }); + } +}); diff --git a/cypress/e2e/oidc/configure-provider.cy.ts b/cypress/e2e/oidc/configure-provider.cy.ts new file mode 100644 index 0000000000..983f31b1e9 --- /dev/null +++ b/cypress/e2e/oidc/configure-provider.cy.ts @@ -0,0 +1,144 @@ +describe('OIDC Provider Configuration', () => { + const testProvider = { + slug: 'test-provider', + name: 'Test Provider', + issuerUrl: Cypress.env('OIDC_ISSUER_URL'), + clientId: Cypress.env('OIDC_CLIENT_ID'), + clientSecret: Cypress.env('OIDC_CLIENT_SECRET'), + newUserLogin: true, + }; + + before(() => { + cy.task('startOidcServer'); + }); + + after(() => { + cy.task('stopOidcServer'); + }); + + beforeEach(() => { + cy.loginAsAdmin(); + // Clean up any existing test provider and disable OIDC + cy.deleteOidcProvider(testProvider.slug); + cy.request({ + method: 'POST', + url: '/api/v1/settings/main', + body: { oidcLogin: false }, + }); + }); + + it('should open when OIDC enabled', () => { + cy.visit('/settings/users'); + + // Enable OIDC login checkbox + cy.get('input[name="oidcLogin"]').check(); + + // Verify the dialog is open + cy.contains('OpenID Connect').should('be.visible'); + }); + + it('should manually close and open', () => { + cy.visit('/settings/users'); + + // Enable OIDC login checkbox + cy.get('input[name="oidcLogin"]').check(); + + // Verify the dialog is open + cy.get('[data-testid="settings-oidc"]').should('be.visible'); + + // Close the dialog + cy.contains('Close').click(); + cy.get('[data-testid="settings-oidc"]').should('not.exist'); + + cy.get('[data-testid="oidc-settings-button"]'); + }); + + it('should add a new OIDC provider', () => { + cy.visit('/settings/users'); + + // Enable OIDC login + cy.get('input[name="oidcLogin"]').check(); + + // Click add provider button + cy.contains('button', 'Add').click(); + + const getModal = () => cy.get('[data-testid="edit-oidc-modal"]'); + + // Fill in provider details + getModal().get('input[name="name"]').type(testProvider.name); + getModal().get('input[name="issuerUrl"]').type(testProvider.issuerUrl); + getModal().get('input[name="clientId"]').type(testProvider.clientId); + getModal() + .get('input[name="clientSecret"]') + .type(testProvider.clientSecret); + + // Open Advanced Settings section + getModal().contains('button', 'Advanced Settings').click(); + + // Set slug + getModal() + .get('input[name="slug"]') + .scrollIntoView() + .clear() + .type(testProvider.slug); + + // Enable new user login + getModal() + .get('input[name="newUserLogin"]') + .scrollIntoView() + .check({ force: true }); + + // Save the provider + getModal().contains('button', 'Save Changes').scrollIntoView().click(); + + // Verify provider appears in the list + cy.contains(testProvider.name).should('be.visible'); + }); + + it('should edit an existing OIDC provider', () => { + // First create a provider via API + cy.configureOidcProvider(testProvider); + + cy.visit('/settings/users'); + + // Enable OIDC login + cy.get('input[name="oidcLogin"]').check(); + + // Click on the provider to edit + cy.contains('li', testProvider.name).contains('button', 'Edit').click(); + + const getModal = () => cy.get('[data-testid="edit-oidc-modal"]'); + + // Update the name + const updatedName = 'Updated Test Provider'; + getModal().get('input[name="name"]').clear().type(updatedName); + + // Save changes + getModal().contains('button', 'Save Changes').click(); + + // Verify updated name appears + cy.contains(updatedName).should('be.visible'); + }); + + it('should delete an OIDC provider', () => { + // First create a provider via API + cy.configureOidcProvider(testProvider); + + cy.visit('/settings/users'); + + // Enable OIDC login + cy.get('input[name="oidcLogin"]').check(); + + // Click on the provider to edit + cy.contains(testProvider.name).click(); + + // Click delete button once to initiate + cy.contains('button', 'Delete').click(); + + // Click "Are you sure?" to confirm deletion + cy.contains('button', 'Are you sure?').click(); + + // Verify provider is removed from the list + cy.contains(testProvider.name).should('not.exist'); + }); +}); diff --git a/cypress/e2e/oidc/link-account.cy.ts b/cypress/e2e/oidc/link-account.cy.ts new file mode 100644 index 0000000000..60178ba939 --- /dev/null +++ b/cypress/e2e/oidc/link-account.cy.ts @@ -0,0 +1,136 @@ +describe('OIDC Account Linking', () => { + const testProvider = { + slug: 'test-provider', + name: 'Test Provider', + issuerUrl: Cypress.env('OIDC_ISSUER_URL'), + clientId: Cypress.env('OIDC_CLIENT_ID'), + clientSecret: Cypress.env('OIDC_CLIENT_SECRET'), + newUserLogin: true, + }; + + before(() => { + cy.task('startOidcServer'); + }); + + after(() => { + cy.task('stopOidcServer'); + }); + + beforeEach(() => { + // Configure OIDC provider via API + cy.loginAsAdmin(); + cy.configureOidcProvider(testProvider); + cy.enableOidcLogin(); + }); + + afterEach(() => { + // Clean up any linked accounts + cy.loginAsAdmin(); + cy.request('/api/v1/auth/me').then((response) => { + cy.unlinkAllOidcAccounts(response.body.id); + }); + cy.loginAsUser(); + cy.request('/api/v1/auth/me').then((response) => { + cy.unlinkAllOidcAccounts(response.body.id); + }); + }); + + it('should display OIDC linking option in profile settings', () => { + cy.loginAsUser(); + cy.visit('/profile/settings/linked-accounts'); + + cy.contains('button', 'Link Account').click(); + + // Verify OIDC provider button is visible + cy.contains('a', testProvider.name).should('be.visible'); + }); + + it('should link an OIDC account to existing user', () => { + cy.loginAsUser(); + + // Link OIDC account + cy.linkOidcAccount(testProvider.name, 'foo-sub-123'); + + // Verify we're back on the linked accounts page and account is linked + cy.url().should('include', '/profile/settings/linked-accounts'); + cy.contains('li', 'Test Provider').contains('foo').should('exist'); + }); + + it('should show error when OIDC account is already linked to another user', () => { + // First, link the account to the regular user + cy.loginAsUser(); + cy.linkOidcAccount(testProvider.name, 'foo-sub-123'); + cy.request('POST', '/api/v1/auth/logout'); + cy.clearCookies(); + + // Now try to link the same OIDC account to admin + cy.loginAsAdmin(); + cy.visit('/profile/settings/linked-accounts'); + + // Try to link with the same OIDC user + cy.contains('button', 'Link Account').click(); + cy.contains('a', testProvider.name).click(); + + cy.origin('http://localhost:8092', () => { + cy.get('input[name="login"]').type('foo-sub-123'); + cy.get('input[name="password"]').type('password'); + cy.contains('button', 'Sign-in').click(); + cy.contains('button', 'Continue').click(); + }); + + // Should be redirected back to linked accounts page with error + cy.url().should('include', '/profile/settings/linked-accounts'); + + // Should show error message + cy.contains('already linked to another user').should('be.visible'); + + // Should still be logged in as admin + cy.request('/api/v1/auth/me').then((response) => { + expect(response.status).to.eq(200); + expect(response.body.email).to.eq(Cypress.env('ADMIN_EMAIL')); + }); + }); + + it('should unlink an OIDC account', () => { + cy.loginAsUser(); + + // First link an OIDC account + cy.linkOidcAccount(testProvider.name, 'foo-sub-123'); + + // Verify account is linked + cy.url().should('include', '/profile/settings/linked-accounts'); + cy.contains('li', 'Test Provider').should('exist'); + + // Click delete button to initiate unlink + cy.contains('li', 'Test Provider').contains('button', 'Delete').click(); + + // Click "Are you sure?" to confirm + cy.contains('button', 'Are you sure?').click(); + + // Verify account is no longer linked + cy.contains('li', 'Test Provider').should('not.exist'); + }); + + it('should allow re-linking after unlinking an OIDC account', () => { + cy.loginAsUser(); + + // Link an OIDC account + cy.linkOidcAccount(testProvider.name, 'foo-sub-123'); + cy.contains('li', 'Test Provider').should('exist'); + + // Unlink the account + cy.contains('li', 'Test Provider').contains('button', 'Delete').click(); + cy.contains('button', 'Are you sure?').click(); + cy.contains('li', 'Test Provider').should('not.exist'); + + // clear cookies to get a fresh login flow + cy.clearCookies(); + cy.loginAsUser(); + + // Re-link the same OIDC account + cy.linkOidcAccount(testProvider.name, 'foo-sub-123'); + + // Verify account is linked again + cy.contains('li', 'Test Provider').contains('foo').should('exist'); + }); +}); diff --git a/cypress/e2e/oidc/login-existing-user.cy.ts b/cypress/e2e/oidc/login-existing-user.cy.ts new file mode 100644 index 0000000000..219fb0cb1e --- /dev/null +++ b/cypress/e2e/oidc/login-existing-user.cy.ts @@ -0,0 +1,141 @@ +describe('OIDC Login with Existing User', () => { + const testProvider = { + slug: 'test-provider', + name: 'Test Provider', + issuerUrl: Cypress.env('OIDC_ISSUER_URL'), + clientId: Cypress.env('OIDC_CLIENT_ID'), + clientSecret: Cypress.env('OIDC_CLIENT_SECRET'), + newUserLogin: true, + }; + + before(() => { + cy.task('startOidcServer'); + }); + + after(() => { + cy.task('stopOidcServer'); + }); + + beforeEach(() => { + // Configure OIDC provider via API + cy.loginAsAdmin(); + cy.configureOidcProvider(testProvider); + cy.enableOidcLogin(); + + // Link OIDC account to existing user + cy.loginAsUser(); + cy.linkOidcAccount(testProvider.name, 'foo-sub-123'); + + // Logout to test login flow + cy.request('POST', '/api/v1/auth/logout'); + cy.clearCookies(); + cy.clearAllSessionStorage(); + }); + + afterEach(() => { + // Clean up linked accounts + cy.loginAsUser(); + cy.request('/api/v1/auth/me').then((response) => { + cy.unlinkAllOidcAccounts(response.body.id); + }); + + // Clear OIDC server session + cy.clearAllSessionStorage(); + }); + + it('should display OIDC login button on login page', () => { + cy.visit('/login'); + + // Verify OIDC provider button is visible + cy.get(`[data-testid="oidc-login-${testProvider.slug}"]`).should( + 'be.visible' + ); + cy.contains('button', testProvider.name).should('be.visible'); + }); + + it('should login with linked OIDC account', () => { + cy.visit('/login'); + + // Click OIDC login button + cy.get(`[data-testid="oidc-login-${testProvider.slug}"]`).click(); + + // Handle OIDC provider login + cy.origin('http://localhost:8092', () => { + cy.get('input[name="login"]').type('foo-sub-123'); + cy.get('input[name="password"]').type('password'); + cy.contains('button', 'Sign-in').click(); + cy.contains('button', 'Continue').click(); + }); + + // Verify successful login - should be redirected to home + cy.url().should('not.include', 'localhost:8092'); + cy.url().should('not.include', '/login'); + + // Verify user is logged in + cy.request('/api/v1/auth/me').its('status').should('eq', 200); + }); + + it('should maintain session after OIDC login', () => { + cy.visit('/login'); + + // Login with OIDC + cy.get(`[data-testid="oidc-login-${testProvider.slug}"]`).click(); + + cy.origin('http://localhost:8092', () => { + cy.get('input[name="login"]').type('foo-sub-123'); + cy.get('input[name="password"]').type('password'); + cy.contains('button', 'Sign-in').click(); + cy.contains('button', 'Continue').click(); + }); + + // Wait for redirect to complete + cy.url().should('not.include', '/login'); + + // Navigate to different pages and verify session persists + cy.visit('/profile'); + cy.request('/api/v1/auth/me').its('status').should('eq', 200); + + cy.visit('/'); + cy.request('/api/v1/auth/me').its('status').should('eq', 200); + }); + + it('should redirect to original page after OIDC login', () => { + // Try to access a protected page while logged out + cy.visit('/profile/settings'); + + // Should be redirected to login + cy.url().should('include', '/login'); + + // Login with OIDC + cy.get(`[data-testid="oidc-login-${testProvider.slug}"]`).click(); + + cy.origin('http://localhost:8092', () => { + cy.get('input[name="login"]').type('foo-sub-123'); + cy.get('input[name="password"]').type('password'); + cy.contains('button', 'Sign-in').click(); + cy.contains('button', 'Continue').click(); + }); + + // Should be redirected back to the original page after login + cy.url().should('not.include', '/login'); + }); + + it('should allow logout after OIDC login', () => { + // Login with OIDC + cy.loginWithOidc(testProvider.name, 'foo-sub-123'); + + // Verify logged in + cy.request('/api/v1/auth/me').its('status').should('eq', 200); + + // Logout + cy.request('POST', '/api/v1/auth/logout'); + + // Verify logged out + cy.request({ + url: '/api/v1/auth/me', + failOnStatusCode: false, + }) + .its('status') + .should('eq', 403); + }); +}); diff --git a/cypress/e2e/oidc/new-user-login.cy.ts b/cypress/e2e/oidc/new-user-login.cy.ts new file mode 100644 index 0000000000..ba55c7aaac --- /dev/null +++ b/cypress/e2e/oidc/new-user-login.cy.ts @@ -0,0 +1,101 @@ +describe('OIDC New User Login', () => { + const testProvider = { + slug: 'test-provider', + name: 'Test Provider', + issuerUrl: Cypress.env('OIDC_ISSUER_URL'), + clientId: Cypress.env('OIDC_CLIENT_ID'), + clientSecret: Cypress.env('OIDC_CLIENT_SECRET'), + newUserLogin: true, + }; + + before(() => { + cy.task('startOidcServer'); + }); + + after(() => { + cy.task('stopOidcServer'); + }); + + beforeEach(() => { + // Configure OIDC provider with newUserLogin enabled + cy.loginAsAdmin(); + cy.configureOidcProvider(testProvider); + cy.enableOidcLogin(); + + // Logout to test new user flow + cy.request('POST', '/api/v1/auth/logout'); + cy.clearCookies(); + cy.clearAllSessionStorage(); + }); + + afterEach(() => { + // Clean up any users created during tests + cy.loginAsAdmin(); + cy.deleteUserByEmail('newuser@example.com'); + cy.deleteUserByEmail('bar@example.com'); + + // Clear OIDC server session + cy.clearAllSessionStorage(); + }); + + it('should create new user account on first OIDC login', () => { + cy.loginWithOidc(testProvider.name, 'newuser-sub-789'); + + // Verify user is logged in with email from OIDC provider + cy.request('/api/v1/auth/me').then((response) => { + expect(response.status).to.eq(200); + expect(response.body.email).to.eq('newuser@example.com'); + }); + }); + + it('should use OIDC profile information for new user', () => { + cy.loginWithOidc(testProvider.name, 'bar-sub-456'); + + // Verify user info matches OIDC claims + cy.request('/api/v1/auth/me').then((response) => { + expect(response.status).to.eq(200); + expect(response.body.email).to.eq('bar@example.com'); + }); + }); + + it('should deny new user login when newUserLogin is disabled', () => { + // Reconfigure provider with newUserLogin disabled + cy.loginAsAdmin(); + cy.configureOidcProvider({ + ...testProvider, + newUserLogin: false, + }); + cy.request('POST', '/api/v1/auth/logout'); + cy.clearCookies(); + + cy.loginWithOidc(testProvider.name, 'newuser-sub-789', false); + + // Should be redirected back to login with an error + cy.url().should('include', '/login'); + + // Should show an error message + cy.contains(/do not have permission/i).should('be.visible'); + }); + + it('should allow new user to access protected routes after OIDC login', () => { + cy.loginWithOidc(testProvider.name, 'newuser-sub-789'); + + // Access profile page + cy.visit('/profile'); + cy.request('/api/v1/auth/me').its('status').should('eq', 200); + + // Access profile settings + cy.visit('/profile/settings'); + cy.request('/api/v1/auth/me').its('status').should('eq', 200); + }); + + it('should show OIDC provider as linked in new user profile', () => { + cy.loginWithOidc(testProvider.name, 'newuser-sub-789'); + + // Navigate to linked accounts settings + cy.visit('/profile/settings/linked-accounts'); + + // The OIDC provider should show as linked + cy.contains('li', testProvider.name).should('exist'); + }); +}); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index a23cb5e688..47af07985a 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -15,7 +15,7 @@ Cypress.Commands.add('login', (email, password) => { cy.wait('@localLogin'); - cy.url().should('contain', '/'); + cy.url().should('not.contain', '/login'); }, { validate() { @@ -32,3 +32,110 @@ Cypress.Commands.add('loginAsAdmin', () => { Cypress.Commands.add('loginAsUser', () => { cy.login(Cypress.env('USER_EMAIL'), Cypress.env('USER_PASSWORD')); }); + +// OIDC Commands + +export interface OidcProviderConfig { + slug: string; + name: string; + issuerUrl: string; + clientId: string; + clientSecret: string; + newUserLogin?: boolean; +} + +Cypress.Commands.add( + 'configureOidcProvider', + (provider: OidcProviderConfig) => { + const { slug, ...body } = provider; + cy.request({ + method: 'PUT', + url: `/api/v1/settings/oidc/${slug}`, + body, + }); + } +); + +Cypress.Commands.add('deleteOidcProvider', (slug: string) => { + cy.request({ + method: 'DELETE', + url: `/api/v1/settings/oidc/${slug}`, + failOnStatusCode: false, + }); +}); + +Cypress.Commands.add('enableOidcLogin', () => { + cy.request({ + method: 'POST', + url: '/api/v1/settings/main', + body: { oidcLogin: true, localLogin: true }, + }); +}); + +Cypress.Commands.add( + 'loginWithOidc', + (providerName: string, sub: string, expectSuccess = true) => { + cy.visit('/login'); + cy.contains('button', providerName).click(); + cy.origin('http://localhost:8092', { args: { sub } }, ({ sub }) => { + cy.get('input[name="login"]').type(sub); + cy.get('input[name="password"]').type('password'); + cy.contains('button', 'Sign-in').click(); + cy.contains('button', 'Continue').click(); + }); + cy.url().should('not.include', 'localhost:8092'); + if (expectSuccess) { + cy.url({ timeout: 5000 }).should('not.include', '/login'); + } + } +); + +Cypress.Commands.add('linkOidcAccount', (providerName: string, sub: string) => { + cy.visit('/profile/settings/linked-accounts'); + cy.contains('button', 'Link Account').click(); + cy.contains('a', providerName).click(); + cy.origin('http://localhost:8092', { args: { sub } }, ({ sub }) => { + cy.get('input[name="login"]').type(sub); + cy.get('input[name="password"]').type('password'); + cy.contains('button', 'Sign-in').click(); + cy.contains('button', 'Continue').click(); + }); + cy.url().should('include', '/profile/settings/linked-accounts'); +}); + +Cypress.Commands.add('unlinkAllOidcAccounts', (userId: number) => { + cy.request<{ id: number }[]>({ + method: 'GET', + url: `/api/v1/user/${userId}/settings/linked-accounts`, + failOnStatusCode: false, + }).then((response) => { + if (response.status === 200 && Array.isArray(response.body)) { + response.body.forEach((account) => { + cy.request({ + method: 'DELETE', + url: `/api/v1/user/${userId}/settings/linked-accounts/${account.id}`, + failOnStatusCode: false, + }); + }); + } + }); +}); + +Cypress.Commands.add('deleteUserByEmail', (email: string) => { + cy.request<{ results: { id: number; email: string }[] }>({ + method: 'GET', + url: '/api/v1/user', + failOnStatusCode: false, + }).then((response) => { + if (response.status === 200 && Array.isArray(response.body.results)) { + const user = response.body.results.find((u) => u.email === email); + if (user) { + cy.request({ + method: 'DELETE', + url: `/api/v1/user/${user.id}`, + failOnStatusCode: false, + }); + } + } + }); +}); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 857067613e..9c85519dcb 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -1,12 +1,27 @@ /* eslint-disable @typescript-eslint/no-namespace */ /// +import type { OidcProviderConfig } from './commands'; + declare global { namespace Cypress { interface Chainable { login(email?: string, password?: string): Chainable; loginAsAdmin(): Chainable; loginAsUser(): Chainable; + configureOidcProvider( + provider: OidcProviderConfig + ): Chainable>; + deleteOidcProvider(slug: string): Chainable>; + enableOidcLogin(): Chainable>; + loginWithOidc( + providerName: string, + sub: string, + expectSuccess?: boolean + ): Chainable; + linkOidcAccount(providerName: string, sub: string): Chainable; + unlinkAllOidcAccounts(userId: number): Chainable; + deleteUserByEmail(email: string): Chainable; } } } diff --git a/cypress/support/oidc-server.mts b/cypress/support/oidc-server.mts new file mode 100644 index 0000000000..268ce8fd09 --- /dev/null +++ b/cypress/support/oidc-server.mts @@ -0,0 +1,109 @@ +import Provider, { + type AccountClaims, + type Configuration, +} from 'oidc-provider'; + +const PORT = 8092; +const ISSUER = `http://localhost:${PORT}`; + +interface UserClaims extends AccountClaims { + sub: string; + email: string; + email_verified: boolean; + preferred_username: string; + name: string; +} + +// Mock users database +const users: Record< + string, + { accountId: string; password: string; claims: UserClaims } +> = { + 'foo-sub-123': { + accountId: 'foo-sub-123', + password: 'password', + claims: { + sub: 'foo-sub-123', + email: 'foo@example.com', + email_verified: true, + preferred_username: 'foo', + name: 'Foo User', + }, + }, + 'bar-sub-456': { + accountId: 'bar-sub-456', + password: 'password', + claims: { + sub: 'bar-sub-456', + email: 'bar@example.com', + email_verified: true, + preferred_username: 'bar', + name: 'Bar User', + }, + }, + 'newuser-sub-789': { + accountId: 'newuser-sub-789', + password: 'password', + claims: { + sub: 'newuser-sub-789', + email: 'newuser@example.com', + email_verified: true, + preferred_username: 'newuser', + name: 'New User', + }, + }, +}; + +const findAccount: Configuration['findAccount'] = async (_ctx, id) => { + const account = users[id]; + if (!account) return undefined; + + return { + accountId: id, + claims: async () => account.claims, + }; +}; + +const configuration: Configuration = { + clients: [ + { + client_id: 'jellyseerr-test', + client_secret: 'test-secret', + grant_types: ['authorization_code', 'refresh_token'], + redirect_uris: [ + 'http://localhost:5055/login', + 'http://localhost:5055/profile/settings/linked-accounts', + ], + response_types: ['code'], + scope: 'openid email profile', + }, + ], + findAccount, + claims: { + openid: ['sub'], + email: ['email', 'email_verified'], + profile: ['name', 'preferred_username'], + }, + features: { + devInteractions: { enabled: true }, + }, + cookies: { + keys: ['test-secret-key'], + }, + pkce: { + required: () => false, + }, + ttl: { + AccessToken: 3600, + AuthorizationCode: 600, + IdToken: 3600, + RefreshToken: 86400, + }, +}; + +const provider = new Provider(ISSUER, configuration); + +provider.listen(PORT, () => { + console.log(`Mock OIDC Provider listening on ${ISSUER}`); + console.log(`Available users: ${Object.keys(users).join(', ')}`); +}); diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 1b6425b80f..525bfb6d59 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -6,5 +6,5 @@ "resolveJsonModule": true, "esModuleInterop": true }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.mts"] } diff --git a/docs/using-seerr/settings/users/assets/oidc_keycloak_1.png b/docs/using-seerr/settings/users/assets/oidc_keycloak_1.png new file mode 100644 index 0000000000..97f72ac56d Binary files /dev/null and b/docs/using-seerr/settings/users/assets/oidc_keycloak_1.png differ diff --git a/docs/using-seerr/settings/users/assets/oidc_keycloak_2.png b/docs/using-seerr/settings/users/assets/oidc_keycloak_2.png new file mode 100644 index 0000000000..981f259621 Binary files /dev/null and b/docs/using-seerr/settings/users/assets/oidc_keycloak_2.png differ diff --git a/docs/using-seerr/settings/users/assets/oidc_keycloak_3.png b/docs/using-seerr/settings/users/assets/oidc_keycloak_3.png new file mode 100644 index 0000000000..b7cf6fbcd1 Binary files /dev/null and b/docs/using-seerr/settings/users/assets/oidc_keycloak_3.png differ diff --git a/docs/using-seerr/settings/users/assets/oidc_keycloak_4.png b/docs/using-seerr/settings/users/assets/oidc_keycloak_4.png new file mode 100644 index 0000000000..e6bfed12bf Binary files /dev/null and b/docs/using-seerr/settings/users/assets/oidc_keycloak_4.png differ diff --git a/docs/using-seerr/settings/users/assets/oidc_keycloak_5.png b/docs/using-seerr/settings/users/assets/oidc_keycloak_5.png new file mode 100644 index 0000000000..00d370a878 Binary files /dev/null and b/docs/using-seerr/settings/users/assets/oidc_keycloak_5.png differ diff --git a/docs/using-seerr/settings/users.md b/docs/using-seerr/settings/users/index.md similarity index 78% rename from docs/using-seerr/settings/users.md rename to docs/using-seerr/settings/users/index.md index 6abf551513..556e4f0805 100644 --- a/docs/using-seerr/settings/users.md +++ b/docs/using-seerr/settings/users/index.md @@ -22,6 +22,14 @@ When disabled, users will only be able to sign in using their email address. Use This setting is **enabled** by default. +## Enable OpenID Connect Sign-In + +When enabled, users will be able to sign in to Seerr using their OpenID Connect credentials, provided they have linked their OpenID Connect accounts. Once enabled, the [OpenID Connect settings](./oidc.md) can be accessed using the settings cog to the right of this option, and OpenID Connect providers can be configured. + +When disabled, users will only be able to sign in using their Seerr username or email address. Users without a password set will not be able to sign in to Seerr. + +This setting is **disabled** by default. + ## Enable New Jellyfin/Emby/Plex Sign-In When enabled, users with access to your media server will be able to sign in to Seerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in. diff --git a/docs/using-seerr/settings/users/oidc.md b/docs/using-seerr/settings/users/oidc.md new file mode 100644 index 0000000000..e9f5b7aa40 --- /dev/null +++ b/docs/using-seerr/settings/users/oidc.md @@ -0,0 +1,98 @@ +--- +title: OpenID Connect +description: Configure OpenID Connect settings. +sidebar_position: 2.5 +--- + +# OpenID Connect + +Seerr supports OpenID Connect (OIDC) for authentication and authorization. To begin setting up OpenID Connect, follow these steps: + +1. First, enable OpenID Connect [on the User settings page](./index.md#enable-openid-connect-sign-in). +2. Once enabled, access OpenID Connect settings using the cog icon to the right. +3. Add a new provider by clicking the "Add Provider" button. +4. Configure the provider with the options described below. +5. Link your OpenID Connect account to your Seerr account using the "Link Account" button on the Linked Accounts page in your user's settings. +6. Finally, you should be able to log in using your OpenID Connect account. + +## Configuration Options + +### Provider Name + +Name of the provider which appears on the login screen. + +Configuring this setting will automatically determine the [provider slug](#provider-slug), unless it is manually specified. + +### Logo + +The logo to display for the provider. Should be a URL or base64 encoded image. + +:::tip + +The search icon at the right of the logo field opens the [selfh.st/icons](https://selfh.st/icons) database. These icons include popular self-hosted OpenID Connect providers. + +::: + +### Issuer URL +The base URL of the identity provider's OpenID Connect endpoint + +### Client ID + +The Client ID assigned to Seerr + +### Client Secret + +The Client Secret assigned to Seerr + +### Provider Slug + +Unique identifier for the provider + +### Scopes + +Space-separated list of scopes to request from the provider + +### Required Claims + +Space-separated list of boolean claims that are required to log in + +### Allow New Users + +Create accounts for new users logging in with this provider + +## Provider Setup + +Most OpenID Connect providers follow the same basic setup pattern: + +1. **Create a new client/application** in your identity provider using the OAuth 2.0 / OpenID Connect protocol. +2. **Set the client type to confidential** (as opposed to public) so that a client secret is issued. +3. **Add redirect URIs** pointing to your Seerr instance. At minimum, allow: + - `https:///login` + - `https:///profile/settings/linked-accounts` +4. **Copy the Client ID and Client Secret** from your provider and enter them in Seerr's provider configuration. +5. **Set the Issuer URL** to the base URL of your provider's OpenID Connect discovery endpoint. Most providers publish a `/.well-known/openid-configuration` document — the Issuer URL is the part before that path. + +The default scopes (`openid profile email`) are sufficient for most providers. Only adjust scopes or required claims if your provider requires it. + +## Provider Guides + +### Keycloak + +To set up Keycloak, follow these steps: + +1. First, create a new client in Keycloak. + ![Keycloak Step 1](./assets/oidc_keycloak_1.png) + +1. Set the client ID to `seerr`, and set the name to "Seerr" (or whatever you prefer). + ![Keycloak Step 2](./assets/oidc_keycloak_2.png) + +1. Next, be sure to enable "Client authentication" in the capabilities section. The remaining defaults should be fine. + ![Keycloak Step 3](./assets/oidc_keycloak_3.png) + +1. Finally, set the root url to your Seerr instance's URL, and a wildcard `/*` as a valid redirect URL. + ![Keycloak Step 4](./assets/oidc_keycloak_4.png) + +1. With all that set up, you should be able to configure Seerr to use Keycloak for authentication. Be sure to copy the client secret from the credentials page, as shown above. The issuer URL can be obtained from the "Realm Settings" page, by copying the link titled "OpenID Endpoint Configuration". + ![Keycloak Step 5](./assets/oidc_keycloak_5.png) + +1. Use `https:///realms//` as the Issuer URL, replacing `` with your Keycloak realm name. diff --git a/package.json b/package.json index 30eec037a2..34f8814fbb 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "prepare": "node bin/prepare.js", "cypress:open": "cypress open", "cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts", - "cypress:build": "pnpm build && pnpm cypress:prepare" + "cypress:build": "pnpm build && pnpm cypress:prepare", + "cypress:start": "OIDC_ALLOW_INSECURE=true pnpm start" }, "repository": { "type": "git", @@ -76,6 +77,8 @@ "node-gyp": "9.3.1", "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", @@ -136,6 +139,7 @@ "@types/node": "22.10.5", "@types/node-schedule": "2.1.8", "@types/nodemailer": "7", + "@types/oidc-provider": "^9.5.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/react-transition-group": "4.4.12", @@ -164,11 +168,14 @@ "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", + "oidc-provider": "^9.6.1", "postcss": "^8.5.6", "prettier": "3.8.1", "prettier-plugin-organize-imports": "4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0968c05748..2f86beacac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,12 @@ importers: nodemailer: specifier: 7.0.12 version: 7.0.12 + 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 @@ -313,6 +319,9 @@ importers: '@types/nodemailer': specifier: '7' version: 7.0.9 + '@types/oidc-provider': + specifier: ^9.5.0 + version: 9.5.0 '@types/react': specifier: ^18.3.3 version: 18.3.27 @@ -397,6 +406,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 @@ -406,12 +418,18 @@ 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) nodemon: specifier: 3.1.11 version: 3.1.11 + oidc-provider: + specifier: ^9.6.1 + version: 9.7.0 postcss: specifier: ^8.5.6 version: 8.5.6 @@ -1843,6 +1861,16 @@ packages: '@jsdevtools/ono@7.1.3': resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@koa/cors@5.0.0': + resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} + engines: {node: '>= 14.0.0'} + + '@koa/router@15.4.0': + resolution: {integrity: sha512-vKYlXtoCfcAN8z4dHiveYX55rTYOgHEYJNumK1WM9ZAwaArhreGVkyC1LTMGfUQUJyIO/SbwRFBOHeOCY8/MaQ==} + engines: {node: '>= 20'} + peerDependencies: + koa: ^2.0.0 || ^3.0.0 + '@ladjs/consolidate@1.0.4': resolution: {integrity: sha512-ErvBg5acSqns86V/xW7gjqqnBBs6thnpMB0gGc3oM7WHsV8PWrnBtKI6dumHDT3UT/zEOfGzp7dmSFqWoCXKWQ==} engines: {node: '>=14'} @@ -3089,6 +3117,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/accepts@1.3.7': + resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3110,6 +3141,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/content-disposition@0.5.9': + resolution: {integrity: sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==} + '@types/conventional-commits-parser@5.0.0': resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==} @@ -3121,6 +3155,9 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/cookies@0.9.2': + resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==} + '@types/country-flag-icons@1.2.2': resolution: {integrity: sha512-CefEn/J336TBDp7NX8JqzlDtCBOsm8M3r1Li0gEOt0HOMHF1XemNyrx9lSHjsafcb1yYWybU0N8ZAXuyCaND0w==} @@ -3151,6 +3188,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==} @@ -3160,6 +3200,9 @@ packages: '@types/html-to-text@9.0.4': resolution: {integrity: sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ==} + '@types/http-assert@1.5.6': + resolution: {integrity: sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==} + '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} @@ -3175,6 +3218,15 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/keygrip@1.0.6': + resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} + + '@types/koa-compose@3.2.9': + resolution: {integrity: sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==} + + '@types/koa@3.0.1': + resolution: {integrity: sha512-VkB6WJUQSe0zBpR+Q7/YIUESGp5wPHcaXr0xueU5W0EOUWtlSbblsl+Kl31lyRQ63nIILh0e/7gXjQ09JXJIHw==} + '@types/lodash@4.17.21': resolution: {integrity: sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==} @@ -3226,6 +3278,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/oidc-provider@9.5.0': + resolution: {integrity: sha512-eEzCRVTSqIHD9Bo/qRJ4XQWQ5Z/zBcG+Z2cGJluRsSuWx1RJihqRyPxhIEpMXTwPzHYRTQkVp7hwisQOwzzSAg==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -4305,6 +4360,10 @@ packages: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} @@ -4360,6 +4419,10 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -4574,6 +4637,9 @@ packages: babel-plugin-macros: optional: true + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4610,6 +4676,10 @@ packages: denodeify@1.2.1: resolution: {integrity: sha512-KNTihKNmQENUZeKu5fzfpzRqR5S2VMp4gl9RFHiWzj9DfvYQPMJ6XHKNaQxaGCXwPk6y9yme3aUoaiAe+KX+vg==} + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4969,6 +5039,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eta@4.5.1: + resolution: {integrity: sha512-EaNCGm+8XEIU7YNcc+THptWAO5NfKBHHARxt+wxZljj9bTr/+arRoOm9/MpGt4n6xn9fLnPFRSoLD0WFYGFUxQ==} + engines: {node: '>=20'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -5111,6 +5185,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'} @@ -5340,6 +5418,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 @@ -5506,9 +5587,17 @@ packages: htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -5580,6 +5669,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -6101,6 +6194,12 @@ packages: joi@17.13.3: resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + jose@6.2.1: + resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + js-stringify@1.0.2: resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} @@ -6216,6 +6315,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6231,6 +6334,13 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa@3.1.2: + resolution: {integrity: sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==} + engines: {node: '>= 18'} + konva@9.3.12: resolution: {integrity: sha512-IxX+ka+gVGm63APkB/taepMxpbUdjfLBUA1OIqx7nbH3126Df6eAuVWasH3qh3vg4ctRseops031sZO0b2O33A==} @@ -6698,6 +6808,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -6863,6 +6977,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.7: + resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} + engines: {node: ^18 || >=20} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -7040,6 +7159,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'} @@ -7080,6 +7202,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + oidc-provider@9.7.0: + resolution: {integrity: sha512-xrOjNvwSOZf6hSR0fmD1SodaUIETbZeBMxjPnwQSMeCotHWWSBPqiZVeqCp/YFwGP/U+4VIBLYoO5PlypAg8KA==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -7117,6 +7242,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'} @@ -7259,6 +7387,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -7667,6 +7798,10 @@ packages: resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} engines: {node: '>=8'} + quick-lru@7.3.0: + resolution: {integrity: sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==} + engines: {node: '>=18'} + random-bytes@1.0.0: resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} engines: {node: '>= 0.8'} @@ -7679,6 +7814,10 @@ packages: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -7911,6 +8050,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'} @@ -8796,6 +8939,10 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -8932,6 +9079,10 @@ packages: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} + undici@7.24.4: + resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} @@ -11227,6 +11378,20 @@ snapshots: '@jsdevtools/ono@7.1.3': {} + '@koa/cors@5.0.0': + dependencies: + vary: 1.1.2 + + '@koa/router@15.4.0(koa@3.1.2)': + dependencies: + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + koa: 3.1.2 + koa-compose: 4.1.0 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + '@ladjs/consolidate@1.0.4(@babel/core@7.28.6)(handlebars@4.7.8)(lodash@4.17.23)(mustache@4.2.0)(pug@3.0.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(underscore@1.13.7)': optionalDependencies: '@babel/core': 7.28.6 @@ -12960,6 +13125,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/accepts@1.3.7': + dependencies: + '@types/node': 22.10.5 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.6 @@ -12998,6 +13167,8 @@ snapshots: dependencies: '@types/node': 22.10.5 + '@types/content-disposition@0.5.9': {} + '@types/conventional-commits-parser@5.0.0': dependencies: '@types/node': 22.10.5 @@ -13009,6 +13180,13 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/cookies@0.9.2': + dependencies: + '@types/connect': 3.4.38 + '@types/express': 4.17.17 + '@types/keygrip': 1.0.6 + '@types/node': 22.10.5 + '@types/country-flag-icons@1.2.2': {} '@types/csurf@1.11.5': @@ -13056,6 +13234,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 @@ -13067,6 +13247,8 @@ snapshots: '@types/html-to-text@9.0.4': {} + '@types/http-assert@1.5.6': {} + '@types/http-errors@2.0.4': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -13081,6 +13263,23 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/keygrip@1.0.6': {} + + '@types/koa-compose@3.2.9': + dependencies: + '@types/koa': 3.0.1 + + '@types/koa@3.0.1': + dependencies: + '@types/accepts': 1.3.7 + '@types/content-disposition': 0.5.9 + '@types/cookies': 0.9.2 + '@types/http-assert': 1.5.6 + '@types/http-errors': 2.0.4 + '@types/keygrip': 1.0.6 + '@types/koa-compose': 3.2.9 + '@types/node': 22.10.5 + '@types/lodash@4.17.21': {} '@types/mdast@3.0.15': @@ -13131,6 +13330,12 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/oidc-provider@9.5.0': + dependencies: + '@types/keygrip': 1.0.6 + '@types/koa': 3.0.1 + '@types/node': 22.10.5 + '@types/parse-json@4.0.2': {} '@types/picomatch@4.0.2': {} @@ -14343,6 +14548,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + content-disposition@1.0.1: {} + content-type@1.0.5: {} conventional-changelog-angular@6.0.0: @@ -14387,6 +14594,11 @@ snapshots: cookiejar@2.1.4: {} + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -14667,6 +14879,8 @@ snapshots: optionalDependencies: babel-plugin-macros: 3.1.0 + deep-equal@1.0.1: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -14697,6 +14911,8 @@ snapshots: denodeify@1.2.1: {} + depd@1.1.2: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -15212,6 +15428,8 @@ snapshots: esutils@2.0.3: {} + eta@4.5.1: {} + etag@1.8.1: {} event-target-shim@5.0.1: {} @@ -15433,6 +15651,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 @@ -15708,6 +15933,8 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regexp@0.4.1: {} + glob@10.5.0: dependencies: foreground-child: 3.2.1 @@ -15904,8 +16131,21 @@ snapshots: domutils: 3.1.0 entities: 4.5.0 + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + http-cache-semantics@4.1.1: {} + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -16004,6 +16244,10 @@ snapshots: safer-buffer: 2.1.2 optional: true + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore-by-default@1.0.1: {} @@ -16748,6 +16992,10 @@ snapshots: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + jose@6.1.3: {} + + jose@6.2.1: {} + js-stringify@1.0.2: {} js-tokens@4.0.0: {} @@ -16890,6 +17138,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -16900,6 +17152,29 @@ snapshots: kleur@4.1.5: {} + koa-compose@4.1.0: {} + + koa@3.1.2: + dependencies: + accepts: 1.3.8 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookies: 0.9.1 + delegates: 1.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 2.0.1 + koa-compose: 4.1.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + konva@9.3.12: {} kuler@2.0.0: {} @@ -17619,6 +17894,10 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@2.6.0: {} @@ -17765,6 +18044,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.7: {} + napi-build-utils@2.0.0: {} napi-postinstall@0.3.4: @@ -17829,7 +18110,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: @@ -17962,6 +18243,8 @@ snapshots: nullthrows@1.1.1: {} + oauth4webapi@3.8.5: {} + ob1@0.80.12: dependencies: flow-enums-runtime: 0.0.6 @@ -18006,6 +18289,22 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + oidc-provider@9.7.0: + dependencies: + '@koa/cors': 5.0.0 + '@koa/router': 15.4.0(koa@3.1.2) + debug: 4.4.3(supports-color@5.5.0) + eta: 4.5.1 + jose: 6.2.1 + jsesc: 3.1.0 + koa: 3.1.2 + nanoid: 5.1.7 + quick-lru: 7.3.0 + raw-body: 3.0.2 + undici: 7.24.4 + transitivePeerDependencies: + - supports-color + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -18045,6 +18344,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: @@ -18188,6 +18492,8 @@ snapshots: path-to-regexp@6.3.0: {} + path-to-regexp@8.3.0: {} + path-type@4.0.0: {} peberminta@0.9.0: {} @@ -18537,6 +18843,8 @@ snapshots: quick-lru@4.0.1: {} + quick-lru@7.3.0: {} + random-bytes@1.0.0: {} range-parser@1.2.1: {} @@ -18548,6 +18856,13 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -18961,6 +19276,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 @@ -19772,7 +20089,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: {} @@ -19982,6 +20299,12 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -20092,6 +20415,8 @@ snapshots: undici@7.18.2: {} + undici@7.24.4: {} + unicode-canonical-property-names-ecmascript@2.0.0: {} unicode-match-property-ecmascript@2.0.0: 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/seerr-api.yml b/seerr-api.yml index 99ef16cc3c..6d1fb2f46b 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -254,6 +254,64 @@ 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 + PublicOidcProvider: + type: object + readOnly: true + properties: + slug: + type: string + name: + type: string + logo: + type: string + required: + - slug + - name + - logo + OidcSettings: + type: object + properties: + providers: + 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: @@ -2215,6 +2273,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 @@ -4012,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 @@ -4935,6 +5187,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 @@ -5033,6 +5308,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/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/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/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/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/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/lib/settings/index.ts b/server/lib/settings/index.ts index 779413ca66..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; @@ -55,6 +59,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 +165,7 @@ export interface MainSettings { hideBlocklisted: boolean; localLogin: boolean; mediaServerLogin: boolean; + oidcLogin: boolean; newPlexLogin: boolean; discoverRegion: string; streamingRegion: string; @@ -211,6 +235,7 @@ interface FullPublicSettings extends PublicSettings { userEmailRequired: boolean; newPlexLogin: boolean; youtubeUrl: string; + openIdProviders: PublicOidcProvider[]; } export interface NotificationAgentConfig { @@ -365,6 +390,7 @@ export interface AllSettings { main: MainSettings; plex: PlexSettings; jellyfin: JellyfinSettings; + oidc: OidcSettings; tautulli: TautulliSettings; radarr: RadarrSettings[]; sonarr: SonarrSettings[]; @@ -385,233 +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, - 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: '', - }, - 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 { @@ -638,6 +439,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 +522,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, + })) + : [], }; } @@ -784,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; } @@ -847,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/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/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); + }); + }); +}); 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; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 12b5746595..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'; @@ -36,6 +37,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 +111,83 @@ 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 { 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' }); + + 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); +}); + +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/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(), 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/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 ( <>

- +
-