Option for merging/blocking account creations with same email but different oauth provider (#502)

* Add SettingSelect component and sign-up mode configuration

* update schema

* update schema

* add merge oauth schema fields

* update test snapshots

* reformat the file

* add mergeOauthMethods in the UI

* Improve documentation with minor enhancements and fixes (#466)

* Improve PATCH /users/me performance

* Neon domain (#488)

* React setup docs (#491)

* chore: update package versions

* Update branding from Stack to Stack Auth in documentation (#504)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: zai@stack-auth.com <zai@stack-auth.com>

* Improve inner OAuth cookie error description

* Fix tests

* Add several spans

* Fix Next.js navigation

* chore: update package versions

* add handler config

* Fix conditional hooks in account settings page

* Add `pnpm run claude-code`

* Fixed docs image ratio (#507)

* Documentation Updates (#508)

* add links

* moar

* fix broken links

* fixed images

* updated readme

* Add Python SDK tab to docs

* Fix broken links

* Prefix `pnpm pre` before `pnpm dev`

* Automatically update pull request branches (#509)

* Give PR updater more permissions

* Ignore PR merge conflicts in GH Actions

* [DEVIN: Konsti] Add userCount property to Project table with automatic update trigger (#506)

Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>

* chore: update package versions

* rewrite as toMatchobject

* fix test

* test again

* fix

* update claude

* fix migrations

* fix migration

* fix types

* Update CLAUDE.md

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>

* more instructions

* fix

* what was i on

* wew

* factor things

* correct the flow

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

Very good catch

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* rename everything

* fix more

* fix typeerror

* update all the tests

* modify migration for legacy projects

* add loading state

* enable email verification for tests

* update test

* get a failing test

* the test works now

* remove debug console log

* change error

* no lowercase

* use typedToUpper/Lowercase

* capture err

* fix types

* modify error throw

* fixed

* add working tests

* documentation update

* Update apps/e2e/tests/backend/endpoints/api/v1/auth/oauth/merge-strategy.test.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* remove eslint rule

* fix json args

* documentation update

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Konstantin Wohlwend <n2d4xc@gmail.com>
Co-authored-by: Zai Shi <zaishi00@outlook.com>
Co-authored-by: zai@stack-auth.com <zai@stack-auth.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
CactusBlue 2025-03-10 14:25:12 -07:00 committed by GitHub
parent 7c3c4863ab
commit ef6248dd87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 413 additions and 77 deletions

View File

@ -8,9 +8,11 @@
- Run single test: `pnpm test path/to/test.test.ts` or `pnpm test -t "test name pattern"`
- Start dependencies: `pnpm start-deps` (DB, services), `pnpm stop-deps` (shutdown)
- Dev mode: `pnpm dev` (all services) or `pnpm dev:basic` (backend+dashboard)
- Prisma CLI: `pnpm prisma` (use instead of the `prisma` command)
## Coding Guidelines
- TypeScript with strict types, prefer `type` over `interface`
- Avoid casting to `any`; Prefer making changes to the API so that `any` casts are unnecessary to access a property or method
- 2-space indentation, spaces in braces, semicolons required
- Return promises with `return await`, no floating promises
- Proper error handling for async code with try/catch
@ -26,3 +28,4 @@
## Monorepo Structure
Managed with Turbo and pnpm workspaces. Core packages in `packages/`, apps in `apps/`.
`packages/stack` is generated and will not be committed into the repository; change the files in `packages/template` instead.

View File

@ -0,0 +1,8 @@
-- CreateEnum
CREATE TYPE "OAuthAccountMergeStrategy" AS ENUM ('LINK_METHOD', 'RAISE_ERROR', 'ALLOW_DUPLICATES');
-- AlterTable
ALTER TABLE "ProjectConfig" ADD COLUMN "oauthAccountMergeStrategy" "OAuthAccountMergeStrategy" NOT NULL DEFAULT 'LINK_METHOD';
-- Update existing projects to use the new strategy
UPDATE "ProjectConfig" SET "oauthAccountMergeStrategy" = 'ALLOW_DUPLICATES';

View File

@ -53,6 +53,13 @@ model ProjectConfig {
permissions Permission[]
authMethodConfigs AuthMethodConfig[]
connectedAccountConfigs ConnectedAccountConfig[]
oauthAccountMergeStrategy OAuthAccountMergeStrategy @default(LINK_METHOD)
}
enum OAuthAccountMergeStrategy {
LINK_METHOD
RAISE_ERROR
ALLOW_DUPLICATES
}
model Tenancy {

View File

@ -15,6 +15,41 @@ import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { oauthResponseToSmartResponse } from "../../oauth-helpers";
/**
* Create a project user OAuth account with the provided data
*/
async function createProjectUserOAuthAccount(params: {
tenancyId: string,
projectConfigId: string,
providerId: string,
providerAccountId: string,
email?: string | null,
projectUserId: string,
}) {
return await prismaClient.projectUserOAuthAccount.create({
data: {
providerAccountId: params.providerAccountId,
email: params.email,
providerConfig: {
connect: {
projectConfigId_id: {
projectConfigId: params.projectConfigId,
id: params.providerId,
},
},
},
projectUser: {
connect: {
tenancyId_projectUserId: {
tenancyId: params.tenancyId,
projectUserId: params.projectUserId,
},
},
},
},
});
}
const redirectOrThrowError = (error: KnownError, tenancy: Tenancy, errorRedirectUrl?: string) => {
if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, tenancy.config.domains, tenancy.config.allow_localhost)) {
throw error;
@ -219,27 +254,13 @@ const handler = createSmartRouteHandler({
await storeTokens();
} else {
// ========================== connect account with user ==========================
await prismaClient.projectUserOAuthAccount.create({
data: {
providerAccountId: userInfo.accountId,
email: userInfo.email,
providerConfig: {
connect: {
projectConfigId_id: {
projectConfigId: tenancy.config.id,
id: provider.id,
},
},
},
projectUser: {
connect: {
tenancyId_projectUserId: {
tenancyId: outerInfo.tenancyId,
projectUserId: projectUserId,
},
},
},
},
await createProjectUserOAuthAccount({
tenancyId: outerInfo.tenancyId,
projectConfigId: tenancy.config.id,
providerId: provider.id,
providerAccountId: userInfo.accountId,
email: userInfo.email,
projectUserId,
});
}
@ -281,12 +302,82 @@ const handler = createSmartRouteHandler({
value: userInfo.email,
}
);
// Check if we should link this OAuth account to an existing user based on email
if (oldContactChannel && oldContactChannel.usedForAuth) {
// if the email is already used for auth by another account, still create an account but don't
// enable auth on it
primaryEmailAuthEnabled = false;
const oauthAccountMergeStrategy = tenancy.config.oauth_account_merge_strategy;
switch (oauthAccountMergeStrategy) {
case 'link_method': {
if (!oldContactChannel.isVerified) {
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", userInfo.email);
}
if (!userInfo.emailVerified) {
const err = new StackAssertionError("OAuth account merge strategy is set to link_method, but the email is not verified");
captureError("oauth-link-method-email-not-verified", err);
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", userInfo.email);
}
const existingUser = oldContactChannel.projectUser;
// First create the OAuth account
await createProjectUserOAuthAccount({
tenancyId: outerInfo.tenancyId,
projectConfigId: tenancy.config.id,
providerId: provider.id,
providerAccountId: userInfo.accountId,
email: userInfo.email,
projectUserId: existingUser.projectUserId,
});
// Then create the auth method that uses this OAuth account
// Find auth method config for this provider from the provider list
const authMethodConfig = await prismaClient.authMethodConfig.findFirst({
where: {
projectConfigId: tenancy.config.id,
oauthProviderConfig: {
id: provider.id,
}
}
});
if (!authMethodConfig) {
throw new StackAssertionError("Auth method config not found, this is most likely a bug.");
}
await prismaClient.authMethod.create({
data: {
tenancyId: outerInfo.tenancyId,
projectUserId: existingUser.projectUserId,
projectConfigId: tenancy.config.id,
authMethodConfigId: authMethodConfig.id,
oauthAuthMethod: {
create: {
projectUserId: existingUser.projectUserId,
projectConfigId: tenancy.config.id,
oauthProviderConfigId: provider.id,
providerAccountId: userInfo.accountId,
}
}
}
});
await storeTokens();
return {
id: existingUser.projectUserId,
newUser: false,
afterCallbackRedirectUrl,
};
}
case 'raise_error': {
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", userInfo.email);
}
case 'allow_duplicates': {
primaryEmailAuthEnabled = false;
break;
}
}
}
// TODO: check whether this OAuth account can be used to login to an existing non-OAuth account instead
}
const newAccount = await usersCrudHandlers.adminCreate({

View File

@ -83,7 +83,7 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
},
});
if (existingWithSameChannel) {
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse(data.type);
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse(data.type, data.value);
}
}

View File

@ -1,7 +1,7 @@
import { isTeamSystemPermission, listTeamPermissionDefinitions, teamSystemPermissionStringToDBType } from "@/lib/permissions";
import { fullProjectInclude, projectPrismaToCrud } from "@/lib/projects";
import { ensureSharedProvider } from "@/lib/request-checks";
import { prismaClient, retryTransaction } from "@/prisma-client";
import { retryTransaction } from "@/prisma-client";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { projectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { yupObject } from "@stackframe/stack-shared/dist/schema-fields";
@ -468,6 +468,7 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro
clientUserDeletionEnabled: data.config?.client_user_deletion_enabled,
allowLocalhost: data.config?.allow_localhost,
createTeamOnSignUp: data.config?.create_team_on_sign_up,
oauthAccountMergeStrategy: data.config?.oauth_account_merge_strategy ? typedToUppercase(data.config.oauth_account_merge_strategy) : undefined,
domains: data.config?.domains ? {
deleteMany: {},
create: data.config.domains.map(item => ({

View File

@ -148,20 +148,19 @@ async function checkAuthData(
if (!data.primaryEmail && data.primaryEmailVerified) {
throw new StatusError(400, "primary_email_verified cannot be true without primary_email");
}
if (data.primaryEmailAuthEnabled) {
if (!data.oldPrimaryEmail || data.oldPrimaryEmail !== data.primaryEmail) {
const existingChannelUsedForAuth = await tx.contactChannel.findFirst({
where: {
tenancyId: data.tenancyId,
type: 'EMAIL',
value: data.primaryEmail || throwErr("primary_email_auth_enabled is true but primary_email is not set"),
usedForAuth: BooleanTrue.TRUE,
}
});
if (existingChannelUsedForAuth) {
throw new KnownErrors.UserEmailAlreadyExists();
if (!data.primaryEmailAuthEnabled) return;
if (!data.oldPrimaryEmail || data.oldPrimaryEmail !== data.primaryEmail) {
const existingChannelUsedForAuth = await tx.contactChannel.findFirst({
where: {
tenancyId: data.tenancyId,
type: 'EMAIL',
value: data.primaryEmail || throwErr("primary_email_auth_enabled is true but primary_email is not set"),
usedForAuth: BooleanTrue.TRUE,
}
});
if (existingChannelUsedForAuth) {
throw new KnownErrors.UserEmailAlreadyExists();
}
}
}

View File

@ -138,6 +138,7 @@ export function projectPrismaToCrud(
})),
oauth_providers: oauthProviders,
enabled_oauth_providers: oauthProviders.filter(provider => provider.enabled),
oauth_account_merge_strategy: typedToLowercase(prisma.config.oauthAccountMergeStrategy),
email_config: (() => {
const emailServiceConfig = prisma.config.emailServiceConfig;
if (!emailServiceConfig) {
@ -427,6 +428,7 @@ export function getProjectQuery(projectId: string): RawQuery<ProjectsCrud["Admin
})),
oauth_providers: oauthProviderAuthMethods,
enabled_oauth_providers: oauthProviderAuthMethods.filter((provider: any) => provider.enabled),
oauth_account_merge_strategy: typedToLowercase(row.ProjectConfig.oauthAccountMergeStrategy) as "link_method" | "raise_error" | "allow_duplicates",
email_config: (() => {
const emailServiceConfig = row.ProjectConfig.EmailServiceConfig;
if (!emailServiceConfig) {
@ -508,6 +510,7 @@ export async function createProject(ownerIds: string[], data: InternalProjectsCr
createTeamOnSignUp: data.config?.create_team_on_sign_up ?? false,
clientTeamCreationEnabled: data.config?.client_team_creation_enabled ?? false,
clientUserDeletionEnabled: data.config?.client_user_deletion_enabled ?? false,
oauthAccountMergeStrategy: data.config?.oauth_account_merge_strategy ? typedToUppercase(data.config.oauth_account_merge_strategy): 'LINK_METHOD',
domains: data.config?.domains ? {
create: data.config.domains.map(item => ({
domain: item.domain,

View File

@ -28,6 +28,7 @@ export class MockProvider extends OAuthBaseProvider {
displayName: rawUserInfo.name,
email: rawUserInfo.sub,
profileImageUrl: rawUserInfo.picture,
emailVerified: true,
});
}
}

View File

@ -1,9 +1,9 @@
"use client";
import { SettingCard, SettingSwitch } from "@/components/settings";
import { SettingCard, SettingSelect, SettingSwitch } from "@/components/settings";
import { AdminOAuthProviderConfig, AuthPage, OAuthProviderConfig } from "@stackframe/stack";
import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { ActionDialog, Badge, BrandIcons, BrowserFrame, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, SimpleTooltip, Typography } from "@stackframe/stack-ui";
import { ActionDialog, Badge, BrandIcons, BrowserFrame, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, SelectItem, SimpleTooltip, Typography } from "@stackframe/stack-ui";
import { AsteriskSquare, CirclePlus, Key, Link2, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import { CardSubtitle } from "../../../../../../../../../packages/stack-ui/dist/components/ui/card";
@ -11,6 +11,8 @@ import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { ProviderSettingDialog, ProviderSettingSwitch, TurnOffProviderDialog } from "./providers";
type OAuthAccountMergeStrategy = 'link_method' | 'raise_error' | 'allow_duplicates';
function ConfirmSignUpEnabledDialog(props: {
open?: boolean,
onOpenChange?: (open: boolean) => void,
@ -256,7 +258,7 @@ export default function PageClient() {
SSO Providers
</CardSubtitle>
{ enabledProviders.map(([, provider]) => provider)
{enabledProviders.map(([, provider]) => provider)
.filter((provider): provider is AdminOAuthProviderConfig => !!provider).map(provider => {
return <div key={provider.id} className="flex h-10 items-center justify-between">
<div className="flex items-center gap-2">
@ -332,6 +334,22 @@ export default function PageClient() {
}}
hint="Existing users can still sign in when sign-up is disabled. You can always create new accounts manually via the dashboard."
/>
<SettingSelect
label="Sign-up mode when logging in with same email on multiple providers"
value={project.config.oauthAccountMergeStrategy}
onValueChange={async (value) => {
await project.update({
config: {
oauthAccountMergeStrategy: value as OAuthAccountMergeStrategy,
},
});
}}
hint="Determines what happens when a user tries to sign in with a different OAuth provider using the same email address"
>
<SelectItem value="link_method">Link - Connect multiple providers to the same account</SelectItem>
<SelectItem value="allow_duplicates">Allow - Create separate accounts for each provider</SelectItem>
<SelectItem value="raise_error">Block - Show an error and prevent sign-in with multiple providers</SelectItem>
</SettingSelect>
</SettingCard>
<SettingCard title="User deletion">

View File

@ -1,7 +1,7 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DelayedInput, Form, Label, Switch, Typography, useToast } from "@stackframe/stack-ui";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DelayedInput, Form, Label, Select, SelectContent, SelectTrigger, SelectValue, Switch, Typography, useToast } from "@stackframe/stack-ui";
import { Settings } from "lucide-react";
import React, { useEffect, useId, useState } from "react";
import { FieldValues, useForm } from "react-hook-form";
@ -128,6 +128,51 @@ export function SettingText(props: {
);
}
export function SettingSelect<TValue extends string>(props: {
label: string | React.ReactNode,
hint?: string | React.ReactNode,
value?: TValue,
placeholder?: string,
disabled?: boolean,
loading?: boolean,
onValueChange?: (value: TValue) => void | Promise<void>,
actions?: React.ReactNode,
children: React.ReactNode,
}) {
const id = useId();
const [valueState, setValueState] = useState<TValue | undefined>(props.value);
const value = props.value ?? valueState;
const onValueChange = async (value: TValue) => {
setValueState(value);
await props.onValueChange?.(value);
};
return (
<div className="flex flex-col gap-2">
<div className="flex items-center">
<Label className='pr-2' htmlFor={id}>{props.label}</Label>
{props.actions}
</div>
<Select
value={value}
onValueChange={(value: TValue) => runAsynchronouslyWithAlert(onValueChange(value))}
disabled={props.disabled || props.loading}
>
<SelectTrigger id={id} className="max-w-lg" loading={props.loading}>
<SelectValue placeholder={props.placeholder} />
</SelectTrigger>
<SelectContent
className="max-w-lg"
>
{props.children}
</SelectContent>
</Select>
{props.hint && <Typography variant="secondary" type="footnote">{props.hint}</Typography>}
</div>
);
}
export function FormSettingCard<F extends FieldValues>(
props: Omit<React.ComponentProps<typeof SettingCard>, 'children' | 'actions'> & {
defaultValues?: Partial<F>,

View File

@ -717,26 +717,17 @@ export namespace Auth {
grant_type: "authorization_code",
},
});
expect(tokenResponse).toMatchInlineSnapshot(`
NiceResponse {
"status": 200,
"body": {
"access_token": <stripped field 'access_token'>,
"afterCallbackRedirectUrl": null,
"after_callback_redirect_url": null,
"expires_in": 3599,
"is_new_user": true,
"newUser": true,
"refresh_token": <stripped field 'refresh_token'>,
"scope": "legacy",
"token_type": "Bearer",
},
"headers": Headers {
"pragma": "no-cache",
<some fields may have been hidden>,
},
}
`);
expect(tokenResponse).toMatchObject({
status: 200,
body: {
access_token: expect.any(String),
afterCallbackRedirectUrl: null,
after_callback_redirect_url: null,
refresh_token: expect.any(String),
scope: "legacy",
token_type: "Bearer"
},
});
backendContext.set({
userAuth: {

View File

@ -1,5 +1,5 @@
import { it } from "../../../../helpers";
import { Auth, ContactChannels, backendContext, niceBackendFetch } from "../../../backend-helpers";
import { ApiKey, Auth, ContactChannels, Project, backendContext, niceBackendFetch } from "../../../backend-helpers";
it("should not be able to sign in again after signing in with OTP and disabling auth", async ({ expect }) => {
await Auth.Otp.signIn();
@ -39,6 +39,13 @@ it("should not be able to sign in again after signing in with OTP and disabling
});
it("should not be able to sign in with OTP anymore after signing in with password first", async ({ expect }) => {
await Project.createAndSwitch({
config: {
magic_link_enabled: true,
oauth_account_merge_strategy: "allow_duplicates",
}
});
await Auth.Password.signUpWithEmail({ password: "some-password" });
const response2 = await niceBackendFetch("/api/v1/auth/otp/send-sign-in-code", {
@ -65,7 +72,7 @@ it("should not be able to sign in with OTP anymore after signing in with passwor
});
it("signing in with OTP first, then signing in with OAuth, should set used_for_auth to false", async ({ expect }) => {
it("signing in with OTP first, then signing in with OAuth, should set used_for_auth to true", async ({ expect }) => {
await Auth.Otp.signIn();
const cc = await ContactChannels.getTheOnlyContactChannel();
expect(cc.is_verified).toBe(true);
@ -74,11 +81,25 @@ it("signing in with OTP first, then signing in with OAuth, should set used_for_a
await Auth.OAuth.signIn();
const cc2 = await ContactChannels.getTheOnlyContactChannel();
expect(cc2.value).toBe(cc.value);
expect(cc2.is_verified).toBe(false);
expect(cc2.used_for_auth).toBe(false);
expect(cc2.is_verified).toBe(true);
expect(cc2.used_for_auth).toBe(true);
});
it("signs in with password first, then signs in with oauth should give an account with used_for_auth false", async ({ expect }) => {
it("signs in with password first, then signs in with oauth should give an account with used_for_auth true with the new defaults", async ({ expect }) => {
const proj = await Project.createAndSwitch({
config: {
credential_enabled: true,
oauth_account_merge_strategy: "allow_duplicates",
oauth_providers: [{
id: "spotify",
enabled: true,
type: "shared",
}],
}
});
await ApiKey.createAndSetProjectKeys(proj.adminAccessToken);
await Auth.Password.signUpWithEmail({ password: "some-password" });
const cc = await ContactChannels.getTheOnlyContactChannel();
expect(cc.is_verified).toBe(false);
@ -87,7 +108,7 @@ it("signs in with password first, then signs in with oauth should give an accoun
await Auth.OAuth.signIn();
const cc2 = await ContactChannels.getTheOnlyContactChannel();
expect(cc2.value).toBe(cc.value);
expect(cc2.is_verified).toBe(false);
expect(cc2.is_verified).toBe(true);
expect(cc2.used_for_auth).toBe(false);
});

View File

@ -0,0 +1,80 @@
import { it } from "../../../../../../helpers";
import { ApiKey, Auth, ContactChannels, Project } from "../../../../../backend-helpers";
it("should allow duplicates, if the merge strategy is set to allow_duplicates", async ({ expect }) => {
const proj = await Project.createAndSwitch({
config: {
magic_link_enabled: true,
oauth_account_merge_strategy: "allow_duplicates",
oauth_providers: [{
id: "spotify",
enabled: true,
type: "shared",
}],
}
});
await ApiKey.createAndSetProjectKeys(proj.adminAccessToken);
await Auth.Otp.signIn();
const cc = await ContactChannels.getTheOnlyContactChannel();
expect(cc.is_verified).toBe(true);
expect(cc.used_for_auth).toBe(true);
await Auth.OAuth.signIn();
const cc2 = await ContactChannels.getTheOnlyContactChannel();
expect(cc2.value).toBe(cc.value);
expect(cc2.is_verified).toBe(true);
expect(cc2.used_for_auth).toBe(false);
expect(cc.id).not.toBe(cc2.id);
});
it("should not allow duplicates, if the merge strategy set to raise_error", async ({ expect }) => {
const proj = await Project.createAndSwitch({
config: {
magic_link_enabled: true,
oauth_account_merge_strategy: "raise_error",
oauth_providers: [{
id: "spotify",
enabled: true,
type: "shared",
}],
}
});
await ApiKey.createAndSetProjectKeys(proj.adminAccessToken);
await Auth.Otp.signIn();
const cc = await ContactChannels.getTheOnlyContactChannel();
expect(cc.is_verified).toBe(true);
expect(cc.used_for_auth).toBe(true);
await expect(Auth.OAuth.signIn()).rejects.toThrowError();
});
it("should merge accounts, if the merge strategy set to link_method", async ({ expect }) => {
const proj = await Project.createAndSwitch({
config: {
magic_link_enabled: true,
oauth_account_merge_strategy: "link_method",
oauth_providers: [{
id: "spotify",
enabled: true,
type: "shared",
}],
}
});
await ApiKey.createAndSetProjectKeys(proj.adminAccessToken);
await Auth.Otp.signIn();
const cc = await ContactChannels.getTheOnlyContactChannel();
expect(cc.is_verified).toBe(true);
expect(cc.used_for_auth).toBe(true);
await Auth.OAuth.signIn();
const cc2 = await ContactChannels.getTheOnlyContactChannel();
expect(cc2.value).toBe(cc.value);
expect(cc2.is_verified).toBe(true);
expect(cc2.used_for_auth).toBe(true);
expect(cc.id).toBe(cc2.id);
});

View File

@ -50,7 +50,7 @@ describe("with grant_type === 'authorization_code'", async () => {
"otp_auth_enabled": false,
"passkey_auth_enabled": false,
"primary_email": "default-mailbox--<stripped UUID>@stack-generated.example.com",
"primary_email_verified": false,
"primary_email_verified": true,
"profile_image_url": null,
"requires_totp_mfa": false,
"selected_team": null,

View File

@ -107,6 +107,7 @@ it("lists oauth providers", async ({ expect }) => {
"enabled_oauth_providers": [{ "id": "google" }],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [
{
"enabled": true,

View File

@ -56,6 +56,7 @@ it("should be able to provision a new project if neon client details are correct
],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [
{
"enabled": true,

View File

@ -79,6 +79,7 @@ it("creates a new project", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -126,6 +127,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": true,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": false,
@ -178,6 +180,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"enabled_oauth_providers": [{ "id": "google" }],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [
{
"enabled": true,
@ -232,6 +235,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -287,6 +291,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -344,6 +349,7 @@ it("creates a new project with different configurations", async ({ expect }) =>
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -384,6 +390,7 @@ it("lists the current projects after creating a new project", async ({ expect })
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,

View File

@ -80,6 +80,7 @@ it("creates and updates the basic project information of a project", async ({ ex
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -124,6 +125,7 @@ it("updates the basic project configuration", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": true,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": false,
@ -173,6 +175,7 @@ it("updates the project domains configuration", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -228,6 +231,7 @@ it("updates the project domains configuration", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -301,6 +305,7 @@ it("should allow insecure HTTP connections if insecureHttp is true", async ({ ex
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -393,6 +398,7 @@ it("updates the project email configuration", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -447,6 +453,7 @@ it("updates the project email configuration", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -487,6 +494,7 @@ it("updates the project email configuration", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -527,6 +535,7 @@ it("updates the project email configuration", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -581,6 +590,7 @@ it("updates the project email configuration", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,
@ -743,6 +753,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"enabled_oauth_providers": [{ "id": "google" }],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [
{
"enabled": true,
@ -791,6 +802,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"enabled_oauth_providers": [{ "id": "google" }],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [
{
"enabled": true,
@ -841,6 +853,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"enabled_oauth_providers": [{ "id": "google" }],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [
{
"client_id": "client_id",
@ -919,6 +932,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [
{
"enabled": true,
@ -979,6 +993,7 @@ it("updates the project oauth configuration", async ({ expect }) => {
"enabled_oauth_providers": [{ "id": "spotify" }],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [
{
"enabled": false,
@ -1306,6 +1321,7 @@ it("should increment and decrement userCount when a user is added to a project",
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": true,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,

View File

@ -195,6 +195,7 @@ it("can customize default team permissions", async ({ expect }) => {
"enabled_oauth_providers": [],
"id": "<stripped UUID>",
"magic_link_enabled": false,
"oauth_account_merge_strategy": "link_method",
"oauth_providers": [],
"passkey_enabled": false,
"sign_up_enabled": true,

View File

@ -99,3 +99,20 @@ export const stackServerApp = new StackServerApp({
}
});
```
## OAuth account merging strategies
When a user attempts to sign in with an OAuth provider that matches an existing account, Stack provides different strategies for handling the authentication flow.
The available strategies are:
- Allow duplicates (legacy default)
- Link method (new default)
- Block duplicates (most secure)
The "Link" strategy is the default behavior. If a user attempts to sign in with an OAuth provider that matches an existing account, Stack will link the OAuth identity to the existing account, and the user will be signed into that account.
This requires both of the credentials to be verified, or otherwise the link will be blocked, in the same way as the "Block" strategy.
The "Allow" strategy is the default behavior for old projects. If a user attempts to sign in with an OAuth provider that has an existing account with the same email address, Stack will create a separate account for the user.
The "Block" strategy will explicitly raise an error if a user attempts to sign in with an OAuth provider that matches an existing account.

View File

@ -87,6 +87,7 @@ export const projectsCrudAdminReadSchema = yupObject({
create_team_on_sign_up: schemaFields.projectCreateTeamOnSignUpSchema.defined(),
team_creator_default_permissions: yupArray(teamPermissionSchema.defined()).defined(),
team_member_default_permissions: yupArray(teamPermissionSchema.defined()).defined(),
oauth_account_merge_strategy: schemaFields.oauthAccountMergeStrategySchema.defined(),
}).defined(),
}).defined();
@ -123,6 +124,7 @@ export const projectsCrudAdminUpdateSchema = yupObject({
create_team_on_sign_up: schemaFields.projectCreateTeamOnSignUpSchema.optional(),
team_creator_default_permissions: yupArray(teamPermissionSchema.defined()).optional(),
team_member_default_permissions: yupArray(teamPermissionSchema.defined()).optional(),
oauth_account_merge_strategy: schemaFields.oauthAccountMergeStrategySchema.optional(),
}).optional().default(undefined),
}).defined();

View File

@ -557,6 +557,16 @@ const UserEmailAlreadyExists = createKnownErrorConstructor(
() => [] as const,
);
const EmailNotVerified = createKnownErrorConstructor(
KnownError,
"EMAIL_NOT_VERIFIED",
() => [
400,
"The email is not verified.",
] as const,
() => [] as const,
);
const CannotGetOwnUserWithoutUser = createKnownErrorConstructor(
KnownError,
"CANNOT_GET_OWN_USER_WITHOUT_USER",
@ -1144,12 +1154,14 @@ const OAuthProviderAccessDenied = createKnownErrorConstructor(
const ContactChannelAlreadyUsedForAuthBySomeoneElse = createKnownErrorConstructor(
KnownError,
"CONTACT_CHANNEL_ALREADY_USED_FOR_AUTH_BY_SOMEONE_ELSE",
(type: "email") => [
(type: "email", contactChannelValue?: string) => [
409,
contactChannelValue ?
`The ${type} (${contactChannelValue}) is already used for authentication by another account.` :
`This ${type} is already used for authentication by another account.`,
{ type },
{ type, contact_channel_value: contactChannelValue ?? null },
] as const,
(json) => [json.type] as const,
(json) => [json.type, json.contact_channel_value] as const,
);
export type KnownErrors = {
@ -1195,6 +1207,7 @@ export const KnownErrors = {
ProviderRejected,
RefreshTokenNotFoundOrExpired,
UserEmailAlreadyExists,
EmailNotVerified,
UserIdDoesNotExist,
UserNotFound,
ApiKeyNotFound,

View File

@ -283,6 +283,7 @@ export const oauthClientIdSchema = yupString().meta({ openapiField: { descriptio
export const oauthClientSecretSchema = yupString().meta({ openapiField: { description: 'OAuth client secret. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-secret' } });
export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } });
export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } });
export const oauthAccountMergeStrategySchema = yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).meta({ openapiField: { description: 'Determines how to handle OAuth logins that match an existing user by email. `link_method` adds the OAuth method to the existing user. `raise_error` rejects the login with an error. `allow_duplicates` creates a new user.', exampleValue: 'link_method' } });
// Project email config
export const emailTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'Email provider type, one of shared, standard. "shared" uses Stack shared email provider and it is only meant for development. "standard" uses your own email server and will have your email address as the sender.', exampleValue: 'standard' } });
export const emailSenderNameSchema = yupString().meta({ openapiField: { description: 'Email sender name. Needs to be specified when using type="standard"', exampleValue: 'Stack' } });

View File

@ -20,19 +20,24 @@ const SelectValue = SelectPrimitive.Value;
const SelectTrigger = forwardRefIfNeeded<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & { loading?: boolean }
>(({ className, children, loading, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
loading && "cursor-wait",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
{loading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent opacity-50" />
) : (
<CaretSortIcon className="h-4 w-4 opacity-50" />
)}
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));

View File

@ -99,6 +99,7 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
clientTeamCreationEnabled: data.config.client_team_creation_enabled,
clientUserDeletionEnabled: data.config.client_user_deletion_enabled,
allowLocalhost: data.config.allow_localhost,
oauthAccountMergeStrategy: data.config.oauth_account_merge_strategy,
oauthProviders: data.config.oauth_providers.map((p) => ((p.type === 'shared' ? {
id: p.id,
enabled: p.enabled,

View File

@ -31,6 +31,7 @@ export type AdminProjectConfig = {
readonly createTeamOnSignUp: boolean,
readonly teamCreatorDefaultPermissions: AdminTeamPermission[],
readonly teamMemberDefaultPermissions: AdminTeamPermission[],
readonly oauthAccountMergeStrategy: 'link_method' | 'raise_error' | 'allow_duplicates',
};
export type AdminEmailConfig = (
@ -84,4 +85,5 @@ export type AdminProjectConfigUpdateOptions = {
emailConfig?: AdminEmailConfig,
teamCreatorDefaultPermissions?: { id: string }[],
teamMemberDefaultPermissions?: { id: string }[],
oauthAccountMergeStrategy?: 'link_method' | 'raise_error' | 'allow_duplicates',
};

View File

@ -81,6 +81,7 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio
client_user_deletion_enabled: options.config?.clientUserDeletionEnabled,
team_creator_default_permissions: options.config?.teamCreatorDefaultPermissions,
team_member_default_permissions: options.config?.teamMemberDefaultPermissions,
oauth_account_merge_strategy: options.config?.oauthAccountMergeStrategy,
},
};
}