Skip to content

fix: allow OAuth sign-in with existing email when sign-ups disabled#1055

Open
ayaangazali wants to merge 1 commit intostack-auth:devfrom
ayaangazali:fix/oauth-signin-with-existing-email-when-signups-disabled
Open

fix: allow OAuth sign-in with existing email when sign-ups disabled#1055
ayaangazali wants to merge 1 commit intostack-auth:devfrom
ayaangazali:fix/oauth-signin-with-existing-email-when-signups-disabled

Conversation

@ayaangazali
Copy link

@ayaangazali ayaangazali commented Dec 10, 2025

Summary

When project sign-ups are disabled, signing in with Google (or another OAuth provider) using an email that already exists as a user currently fails with SIGN_UP_NOT_ENABLED.

This PR updates the OAuth flow so that, if a user with that email already exists, the login is treated as a sign-in + account link, not as a new sign-up.


Background / Bug

Current behavior:

  1. Admin disables sign-ups in Auth Methods.

  2. Admin enables Google SSO.

  3. Admin creates a user in the dashboard with user@example.com and marks them verified.

  4. The user tries to “Sign in with Google” using the same email.

  5. OAuth completes, but Stack Auth returns:

    • SIGN_UP_NOT_ENABLED
    • “Creation of new accounts is not enabled for this project…”

Even though the user already exists, the OAuth flow treats this as a new sign-up attempt instead of linking to the existing account.

Expected behavior:

  • If an OAuth login comes in with an email that already belongs to an existing user:
    • Link the OAuth account to that user.
    • Sign them in successfully.
  • Only when no user exists with that email and sign-ups are disabled should SIGN_UP_NOT_ENABLED be returned.

Updated OAuth flow

OAuth callback received
  ↓
Check for existing OAuth connected account
  ↓
Found? → ✅ Sign in (existing behavior)
  ↓
Not found → Is email provided?
  ↓
Yes → Check if email is already used for auth by someone else
  ↓
  ├─ Used for auth → Handle merge strategy (existing behavior)
  └─ Not used for auth → Remember the contact channel
  ↓
Are sign-ups disabled?
  ↓
Yes → Does a user exist with this email? (via remembered contact channel)
  ↓
  ├─ Yes → ✅ Link OAuth account to that user + sign in (NEW)
  └─ No → ❌ Throw SIGN_UP_NOT_ENABLED (existing behavior preserved)
  ↓
No (sign-ups enabled) → ✅ Create new user from OAuth profile (existing behavior)

## Rationale

This allows admins to create users via the dashboard while keeping public sign-ups turned off, and still let those users sign in with OAuth using the same email address.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * OAuth sign-ins can be linked to existing user accounts when sign-ups are disabled; users authenticating via OAuth with a matching contact will be connected to their existing account and treated as returning users.

* **Tests**
  * Added end-to-end tests covering OAuth linking when sign-ups are disabled and ensuring the original sign-up-disabled error remains when no matching user exists.

<sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Copilot AI review requested due to automatic review settings December 10, 2025 11:17
@vercel
Copy link

vercel bot commented Dec 10, 2025

Someone is attempting to deploy a commit to the Stack Auth Team on Vercel.

A member of the Team first needs to authorize it.

@cmux-agent
Copy link

cmux-agent bot commented Dec 10, 2025

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

@CLAassistant
Copy link

CLAassistant commented Dec 10, 2025

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 10, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

When sign-ups are disabled, the OAuth callback now uses a previously fetched contact channel to link an incoming OAuth account to an existing user (if email matches). It creates the OAuth account, an oauthAuthMethod, stores tokens, and returns the existing user's ID with newUser: false; otherwise it still errors.

Changes

Cohort / File(s) Summary
OAuth callback change
apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
Added a branch for the sign-ups-disabled path that reuses oldContactChannel to find an existing user, create an OAuth account and oauthAuthMethod, persist tokens, and return the existing user's ID with newUser: false. Retains SignUpNotEnabled when no matching user exists.
End-to-end tests
apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
Updated imports to include backendContext and added two tests: linking OAuth to an existing user when sign-ups are disabled (expecting is_new_user=false) and ensuring SIGN_UP_NOT_ENABLED still occurs when no matching user exists.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Review the new sign-ups-disabled branch in route.tsx for correct transactional behavior and error handling.
  • Verify oldContactChannel reuse avoids duplicate lookups and is always defined when used.
  • Check creation of OAuth account, oauthAuthMethod linkage, and token storage for consistency with existing data models.
  • Confirm the new tests correctly set up tenancy configuration and assertions for is_new_user.

Poem

🐇 I sniffed the email in the night,
Sign-ups barred, but all felt right.
With one small hop I made a link,
Old channel kept — no extra brink.
New tokens snug, the user stays bright.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: allowing OAuth sign-in with existing email when sign-ups are disabled.
Description check ✅ Passed The description is comprehensive, well-structured with clear sections (Summary, Background/Bug, Expected behavior, Updated OAuth flow, Rationale) and provides thorough context about the change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 38ce2a5 and 7d7dd79.

📒 Files selected for processing (2)
  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx (2 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Always add new E2E tests when changing the API or SDK interface
For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling
Use ES6 maps instead of records wherever you can

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,css}: Keep hover/click transitions snappy and fast; avoid fade-in delays on hover. Apply transitions after action completion instead, like smooth fade-out when hover ends
Use hover-exit transitions instead of hover-enter transitions; for example, use 'transition-colors hover:transition-none' instead of fade-in on hover

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
apps/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
{.env*,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (AGENTS.md)

Prefix environment variables with STACK_ (or NEXT_PUBLIC_STACK_ if public) so changes are picked up by Turborepo and improves readability

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
apps/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Prefer .toMatchInlineSnapshot over other selectors in tests when possible; check snapshot-serializer.ts for formatting details

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
apps/e2e/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Always add new E2E tests when changing API or SDK interface; err on the side of creating too many tests due to the critical nature of the industry

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
🧠 Learnings (3)
📚 Learning: 2025-12-03T07:19:44.433Z
Learnt from: madster456
Repo: stack-auth/stack-auth PR: 1040
File: packages/stack-shared/src/interface/crud/oauth-providers.ts:62-87
Timestamp: 2025-12-03T07:19:44.433Z
Learning: In packages/stack-shared/src/interface/crud/oauth-providers.ts and similar CRUD files, the tag "Oauth" (not "OAuth") is the correct capitalization format as it's used by the documentation generation system and follows OpenAPI conventions.

Applied to files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to apps/e2e/**/*.{ts,tsx} : Always add new E2E tests when changing API or SDK interface; err on the side of creating too many tests due to the critical nature of the industry

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
📚 Learning: 2025-12-04T18:03:49.984Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.984Z
Learning: Applies to **/*.{ts,tsx} : Always add new E2E tests when changing the API or SDK interface

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
🧬 Code graph analysis (1)
apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx (1)
apps/backend/src/lib/contact-channel.tsx (1)
  • getAuthContactChannelWithEmailNormalization (51-80)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Vercel Agent Review
🔇 Additional comments (2)
apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts (1)

299-329: LGTM! Test correctly validates the negative case.

This test appropriately verifies that when sign-ups are disabled and no existing user matches the OAuth email, the flow still fails with SIGN_UP_NOT_ENABLED, preserving the original security behavior.

apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx (1)

392-393: LGTM! Correct fallback behavior.

When no existing user is found with the email, the code correctly throws SIGN_UP_NOT_ENABLED, preserving the original security behavior when sign-ups are disabled.


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.

Copy link
Contributor

@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

🧹 Nitpick comments (1)
apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx (1)

362-389: Consider extracting shared account linking logic.

This code block duplicates the link_method case (lines 313-340). Both perform the same sequence: create OAuth account → create auth method → store tokens → return user ID.

A helper function could reduce duplication:

async function linkOAuthAccountToUser(params: {
  prisma: PrismaClient,
  tenancyId: string,
  providerId: string,
  providerAccountId: string,
  email: string | undefined,
  projectUserId: string,
  afterCallbackRedirectUrl: string | undefined,
  storeTokens: (oauthAccountId: string) => Promise<void>,
}) { /* ... */ }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8827c0c and 38ce2a5.

📒 Files selected for processing (2)
  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx (2 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts (2 hunks)
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Always add new E2E tests when changing the API or SDK interface
For blocking alerts and errors, never use toast; use alerts instead as they are less easily missed by the user
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error); use loading indicators and async callbacks instead, or use runAsynchronously/runAsynchronouslyWithAlert for error handling
Use ES6 maps instead of records wherever you can

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
**/*.{ts,tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,css}: Keep hover/click transitions snappy and fast; avoid fade-in delays on hover. Apply transitions after action completion instead, like smooth fade-out when hover ends
Use hover-exit transitions instead of hover-enter transitions; for example, use 'transition-colors hover:transition-none' instead of fade-in on hover

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
apps/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them; prefer using client components and hooks like usePathname instead of await params to keep pages static

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
{.env*,**/*.{ts,tsx,js}}

📄 CodeRabbit inference engine (AGENTS.md)

Prefix environment variables with STACK_ (or NEXT_PUBLIC_STACK_ if public) so changes are picked up by Turborepo and improves readability

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
apps/**/*.tsx

📄 CodeRabbit inference engine (AGENTS.md)

Check existing apps for inspiration when implementing new apps or pages; update apps-frontend.tsx and apps-config.ts to add new apps

Files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
**/*.test.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Prefer .toMatchInlineSnapshot over other selectors in tests when possible; check snapshot-serializer.ts for formatting details

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
apps/e2e/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Always add new E2E tests when changing API or SDK interface; err on the side of creating too many tests due to the critical nature of the industry

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
🧠 Learnings (3)
📚 Learning: 2025-12-03T07:19:44.433Z
Learnt from: madster456
Repo: stack-auth/stack-auth PR: 1040
File: packages/stack-shared/src/interface/crud/oauth-providers.ts:62-87
Timestamp: 2025-12-03T07:19:44.433Z
Learning: In packages/stack-shared/src/interface/crud/oauth-providers.ts and similar CRUD files, the tag "Oauth" (not "OAuth") is the correct capitalization format as it's used by the documentation generation system and follows OpenAPI conventions.

Applied to files:

  • apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
📚 Learning: 2025-12-04T18:03:49.961Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.961Z
Learning: Applies to apps/e2e/**/*.{ts,tsx} : Always add new E2E tests when changing API or SDK interface; err on the side of creating too many tests due to the critical nature of the industry

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
📚 Learning: 2025-12-04T18:03:49.961Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-12-04T18:03:49.961Z
Learning: Applies to **/*.{ts,tsx} : Always add new E2E tests when changing the API or SDK interface

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts
🧬 Code graph analysis (2)
apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx (1)
apps/backend/src/lib/contact-channel.tsx (1)
  • getAuthContactChannelWithEmailNormalization (51-80)
apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts (6)
apps/e2e/tests/helpers.ts (3)
  • it (12-12)
  • updateCookiesFromResponse (111-118)
  • localRedirectUrl (187-187)
packages/template/src/lib/stack-app/index.ts (3)
  • Project (76-76)
  • InternalApiKey (20-20)
  • Auth (96-96)
packages/template/src/lib/stack-app/projects/index.ts (1)
  • Project (9-13)
packages/template/src/lib/stack-app/internal-api-keys/index.ts (1)
  • InternalApiKey (24-34)
apps/e2e/tests/backend/backend-helpers.ts (2)
  • niceBackendFetch (109-173)
  • backendContext (35-57)
packages/template/src/lib/stack-app/users/index.ts (1)
  • Auth (71-77)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Agent
  • GitHub Check: CodeQL analysis (javascript-typescript)
  • GitHub Check: Vercel Agent Review
🔇 Additional comments (2)
apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts (2)

3-3: LGTM!

The backendContext import is correctly added to support accessing mailbox.emailAddress and projectKeys in the new tests.


244-297: Tests look good; consider adding edge case coverage.

The test correctly validates the new linking behavior. The setup is clear, and using toMatchInlineSnapshot for the error case aligns with guidelines.

Consider adding tests for these edge cases to ensure security:

  1. User exists with unverified email (primary_email_verified: false) — should this link succeed?
  2. OAuth provider returns an unverified email — does the linking behavior match expectations?

These additional tests would help document and enforce the expected security behavior. Based on learnings, E2E tests should err on the side of creating too many tests due to the critical nature of authentication flows.

Comment on lines +355 to 393
// Before rejecting as a new sign-up, check if a user with this email already exists
// (reuse oldContactChannel from above to avoid duplicate database lookup)
// If a user with this email exists (even if email is not used for auth), link the OAuth account
if (oldContactChannel) {
const existingUser = oldContactChannel.projectUser;

// Create the OAuth account linked to the existing user
const newOAuthAccount = await createProjectUserOAuthAccount(prisma, {
tenancyId: outerInfo.tenancyId,
providerId: provider.id,
providerAccountId: userInfo.accountId,
email: userInfo.email,
projectUserId: existingUser.projectUserId,
});

await prisma.authMethod.create({
data: {
tenancyId: outerInfo.tenancyId,
projectUserId: existingUser.projectUserId,
oauthAuthMethod: {
create: {
projectUserId: existingUser.projectUserId,
configOAuthProviderId: provider.id,
providerAccountId: userInfo.accountId,
}
}
}
});

await storeTokens(newOAuthAccount.id);
return {
id: existingUser.projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
}

// No existing user with this email, so throw SIGN_UP_NOT_ENABLED as expected
throw new KnownErrors.SignUpNotEnabled();
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing email verification checks may allow unauthorized account linking.

The link_method merge strategy (lines 299-308) enforces that both oldContactChannel.isVerified and userInfo.emailVerified are true before linking. This new path bypasses those checks, potentially allowing an attacker to link an OAuth account to a user with an unverified email.

Consider adding similar verification logic:

                  if (oldContactChannel) {
+                   // Require email verification to prevent unauthorized linking
+                   if (!oldContactChannel.isVerified) {
+                     throw new KnownErrors.SignUpNotEnabled();
+                   }
+                   if (!userInfo.emailVerified) {
+                     throw new KnownErrors.SignUpNotEnabled();
+                   }
+
                    const existingUser = oldContactChannel.projectUser;

Alternatively, if intentionally allowing unverified linking for admin-created users, please document this security trade-off with a comment explaining the rationale.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Before rejecting as a new sign-up, check if a user with this email already exists
// (reuse oldContactChannel from above to avoid duplicate database lookup)
// If a user with this email exists (even if email is not used for auth), link the OAuth account
if (oldContactChannel) {
const existingUser = oldContactChannel.projectUser;
// Create the OAuth account linked to the existing user
const newOAuthAccount = await createProjectUserOAuthAccount(prisma, {
tenancyId: outerInfo.tenancyId,
providerId: provider.id,
providerAccountId: userInfo.accountId,
email: userInfo.email,
projectUserId: existingUser.projectUserId,
});
await prisma.authMethod.create({
data: {
tenancyId: outerInfo.tenancyId,
projectUserId: existingUser.projectUserId,
oauthAuthMethod: {
create: {
projectUserId: existingUser.projectUserId,
configOAuthProviderId: provider.id,
providerAccountId: userInfo.accountId,
}
}
}
});
await storeTokens(newOAuthAccount.id);
return {
id: existingUser.projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
}
// No existing user with this email, so throw SIGN_UP_NOT_ENABLED as expected
throw new KnownErrors.SignUpNotEnabled();
// Before rejecting as a new sign-up, check if a user with this email already exists
// (reuse oldContactChannel from above to avoid duplicate database lookup)
// If a user with this email exists (even if email is not used for auth), link the OAuth account
if (oldContactChannel) {
// Require email verification to prevent unauthorized linking
if (!oldContactChannel.isVerified) {
throw new KnownErrors.SignUpNotEnabled();
}
if (!userInfo.emailVerified) {
throw new KnownErrors.SignUpNotEnabled();
}
const existingUser = oldContactChannel.projectUser;
// Create the OAuth account linked to the existing user
const newOAuthAccount = await createProjectUserOAuthAccount(prisma, {
tenancyId: outerInfo.tenancyId,
providerId: provider.id,
providerAccountId: userInfo.accountId,
email: userInfo.email,
projectUserId: existingUser.projectUserId,
});
await prisma.authMethod.create({
data: {
tenancyId: outerInfo.tenancyId,
projectUserId: existingUser.projectUserId,
oauthAuthMethod: {
create: {
projectUserId: existingUser.projectUserId,
configOAuthProviderId: provider.id,
providerAccountId: userInfo.accountId,
}
}
}
});
await storeTokens(newOAuthAccount.id);
return {
id: existingUser.projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
}
// No existing user with this email, so throw SIGN_UP_NOT_ENABLED as expected
throw new KnownErrors.SignUpNotEnabled();
🤖 Prompt for AI Agents
In apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx
around lines 355 to 393, the new OAuth-account-linking path skips the email
verification checks used by the link_method flow and may allow unauthorized
linking; update this block to enforce the same verification logic (require
oldContactChannel.isVerified && userInfo.emailVerified) before creating the
OAuth account and authMethod and throwing SignUpNotEnabled if the checks fail,
or if you intentionally permit linking for admin-created users, add a clear
comment documenting that exception and the exact conditions (e.g.,
createdByAdmin) under which unverified linking is allowed.

Copy link
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

This PR aims to fix an issue where OAuth sign-in fails with SIGN_UP_NOT_ENABLED when a user with a matching email already exists in the system, even though it should link to the existing account instead.

Key Changes:

  • Modified OAuth callback logic to check for existing users by email before rejecting sign-in when sign-ups are disabled
  • Added new database lookup and account linking flow when existing user is found
  • Added two test cases covering both the fix scenario and the existing error behavior

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx Adds logic to link OAuth accounts to existing users with matching emails when sign-ups are disabled, including account creation and auth method setup
apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts Adds test coverage for the new account linking behavior and verifies the existing error case still works correctly

Critical Issues Found:

  1. Bug: The implementation uses getAuthContactChannelWithEmailNormalization() which only finds emails where usedForAuth: "TRUE". Users created without primary_email_auth_enabled: true won't be found, causing the fix to not work as intended.

  2. Security: Missing email verification checks before linking accounts. Unlike the existing merge logic (lines 299-308), the new code doesn't verify that both the existing user's email and the OAuth provider's email are verified, creating a potential account takeover vulnerability.

  3. Test Coverage: The test doesn't set primary_email_auth_enabled: true when creating the user, so it will fail with the current implementation due to issue #1.


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

Comment on lines +358 to +359
if (oldContactChannel) {
const existingUser = oldContactChannel.projectUser;
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

The comment states "If a user with this email exists (even if email is not used for auth)" but this is not actually what the code does. Due to the bug in line 358, the code only checks for users where the email IS used for auth.

Once the bug in line 358 is fixed to properly query for any user with this email, this comment will be accurate. Until then, it's misleading.

Suggested change
if (oldContactChannel) {
const existingUser = oldContactChannel.projectUser;
let contactChannelForEmail = oldContactChannel;
if (!contactChannelForEmail && userInfo.email) {
// Try to find any contact channel with this email, even if not used for auth
contactChannelForEmail = await prisma.contactChannel.findFirst({
where: {
tenancyId: outerInfo.tenancyId,
type: 'EMAIL',
value: userInfo.email,
},
include: {
projectUser: true,
},
});
}
if (contactChannelForEmail) {
const existingUser = contactChannelForEmail.projectUser;

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +256
const createUserResponse = await niceBackendFetch("/api/v1/users", {
method: "POST",
accessType: "server",
body: {
primary_email: backendContext.value.mailbox.emailAddress,
primary_email_verified: true,
},
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

This test will fail with the current implementation because primary_email_auth_enabled is not set to true. When creating a user without primary_email_auth_enabled: true, the email's usedForAuth field is set to null, and the OAuth callback logic won't find this user (see the bug in the route handler at line 358).

To fix, you need to either:

  1. Add primary_email_auth_enabled: true to the test user creation
  2. Fix the route handler to look up users by email regardless of the usedForAuth status

Option 2 is the correct fix since the PR description explicitly states it should work for users created "in the dashboard" who may not have auth enabled for that email.

Copilot uses AI. Check for mistakes.
Comment on lines +358 to +390
if (oldContactChannel) {
const existingUser = oldContactChannel.projectUser;

// Create the OAuth account linked to the existing user
const newOAuthAccount = await createProjectUserOAuthAccount(prisma, {
tenancyId: outerInfo.tenancyId,
providerId: provider.id,
providerAccountId: userInfo.accountId,
email: userInfo.email,
projectUserId: existingUser.projectUserId,
});

await prisma.authMethod.create({
data: {
tenancyId: outerInfo.tenancyId,
projectUserId: existingUser.projectUserId,
oauthAuthMethod: {
create: {
projectUserId: existingUser.projectUserId,
configOAuthProviderId: provider.id,
providerAccountId: userInfo.accountId,
}
}
}
});

await storeTokens(newOAuthAccount.id);
return {
id: existingUser.projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
}
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

Missing email verification checks before linking OAuth account to existing user. This creates a potential account takeover vulnerability.

The existing merge logic (lines 299-308) requires both:

  1. The existing user's email to be verified (oldContactChannel.isVerified)
  2. The OAuth provider's email to be verified (userInfo.emailVerified)

The new code should enforce the same checks. An attacker could potentially take over an account by:

  1. Creating an OAuth account with an unverified email matching an existing user
  2. Triggering this code path to link to the victim's account

Add verification checks similar to the existing merge logic before allowing the account link.

Copilot uses AI. Check for mistakes.
// Before rejecting as a new sign-up, check if a user with this email already exists
// (reuse oldContactChannel from above to avoid duplicate database lookup)
// If a user with this email exists (even if email is not used for auth), link the OAuth account
if (oldContactChannel) {
Copy link

Copilot AI Dec 10, 2025

Choose a reason for hiding this comment

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

The condition if (oldContactChannel) will not work as intended for users created without primary_email_auth_enabled: true.

getAuthContactChannelWithEmailNormalization only returns contact channels where usedForAuth: "TRUE". When a user is created via the server API without setting primary_email_auth_enabled: true (which is the common case described in the PR), usedForAuth will be null, and oldContactChannel will be null even though a user with that email exists.

To fix this, you need to query for any contact channel with the email (regardless of usedForAuth), not just auth-enabled ones. Consider creating a new query function or modifying the lookup logic to check for any primary email contact channel first, then fall back to the auth-enabled check for the existing merge strategy logic.

Copilot uses AI. Check for mistakes.
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Dec 10, 2025

Greptile Overview

Greptile Summary

This PR fixes a bug in the OAuth authentication flow where users with existing accounts would receive SIGN_UP_NOT_ENABLED errors when trying to sign in via OAuth providers (like Google) if project sign-ups were disabled. The fix updates the OAuth callback handler to intelligently distinguish between new account creation and account linking scenarios.

The core change modifies the OAuth flow in apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx to check for existing users with matching email addresses before rejecting OAuth requests due to disabled sign-ups. When an existing user is found with the same email, the system creates an OAuth account and auth method linked to that user instead of throwing an error. This allows administrators to disable public sign-ups while still permitting existing users (created manually through the dashboard) to authenticate via OAuth providers using their registered email addresses.

The implementation reuses existing database queries for efficiency and maintains backward compatibility by preserving the SIGN_UP_NOT_ENABLED error when no matching user exists. Comprehensive test coverage was added to validate both the new account linking behavior and the preservation of existing error handling.

Important Files Changed

Filename Score Overview
apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts 5/5 Added comprehensive test coverage for OAuth account linking when sign-ups disabled
apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx 4/5 Implemented OAuth account linking logic for existing users when sign-ups disabled

Confidence score: 4/5

  • This PR is safe to merge with minimal risk as it preserves existing behavior while fixing a legitimate user experience issue
  • Score reflects solid implementation with good test coverage, but docked one point due to complex authentication logic changes in a critical security-related file
  • Pay close attention to the OAuth callback route implementation to ensure the account linking logic handles edge cases correctly

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend
    participant OAuthProvider as "OAuth Provider"
    participant Backend as "OAuth Callback Handler"
    participant Database
    participant TokenEndpoint as "Token Endpoint"

    User->>Frontend: "Click 'Sign in with OAuth'"
    Frontend->>Backend: "GET /auth/oauth/authorize"
    Backend->>Database: "Store outer OAuth info"
    Backend->>Frontend: "Return authorization URL"
    Frontend->>OAuthProvider: "Redirect to OAuth provider"
    User->>OAuthProvider: "Authenticate with provider"
    OAuthProvider->>Backend: "GET /auth/oauth/callback/{provider_id}"
    Backend->>Database: "Retrieve outer OAuth info"
    Backend->>OAuthProvider: "Exchange code for tokens"
    OAuthProvider->>Backend: "Return user info & tokens"
    
    alt Existing OAuth Account Found
        Backend->>Database: "Find existing OAuth account"
        Backend->>Database: "Store OAuth tokens"
        Backend->>Frontend: "Redirect with auth code"
    else Sign-ups Disabled & User Exists with Email
        Backend->>Database: "Check for existing user with email"
        Backend->>Database: "Create OAuth account for existing user"
        Backend->>Database: "Create auth method"
        Backend->>Database: "Store OAuth tokens"
        Backend->>Frontend: "Redirect with auth code"
    else Sign-ups Disabled & No User with Email
        Backend->>Frontend: "Throw SIGN_UP_NOT_ENABLED error"
    else Sign-ups Enabled
        Backend->>Database: "Create new user"
        Backend->>Database: "Create OAuth account"
        Backend->>Database: "Create auth method"
        Backend->>Database: "Store OAuth tokens"
        Backend->>Frontend: "Redirect with auth code"
    end
    
    Frontend->>TokenEndpoint: "POST /auth/oauth/token"
    TokenEndpoint->>Database: "Validate authorization code"
    TokenEndpoint->>Frontend: "Return access & refresh tokens"
    Frontend->>User: "User signed in successfully"
Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

Additional Comments (1)

  1. apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx, line 358-390 (link)

    style: this section duplicates the OAuth account linking logic from lines 313-334. The duplication creates a maintenance burden and potential for inconsistencies

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

2 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

- Add logic to link OAuth accounts to existing users by email when sign-ups are disabled
- Previously would throw SIGN_UP_NOT_ENABLED even if user with that email already existed
- Now checks for existing user by email before rejecting as new sign-up
- Reuses existing contact channel lookup to avoid duplicate database queries
- Add comprehensive test coverage for both success and error cases
- Preserves all existing behavior when no user exists with that email

Fixes the issue where users created via dashboard couldn't sign in with OAuth
when sign-ups were disabled, even though they already had an account.
@ayaangazali ayaangazali force-pushed the fix/oauth-signin-with-existing-email-when-signups-disabled branch from 38ce2a5 to 7d7dd79 Compare December 10, 2025 23:51
@cmux-agent
Copy link

cmux-agent bot commented Dec 10, 2025

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

@N2D4 N2D4 self-assigned this Dec 18, 2025
@N2D4 N2D4 self-requested a review December 18, 2025 18:53
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.

4 participants