Skip to content

Commit 7d7dd79

Browse files
committed
fix: allow OAuth sign-in with existing email when sign-ups disabled
- 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.
1 parent 8827c0c commit 7d7dd79

2 files changed

Lines changed: 128 additions & 2 deletions

File tree

  • apps
    • backend/src/app/api/latest/auth/oauth/callback/[provider_id]
    • e2e/tests/backend/endpoints/api/v1/auth/oauth

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,10 +278,11 @@ const handler = createSmartRouteHandler({
278278
// ========================== sign up user ==========================
279279

280280
let primaryEmailAuthEnabled = false;
281+
let oldContactChannel = null;
281282
if (userInfo.email) {
282283
primaryEmailAuthEnabled = true;
283284

284-
const oldContactChannel = await getAuthContactChannelWithEmailNormalization(
285+
oldContactChannel = await getAuthContactChannelWithEmailNormalization(
285286
prisma,
286287
{
287288
tenancyId: outerInfo.tenancyId,
@@ -351,6 +352,44 @@ const handler = createSmartRouteHandler({
351352

352353

353354
if (!tenancy.config.auth.allowSignUp) {
355+
// Before rejecting as a new sign-up, check if a user with this email already exists
356+
// (reuse oldContactChannel from above to avoid duplicate database lookup)
357+
// If a user with this email exists (even if email is not used for auth), link the OAuth account
358+
if (oldContactChannel) {
359+
const existingUser = oldContactChannel.projectUser;
360+
361+
// Create the OAuth account linked to the existing user
362+
const newOAuthAccount = await createProjectUserOAuthAccount(prisma, {
363+
tenancyId: outerInfo.tenancyId,
364+
providerId: provider.id,
365+
providerAccountId: userInfo.accountId,
366+
email: userInfo.email,
367+
projectUserId: existingUser.projectUserId,
368+
});
369+
370+
await prisma.authMethod.create({
371+
data: {
372+
tenancyId: outerInfo.tenancyId,
373+
projectUserId: existingUser.projectUserId,
374+
oauthAuthMethod: {
375+
create: {
376+
projectUserId: existingUser.projectUserId,
377+
configOAuthProviderId: provider.id,
378+
providerAccountId: userInfo.accountId,
379+
}
380+
}
381+
}
382+
});
383+
384+
await storeTokens(newOAuthAccount.id);
385+
return {
386+
id: existingUser.projectUserId,
387+
newUser: false,
388+
afterCallbackRedirectUrl,
389+
};
390+
}
391+
392+
// No existing user with this email, so throw SIGN_UP_NOT_ENABLED as expected
354393
throw new KnownErrors.SignUpNotEnabled();
355394
}
356395

apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/callback.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11

22
import { it, localRedirectUrl, updateCookiesFromResponse } from "../../../../../../helpers";
3-
import { Auth, InternalApiKey, Project, niceBackendFetch } from "../../../../../backend-helpers";
3+
import { Auth, InternalApiKey, Project, backendContext, niceBackendFetch } from "../../../../../backend-helpers";
44

55
it("should return outer authorization code when inner callback url is valid", async ({ expect }) => {
66
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
241241
`);
242242
});
243243

244+
it("should link OAuth account to existing user when sign-ups are disabled but user exists with matching email", async ({ expect }) => {
245+
// Test Case A: sign-ups disabled, existing user with matching email, no existing connected account → OAuth login succeeds, links account, and signs in.
246+
await Project.createAndSwitch({ config: { sign_up_enabled: false, oauth_providers: [ { id: "spotify", type: "shared" } ] } });
247+
await InternalApiKey.createAndSetProjectKeys();
248+
249+
// Create a user via the server API with the same email that will be returned by OAuth
250+
const createUserResponse = await niceBackendFetch("/api/v1/users", {
251+
method: "POST",
252+
accessType: "server",
253+
body: {
254+
primary_email: backendContext.value.mailbox.emailAddress,
255+
primary_email_verified: true,
256+
},
257+
});
258+
expect(createUserResponse.status).toBe(201);
259+
const existingUserId = createUserResponse.body.id;
260+
261+
// Now attempt OAuth sign-in with the same email
262+
// Since a user with this email already exists, it should link the OAuth account instead of throwing SIGN_UP_NOT_ENABLED
263+
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl();
264+
const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse);
265+
const response = await niceBackendFetch(getInnerCallbackUrlResponse.innerCallbackUrl, {
266+
redirect: "manual",
267+
headers: {
268+
cookie,
269+
},
270+
});
271+
272+
// The OAuth callback should succeed and return an authorization code
273+
expect(response.status).toBe(303);
274+
expect(response.headers.get("location")).toBeTruthy();
275+
const outerCallbackUrl = new URL(response.headers.get("location")!);
276+
expect(outerCallbackUrl.searchParams.get("code")).toBeTruthy();
277+
278+
// Exchange the authorization code for tokens
279+
const projectKeys = backendContext.value.projectKeys;
280+
if (projectKeys === "no-project") throw new Error("No project keys found");
281+
const tokenResponse = await niceBackendFetch("/api/v1/auth/oauth/token", {
282+
method: "POST",
283+
accessType: "client",
284+
body: {
285+
client_id: projectKeys.projectId,
286+
client_secret: projectKeys.publishableClientKey,
287+
code: outerCallbackUrl.searchParams.get("code")!,
288+
redirect_uri: localRedirectUrl,
289+
grant_type: "authorization_code",
290+
code_verifier: "some-code-challenge",
291+
},
292+
});
293+
294+
expect(tokenResponse.status).toBe(200);
295+
expect(tokenResponse.body.user_id).toBe(existingUserId);
296+
expect(tokenResponse.body.is_new_user).toBe(false);
297+
});
298+
299+
it("should still fail with SIGN_UP_NOT_ENABLED when no user exists with that email", async ({ expect }) => {
300+
// Test Case B: sign-ups disabled, NO user with that email → still returns SIGN_UP_NOT_ENABLED (unchanged behavior).
301+
await Project.createAndSwitch({ config: { sign_up_enabled: false, oauth_providers: [ { id: "spotify", type: "shared" } ] } });
302+
await InternalApiKey.createAndSetProjectKeys();
303+
304+
// Do NOT create a user first - attempt OAuth sign-in directly
305+
const getInnerCallbackUrlResponse = await Auth.OAuth.getInnerCallbackUrl();
306+
const cookie = updateCookiesFromResponse("", getInnerCallbackUrlResponse.authorizeResponse);
307+
const response = await niceBackendFetch(getInnerCallbackUrlResponse.innerCallbackUrl, {
308+
redirect: "manual",
309+
headers: {
310+
cookie,
311+
},
312+
});
313+
314+
// Should still throw SIGN_UP_NOT_ENABLED as before
315+
expect(response).toMatchInlineSnapshot(`
316+
NiceResponse {
317+
"status": 400,
318+
"body": {
319+
"code": "SIGN_UP_NOT_ENABLED",
320+
"error": "Creation of new accounts is not enabled for this project. Please ask the project owner to enable it.",
321+
},
322+
"headers": Headers {
323+
"set-cookie": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
324+
"x-stack-known-error": "SIGN_UP_NOT_ENABLED",
325+
<some fields may have been hidden>,
326+
},
327+
}
328+
`);
329+
});
330+

0 commit comments

Comments
 (0)