From d4d25f6255f970335526d4b2246e1fe9910fa248 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 15:09:18 -0700 Subject: [PATCH 01/11] feat(mock-saml-idp): scaffold mock SAML 2.0 IdP for SAML SSO testing Adds apps/mock-saml-idp, a multi-tenant SAML 2.0 Identity Provider mock mirroring apps/mock-oauth-server. Each tenant has its own RSA keypair and self-signed cert generated at startup, so one mock service can back many SamlConnection rows in tests and exercise per-connection isolation. Uses samlify deliberately because the upcoming backend SAML wrapper will use @node-saml/node-saml. Different libraries on each side means a bug in either library's signature canonicalization surfaces as a test failure instead of being masked by both sides agreeing. Endpoints: - GET /idp/:tenant/metadata IdP metadata XML - GET /idp/:tenant/sso AuthnRequest receiver, renders login form - POST /idp/:tenant/login builds and auto-POSTs signed assertion - POST /idp/:tenant/test-controls queues misbehaviors (bad-signature, expired, wrong-audience, replay, etc.) - GET /idp introspection Also adds @node-saml/node-saml to apps/backend deps for the upcoming backend SAML protocol wrapper. --- apps/backend/package.json | 1 + apps/mock-saml-idp/.eslintrc.js | 7 + apps/mock-saml-idp/package.json | 26 ++ apps/mock-saml-idp/src/index.ts | 445 +++++++++++++++++++++++++++++++ apps/mock-saml-idp/tsconfig.json | 33 +++ pnpm-lock.yaml | 221 +++++++++++++-- 6 files changed, 708 insertions(+), 25 deletions(-) create mode 100644 apps/mock-saml-idp/.eslintrc.js create mode 100644 apps/mock-saml-idp/package.json create mode 100644 apps/mock-saml-idp/src/index.ts create mode 100644 apps/mock-saml-idp/tsconfig.json diff --git a/apps/backend/package.json b/apps/backend/package.json index b0500582df..5264adf3d6 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -61,6 +61,7 @@ "@aws-sdk/client-s3": "^3.855.0", "@clickhouse/client": "^1.14.0", "@node-oauth/oauth2-server": "^5.1.0", + "@node-saml/node-saml": "^5.0.0", "@openrouter/ai-sdk-provider": "2.2.3", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.53.0", diff --git a/apps/mock-saml-idp/.eslintrc.js b/apps/mock-saml-idp/.eslintrc.js new file mode 100644 index 0000000000..49bc087f44 --- /dev/null +++ b/apps/mock-saml-idp/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "extends": [ + "../../configs/eslint/defaults.js", + "../../configs/eslint/next.js", + ], + "ignorePatterns": ['/*', '!/src'] +}; diff --git a/apps/mock-saml-idp/package.json b/apps/mock-saml-idp/package.json new file mode 100644 index 0000000000..7af22dd074 --- /dev/null +++ b/apps/mock-saml-idp/package.json @@ -0,0 +1,26 @@ +{ + "name": "@stackframe/mock-saml-idp", + "version": "2.8.86", + "repository": "https://github.com/stack-auth/stack-auth", + "private": true, + "main": "index.js", + "scripts": { + "start": "tsx src/index.ts", + "dev": "tsx watch --clear-screen=false src/index.ts", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "clean": "rimraf dist && rimraf node_modules" + }, + "dependencies": { + "@types/express": "^5.0.0", + "@types/node-forge": "^1.3.11", + "express": "^4.21.2", + "handlebars": "^4.7.8", + "node-forge": "^1.3.1", + "samlify": "^2.10.0" + }, + "devDependencies": { + "tsx": "^4.16.2" + }, + "packageManager": "pnpm@10.23.0" +} diff --git a/apps/mock-saml-idp/src/index.ts b/apps/mock-saml-idp/src/index.ts new file mode 100644 index 0000000000..e3b9efcb7f --- /dev/null +++ b/apps/mock-saml-idp/src/index.ts @@ -0,0 +1,445 @@ +/** + * Mock SAML 2.0 Identity Provider for e2e tests + local development. + * + * Multi-tenant: serves N virtual IdPs under /idp/:tenant/. Each tenant has + * its own RSA keypair + self-signed cert generated at startup. This lets one + * mock service back many SamlConnection rows in tests and exercise per- + * connection isolation. + * + * IMPORTANT: Uses `samlify` deliberately because the backend SAML wrapper + * uses `@node-saml/node-saml`. Different libraries on each side means a bug + * in either library's signature canonicalization surfaces as a test failure + * instead of being masked by both sides agreeing. + */ +import express from 'express'; +import handlebars from 'handlebars'; +import forge from 'node-forge'; +import * as samlify from 'samlify'; + +const stackPortPrefix = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81"; +const defaultPort = Number(`${stackPortPrefix}15`); +const port = Number(process.env.STACK_SAML_MOCK_PORT ?? process.env.PORT ?? defaultPort); +const tenantSlugs = (process.env.STACK_MOCK_SAML_TENANTS ?? "acme,globex").split(",").map(s => s.trim()).filter(Boolean); + +// samlify requires a schema validator. For a test mock we skip XSD validation +// so we don't need to ship the SAML schema files. +samlify.setSchemaValidator({ + validate: async () => "skipped", +}); + +type Misbehavior = + | { kind: 'none' } + | { kind: 'bad-signature' } // sign with another tenant's key + | { kind: 'expired' } // NotOnOrAfter in the past + | { kind: 'not-yet-valid' } // NotBefore in the far future + | { kind: 'wrong-audience' } // Audience set to "https://wrong.example/" + | { kind: 'wrong-in-response-to' } // InResponseTo set to a random ID + | { kind: 'missing-name-id' } // strip the + | { kind: 'missing-email' } // omit the email attribute + | { kind: 'replay' } // emit the previous tenant's response again + | { kind: 'sign-with-tenant', tenant: string } // sign with a specified tenant's key +; + +type TenantState = { + slug: string, + entityId: string, + privateKeyPem: string, + certPem: string, + certForMetadata: string, // cert with PEM headers stripped, base64-only (per SAML spec) + idp: ReturnType, + // Pending misbehavior consumed on the next assertion. Cleared after one use. + nextMisbehavior: Misbehavior, + // Last successful response context, used for the `replay` misbehavior. + lastResponse: { samlResponseB64: string, relayState: string, acsUrl: string } | null, +}; + +const tenants = new Map(); + +// ---------- key + cert generation ---------------------------------------- + +function generateSelfSignedCert(commonName: string): { privateKeyPem: string, certPem: string } { + const keys = forge.pki.rsa.generateKeyPair(2048); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = '01'; + cert.validity.notBefore = new Date(); + cert.validity.notAfter = new Date(); + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10); + const attrs = [ + { name: 'commonName', value: commonName }, + { name: 'organizationName', value: 'Stack Auth Mock SAML IdP' }, + ]; + cert.setSubject(attrs); + cert.setIssuer(attrs); + cert.sign(keys.privateKey, forge.md.sha256.create()); + return { + privateKeyPem: forge.pki.privateKeyToPem(keys.privateKey), + certPem: forge.pki.certificateToPem(cert), + }; +} + +function pemToBase64Cert(pem: string): string { + return pem + .replace(/-----BEGIN CERTIFICATE-----/g, '') + .replace(/-----END CERTIFICATE-----/g, '') + .replace(/\s+/g, ''); +} + +function entityIdFor(slug: string): string { + return `http://localhost:${port}/idp/${slug}/metadata`; +} + +function ssoUrlFor(slug: string): string { + return `http://localhost:${port}/idp/${slug}/sso`; +} + +function buildIdpMetadataXml(tenant: TenantState): string { + return ` + + + + + + ${tenant.certForMetadata} + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + +`; +} + +// samlify's loginResponseTemplate. Placeholders are substituted in the +// customTagReplacement callback so we can inject misbehaviors there. +const loginResponseTemplate = { + context: `{Issuer}{Issuer}{NameID}{Audience}{Email}{DisplayName}urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport`, +}; + +// ---------- tenant init -------------------------------------------------- + +for (const slug of tenantSlugs) { + const { privateKeyPem, certPem } = generateSelfSignedCert(`mock-saml-idp-${slug}`); + const certForMetadata = pemToBase64Cert(certPem); + const entityId = entityIdFor(slug); + + // Build minimal IdP metadata for samlify's IdentityProvider constructor. + const metadata = ` + + + ${certForMetadata} + + + +`; + + const idp = samlify.IdentityProvider({ + metadata, + privateKey: privateKeyPem, + isAssertionEncrypted: false, + loginResponseTemplate, + }); + + tenants.set(slug, { + slug, + entityId, + privateKeyPem, + certPem, + certForMetadata, + idp, + nextMisbehavior: { kind: 'none' }, + lastResponse: null, + }); +} + +// ---------- request parsing ---------------------------------------------- + +type ParsedRequest = { + requestId: string, + issuer: string, // SP entity ID + acsUrl: string, // AssertionConsumerService URL from request + relayState: string, +}; + +// Decode an HTTP-Redirect AuthnRequest. samlify can do this but to keep the +// request parsing lib-independent (so a samlify bug here doesn't mask +// backend bugs), we decode the XML ourselves and pull the fields we need. +async function parseAuthnRequestRedirect(samlRequestParam: string, relayState: string): Promise { + const compressed = Buffer.from(samlRequestParam, 'base64'); + const zlib = await import('zlib'); + const xml = zlib.inflateRawSync(compressed).toString('utf-8'); + return extractRequestFields(xml, relayState); +} + +function extractRequestFields(xml: string, relayState: string): ParsedRequest { + const idMatch = xml.match(/ID="([^"]+)"/); + const issuerMatch = xml.match(/<(?:saml:)?Issuer[^>]*>([^<]+)<\/(?:saml:)?Issuer>/); + const acsMatch = xml.match(/AssertionConsumerServiceURL="([^"]+)"/); + if (!idMatch || !issuerMatch || !acsMatch) { + throw new Error(`Mock IdP could not parse AuthnRequest (id=${!!idMatch}, issuer=${!!issuerMatch}, acs=${!!acsMatch})`); + } + return { + requestId: idMatch[1], + issuer: issuerMatch[1], + acsUrl: acsMatch[1], + relayState, + }; +} + +// ---------- assertion building ------------------------------------------- + +const ASSERTION_LIFETIME_MS = 5 * 60 * 1000; + +function isoNow(offsetMs = 0): string { + return new Date(Date.now() + offsetMs).toISOString(); +} + +async function buildAssertion( + tenant: TenantState, + parsed: ParsedRequest, + user: { email: string, displayName: string }, +): Promise<{ samlResponseB64: string, acsUrl: string }> { + const misbehavior = tenant.nextMisbehavior; + tenant.nextMisbehavior = { kind: 'none' }; + + // `replay`: re-emit the cached response. + if (misbehavior.kind === 'replay') { + if (!tenant.lastResponse) { + throw new Error('replay misbehavior requested but no previous response cached for this tenant'); + } + return { samlResponseB64: tenant.lastResponse.samlResponseB64, acsUrl: tenant.lastResponse.acsUrl }; + } + + // `bad-signature` and `sign-with-tenant`: sign with another tenant's key. + let signingTenant: TenantState = tenant; + if (misbehavior.kind === 'bad-signature') { + const other = Array.from(tenants.values()).find(t => t.slug !== tenant.slug); + if (!other) { + throw new Error('bad-signature misbehavior requires at least 2 tenants configured'); + } + signingTenant = other; + } else if (misbehavior.kind === 'sign-with-tenant') { + const other = tenants.get(misbehavior.tenant); + if (!other) { + throw new Error(`sign-with-tenant misbehavior references unknown tenant ${misbehavior.tenant}`); + } + signingTenant = other; + } + + // Build inline SP — derive from the AuthnRequest to avoid pre-registration. + const sp = samlify.ServiceProvider({ + entityID: parsed.issuer, + assertionConsumerService: [{ + Binding: samlify.Constants.namespace.binding.post, + Location: parsed.acsUrl, + }], + }); + + // Compute the substitution map. Misbehaviors mutate this. + const audience = misbehavior.kind === 'wrong-audience' + ? 'https://wrong.example/audience' + : parsed.issuer; + + const inResponseTo = misbehavior.kind === 'wrong-in-response-to' + ? `_mock_misbehave_${Math.random().toString(36).slice(2)}` + : parsed.requestId; + + const conditionsNotBefore = misbehavior.kind === 'not-yet-valid' + ? isoNow(60 * 60 * 1000) // +1 hour + : isoNow(-30 * 1000); + + const conditionsNotOnOrAfter = misbehavior.kind === 'expired' + ? isoNow(-60 * 1000) // expired 1 minute ago + : isoNow(ASSERTION_LIFETIME_MS); + + const result = await signingTenant.idp.createLoginResponse( + sp, + { extract: { request: { id: parsed.requestId } } } as any, + 'post', + user, + (template: string) => { + const id = `_mock_resp_${Math.random().toString(36).slice(2)}`; + const assertionId = `_mock_assert_${Math.random().toString(36).slice(2)}`; + const issueInstant = isoNow(); + + let context = template + .replace(/\{ID\}/g, id) + .replace(/\{AssertionID\}/g, assertionId) + .replace(/\{IssueInstant\}/g, issueInstant) + .replace(/\{Destination\}/g, parsed.acsUrl) + .replace(/\{Issuer\}/g, tenant.entityId) + .replace(/\{StatusCode\}/g, 'urn:oasis:names:tc:SAML:2.0:status:Success') + .replace(/\{NameID\}/g, user.email) + .replace(/\{NameIDFormat\}/g, 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress') + .replace(/\{SubjectConfirmationDataNotOnOrAfter\}/g, isoNow(ASSERTION_LIFETIME_MS)) + .replace(/\{SubjectRecipient\}/g, parsed.acsUrl) + .replace(/\{InResponseTo\}/g, inResponseTo) + .replace(/\{ConditionsNotBefore\}/g, conditionsNotBefore) + .replace(/\{ConditionsNotOnOrAfter\}/g, conditionsNotOnOrAfter) + .replace(/\{Audience\}/g, audience) + .replace(/\{Email\}/g, user.email) + .replace(/\{DisplayName\}/g, user.displayName); + + if (misbehavior.kind === 'missing-name-id') { + context = context.replace(/]*>[^<]*<\/saml:NameID>/, ''); + } + if (misbehavior.kind === 'missing-email') { + context = context.replace( + //, + '', + ); + } + return { id, context }; + }, + ); + + const samlResponseB64 = result.context; + // Cache for `replay` misbehavior (only happy-path responses get cached). + if (misbehavior.kind === 'none') { + tenant.lastResponse = { samlResponseB64, relayState: parsed.relayState, acsUrl: parsed.acsUrl }; + } + return { samlResponseB64, acsUrl: parsed.acsUrl }; +} + +// ---------- HTTP server -------------------------------------------------- + +const app = express(); +app.use(express.urlencoded({ extended: false })); +app.use(express.json()); + +const loginFormSource = ` +Mock SAML IdP — {{tenant}} +
+

Mock SAML IdP

+
Tenant: {{tenant}} · Request ID: {{requestId}}
+
+ + + + + + + +
+
No password — this is a test IdP. The submitted email becomes the NameID.
+
`; +const loginForm = handlebars.compile(loginFormSource); + +const autoPostFormSource = ` + +
+ + + +
+`; +const autoPostForm = handlebars.compile(autoPostFormSource); + +function getTenant(req: express.Request, res: express.Response): TenantState | null { + const slug = req.params.tenant; + const t = tenants.get(slug); + if (!t) { + res.status(404).send(`Unknown tenant "${slug}". Configured: ${Array.from(tenants.keys()).join(", ")}`); + return null; + } + return t; +} + +// Metadata +app.get('/idp/:tenant/metadata', (req, res) => { + const t = getTenant(req, res); + if (!t) return; + res.type('application/xml').send(buildIdpMetadataXml(t)); +}); + +// SSO endpoint — HTTP-Redirect binding (GET) shows the login form. +app.get('/idp/:tenant/sso', async (req, res) => { + const t = getTenant(req, res); + if (!t) return; + const samlRequest = req.query.SAMLRequest; + const relayState = (req.query.RelayState as string | undefined) ?? ''; + if (typeof samlRequest !== 'string') { + res.status(400).send('Missing SAMLRequest query parameter'); + return; + } + try { + const parsed = await parseAuthnRequestRedirect(samlRequest, relayState); + res.send(loginForm({ + tenant: t.slug, + requestId: parsed.requestId, + samlRequest, + relayState, + })); + } catch (err: any) { + res.status(400).send(`Mock IdP failed to parse AuthnRequest: ${err.message}`); + } +}); + +// Login form submission — builds the assertion and auto-POSTs to ACS. +app.post('/idp/:tenant/login', async (req, res) => { + const t = getTenant(req, res); + if (!t) return; + const email = String(req.body.email ?? '').trim(); + const displayName = String(req.body.displayName ?? email.split('@')[0] ?? 'Mock User').trim(); + const samlRequest = String(req.body.SAMLRequest ?? ''); + const relayState = String(req.body.RelayState ?? ''); + if (!email || !samlRequest) { + res.status(400).send('Missing email or SAMLRequest'); + return; + } + try { + const parsed = await parseAuthnRequestRedirect(samlRequest, relayState); + const { samlResponseB64, acsUrl } = await buildAssertion(t, parsed, { email, displayName }); + res.send(autoPostForm({ acsUrl, samlResponse: samlResponseB64, relayState })); + } catch (err: any) { + res.status(500).send(`Mock IdP failed to build assertion: ${err.message}`); + } +}); + +// Test-controls — set the next assertion to misbehave in a specific way. +// E2E tests call this BEFORE driving the login flow. +app.post('/idp/:tenant/test-controls', (req, res) => { + const t = getTenant(req, res); + if (!t) return; + const body = req.body as { kind?: unknown }; + if (typeof body.kind !== 'string') { + res.status(400).json({ error: 'body must be a Misbehavior object with `kind`' }); + return; + } + t.nextMisbehavior = body as Misbehavior; + res.json({ ok: true, queued: body }); +}); + +// Health + introspection +app.get('/idp', (req, res) => { + res.json({ + tenants: Array.from(tenants.values()).map(t => ({ + slug: t.slug, + entityId: t.entityId, + metadataUrl: `http://localhost:${port}/idp/${t.slug}/metadata`, + ssoUrl: ssoUrlFor(t.slug), + nextMisbehavior: t.nextMisbehavior, + })), + }); +}); + +app.listen(port, () => { + console.log(`Mock SAML IdP listening on http://localhost:${port}`); + console.log(` tenants: ${Array.from(tenants.keys()).join(", ")}`); + for (const t of Array.from(tenants.values())) { + console.log(` /idp/${t.slug}/metadata`); + } +}); diff --git a/apps/mock-saml-idp/tsconfig.json b/apps/mock-saml-idp/tsconfig.json new file mode 100644 index 0000000000..2d891a868c --- /dev/null +++ b/apps/mock-saml-idp/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "noErrorTruncation": true, + "paths": { + "@/*": [ + "./src/*" + ] + }, + "skipLibCheck": true + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 482352d2f2..edc9ea8389 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: '@node-oauth/oauth2-server': specifier: ^5.1.0 version: 5.1.0 + '@node-saml/node-saml': + specifier: ^5.0.0 + version: 5.1.0 '@openrouter/ai-sdk-provider': specifier: 2.2.3 version: 2.2.3(ai@6.0.81(zod@3.25.76))(zod@3.25.76) @@ -749,7 +752,7 @@ importers: version: 1.166.6(crossws@0.4.4(srvx@0.8.16)) nitro: specifier: ^3.0.0 - version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2) + version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2) react: specifier: 19.2.1 version: 19.2.1 @@ -865,6 +868,34 @@ importers: specifier: ^4.16.2 version: 4.16.2 + apps/mock-saml-idp: + dependencies: + '@types/express': + specifier: ^5.0.0 + version: 5.0.0 + '@types/node-forge': + specifier: ^1.3.11 + version: 1.3.14 + body-parser: + specifier: ^1.20.3 + version: 1.20.3 + express: + specifier: ^4.21.2 + version: 4.21.2 + handlebars: + specifier: ^4.7.8 + version: 4.7.8 + node-forge: + specifier: ^1.3.1 + version: 1.4.0 + samlify: + specifier: ^2.10.0 + version: 2.12.0 + devDependencies: + tsx: + specifier: ^4.16.2 + version: 4.21.0 + docs: dependencies: 2027-track: @@ -2712,6 +2743,10 @@ packages: '@asyncapi/specs@6.8.1': resolution: {integrity: sha512-czHoAk3PeXTLR+X8IUaD+IpT+g+zUvkcgMDJVothBsan+oHN3jfcFcFUNdOPAAFoUCQN1hXF1dWuphWy05THlA==} + '@authenio/xml-encryption@2.0.2': + resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} + engines: {node: '>=12'} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -5957,6 +5992,10 @@ packages: resolution: {integrity: sha512-sYvqL1GeZLRSwgl++/oOzxJj/ZBe2yXnp6E5LGNQ5qjpn0+t/dwquXILUe3Sk2Y8/wU7XeRxToOtBVeSVkuJag==} engines: {node: '>=16.0.0'} + '@node-saml/node-saml@5.1.0': + resolution: {integrity: sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==} + engines: {node: '>= 18'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -10492,6 +10531,9 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node-forge@1.3.14': + resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -10540,6 +10582,9 @@ packages: '@types/qrcode@1.5.5': resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} + '@types/qs@6.15.0': + resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} + '@types/qs@6.9.15': resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} @@ -10612,6 +10657,12 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/xml-encryption@1.2.4': + resolution: {integrity: sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==} + + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -10928,6 +10979,14 @@ packages: '@webgpu/types@0.1.66': resolution: {integrity: sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==} + '@xmldom/is-dom-node@1.0.1': + resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} + engines: {node: '>= 16'} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + '@xstate/fsm@1.6.5': resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} @@ -11206,6 +11265,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + asn1js@3.0.7: resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} engines: {node: '>=12.0.0'} @@ -15847,6 +15909,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} + engines: {node: '>= 6.13.0'} + node-gyp-build@4.8.4: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true @@ -15860,6 +15926,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + node-rsa@1.1.1: + resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + nodemailer@6.9.13: resolution: {integrity: sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==} engines: {node: '>=6.0.0'} @@ -17434,6 +17503,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + samlify@2.12.0: + resolution: {integrity: sha512-ewGsHyY4kInDH0BfprlAZ1rHpH1jBmbqYiXDbuI3t1Y8h71gqEt4Z7jdCFyPHFR8jItJkbdckTijUZGg14CDlg==} + sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} @@ -19335,6 +19407,16 @@ packages: resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} engines: {node: '>= 6.0'} + xml-crypto@6.1.2: + resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==} + engines: {node: '>=16'} + + xml-encryption@3.1.0: + resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==} + + xml-escape@1.1.0: + resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} + xml-js@1.6.11: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true @@ -19347,6 +19429,9 @@ packages: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} @@ -19355,9 +19440,25 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xpath@0.0.32: + resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==} + engines: {node: '>=0.6.0'} + + xpath@0.0.33: + resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==} + engines: {node: '>=0.6.0'} + + xpath@0.0.34: + resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} + engines: {node: '>=0.6.0'} + xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} @@ -19806,6 +19907,12 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@authenio/xml-encryption@2.0.2': + dependencies: + '@xmldom/xmldom': 0.8.13 + escape-html: 1.0.3 + xpath: 0.0.32 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -23697,6 +23804,23 @@ snapshots: basic-auth: 2.0.1 type-is: 1.6.18 + '@node-saml/node-saml@5.1.0': + dependencies: + '@types/debug': 4.1.12 + '@types/qs': 6.15.0 + '@types/xml-encryption': 1.2.4 + '@types/xml2js': 0.4.14 + '@xmldom/is-dom-node': 1.0.1 + '@xmldom/xmldom': 0.8.13 + debug: 4.4.3 + xml-crypto: 6.1.2 + xml-encryption: 3.1.0 + xml2js: 0.6.2 + xmlbuilder: 15.1.1 + xpath: 0.0.34 + transitivePeerDependencies: + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -30230,6 +30354,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/node-forge@1.3.14': + dependencies: + '@types/node': 22.19.0 + '@types/node@12.20.55': {} '@types/node@20.17.6': @@ -30295,6 +30423,8 @@ snapshots: dependencies: '@types/node': 22.19.0 + '@types/qs@6.15.0': {} + '@types/qs@6.9.15': {} '@types/range-parser@1.2.7': {} @@ -30381,6 +30511,14 @@ snapshots: dependencies: '@types/node': 22.19.0 + '@types/xml-encryption@1.2.4': + dependencies: + '@types/node': 22.19.0 + + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 22.19.0 + '@types/yauzl@2.10.3': dependencies: '@types/node': 22.19.0 @@ -30636,13 +30774,6 @@ snapshots: optionalDependencies: '@aws-sdk/credential-provider-web-identity': 3.972.27 - '@vercel/mcp-adapter@1.0.0(@modelcontextprotocol/sdk@1.17.2)(next@15.5.10(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': - dependencies: - '@modelcontextprotocol/sdk': 1.17.2 - mcp-handler: 1.0.1(@modelcontextprotocol/sdk@1.17.2)(next@15.5.10(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) - optionalDependencies: - next: 15.5.10(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@vercel/mcp-adapter@1.0.0(@modelcontextprotocol/sdk@1.17.2)(next@16.1.7(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': dependencies: '@modelcontextprotocol/sdk': 1.17.2 @@ -30831,6 +30962,10 @@ snapshots: '@webgpu/types@0.1.66': {} + '@xmldom/is-dom-node@1.0.1': {} + + '@xmldom/xmldom@0.8.13': {} + '@xstate/fsm@1.6.5': {} '@xtuc/ieee754@1.2.0': {} @@ -31141,6 +31276,10 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + asn1js@3.0.7: dependencies: pvtsutils: 1.3.6 @@ -33358,7 +33497,7 @@ snapshots: eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1) fast-glob: 3.3.3 - get-tsconfig: 4.8.1 + get-tsconfig: 4.13.6 is-core-module: 2.15.1 is-glob: 4.0.3 transitivePeerDependencies: @@ -33373,7 +33512,7 @@ snapshots: debug: 4.4.3 enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) fast-glob: 3.3.3 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -33416,7 +33555,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -33494,7 +33633,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -34181,7 +34320,7 @@ snapshots: escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -36229,15 +36368,6 @@ snapshots: math-intrinsics@1.1.0: {} - mcp-handler@1.0.1(@modelcontextprotocol/sdk@1.17.2)(next@15.5.10(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): - dependencies: - '@modelcontextprotocol/sdk': 1.17.2 - chalk: 5.6.2 - commander: 11.1.0 - redis: 4.7.1 - optionalDependencies: - next: 15.5.10(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - mcp-handler@1.0.1(@modelcontextprotocol/sdk@1.17.2)(next@16.1.7(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): dependencies: '@modelcontextprotocol/sdk': 1.17.2 @@ -37367,7 +37497,7 @@ snapshots: jsonpath-plus: 10.4.0 lodash.topath: 4.5.2 - nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2): + nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0))(xml2js@0.6.2): dependencies: consola: 3.4.2 cookie-es: 2.0.0 @@ -37387,6 +37517,7 @@ snapshots: unenv: 2.0.0-rc.21 unstorage: 2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3))(lru-cache@11.2.2)(ofetch@1.5.1) optionalDependencies: + rolldown: 1.0.0-rc.3 vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0) xml2js: 0.6.2 transitivePeerDependencies: @@ -37454,6 +37585,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-forge@1.4.0: {} + node-gyp-build@4.8.4: {} node-releases@2.0.14: {} @@ -37462,6 +37595,10 @@ snapshots: node-releases@2.0.27: {} + node-rsa@1.1.1: + dependencies: + asn1: 0.2.6 + nodemailer@6.9.13: {} non-error@0.1.0: {} @@ -39537,6 +39674,16 @@ snapshots: safer-buffer@2.1.2: {} + samlify@2.12.0: + dependencies: + '@authenio/xml-encryption': 2.0.2 + '@xmldom/xmldom': 0.8.13 + node-rsa: 1.1.1 + xml: 1.0.1 + xml-crypto: 6.1.2 + xml-escape: 1.1.0 + xpath: 0.0.34 + sax@1.4.1: {} saxes@6.0.0: @@ -39641,7 +39788,7 @@ snapshots: ms: 2.1.3 on-finished: 2.4.1 range-parser: 1.2.1 - statuses: 2.0.1 + statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -40861,7 +41008,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.1 - get-tsconfig: 4.8.1 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -41875,6 +42022,20 @@ snapshots: dependencies: os-paths: 4.4.0 + xml-crypto@6.1.2: + dependencies: + '@xmldom/is-dom-node': 1.0.1 + '@xmldom/xmldom': 0.8.13 + xpath: 0.0.33 + + xml-encryption@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + escape-html: 1.0.3 + xpath: 0.0.32 + + xml-escape@1.1.0: {} + xml-js@1.6.11: dependencies: sax: 1.4.1 @@ -41886,6 +42047,8 @@ snapshots: sax: 1.4.1 xmlbuilder: 11.0.1 + xml@1.0.1: {} + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -41895,8 +42058,16 @@ snapshots: xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xpath@0.0.32: {} + + xpath@0.0.33: {} + + xpath@0.0.34: {} + xss@1.0.15: dependencies: commander: 2.20.3 From 6c7b14b3bcaf839da35167f73f722dc1fe2b475c Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 15:40:56 -0700 Subject: [PATCH 02/11] feat: wire mock-saml-idp into CI, snapshots, and seed dummy data Three smaller pieces that unlock e2e testing: - .github/workflows/e2e-api-tests.yaml: starts mock-saml-idp on port 8115 alongside mock-oauth-server, with /idp as the readiness probe. Root package.json adds start:mock-saml-idp script and includes the mock in dev:basic. - apps/e2e/tests/snapshot-serializer.ts: strips SAMLRequest / SAMLResponse / RelayState query+form params, adds stack-saml-inner- to keyed cookie name prefixes (so the per-AuthnRequest CSRF cookie doesn't reroll snapshots), and adds regex replacements for SAML xs:ID identifiers and IssueInstant/NotBefore/NotOnOrAfter timestamps. - apps/backend/src/lib/seed-dummy-data.ts: STACK_SEED_ENABLE_SAML=true pre-creates acme + globex SAML connections on the dummy project, fetching the IdP metadata from the running mock at seed time so the seeded cert matches what the mock generated at startup. The mock regenerates keys per restart, so re-seed if you restart it. Mock URL configurable via STACK_MOCK_SAML_URL (default localhost:8115). --- .github/workflows/e2e-api-tests.yaml | 9 ++++ apps/backend/src/lib/seed-dummy-data.ts | 58 +++++++++++++++++++++++++ apps/e2e/tests/snapshot-serializer.ts | 13 ++++++ package.json | 3 +- 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index d3d58d132c..e7a5aee140 100644 --- a/.github/workflows/e2e-api-tests.yaml +++ b/.github/workflows/e2e-api-tests.yaml @@ -143,6 +143,15 @@ jobs: tail: true wait-for: 30s log-output-if: true + - name: Start mock-saml-idp in background + uses: JarvusInnovations/background-action@v1.0.7 + with: + run: pnpm run start:mock-saml-idp --log-order=stream & + wait-on: | + http://localhost:8115/idp + tail: true + wait-for: 30s + log-output-if: true - name: Start run-email-queue in background uses: JarvusInnovations/background-action@v1.0.7 with: diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index a9ad484273..03b44f638e 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -1939,6 +1939,63 @@ 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 { + const mockUrl = getEnvVariable("STACK_MOCK_SAML_URL", "http://localhost:8115"); + 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 res = await fetch(`${mockUrl}/idp/${t.slug}/metadata`); + if (!res.ok) { + throw new Error(`Mock SAML IdP at ${mockUrl}/idp/${t.slug}/metadata 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(/([\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, ""), + }; + }), + ); + + const overlay: Record = {}; + for (const f of fetched) { + overlay[`auth.saml.connections.${f.slug}.displayName`] = f.displayName; + overlay[`auth.saml.connections.${f.slug}.allowSignIn`] = true; + overlay[`auth.saml.connections.${f.slug}.domain`] = f.domain; + overlay[`auth.saml.connections.${f.slug}.idpEntityId`] = f.idpEntityId; + overlay[`auth.saml.connections.${f.slug}.idpSsoUrl`] = f.idpSsoUrl; + overlay[`auth.saml.connections.${f.slug}.idpCertificate`] = f.idpCertificate; + } + + await overrideEnvironmentConfigOverride({ + projectId, + branchId: DEFAULT_BRANCH_ID, + environmentConfigOverrideOverride: overlay as Parameters[0]["environmentConfigOverrideOverride"], + }); +} + /** * 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. @@ -2068,6 +2125,7 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis "payments.testMode": true, }, }), + ...(getEnvVariable("STACK_SEED_ENABLE_SAML", "false") === "true" ? [seedSamlConnections(projectId)] : []), ...options.skipGithubConfigSource ? [] : [setBranchConfigOverrideSource({ projectId, branchId: DEFAULT_BRANCH_ID, diff --git a/apps/e2e/tests/snapshot-serializer.ts b/apps/e2e/tests/snapshot-serializer.ts index 6a94aceb82..3e3f915ffa 100644 --- a/apps/e2e/tests/snapshot-serializer.ts +++ b/apps/e2e/tests/snapshot-serializer.ts @@ -113,10 +113,17 @@ const stripUrlQueryParams = [ "code", "code_challenge", "interaction_uid", + // SAML — both URL-binding (query) and POST-binding (form) carry these, + // each encodes the AuthnRequest ID + timestamps + signature so snapshots + // would re-roll on every test run. + "SAMLRequest", + "SAMLResponse", + "RelayState", ] as const; const keyedCookieNamePrefixes = [ "stack-oauth-inner-", + "stack-saml-inner-", ] as const; const stringRegexReplacements = [ @@ -124,6 +131,12 @@ const stringRegexReplacements = [ [new RegExp(`localhost\:${getPortPrefix()}`, "gi"), "localhost:<$$NEXT_PUBLIC_STACK_PORT_PREFIX>"], [new RegExp(`localhost\%3A${getPortPrefix()}`, "gi"), "localhost%3A%3C%24NEXT_PUBLIC_STACK_PORT_PREFIX%3E"], [/(Timeout exceeded: elapsed )[0-9.]+( ms)/gi, "$1$2"], + // SAML AuthnRequest / Response / Assertion IDs (xs:ID format starts with + // a non-numeric char, e.g. _abc123). Stripped in error messages and URL + // path segments under /auth/saml/(login|acs)/. + [/_[a-zA-Z][a-zA-Z0-9_.-]{8,}/g, ""], + // SAML XML timestamps (e.g. IssueInstant, NotBefore, NotOnOrAfter). + [/(IssueInstant|NotBefore|NotOnOrAfter)="[^"]+"/g, '$1=""'], ] as const; diff --git a/package.json b/package.json index 7828d0154a..29a0b23b3f 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "dev:tui": "pnpm pre && (trap 'kill 0' EXIT; pnpm run generate-sdks:watch & pnpm run generate-openapi-docs:watch & turbo run dev --ui tui --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo)", "dev:inspect": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev", "dev:profile": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev", - "dev:basic": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/backend --filter=@stackframe/dashboard --filter=@stackframe/mock-oauth-server\"", + "dev:basic": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/backend --filter=@stackframe/dashboard --filter=@stackframe/mock-oauth-server --filter=@stackframe/mock-saml-idp\"", "dev:docs": "pnpm pre && concurrently -k \"pnpm run generate-openapi-docs:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/stack-docs\"", "dev:named": "pnpm pre && concurrently -k \"pnpm run dev\" \"node -e \\\"process.title='node (stack-named-dev-server)'; process.stdin.resume();\\\"\"", "kill-dev:named": "(pgrep -f 'stack-named-dev-server' | xargs -r -n1 pkill -P); echo 'Killed named dev server (if found). Sleeping to give some time for it to shut down...' && sleep 10", @@ -63,6 +63,7 @@ "start:backend": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/backend", "start:dashboard": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/dashboard", "start:mock-oauth-server": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/mock-oauth-server", + "start:mock-saml-idp": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/mock-saml-idp", "lint": "pnpm pre && turbo run lint --continue -- --max-warnings=0", "release": "pnpm pre && release", "dotenv": "dotenv", From 4949a9cfc2efedeae0d9224e60e337c8f586f655 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 16:38:35 -0700 Subject: [PATCH 03/11] fix(seed): use whole-entry config writes for SAML connections Deep dot-keys like `auth.saml.connections.X.field` get dropped by config normalization with onDotIntoNonObject=ignore when the parent record entry doesn't yet exist. Match the existing convention from auth.oauth.providers and write the whole connection entry as a single value. (Bug surfaced when running the SAML e2e tests against a live backend in a separate PR. Applied here so the seed function works on its own without requiring downstream PRs.) --- apps/backend/src/lib/seed-dummy-data.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index 03b44f638e..5b57842788 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -1979,14 +1979,20 @@ async function seedSamlConnections(projectId: string): Promise { }), ); + // 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: Record = {}; for (const f of fetched) { - overlay[`auth.saml.connections.${f.slug}.displayName`] = f.displayName; - overlay[`auth.saml.connections.${f.slug}.allowSignIn`] = true; - overlay[`auth.saml.connections.${f.slug}.domain`] = f.domain; - overlay[`auth.saml.connections.${f.slug}.idpEntityId`] = f.idpEntityId; - overlay[`auth.saml.connections.${f.slug}.idpSsoUrl`] = f.idpSsoUrl; - overlay[`auth.saml.connections.${f.slug}.idpCertificate`] = f.idpCertificate; + 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({ From 0e59570bc9273fa0c8c7cfcde1af5b753afa5ff7 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 16:46:13 -0700 Subject: [PATCH 04/11] fix(lockfile): regenerate after removing body-parser dep from mock-saml-idp mock-saml-idp originally depended on body-parser for the parser middleware, but switched to using express.urlencoded()/express.json() directly. The package.json dep was removed but the lockfile entry remained, breaking 'pnpm install --frozen-lockfile' in CI. --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edc9ea8389..bc67ad480f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,9 +876,6 @@ importers: '@types/node-forge': specifier: ^1.3.11 version: 1.3.14 - body-parser: - specifier: ^1.20.3 - version: 1.20.3 express: specifier: ^4.21.2 version: 4.21.2 From aaeb8318a8534c45ee8a41fbcecceaf7fb3ba7a1 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 18:28:43 -0700 Subject: [PATCH 05/11] fix(test): drop overbroad SAML ID strip regex from snapshot serializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pattern \`/_[a-zA-Z][a-zA-Z0-9_.-]{8,}/g\` matched any SCREAMING_SNAKE_CASE identifier with an underscore followed by 9+ chars — e.g. \`STRIPE_ACCOUNT_INFO_NOT_FOUND\` became \`STRIPE\`, breaking unrelated Stripe / payments / OAuth e2e snapshots. The regex isn't load-bearing today: no current SAML test snapshots a random AuthnRequest / Response / Assertion ID. Cookie-name SAML IDs are already covered by \`keyedCookieNamePrefixes\` ("stack-saml-inner-"), URL path segments only carry the deterministic connection_id, and no test snapshots raw SAML XML. If a future test ever does, follow the precedent of the timestamp strip on the next line and anchor the replacement to specific XML attributes (\`ID="..."\`, \`InResponseTo="..."\`) rather than matching loose \`_\` strings everywhere. --- apps/e2e/tests/snapshot-serializer.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/e2e/tests/snapshot-serializer.ts b/apps/e2e/tests/snapshot-serializer.ts index 3e3f915ffa..1975be83c8 100644 --- a/apps/e2e/tests/snapshot-serializer.ts +++ b/apps/e2e/tests/snapshot-serializer.ts @@ -131,10 +131,6 @@ const stringRegexReplacements = [ [new RegExp(`localhost\:${getPortPrefix()}`, "gi"), "localhost:<$$NEXT_PUBLIC_STACK_PORT_PREFIX>"], [new RegExp(`localhost\%3A${getPortPrefix()}`, "gi"), "localhost%3A%3C%24NEXT_PUBLIC_STACK_PORT_PREFIX%3E"], [/(Timeout exceeded: elapsed )[0-9.]+( ms)/gi, "$1$2"], - // SAML AuthnRequest / Response / Assertion IDs (xs:ID format starts with - // a non-numeric char, e.g. _abc123). Stripped in error messages and URL - // path segments under /auth/saml/(login|acs)/. - [/_[a-zA-Z][a-zA-Z0-9_.-]{8,}/g, ""], // SAML XML timestamps (e.g. IssueInstant, NotBefore, NotOnOrAfter). [/(IssueInstant|NotBefore|NotOnOrAfter)="[^"]+"/g, '$1=""'], ] as const; From 1e912c7548ce70913ae96155950b90480684bf81 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 29 Apr 2026 18:42:59 -0700 Subject: [PATCH 06/11] fix(seed): serialize SAML seed; drop overlay cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cleanups in seed-dummy-data, both flagged on PR #1395: - The parallel `Promise.all` block ran `seedSamlConnections(projectId)` alongside another `overrideEnvironmentConfigOverride` write on the same project (`payments.testMode`). Both are read-modify-write, so concurrent reads of the env config can each see the stale state and the second write clobbers the first — the existing TODO at `config.ts:491` already documents the underlying race. Sequence the SAML seed after the parallel block to avoid the race until the override is wrapped in a serializable txn. - Type the SAML overlay with the target parameter type directly instead of using an `as Parameters<...>` cast, per project style. --- apps/backend/src/lib/seed-dummy-data.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index 5b57842788..479cd10887 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -1983,7 +1983,7 @@ async function seedSamlConnections(projectId: string): Promise { // 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: Record = {}; + const overlay: Parameters[0]["environmentConfigOverrideOverride"] = {}; for (const f of fetched) { overlay[`auth.saml.connections.${f.slug}`] = { displayName: f.displayName, @@ -1998,7 +1998,7 @@ async function seedSamlConnections(projectId: string): Promise { await overrideEnvironmentConfigOverride({ projectId, branchId: DEFAULT_BRANCH_ID, - environmentConfigOverrideOverride: overlay as Parameters[0]["environmentConfigOverrideOverride"], + environmentConfigOverrideOverride: overlay, }); } @@ -2131,7 +2131,6 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis "payments.testMode": true, }, }), - ...(getEnvVariable("STACK_SEED_ENABLE_SAML", "false") === "true" ? [seedSamlConnections(projectId)] : []), ...options.skipGithubConfigSource ? [] : [setBranchConfigOverrideSource({ projectId, branchId: DEFAULT_BRANCH_ID, @@ -2154,6 +2153,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, From 7dacbc4c08a36374e6e922c6563bb164c2cd460a Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 30 Apr 2026 09:29:38 -0700 Subject: [PATCH 07/11] fix(test): use matched cookie prefix in snapshot serializer `${keyedCookieNamePrefixes}` interpolated the entire array, which was harmless when the array had one entry but produced "stack-oauth-inner-,stack-saml-inner-" once a second prefix was added, breaking every existing OAuth set-cookie snapshot. --- apps/e2e/tests/snapshot-serializer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/e2e/tests/snapshot-serializer.ts b/apps/e2e/tests/snapshot-serializer.ts index 1975be83c8..10337f8a5c 100644 --- a/apps/e2e/tests/snapshot-serializer.ts +++ b/apps/e2e/tests/snapshot-serializer.ts @@ -228,8 +228,9 @@ const snapshotSerializer: SnapshotSerializer = { if (headerName === "set-cookie") { const partsStrings = value.split(";").map((part) => part.trim()); let cookieName = partsStrings[0].split("=")[0]; - if (keyedCookieNamePrefixes.some((prefix) => cookieName.startsWith(prefix))) { - cookieName = `${keyedCookieNamePrefixes}`; + const matchedPrefix = keyedCookieNamePrefixes.find((prefix) => cookieName.startsWith(prefix)); + if (matchedPrefix) { + cookieName = `${matchedPrefix}`; } const cookieValue = partsStrings[0].split("=")[1]; const parts = new Map(partsStrings.map((part) => { From c25cc8ad564a6e38a0d62b8d9ca736e09c76c6d0 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 30 Apr 2026 14:44:12 -0700 Subject: [PATCH 08/11] chore(mock-saml-idp): tidy ESLint config and dev-only deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move @types/express and @types/node-forge to devDependencies — they're compile-time only. - Drop the next.js ESLint extend (this is a plain Express service, not Next). Rename .eslintrc.js to .eslintrc.cjs to match the convention used by every other workspace package. - Add --ext .tsx,.ts to the lint script (required when only the defaults config is used, since the bare typescript-eslint parser doesn't pick up .ts/.tsx by default; matches apps/e2e and packages/stack-shared). --- apps/mock-saml-idp/{.eslintrc.js => .eslintrc.cjs} | 1 - apps/mock-saml-idp/package.json | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) rename apps/mock-saml-idp/{.eslintrc.js => .eslintrc.cjs} (76%) diff --git a/apps/mock-saml-idp/.eslintrc.js b/apps/mock-saml-idp/.eslintrc.cjs similarity index 76% rename from apps/mock-saml-idp/.eslintrc.js rename to apps/mock-saml-idp/.eslintrc.cjs index 49bc087f44..51b882f29c 100644 --- a/apps/mock-saml-idp/.eslintrc.js +++ b/apps/mock-saml-idp/.eslintrc.cjs @@ -1,7 +1,6 @@ module.exports = { "extends": [ "../../configs/eslint/defaults.js", - "../../configs/eslint/next.js", ], "ignorePatterns": ['/*', '!/src'] }; diff --git a/apps/mock-saml-idp/package.json b/apps/mock-saml-idp/package.json index 7af22dd074..59151b555c 100644 --- a/apps/mock-saml-idp/package.json +++ b/apps/mock-saml-idp/package.json @@ -8,18 +8,18 @@ "start": "tsx src/index.ts", "dev": "tsx watch --clear-screen=false src/index.ts", "typecheck": "tsc --noEmit", - "lint": "eslint .", + "lint": "eslint --ext .tsx,.ts .", "clean": "rimraf dist && rimraf node_modules" }, "dependencies": { - "@types/express": "^5.0.0", - "@types/node-forge": "^1.3.11", "express": "^4.21.2", "handlebars": "^4.7.8", "node-forge": "^1.3.1", "samlify": "^2.10.0" }, "devDependencies": { + "@types/express": "^5.0.0", + "@types/node-forge": "^1.3.11", "tsx": "^4.16.2" }, "packageManager": "pnpm@10.23.0" From 18567fb2d05ae8f5c789891b33cd8df31a229eef Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 30 Apr 2026 14:45:04 -0700 Subject: [PATCH 09/11] chore(mock-saml-idp): switch to port suffix 42 to avoid collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mock SAML IdP previously used port suffix 15, which is also bound by examples/supabase. Under \`pnpm dev:basic\` / \`dev:full\` whichever started second failed to bind. Suffixes 01–41 are otherwise spoken for; 42 is the first free slot before the LocalStack reservation at 50–99. - Default port → \`\${prefix}42\` - \`pnpm kms\` cleanup list grows to include 42 - e2e CI health-check URL updated to \`http://localhost:8142/idp\` - Add a dev launchpad tile so the SAML mock is discoverable next to the OAuth mock --- .github/workflows/e2e-api-tests.yaml | 2 +- apps/dev-launchpad/public/index.html | 7 +++++++ apps/mock-saml-idp/src/index.ts | 2 +- package.json | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index e7a5aee140..a5ae9f466e 100644 --- a/.github/workflows/e2e-api-tests.yaml +++ b/.github/workflows/e2e-api-tests.yaml @@ -148,7 +148,7 @@ jobs: with: run: pnpm run start:mock-saml-idp --log-order=stream & wait-on: | - http://localhost:8115/idp + http://localhost:8142/idp tail: true wait-for: 30s log-output-if: true diff --git a/apps/dev-launchpad/public/index.html b/apps/dev-launchpad/public/index.html index 8447dd6d98..ccb64c55d9 100644 --- a/apps/dev-launchpad/public/index.html +++ b/apps/dev-launchpad/public/index.html @@ -271,6 +271,13 @@

Background services

"Src: ./apps/mock-oauth-server", ], }, + { + name: "SAML mock IdP", + portSuffix: "42", + description: [ + "Src: ./apps/mock-saml-idp", + ], + }, { name: "examples/supabase", portSuffix: "15", diff --git a/apps/mock-saml-idp/src/index.ts b/apps/mock-saml-idp/src/index.ts index e3b9efcb7f..b4c463ba36 100644 --- a/apps/mock-saml-idp/src/index.ts +++ b/apps/mock-saml-idp/src/index.ts @@ -17,7 +17,7 @@ import forge from 'node-forge'; import * as samlify from 'samlify'; const stackPortPrefix = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81"; -const defaultPort = Number(`${stackPortPrefix}15`); +const defaultPort = Number(`${stackPortPrefix}42`); const port = Number(process.env.STACK_SAML_MOCK_PORT ?? process.env.PORT ?? defaultPort); const tenantSlugs = (process.env.STACK_MOCK_SAML_TENANTS ?? "acme,globex").split(",").map(s => s.trim()).filter(Boolean); diff --git a/package.json b/package.json index 29a0b23b3f..8a7c6120bd 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "dev:docs": "pnpm pre && concurrently -k \"pnpm run generate-openapi-docs:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/stack-docs\"", "dev:named": "pnpm pre && concurrently -k \"pnpm run dev\" \"node -e \\\"process.title='node (stack-named-dev-server)'; process.stdin.resume();\\\"\"", "kill-dev:named": "(pgrep -f 'stack-named-dev-server' | xargs -r -n1 pkill -P); echo 'Killed named dev server (if found). Sleeping to give some time for it to shut down...' && sleep 10", - "kms": "PREFIX=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}; for p in 00 01 02 03 04 06 14; do pids=$(lsof -i :$PREFIX$p 2>/dev/null | grep LISTEN | awk '$1 != \"OrbStack\" {print $2}' | sort -u); [ -n \"$pids\" ] && echo $pids | xargs kill -9 2>/dev/null; done; echo Done.", + "kms": "PREFIX=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}; for p in 00 01 02 03 04 06 14 42; do pids=$(lsof -i :$PREFIX$p 2>/dev/null | grep LISTEN | awk '$1 != \"OrbStack\" {print $2}' | sort -u); [ -n \"$pids\" ] && echo $pids | xargs kill -9 2>/dev/null; done; echo Done.", "start": "pnpm pre && turbo run start --concurrency 99999", "start:backend": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/backend", "start:dashboard": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/dashboard", From 77e4fae463453deb874932878d15a3f662b000b8 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 30 Apr 2026 14:45:39 -0700 Subject: [PATCH 10/11] fix(mock-saml-idp): split buildAssertion + replay full POST body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes in apps/mock-saml-idp/src/index.ts: 1. Replay misbehavior now re-emits the original RelayState alongside the cached SAMLResponse. Previously buildAssertion returned the cached SAMLResponse but the login handler still rendered the form with the *current* request's RelayState, which tests "old response + fresh state" rather than a true replay. AssertionResult now carries relayState so the handler uses whatever the assertion path returned. 2. Split the 100-line buildAssertion into focused helpers: consumeNextMisbehavior, resolveSigningTenant, buildAssertionFields, renderLoginResponseXml, cacheReplayableResponse. Same behavior; the replay short-circuit and the cache update are now obvious at a glance. Also updates the file header to clarify that the @node-saml/node-saml dependency it pairs with lands in the stacked backend PR — this PR ships the mock alone. --- apps/mock-saml-idp/src/index.ts | 162 ++++++++++++++++++++++---------- 1 file changed, 112 insertions(+), 50 deletions(-) diff --git a/apps/mock-saml-idp/src/index.ts b/apps/mock-saml-idp/src/index.ts index b4c463ba36..ca8db66e14 100644 --- a/apps/mock-saml-idp/src/index.ts +++ b/apps/mock-saml-idp/src/index.ts @@ -7,9 +7,10 @@ * connection isolation. * * IMPORTANT: Uses `samlify` deliberately because the backend SAML wrapper - * uses `@node-saml/node-saml`. Different libraries on each side means a bug - * in either library's signature canonicalization surfaces as a test failure - * instead of being masked by both sides agreeing. + * (added in the stacked backend PR) uses `@node-saml/node-saml`. Different + * libraries on each side means a bug in either library's signature + * canonicalization surfaces as a test failure instead of being masked by + * both sides agreeing. */ import express from 'express'; import handlebars from 'handlebars'; @@ -198,38 +199,68 @@ function isoNow(offsetMs = 0): string { return new Date(Date.now() + offsetMs).toISOString(); } -async function buildAssertion( - tenant: TenantState, - parsed: ParsedRequest, - user: { email: string, displayName: string }, -): Promise<{ samlResponseB64: string, acsUrl: string }> { - const misbehavior = tenant.nextMisbehavior; - tenant.nextMisbehavior = { kind: 'none' }; +type AssertionFields = { + audience: string, + inResponseTo: string, + conditionsNotBefore: string, + conditionsNotOnOrAfter: string, +}; - // `replay`: re-emit the cached response. - if (misbehavior.kind === 'replay') { - if (!tenant.lastResponse) { - throw new Error('replay misbehavior requested but no previous response cached for this tenant'); - } - return { samlResponseB64: tenant.lastResponse.samlResponseB64, acsUrl: tenant.lastResponse.acsUrl }; - } +type AssertionResult = { + samlResponseB64: string, + acsUrl: string, + relayState: string, +}; - // `bad-signature` and `sign-with-tenant`: sign with another tenant's key. - let signingTenant: TenantState = tenant; +function consumeNextMisbehavior(tenant: TenantState): Misbehavior { + const m = tenant.nextMisbehavior; + tenant.nextMisbehavior = { kind: 'none' }; + return m; +} + +function resolveSigningTenant(tenant: TenantState, misbehavior: Misbehavior): TenantState { if (misbehavior.kind === 'bad-signature') { const other = Array.from(tenants.values()).find(t => t.slug !== tenant.slug); if (!other) { throw new Error('bad-signature misbehavior requires at least 2 tenants configured'); } - signingTenant = other; - } else if (misbehavior.kind === 'sign-with-tenant') { + return other; + } + if (misbehavior.kind === 'sign-with-tenant') { const other = tenants.get(misbehavior.tenant); if (!other) { throw new Error(`sign-with-tenant misbehavior references unknown tenant ${misbehavior.tenant}`); } - signingTenant = other; + return other; } + return tenant; +} + +function buildAssertionFields(parsed: ParsedRequest, misbehavior: Misbehavior): AssertionFields { + return { + audience: misbehavior.kind === 'wrong-audience' + ? 'https://wrong.example/audience' + : parsed.issuer, + inResponseTo: misbehavior.kind === 'wrong-in-response-to' + ? `_mock_misbehave_${Math.random().toString(36).slice(2)}` + : parsed.requestId, + conditionsNotBefore: misbehavior.kind === 'not-yet-valid' + ? isoNow(60 * 60 * 1000) // +1 hour + : isoNow(-30 * 1000), + conditionsNotOnOrAfter: misbehavior.kind === 'expired' + ? isoNow(-60 * 1000) // expired 1 minute ago + : isoNow(ASSERTION_LIFETIME_MS), + }; +} +async function renderLoginResponseXml( + signingTenant: TenantState, + issuerEntityId: string, + parsed: ParsedRequest, + user: { email: string, displayName: string }, + fields: AssertionFields, + misbehavior: Misbehavior, +): Promise { // Build inline SP — derive from the AuthnRequest to avoid pre-registration. const sp = samlify.ServiceProvider({ entityID: parsed.issuer, @@ -239,23 +270,6 @@ async function buildAssertion( }], }); - // Compute the substitution map. Misbehaviors mutate this. - const audience = misbehavior.kind === 'wrong-audience' - ? 'https://wrong.example/audience' - : parsed.issuer; - - const inResponseTo = misbehavior.kind === 'wrong-in-response-to' - ? `_mock_misbehave_${Math.random().toString(36).slice(2)}` - : parsed.requestId; - - const conditionsNotBefore = misbehavior.kind === 'not-yet-valid' - ? isoNow(60 * 60 * 1000) // +1 hour - : isoNow(-30 * 1000); - - const conditionsNotOnOrAfter = misbehavior.kind === 'expired' - ? isoNow(-60 * 1000) // expired 1 minute ago - : isoNow(ASSERTION_LIFETIME_MS); - const result = await signingTenant.idp.createLoginResponse( sp, { extract: { request: { id: parsed.requestId } } } as any, @@ -271,16 +285,16 @@ async function buildAssertion( .replace(/\{AssertionID\}/g, assertionId) .replace(/\{IssueInstant\}/g, issueInstant) .replace(/\{Destination\}/g, parsed.acsUrl) - .replace(/\{Issuer\}/g, tenant.entityId) + .replace(/\{Issuer\}/g, issuerEntityId) .replace(/\{StatusCode\}/g, 'urn:oasis:names:tc:SAML:2.0:status:Success') .replace(/\{NameID\}/g, user.email) .replace(/\{NameIDFormat\}/g, 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress') .replace(/\{SubjectConfirmationDataNotOnOrAfter\}/g, isoNow(ASSERTION_LIFETIME_MS)) .replace(/\{SubjectRecipient\}/g, parsed.acsUrl) - .replace(/\{InResponseTo\}/g, inResponseTo) - .replace(/\{ConditionsNotBefore\}/g, conditionsNotBefore) - .replace(/\{ConditionsNotOnOrAfter\}/g, conditionsNotOnOrAfter) - .replace(/\{Audience\}/g, audience) + .replace(/\{InResponseTo\}/g, fields.inResponseTo) + .replace(/\{ConditionsNotBefore\}/g, fields.conditionsNotBefore) + .replace(/\{ConditionsNotOnOrAfter\}/g, fields.conditionsNotOnOrAfter) + .replace(/\{Audience\}/g, fields.audience) .replace(/\{Email\}/g, user.email) .replace(/\{DisplayName\}/g, user.displayName); @@ -297,12 +311,60 @@ async function buildAssertion( }, ); - const samlResponseB64 = result.context; - // Cache for `replay` misbehavior (only happy-path responses get cached). + return result.context; +} + +function cacheReplayableResponse(tenant: TenantState, parsed: ParsedRequest, samlResponseB64: string): void { + tenant.lastResponse = { + samlResponseB64, + relayState: parsed.relayState, + acsUrl: parsed.acsUrl, + }; +} + +async function buildAssertion( + tenant: TenantState, + parsed: ParsedRequest, + user: { email: string, displayName: string }, +): Promise { + const misbehavior = consumeNextMisbehavior(tenant); + + // True replay: re-emit the previous response *and* the previous RelayState + // so the entire POST body matches the cached one. (Returning fresh + // RelayState here would test "old response + new state", which is a + // different attack class than replay.) + if (misbehavior.kind === 'replay') { + if (!tenant.lastResponse) { + throw new Error('replay misbehavior requested but no previous response cached for this tenant'); + } + return { + samlResponseB64: tenant.lastResponse.samlResponseB64, + acsUrl: tenant.lastResponse.acsUrl, + relayState: tenant.lastResponse.relayState, + }; + } + + const signingTenant = resolveSigningTenant(tenant, misbehavior); + const fields = buildAssertionFields(parsed, misbehavior); + const samlResponseB64 = await renderLoginResponseXml( + signingTenant, + tenant.entityId, + parsed, + user, + fields, + misbehavior, + ); + + // Only happy-path responses get cached for replay. if (misbehavior.kind === 'none') { - tenant.lastResponse = { samlResponseB64, relayState: parsed.relayState, acsUrl: parsed.acsUrl }; + cacheReplayableResponse(tenant, parsed, samlResponseB64); } - return { samlResponseB64, acsUrl: parsed.acsUrl }; + + return { + samlResponseB64, + acsUrl: parsed.acsUrl, + relayState: parsed.relayState, + }; } // ---------- HTTP server -------------------------------------------------- @@ -402,8 +464,8 @@ app.post('/idp/:tenant/login', async (req, res) => { } try { const parsed = await parseAuthnRequestRedirect(samlRequest, relayState); - const { samlResponseB64, acsUrl } = await buildAssertion(t, parsed, { email, displayName }); - res.send(autoPostForm({ acsUrl, samlResponse: samlResponseB64, relayState })); + const { samlResponseB64, acsUrl, relayState: outRelayState } = await buildAssertion(t, parsed, { email, displayName }); + res.send(autoPostForm({ acsUrl, samlResponse: samlResponseB64, relayState: outRelayState })); } catch (err: any) { res.status(500).send(`Mock IdP failed to build assertion: ${err.message}`); } From 6b8cd7e564160e1fb7493b2d8afc9243a2715237 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 30 Apr 2026 14:45:48 -0700 Subject: [PATCH 11/11] chore(backend): defer SAML seed + node-saml dep to stacked backend PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes seedSamlConnections (and its STACK_SEED_ENABLE_SAML callsite) plus the @node-saml/node-saml dependency from this PR. Both depend on config.auth.saml schema entries that don't exist on this branch yet — the seed wrote overrides that were silently dropped during config normalization, and node-saml had no consumer here. They land together in the stacked backend PR alongside the schema and the SAML protocol wrapper that actually imports node-saml. --- apps/backend/package.json | 1 - apps/backend/src/lib/seed-dummy-data.ts | 73 ------------------------- pnpm-lock.yaml | 70 ++---------------------- 3 files changed, 6 insertions(+), 138 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 5264adf3d6..b0500582df 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -61,7 +61,6 @@ "@aws-sdk/client-s3": "^3.855.0", "@clickhouse/client": "^1.14.0", "@node-oauth/oauth2-server": "^5.1.0", - "@node-saml/node-saml": "^5.0.0", "@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/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index 479cd10887..a9ad484273 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -1939,69 +1939,6 @@ 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 { - const mockUrl = getEnvVariable("STACK_MOCK_SAML_URL", "http://localhost:8115"); - 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 res = await fetch(`${mockUrl}/idp/${t.slug}/metadata`); - if (!res.ok) { - throw new Error(`Mock SAML IdP at ${mockUrl}/idp/${t.slug}/metadata 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(/([\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[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. @@ -2153,16 +2090,6 @@ 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc67ad480f..3cef648e0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,9 +126,6 @@ importers: '@node-oauth/oauth2-server': specifier: ^5.1.0 version: 5.1.0 - '@node-saml/node-saml': - specifier: ^5.0.0 - version: 5.1.0 '@openrouter/ai-sdk-provider': specifier: 2.2.3 version: 2.2.3(ai@6.0.81(zod@3.25.76))(zod@3.25.76) @@ -870,12 +867,6 @@ importers: apps/mock-saml-idp: dependencies: - '@types/express': - specifier: ^5.0.0 - version: 5.0.0 - '@types/node-forge': - specifier: ^1.3.11 - version: 1.3.14 express: specifier: ^4.21.2 version: 4.21.2 @@ -889,6 +880,12 @@ importers: specifier: ^2.10.0 version: 2.12.0 devDependencies: + '@types/express': + specifier: ^5.0.0 + version: 5.0.0 + '@types/node-forge': + specifier: ^1.3.11 + version: 1.3.14 tsx: specifier: ^4.16.2 version: 4.21.0 @@ -5989,10 +5986,6 @@ packages: resolution: {integrity: sha512-sYvqL1GeZLRSwgl++/oOzxJj/ZBe2yXnp6E5LGNQ5qjpn0+t/dwquXILUe3Sk2Y8/wU7XeRxToOtBVeSVkuJag==} engines: {node: '>=16.0.0'} - '@node-saml/node-saml@5.1.0': - resolution: {integrity: sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==} - engines: {node: '>= 18'} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -10579,9 +10572,6 @@ packages: '@types/qrcode@1.5.5': resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} - '@types/qs@6.15.0': - resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} - '@types/qs@6.9.15': resolution: {integrity: sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==} @@ -10654,12 +10644,6 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@types/xml-encryption@1.2.4': - resolution: {integrity: sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==} - - '@types/xml2js@0.4.14': - resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} - '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -19408,9 +19392,6 @@ packages: resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==} engines: {node: '>=16'} - xml-encryption@3.1.0: - resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==} - xml-escape@1.1.0: resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} @@ -19437,10 +19418,6 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} - xmlbuilder@15.1.1: - resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} - engines: {node: '>=8.0'} - xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -23801,23 +23778,6 @@ snapshots: basic-auth: 2.0.1 type-is: 1.6.18 - '@node-saml/node-saml@5.1.0': - dependencies: - '@types/debug': 4.1.12 - '@types/qs': 6.15.0 - '@types/xml-encryption': 1.2.4 - '@types/xml2js': 0.4.14 - '@xmldom/is-dom-node': 1.0.1 - '@xmldom/xmldom': 0.8.13 - debug: 4.4.3 - xml-crypto: 6.1.2 - xml-encryption: 3.1.0 - xml2js: 0.6.2 - xmlbuilder: 15.1.1 - xpath: 0.0.34 - transitivePeerDependencies: - - supports-color - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -30420,8 +30380,6 @@ snapshots: dependencies: '@types/node': 22.19.0 - '@types/qs@6.15.0': {} - '@types/qs@6.9.15': {} '@types/range-parser@1.2.7': {} @@ -30508,14 +30466,6 @@ snapshots: dependencies: '@types/node': 22.19.0 - '@types/xml-encryption@1.2.4': - dependencies: - '@types/node': 22.19.0 - - '@types/xml2js@0.4.14': - dependencies: - '@types/node': 22.19.0 - '@types/yauzl@2.10.3': dependencies: '@types/node': 22.19.0 @@ -42025,12 +41975,6 @@ snapshots: '@xmldom/xmldom': 0.8.13 xpath: 0.0.33 - xml-encryption@3.1.0: - dependencies: - '@xmldom/xmldom': 0.8.13 - escape-html: 1.0.3 - xpath: 0.0.32 - xml-escape@1.1.0: {} xml-js@1.6.11: @@ -42055,8 +41999,6 @@ snapshots: xmlbuilder@11.0.1: {} - xmlbuilder@15.1.1: {} - xmlchars@2.2.0: {} xpath@0.0.32: {}