diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index d3d58d132c..a5ae9f466e 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:8142/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/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/e2e/tests/snapshot-serializer.ts b/apps/e2e/tests/snapshot-serializer.ts index 6a94aceb82..10337f8a5c 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,8 @@ 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 XML timestamps (e.g. IssueInstant, NotBefore, NotOnOrAfter). + [/(IssueInstant|NotBefore|NotOnOrAfter)="[^"]+"/g, '$1=""'], ] as const; @@ -219,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) => { diff --git a/apps/mock-saml-idp/.eslintrc.cjs b/apps/mock-saml-idp/.eslintrc.cjs new file mode 100644 index 0000000000..51b882f29c --- /dev/null +++ b/apps/mock-saml-idp/.eslintrc.cjs @@ -0,0 +1,6 @@ +module.exports = { + "extends": [ + "../../configs/eslint/defaults.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..59151b555c --- /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 --ext .tsx,.ts .", + "clean": "rimraf dist && rimraf node_modules" + }, + "dependencies": { + "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" +} diff --git a/apps/mock-saml-idp/src/index.ts b/apps/mock-saml-idp/src/index.ts new file mode 100644 index 0000000000..ca8db66e14 --- /dev/null +++ b/apps/mock-saml-idp/src/index.ts @@ -0,0 +1,507 @@ +/** + * 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 + * (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'; +import forge from 'node-forge'; +import * as samlify from 'samlify'; + +const stackPortPrefix = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81"; +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); + +// 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(); +} + +type AssertionFields = { + audience: string, + inResponseTo: string, + conditionsNotBefore: string, + conditionsNotOnOrAfter: string, +}; + +type AssertionResult = { + samlResponseB64: string, + acsUrl: string, + relayState: string, +}; + +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'); + } + 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}`); + } + 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, + assertionConsumerService: [{ + Binding: samlify.Constants.namespace.binding.post, + Location: parsed.acsUrl, + }], + }); + + 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, 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, 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); + + if (misbehavior.kind === 'missing-name-id') { + context = context.replace(/]*>[^<]*<\/saml:NameID>/, ''); + } + if (misbehavior.kind === 'missing-email') { + context = context.replace( + //, + '', + ); + } + return { id, context }; + }, + ); + + 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') { + cacheReplayableResponse(tenant, parsed, samlResponseB64); + } + + return { + samlResponseB64, + acsUrl: parsed.acsUrl, + relayState: parsed.relayState, + }; +} + +// ---------- 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, 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}`); + } +}); + +// 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/package.json b/package.json index 7828d0154a..8a7c6120bd 100644 --- a/package.json +++ b/package.json @@ -54,15 +54,16 @@ "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", - "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", "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 482352d2f2..3cef648e0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -749,7 +749,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 +865,31 @@ importers: specifier: ^4.16.2 version: 4.16.2 + apps/mock-saml-idp: + dependencies: + 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: + '@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 + docs: dependencies: 2027-track: @@ -2712,6 +2737,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'} @@ -10492,6 +10521,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==} @@ -10928,6 +10960,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 +11246,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 +15890,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 +15907,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 +17484,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 +19388,13 @@ 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-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 +19407,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'} @@ -19358,6 +19421,18 @@ packages: 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 +19881,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 @@ -30230,6 +30311,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': @@ -30636,13 +30721,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 +30909,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 +31223,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 +33444,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 +33459,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 +33502,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 +33580,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 +34267,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 +36315,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 +37444,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 +37464,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 +37532,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 +37542,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 +39621,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 +39735,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 +40955,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 +41969,14 @@ 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-escape@1.1.0: {} + xml-js@1.6.11: dependencies: sax: 1.4.1 @@ -41886,6 +41988,8 @@ snapshots: sax: 1.4.1 xmlbuilder: 11.0.1 + xml@1.0.1: {} + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -41897,6 +42001,12 @@ snapshots: 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