feat(stack-shared): add SAML connection config to project schema

Adds tenancy.config.auth.saml — mirrors the auth.oauth shape:

- branchAuthSchema gains saml.{accountMergeStrategy, connections}
  with non-sensitive per-connection fields (displayName, allowSignIn,
  domain). domain feeds /auth/saml/discover.

- environmentConfigSchema extends saml.connections with IdP-side
  fields (idpEntityId, idpSsoUrl, idpCertificate, attributeMapping).
  These belong at the environment level — different per IdP deployment
  even though the cert is technically a public key — same way
  oauth.providers splits clientId/clientSecret out of branch config.

- Defaults block adds an empty saml block; per-connection defaults set
  allowSignIn=true and a placeholder displayName so partial configs
  validate cleanly.

Also drops the temporary unknown-cast workaround in saml-account.tsx
(handleSamlEmailMergeStrategy) and updates the metadata + discover
routes to construct SamlConnectionConfig from the typed config record
(injecting the connection ID since it's stored as the record key).

Adds matching coverage in schema-fuzzer.test.ts so the fuzzed config
shape includes a sample SAML connection.
This commit is contained in:
Bilal Godil 2026-04-29 15:33:19 -07:00
parent 11239b4687
commit 189a543a31
5 changed files with 101 additions and 10 deletions

View File

@ -1,6 +1,7 @@
import { discoverConnectionByEmail } from "@/saml/discovery";
import type { SamlConnectionConfig } from "@/saml/saml";
import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID } from "@/lib/tenancies";
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
@ -39,8 +40,21 @@ export const GET = createSmartRouteHandler({
if (!tenancy) {
throw new StatusError(StatusError.NotFound, `Project ${query.project_id} not found`);
}
const samlConfig = (tenancy.config.auth as { saml?: { connections?: Record<string, SamlConnectionConfig> } }).saml;
const connections = samlConfig?.connections ?? {};
// Inject `id` into each connection so it satisfies SamlConnectionConfig —
// the config schema stores id as the record key, not a value field.
const connections: Record<string, SamlConnectionConfig> = {};
for (const [id, conn] of typedEntries(tenancy.config.auth.saml.connections)) {
if (!conn.idpEntityId || !conn.idpSsoUrl || !conn.idpCertificate) continue;
connections[id] = {
id,
displayName: conn.displayName,
idpEntityId: conn.idpEntityId,
idpSsoUrl: conn.idpSsoUrl,
idpCertificate: conn.idpCertificate,
domain: conn.domain,
attributeMapping: conn.attributeMapping,
};
}
const matched = discoverConnectionByEmail(connections, query.email);
if (!matched) {
throw new StatusError(StatusError.NotFound, "No SAML connection matches this email's domain");

View File

@ -1,5 +1,5 @@
import { getSoleTenancyFromProjectBranch, DEFAULT_BRANCH_ID } from "@/lib/tenancies";
import { getSpMetadataXml, SamlConnectionConfig } from "@/saml/saml";
import { getSpMetadataXml } from "@/saml/saml";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
@ -37,17 +37,24 @@ export const GET = createSmartRouteHandler({
if (!tenancy) {
throw new StatusError(StatusError.NotFound, `Project ${query.project_id} not found`);
}
const samlConfig = (tenancy.config.auth as { saml?: { connections?: Record<string, SamlConnectionConfig> } }).saml;
const connection = samlConfig?.connections?.[params.connection_id];
if (!connection) {
throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} not found in project ${query.project_id}`);
const connection = tenancy.config.auth.saml.connections[params.connection_id];
if (!connection.idpEntityId || !connection.idpSsoUrl || !connection.idpCertificate) {
throw new StatusError(StatusError.NotFound, `SAML connection ${params.connection_id} not found or incompletely configured in project ${query.project_id}`);
}
// Derive the public-facing base URL from the request origin so SP
// metadata reflects the host the IdP is calling.
const reqUrl = new URL(fullReq.url);
const baseUrl = `${reqUrl.protocol}//${reqUrl.host}`;
const xml = getSpMetadataXml({ ...connection, id: params.connection_id }, baseUrl);
const xml = getSpMetadataXml({
id: params.connection_id,
displayName: connection.displayName,
idpEntityId: connection.idpEntityId,
idpSsoUrl: connection.idpSsoUrl,
idpCertificate: connection.idpCertificate,
domain: connection.domain,
attributeMapping: connection.attributeMapping,
}, baseUrl);
return {
statusCode: 200,

View File

@ -60,8 +60,7 @@ export async function handleSamlEmailMergeStrategy(
// 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;
const accountMergeStrategy = tenancy.config.auth.saml.accountMergeStrategy ?? tenancy.config.auth.oauth.accountMergeStrategy;
return await handleExternalEmailMergeStrategy(prisma, tenancy, {
email: params.email,
emailVerified: params.emailVerified,

View File

@ -51,6 +51,16 @@ const branchSchemaFuzzerConfig = [{
}],
}],
}],
saml: [{
accountMergeStrategy: ["link_method", "raise_error", "allow_duplicates"],
connections: [{
"some-saml-connection-id": [{
displayName: ["Acme Corp SSO", "Globex SAML"],
allowSignIn: [true, false],
domain: ["acme.test", "globex.test"],
}],
}],
}],
signUpRules: [{
"some-rule-id": [{
enabled: [true, false],
@ -218,6 +228,16 @@ const environmentSchemaFuzzerConfig = [{
appleBundles: [{ "some-bundle-id": [{ bundleId: ["com.example.app"] }] }],
}]]))] as const,
}],
saml: [{
...branchSchemaFuzzerConfig[0].auth[0].saml[0],
connections: [typedFromEntries(typedEntries(branchSchemaFuzzerConfig[0].auth[0].saml[0].connections[0]).map(([key, value]) => [key, [{
...value[0],
idpEntityId: ["https://idp.example.com/saml/metadata"],
idpSsoUrl: ["https://idp.example.com/saml/sso"],
idpCertificate: ["MIICertificatePlaceholderBase64="],
attributeMapping: [{ email: ["email"], displayName: ["displayName"] }],
}]]))] as const,
}],
}],
domains: [{
allowLocalhost: [true, false],

View File

@ -133,6 +133,24 @@ const branchAuthSchema = yupObject({
}),
),
}),
saml: yupObject({
// Mirrors auth.oauth.accountMergeStrategy. Falls back to the OAuth strategy
// when unset (see saml-account.tsx#handleSamlEmailMergeStrategy), so existing
// projects keep consistent merge behavior across protocols.
accountMergeStrategy: yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).optional(),
// Connections — non-sensitive fields here, IdP cert + URLs added at the
// environment-config level below, mirroring how oauth.providers splits.
connections: yupRecord(
userSpecifiedIdSchema("samlConnectionId"),
yupObject({
displayName: yupString(),
allowSignIn: yupBoolean(),
// Email domain used by /auth/saml/discover for the signInWithSso flow.
// Optional — connections without a domain are addressable by ID only.
domain: yupString().optional(),
}),
),
}),
signUpRules: yupRecord(
userSpecifiedIdSchema("signUpRuleId"),
yupObject({
@ -310,6 +328,27 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
}),
),
})),
saml: branchConfigSchema.getNested("auth").getNested("saml").concat(yupObject({
connections: yupRecord(
userSpecifiedIdSchema("samlConnectionId"),
yupObject({
displayName: yupString().optional(),
allowSignIn: yupBoolean().optional(),
domain: yupString().optional(),
// IdP-side fields. The IdP X.509 cert is technically a public key,
// but it's environment-specific (different per IdP deployment) so it
// belongs here, not at the branch level.
idpEntityId: yupString().optional(),
idpSsoUrl: yupString().optional(),
idpCertificate: yupString().optional(),
// Attribute mapping — defaults to email -> "email", displayName -> "displayName".
attributeMapping: yupObject({
email: yupString().optional(),
displayName: yupString().optional(),
}).optional(),
}),
),
})),
})),
emails: branchConfigSchema.getNested("emails").concat(yupObject({
@ -627,6 +666,18 @@ const organizationConfigDefaults = {
appleBundles: undefined,
}),
},
saml: {
accountMergeStrategy: undefined,
connections: (key: string) => ({
displayName: 'Unnamed SAML connection',
allowSignIn: true,
domain: undefined,
idpEntityId: undefined,
idpSsoUrl: undefined,
idpCertificate: undefined,
attributeMapping: undefined,
}),
},
signUpRules: (key: string) => ({
enabled: false,
displayName: undefined,