diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index 396b93af05..b0f639a8b1 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -278,10 +278,11 @@ const handler = createSmartRouteHandler({ // ========================== sign up user ========================== let primaryEmailAuthEnabled = false; + let oldContactChannel = null; if (userInfo.email) { primaryEmailAuthEnabled = true; - const oldContactChannel = await getAuthContactChannelWithEmailNormalization( + oldContactChannel = await getAuthContactChannelWithEmailNormalization( prisma, { tenancyId: outerInfo.tenancyId, @@ -351,6 +352,44 @@ const handler = createSmartRouteHandler({ if (!tenancy.config.auth.allowSignUp) { + // 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(); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts index 5e4bb8e43e..d9a0b05755 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts @@ -1,6 +1,6 @@ import { it, localRedirectUrl, updateCookiesFromResponse } from "../../../../../../helpers"; -import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers"; +import { Auth, InternalApiKey, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers"; it("should return outer authorization code when inner callback url is valid", async ({ expect }) => { const response = await Auth.OAuth.getAuthorizationCode(); @@ -241,3 +241,90 @@ it("should fail if an untrusted redirect URL is provided that is similar to a tr `); }); +it("should link OAuth account to existing user when sign-ups are disabled but user exists with matching email", async ({ expect }) => { + // Test Case A: sign-ups disabled, existing user with matching email, no existing connected account → OAuth login succeeds, links account, and signs in. + await Project.createAndSwitch({ config: { sign_up_enabled: false, oauth_providers: [ { id: "spotify", type: "shared" } ] } }); + await InternalApiKey.createAndSetProjectKeys(); + + // Create a user via the server API with the same email that will be returned by OAuth + const createUserResponse = await niceBackendFetch("/api/v1/users", { + method: "POST", + accessType: "server", + body: { + primary_email: backendContext.value.mailbox.emailAddress, + primary_email_verified: true, + }, + }); + expect(createUserResponse.status).toBe(201); + const existingUserId = createUserResponse.body.id; + + // Now attempt OAuth sign-in with the same email + // Since a user with this email already exists, it should link the OAuth account instead of throwing SIGN_UP_NOT_ENABLED + const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl(); + const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse); + const response = await niceBackendFetch(getInnerCallbackUrlResponse.innerCallbackUrl, { + redirect: "manual", + headers: { + cookie, + }, + }); + + // The OAuth callback should succeed and return an authorization code + expect(response.status).toBe(303); + expect(response.headers.get("location")).toBeTruthy(); + const outerCallbackUrl = new URL(response.headers.get("location")!); + expect(outerCallbackUrl.searchParams.get("code")).toBeTruthy(); + + // Exchange the authorization code for tokens + const projectKeys = backendContext.value.projectKeys; + if (projectKeys === "no-project") throw new Error("No project keys found"); + const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", { + method: "POST", + accessType: "client", + body: { + client_id: projectKeys.projectId, + client_secret: projectKeys.publishableClientKey, + code: outerCallbackUrl.searchParams.get("code")!, + redirect_uri: localRedirectUrl, + grant_type: "authorization_code", + code_verifier: "some-code-challenge", + }, + }); + + expect(tokenResponse.status).toBe(200); + expect(tokenResponse.body.user_id).toBe(existingUserId); + expect(tokenResponse.body.is_new_user).toBe(false); +}); + +it("should still fail with SIGN_UP_NOT_ENABLED when no user exists with that email", async ({ expect }) => { + // Test Case B: sign-ups disabled, NO user with that email → still returns SIGN_UP_NOT_ENABLED (unchanged behavior). + await Project.createAndSwitch({ config: { sign_up_enabled: false, oauth_providers: [ { id: "spotify", type: "shared" } ] } }); + await InternalApiKey.createAndSetProjectKeys(); + + // Do NOT create a user first - attempt OAuth sign-in directly + const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl(); + const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse); + const response = await niceBackendFetch(getInnerCallbackUrlResponse.innerCallbackUrl, { + redirect: "manual", + headers: { + cookie, + }, + }); + + // Should still throw SIGN_UP_NOT_ENABLED as before + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 400, + "body": { + "code": "SIGN_UP_NOT_ENABLED", + "error": "Creation of new accounts is not enabled for this project. Please ask the project owner to enable it.", + }, + "headers": Headers { + "set-cookie": ' at path '/'>, + "x-stack-known-error": "SIGN_UP_NOT_ENABLED", +