feat(seed): seed mock SAML connections on the dummy project

Adds back seedSamlConnections + STACK_SEED_ENABLE_SAML callsite. This
PR is the first one in the stack where auth.saml.connections.* exists
in the config schema, so the writes actually take effect (on the mock
IdP PR they were silently dropped during normalization).

Default mock URL is now derived from NEXT_PUBLIC_STACK_PORT_PREFIX +
the mock's new port suffix 42, instead of being hardcoded to
http://localhost:8115. STACK_MOCK_SAML_URL still wins as an explicit
override. Metadata fetches now use a 10s AbortSignal.timeout and
encodeURIComponent the slug into the URL.
This commit is contained in:
Bilal Godil 2026-04-30 14:48:49 -07:00
parent 7bcc84fb28
commit 841d0591d0

View File

@ -1939,6 +1939,74 @@ async function seedBulkSignupsAndActivity(options: {
console.log(`[seed-activity] Events: $token-refresh=${tokenRefreshCount} $page-view=${pageViewCount} $click=${clickCount} total=${clickhouseRows.length}`);
}
/**
* Pre-creates two SAML connections (acme + globex) on the dummy project that
* point at the local mock SAML IdP. Gated on STACK_SEED_ENABLE_SAML='true'.
* Fetches the mock IdP's metadata at seed time so the seeded cert matches
* the cert the mock generated at startup the mock currently regenerates
* keys per restart, so re-seed if you restart the mock.
*/
async function seedSamlConnections(projectId: string): Promise<void> {
// Default URL is derived from NEXT_PUBLIC_STACK_PORT_PREFIX so a non-default
// prefix (e.g. running multiple dev environments side by side) doesn't make
// the seed reach into a stale service from a different stack.
const portPrefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81");
const mockUrl = getEnvVariable("STACK_MOCK_SAML_URL", `http://localhost:${portPrefix}42`);
const tenants: Array<{ slug: string, displayName: string, domain: string }> = [
{ slug: "acme", displayName: "Acme Corp SSO", domain: "acme.test" },
{ slug: "globex", displayName: "Globex SAML", domain: "globex.test" },
];
const fetched = await Promise.all(
tenants.map(async (t) => {
const metadataUrl = new URL(`/idp/${encodeURIComponent(t.slug)}/metadata`, mockUrl);
const res = await fetch(metadataUrl, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) {
throw new Error(`Mock SAML IdP at ${metadataUrl.toString()} returned ${res.status} — is the mock running?`);
}
const xml = await res.text();
// Inline minimal metadata parse to avoid a circular import. Format is
// exactly what the mock emits, so a regex is enough; the production
// parser at apps/backend/src/saml/metadata-parser.tsx is the
// robust one used by the dashboard "paste metadata" form.
const entityIdMatch = xml.match(/entityID="([^"]+)"/);
const ssoUrlMatch = xml.match(/Binding="urn:oasis:names:tc:SAML:2\.0:bindings:HTTP-Redirect"[^>]*Location="([^"]+)"/);
const certMatch = xml.match(/<X509Certificate>([\s\S]+?)<\/X509Certificate>/);
if (!entityIdMatch || !ssoUrlMatch || !certMatch) {
throw new Error(`Could not parse mock IdP metadata for tenant ${t.slug}`);
}
return {
...t,
idpEntityId: entityIdMatch[1],
idpSsoUrl: ssoUrlMatch[1],
idpCertificate: certMatch[1].replace(/\s+/g, ""),
};
}),
);
// Set the entire connection entry as a single value, not as deep
// dot-keys — config normalization with onDotIntoNonObject="ignore"
// drops dot-keys that try to navigate into a record entry that
// doesn't yet exist (same convention as auth.oauth.providers).
const overlay: Parameters<typeof overrideEnvironmentConfigOverride>[0]["environmentConfigOverrideOverride"] = {};
for (const f of fetched) {
overlay[`auth.saml.connections.${f.slug}`] = {
displayName: f.displayName,
allowSignIn: true,
domain: f.domain,
idpEntityId: f.idpEntityId,
idpSsoUrl: f.idpSsoUrl,
idpCertificate: f.idpCertificate,
};
}
await overrideEnvironmentConfigOverride({
projectId,
branchId: DEFAULT_BRANCH_ID,
environmentConfigOverrideOverride: overlay,
});
}
/**
* Creates a new project and fills it with dummy data (users, teams, payments, emails, analytics events).
* Used by both the seed script and the preview project creation endpoint.
@ -2090,6 +2158,16 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis
}),
]);
// Run sequentially after the parallel block. Both this and the
// `payments.testMode` write above target the same environment config,
// and `overrideEnvironmentConfigOverride` is read-modify-write — running
// them in parallel races and one write clobbers the other (TODO at
// config.ts:491 already documents this). Sequencing avoids the race
// until the underlying override is wrapped in a serializable txn.
if (getEnvVariable("STACK_SEED_ENABLE_SAML", "false") === "true") {
await seedSamlConnections(projectId);
}
await seedDummyTransactions({
prisma: dummyPrisma,
tenancyId: dummyTenancy.id,