mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
cbd2e3fca3
commit
c1b7bed261
@ -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",
|
||||
|
||||
74
apps/backend/src/lib/external-auth.tsx
Normal file
74
apps/backend/src/lib/external-auth.tsx
Normal 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 };
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
180
apps/backend/src/lib/saml-account.tsx
Normal file
180
apps/backend/src/lib/saml-account.tsx
Normal 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 };
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user