diff --git a/apps/backend/package.json b/apps/backend/package.json index 5264adf3d..7fbbd50cf 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -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", diff --git a/apps/backend/src/lib/external-auth.tsx b/apps/backend/src/lib/external-auth.tsx new file mode 100644 index 000000000..95ec1df68 --- /dev/null +++ b/apps/backend/src/lib/external-auth.tsx @@ -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 }; +} diff --git a/apps/backend/src/lib/oauth.tsx b/apps/backend/src/lib/oauth.tsx index 3b796771a..081590705 100644 --- a/apps/backend/src/lib/oauth.tsx +++ b/apps/backend/src/lib/oauth.tsx @@ -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, + }); } /** diff --git a/apps/backend/src/lib/saml-account.tsx b/apps/backend/src/lib/saml-account.tsx new file mode 100644 index 000000000..16fbb1f31 --- /dev/null +++ b/apps/backend/src/lib/saml-account.tsx @@ -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>, +): 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 }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc67ad480..5ca462c62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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