Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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": <deleting cookie 'stack-oauth-inner-<stripped cookie name key>' at path '/'>,
"x-stack-known-error": "SIGN_UP_NOT_ENABLED",
<some fields may have been hidden>,
},
}
`);
});