Skip to content

feat: initial support for OpenID Connect authentication#2715

Open
michaelhthomas wants to merge 30 commits intoseerr-team:developfrom
michaelhthomas:feat/oidc-login-basic
Open

feat: initial support for OpenID Connect authentication#2715
michaelhthomas wants to merge 30 commits intoseerr-team:developfrom
michaelhthomas:feat/oidc-login-basic

Conversation

@michaelhthomas
Copy link
Copy Markdown
Contributor

@michaelhthomas michaelhthomas commented Mar 17, 2026

Description

This PR adds basic support for logging in with OpenID Connect. Here's a basic overview of what's included:

  • Add OpenID Connect providers as a supported authentication source, which appear with a button on the login page
  • Users can link an account from an OpenID Connect identity provider to their existing account from the user settings "Linked Accounts" page
  • With the newUserLogin setting (configured at the provider level) enabled, users can log in with their OpenID Connect provider and a new account will be created for them

What isn't included (yet)

  • Configuration of OpenID Connect providers from the UI
    • Must be configured in settings.json
  • Comprehensive documentation
  • End-to-end Cypress tests against a real IDP

All of the above have been implemented and are coming soon, but have been omitted from this PR to ease the review process.

⚠️ Please use the dedicated thread to discuss about anything not related to the code review PR: #2721

How Has This Been Tested?

  • API tests (in this PR)
  • Cypress tests (coming soon)
  • Manual testing

Screenshots / Logs (if applicable)

image

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

Summary by CodeRabbit

  • New Features

    • OpenID Connect (OIDC) login: provider buttons, full auth/callback flow, account linking/unlinking, and persistent linked accounts.
  • UX

    • Integrated loading states on login buttons; smooth login/callback redirects; provider logos and names shown in login and linked-accounts UI.
  • Settings

    • Public settings now expose configured OIDC providers for the UI.
  • Internationalization

    • Added OIDC-related error and status messages.
  • Documentation

    • New guide for configuring OIDC.
  • Tests

    • End-to-end OIDC flow tests added.
  • Chores

    • Added runtime/dev packages for OIDC/testing and tightened Node engine requirement.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 46ed940b-91f8-4cf2-bea6-6794108a3163

📥 Commits

Reviewing files that changed from the base of the PR and between 6af7d47 and 86dcadd.

📒 Files selected for processing (2)
  • src/components/Login/OidcLoginButton.tsx
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx
✅ Files skipped from review due to trivial changes (1)
  • src/components/Login/OidcLoginButton.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx

📝 Walkthrough

Walkthrough

Adds OpenID Connect (OIDC) authentication and linking: server discovery/exchange with PKCE/state, LinkedAccount entity and migrations, API endpoints for OIDC login/callback and linked-account GET/DELETE, client utilities and UI components, settings/schema updates, tests, and documentation.

Changes

Cohort / File(s) Summary
Project Dependencies
package.json
Added runtime openid-client; dev deps fetch-mock, jose; tightened engines.node to ^22.12.0.
API Spec
seerr-api.yml
Added PublicOidcProvider & LinkedAccount schemas and unauthenticated endpoints: GET /auth/oidc/login/{slug}, POST /auth/oidc/callback/{slug}, plus GET/DELETE linked-account user endpoints.
Error Codes
server/constants/error.ts
Added OIDC-specific ApiErrorCode values (OidcProviderDiscoveryFailed, OidcAuthorizationFailed, OidcMissingEmail, OidcAccountAlreadyLinked).
Settings & Types
server/lib/settings/index.ts, server/interfaces/api/..., src/context/SettingsContext.tsx, src/pages/_app.tsx
Introduced OIDC types (OidcProvider, PublicOidcProvider, OidcSettings), added main.oidcLogin, openIdProviders; Settings gained reset(), accessors, DeepPartial handling; client defaults updated.
Entities & Migrations
server/entity/LinkedAccount.ts, server/entity/User.ts, server/migration/postgres/..., server/migration/sqlite/...
New LinkedAccount entity; User.linkedAccounts relation; migrations create linked_accounts table with FK cascade and unique (provider, sub) index.
Server Core
server/index.ts
Initialized cookie-parser with settings.clientId; error handler may include optional error field in JSON responses.
Auth Routes & Tests
server/routes/auth.ts, server/routes/auth.test.ts
Implemented OIDC login/callback handlers (discovery, PKCE/state, token exchange, optional userinfo), linking/creation rules, and expanded tests with mocked OIDC flows and signed JWTs.
User Settings Endpoints & UI
server/routes/user/usersettings.ts, src/components/UserProfile/.../UserLinkedAccountsSettings/index.tsx
Added linked-account GET/DELETE endpoints; client lists, links (initiates OIDC), and unlinks accounts with updated unlink safety logic.
Client OIDC Utilities
src/utils/oidc.ts
New helpers: initiateOidcLogin, processOidcCallback, getOidcErrorMessage, and provider-slug storage helpers.
Login UI & Components
src/components/Login/*, src/components/Common/ButtonWithLoader/*, src/components/Common/Button/*
Added OidcLoginButton, integrated providers into login options, added ButtonWithLoader, exported ButtonProps, and adjusted button behaviors/styles.
Localization & Docs
src/i18n/locale/en.json, docs/using-seerr/settings/users/oidc.md, gen-docs/docusaurus.config.ts
Added OIDC translation keys, new OIDC docs page, and enabled diff syntax highlighting.
Tests & Test Runner
server/test/index.mts
Enabled forceExit: true for node:test runner; updated auth tests and test app error handling.
Misc (Client App)
src/pages/_app.tsx, src/context/SettingsContext.tsx
Propagated openIdProviders default to server-side rendering and client context; adjusted redirect behavior to avoid interfering with OIDC callback code query.

Sequence Diagram(s)

sequenceDiagram
    participant Browser as Browser (Client)
    participant Seerr as Seerr Server
    participant Provider as OIDC Provider

    Browser->>Seerr: GET /auth/oidc/login/:slug?returnUrl=...
    activate Seerr
    Seerr->>Provider: GET /.well-known/openid-configuration
    Provider-->>Seerr: discovery metadata
    Seerr->>Seerr: generate PKCE & state, set signed cookies
    Seerr-->>Browser: { redirectUrl }
    deactivate Seerr

    Browser->>Provider: GET /authorize?code_challenge=...&state=...
    Provider-->>Browser: redirect to callback?code=...&state=...

    Browser->>Seerr: POST /auth/oidc/callback/:slug { callbackUrl }
    activate Seerr
    Seerr->>Seerr: read & clear signed cookies (verifier, state)
    Seerr->>Provider: POST /token (code, code_verifier)
    Provider-->>Seerr: { id_token, access_token }
    Seerr->>Provider: GET /userinfo (optional)
    Provider-->>Seerr: userinfo
    Seerr->>Seerr: validate claims, find/create LinkedAccount & User
    Seerr-->>Browser: 204 No Content (session set if applicable)
    deactivate Seerr
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • gauthier-th
  • fallenbagel
  • 0xSysR3ll

Poem

🐰 I hopped through configs, cookies, and code,

PKCE in paw, down the login road.
Linked accounts gleam, providers align,
New users and tokens, a neat little line.
Hooray for auth — carrots and sign!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: initial support for OpenID Connect authentication' clearly and concisely summarizes the main change—adding OIDC authentication support—which is the primary focus of this changeset.
Linked Issues check ✅ Passed The PR fulfills all coding requirements from issue #183: OIDC provider configuration support, login page integration with configured provider buttons, generic OIDC login functionality, and linked account management.
Out of Scope Changes check ✅ Passed All changes are directly related to OIDC implementation: authentication routes, database models, settings configuration, login UI components, and API endpoints. No unrelated refactoring or scope creep detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@michaelhthomas michaelhthomas changed the title Feat/OIDC login basic feat: initial support for OpenID Connect authentication Mar 17, 2026
@michaelhthomas michaelhthomas force-pushed the feat/oidc-login-basic branch from c8f20ec to 995628b Compare March 17, 2026 18:06
@michaelhthomas michaelhthomas force-pushed the feat/oidc-login-basic branch from 995628b to 20ffef0 Compare March 18, 2026 17:35
@michaelhthomas michaelhthomas force-pushed the feat/oidc-login-basic branch from 20ffef0 to ede8be1 Compare March 18, 2026 17:37
@michaelhthomas michaelhthomas marked this pull request as ready for review March 18, 2026 17:40
@michaelhthomas michaelhthomas requested a review from a team as a code owner March 18, 2026 17:40
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (10)
src/utils/oidc.ts (1)

49-59: Consider storing provider slug after successful API response.

The provider slug is stored in localStorage before the API call. If axios.get fails, the stale slug remains. While this doesn't cause incorrect behavior (callers handle errors, and the slug gets overwritten on retry), moving localStorage.setItem after a successful response would be cleaner:

♻️ Optional cleanup for error resilience
 export async function initiateOidcLogin(
   providerSlug: string,
   returnUrl: string
 ): Promise<void> {
-  localStorage.setItem(OIDC_PROVIDER_KEY, providerSlug);
   const res = await axios.get<{ redirectUrl: string }>(
     `/api/v1/auth/oidc/login/${encodeURIComponent(providerSlug)}`,
     { params: { returnUrl } }
   );
+  localStorage.setItem(OIDC_PROVIDER_KEY, providerSlug);
   window.location.href = res.data.redirectUrl;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/oidc.ts` around lines 49 - 59, The code stores OIDC_PROVIDER_KEY in
localStorage before calling the network request in initiateOidcLogin, leaving a
stale entry if axios.get throws; move localStorage.setItem(OIDC_PROVIDER_KEY,
providerSlug) to after the successful response (after the axios.get resolves and
before setting window.location.href = res.data.redirectUrl) so the provider slug
is only persisted on a successful API response and not on failures.
src/components/Common/Button/index.tsx (1)

105-116: Apply the same width constraint to the anchor branch for consistency.

max-w-full is only on the <button> path right now, so as="a" can still behave differently on long content.

Proposed consistency patch
-        <span className="flex items-center">{children}</span>
+        <span className="flex max-w-full items-center">{children}</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Common/Button/index.tsx` around lines 105 - 116, The anchor
branch rendering when as="a" uses <span className="flex
items-center">{children}</span> which lacks the max-w-full constraint present in
the <button> branch; update the anchor branch’s span to include max-w-full
(i.e., change the span class to "flex max-w-full items-center") so both branches
use the same width constraint and retain consistent behavior; locate this in the
component that builds the anchor path alongside buttonStyle/twMerge, ref,
children and apply the same class change.
seerr-api.yml (1)

258-260: Consider setting an upper bound for openIdProviders.

Adding a maxItems limit on this array improves schema hardening and addresses the static-analysis warning for unbounded arrays.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 258 - 260, The openIdProviders array schema is
unbounded; add a sensible maxItems constraint to the array definition for
openIdProviders to harden the schema and satisfy static analysis. Locate the
array that references components/schemas/PublicOidcProvider (the openIdProviders
entry) and add a maxItems property (e.g., maxItems: 50 or another
project-appropriate limit) alongside type: array and items: $ref:
'#/components/schemas/PublicOidcProvider' so the OpenAPI spec enforces an upper
bound.
server/routes/auth.ts (3)

959-970: Redundant userRepository.save(user) call.

The user is saved on line 959, then linkedAccounts is assigned and saved again on line 970. The second save only updates the in-memory relation array, which doesn't require a database write since the LinkedAccount already has the correct userId foreign key set.

♻️ Remove redundant save
     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);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 959 - 970, The second database write is
redundant: after creating and persisting LinkedAccount (linkedAccount and
linkedAccountsRepository.save), you only set the in-memory relation
user.linkedAccounts = [linkedAccount] but then call await
userRepository.save(user) again; remove that second await
userRepository.save(user) call and keep only the initial await
userRepository.save(user) before creating the LinkedAccount (or, if you need the
relation in-memory, set user.linkedAccounts = [linkedAccount] without saving).
Ensure LinkedAccount construction uses user (or user.id) so the foreign key is
persisted by linkedAccountsRepository.save and no extra userRepository.save is
performed.

703-731: Consider adding SameSite attribute to OIDC cookies for improved security.

The OIDC flow cookies (oidc-code-verifier and oidc-state) are set without an explicit SameSite attribute. Adding SameSite: 'Lax' ensures the cookies are sent during the OIDC redirect while providing protection against CSRF in other contexts.

🔒 Proposed cookie configuration
   res.cookie('oidc-code-verifier', code_verifier, {
     maxAge: 60000,
     httpOnly: true,
     secure: req.protocol === 'https',
+    sameSite: 'lax',
   });

   // ... and similarly for oidc-state cookie:
   res.cookie('oidc-state', state, {
     maxAge: 60000,
     httpOnly: true,
     secure: req.protocol === 'https',
+    sameSite: 'lax',
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 703 - 731, Add an explicit SameSite
attribute (SameSite: 'Lax') to the OIDC flow cookies so they are sent during the
auth redirect but provide CSRF protection otherwise; update the res.cookie calls
that set 'oidc-code-verifier' and 'oidc-state' (the res.cookie(...) invocations
in this block) to include SameSite: 'Lax' in their options objects alongside
maxAge, httpOnly and secure.

768-793: OIDC provider discovery is performed twice (login and callback).

The openIdClient.discovery() call is made in both the login endpoint (line 675) and the callback endpoint (line 770). While this ensures fresh configuration, it adds latency and an external network call on every callback. Consider caching the configuration with a reasonable TTL if performance becomes a concern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 768 - 793, The OIDC discovery call
(openIdClient.discovery) is being invoked in both the login and callback flows
causing duplicate external calls; introduce a simple in-memory cache keyed by
provider.issuerUrl (and clientId) at module scope to store the resulting
openIdClient.Configuration with a TTL, update both places (where
openIdClient.discovery is called) to first check the cache and return the cached
Configuration if still valid, and fall back to calling openIdClient.discovery
and storing the fresh result on success; ensure the cache entry stores the
timestamp so entries older than the TTL are refreshed, and use the same cache
key when referencing provider.issuerUrl/provider.clientId to locate the stored
config for functions handling login and callback.
server/migration/sqlite/1742858484395-AddLinkedAccounts.ts (1)

6-9: Same recommendation: add a unique constraint on (provider, sub) for SQLite.

For consistency with the Postgres migration recommendation, consider adding a unique index on (provider, sub) to enforce data integrity at the database level.

🔒 Proposed migration change
   public async up(queryRunner: QueryRunner): Promise<void> {
     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)`
     );
+    await queryRunner.query(
+      `CREATE UNIQUE INDEX "UQ_linked_accounts_provider_sub" ON "linked_accounts" ("provider", "sub")`
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/migration/sqlite/1742858484395-AddLinkedAccounts.ts` around lines 6 -
9, The migration currently creates the "linked_accounts" table in
AddLinkedAccounts.ts within the up(queryRunner: QueryRunner) method but lacks a
uniqueness constraint on (provider, sub); update the migration to enforce
uniqueness by either adding UNIQUE("provider","sub") to the CREATE TABLE
statement for "linked_accounts" or issuing a CREATE UNIQUE INDEX (e.g.,
IDX_linked_accounts_provider_sub) immediately after table creation using
queryRunner.query, and ensure the corresponding down migration drops that unique
index/constraint when rolling back.
server/routes/auth.test.ts (1)

595-622: Consider cleaning up LinkedAccount records between tests to ensure test isolation.

The test creates a LinkedAccount record that may persist across tests, potentially causing test pollution. Each test in the OIDC suite that creates linked accounts should clean up after itself or use a fresh database state.

Similar pattern exists in lines 650-665.

🧹 Suggested cleanup approach
     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);
+
+      // Cleanup
+      await linkedAccountRepo.delete({ id: linkedAccount.id });
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.test.ts` around lines 595 - 622, This test creates a
persistent LinkedAccount record (via new LinkedAccount and
linkedAccountRepo.save) which can pollute other OIDC tests; update the test (or
the OIDC test suite setup/teardown) to remove the created LinkedAccount after
the test or reset the repository before each test: locate the usage of
LinkedAccount/linkedAccountRepo and either call linkedAccountRepo.delete(...) or
use an afterEach/beforeEach to clear LinkedAccount entries (or truncate the
table) so performOidcCallback and subsequent tests run against an isolated DB
state; ensure the cleanup references the same provider/sub or the saved entity
instance to remove the exact record.
server/migration/postgres/1742858617989-AddLinkedAccount.ts (1)

6-12: Consider adding a unique constraint on (provider, sub) to prevent duplicate linked accounts.

The combination of provider and sub should be unique to prevent the same OIDC identity from being linked multiple times. Without this constraint, the same OIDC account could potentially be linked to multiple users, bypassing the application-level check in the callback handler if there's a race condition.

🔒 Proposed migration change
   public async up(queryRunner: QueryRunner): Promise<void> {
     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`
     );
+    await queryRunner.query(
+      `CREATE UNIQUE INDEX "UQ_linked_accounts_provider_sub" ON "linked_accounts" ("provider", "sub")`
+    );
   }

And update down() to drop the index before the table.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/migration/postgres/1742858617989-AddLinkedAccount.ts` around lines 6 -
12, Add a unique constraint on the linked_accounts table for the (provider, sub)
pair to prevent duplicate OIDC identities: modify the up() migration (in
AddLinkedAccount.ts -> up(queryRunner: QueryRunner)) to add a UNIQUE constraint
or unique index on ("provider","sub") after creating "linked_accounts", and
update down(queryRunner: QueryRunner) to drop that unique index/constraint
before dropping the "linked_accounts" table so rollback succeeds.
src/components/Login/LoginButton.tsx (1)

11-34: Potential conflict if disabled is passed via buttonProps.

If a consumer passes disabled={true} through buttonProps, it will be overwritten by disabled={loading} (spread happens before the explicit disabled prop). This could be intentional (loading always controls disabled state) or a subtle bug. Consider either:

  1. Documenting this behavior
  2. Merging the disabled states: disabled={loading || buttonProps.disabled}
♻️ Option to merge disabled states
 export default function LoginButton({
   loading,
   className,
   children,
   ...buttonProps
 }: LoginButtonProps) {
   return (
     <Button
       className={twMerge(
         'relative min-w-0 flex-grow bg-transparent disabled:opacity-50',
         className
       )}
-      disabled={loading}
       {...buttonProps}
+      disabled={loading || buttonProps.disabled}
     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Login/LoginButton.tsx` around lines 11 - 34, The component
LoginButton currently spreads buttonProps into the Button and then sets
disabled={loading}, which overwrites any disabled passed by consumers; update
LoginButton so the disabled state is merged instead of overwritten: compute a
combinedDisabled from loading and buttonProps.disabled (or remove disabled from
buttonProps before spreading) and pass that combinedDisabled to the Button
(reference LoginButton, buttonProps, loading, and the Button element), ensuring
consumer-provided disabled is respected while still disabling during loading.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@seerr-api.yml`:
- Around line 257-260: MainSettings now includes the openIdProviders array, but
PublicSettings (used by the /settings/public response) lacks this field; update
the PublicSettings schema to add an openIdProviders property with type: array
and items: $ref: '#/components/schemas/PublicOidcProvider' (or include it as
nullable/optional if intended), and ensure the /settings/public response
references the updated PublicSettings so generated clients see the same contract
as MainSettings.

In `@server/routes/auth.ts`:
- Around line 864-880: The current required-claims check in hasRequiredClaims
(which iterates requiredClaims and reads fullUserInfo[claim]) only accepts
boolean true and will reject valid non-boolean claim values; change the
predicate to verify claim presence/truthiness instead of strict === true (e.g.,
treat any non-null/undefined or any truthy value as valid by using a check like
value !== undefined && value !== null or Boolean(value)), or if you require
booleans, add documentation and keep the strict check; update references in the
error/logging path (logger.info using provider.requiredClaims and the next(...)
unauthorized branch) only if you change semantics so logs remain accurate.

In `@server/routes/user/usersettings.ts`:
- Around line 529-546: Current logic returns [] when settings.main.oidcLogin is
false and also filters LinkedAccount.find by activeProviders, hiding accounts
for disabled/removed providers; change this so linked accounts are always
fetched for the user (use linkedAccountsRepository.find with where.user.id only)
regardless of settings.main.oidcLogin or settings.oidc.providers, then compute
an "isActive" flag per account by checking activeProviders =
settings.oidc.providers.map(p=>p.slug) and
activeProviders.includes(account.provider) so the UI can show/unlink accounts
for inactive/removed providers; keep using LinkedAccount,
linkedAccountsRepository and req.params.id to locate records.

In `@src/components/Login/OidcLoginButton.tsx`:
- Around line 9-10: Replace App Router hooks with Pages Router APIs in
OidcLoginButton: import useRouter from 'next/router' (not next/navigation),
remove useSearchParams, and switch logic that reads query params to use
router.query. In the effects that rely on query/OIDC params (the useEffect
blocks around the logic referenced at lines ~55 and ~61), add a guard checking
router.isReady before accessing router.query and include router.isReady and
relevant router.query keys in the dependency arrays so the effects re-run when
the router becomes ready; also handle possibly undefined query values
(null/undefined) before using them to trigger the OIDC callback or provider
flow. Ensure all references to useSearchParams and next/navigation hooks are
replaced with useRouter().query and readiness checks.

In
`@src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx`:
- Around line 313-323: The current condition lets a user unlink an OIDC account
even when it is their only login method; update the ConfirmButton onclick guard
around deleteRequest to block unlinking when acct.type ===
LinkedAccountType.OpenIdConnect and the user has no password and the account is
not a root/admin (i.e., implement or call a helper like canUnlinkOidc(user,
acct) or isLastOidcLogin(user, acct) and only call deleteRequest when that
returns true), keep enableMediaServerUnlink behavior for Plex/Jellyfin
unchanged, and surface a disabled state or tooltip on the ConfirmButton when
unlinking is disallowed.
- Around line 91-114: The router.replace call in the useEffect that handles the
OIDC callback currently clears all query params; update it to preserve existing
router.query values while removing only the OIDC-specific params (e.g.,
code/state) so route params like userId are not lost. In the useEffect (around
getOidcProviderSlug, clearOidcProviderSlug, processOidcCallback), build a
newQuery object from router.query, delete the OIDC keys (code, state, etc.), and
pass that newQuery as the second argument to router.replace(router.pathname, {
query: newQuery }, { shallow: true }) so you preserve other query params while
stripping OIDC parameters. Ensure this change is applied where router.replace is
called and keep existing calls to getOidcProviderSlug, clearOidcProviderSlug,
processOidcCallback, revalidateLinkedAccounts, and setError unchanged.

---

Nitpick comments:
In `@seerr-api.yml`:
- Around line 258-260: The openIdProviders array schema is unbounded; add a
sensible maxItems constraint to the array definition for openIdProviders to
harden the schema and satisfy static analysis. Locate the array that references
components/schemas/PublicOidcProvider (the openIdProviders entry) and add a
maxItems property (e.g., maxItems: 50 or another project-appropriate limit)
alongside type: array and items: $ref: '#/components/schemas/PublicOidcProvider'
so the OpenAPI spec enforces an upper bound.

In `@server/migration/postgres/1742858617989-AddLinkedAccount.ts`:
- Around line 6-12: Add a unique constraint on the linked_accounts table for the
(provider, sub) pair to prevent duplicate OIDC identities: modify the up()
migration (in AddLinkedAccount.ts -> up(queryRunner: QueryRunner)) to add a
UNIQUE constraint or unique index on ("provider","sub") after creating
"linked_accounts", and update down(queryRunner: QueryRunner) to drop that unique
index/constraint before dropping the "linked_accounts" table so rollback
succeeds.

In `@server/migration/sqlite/1742858484395-AddLinkedAccounts.ts`:
- Around line 6-9: The migration currently creates the "linked_accounts" table
in AddLinkedAccounts.ts within the up(queryRunner: QueryRunner) method but lacks
a uniqueness constraint on (provider, sub); update the migration to enforce
uniqueness by either adding UNIQUE("provider","sub") to the CREATE TABLE
statement for "linked_accounts" or issuing a CREATE UNIQUE INDEX (e.g.,
IDX_linked_accounts_provider_sub) immediately after table creation using
queryRunner.query, and ensure the corresponding down migration drops that unique
index/constraint when rolling back.

In `@server/routes/auth.test.ts`:
- Around line 595-622: This test creates a persistent LinkedAccount record (via
new LinkedAccount and linkedAccountRepo.save) which can pollute other OIDC
tests; update the test (or the OIDC test suite setup/teardown) to remove the
created LinkedAccount after the test or reset the repository before each test:
locate the usage of LinkedAccount/linkedAccountRepo and either call
linkedAccountRepo.delete(...) or use an afterEach/beforeEach to clear
LinkedAccount entries (or truncate the table) so performOidcCallback and
subsequent tests run against an isolated DB state; ensure the cleanup references
the same provider/sub or the saved entity instance to remove the exact record.

In `@server/routes/auth.ts`:
- Around line 959-970: The second database write is redundant: after creating
and persisting LinkedAccount (linkedAccount and linkedAccountsRepository.save),
you only set the in-memory relation user.linkedAccounts = [linkedAccount] but
then call await userRepository.save(user) again; remove that second await
userRepository.save(user) call and keep only the initial await
userRepository.save(user) before creating the LinkedAccount (or, if you need the
relation in-memory, set user.linkedAccounts = [linkedAccount] without saving).
Ensure LinkedAccount construction uses user (or user.id) so the foreign key is
persisted by linkedAccountsRepository.save and no extra userRepository.save is
performed.
- Around line 703-731: Add an explicit SameSite attribute (SameSite: 'Lax') to
the OIDC flow cookies so they are sent during the auth redirect but provide CSRF
protection otherwise; update the res.cookie calls that set 'oidc-code-verifier'
and 'oidc-state' (the res.cookie(...) invocations in this block) to include
SameSite: 'Lax' in their options objects alongside maxAge, httpOnly and secure.
- Around line 768-793: The OIDC discovery call (openIdClient.discovery) is being
invoked in both the login and callback flows causing duplicate external calls;
introduce a simple in-memory cache keyed by provider.issuerUrl (and clientId) at
module scope to store the resulting openIdClient.Configuration with a TTL,
update both places (where openIdClient.discovery is called) to first check the
cache and return the cached Configuration if still valid, and fall back to
calling openIdClient.discovery and storing the fresh result on success; ensure
the cache entry stores the timestamp so entries older than the TTL are
refreshed, and use the same cache key when referencing
provider.issuerUrl/provider.clientId to locate the stored config for functions
handling login and callback.

In `@src/components/Common/Button/index.tsx`:
- Around line 105-116: The anchor branch rendering when as="a" uses <span
className="flex items-center">{children}</span> which lacks the max-w-full
constraint present in the <button> branch; update the anchor branch’s span to
include max-w-full (i.e., change the span class to "flex max-w-full
items-center") so both branches use the same width constraint and retain
consistent behavior; locate this in the component that builds the anchor path
alongside buttonStyle/twMerge, ref, children and apply the same class change.

In `@src/components/Login/LoginButton.tsx`:
- Around line 11-34: The component LoginButton currently spreads buttonProps
into the Button and then sets disabled={loading}, which overwrites any disabled
passed by consumers; update LoginButton so the disabled state is merged instead
of overwritten: compute a combinedDisabled from loading and buttonProps.disabled
(or remove disabled from buttonProps before spreading) and pass that
combinedDisabled to the Button (reference LoginButton, buttonProps, loading, and
the Button element), ensuring consumer-provided disabled is respected while
still disabling during loading.

In `@src/utils/oidc.ts`:
- Around line 49-59: The code stores OIDC_PROVIDER_KEY in localStorage before
calling the network request in initiateOidcLogin, leaving a stale entry if
axios.get throws; move localStorage.setItem(OIDC_PROVIDER_KEY, providerSlug) to
after the successful response (after the axios.get resolves and before setting
window.location.href = res.data.redirectUrl) so the provider slug is only
persisted on a successful API response and not on failures.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e26bc5ae-2f0b-4ecb-bc45-534e860d9aa8

📥 Commits

Reviewing files that changed from the base of the PR and between 10f23f0 and ede8be1.

⛔ Files ignored due to path filters (2)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/images/openid.svg is excluded by !**/*.svg
📒 Files selected for processing (24)
  • package.json
  • seerr-api.yml
  • server/constants/error.ts
  • server/entity/LinkedAccount.ts
  • server/entity/User.ts
  • server/index.ts
  • server/interfaces/api/settingsInterfaces.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • server/lib/settings/index.ts
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • server/routes/auth.test.ts
  • server/routes/auth.ts
  • server/routes/user/usersettings.ts
  • src/components/Common/Button/index.tsx
  • src/components/Login/LoginButton.tsx
  • src/components/Login/OidcLoginButton.tsx
  • src/components/Login/PlexLoginButton.tsx
  • src/components/Login/index.tsx
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx
  • src/context/SettingsContext.tsx
  • src/i18n/locale/en.json
  • src/pages/_app.tsx
  • src/utils/oidc.ts

@gauthier-th gauthier-th requested a review from Copilot March 18, 2026 19:42
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds initial OpenID Connect (OIDC) authentication support across the server and UI, enabling configured providers to appear on the login screen and allowing users to link/unlink OIDC identities to their existing accounts.

Changes:

  • Add OIDC auth endpoints (/auth/oidc/login/:slug, /auth/oidc/callback/:slug) plus linked-account persistence.
  • Expose configured public OIDC providers via public settings and surface them in the login + “Linked Accounts” UI.
  • Add DB migrations, API schema/types, and server-side tests for OIDC flows.

Reviewed changes

Copilot reviewed 24 out of 26 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
src/utils/oidc.ts Client-side helpers for initiating OIDC login, handling callbacks, and mapping error codes to i18n messages
src/pages/_app.tsx Adds openIdProviders to default public settings and avoids redirecting away from login during OIDC callback (?code=)
src/i18n/locale/en.json Adds English strings for OIDC-specific error messages
src/context/SettingsContext.tsx Extends default settings to include openIdProviders
src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx Adds OIDC linked-account listing, linking via provider buttons, callback handling, and unlink support
src/components/Login/index.tsx Renders OIDC login buttons and introduces shared LoginButton styling/behavior
src/components/Login/PlexLoginButton.tsx Refactors to use LoginButton and unify loading UI
src/components/Login/OidcLoginButton.tsx New OIDC provider login button with callback handling
src/components/Login/LoginButton.tsx New shared button wrapper with consistent styling + loading spinner
src/components/Common/Button/index.tsx Exports ButtonProps and improves child layout to better support truncation
server/routes/user/usersettings.ts Adds linked-account list + delete endpoints for OIDC accounts
server/routes/auth.ts Implements OIDC discovery, auth URL creation, callback/token exchange, linking, and optional new-user creation
server/routes/auth.test.ts Adds server tests for OIDC login/callback, PKCE/state handling, new-user creation rules, and error cases
server/migration/sqlite/1742858484395-AddLinkedAccounts.ts SQLite migration creating linked_accounts table
server/migration/postgres/1742858617989-AddLinkedAccount.ts Postgres migration creating linked_accounts table
server/lib/settings/index.ts Adds OIDC settings types, public provider exposure, deep-merge improvements, and a reset() helper
server/interfaces/api/userSettingsInterfaces.ts Adds API types for linked OIDC accounts
server/interfaces/api/settingsInterfaces.ts Adds openIdProviders to public settings response type
server/index.ts Extends API error response to optionally include an error code field
server/entity/User.ts Adds linkedAccounts relation
server/entity/LinkedAccount.ts New entity for persisting OIDC linked identities
server/constants/error.ts Adds OIDC-related API error codes
seerr-api.yml Documents new OIDC endpoints, linked accounts endpoints, and provider schemas
public/images/openid.svg Adds OpenID fallback icon asset
package.json Adds openid-client runtime dependency and test deps (fetch-mock, jose)
pnpm-lock.yaml Locks new dependencies
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

@gauthier-th
Copy link
Copy Markdown
Member

Please use the dedicated thread to discuss about anything not related to the code review PR: #2721

@michaelhthomas michaelhthomas force-pushed the feat/oidc-login-basic branch from ede8be1 to 8d1ddac Compare March 21, 2026 00:35
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx (1)

168-208: ⚠️ Potential issue | 🟡 Minor

Use a stable unique key for each link option instead of the display name.

The current code uses key={name} at line 271, but the Login component elsewhere in this codebase establishes the pattern of using provider.slug for OIDC provider keys. Since OIDC provider names are admin-defined and can collide with each other or with the built-in names (Plex, Jellyfin, Emby), duplicate keys will cause React to reuse the wrong dropdown state.

♻️ Suggested change
   const linkable = [
     {
+      key: 'plex',
       name: 'Plex',
       action: () => {
         plexOAuth.preparePopup();
         setTimeout(() => linkPlexAccount(), 1500);
       },
@@
     {
+      key: 'jellyfin',
       name: 'Jellyfin',
       action: () => setShowJellyfinModal(true),
       hide:
         settings.currentSettings.mediaServerType !== MediaServerType.JELLYFIN ||
         accounts.some((a) => a.type === LinkedAccountType.Jellyfin),
     },
     {
+      key: 'emby',
       name: 'Emby',
       action: () => setShowJellyfinModal(true),
       hide:
         settings.currentSettings.mediaServerType !== MediaServerType.EMBY ||
         accounts.some((a) => a.type === LinkedAccountType.Emby),
     },
     ...settings.currentSettings.openIdProviders.map((p) => ({
+      key: `oidc:${p.slug}`,
       name: p.name,
       action: async () => {
         try {
           await initiateOidcLogin(p.slug, window.location.href);
@@
         {currentUser?.id === user?.id && !!linkable.length && (
           <div>
             <Dropdown text="Link Account" buttonType="ghost">
-              {linkable.map(({ name, action }) => (
-                <Dropdown.Item key={name} onClick={action} buttonType="ghost">
+              {linkable.map(({ key, name, action }) => (
+                <Dropdown.Item key={key} onClick={action} buttonType="ghost">
                   {name}
                 </Dropdown.Item>
               ))}
             </Dropdown>
           </div>

Also applies to: 267-274

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx`
around lines 168 - 208, The linkable array builds menu items using display names
which can collide; change the items to include a stable unique id for each entry
and use that as the React key (e.g., add an id/slug field for built-ins and OIDC
entries). For built-ins (Plex, Jellyfin, Emby) set id values like 'plex',
'jellyfin', 'emby', and for OIDC providers use the provider.slug from
settings.currentSettings.openIdProviders; ensure the component rendering these
items (where key={name}) uses that id instead of name and that the action
handlers (plexOAuth.preparePopup, linkPlexAccount, setShowJellyfinModal,
initiateOidcLogin) remain unchanged.
♻️ Duplicate comments (2)
server/routes/user/usersettings.ts (1)

569-583: ⚠️ Potential issue | 🟠 Major

Enforce the unlink safety check on the API too.

The UI now blocks removing the last login path, but this endpoint still deletes any matching OIDC link. A passwordless UserType.LOCAL user with a single linked provider can call this route directly and strand the account.

🔒 Suggested guard
 userSettingsRoutes.delete<{ id: string; acctId: string }>(
   '/linked-accounts/:acctId',
   isOwnProfileOrAdmin(),
   async (req, res) => {
+    const userRepository = getRepository(User);
     const linkedAccountsRepository = getRepository(LinkedAccount);
+    const user = await userRepository.findOne({
+      select: ['id', 'password', 'userType'],
+      where: { id: Number(req.params.id) },
+    });
+
+    if (!user) {
+      return res.status(404).send();
+    }
+
     const condition: FindOptionsWhere<LinkedAccount> = {
       id: Number(req.params.acctId),
       user: {
-        id: Number(req.params.id),
+        id: user.id,
       },
     };
 
     if (await linkedAccountsRepository.exist({ where: condition })) {
+      const linkedAccountCount = await linkedAccountsRepository.count({
+        where: { user: { id: user.id } },
+      });
+      const hasAlternativeLogin =
+        !!user.password ||
+        user.userType !== UserType.LOCAL ||
+        linkedAccountCount > 1;
+
+      if (!hasAlternativeLogin) {
+        return res.status(400).json({
+          message: 'User must keep at least one login method.',
+        });
+      }
+
       await linkedAccountsRepository.delete(condition);
       return res.status(204).send();
     } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/user/usersettings.ts` around lines 569 - 583, The delete
handler userSettingsRoutes.delete currently removes any matching LinkedAccount
without ensuring it isn't the user's last authentication method; to fix, load
the target linked account (via linkedAccountsRepository.findOne with relations
['user']) and/or count the user's linked accounts
(linkedAccountsRepository.count({ where: { user: { id: ... } } })), then if the
owning user has UserType.LOCAL and the count === 1 and the user has no local
credential (e.g., no passwordHash / password field), block the deletion and
return an error (409 or 400) instead of deleting; only proceed to
linkedAccountsRepository.delete(condition) when this safety check passes. Ensure
you reference LinkedAccount, userSettingsRoutes.delete, and UserType.LOCAL in
the change.
seerr-api.yml (1)

257-260: ⚠️ Potential issue | 🟡 Minor

openIdProviders is still missing from the public settings schema.

The provider list was added to MainSettings, but /settings/public still returns PublicSettings, which does not define openIdProviders. That leaves the unauthenticated login contract out of sync with the new OIDC surface.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 257 - 260, PublicSettings is missing the new
openIdProviders list introduced in MainSettings, causing /settings/public to
omit the OIDC provider surface; add an openIdProviders property to the
PublicSettings schema that mirrors MainSettings by declaring type: array and
items: $ref: '#/components/schemas/PublicOidcProvider' (make it optional if
MainSettings treats it as optional) so the unauthenticated settings contract
returned by /settings/public includes the provider list.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@seerr-api.yml`:
- Around line 4055-4062: Change the OpenAPI parameter definition for returnUrl
to accept URI references instead of only absolute URIs: locate the parameter
named returnUrl in the OpenAPI spec (the parameter block containing schema:
type: string and format: uri) and replace format: uri with format: uri-reference
so relative paths like /login are valid; ensure the description and
allowReserved remain unchanged and the schema remains type: string to preserve
backward compatibility.

In `@server/routes/auth.ts`:
- Around line 935-977: The signup creates a User then a LinkedAccount separately
so a failure saving LinkedAccount leaves a stray User; wrap both writes in a
single transaction using the repository/ORM transaction API (e.g., call
userRepository.manager.transaction or similar) so creation of User and
LinkedAccount happens atomically; inside the transaction create the new User
(User constructor), save via userRepository.save, create LinkedAccount
(LinkedAccount constructor) and save via linkedAccountsRepository.save using the
same transactional entity manager, set user.linkedAccounts and save user before
committing, and keep the existing checks (existingUser, provider.newUserLogin,
fullUserInfo.email) outside or as preconditions.
- Around line 700-707: The current code stores a single fixed cookie
'oidc-code-verifier' with a 60s TTL which collides across concurrent OIDC flows
and can expire too soon; instead generate a unique per-auth-flow key (use the
existing OIDC state or generate a UUID) and store the PKCE code_verifier keyed
to that value (e.g., cookie name like oidc-code-verifier-<state> or store a
small server-side map keyed by state) and set the cookie/entry TTL to match the
auth-code lifetime (several minutes) so callbacks can retrieve the correct
code_verifier; update all usages that set/read code_verifier and state
(including the code_challenge creation and the callback handlers referenced
around the other occurrences) to use the per-flow key.

In `@src/components/Login/OidcLoginButton.tsx`:
- Around line 53-65: The effect in OidcLoginButton currently only checks
query.code and ignores OIDC error callbacks (query.error); update the useEffect
guard to also detect when query.error is present for the same provider
(getOidcProviderSlug() === provider.slug), call clearOidcProviderSlug(), and
invoke handleCallback() so error callbacks are processed; apply the same change
to the duplicate logic in UserLinkedAccountsSettings (same query/provider slug
checks) so cancelled/denied authorizations are handled consistently.

---

Outside diff comments:
In
`@src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx`:
- Around line 168-208: The linkable array builds menu items using display names
which can collide; change the items to include a stable unique id for each entry
and use that as the React key (e.g., add an id/slug field for built-ins and OIDC
entries). For built-ins (Plex, Jellyfin, Emby) set id values like 'plex',
'jellyfin', 'emby', and for OIDC providers use the provider.slug from
settings.currentSettings.openIdProviders; ensure the component rendering these
items (where key={name}) uses that id instead of name and that the action
handlers (plexOAuth.preparePopup, linkPlexAccount, setShowJellyfinModal,
initiateOidcLogin) remain unchanged.

---

Duplicate comments:
In `@seerr-api.yml`:
- Around line 257-260: PublicSettings is missing the new openIdProviders list
introduced in MainSettings, causing /settings/public to omit the OIDC provider
surface; add an openIdProviders property to the PublicSettings schema that
mirrors MainSettings by declaring type: array and items: $ref:
'#/components/schemas/PublicOidcProvider' (make it optional if MainSettings
treats it as optional) so the unauthenticated settings contract returned by
/settings/public includes the provider list.

In `@server/routes/user/usersettings.ts`:
- Around line 569-583: The delete handler userSettingsRoutes.delete currently
removes any matching LinkedAccount without ensuring it isn't the user's last
authentication method; to fix, load the target linked account (via
linkedAccountsRepository.findOne with relations ['user']) and/or count the
user's linked accounts (linkedAccountsRepository.count({ where: { user: { id:
... } } })), then if the owning user has UserType.LOCAL and the count === 1 and
the user has no local credential (e.g., no passwordHash / password field), block
the deletion and return an error (409 or 400) instead of deleting; only proceed
to linkedAccountsRepository.delete(condition) when this safety check passes.
Ensure you reference LinkedAccount, userSettingsRoutes.delete, and
UserType.LOCAL in the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4487d7e8-04f7-4b4f-bb5d-1d0be67131a4

📥 Commits

Reviewing files that changed from the base of the PR and between ede8be1 and 8d1ddac.

⛔ Files ignored due to path filters (2)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/images/openid.svg is excluded by !**/*.svg
📒 Files selected for processing (24)
  • package.json
  • seerr-api.yml
  • server/constants/error.ts
  • server/entity/LinkedAccount.ts
  • server/entity/User.ts
  • server/index.ts
  • server/interfaces/api/settingsInterfaces.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • server/lib/settings/index.ts
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • server/routes/auth.test.ts
  • server/routes/auth.ts
  • server/routes/user/usersettings.ts
  • src/components/Common/Button/index.tsx
  • src/components/Login/LoginButton.tsx
  • src/components/Login/OidcLoginButton.tsx
  • src/components/Login/PlexLoginButton.tsx
  • src/components/Login/index.tsx
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx
  • src/context/SettingsContext.tsx
  • src/i18n/locale/en.json
  • src/pages/_app.tsx
  • src/utils/oidc.ts
✅ Files skipped from review due to trivial changes (6)
  • src/context/SettingsContext.tsx
  • server/interfaces/api/userSettingsInterfaces.ts
  • package.json
  • server/constants/error.ts
  • server/entity/LinkedAccount.ts
  • server/interfaces/api/settingsInterfaces.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/components/Common/Button/index.tsx
  • server/index.ts
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • src/components/Login/LoginButton.tsx
  • src/components/Login/PlexLoginButton.tsx
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • server/routes/auth.test.ts
  • src/i18n/locale/en.json
  • src/utils/oidc.ts
  • server/lib/settings/index.ts

Copy link
Copy Markdown
Member

@gauthier-th gauthier-th left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good.
Documentation on how to setup OIDC should also be included in this PR.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
server/entity/User.ts (1)

104-105: Keep linkedAccounts out of generic User.filter() responses.

filter() serializes every enumerable property that is not listed in filteredFields. Once a User is loaded with linkedAccounts, generic user payloads can start leaking linked-provider usernames outside the dedicated linked-accounts settings API.

♻️ Proposed guardrail
   static readonly filteredFields: string[] = [
     'email',
     'plexId',
     'password',
     'resetPasswordGuid',
     'jellyfinDeviceId',
     'jellyfinAuthToken',
     'plexToken',
     'settings',
+    'linkedAccounts',
   ];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/entity/User.ts` around lines 104 - 105, The User entity's
linkedAccounts array is being serialized by the generic User.filter() path and
can leak provider usernames; update the User.filter() implementation (or its
filteredFields list) to explicitly exclude the linkedAccounts property by adding
"linkedAccounts" to the filteredFields set or conditionally skipping properties
named linkedAccounts, ensuring linked-provider data is only exposed via the
dedicated linked-accounts API; reference the User.filter() method and the
linkedAccounts property on the User class when making the change.
src/components/Common/ButtonWithLoader/index.tsx (1)

19-23: Keep ButtonWithLoader behavior-focused.

flex-grow and bg-transparent are login-page styling choices, not loader behavior. Baking them into a shared Common component will surprise the next caller and makes reuse harder.

♻️ Proposed split between shared behavior and call-site styling
     <Button
       className={twMerge(
-        'relative min-w-0 flex-grow bg-transparent disabled:opacity-50',
+        'relative min-w-0 disabled:opacity-50',
         className
       )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Common/ButtonWithLoader/index.tsx` around lines 19 - 23, The
ButtonWithLoader component mixes behavior and page-specific styling by including
'flex-grow' and 'bg-transparent' in the default class list passed to Button;
remove those layout/appearance classes and keep only behavior-focused classes
(e.g., 'relative min-w-0 disabled:opacity-50') in the twMerge call inside
ButtonWithLoader, leaving callers to supply layout classes via the className
prop; update any callers that relied on those defaults (such as the login page)
to add 'flex-grow' and 'bg-transparent' to their ButtonWithLoader usage so
appearance is preserved.
src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx (1)

287-289: Consider using stable keys instead of array index.

Using index as key can cause unnecessary re-renders or state issues if the list order changes. Each account has a unique identifier available:

♻️ Suggested improvement
-          {accounts.map((acct, i) => (
-            <li
-              key={i}
+          {accounts.map((acct) => (
+            <li
+              key={
+                acct.type === LinkedAccountType.OpenIdConnect
+                  ? `oidc-${acct.id}`
+                  : acct.type
+              }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx`
around lines 287 - 289, The list rendering uses the array index as the React key
(in the accounts.map callback where key={i}), which can cause incorrect
re-renders; change the key to a stable unique identifier from each account
object (e.g., acct.id or acct.providerAccountId) in the <li> elements so React
can track items reliably — locate the accounts.map(...) block and replace
key={i} with key={acct.<uniqueIdField>} where <uniqueIdField> is the account's
unique property.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@seerr-api.yml`:
- Around line 5191-5211: Add a 403 response to the DELETE "Remove a linked
account for a user" operation so permission denials are documented; update the
operation that takes path parameters userId and acctId to include a '403'
response entry (e.g., description: "Forbidden — insufficient permissions
(requires MANAGE_USERS to edit other users)") alongside the existing '204' and
'404' responses so clients can handle authorization failures explicitly.
- Around line 4063-4075: The OpenAPI spec for the /auth/oidc/callback/{slug}
route only documents a 200 redirectUrl and a 403 but omits the error payload
emitted when authorization URL generation fails; update the responses for this
operation to include the failure response (e.g., 400 or 500 as appropriate) and
add a JSON schema for the error payload (include properties like "error"
(string) and optional "error_description" (string) and "details" (object/string)
so clients know the contract). Ensure the new response entry references the same
path /auth/oidc/callback/{slug} and clearly documents the error payload shape
returned when authorization URL generation (OIDC initiation) fails.

In
`@src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx`:
- Around line 248-250: canUnlinkMediaServer is too permissive compared to
server-side checks: it currently uses canUnlink (which allows accounts.length >
1) and user?.id !== 1, causing the UI to show unlink for media servers even when
the user lacks a local password/email and the server will reject the request;
update the logic in UserLinkedAccountsSettings where canUnlink and
canUnlinkMediaServer are defined so that canUnlinkMediaServer requires
passwordInfo?.hasPassword (and/or user.email) in addition to existing checks
(references: canUnlink, canUnlinkMediaServer, passwordInfo, accounts, user?.id)
so the client only shows the delete button when the server will accept the
unlink request.

---

Nitpick comments:
In `@server/entity/User.ts`:
- Around line 104-105: The User entity's linkedAccounts array is being
serialized by the generic User.filter() path and can leak provider usernames;
update the User.filter() implementation (or its filteredFields list) to
explicitly exclude the linkedAccounts property by adding "linkedAccounts" to the
filteredFields set or conditionally skipping properties named linkedAccounts,
ensuring linked-provider data is only exposed via the dedicated linked-accounts
API; reference the User.filter() method and the linkedAccounts property on the
User class when making the change.

In `@src/components/Common/ButtonWithLoader/index.tsx`:
- Around line 19-23: The ButtonWithLoader component mixes behavior and
page-specific styling by including 'flex-grow' and 'bg-transparent' in the
default class list passed to Button; remove those layout/appearance classes and
keep only behavior-focused classes (e.g., 'relative min-w-0
disabled:opacity-50') in the twMerge call inside ButtonWithLoader, leaving
callers to supply layout classes via the className prop; update any callers that
relied on those defaults (such as the login page) to add 'flex-grow' and
'bg-transparent' to their ButtonWithLoader usage so appearance is preserved.

In
`@src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx`:
- Around line 287-289: The list rendering uses the array index as the React key
(in the accounts.map callback where key={i}), which can cause incorrect
re-renders; change the key to a stable unique identifier from each account
object (e.g., acct.id or acct.providerAccountId) in the <li> elements so React
can track items reliably — locate the accounts.map(...) block and replace
key={i} with key={acct.<uniqueIdField>} where <uniqueIdField> is the account's
unique property.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: edd2c691-69a7-417a-8804-4ea130a82b8f

📥 Commits

Reviewing files that changed from the base of the PR and between 8d1ddac and 02f4ad8.

📒 Files selected for processing (10)
  • seerr-api.yml
  • server/entity/User.ts
  • server/index.ts
  • server/routes/auth.ts
  • src/components/Common/ButtonWithLoader/index.tsx
  • src/components/Login/OidcLoginButton.tsx
  • src/components/Login/PlexLoginButton.tsx
  • src/components/Login/index.tsx
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx
  • src/utils/oidc.ts
✅ Files skipped from review due to trivial changes (1)
  • src/components/Login/OidcLoginButton.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/components/Login/PlexLoginButton.tsx
  • server/routes/auth.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
docs/using-seerr/settings/users/oidc.md (1)

113-114: Consider recommending specific redirect URIs over wildcard.

The guide suggests using a wildcard /* as the valid redirect URL, which is permissive and allows any path. While this works, it's more secure to configure only the specific redirect URIs needed (as documented in lines 91-92 of the general setup section). Consider revising the Keycloak guide to recommend the specific paths instead of the wildcard.

🔒 Proposed revision for more specific redirect URIs
-1. Finally, set the root url to your Seerr instance's URL, and a wildcard `/*` as a valid redirect URL.
+1. Finally, set the root url to your Seerr instance's URL, and add the following as valid redirect URLs:
+   - `/login`
+   - `/profile/settings/linked-accounts`
   ![Keycloak Step 4](./assets/oidc_keycloak_4.png)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/using-seerr/settings/users/oidc.md` around lines 113 - 114, The docs
currently advise using a wildcard `/*` as the valid redirect URL in the Keycloak
step (the paragraph that ends with "and a wildcard `/*` as a valid redirect URL"
next to the Keycloak Step 4 image); update this to recommend and show the
explicit redirect URIs used by Seerr (the specific paths mentioned in the
general setup section around lines 91–92) instead of the wildcard, and adjust
the explanatory text and the Keycloak Step 4 caption to list those exact
redirect URIs for tighter security.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/using-seerr/settings/users/oidc.md`:
- Line 45: Fix the typo in the documentation sentence by changing "inital" to
"initial" in the line that reads "Unique identifier for the provider. This
should not be changed after inital setup." so it reads "Unique identifier for
the provider. This should not be changed after initial setup."
- Around line 55-59: Remove the tip block that references a non-existent "search
icon" UI and the selfh.st/icons browse feature (the text starting with "The
search icon at the right of the logo field opens the [selfh.st/icons]..."
through the closing :::), since OIDC provider UI is not implemented; update the
docs/using-seerr/settings/users/oidc.md content by deleting that tip paragraph
so the page only documents manual configuration in settings.json.

---

Nitpick comments:
In `@docs/using-seerr/settings/users/oidc.md`:
- Around line 113-114: The docs currently advise using a wildcard `/*` as the
valid redirect URL in the Keycloak step (the paragraph that ends with "and a
wildcard `/*` as a valid redirect URL" next to the Keycloak Step 4 image);
update this to recommend and show the explicit redirect URIs used by Seerr (the
specific paths mentioned in the general setup section around lines 91–92)
instead of the wildcard, and adjust the explanatory text and the Keycloak Step 4
caption to list those exact redirect URIs for tighter security.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 92594e63-080c-4495-a930-807139133af7

📥 Commits

Reviewing files that changed from the base of the PR and between 02f4ad8 and 6ac0ab6.

⛔ Files ignored due to path filters (5)
  • docs/using-seerr/settings/users/assets/oidc_keycloak_1.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_2.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_3.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_4.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_5.png is excluded by !**/*.png
📒 Files selected for processing (3)
  • docs/using-seerr/settings/users/index.md
  • docs/using-seerr/settings/users/oidc.md
  • gen-docs/docusaurus.config.ts
✅ Files skipped from review due to trivial changes (1)
  • gen-docs/docusaurus.config.ts

@michaelhthomas michaelhthomas force-pushed the feat/oidc-login-basic branch from 6ac0ab6 to 78a7d7a Compare March 26, 2026 16:18
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (2)
seerr-api.yml (1)

257-260: ⚠️ Potential issue | 🟡 Minor

openIdProviders is modeled on the wrong settings schema.

MainSettings is reused as the /settings/main request schema, so adding this field here makes it look writable. The login flow needs the public provider list, but PublicSettings still doesn't declare it.

Suggested change
     MainSettings:
       type: object
       properties:
@@
         openIdProviders:
           type: array
+          readOnly: true
           items:
             $ref: '#/components/schemas/PublicOidcProvider'
@@
     PublicSettings:
       type: object
       properties:
         initialized:
           type: boolean
           example: false
+        openIdProviders:
+          type: array
+          items:
+            $ref: '#/components/schemas/PublicOidcProvider'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 257 - 260, The openIdProviders field was added to
MainSettings (making it appear writable via the /settings/main schema) but
should be exposed only on the public settings schema used by the login flow;
remove the openIdProviders entry from MainSettings and instead add a declaration
on PublicSettings as: openIdProviders: type: array, items: $ref:
'#/components/schemas/PublicOidcProvider' (or equivalent), ensuring the
PublicSettings schema exports the provider list while MainSettings no longer
contains openIdProviders.
src/components/Login/OidcLoginButton.tsx (1)

53-68: ⚠️ Potential issue | 🟠 Major

Add router.isReady guard to ensure query params are available.

The effect runs once on mount with an empty dependency array. Since the login page is statically optimized (no getServerSideProps), router.query will be empty on the initial render until after hydration completes and router.isReady becomes true. This means if a user lands directly on /login?code=..., the effect runs with empty query params and the callback is never triggered, since the effect won't run again.

Adding router.isReady to the guard ensures the query params are populated before the effect logic executes:

   useEffect(() => {
-    if (loading) return;
+    if (loading || !router.isReady) return;
 
     // OIDC provider has redirected back with an authorization code or error
     const isCallback = query.code != null || query.error != null;
     ...
-  }, []);
+  }, [router.isReady]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Login/OidcLoginButton.tsx` around lines 53 - 68, The effect
that checks query params should wait for Next router readiness; update the
useEffect so it returns early if loading is true or router.isReady is false, and
add router.isReady to the dependency array so the effect reruns when query
params populate; keep the existing checks for isCallback (using
query.code/query.error) and the logic that calls getOidcProviderSlug(),
clearOidcProviderSlug(), handleCallback(), and redirectToLogin() unchanged.
🧹 Nitpick comments (3)
server/routes/auth.test.ts (1)

462-487: Assert the authenticated session after the callback.

performOidcCallback() returns a bare response from request(app), so the success-path tests never prove that the callback actually establishes a session. A regression that stops setting connect.sid would still pass.

Suggested change
   async function performOidcCallback() {
-    const loginResponse = await request(app)
+    const agent = request.agent(app);
+    const loginResponse = await agent
       .get('/auth/oidc/login/test')
       .set('Accept', 'application/json');
@@
-    const response = await request(app)
+    const response = await agent
       .post('/auth/oidc/callback/test')
       .set('Accept', 'application/json')
       .set('Cookie', cookieHeader)
       .send({ callbackUrl: callbackUrl.toString() });
 
-    return response;
+    return { agent, response };
   }

Then in the success-path tests:

const { agent, response } = await performOidcCallback();
assert.strictEqual(response.status, 204);

const me = await agent.get('/auth/me');
assert.strictEqual(me.status, 200);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.test.ts` around lines 462 - 487, performOidcCallback
currently returns the raw response so tests never verify a session cookie (e.g.
connect.sid); change performOidcCallback to use a persistent supertest agent
(create with request.agent(app)), perform the login and the callback via that
agent so cookies are preserved (use agent.get('/auth/oidc/login/test') and
agent.post('/auth/oidc/callback/test')), then return both the agent and the
callback response (e.g. { agent, response }). Update success-path tests to
assert response.status === 204 and then call agent.get('/auth/me') and assert
that returns 200 to prove the session was established.
server/routes/user/usersettings.ts (1)

559-573: Non-null assertion is safe given the query filter, but consider a defensive guard.

The ! on line 562 relies on the In(activeProviders) filter at line 552 to ensure only accounts matching current providers are returned. This is correct given the current logic, but a future refactor could inadvertently break this invariant.

A quick defensive check would prevent a silent crash:

     const linkedAccountInfo = linkedAccounts.map((acc) => {
       const provider = settings.oidc.providers.find(
         (p) => p.slug === acc.provider
-      )!;
+      );
+      if (!provider) return null;
 
       return {
         id: acc.id,
         ...
       } satisfies UserSettingsLinkedAccount;
-    });
+    }).filter((p): p is UserSettingsLinkedAccount => p != null);

Low priority—current logic is correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/user/usersettings.ts` around lines 559 - 573, The mapping block
that builds linkedAccountInfo uses a non-null assertion on the result of
settings.oidc.providers.find, which can throw if the invariant breaks; update
the linkedAccounts.map in usersettings.ts to defensively handle a missing
provider by checking the find result (the provider variable returned by
settings.oidc.providers.find) and either skipping that account (filtering out)
or logging/throwing a clear error, then only return an object conforming to
UserSettingsLinkedAccount when provider is present; ensure the code references
linkedAccountInfo, linkedAccounts.map, settings.oidc.providers.find, and
UserSettingsLinkedAccount so reviewers can locate the change.
src/utils/oidc.ts (1)

84-88: OIDC error parameter handling is simplified but acceptable.

The IdP may return specific error codes (access_denied, login_required, consent_required, etc.) that could provide more informative messages. Currently, all IdP-side errors map to OidcAuthorizationFailed.

This is fine for the initial implementation—IdP error descriptions aren't always user-friendly anyway. Consider logging the actual error/error_description for debugging if issues arise in production.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/oidc.ts` around lines 84 - 88, The current OIDC error handling
reads the URLSearchParams 'error' into errorParam and always returns
ApiErrorCode.OidcAuthorizationFailed; update this to also read
'error_description' (e.g., params.get('error_description')) and emit a debug log
containing both error and error_description for diagnostics while keeping the
returned value as { type: 'error', errorCode:
ApiErrorCode.OidcAuthorizationFailed }; locate the code that constructs params
and errorParam in this module (URLSearchParams usage) and add a concise log call
that records errorParam and the description so production debugging can surface
IdP-provided details.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/using-seerr/settings/users/oidc.md`:
- Around line 70-72: Clarify that requiredClaims must be present and set to the
boolean true (not just present or truthy) by updating the docs for
requiredClaims to explicitly state the match semantics used by hasRequiredClaims
in server/routes/auth.ts: only claims with the boolean value true pass
validation; other values (e.g., "true", "1", non-boolean, or mere presence) will
not satisfy the requirement.
- Around line 107-113: Remove the recommendation to use a wildcard `/*` redirect
and instead instruct users to configure only the specific Seerr callback paths
already documented above; update the sentence that currently reads "set the root
url to your Seerr instance's URL, and a wildcard `/*` as a valid redirect URL"
to say to add the exact callback URLs listed earlier (do not use wildcard
redirects) and keep the Issuer URL guidance ("Use
`https://<keycloak-url>/realms/<realm>/` as the Issuer URL") unchanged.

In `@server/entity/LinkedAccount.ts`:
- Around line 15-17: The LinkedAccount constructor currently requires an
argument which breaks TypeORM hydration; make the constructor optional by
changing its signature to accept an optional options parameter (e.g.,
constructor(options?: Omit<LinkedAccount,'id'>)) and only call
Object.assign(this, options) when options is provided, so TypeORM can
instantiate empty instances during DB loads; update any call sites that relied
on the old required signature to pass the options or nothing as appropriate.

In `@server/routes/auth.ts`:
- Around line 651-658: getOidcRedirectUrl currently trusts the returnUrl query
and constructs a URL that can point to external origins; validate and only allow
safe redirects by ensuring returnUrl is either a relative path (starts with "/")
or, if absolute, has the same origin as getSettings().main.applicationUrl (or
`${req.protocol}://${req.headers.host}`) before calling new URL(); reject or
fallback to '/login' for anything else. Use the existing symbols returnUrl,
getOidcRedirectUrl, getSettings(), and req to perform the check and normalize
the safe returnUrl prior to creating the URL object.
- Line 810: Validate req.body.callbackUrl before calling new URL(): check that
req.body.callbackUrl is a non-empty string, then wrap the URL parsing in a
try/catch (e.g., when creating redirectUrl with new URL(req.body.callbackUrl))
and on error log the failure and return a 400/invalid-callback response rather
than allowing an unhandled exception; update the logic around redirectUrl and
any downstream use to handle the invalid case gracefully.

---

Duplicate comments:
In `@seerr-api.yml`:
- Around line 257-260: The openIdProviders field was added to MainSettings
(making it appear writable via the /settings/main schema) but should be exposed
only on the public settings schema used by the login flow; remove the
openIdProviders entry from MainSettings and instead add a declaration on
PublicSettings as: openIdProviders: type: array, items: $ref:
'#/components/schemas/PublicOidcProvider' (or equivalent), ensuring the
PublicSettings schema exports the provider list while MainSettings no longer
contains openIdProviders.

In `@src/components/Login/OidcLoginButton.tsx`:
- Around line 53-68: The effect that checks query params should wait for Next
router readiness; update the useEffect so it returns early if loading is true or
router.isReady is false, and add router.isReady to the dependency array so the
effect reruns when query params populate; keep the existing checks for
isCallback (using query.code/query.error) and the logic that calls
getOidcProviderSlug(), clearOidcProviderSlug(), handleCallback(), and
redirectToLogin() unchanged.

---

Nitpick comments:
In `@server/routes/auth.test.ts`:
- Around line 462-487: performOidcCallback currently returns the raw response so
tests never verify a session cookie (e.g. connect.sid); change
performOidcCallback to use a persistent supertest agent (create with
request.agent(app)), perform the login and the callback via that agent so
cookies are preserved (use agent.get('/auth/oidc/login/test') and
agent.post('/auth/oidc/callback/test')), then return both the agent and the
callback response (e.g. { agent, response }). Update success-path tests to
assert response.status === 204 and then call agent.get('/auth/me') and assert
that returns 200 to prove the session was established.

In `@server/routes/user/usersettings.ts`:
- Around line 559-573: The mapping block that builds linkedAccountInfo uses a
non-null assertion on the result of settings.oidc.providers.find, which can
throw if the invariant breaks; update the linkedAccounts.map in usersettings.ts
to defensively handle a missing provider by checking the find result (the
provider variable returned by settings.oidc.providers.find) and either skipping
that account (filtering out) or logging/throwing a clear error, then only return
an object conforming to UserSettingsLinkedAccount when provider is present;
ensure the code references linkedAccountInfo, linkedAccounts.map,
settings.oidc.providers.find, and UserSettingsLinkedAccount so reviewers can
locate the change.

In `@src/utils/oidc.ts`:
- Around line 84-88: The current OIDC error handling reads the URLSearchParams
'error' into errorParam and always returns ApiErrorCode.OidcAuthorizationFailed;
update this to also read 'error_description' (e.g.,
params.get('error_description')) and emit a debug log containing both error and
error_description for diagnostics while keeping the returned value as { type:
'error', errorCode: ApiErrorCode.OidcAuthorizationFailed }; locate the code that
constructs params and errorParam in this module (URLSearchParams usage) and add
a concise log call that records errorParam and the description so production
debugging can surface IdP-provided details.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b05f04de-c154-4a63-8062-1a7b526f1945

📥 Commits

Reviewing files that changed from the base of the PR and between 6ac0ab6 and 78a7d7a.

⛔ Files ignored due to path filters (7)
  • docs/using-seerr/settings/users/assets/oidc_keycloak_1.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_2.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_3.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_4.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_5.png is excluded by !**/*.png
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/images/openid.svg is excluded by !**/*.svg
📒 Files selected for processing (28)
  • docs/using-seerr/settings/users/index.md
  • docs/using-seerr/settings/users/oidc.md
  • gen-docs/docusaurus.config.ts
  • package.json
  • seerr-api.yml
  • server/constants/error.ts
  • server/entity/LinkedAccount.ts
  • server/entity/User.ts
  • server/index.ts
  • server/interfaces/api/settingsInterfaces.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • server/lib/settings/index.ts
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • server/routes/auth.test.ts
  • server/routes/auth.ts
  • server/routes/user/usersettings.ts
  • server/test/index.mts
  • src/components/Common/Button/index.tsx
  • src/components/Common/ButtonWithLoader/index.tsx
  • src/components/Login/OidcLoginButton.tsx
  • src/components/Login/PlexLoginButton.tsx
  • src/components/Login/index.tsx
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx
  • src/context/SettingsContext.tsx
  • src/i18n/locale/en.json
  • src/pages/_app.tsx
  • src/utils/oidc.ts
✅ Files skipped from review due to trivial changes (7)
  • server/interfaces/api/userSettingsInterfaces.ts
  • gen-docs/docusaurus.config.ts
  • package.json
  • server/constants/error.ts
  • src/context/SettingsContext.tsx
  • server/index.ts
  • src/i18n/locale/en.json
🚧 Files skipped from review as they are similar to previous changes (11)
  • server/interfaces/api/settingsInterfaces.ts
  • src/components/Common/Button/index.tsx
  • src/pages/_app.tsx
  • src/components/Login/PlexLoginButton.tsx
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • server/entity/User.ts
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • src/components/Common/ButtonWithLoader/index.tsx
  • src/components/Login/index.tsx
  • server/lib/settings/index.ts
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx

@michaelhthomas
Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 26, 2026

✅ Actions performed

Reviews resumed.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx (1)

248-250: ⚠️ Potential issue | 🟠 Major

Media-server unlink is still looser than the backend.

canUnlinkMediaServer still inherits the accounts.length > 1 branch from canUnlink, so a user with OIDC plus Plex/Jellyfin/Emby but no local credentials will still see a delete button that server/routes/user/usersettings.ts rejects with 400. Keep the broader rule for OpenID Connect rows, but mirror the media-server requirement here.

🔧 Proposed fix
-  const canUnlink = !!passwordInfo?.hasPassword || accounts.length > 1;
+  const canUnlinkOidc = !!passwordInfo?.hasPassword || accounts.length > 1;
   // primary admin cannot unlink their media server account
-  const canUnlinkMediaServer = canUnlink && user?.id !== 1;
+  const canUnlinkMediaServer =
+    !!user?.email && !!passwordInfo?.hasPassword && user?.id !== 1;
@@
-              {(acct.type === LinkedAccountType.OpenIdConnect
-                ? canUnlink
+              {(acct.type === LinkedAccountType.OpenIdConnect
+                ? canUnlinkOidc
                 : canUnlinkMediaServer) && (

Based on learnings: media-server unlink in this UI should require local credentials and the primary-admin restriction; multiple linked accounts alone is insufficient for /linked-accounts/plex and /linked-accounts/jellyfin.

Also applies to: 320-322

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx`
around lines 248 - 250, canUnlinkMediaServer is too permissive because it
inherits accounts.length > 1 from canUnlink and allows media-server rows to show
a delete button even when the user has no local credentials; change the
media-server rule to require local credentials plus the primary-admin check by
making canUnlinkMediaServer = !!passwordInfo?.hasPassword && user?.id !== 1
(remove the accounts.length > 1 clause), and apply the same stricter check to
the other duplicated media-server unlink logic (the second occurrence of this
rule around the other linked-account rows).
src/components/Login/OidcLoginButton.tsx (1)

53-68: ⚠️ Potential issue | 🟠 Major

Wait for the Pages Router query to be ready before running this effect.

In the Pages Router, router.query is not guaranteed on the first render. With the empty dependency array at Line 68, the ?provider= deep link and the ?code=/?error= callback path can be missed permanently if hydration populates the query after mount.

🔧 Proposed fix
-  const { query } = router;
+  const { isReady, query } = router;
@@
   useEffect(() => {
-    if (loading) return;
+    if (loading || !isReady) return;
@@
     if (isCallback && getOidcProviderSlug() === provider.slug) {
       clearOidcProviderSlug();
-      handleCallback();
-    }
-    // Support direct redirect via ?provider=slug query param
-    else if (!isCallback && query.provider === provider.slug) {
-      redirectToLogin();
+      void handleCallback();
+    } else if (
+      !isCallback &&
+      typeof query.provider === 'string' &&
+      query.provider === provider.slug
+    ) {
+      void redirectToLogin();
     }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, []);
+  }, [
+    handleCallback,
+    isReady,
+    loading,
+    provider.slug,
+    query.code,
+    query.error,
+    query.provider,
+    redirectToLogin,
+  ]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Login/OidcLoginButton.tsx` around lines 53 - 68, The effect
runs too early because router.query may be empty on first render; update the
useEffect to wait for the Pages Router to be ready (use router.isReady) and
include router.isReady and query in the dependency array so the effect re-runs
when query becomes available, then perform the same checks and calls
(getOidcProviderSlug(), clearOidcProviderSlug(), handleCallback(),
redirectToLogin()) only when router.isReady is true; remove the eslint-disable
for exhaustive-deps and ensure provider.slug and loading are included in the
dependencies as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/entity/User.ts`:
- Around line 104-106: The OneToMany relation linkedAccounts can leak through
User.filter(), so update the filtering logic to keep linked account data
private: add the property name "linkedAccounts" to the User.filteredFields (or
the array/constant used to drive User.filter()) so User.filter() excludes it,
and/or annotate the linkedAccounts property with a non-serializing decorator
(e.g., `@Exclude` from class-transformer) to prevent automatic exposure; refer to
the linkedAccounts property and the User.filter() / filteredFields symbols when
making the change.

---

Duplicate comments:
In `@src/components/Login/OidcLoginButton.tsx`:
- Around line 53-68: The effect runs too early because router.query may be empty
on first render; update the useEffect to wait for the Pages Router to be ready
(use router.isReady) and include router.isReady and query in the dependency
array so the effect re-runs when query becomes available, then perform the same
checks and calls (getOidcProviderSlug(), clearOidcProviderSlug(),
handleCallback(), redirectToLogin()) only when router.isReady is true; remove
the eslint-disable for exhaustive-deps and ensure provider.slug and loading are
included in the dependencies as well.

In
`@src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx`:
- Around line 248-250: canUnlinkMediaServer is too permissive because it
inherits accounts.length > 1 from canUnlink and allows media-server rows to show
a delete button even when the user has no local credentials; change the
media-server rule to require local credentials plus the primary-admin check by
making canUnlinkMediaServer = !!passwordInfo?.hasPassword && user?.id !== 1
(remove the accounts.length > 1 clause), and apply the same stricter check to
the other duplicated media-server unlink logic (the second occurrence of this
rule around the other linked-account rows).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 183bea7f-f845-4e04-bede-a04a8bfb47d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6ac0ab6 and 490b5e8.

⛔ Files ignored due to path filters (7)
  • docs/using-seerr/settings/users/assets/oidc_keycloak_1.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_2.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_3.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_4.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_5.png is excluded by !**/*.png
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/images/openid.svg is excluded by !**/*.svg
📒 Files selected for processing (28)
  • docs/using-seerr/settings/users/index.md
  • docs/using-seerr/settings/users/oidc.md
  • gen-docs/docusaurus.config.ts
  • package.json
  • seerr-api.yml
  • server/constants/error.ts
  • server/entity/LinkedAccount.ts
  • server/entity/User.ts
  • server/index.ts
  • server/interfaces/api/settingsInterfaces.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • server/lib/settings/index.ts
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • server/routes/auth.test.ts
  • server/routes/auth.ts
  • server/routes/user/usersettings.ts
  • server/test/index.mts
  • src/components/Common/Button/index.tsx
  • src/components/Common/ButtonWithLoader/index.tsx
  • src/components/Login/OidcLoginButton.tsx
  • src/components/Login/PlexLoginButton.tsx
  • src/components/Login/index.tsx
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx
  • src/context/SettingsContext.tsx
  • src/i18n/locale/en.json
  • src/pages/_app.tsx
  • src/utils/oidc.ts
✅ Files skipped from review due to trivial changes (9)
  • gen-docs/docusaurus.config.ts
  • server/interfaces/api/settingsInterfaces.ts
  • server/constants/error.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • package.json
  • server/entity/LinkedAccount.ts
  • src/context/SettingsContext.tsx
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • server/lib/settings/index.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/pages/_app.tsx
  • src/components/Common/Button/index.tsx
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • server/routes/auth.test.ts
  • src/i18n/locale/en.json
  • server/routes/user/usersettings.ts
  • server/routes/auth.ts
👮 Files not reviewed due to content moderation or server errors (5)
  • docs/using-seerr/settings/users/oidc.md
  • src/components/Common/ButtonWithLoader/index.tsx
  • src/components/Login/PlexLoginButton.tsx
  • src/components/Login/index.tsx
  • seerr-api.yml

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
server/entity/User.ts (1)

105-106: Narrow relation cascade scope to ['insert'] only.

Verification confirms linkedAccounts mutations are always performed directly via LinkedAccountsRepository:

  • Deletes at usersettings.ts:623 use linkedAccountsRepository.delete() directly
  • Saves at auth.ts:935 use linkedAccountsRepository.save() directly
  • User cascade is only needed for inserts during account creation (auth.ts:980: user.linkedAccounts = [linkedAccount]; await userRepository.save(user))

Since update and remove operations are never cascaded, restricting to cascade: ['insert'] eliminates unnecessary side effect risk without impact.

Suggested change
-  `@OneToMany`(() => LinkedAccount, (link) => link.user, { cascade: true })
+  `@OneToMany`(() => LinkedAccount, (link) => link.user, {
+    cascade: ['insert'],
+  })
   public linkedAccounts: LinkedAccount[];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/entity/User.ts` around lines 105 - 106, Change the cascade setting on
the User entity's linkedAccounts relation from a blanket true to the narrower
insert-only cascade: update the decorator on the linkedAccounts property (the
`@OneToMany`(() => LinkedAccount, (link) => link.user, { cascade: true }) line) to
use { cascade: ['insert'] } so only inserts are cascaded while updates and
removes remain handled by LinkedAccountsRepository.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@server/entity/User.ts`:
- Around line 105-106: Change the cascade setting on the User entity's
linkedAccounts relation from a blanket true to the narrower insert-only cascade:
update the decorator on the linkedAccounts property (the `@OneToMany`(() =>
LinkedAccount, (link) => link.user, { cascade: true }) line) to use { cascade:
['insert'] } so only inserts are cascaded while updates and removes remain
handled by LinkedAccountsRepository.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d6bfa523-1c0c-41df-ac7e-b2793aeca59c

📥 Commits

Reviewing files that changed from the base of the PR and between 490b5e8 and f1b1b2c.

📒 Files selected for processing (1)
  • server/entity/User.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
server/routes/auth.ts (1)

660-661: ⚠️ Potential issue | 🟠 Major

Key the OIDC cookies per auth attempt.

Dropping the old short TTL helped, but these cookies are still shared across every OIDC flow. A second login/link attempt overwrites the first verifier/state and makes the earlier callback fail nondeterministically. Namespace the transient cookie(s) by a per-flow value and read/clear that specific entry in the callback.

Also applies to: 703-710, 727-734, 804-808

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 660 - 661, The OIDC state and
code-verifier cookies (OIDC_STATE_KEY and OIDC_CODE_VERIFIER_KEY) are global and
get clobbered across concurrent flows; change to per-flow keys by generating a
random flowId on auth start, set cookies using `${OIDC_STATE_KEY}-${flowId}` and
`${OIDC_CODE_VERIFIER_KEY}-${flowId}` and include that flowId in the outbound
redirect (e.g., as a query param or encoded in state). In the callback handlers
that currently read/clear OIDC_STATE_KEY and OIDC_CODE_VERIFIER_KEY (the code
around the blocks you flagged at 703-710, 727-734, 804-808), read and clear the
specific `${...}-${flowId}` entries based on the returned flowId instead of the
shared names so each auth attempt resolves and clears only its own cookies.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/routes/auth.ts`:
- Around line 933-942: The current linked account creation (new LinkedAccount +
linkedAccountsRepository.save in the callback handling) can race despite the
pre-check because the DB enforces UNIQUE (provider, sub); modify the save logic
in the route handler to either perform an atomic upsert/transaction or catch
unique-constraint DB errors from linkedAccountsRepository.save (e.g., Postgres
error code 23505 or ORM UniqueConstraintError) and translate them into the
appropriate conflict/deny HTTP response instead of letting a generic 500 bubble
up; apply the same fix to the other creation site around the LinkedAccount code
at the later block (the second occurrence noted).
- Around line 948-975: Normalize the incoming OIDC email claim before any lookup
or creation: compute a normalizedEmail = fullUserInfo.email.trim().toLowerCase()
(or call your existing normalizeEmail helper) and use that for
userRepository.findOne({ where: { email: normalizedEmail } }) and for the new
User({ email: normalizedEmail, ... }) assignment; also use normalizedEmail when
generating the gravatar URL and any logging so all email comparisons in auth
flow (including userRepository.findOne and the User constructor) are
case-insensitive and whitespace-tolerant.

---

Duplicate comments:
In `@server/routes/auth.ts`:
- Around line 660-661: The OIDC state and code-verifier cookies (OIDC_STATE_KEY
and OIDC_CODE_VERIFIER_KEY) are global and get clobbered across concurrent
flows; change to per-flow keys by generating a random flowId on auth start, set
cookies using `${OIDC_STATE_KEY}-${flowId}` and
`${OIDC_CODE_VERIFIER_KEY}-${flowId}` and include that flowId in the outbound
redirect (e.g., as a query param or encoded in state). In the callback handlers
that currently read/clear OIDC_STATE_KEY and OIDC_CODE_VERIFIER_KEY (the code
around the blocks you flagged at 703-710, 727-734, 804-808), read and clear the
specific `${...}-${flowId}` entries based on the returned flowId instead of the
shared names so each auth attempt resolves and clears only its own cookies.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6dd5d310-6699-4d8a-bc23-aaec6ccf26cc

📥 Commits

Reviewing files that changed from the base of the PR and between f1b1b2c and a5cf3e9.

📒 Files selected for processing (1)
  • server/routes/auth.ts

@michaelhthomas michaelhthomas force-pushed the feat/oidc-login-basic branch from a5cf3e9 to ed3b51c Compare March 26, 2026 21:57
@michaelhthomas michaelhthomas force-pushed the feat/oidc-login-basic branch from ed3b51c to dbe8796 Compare March 27, 2026 15:33
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (7)
docs/using-seerr/settings/users/oidc.md (2)

70-72: ⚠️ Potential issue | 🟡 Minor

Spell out the requiredClaims match semantics.

This still reads like claim presence or truthiness may be enough. The server only accepts claims whose value is the boolean true, so a provider config can look correct here and still fail login.

Suggested change
-Space-separated list of boolean claims that are required to log in
+Space-separated list of claim names that must be present and set to the boolean `true` for login to succeed

Based on learnings OIDC required-claims semantics: in server/routes/auth.ts, hasRequiredClaims is intentionally strict and only passes when the claim value is boolean true.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/using-seerr/settings/users/oidc.md` around lines 70 - 72, Update the
docs for the requiredClaims setting to explicitly state that each required claim
must be present and have the boolean value true (not just exist or be truthy);
reference the server-side check implemented in hasRequiredClaims in
server/routes/auth.ts so readers know the server only accepts claims with value
=== true, and update the description for the requiredClaims section to reflect
this strict match semantics.

107-113: ⚠️ Potential issue | 🟡 Minor

Avoid recommending a wildcard redirect URL.

The exact Seerr callback paths are already documented above. Suggesting /* broadens the IdP client configuration unnecessarily and can hide redirect mismatches during setup.

Suggested change
-1. Finally, set the root url to your Seerr instance's URL, and a wildcard `/*` as a valid redirect URL.
+1. Finally, set the root URL to your Seerr instance's URL, and add the exact callback URLs listed above instead of a wildcard.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/using-seerr/settings/users/oidc.md` around lines 107 - 113, The
paragraph that tells users to "set the root url to your Seerr instance's URL,
and a wildcard `/*` as a valid redirect URL" should be changed to avoid
recommending a wildcard redirect; instead instruct readers to use the exact
Seerr OAuth callback URLs (the callback paths already documented earlier in this
doc) as the valid redirect URIs in the IdP client configuration, and provide a
concrete example format using the Seerr instance URL plus the documented
callback path (e.g., https://<seerr-url>/<callback-path>) to replace the `/*`
suggestion; update the sentence referencing the wildcard so it points readers to
the documented callback paths above and removes the wildcard recommendation.
seerr-api.yml (1)

257-260: ⚠️ Potential issue | 🟠 Major

Keep /settings/public contract aligned with openIdProviders.

Line 257 adds openIdProviders to MainSettings, but PublicSettings (used by /settings/public) still omits it, so generated clients can miss this field.

🧾 Proposed OpenAPI fix
     PublicSettings:
       type: object
       properties:
         initialized:
           type: boolean
           example: false
+        openIdProviders:
+          type: array
+          items:
+            $ref: '#/components/schemas/PublicOidcProvider'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 257 - 260, PublicSettings (used by
/settings/public) is missing the openIdProviders property that was added to
MainSettings, causing generated clients to omit it; update the components schema
for PublicSettings to include an openIdProviders property with the same type and
items reference as MainSettings (type: array, items: $ref
'#/components/schemas/PublicOidcProvider') so the public settings contract
matches MainSettings and clients receive the field.
server/routes/auth.ts (4)

936-940: ⚠️ Potential issue | 🟠 Major

Email case normalization missing in OIDC user lookup.

The existingUser lookup uses fullUserInfo.email directly, but the codebase normalizes emails with toLowerCase() elsewhere (e.g., lines 81, 615). An IdP returning Alice@Example.com would miss an existing alice@example.com user, potentially causing duplicate account creation or constraint violations.

🔧 Proposed fix
+    const normalizedEmail = fullUserInfo.email?.toLowerCase();
-    if (!user && fullUserInfo.email != null && provider.newUserLogin) {
+    if (!user && normalizedEmail && provider.newUserLogin) {
       const existingUser = await userRepository.findOne({
-        where: { email: fullUserInfo.email },
+        where: { email: normalizedEmail },
       });
       ...
       user = new User({
         ...
-        email: fullUserInfo.email,
+        email: normalizedEmail,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 936 - 940, The OIDC lookup uses
fullUserInfo.email as-is which can miss matches due to case differences; update
the existingUser query (around the block checking if (!user &&
fullUserInfo.email != null && provider.newUserLogin)) to normalize the email
before querying (e.g., compute a lowercasedEmail =
fullUserInfo.email.trim().toLowerCase()) and use that in
userRepository.findOne({ where: { email: lowercasedEmail } }); also ensure any
downstream creation of the new user uses the same normalized email to avoid
duplicates (refer to existingUser, fullUserInfo.email, provider.newUserLogin,
and userRepository.findOne).

967-975: ⚠️ Potential issue | 🟠 Major

Non-atomic User + LinkedAccount creation persists.

If userRepository.save(user) succeeds for the User but the cascaded LinkedAccount insert fails (e.g., unique constraint race), an orphaned user without any linked identity remains. Subsequent OIDC attempts will hit the existingUser check and be denied. Wrap both writes in a transaction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 967 - 975, The code currently assigns a
new LinkedAccount to user.linkedAccounts and calls userRepository.save(user)
which can persist the User while the cascaded LinkedAccount insert fails,
leaving an orphaned user; change this to perform both inserts inside a single
database transaction (e.g., using your ORM's transaction runner or manager) so
that creation of LinkedAccount and User are atomic: begin a transaction, save
the LinkedAccount and associate it to the User (or save the User with its
linkedAccounts) via the transactional entity manager, commit on success and
rollback on error, and ensure errors (like unique constraint conflicts) cause
the whole transaction to roll back instead of leaving an orphaned user.

920-929: ⚠️ Potential issue | 🟠 Major

Race condition on LinkedAccount creation remains unaddressed.

The existence check before insert doesn't prevent concurrent callbacks from racing. The DB unique constraint on (provider, sub) will reject one insert, surfacing as a 500 error. Consider catching unique-constraint errors and mapping to an appropriate response, or using an upsert.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 920 - 929, The LinkedAccount creation has
a race: the pre-check can be lost to concurrent inserts causing
linkedAccountsRepository.save(newLinkedAccount) to throw a DB unique-constraint
error; update the block that constructs and saves a new LinkedAccount (new
LinkedAccount, linkedAccountsRepository.save) to handle this by either
performing an upsert/insert-or-ignore at the repository level or wrapping the
save in a try/catch that catches the DB unique-constraint error for
(provider.slug, fullUserInfo.sub), then in the catch load the existing
LinkedAccount (by provider and sub) and treat that as the successful result (or
return a 409/appropriate response), so concurrent callbacks no longer produce a
500.

798-812: ⚠️ Potential issue | 🔴 Critical

Fail-fast when OIDC correlation cookies are missing.

The callback proceeds to authorizationCodeGrant even when pkceCodeVerifier or expectedState are undefined (missing or tampered cookies). While the openid-client library may catch state mismatches, if the IdP doesn't enforce PKCE or doesn't return state in certain error flows, an unsolicited callback could succeed. Add an explicit guard to fail closed.

🔒 Proposed fix
     const pkceCodeVerifier: string | undefined =
       req.signedCookies[OIDC_CODE_VERIFIER_KEY];
     const expectedState: string | undefined = req.signedCookies[OIDC_STATE_KEY];
     res.clearCookie(OIDC_CODE_VERIFIER_KEY);
     res.clearCookie(OIDC_STATE_KEY);

+    if (!pkceCodeVerifier || !expectedState) {
+      logger.warn('Rejected OIDC callback: missing correlation cookies', {
+        label: 'Auth',
+        provider: provider.slug,
+        ip: req.ip,
+      });
+      return next({
+        status: 400,
+        error: ApiErrorCode.OidcAuthorizationFailed,
+      });
+    }
+
     const redirectUrl = new URL(req.body.callbackUrl);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 798 - 812, Read the signed cookies into
pkceCodeVerifier and expectedState and add an explicit guard that returns a 400
(or throws) if either is undefined before proceeding to call
openIdClient.authorizationCodeGrant; reference the cookie keys
OIDC_CODE_VERIFIER_KEY and OIDC_STATE_KEY and the variables
pkceCodeVerifier/expectedState, perform the check immediately after reading them
(and preferably before or instead of clearing cookies), and stop the handler
early with an error response so authorizationCodeGrant is never invoked with
missing correlation data.
🧹 Nitpick comments (1)
server/routes/auth.ts (1)

707-729: Consider adding sameSite and maxAge to OIDC correlation cookies.

The signed cookies provide integrity, but explicitly setting sameSite: 'lax' and a reasonable maxAge (e.g., 10 minutes) would improve clarity and limit the cookie lifetime:

🔧 Proposed enhancement
   res.cookie(OIDC_CODE_VERIFIER_KEY, code_verifier, {
     httpOnly: true,
     secure: req.protocol === 'https',
     signed: true,
+    sameSite: 'lax',
+    maxAge: 10 * 60 * 1000, // 10 minutes
   });
   ...
   res.cookie(OIDC_STATE_KEY, state, {
     httpOnly: true,
     secure: req.protocol === 'https',
     signed: true,
+    sameSite: 'lax',
+    maxAge: 10 * 60 * 1000, // 10 minutes
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 707 - 729, Add explicit sameSite and
maxAge options to the OIDC correlation cookies to limit lifetime and improve
CSRF protection: when setting the code_verifier cookie (res.cookie with
OIDC_CODE_VERIFIER_KEY, code_verifier) and the state cookie (res.cookie with
OIDC_STATE_KEY, state), include sameSite: 'lax' and a maxAge (e.g., 10 minutes
in milliseconds) in the cookie options alongside httpOnly, secure, and signed;
update both cookie option objects so the changes apply consistently wherever
getOidcRedirectUrl and openIdClient.randomState() are used to initiate the OIDC
flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@package.json`:
- Line 79: Update the Node.js engine requirement in package.json to ensure
ESM-only deps like openid-client and jose work: locate the "engines" entry in
package.json (the "node" field) and change its value from "^22.0.0" to
"^22.12.0" so the project requires Node versions supporting require(esm) (i.e.,
^20.19.0 || ^22.12.0 || >=23.0.0).

In `@seerr-api.yml`:
- Around line 5156-5157: The 403 response description in seerr-api.yml currently
reads "Invalid credentials" which is misleading; update the '403' description
for the linked-account listing operation to indicate an authorization/forbidden
error (e.g., "Forbidden — insufficient permissions" or "Insufficient permissions
to list linked accounts") so API consumers understand it's a permissions failure
rather than invalid authentication.

In `@server/routes/user/usersettings.ts`:
- Around line 350-358: The count currently uses linkedAccountsRepository.count({
where: { user: { id: user.id } } }) and thus counts all LinkedAccount rows;
change the queries that compute oidcAccountCount (and the similar checks at the
other locations) to only count linked accounts whose provider is one of the
active provider slugs used by the listing route (use the same
activeProviderSlugs variable defined in this file). Concretely, modify the
count/query for LinkedAccount (or use a queryBuilder join to Provider) to add a
filter like provider.slug IN activeProviderSlugs (or provider:
In(activeProviderSlugs)) so the fallback sign-in check only considers active
OIDC providers; apply the same change for the checks around the other ranges
mentioned (the blocks that compute and check oidcAccountCount at 509-517 and
599-613).

---

Duplicate comments:
In `@docs/using-seerr/settings/users/oidc.md`:
- Around line 70-72: Update the docs for the requiredClaims setting to
explicitly state that each required claim must be present and have the boolean
value true (not just exist or be truthy); reference the server-side check
implemented in hasRequiredClaims in server/routes/auth.ts so readers know the
server only accepts claims with value === true, and update the description for
the requiredClaims section to reflect this strict match semantics.
- Around line 107-113: The paragraph that tells users to "set the root url to
your Seerr instance's URL, and a wildcard `/*` as a valid redirect URL" should
be changed to avoid recommending a wildcard redirect; instead instruct readers
to use the exact Seerr OAuth callback URLs (the callback paths already
documented earlier in this doc) as the valid redirect URIs in the IdP client
configuration, and provide a concrete example format using the Seerr instance
URL plus the documented callback path (e.g.,
https://<seerr-url>/<callback-path>) to replace the `/*` suggestion; update the
sentence referencing the wildcard so it points readers to the documented
callback paths above and removes the wildcard recommendation.

In `@seerr-api.yml`:
- Around line 257-260: PublicSettings (used by /settings/public) is missing the
openIdProviders property that was added to MainSettings, causing generated
clients to omit it; update the components schema for PublicSettings to include
an openIdProviders property with the same type and items reference as
MainSettings (type: array, items: $ref
'#/components/schemas/PublicOidcProvider') so the public settings contract
matches MainSettings and clients receive the field.

In `@server/routes/auth.ts`:
- Around line 936-940: The OIDC lookup uses fullUserInfo.email as-is which can
miss matches due to case differences; update the existingUser query (around the
block checking if (!user && fullUserInfo.email != null &&
provider.newUserLogin)) to normalize the email before querying (e.g., compute a
lowercasedEmail = fullUserInfo.email.trim().toLowerCase()) and use that in
userRepository.findOne({ where: { email: lowercasedEmail } }); also ensure any
downstream creation of the new user uses the same normalized email to avoid
duplicates (refer to existingUser, fullUserInfo.email, provider.newUserLogin,
and userRepository.findOne).
- Around line 967-975: The code currently assigns a new LinkedAccount to
user.linkedAccounts and calls userRepository.save(user) which can persist the
User while the cascaded LinkedAccount insert fails, leaving an orphaned user;
change this to perform both inserts inside a single database transaction (e.g.,
using your ORM's transaction runner or manager) so that creation of
LinkedAccount and User are atomic: begin a transaction, save the LinkedAccount
and associate it to the User (or save the User with its linkedAccounts) via the
transactional entity manager, commit on success and rollback on error, and
ensure errors (like unique constraint conflicts) cause the whole transaction to
roll back instead of leaving an orphaned user.
- Around line 920-929: The LinkedAccount creation has a race: the pre-check can
be lost to concurrent inserts causing
linkedAccountsRepository.save(newLinkedAccount) to throw a DB unique-constraint
error; update the block that constructs and saves a new LinkedAccount (new
LinkedAccount, linkedAccountsRepository.save) to handle this by either
performing an upsert/insert-or-ignore at the repository level or wrapping the
save in a try/catch that catches the DB unique-constraint error for
(provider.slug, fullUserInfo.sub), then in the catch load the existing
LinkedAccount (by provider and sub) and treat that as the successful result (or
return a 409/appropriate response), so concurrent callbacks no longer produce a
500.
- Around line 798-812: Read the signed cookies into pkceCodeVerifier and
expectedState and add an explicit guard that returns a 400 (or throws) if either
is undefined before proceeding to call openIdClient.authorizationCodeGrant;
reference the cookie keys OIDC_CODE_VERIFIER_KEY and OIDC_STATE_KEY and the
variables pkceCodeVerifier/expectedState, perform the check immediately after
reading them (and preferably before or instead of clearing cookies), and stop
the handler early with an error response so authorizationCodeGrant is never
invoked with missing correlation data.

---

Nitpick comments:
In `@server/routes/auth.ts`:
- Around line 707-729: Add explicit sameSite and maxAge options to the OIDC
correlation cookies to limit lifetime and improve CSRF protection: when setting
the code_verifier cookie (res.cookie with OIDC_CODE_VERIFIER_KEY, code_verifier)
and the state cookie (res.cookie with OIDC_STATE_KEY, state), include sameSite:
'lax' and a maxAge (e.g., 10 minutes in milliseconds) in the cookie options
alongside httpOnly, secure, and signed; update both cookie option objects so the
changes apply consistently wherever getOidcRedirectUrl and
openIdClient.randomState() are used to initiate the OIDC flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: feedebbb-629c-4c3b-a4ac-f8a7b0dd7c27

📥 Commits

Reviewing files that changed from the base of the PR and between ed3b51c and dbe8796.

⛔ Files ignored due to path filters (7)
  • docs/using-seerr/settings/users/assets/oidc_keycloak_1.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_2.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_3.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_4.png is excluded by !**/*.png
  • docs/using-seerr/settings/users/assets/oidc_keycloak_5.png is excluded by !**/*.png
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • public/images/openid.svg is excluded by !**/*.svg
📒 Files selected for processing (28)
  • docs/using-seerr/settings/users/index.md
  • docs/using-seerr/settings/users/oidc.md
  • gen-docs/docusaurus.config.ts
  • package.json
  • seerr-api.yml
  • server/constants/error.ts
  • server/entity/LinkedAccount.ts
  • server/entity/User.ts
  • server/index.ts
  • server/interfaces/api/settingsInterfaces.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • server/lib/settings/index.ts
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • server/routes/auth.test.ts
  • server/routes/auth.ts
  • server/routes/user/usersettings.ts
  • server/test/index.mts
  • src/components/Common/Button/index.tsx
  • src/components/Common/ButtonWithLoader/index.tsx
  • src/components/Login/OidcLoginButton.tsx
  • src/components/Login/PlexLoginButton.tsx
  • src/components/Login/index.tsx
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx
  • src/context/SettingsContext.tsx
  • src/i18n/locale/en.json
  • src/pages/_app.tsx
  • src/utils/oidc.ts
✅ Files skipped from review due to trivial changes (6)
  • gen-docs/docusaurus.config.ts
  • server/constants/error.ts
  • server/migration/postgres/1742858617989-AddLinkedAccount.ts
  • server/interfaces/api/userSettingsInterfaces.ts
  • src/utils/oidc.ts
  • server/lib/settings/index.ts
🚧 Files skipped from review as they are similar to previous changes (13)
  • server/test/index.mts
  • src/context/SettingsContext.tsx
  • server/interfaces/api/settingsInterfaces.ts
  • src/components/Common/Button/index.tsx
  • server/entity/User.ts
  • server/entity/LinkedAccount.ts
  • server/migration/sqlite/1742858484395-AddLinkedAccounts.ts
  • src/components/Login/PlexLoginButton.tsx
  • src/components/Common/ButtonWithLoader/index.tsx
  • src/components/Login/OidcLoginButton.tsx
  • src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx
  • server/routes/auth.test.ts
  • src/components/Login/index.tsx

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
server/routes/auth.ts (2)

949-975: ⚠️ Potential issue | 🟠 Major

Normalize OIDC email before lookup and persistence.

Line 952 and Line 974 use raw IdP email casing/whitespace. This can miss existing users and create duplicate-account conflicts depending on DB collation. Normalize once and reuse.

🔧 Suggested fix
-    if (!user && fullUserInfo.email != null && provider.newUserLogin) {
+    const normalizedEmail = fullUserInfo.email?.trim().toLowerCase();
+    if (!user && normalizedEmail != null && provider.newUserLogin) {
       // Check if a user with this email already exists
       const existingUser = await userRepository.findOne({
-        where: { email: fullUserInfo.email },
+        where: { email: normalizedEmail },
       });

@@
-      logger.info(`Creating user for ${fullUserInfo.email}`, {
+      logger.info(`Creating user for ${normalizedEmail}`, {
         ip: req.ip,
-        email: fullUserInfo.email,
+        email: normalizedEmail,
       });

       const avatar =
         fullUserInfo.picture ??
-        gravatarUrl(fullUserInfo.email, { default: 'mm', size: 200 });
+        gravatarUrl(normalizedEmail, { default: 'mm', size: 200 });

       user = new User({
         avatar: avatar,
         username: fullUserInfo.preferred_username,
-        email: fullUserInfo.email,
+        email: normalizedEmail,
         permissions: settings.main.defaultPermissions,
         plexToken: '',
         userType: UserType.LOCAL,
       });
@@
         provider: provider.slug,
         sub: fullUserInfo.sub,
-        username: fullUserInfo.preferred_username ?? fullUserInfo.email,
+        username: fullUserInfo.preferred_username ?? normalizedEmail,
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 949 - 975, Normalize the OIDC email once
(trim + toLowerCase) and reuse it for lookup and persistence: compute a
normalizedEmail from fullUserInfo.email early in the provider.newUserLogin
block, use normalizedEmail in userRepository.findOne(...) instead of
fullUserInfo.email, use normalizedEmail when generating gravatarUrl(...) and
when constructing the new User(...). Ensure any logging that prints the email
uses the normalizedEmail as well so lookups and created user records are
consistent.

933-943: ⚠️ Potential issue | 🟠 Major

Handle linked-account uniqueness races explicitly.

Line 942 and Line 988 can still surface a generic 500 under concurrent callbacks due to unique (provider, sub) collisions. Catch and translate duplicate-key failures to deterministic auth responses.

🔧 Suggested hardening
+const isLinkedAccountUniqueViolation = (error: unknown): boolean => {
+  if (!(error instanceof Error)) return false;
+  // Keep DB-agnostic fallback; can be refined per driver in follow-up.
+  return /unique|duplicate|constraint/i.test(error.message);
+};

@@
       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);
+        try {
+          await linkedAccountsRepository.save(newLinkedAccount);
+        } catch (error) {
+          if (isLinkedAccountUniqueViolation(error)) {
+            const existing = await linkedAccountsRepository.findOne({
+              relations: { user: true },
+              where: { provider: provider.slug, sub: fullUserInfo.sub },
+            });
+            if (existing && existing.user.id !== req.user.id) {
+              return next({
+                status: 409,
+                error: ApiErrorCode.OidcAccountAlreadyLinked,
+              });
+            }
+            return res.sendStatus(204);
+          }
+          throw error;
+        }
       }

@@
-      await userRepository.save(user);
+      try {
+        await userRepository.save(user);
+      } catch (error) {
+        if (isLinkedAccountUniqueViolation(error)) {
+          return next({
+            status: 409,
+            error: ApiErrorCode.OidcAccountAlreadyLinked,
+          });
+        }
+        throw error;
+      }
#!/bin/bash
set -euo pipefail

echo "1) Confirm linked-account uniqueness constraints"
fd LinkedAccount.ts server/entity --exec sed -n '1,220p' {}

echo
echo "2) Inspect OIDC linked-account creation sites and current error handling"
rg -n -C4 'new LinkedAccount|linkedAccountsRepository\.save|OidcAccountAlreadyLinked|userRepository\.save\(user\)' server/routes/auth.ts

Also applies to: 980-989

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/auth.ts` around lines 933 - 943, The code creating a new
LinkedAccount can race under concurrent callbacks and surface a generic 500 on
unique (provider, sub) violations; wrap the save of the new LinkedAccount (the
new LinkedAccount(...) and linkedAccountsRepository.save(...) call) in a
try/catch that detects duplicate-key/unique-constraint database errors and
translates them into a deterministic auth error response (e.g., throw or return
the existing OidcAccountAlreadyLinked / a specific AuthError) mentioning
provider.slug and fullUserInfo.sub; apply the same handling to the other similar
creation site that calls linkedAccountsRepository.save (the second block around
the other OIDC-linked-account creation) so duplicate-key failures are
consistently mapped to the same auth response rather than a 500.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@server/routes/auth.ts`:
- Around line 949-975: Normalize the OIDC email once (trim + toLowerCase) and
reuse it for lookup and persistence: compute a normalizedEmail from
fullUserInfo.email early in the provider.newUserLogin block, use normalizedEmail
in userRepository.findOne(...) instead of fullUserInfo.email, use
normalizedEmail when generating gravatarUrl(...) and when constructing the new
User(...). Ensure any logging that prints the email uses the normalizedEmail as
well so lookups and created user records are consistent.
- Around line 933-943: The code creating a new LinkedAccount can race under
concurrent callbacks and surface a generic 500 on unique (provider, sub)
violations; wrap the save of the new LinkedAccount (the new LinkedAccount(...)
and linkedAccountsRepository.save(...) call) in a try/catch that detects
duplicate-key/unique-constraint database errors and translates them into a
deterministic auth error response (e.g., throw or return the existing
OidcAccountAlreadyLinked / a specific AuthError) mentioning provider.slug and
fullUserInfo.sub; apply the same handling to the other similar creation site
that calls linkedAccountsRepository.save (the second block around the other
OIDC-linked-account creation) so duplicate-key failures are consistently mapped
to the same auth response rather than a 500.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f95f2ed5-8c9b-4b58-984e-b70d054281c2

📥 Commits

Reviewing files that changed from the base of the PR and between dbe8796 and c4f7f67.

📒 Files selected for processing (2)
  • server/routes/auth.test.ts
  • server/routes/auth.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/routes/auth.test.ts

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds initial OpenID Connect (OIDC) authentication support across the Seerr server, API schema, UI, settings, migrations, and documentation to enable OIDC login and account linking.

Changes:

  • Implement OIDC auth endpoints (login + callback), linked-account persistence, and new error codes.
  • Expose configured OIDC providers via public settings and add UI for OIDC login + linked account management.
  • Add database migrations, API docs/schema updates, and server tests covering core OIDC flows.

Reviewed changes

Copilot reviewed 27 out of 35 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/utils/oidc.ts Client-side helpers for OIDC login initiation, callback processing, and error messaging
src/pages/_app.tsx Prevent redirect-on-login when handling OIDC callback code
src/i18n/locale/en.json Adds OIDC-related UI strings
src/context/SettingsContext.tsx Extends default public settings with openIdProviders
src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx Adds OIDC linked account listing, linking, callback handling, and unlink logic
src/components/Login/index.tsx Renders OIDC provider buttons and updates login button behavior/layout
src/components/Login/PlexLoginButton.tsx Switches to shared ButtonWithLoader for consistent loading UX
src/components/Login/OidcLoginButton.tsx New OIDC provider login button with callback + deep-link support
src/components/Common/ButtonWithLoader/index.tsx New shared button wrapper with inline spinner/loading disable behavior
src/components/Common/Button/index.tsx Exports ButtonProps and improves child layout constraints
server/test/index.mts Forces test runner to exit to avoid hangs on failing tests
server/routes/user/usersettings.ts Adds linked-account list/delete endpoints and adjusts unlink guards to consider linked accounts
server/routes/auth.ts Adds OIDC login + callback endpoints, PKCE/state correlation cookies, and linked-account handling
server/routes/auth.test.ts Adds OIDC flow tests (discovery, state/PKCE, existing user, new user, error cases)
server/migration/sqlite/1742858484395-AddLinkedAccounts.ts SQLite migration to add linked_accounts table and indexes
server/migration/postgres/1742858617989-AddLinkedAccount.ts Postgres migration to add linked_accounts table and indexes
server/lib/settings/index.ts Adds OIDC settings types, public provider exposure, deep-partial merging, and reset()
server/interfaces/api/userSettingsInterfaces.ts Adds API types for linked OIDC accounts
server/interfaces/api/settingsInterfaces.ts Extends public settings response with openIdProviders
server/index.ts Uses settings-derived cookie signing secret and includes optional error in JSON error responses
server/entity/User.ts Adds linkedAccounts relation and filters it from serialized output
server/entity/LinkedAccount.ts New entity for provider/sub-based linked accounts
server/constants/error.ts Adds OIDC-specific API error codes
seerr-api.yml Documents public OIDC providers and OIDC auth + linked-account endpoints
public/images/openid.svg Adds default OpenID logo asset
pnpm-lock.yaml Locks new deps (openid-client, fetch-mock, jose)
package.json Adds runtime/dev dependencies for OIDC and tests
gen-docs/docusaurus.config.ts Enables diff highlighting for docs snippets
docs/using-seerr/settings/users/oidc.md Adds OIDC configuration guide (settings.json-based)
docs/using-seerr/settings/users/index.md Adds initial “User Settings” docs entry
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@michaelhthomas
Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 27, 2026

✅ Actions performed

Reviews resumed.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
seerr-api.yml (1)

257-260: ⚠️ Potential issue | 🟡 Minor

Expose openIdProviders on PublicSettings too.

This addition only updates MainSettings, but /settings/public still returns PublicSettings. The public contract therefore still omits the configured OIDC provider list.

📘 Proposed OpenAPI update
     PublicSettings:
       type: object
       properties:
         initialized:
           type: boolean
           example: false
+        openIdProviders:
+          type: array
+          items:
+            $ref: '#/components/schemas/PublicOidcProvider'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@seerr-api.yml` around lines 257 - 260, The PublicSettings schema currently
lacks the openIdProviders array (you added it only to MainSettings), so update
the PublicSettings schema to include an openIdProviders property mirroring
MainSettings: add openIdProviders: type: array with items: $ref:
'#/components/schemas/PublicOidcProvider' (and add to required if appropriate).
Locate the PublicSettings definition in seerr-api.yml and insert the property so
/settings/public returns the configured OIDC provider list.
🧹 Nitpick comments (1)
server/routes/user/usersettings.ts (1)

541-543: Minor: Empty 404 response body.

These 404 responses send no body, while other handlers in this file return res.status(404).json({ message: 'User not found.' }). Consider aligning for consistency, though this is a minor detail.

♻️ Optional: align 404 response format
     if (!user) {
-      return res.status(404).send();
+      return res.status(404).json({ message: 'User not found.' });
     }

Also applies to: 581-583

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/user/usersettings.ts` around lines 541 - 543, Replace the empty
404 responses that use "if (!user) return res.status(404).send();" with the
consistent JSON payload used elsewhere: "res.status(404).json({ message: 'User
not found.' })". Update both locations where the "user" check returns an empty
404 so the handlers return the same structured error message (match other
handlers in usersettings.ts).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@seerr-api.yml`:
- Around line 4124-4133: Update the 400 response schema to include the
OIDC_AUTHORIZATION_FAILED error returned by the OIDC callback when PKCE/state
cookies are missing or tampered; specifically add an additional example or enum
entry for "OIDC_AUTHORIZATION_FAILED" in the 'error' property so the OpenAPI
spec documents both OIDC_MISSING_EMAIL and OIDC_AUTHORIZATION_FAILED (aligning
with server/routes/auth.ts which returns ApiErrorCode.OidcAuthorizationFailed on
PKCE/state verification failures). Ensure the description or examples plainly
note this path occurs before account creation and maps to the same 400 response.

In `@src/components/Login/OidcLoginButton.tsx`:
- Line 11: The effect that auto-starts OIDC uses loading and the presence of the
query param to call initiateOidcLogin repeatedly on failure; add a guard state
(e.g., attemptedAutoLoginProvider) tracked in the Login component and checked
inside the useEffect so you only call initiateOidcLogin(slug) when
attemptedAutoLoginProvider !== slug, set attemptedAutoLoginProvider to slug
immediately before calling initiateOidcLogin, and on rejection leave that flag
set (and clear loading as you already do) so the effect will not retry the same
provider; update references to loading and the query param check in the
useEffect to include the attemptedAutoLoginProvider guard.

---

Duplicate comments:
In `@seerr-api.yml`:
- Around line 257-260: The PublicSettings schema currently lacks the
openIdProviders array (you added it only to MainSettings), so update the
PublicSettings schema to include an openIdProviders property mirroring
MainSettings: add openIdProviders: type: array with items: $ref:
'#/components/schemas/PublicOidcProvider' (and add to required if appropriate).
Locate the PublicSettings definition in seerr-api.yml and insert the property so
/settings/public returns the configured OIDC provider list.

---

Nitpick comments:
In `@server/routes/user/usersettings.ts`:
- Around line 541-543: Replace the empty 404 responses that use "if (!user)
return res.status(404).send();" with the consistent JSON payload used elsewhere:
"res.status(404).json({ message: 'User not found.' })". Update both locations
where the "user" check returns an empty 404 so the handlers return the same
structured error message (match other handlers in usersettings.ts).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 04357517-f8af-434e-af0b-5849377ec7cd

📥 Commits

Reviewing files that changed from the base of the PR and between dbe8796 and 6af7d47.

📒 Files selected for processing (7)
  • package.json
  • seerr-api.yml
  • server/entity/User.ts
  • server/routes/auth.test.ts
  • server/routes/auth.ts
  • server/routes/user/usersettings.ts
  • src/components/Login/OidcLoginButton.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • package.json
  • server/routes/auth.test.ts

@michaelhthomas michaelhthomas force-pushed the feat/oidc-login-basic branch from 6af7d47 to 86dcadd Compare March 27, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] Support login with OIDC

3 participants