mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
11239b4687
commit
189a543a31
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user