feat(backend): extract email-merge helper and add SAML account helpers

Splits the email-merge strategy out of oauth.tsx into a small shared
external-auth.tsx so the upcoming SAML ACS handler can reuse the same
contact-channel lookup + link_method/raise_error/allow_duplicates switch
without duplicating it.

Also adds saml-account.tsx with the SAML-side parallel of OAuth's
findExisting / link / create user-linking helpers, operating on
ProjectUserSamlAccount and SamlAuthMethod. Each helper is keyed by
(tenancyId, samlConnectionId, nameId), so a NameID arriving from a
different connection is treated as a separate identity — connection
isolation is enforced at the DB level.

Schema strategy fallback: handleSamlEmailMergeStrategy reads
tenancy.config.auth.saml.accountMergeStrategy if set, otherwise falls
back to the OAuth strategy. The SAML config field will be added with
the project config schema work.

Adds @xmldom/xmldom and xpath as direct backend deps for the upcoming
SAML protocol wrapper (currently transitive through @node-saml/node-saml).
This commit is contained in:
Bilal Godil 2026-04-29 15:17:35 -07:00
parent cbd2e3fca3
commit c1b7bed261
5 changed files with 271 additions and 50 deletions

View File

@ -62,6 +62,8 @@
"@clickhouse/client": "^1.14.0",
"@node-oauth/oauth2-server": "^5.1.0",
"@node-saml/node-saml": "^5.0.0",
"@xmldom/xmldom": "^0.8.10",
"xpath": "^0.0.34",
"@openrouter/ai-sdk-provider": "2.2.3",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.53.0",

View File

@ -0,0 +1,74 @@
import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel";
import { Tenancy } from "@/lib/tenancies";
import { PrismaClientTransaction } from "@/prisma-client";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
/**
* Email-based account merging shared between OAuth and SAML sign-in.
*
* When a new external sign-in arrives with an email that's already used
* for auth by an existing user, this returns whether to link to that user
* (link_method), reject (raise_error), or create a duplicate
* (allow_duplicates).
*
* Lifted from oauth.tsx so the SAML ACS handler can reuse the same logic
* without duplicating the merge-strategy switch and contact-channel
* lookup.
*
* @returns linkedUserId - User ID to link to, or null if creating new user
* @returns primaryEmailAuthEnabled - Whether the email should be used for auth
*/
export async function handleExternalEmailMergeStrategy(
prisma: PrismaClientTransaction,
tenancy: Tenancy,
params: {
email: string,
emailVerified: boolean,
accountMergeStrategy: "link_method" | "raise_error" | "allow_duplicates",
},
): Promise<{ linkedUserId: string | null, primaryEmailAuthEnabled: boolean }> {
let primaryEmailAuthEnabled = true;
let linkedUserId: string | null = null;
const existingContactChannel = await getAuthContactChannelWithEmailNormalization(
prisma,
{
tenancyId: tenancy.id,
type: "EMAIL",
value: params.email,
},
);
if (existingContactChannel && existingContactChannel.usedForAuth) {
switch (params.accountMergeStrategy) {
case "link_method": {
if (!existingContactChannel.isVerified) {
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", params.email, true);
}
if (!params.emailVerified) {
// Edge case: existing user is verified, but the new external assertion claims an
// unverified email. Linking would let an attacker hijack an account by claiming
// the victim's email at an unverified IdP. We refuse for safety.
const err = new StackAssertionError(
"Account merge strategy is link_method, but the new external email is not verified.",
{ existingContactChannel, email: params.email, emailVerified: params.emailVerified },
);
captureError("external-auth-link-method-email-not-verified", err);
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", params.email);
}
linkedUserId = existingContactChannel.projectUserId;
break;
}
case "raise_error": {
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", params.email);
}
case "allow_duplicates": {
primaryEmailAuthEnabled = false;
break;
}
}
}
return { linkedUserId, primaryEmailAuthEnabled };
}

View File

@ -1,10 +1,10 @@
import { getAuthContactChannelWithEmailNormalization } from "@/lib/contact-channel";
import { handleExternalEmailMergeStrategy } from "@/lib/external-auth";
import { Tenancy } from "@/lib/tenancies";
import { createOrUpgradeAnonymousUserWithRules, SignUpRuleOptions } from "@/lib/users";
import { PrismaClientTransaction } from "@/prisma-client";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
/**
* Find an existing OAuth account for sign-in.
@ -53,8 +53,8 @@ export function getProjectUserIdFromOAuthAccount(
/**
* Handle the OAuth email merge strategy.
*
* This determines whether a new OAuth sign-up should be linked to an existing user
* based on email address, according to the project's merge strategy setting.
* Thin wrapper around handleExternalEmailMergeStrategy that pulls the
* project's OAuth-specific account merge strategy from tenancy config.
*
* @returns linkedUserId - The user ID to link to, or null if creating a new user
* @returns primaryEmailAuthEnabled - Whether the email should be used for auth
@ -65,52 +65,11 @@ export async function handleOAuthEmailMergeStrategy(
email: string,
emailVerified: boolean,
): Promise<{ linkedUserId: string | null, primaryEmailAuthEnabled: boolean }> {
let primaryEmailAuthEnabled = true;
let linkedUserId: string | null = null;
const existingContactChannel = await getAuthContactChannelWithEmailNormalization(
prisma,
{
tenancyId: tenancy.id,
type: "EMAIL",
value: email,
}
);
// Check if we should link this OAuth account to an existing user based on email
if (existingContactChannel && existingContactChannel.usedForAuth) {
const accountMergeStrategy = tenancy.config.auth.oauth.accountMergeStrategy;
switch (accountMergeStrategy) {
case "link_method": {
if (!existingContactChannel.isVerified) {
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", email, true);
}
if (!emailVerified) {
// TODO: Handle this case
const err = new StackAssertionError(
"OAuth account merge strategy is set to link_method, but the NEW email is not verified. This is an edge case that we don't handle right now",
{ existingContactChannel, email, emailVerified }
);
captureError("oauth-link-method-email-not-verified", err);
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", email);
}
// Link to existing user
linkedUserId = existingContactChannel.projectUserId;
break;
}
case "raise_error": {
throw new KnownErrors.ContactChannelAlreadyUsedForAuthBySomeoneElse("email", email);
}
case "allow_duplicates": {
primaryEmailAuthEnabled = false;
break;
}
}
}
return { linkedUserId, primaryEmailAuthEnabled };
return await handleExternalEmailMergeStrategy(prisma, tenancy, {
email,
emailVerified,
accountMergeStrategy: tenancy.config.auth.oauth.accountMergeStrategy,
});
}
/**

View File

@ -0,0 +1,180 @@
import { handleExternalEmailMergeStrategy } from "@/lib/external-auth";
import { Tenancy } from "@/lib/tenancies";
import { createOrUpgradeAnonymousUserWithRules, SignUpRuleOptions } from "@/lib/users";
import { PrismaClientTransaction } from "@/prisma-client";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
/**
* Find an existing SAML account by NameID within a connection. Used when the
* ACS handler receives a verified assertion and needs to look up the user.
*
* Connection isolation invariant: the unique key includes samlConnectionId,
* so the same NameID arriving from a different connection is treated as a
* separate identity. Tests covered: "two connections, two distinct user
* pools" + "cross-connection assertion forgery" (see plan §multi-tenancy).
*/
export async function findExistingSamlAccount(
prisma: PrismaClientTransaction,
tenancyId: string,
samlConnectionId: string,
nameId: string,
) {
const account = await prisma.projectUserSamlAccount.findUnique({
where: {
tenancyId_samlConnectionId_nameId: {
tenancyId,
samlConnectionId,
nameId,
},
},
});
// allowSignIn=false means the user (or admin) has disabled SAML sign-in for
// this account — treat as if not found so the caller errors out cleanly.
if (account && !account.allowSignIn) {
return null;
}
return account;
}
export function getProjectUserIdFromSamlAccount(
account: Awaited<ReturnType<typeof findExistingSamlAccount>>,
): string {
if (!account) {
throw new StackAssertionError("SAML account is null");
}
return account.projectUserId ?? throwErr("SAML account exists but has no associated user");
}
/**
* Wraps the shared email-merge logic with the SAML-specific config path.
* Defaults to "link_method" if the project hasn't configured a strategy
* (the same default as OAuth).
*/
export async function handleSamlEmailMergeStrategy(
prisma: PrismaClientTransaction,
tenancy: Tenancy,
params: { email: string, emailVerified: boolean },
): Promise<{ linkedUserId: string | null, primaryEmailAuthEnabled: boolean }> {
// Read SAML-specific strategy from config; fall back to OAuth's strategy if
// not set, so existing projects keep consistent behavior across protocols
// until they explicitly opt into a different SAML policy.
const samlConfig = (tenancy.config.auth as { saml?: { accountMergeStrategy?: "link_method" | "raise_error" | "allow_duplicates" } }).saml;
const accountMergeStrategy = samlConfig?.accountMergeStrategy ?? tenancy.config.auth.oauth.accountMergeStrategy;
return await handleExternalEmailMergeStrategy(prisma, tenancy, {
email: params.email,
emailVerified: params.emailVerified,
accountMergeStrategy,
});
}
/**
* Link a verified SAML identity to an already-existing user (matched by email).
* Mirrors linkOAuthAccountToUser. Creates one ProjectUserSamlAccount and one
* AuthMethod with a nested SamlAuthMethod.
*/
export async function linkSamlAccountToUser(
prisma: PrismaClientTransaction,
params: {
tenancyId: string,
samlConnectionId: string,
nameId: string,
nameIdFormat: string | null,
email: string | null,
projectUserId: string,
},
): Promise<{ samlAccountId: string }> {
const samlAccount = await prisma.projectUserSamlAccount.create({
data: {
tenancyId: params.tenancyId,
samlConnectionId: params.samlConnectionId,
nameId: params.nameId,
nameIdFormat: params.nameIdFormat,
email: params.email,
projectUserId: params.projectUserId,
},
});
await prisma.authMethod.create({
data: {
tenancyId: params.tenancyId,
projectUserId: params.projectUserId,
samlAuthMethod: {
create: {
projectUserId: params.projectUserId,
samlConnectionId: params.samlConnectionId,
nameId: params.nameId,
},
},
},
});
return { samlAccountId: samlAccount.id };
}
/**
* Create a new user from a verified SAML identity. JIT provisioning runs
* when no existing account or matching email is found. Mirrors
* createOAuthUserAndAccount.
*/
export async function createSamlUserAndAccount(
prisma: PrismaClientTransaction,
tenancy: Tenancy,
params: {
samlConnectionId: string,
nameId: string,
nameIdFormat: string | null,
email: string | null,
emailVerified: boolean,
primaryEmailAuthEnabled: boolean,
currentUser: UsersCrud["Admin"]["Read"] | null,
displayName: string | null,
profileImageUrl: string | null,
signUpRuleOptions: SignUpRuleOptions,
},
): Promise<{ projectUserId: string, samlAccountId: string }> {
if (!tenancy.config.auth.allowSignUp) {
throw new KnownErrors.SignUpNotEnabled();
}
const newUser = await createOrUpgradeAnonymousUserWithRules(
tenancy,
params.currentUser,
{
display_name: params.displayName,
profile_image_url: params.profileImageUrl,
primary_email: params.email,
primary_email_verified: params.emailVerified,
primary_email_auth_enabled: params.primaryEmailAuthEnabled,
},
[],
params.signUpRuleOptions,
);
const authMethod = await prisma.authMethod.create({
data: {
tenancyId: tenancy.id,
projectUserId: newUser.id,
},
});
const samlAccount = await prisma.projectUserSamlAccount.create({
data: {
tenancyId: tenancy.id,
samlConnectionId: params.samlConnectionId,
nameId: params.nameId,
nameIdFormat: params.nameIdFormat,
email: params.email,
projectUserId: newUser.id,
samlAuthMethod: {
create: {
authMethodId: authMethod.id,
},
},
allowSignIn: true,
},
});
return { projectUserId: newUser.id, samlAccountId: samlAccount.id };
}

View File

@ -216,6 +216,9 @@ importers:
'@vercel/sandbox':
specifier: ^1.2.0
version: 1.2.0
'@xmldom/xmldom':
specifier: ^0.8.10
version: 0.8.13
ai:
specifier: ^6.0.0
version: 6.0.81(zod@3.25.76)
@ -300,6 +303,9 @@ importers:
vite:
specifier: ^6.1.0
version: 6.1.0(@types/node@20.17.6)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.15.5)(yaml@2.4.5)
xpath:
specifier: ^0.0.34
version: 0.0.34
yaml:
specifier: ^2.4.5
version: 2.4.5