Skip to content

fix: resolve OIDC account linking and show SSO error messages#28

Merged
TerrifiedBug merged 2 commits intomainfrom
fix/oidc-account-linking
Mar 7, 2026
Merged

fix: resolve OIDC account linking and show SSO error messages#28
TerrifiedBug merged 2 commits intomainfrom
fix/oidc-account-linking

Conversation

@TerrifiedBug
Copy link
Copy Markdown
Owner

Summary

  • Fix OAuthAccountNotLinked after deleting a local user: The signIn callback creates the User record before Auth.js's handleLoginOrRegister runs. Auth.js then finds the user by email, sees no linked Account, and throws OAuthAccountNotLinked because allowDangerousEmailAccountLinking was false. Changed to true — safe because the callback already guards against local account hijacking (redirects LOCAL authMethod users).
  • Show clear error for local users attempting SSO: Instead of return false (generic AccessDenied), return a redirect to /login?error=local_account with a user-friendly message explaining the admin must link accounts first.
  • Add OIDC group debug logging: Logs the groups claim and values for each SSO login to help diagnose team mapping issues.

Test plan

  • Delete a local user, log in via SSO with same email — should succeed and create OIDC user
  • Attempt SSO login with an existing local account — should show "This email is registered as a local account" message on login page
  • Verify OIDC group claim is logged in server output during SSO login
  • Existing SSO users can still log in normally

Bug 1: OAuthAccountNotLinked after deleting a local user and re-logging
via SSO. Root cause: the signIn callback creates the User record before
Auth.js's handleLoginOrRegister runs. Auth.js then finds the user by
email, sees no linked Account, and throws OAuthAccountNotLinked because
allowDangerousEmailAccountLinking was false. Fix: set it to true — this
is safe because the signIn callback already guards against local account
hijacking (returns redirect for LOCAL authMethod users).

Bug 2: When a local user attempts SSO login, the generic AccessDenied
error gave no indication of what went wrong. Fix: return a redirect URL
with a specific error code instead of returning false, and display a
clear message on the login page explaining the user needs admin linking.

Also adds debug logging for OIDC group claims to help diagnose team
mapping issues.
@github-actions github-actions bot added the fix label Mar 7, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR fixes two OIDC SSO issues: a OAuthAccountNotLinked crash that appeared after deleting and recreating a local user, and a generic AccessDenied error shown when a local-account user attempts SSO login. It also adds group-claim debug logging.

  • allowDangerousEmailAccountLinking: true — the root fix for the account-not-linked error. Because the signIn callback pre-creates the User record before Auth.js's own handleLoginOrRegister runs, Auth.js previously found a User with a matching email but no linked Account, and threw OAuthAccountNotLinked. Enabling this flag allows Auth.js to link the account by email. The security invariant (blocking LOCAL accounts from OIDC takeover) is preserved because the callback checks authMethod === "LOCAL" and redirects before Auth.js's linking logic executes.
  • Redirect instead of return false — replaces the opaque AccessDenied error with a redirect to /login?error=local_account, which the updated login page renders as a clear, human-readable message.
  • Error message display — the login page now reads a ?error= query param on mount, maps it through a safe lookup dictionary (no XSS risk), and cleans the URL. One minor issue: window.history.replaceState is used instead of router.replace, which doesn't update Next.js's searchParams state; functionally harmless but non-idiomatic.
  • Debug logging — unconditional console.log of user email and group memberships added on every OIDC login; should be gated behind a debug flag to avoid leaking PII into production log aggregators.

Confidence Score: 4/5

  • Safe to merge — the security invariant for LOCAL accounts is preserved and both bug fixes are correct; two minor style issues to consider.
  • The core security logic is sound: the signIn callback's LOCAL-account redirect fires before Auth.js's account-linking step, so allowDangerousEmailAccountLinking: true does not open a new attack vector. The error-message UI change is straightforward and safe. The two flagged items (PII logging and replaceState vs router.replace) are minor and don't affect correctness or security in any material way.
  • src/auth.ts — review the allowDangerousEmailAccountLinking: true reasoning if Auth.js ever adds a second OIDC provider; the PII logging line should be gated or removed before shipping to production.

Important Files Changed

Filename Overview
src/auth.ts Enables allowDangerousEmailAccountLinking: true to fix OAuthAccountNotLinked on re-created accounts; the signIn callback's LOCAL-account redirect guard still fires before Auth.js's account-linking step, preserving the security invariant. Also adds unconditional PII logging of email + groups on every OIDC login.
src/app/(auth)/login/page.tsx Reads ?error= search param on mount and maps it to a user-friendly message from a safe lookup dictionary; no XSS or open-redirect risk. Uses window.history.replaceState instead of router.replace, which doesn't sync Next.js router state but has no user-visible impact.
.gitignore Adds a newline at EOF and a .worktrees/ ignore entry. Trivial housekeeping change.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant NextJS as Next.js / Auth.js
    participant Callback as signIn Callback
    participant DB as Prisma / DB

    Browser->>NextJS: OIDC redirect (code exchange complete)
    NextJS->>Callback: signIn({ user, account, profile })

    Callback->>DB: findUnique(email)
    alt No user found
        DB-->>Callback: null
        Callback->>DB: user.create({ authMethod: "OIDC" })
        DB-->>Callback: new dbUser
        Callback-->>NextJS: return true
        NextJS->>DB: create Account record (allowDangerousEmailAccountLinking=true)
    else authMethod === "LOCAL"
        DB-->>Callback: LOCAL user
        Callback->>DB: writeAuditLog(oidc_link_blocked)
        Callback-->>NextJS: return "/login?error=local_account"
        NextJS-->>Browser: redirect → /login?error=local_account
        Browser->>Browser: useEffect reads ?error param, shows message, replaces URL
    else authMethod === "OIDC" (existing, no Account)
        DB-->>Callback: OIDC user
        Callback-->>NextJS: return true
        NextJS->>DB: link Account by email (allowDangerousEmailAccountLinking=true)
        NextJS-->>Browser: session created → redirect /
    end
Loading

Last reviewed commit: 05e9050

const errorParam = searchParams.get("error");
if (errorParam) {
setError(SSO_ERROR_MESSAGES[errorParam] ?? "An error occurred during sign-in. Please try again.");
window.history.replaceState({}, "", "/login");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

replaceState doesn't sync Next.js router state

window.history.replaceState updates the browser URL directly but does NOT update Next.js's internal router state. As a result, useSearchParams() may still return the old params (containing ?error=...) on subsequent renders, causing this useEffect to re-run and redundantly re-set the same error message.

The recommended pattern in Next.js App Router is to use router.replace('/login') instead, which flushes both the browser URL and Next.js's searchParams state:

Suggested change
window.history.replaceState({}, "", "/login");
router.replace("/login");

This ensures searchParams is cleanly emptied after the error is consumed, preventing any stale re-invocation of the effect.

Rule Used: ## Code Style & Conventions

TypeScript Conven... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/(auth)/login/page.tsx
Line: 41

Comment:
**`replaceState` doesn't sync Next.js router state**

`window.history.replaceState` updates the browser URL directly but does NOT update Next.js's internal router state. As a result, `useSearchParams()` may still return the old params (containing `?error=...`) on subsequent renders, causing this `useEffect` to re-run and redundantly re-set the same error message.

The recommended pattern in Next.js App Router is to use `router.replace('/login')` instead, which flushes both the browser URL and Next.js's `searchParams` state:

```suggestion
      router.replace("/login");
```

This ensures `searchParams` is cleanly emptied after the error is consumed, preventing any stale re-invocation of the effect.

**Rule Used:** ## Code Style & Conventions

### TypeScript Conven... ([source](https://app.greptile.com/review/custom-context?memory=6ae51394-d0b6-4686-bc4c-1ad840c2e310))

How can I resolve this? If you propose a fix, please make it concise.

const groupsClaim = settings?.oidcGroupsClaim ?? "groups";
const profileData = profile as Record<string, unknown> | undefined;
const userGroups = (profileData?.[groupsClaim] as string[] | undefined) ?? [];
console.log(`[oidc] User ${user.email} groups (claim "${groupsClaim}"):`, userGroups);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Permanent PII logging on every OIDC login

This line logs the user's email address and their full group membership list on every SSO login. These are server logs that may be collected by log aggregation pipelines, and group membership can reveal organizational structure and access levels.

The PR description calls this "debug logging," but it's unconditional and permanent — there's no debug flag guarding it. Consider either removing it after diagnosis or gating it on an environment variable:

Suggested change
console.log(`[oidc] User ${user.email} groups (claim "${groupsClaim}"):`, userGroups);
if (process.env.DEBUG_OIDC_GROUPS === "true") {
console.log(`[oidc] User ${user.email} groups (claim "${groupsClaim}"):`, userGroups);
}

Rule Used: ## Security & Cryptography Review Rules

When revi... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/auth.ts
Line: 204

Comment:
**Permanent PII logging on every OIDC login**

This line logs the user's email address and their full group membership list on every SSO login. These are server logs that may be collected by log aggregation pipelines, and group membership can reveal organizational structure and access levels.

The PR description calls this "debug logging," but it's unconditional and permanent — there's no debug flag guarding it. Consider either removing it after diagnosis or gating it on an environment variable:

```suggestion
              if (process.env.DEBUG_OIDC_GROUPS === "true") {
                console.log(`[oidc] User ${user.email} groups (claim "${groupsClaim}"):`, userGroups);
              }
```

**Rule Used:** ## Security & Cryptography Review Rules

When revi... ([source](https://app.greptile.com/review/custom-context?memory=7cb20c56-ca6a-40aa-8660-7fa75e6e3db2))

How can I resolve this? If you propose a fix, please make it concise.

@TerrifiedBug TerrifiedBug merged commit eb603a9 into main Mar 7, 2026
14 checks passed
@TerrifiedBug TerrifiedBug deleted the fix/oidc-account-linking branch March 7, 2026 13:02
TerrifiedBug added a commit that referenced this pull request Mar 7, 2026
PR #28 added useSearchParams() to the login page for SSO error
messages, but Next.js requires a Suspense boundary around components
using this hook or static prerendering fails during build.
TerrifiedBug added a commit that referenced this pull request Mar 7, 2026
PR #28 added useSearchParams() to the login page for SSO error
messages, but Next.js requires a Suspense boundary around components
using this hook or static prerendering fails during build.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant