From 465bfc9da46d24eb5b0e3c5279d41c81faa59741 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 10 Jun 2026 16:49:05 -0400 Subject: [PATCH 1/8] chore: migrate formatting from prettier to oxfmt --- .oxfmtrc.json | 6 + .vscode/settings.json | 13 +- package-lock.json | 973 +++++++++++++++++++++++++++++++++++++++++- package.json | 3 + renovate.json | 4 +- src/index.ts | 10 +- 6 files changed, 997 insertions(+), 12 deletions(-) create mode 100644 .oxfmtrc.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..775667a --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "printWidth": 120, + "sortPackageJson": false, + "ignorePatterns": ["package-lock.json", "CHANGELOG.md"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index a0f8c2b..d56fb6f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,9 @@ { - "[json]": { - "editor.defaultFormatter": "vscode.json-language-features" - }, - "[markdown]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - } + "oxc.fmt.configPath": ".oxfmtrc.json", + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + } } diff --git a/package-lock.json b/package-lock.json index f9d2cdf..e9745d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "devDependencies": { "@types/node": "~22.19.7", "@vitest/coverage-v8": "^4.0.18", + "@workos/openapi-spec": "^0.6.0", + "oxfmt": "^0.54.0", "tsx": "^4.20.3", "typescript": "^5.9.3", "vitest": "^4.0.18" @@ -64,6 +66,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", @@ -633,6 +645,403 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.54.0.tgz", + "integrity": "sha512-NAtpl/SiaeU103e7/OmZw0MvUnsUUopW7hEm/ecegJg7YM0skQaA0IXEZoyTV6NUdiNPupdIUreRqUZTShbn/g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.54.0.tgz", + "integrity": "sha512-B4VZfBUlKK1rmMChsssNZbkZjE8+FzG3avMjGgMDwbGxXRoXkoeXiAZ+78Oa+eyDPHvDCiUb4zH/vmCOUSafLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.54.0.tgz", + "integrity": "sha512-i02vF75b+ePsQP3tHqSxVYI5S6b8X/xqdPu7/mDHXtpgXLTYXi3jJmfHU0j+dnZZDKaYTx/ioCK7QYJmtiJR2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.54.0.tgz", + "integrity": "sha512-8VMFvGvooXj7mswkbrhdVZ2/sgiDaBzWpkkbtO+qGDLV4EfJd67nQadHkQC0ZNbaWA9ajXfqI6i7PZLIeDzxEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.54.0.tgz", + "integrity": "sha512-0cRHnp43WN1Jrc5s0BdbdKgR1XirdvHy7TAFi3JEsoEVQVJxTXMbpVd76sxXlgRswNMDhVFSJw+y7Eb8mEavFQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.54.0.tgz", + "integrity": "sha512-JyQAk3hK/OEtup7Rw6kZwfdzbKqTVD5jXXb8Xpfay29suwZyfBDMVW/bj4RqEPySYWc6zCp198pOluf8n5uYzg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.54.0.tgz", + "integrity": "sha512-qnvLatTpM8vtvjOfcckBOzJjk+n6ce/wwpP8OFeUrD5aNLYcKyWAitwj+Rk3PK9jGanbZvKsJnv14JGQ6XqFdw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.54.0.tgz", + "integrity": "sha512-SMkhnCzIYZYDk9vw3W/80eeYKmrMpGF0Giuxt4HruFlCH7jEtnPeb3SdQKMfgYi/dgtaf+hZAb5XWPYnxqCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.54.0.tgz", + "integrity": "sha512-QrwJlBFFKnxOd95TAaszpMbZBLzMoYMpGaQTZF8oibacnF5rv8l12IhILhQRPmksWiBqg0YSe2Mnl7ayeJAHSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.54.0.tgz", + "integrity": "sha512-WILatiol/TUHTlhod7R09+7Az/XlhKwmY1MHfLZNmewltPWNN/EwxP2rQSHahibZ/cB8gmckEBjBOByD+5bYsQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.54.0.tgz", + "integrity": "sha512-f05YMG4BH4G8S4ME6UM6fi1MnJ9094mrnvO5Pa4SJlMfWlUM+1/ZWMEF4NnjM7shZAvbHsHRuVYpUo0PHC4P9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.54.0.tgz", + "integrity": "sha512-UfL+2hj1ClNqcCRT9s8vBU4axDpjxgVxX96G+9DYAYjoc5b0u15CJtn2jgsi9iM+EbGNc5CW1HVRgwVu76UsSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.54.0.tgz", + "integrity": "sha512-3/XZe931Hka+J6NjnaqJzYpsWWxDTuRdUdwSQHnOuJEgbC+SehIMFJS8hsEjV7LBhVSL2OCnRLvbVW8O97XIyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.54.0.tgz", + "integrity": "sha512-Ik93RlObtu43GbxApafayFjwYE06L6Xr08cSwpBPYbDrLp2ReZx0Jm1DqwRyYRnukUJy+rK2WaEvUQOxdytU9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.54.0.tgz", + "integrity": "sha512-yZcakmPlD86CNymknd7KfW+FH+qfbqJH+i0h69CYfV1+KMoVeM9UED+8+TDVoU4haxI0NxY7RPCvRLy3Sqd2Qg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.54.0.tgz", + "integrity": "sha512-GiVBZNnEZnKu00f1jTg49nomv187d0GQX+O+ocykoLeiaALuEO+swoTehHn9TehTfi7V8H0i0e/yvUjCqnwk1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.54.0.tgz", + "integrity": "sha512-J0SSB8Z1Fre2sxRolYcW6Rl1RQmKdQ2hnHyq4YJrfBRiXTObLw4DXnIVraM/UyqGqwOi7yTrQA4VT7DPxlHVKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.54.0.tgz", + "integrity": "sha512-O61UDVj8zz6yXJjkHPf05VaMLOXmEF8P5kf/N0W7AQMmd6bcQogl+KJc7rMutKTL524oE9iH32JXZClBFmEQIg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.54.0.tgz", + "integrity": "sha512-1MDpqJPiFqxWtIHas8vkb1VZ7f7eKyTffAwmO8isxQYMaG1OFKsH666BWLeXQLO+IWNfiMssLD55hbR1lIPTqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.3.tgz", + "integrity": "sha512-l42u0of3hY98sN2A+M4qTX1O/KrpgGH32Hu9kP2GtHyD5Dfqq86PKFLe5dwaD8DEnNmlOlll2BAmeEtf0DaySg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.49.0.tgz", + "integrity": "sha512-OI/rpEffX3fKUuy+OuBHPRspRI/S30b9aiqxfZLMpSWZzDncEGPxSEP1O2LrBVshnDX4hLjVjLvCZ4YT85+1rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "2.7.2" + } + }, + "node_modules/@redocly/openapi-core": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-2.32.0.tgz", + "integrity": "sha512-B4CsuuokMc3vgNh9e+eFX8FAbjMTWUVgYtBvPXm0X2pwBs9nO2v+m6ARp7lEpg1P42B6XztIdI08BGMqUAerJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "^8.18.1", + "@redocly/config": "^0.49.0", + "ajv": "npm:@redocly/ajv@8.18.1", + "ajv-formats": "^3.0.1", + "colorette": "^1.2.0", + "js-levenshtein": "^1.1.6", + "js-yaml": "^4.1.0", + "picomatch": "^4.0.4", + "pluralize": "^8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", @@ -958,6 +1367,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", @@ -1108,10 +1524,107 @@ "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@workos/oagen": { + "version": "0.22.4", + "resolved": "https://registry.npmjs.org/@workos/oagen/-/oagen-0.22.4.tgz", + "integrity": "sha512-tjJvQAkQj2Yuk16tqg/YWlZgjfKsK86nz9ps07SwNRDJFq0zVhfrMZKG2UEkoL8ltP6OkGWTUyF/RfcIiBPOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^2.25.1", + "commander": "^13.1.0", + "dotenv": "^17.3.1", + "tree-sitter": "^0.21.1", + "tree-sitter-c-sharp": "0.23.1", + "tree-sitter-elixir": "^0.3.5", + "tree-sitter-go": "^0.23.4", + "tree-sitter-kotlin": "github:fwcd/tree-sitter-kotlin#f66d2908542e93c0204c6c241f794afe4e9cd5d1", + "tree-sitter-php": "^0.23.12", + "tree-sitter-python": "^0.21.0", + "tree-sitter-ruby": "^0.21.0", + "tree-sitter-rust": "^0.21.0", + "tree-sitter-typescript": "^0.23.2", + "tsx": "^4.19.0", + "typescript": "^6.0.0" + }, + "bin": { + "oagen": "dist/cli/index.mjs" + }, + "engines": { + "node": ">=24.10.0" + } + }, + "node_modules/@workos/oagen/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@workos/openapi-spec": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@workos/openapi-spec/-/openapi-spec-0.6.0.tgz", + "integrity": "sha512-+nNvkvNsjYYX1r9HRB0ZJgdCYMJCLFXhitiui9vzQrhFwsWWqwl3fJuXss2MOPp6hB8S74pLMqU2hVIYjfoXLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@workos/oagen": "^0.22.4" + } + }, + "node_modules/ajv": { + "name": "@redocly/ajv", + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.1.tgz", + "integrity": "sha512-Ifm/pP/tul1qmAecpbVxCBluVE32rKfjf8gYXH4xI2gCv9mRWFhJMHzkPDM4TXlxwPQYIFegymlsy8lXz7optA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1156,6 +1669,23 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1173,6 +1703,19 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1242,6 +1785,30 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1340,6 +1907,16 @@ "node": ">=8" } }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/js-tokens": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", @@ -1347,6 +1924,51 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-to-ts": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-2.7.2.tgz", + "integrity": "sha512-R1JfqKqbBR4qE8UyBR56Ms30LL62/nlhoz+1UkfI/VE7p54Awu919FZ6ZUPG8zIa3XB65usPJgr1ONVncUGSaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@types/json-schema": "^7.0.9", + "ts-algebra": "^1.2.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1677,6 +2299,28 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-addon-api": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", + "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1688,6 +2332,58 @@ ], "license": "MIT" }, + "node_modules/oxfmt": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.54.0.tgz", + "integrity": "sha512-DjnMwn7smSLF+Mc2+pRItnuPftm/dkUFpY/d4+33y9TfKrsHZo8GLhmUg9BrOIUEy94Rlom1Q11N6vuhE+e0oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.54.0", + "@oxfmt/binding-android-arm64": "0.54.0", + "@oxfmt/binding-darwin-arm64": "0.54.0", + "@oxfmt/binding-darwin-x64": "0.54.0", + "@oxfmt/binding-freebsd-x64": "0.54.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.54.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.54.0", + "@oxfmt/binding-linux-arm64-gnu": "0.54.0", + "@oxfmt/binding-linux-arm64-musl": "0.54.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.54.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.54.0", + "@oxfmt/binding-linux-riscv64-musl": "0.54.0", + "@oxfmt/binding-linux-s390x-gnu": "0.54.0", + "@oxfmt/binding-linux-x64-gnu": "0.54.0", + "@oxfmt/binding-linux-x64-musl": "0.54.0", + "@oxfmt/binding-openharmony-arm64": "0.54.0", + "@oxfmt/binding-win32-arm64-msvc": "0.54.0", + "@oxfmt/binding-win32-ia32-msvc": "0.54.0", + "@oxfmt/binding-win32-x64-msvc": "0.54.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1715,6 +2411,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -1744,6 +2450,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", @@ -1869,6 +2585,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", @@ -1879,6 +2605,242 @@ "node": ">=14.0.0" } }, + "node_modules/tree-sitter": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", + "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" + } + }, + "node_modules/tree-sitter-c-sharp": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tree-sitter-c-sharp/-/tree-sitter-c-sharp-0.23.1.tgz", + "integrity": "sha512-9zZ4FlcTRWWfRf6f4PgGhG8saPls6qOOt75tDfX7un9vQZJmARjPrAC6yBNCX2T/VKcCjIDbgq0evFaB3iGhQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-elixir": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/tree-sitter-elixir/-/tree-sitter-elixir-0.3.5.tgz", + "integrity": "sha512-xozQMvYK0aSolcQZAx2d84Xe/YMWFuRPYFlLVxO01bM2GITh5jyiIp0TqPCQa8754UzRAI7A83hZmfiYub5TZQ==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + } + }, + "node_modules/tree-sitter-elixir/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-sitter-go": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/tree-sitter-go/-/tree-sitter-go-0.23.4.tgz", + "integrity": "sha512-iQaHEs4yMa/hMo/ZCGqLfG61F0miinULU1fFh+GZreCRtKylFLtvn798ocCZjO2r/ungNZgAY1s1hPFyAwkc7w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.1", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-javascript": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz", + "integrity": "sha512-/bnhbrTD9frUYHQTiYnPcxyHORIw157ERBa6dqzaKxvR/x3PC4Yzd+D1pZIMS6zNg2v3a8BZ0oK7jHqsQo9fWA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-kotlin": { + "version": "0.4.0", + "resolved": "git+ssh://git@github.com/fwcd/tree-sitter-kotlin.git#f66d2908542e93c0204c6c241f794afe4e9cd5d1", + "integrity": "sha512-onbogYgMICW34xos1mQNJEKnoq+m643z9MBC+AYa7mn4mH/KU4VJZnMVLcTViUErJ8h99KTRQbPH6wPlQLpepg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-php": { + "version": "0.23.12", + "resolved": "https://registry.npmjs.org/tree-sitter-php/-/tree-sitter-php-0.23.12.tgz", + "integrity": "sha512-VwkBVOahhC2NYXK/Fuqq30NxuL/6c2hmbxEF4jrB7AyR5rLc7nT27mzF3qoi+pqx9Gy2AbXnGezF7h4MeM6YRA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-python/-/tree-sitter-python-0.21.0.tgz", + "integrity": "sha512-IUKx7JcTVbByUx1iHGFS/QsIjx7pqwTMHL9bl/NGyhyyydbfNrpruo2C7W6V4KZrbkkCOlX8QVrCoGOFW5qecg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-python/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-sitter-ruby": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-ruby/-/tree-sitter-ruby-0.21.0.tgz", + "integrity": "sha512-UrMpF9CZxKbZ5UFuPdXDuraaaYSMMlAiuzTpQXwNm7y0D48ibc9stWU5D6vDyJD0qf5/R+3yKTYHdHkqibmLSQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.1" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-rust": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/tree-sitter-rust/-/tree-sitter-rust-0.21.0.tgz", + "integrity": "sha512-unVr73YLn3VC4Qa/GF0Nk+Wom6UtI526p5kz9Rn2iZSqwIFedyCZ3e0fKCEmUJLIPGrTb/cIEdu3ZUNGzfZx7A==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "peerDependencies": { + "tree-sitter": "^0.21.1" + }, + "peerDependenciesMeta": { + "tree_sitter": { + "optional": true + } + } + }, + "node_modules/tree-sitter-rust/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tree-sitter-typescript": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz", + "integrity": "sha512-e04JUUKxTT53/x3Uq1zIL45DoYKVfHH4CZqwgZhPg5qYROl5nQjV+85ruFzFGZxu+QeFVbRTPDRnqL9UbU4VeA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.2.2", + "node-gyp-build": "^4.8.2", + "tree-sitter-javascript": "^0.23.1" + }, + "peerDependencies": { + "tree-sitter": "^0.21.0" + }, + "peerDependenciesMeta": { + "tree-sitter": { + "optional": true + } + } + }, + "node_modules/ts-algebra": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-1.2.2.tgz", + "integrity": "sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA==", + "dev": true, + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2126,6 +3088,13 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" } } } diff --git a/package.json b/package.json index b3b643e..68af5e8 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "devDependencies": { "@types/node": "~22.19.7", "@vitest/coverage-v8": "^4.0.18", + "oxfmt": "^0.54.0", "tsx": "^4.20.3", "typescript": "^5.9.3", "vitest": "^4.0.18" @@ -65,6 +66,8 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "gen:routes": "tsx scripts/gen-routes.ts", "check:coverage": "tsx scripts/check-coverage.ts" }, diff --git a/renovate.json b/renovate.json index d702521..144f4fc 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "github>workos/renovate-config:public" - ] + "extends": ["github>workos/renovate-config:public"] } diff --git a/src/index.ts b/src/index.ts index e0ec6bc..1de0e2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,12 @@ -import { createServer, type ApiKeyMap, addErrorHook, removeErrorHook, getErrorHooks, type ErrorHook, type ErrorHookInput } from './core/index.js'; +import { + createServer, + type ApiKeyMap, + addErrorHook, + removeErrorHook, + getErrorHooks, + type ErrorHook, + type ErrorHookInput, +} from './core/index.js'; import { workosPlugin, seedFromConfig, type WorkOSSeedConfig } from './workos/index.js'; import { STORE_KEYS } from './workos/constants.js'; import { serve } from '@hono/node-server'; From b4efcf5ef2aa883e7c74a54b1dc5e5ab6ffa86db Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 10 Jun 2026 16:49:16 -0400 Subject: [PATCH 2/8] feat: generate event catalog from @workos/openapi-spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-written event constants had drifted from the real API (authentication.magicauth_succeeded vs the spec's authentication.magic_auth_succeeded), and the public docs pages are themselves incomplete — authentication.mfa_failed exists only in the spec. Generating the catalog from the published spec package makes drift impossible to reintroduce: regenerating is `npm run gen:events`, and picking up a newer spec is an ordinary dependency bump. The generated file is committed so package consumers never need the spec itself. --- package.json | 2 + scripts/gen-events-lib.spec.ts | 204 ++++++++ scripts/gen-events-lib.ts | 237 +++++++++ scripts/gen-events.ts | 80 +++ src/workos/generated/events.ts | 858 +++++++++++++++++++++++++++++++++ 5 files changed, 1381 insertions(+) create mode 100644 scripts/gen-events-lib.spec.ts create mode 100644 scripts/gen-events-lib.ts create mode 100644 scripts/gen-events.ts create mode 100644 src/workos/generated/events.ts diff --git a/package.json b/package.json index 68af5e8..eba5492 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "devDependencies": { "@types/node": "~22.19.7", "@vitest/coverage-v8": "^4.0.18", + "@workos/openapi-spec": "^0.6.0", "oxfmt": "^0.54.0", "tsx": "^4.20.3", "typescript": "^5.9.3", @@ -69,6 +70,7 @@ "fmt": "oxfmt", "fmt:check": "oxfmt --check", "gen:routes": "tsx scripts/gen-routes.ts", + "gen:events": "tsx scripts/gen-events.ts", "check:coverage": "tsx scripts/check-coverage.ts" }, "author": "WorkOS", diff --git a/scripts/gen-events-lib.spec.ts b/scripts/gen-events-lib.spec.ts new file mode 100644 index 0000000..599bc41 --- /dev/null +++ b/scripts/gen-events-lib.spec.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; +import { + type EventSchemaNode, + eventConstantKey, + parseEventCatalog, + deriveAuthEventDataFields, + generateEventsFile, +} from './gen-events-lib.js'; + +// --------------------------------------------------------------------------- +// Fixture: a miniature spec exercising every extraction path — the +// subscribable enum, inline data schemas, $ref data schemas, const +// type/status fields, and the failed-event error object. +// --------------------------------------------------------------------------- + +const fixtureSpec: EventSchemaNode = { + openapi: '3.1.0', + components: { + schemas: { + CreateWebhookEndpointDto: { + type: 'object', + properties: { + endpoint_url: { type: 'string' }, + events: { + type: 'array', + items: { + type: 'string', + enum: ['user.created', 'authentication.password_succeeded', 'authentication.password_failed'], + }, + }, + }, + }, + UserlandUser: { + type: 'object', + properties: { + object: { const: 'user' }, + id: { type: 'string' }, + email: { type: 'string' }, + }, + required: ['object', 'id', 'email'], + }, + Event: { + oneOf: [ + { + type: 'object', + properties: { + id: { type: 'string' }, + event: { type: 'string', const: 'user.created' }, + data: { $ref: '#/components/schemas/UserlandUser' }, + }, + }, + { + type: 'object', + properties: { + id: { type: 'string' }, + event: { type: 'string', const: 'authentication.password_succeeded' }, + data: { + type: 'object', + properties: { + type: { type: 'string', const: 'password' }, + status: { type: 'string', const: 'succeeded' }, + user_id: { type: ['string', 'null'] }, + email: { type: 'string' }, + ip_address: { type: ['string', 'null'] }, + user_agent: { type: ['string', 'null'] }, + }, + required: ['type', 'status', 'user_id', 'email', 'ip_address', 'user_agent'], + }, + }, + }, + { + type: 'object', + properties: { + id: { type: 'string' }, + event: { type: 'string', const: 'authentication.password_failed' }, + data: { + type: 'object', + properties: { + type: { type: 'string', const: 'password' }, + status: { type: 'string', const: 'failed' }, + user_id: { type: ['string', 'null'] }, + email: { type: ['string', 'null'] }, + ip_address: { type: ['string', 'null'] }, + user_agent: { type: ['string', 'null'] }, + error: { + type: 'object', + properties: { code: { type: 'string' }, message: { type: 'string' } }, + required: ['code', 'message'], + }, + }, + required: ['type', 'status', 'user_id', 'email', 'ip_address', 'user_agent', 'error'], + }, + }, + }, + ], + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// eventConstantKey +// --------------------------------------------------------------------------- + +describe('eventConstantKey', () => { + it('converts dotted snake_case event names to camelCase keys', () => { + expect(eventConstantKey('user.created')).toBe('userCreated'); + expect(eventConstantKey('authentication.magic_auth_failed')).toBe('authenticationMagicAuthFailed'); + expect(eventConstantKey('dsync.group.user_added')).toBe('dsyncGroupUserAdded'); + }); +}); + +// --------------------------------------------------------------------------- +// parseEventCatalog +// --------------------------------------------------------------------------- + +describe('parseEventCatalog', () => { + it('extracts subscribable names from CreateWebhookEndpointDto', () => { + const catalog = parseEventCatalog(fixtureSpec); + expect(catalog.subscribable).toEqual([ + 'authentication.password_failed', + 'authentication.password_succeeded', + 'user.created', + ]); + }); + + it('throws a clear error when the subscribable enum is missing', () => { + expect(() => parseEventCatalog({ components: { schemas: {} } })).toThrow(/CreateWebhookEndpointDto/); + }); + + it('finds event payload schemas anywhere in the spec tree', () => { + const catalog = parseEventCatalog(fixtureSpec); + expect(catalog.events.map((e) => e.name)).toEqual([ + 'authentication.password_failed', + 'authentication.password_succeeded', + 'user.created', + ]); + }); + + it('resolves $ref data schemas for required fields', () => { + const catalog = parseEventCatalog(fixtureSpec); + const userCreated = catalog.events.find((e) => e.name === 'user.created')!; + expect(userCreated.dataRequired).toEqual(['object', 'id', 'email']); + }); + + it('captures const type and status from auth event data', () => { + const catalog = parseEventCatalog(fixtureSpec); + const failed = catalog.events.find((e) => e.name === 'authentication.password_failed')!; + expect(failed.dataType).toBe('password'); + expect(failed.dataStatus).toBe('failed'); + expect(failed.dataRequired).toContain('error'); + }); +}); + +// --------------------------------------------------------------------------- +// deriveAuthEventDataFields +// --------------------------------------------------------------------------- + +describe('deriveAuthEventDataFields', () => { + it('derives literal unions for const fields and merges nullability', () => { + const catalog = parseEventCatalog(fixtureSpec); + const fields = deriveAuthEventDataFields(catalog.events); + const byName = Object.fromEntries(fields.map((f) => [f.name, f])); + + expect(byName.status.tsType).toBe("'failed' | 'succeeded'"); + expect(byName.type.tsType).toBe("'password'"); + // email is `string` on succeeded and `string | null` on failed → merged + expect(byName.email.tsType).toBe('string | null'); + expect(byName.email.optional).toBe(false); + }); + + it('marks fields absent from some auth schemas as optional', () => { + const catalog = parseEventCatalog(fixtureSpec); + const fields = deriveAuthEventDataFields(catalog.events); + const error = fields.find((f) => f.name === 'error')!; + expect(error.optional).toBe(true); + expect(error.tsType).toBe('{ code: string; message: string }'); + }); +}); + +// --------------------------------------------------------------------------- +// generateEventsFile +// --------------------------------------------------------------------------- + +describe('generateEventsFile', () => { + it('generates EVENTS constants, the subscribable list, and requirements', () => { + const catalog = parseEventCatalog(fixtureSpec); + const output = generateEventsFile(catalog); + + expect(output).toContain("userCreated: 'user.created',"); + expect(output).toContain("authenticationPasswordFailed: 'authentication.password_failed',"); + expect(output).toContain('export type WorkOSEventName'); + expect(output).toContain('export const SUBSCRIBABLE_EVENTS'); + expect(output).toContain('export interface AuthenticationEventData'); + expect(output).toContain( + "'authentication.password_failed': { type: 'password', status: 'failed', required: ['type', 'status', 'user_id', 'email', 'ip_address', 'user_agent', 'error'] },", + ); + }); + + it('is deterministic (same catalog → same output)', () => { + const catalog = parseEventCatalog(fixtureSpec); + expect(generateEventsFile(catalog)).toBe(generateEventsFile(catalog)); + }); +}); diff --git a/scripts/gen-events-lib.ts b/scripts/gen-events-lib.ts new file mode 100644 index 0000000..cd4d646 --- /dev/null +++ b/scripts/gen-events-lib.ts @@ -0,0 +1,237 @@ +/** + * Core codegen logic for gen-events. Separated from the CLI entry point + * so the transformation functions can be unit-tested independently. + * + * Extracts the webhook event catalog from a WorkOS OpenAPI spec: + * - subscribable event names (CreateWebhookEndpointDto.properties.events.items.enum) + * - per-event payload schemas (any object schema with properties.event.const) + * and generates src/workos/generated/events.ts. + */ + +import { toPascalCase } from './gen-routes-lib.js'; + +// --------------------------------------------------------------------------- +// Spec types (looser than gen-routes-lib: `type` may be a string or an array +// per JSON Schema 2020, and we walk arbitrary nesting) +// --------------------------------------------------------------------------- + +export interface EventSchemaNode { + type?: string | string[]; + const?: string; + enum?: string[]; + properties?: Record; + required?: string[]; + items?: EventSchemaNode; + $ref?: string; + [key: string]: unknown; +} + +export interface ParsedEvent { + /** Event name, e.g. "authentication.magic_auth_failed" */ + name: string; + /** Required fields of the event's data payload */ + dataRequired: string[]; + /** Properties of the data payload schema (post-$ref resolution) */ + dataProperties: Record; + /** const value of data.type, when present (authentication events) */ + dataType?: string; + /** const value of data.status, when present */ + dataStatus?: string; +} + +export interface ParsedEventCatalog { + /** Names subscribable via webhook endpoints */ + subscribable: string[]; + /** Every event with a payload schema in the spec, sorted by name */ + events: ParsedEvent[]; +} + +// --------------------------------------------------------------------------- +// Extraction +// --------------------------------------------------------------------------- + +/** Convert an event name to a camelCase constant key: "dsync.group.user_added" → "dsyncGroupUserAdded" */ +export function eventConstantKey(name: string): string { + const pascal = toPascalCase(name.replace(/\./g, '_')); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +function resolveRef(node: EventSchemaNode | undefined, spec: EventSchemaNode): EventSchemaNode | undefined { + if (!node?.$ref) return node; + const match = node.$ref.match(/^#\/components\/schemas\/(.+)$/); + if (!match) return node; + const schemas = (spec as { components?: { schemas?: Record } }).components?.schemas; + return schemas?.[match[1]] ?? node; +} + +export function parseEventCatalog(spec: EventSchemaNode): ParsedEventCatalog { + // Subscribable names: CreateWebhookEndpointDto.properties.events.items.enum + const schemas = (spec as { components?: { schemas?: Record } }).components?.schemas ?? {}; + const subscribable = schemas.CreateWebhookEndpointDto?.properties?.events?.items?.enum; + if (!subscribable || subscribable.length === 0) { + throw new Error('Could not find CreateWebhookEndpointDto.properties.events.items.enum in the spec'); + } + + // Payload schemas: walk the whole spec for object schemas shaped like an + // event (properties.event.const + properties.data). Location-independent so + // spec refactors don't break extraction. + const byName = new Map(); + const visited = new WeakSet(); + + const visit = (node: unknown): void => { + if (node === null || typeof node !== 'object') return; + if (visited.has(node)) return; + visited.add(node); + + if (Array.isArray(node)) { + for (const item of node) visit(item); + return; + } + + const schema = node as EventSchemaNode; + const eventName = schema.properties?.event?.const; + if (typeof eventName === 'string' && schema.properties?.data && !byName.has(eventName)) { + const data = resolveRef(schema.properties.data, spec) ?? {}; + byName.set(eventName, { + name: eventName, + dataRequired: data.required ?? [], + dataProperties: data.properties ?? {}, + dataType: data.properties?.type?.const, + dataStatus: data.properties?.status?.const, + }); + } + + for (const value of Object.values(schema)) visit(value); + }; + visit(spec); + + return { + subscribable: [...subscribable].sort(), + events: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)), + }; +} + +// --------------------------------------------------------------------------- +// AuthenticationEventData derivation +// --------------------------------------------------------------------------- + +function isAuthOutcomeEvent(name: string): boolean { + return name.startsWith('authentication.') && (name.endsWith('_succeeded') || name.endsWith('_failed')); +} + +function schemaToTs(node: EventSchemaNode): string { + if (node.const) return `'${node.const}'`; + const types = Array.isArray(node.type) ? node.type : node.type ? [node.type] : []; + if (types.includes('object') || node.properties) { + const props = node.properties ?? {}; + const required = new Set(node.required ?? []); + const entries = Object.entries(props).map( + ([key, value]) => `${key}${required.has(key) ? '' : '?'}: ${schemaToTs(value)}`, + ); + return entries.length > 0 ? `{ ${entries.join('; ')} }` : 'Record'; + } + const mapped = types.map((t) => (t === 'null' ? 'null' : t === 'integer' ? 'number' : t)); + return mapped.length > 0 ? mapped.join(' | ') : 'unknown'; +} + +/** + * Derive the AuthenticationEventData interface from the union of all + * authentication.*_succeeded / *_failed data schemas. Fields with const + * values become literal unions (status, type); fields missing from some + * schemas become optional (error only exists on failed events). + */ +export function deriveAuthEventDataFields( + events: ParsedEvent[], +): Array<{ name: string; tsType: string; optional: boolean }> { + const authEvents = events.filter((e) => isAuthOutcomeEvent(e.name)); + if (authEvents.length === 0) return []; + + const fieldNames: string[] = []; + for (const event of authEvents) { + for (const name of Object.keys(event.dataProperties)) { + if (!fieldNames.includes(name)) fieldNames.push(name); + } + } + + return fieldNames.map((name) => { + const presentIn = authEvents.filter((e) => name in e.dataProperties); + const optional = presentIn.length < authEvents.length; + + const consts = new Set(); + const nonConstTypes = new Set(); + for (const event of presentIn) { + const node = event.dataProperties[name]; + if (node.const) { + consts.add(`'${node.const}'`); + } else { + // Flatten union atoms so "string" + "string | null" dedup to "string | null" + const ts = schemaToTs(node); + for (const atom of ts.includes('{') ? [ts] : ts.split(' | ')) nonConstTypes.add(atom); + } + } + const atoms = [...nonConstTypes].sort((a, b) => (a === 'null' ? 1 : b === 'null' ? -1 : a.localeCompare(b))); + const tsType = + consts.size > 0 && atoms.length === 0 ? [...consts].sort().join(' | ') : atoms.join(' | ') || 'unknown'; + return { name, tsType, optional }; + }); +} + +// --------------------------------------------------------------------------- +// Code generation +// --------------------------------------------------------------------------- + +export function generateEventsFile(catalog: ParsedEventCatalog): string { + const allNames = [...new Set([...catalog.subscribable, ...catalog.events.map((e) => e.name)])].sort(); + + const lines: string[] = []; + lines.push('/**'); + lines.push(' * Generated by scripts/gen-events.ts — do not edit by hand.'); + lines.push(' * Source: the @workos/openapi-spec package. Regenerate with:'); + lines.push(' * npm run gen:events'); + lines.push(' */'); + lines.push(''); + lines.push('/** All WorkOS event names defined in the OpenAPI spec. */'); + lines.push('export const EVENTS = {'); + for (const name of allNames) { + lines.push(` ${eventConstantKey(name)}: '${name}',`); + } + lines.push('} as const;'); + lines.push(''); + lines.push('export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS];'); + lines.push(''); + lines.push('/** Event names subscribable via webhook endpoints (CreateWebhookEndpointDto). */'); + lines.push('export const SUBSCRIBABLE_EVENTS: readonly WorkOSEventName[] = ['); + for (const name of catalog.subscribable) { + lines.push(` '${name}',`); + } + lines.push('];'); + lines.push(''); + + const authFields = deriveAuthEventDataFields(catalog.events); + if (authFields.length > 0) { + lines.push('/** Payload shape shared by authentication.*_succeeded / *_failed events. */'); + lines.push('export interface AuthenticationEventData {'); + for (const field of authFields) { + lines.push(` ${field.name}${field.optional ? '?' : ''}: ${field.tsType};`); + } + lines.push('}'); + lines.push(''); + } + + lines.push('/** Per-event payload requirements from the spec, for test assertions. */'); + lines.push('export const EVENT_DATA_REQUIREMENTS: Record<'); + lines.push(' string,'); + lines.push(' { type?: string; status?: string; required: readonly string[] }'); + lines.push('> = {'); + for (const event of catalog.events) { + const parts: string[] = []; + if (event.dataType) parts.push(`type: '${event.dataType}'`); + if (event.dataStatus) parts.push(`status: '${event.dataStatus}'`); + parts.push(`required: [${event.dataRequired.map((f) => `'${f}'`).join(', ')}]`); + lines.push(` '${event.name}': { ${parts.join(', ')} },`); + } + lines.push('};'); + lines.push(''); + + return lines.join('\n'); +} diff --git a/scripts/gen-events.ts b/scripts/gen-events.ts new file mode 100644 index 0000000..ec689df --- /dev/null +++ b/scripts/gen-events.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env tsx +/** + * Codegen script: reads the WorkOS OpenAPI spec and generates the event catalog + * (src/workos/generated/events.ts) — event names, subscribable list, the + * authentication event payload interface, and per-event payload requirements. + * + * By default the spec comes from the @workos/openapi-spec devDependency, so + * regenerating is just: + * npm run gen:events + * Update the dependency to pick up a newer spec: + * npm install -D @workos/openapi-spec@latest && npm run gen:events + * A local spec file can still be passed explicitly: + * npm run gen:events -- path/to/openapi.yaml [--out ] [--dry-run] + * + * The generated file is committed, so consumers of the package never need the + * spec. Re-running against a newer spec is the drift check. Running twice on + * the same spec produces identical output (idempotent). + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve, extname, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { format, type FormatConfig } from 'oxfmt'; + +import { type EventSchemaNode, parseEventCatalog, generateEventsFile } from './gen-events-lib.js'; + +/** Load the project's oxfmt config so generated output matches `npm run fmt`. */ +function loadFormatConfig(): FormatConfig { + const configPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '.oxfmtrc.json'); + return existsSync(configPath) ? (JSON.parse(readFileSync(configPath, 'utf-8')) as FormatConfig) : {}; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const flags = args.filter((a) => a.startsWith('--')); + const positional = args.filter((a) => !a.startsWith('--')); + + // Default to the published spec package; a positional path overrides it. + const specPath = positional[0] ?? createRequire(import.meta.url).resolve('@workos/openapi-spec/spec'); + + const dryRun = flags.includes('--dry-run'); + const outIdx = args.indexOf('--out'); + const outFile = outIdx !== -1 ? args[outIdx + 1] : 'src/workos/generated/events.ts'; + + const resolvedSpec = resolve(specPath); + if (!existsSync(resolvedSpec)) { + console.error(`Spec file not found: ${resolvedSpec}`); + process.exit(1); + } + + const raw = readFileSync(resolvedSpec, 'utf-8'); + const ext = extname(resolvedSpec).toLowerCase(); + const spec: EventSchemaNode = + ext === '.yaml' || ext === '.yml' ? (YAML.parse(raw) as EventSchemaNode) : (JSON.parse(raw) as EventSchemaNode); + + const catalog = parseEventCatalog(spec); + const resolvedOut = resolve(outFile); + // The output path's `.ts` extension tells oxfmt to use the TypeScript parser. + const formatted = await format(resolvedOut, generateEventsFile(catalog), loadFormatConfig()); + if (formatted.errors.length > 0) { + console.error('oxfmt reported errors while formatting generated output:'); + for (const err of formatted.errors) console.error(` ${err.severity}: ${err.message}`); + process.exit(1); + } + const content = formatted.code; + + if (dryRun) { + console.log(content); + return; + } + + mkdirSync(dirname(resolvedOut), { recursive: true }); + writeFileSync(resolvedOut, content, 'utf-8'); + console.log(` wrote ${resolvedOut}`); + console.log(`\nCatalog: ${catalog.subscribable.length} subscribable, ${catalog.events.length} payload schemas`); +} + +await main(); diff --git a/src/workos/generated/events.ts b/src/workos/generated/events.ts new file mode 100644 index 0000000..6aad81e --- /dev/null +++ b/src/workos/generated/events.ts @@ -0,0 +1,858 @@ +/** + * Generated by scripts/gen-events.ts — do not edit by hand. + * Source: the @workos/openapi-spec package. Regenerate with: + * npm run gen:events + */ + +/** All WorkOS event names defined in the OpenAPI spec. */ +export const EVENTS = { + actionAuthenticationDenied: 'action.authentication.denied', + actionUserRegistrationDenied: 'action.user_registration.denied', + apiKeyCreated: 'api_key.created', + apiKeyRevoked: 'api_key.revoked', + apiKeyUpdated: 'api_key.updated', + authenticationEmailVerificationFailed: 'authentication.email_verification_failed', + authenticationEmailVerificationSucceeded: 'authentication.email_verification_succeeded', + authenticationMagicAuthFailed: 'authentication.magic_auth_failed', + authenticationMagicAuthSucceeded: 'authentication.magic_auth_succeeded', + authenticationMfaFailed: 'authentication.mfa_failed', + authenticationMfaSucceeded: 'authentication.mfa_succeeded', + authenticationOauthFailed: 'authentication.oauth_failed', + authenticationOauthSucceeded: 'authentication.oauth_succeeded', + authenticationPasskeyFailed: 'authentication.passkey_failed', + authenticationPasskeySucceeded: 'authentication.passkey_succeeded', + authenticationPasswordFailed: 'authentication.password_failed', + authenticationPasswordSucceeded: 'authentication.password_succeeded', + authenticationRadarRiskDetected: 'authentication.radar_risk_detected', + authenticationSsoFailed: 'authentication.sso_failed', + authenticationSsoStarted: 'authentication.sso_started', + authenticationSsoSucceeded: 'authentication.sso_succeeded', + authenticationSsoTimedOut: 'authentication.sso_timed_out', + connectionActivated: 'connection.activated', + connectionDeactivated: 'connection.deactivated', + connectionDeleted: 'connection.deleted', + connectionSamlCertificateRenewalRequired: 'connection.saml_certificate_renewal_required', + connectionSamlCertificateRenewed: 'connection.saml_certificate_renewed', + dsyncActivated: 'dsync.activated', + dsyncDeleted: 'dsync.deleted', + dsyncGroupCreated: 'dsync.group.created', + dsyncGroupDeleted: 'dsync.group.deleted', + dsyncGroupUpdated: 'dsync.group.updated', + dsyncGroupUserAdded: 'dsync.group.user_added', + dsyncGroupUserRemoved: 'dsync.group.user_removed', + dsyncTokenCreated: 'dsync.token.created', + dsyncTokenRevoked: 'dsync.token.revoked', + dsyncUserCreated: 'dsync.user.created', + dsyncUserDeleted: 'dsync.user.deleted', + dsyncUserUpdated: 'dsync.user.updated', + emailVerificationCreated: 'email_verification.created', + flagCreated: 'flag.created', + flagDeleted: 'flag.deleted', + flagRuleUpdated: 'flag.rule_updated', + flagUpdated: 'flag.updated', + groupCreated: 'group.created', + groupDeleted: 'group.deleted', + groupMemberAdded: 'group.member_added', + groupMemberRemoved: 'group.member_removed', + groupUpdated: 'group.updated', + invitationAccepted: 'invitation.accepted', + invitationCreated: 'invitation.created', + invitationResent: 'invitation.resent', + invitationRevoked: 'invitation.revoked', + magicAuthCreated: 'magic_auth.created', + organizationCreated: 'organization.created', + organizationDeleted: 'organization.deleted', + organizationUpdated: 'organization.updated', + organizationDomainCreated: 'organization_domain.created', + organizationDomainDeleted: 'organization_domain.deleted', + organizationDomainUpdated: 'organization_domain.updated', + organizationDomainVerificationFailed: 'organization_domain.verification_failed', + organizationDomainVerified: 'organization_domain.verified', + organizationMembershipCreated: 'organization_membership.created', + organizationMembershipDeleted: 'organization_membership.deleted', + organizationMembershipUpdated: 'organization_membership.updated', + organizationRoleCreated: 'organization_role.created', + organizationRoleDeleted: 'organization_role.deleted', + organizationRoleUpdated: 'organization_role.updated', + passwordResetCreated: 'password_reset.created', + passwordResetSucceeded: 'password_reset.succeeded', + permissionCreated: 'permission.created', + permissionDeleted: 'permission.deleted', + permissionUpdated: 'permission.updated', + pipesConnectedAccountConnected: 'pipes.connected_account.connected', + pipesConnectedAccountDisconnected: 'pipes.connected_account.disconnected', + pipesConnectedAccountReauthorizationNeeded: 'pipes.connected_account.reauthorization_needed', + roleCreated: 'role.created', + roleDeleted: 'role.deleted', + roleUpdated: 'role.updated', + sessionCreated: 'session.created', + sessionRevoked: 'session.revoked', + userCreated: 'user.created', + userDeleted: 'user.deleted', + userUpdated: 'user.updated', + vaultByokKeyDeleted: 'vault.byok_key.deleted', + vaultByokKeyVerificationCompleted: 'vault.byok_key.verification_completed', + vaultDataCreated: 'vault.data.created', + vaultDataDeleted: 'vault.data.deleted', + vaultDataRead: 'vault.data.read', + vaultDataUpdated: 'vault.data.updated', + vaultDekDecrypted: 'vault.dek.decrypted', + vaultDekRead: 'vault.dek.read', + vaultKekCreated: 'vault.kek.created', + vaultMetadataRead: 'vault.metadata.read', + vaultNamesListed: 'vault.names.listed', + waitlistUserApproved: 'waitlist_user.approved', + waitlistUserCreated: 'waitlist_user.created', + waitlistUserDenied: 'waitlist_user.denied', +} as const; + +export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS]; + +/** Event names subscribable via webhook endpoints (CreateWebhookEndpointDto). */ +export const SUBSCRIBABLE_EVENTS: readonly WorkOSEventName[] = [ + 'api_key.created', + 'api_key.revoked', + 'api_key.updated', + 'authentication.email_verification_succeeded', + 'authentication.magic_auth_failed', + 'authentication.magic_auth_succeeded', + 'authentication.mfa_succeeded', + 'authentication.oauth_failed', + 'authentication.oauth_succeeded', + 'authentication.passkey_failed', + 'authentication.passkey_succeeded', + 'authentication.password_failed', + 'authentication.password_succeeded', + 'authentication.radar_risk_detected', + 'authentication.sso_failed', + 'authentication.sso_started', + 'authentication.sso_succeeded', + 'authentication.sso_timed_out', + 'connection.activated', + 'connection.deactivated', + 'connection.deleted', + 'connection.saml_certificate_renewal_required', + 'connection.saml_certificate_renewed', + 'dsync.activated', + 'dsync.deleted', + 'dsync.group.created', + 'dsync.group.deleted', + 'dsync.group.updated', + 'dsync.group.user_added', + 'dsync.group.user_removed', + 'dsync.user.created', + 'dsync.user.deleted', + 'dsync.user.updated', + 'email_verification.created', + 'flag.created', + 'flag.deleted', + 'flag.rule_updated', + 'flag.updated', + 'group.created', + 'group.deleted', + 'group.member_added', + 'group.member_removed', + 'group.updated', + 'invitation.accepted', + 'invitation.created', + 'invitation.resent', + 'invitation.revoked', + 'magic_auth.created', + 'organization.created', + 'organization.deleted', + 'organization.updated', + 'organization_domain.created', + 'organization_domain.deleted', + 'organization_domain.updated', + 'organization_domain.verification_failed', + 'organization_domain.verified', + 'organization_membership.created', + 'organization_membership.deleted', + 'organization_membership.updated', + 'organization_role.created', + 'organization_role.deleted', + 'organization_role.updated', + 'password_reset.created', + 'password_reset.succeeded', + 'permission.created', + 'permission.deleted', + 'permission.updated', + 'pipes.connected_account.connected', + 'pipes.connected_account.disconnected', + 'pipes.connected_account.reauthorization_needed', + 'role.created', + 'role.deleted', + 'role.updated', + 'session.created', + 'session.revoked', + 'user.created', + 'user.deleted', + 'user.updated', + 'waitlist_user.approved', + 'waitlist_user.created', + 'waitlist_user.denied', +]; + +/** Payload shape shared by authentication.*_succeeded / *_failed events. */ +export interface AuthenticationEventData { + type: 'email_verification' | 'magic_auth' | 'mfa' | 'oauth' | 'passkey' | 'password' | 'sso'; + status: 'failed' | 'succeeded'; + ip_address: string | null; + user_agent: string | null; + user_id: string | null; + email: string | null; + error?: { code: string; message: string }; + sso?: { organization_id: string | null; connection_id: string | null; session_id: string | null }; +} + +/** Per-event payload requirements from the spec, for test assertions. */ +export const EVENT_DATA_REQUIREMENTS: Record = + { + 'action.authentication.denied': { + type: 'authentication', + required: [ + 'action_endpoint_id', + 'action_execution_id', + 'type', + 'verdict', + 'user_id', + 'organization_id', + 'email', + 'ip_address', + 'user_agent', + ], + }, + 'action.user_registration.denied': { + type: 'user_registration', + required: [ + 'action_endpoint_id', + 'action_execution_id', + 'type', + 'verdict', + 'organization_id', + 'email', + 'ip_address', + 'user_agent', + ], + }, + 'api_key.created': { + required: [ + 'object', + 'id', + 'owner', + 'name', + 'obfuscated_value', + 'last_used_at', + 'expires_at', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'api_key.revoked': { + required: [ + 'object', + 'id', + 'owner', + 'name', + 'obfuscated_value', + 'last_used_at', + 'expires_at', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'api_key.updated': { + required: [ + 'object', + 'id', + 'owner', + 'name', + 'obfuscated_value', + 'last_used_at', + 'expires_at', + 'permissions', + 'created_at', + 'updated_at', + 'previous_attributes', + ], + }, + 'authentication.email_verification_failed': { + type: 'email_verification', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.email_verification_succeeded': { + type: 'email_verification', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.magic_auth_failed': { + type: 'magic_auth', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.magic_auth_succeeded': { + type: 'magic_auth', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.mfa_failed': { + type: 'mfa', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.mfa_succeeded': { + type: 'mfa', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.oauth_failed': { + type: 'oauth', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.oauth_succeeded': { + type: 'oauth', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.passkey_failed': { + type: 'passkey', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.passkey_succeeded': { + type: 'passkey', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.password_failed': { + type: 'password', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'error'], + }, + 'authentication.password_succeeded': { + type: 'password', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.radar_risk_detected': { + required: ['auth_method', 'action', 'control', 'blocklist_type', 'ip_address', 'user_agent', 'user_id', 'email'], + }, + 'authentication.sso_failed': { + type: 'sso', + status: 'failed', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'sso', 'error'], + }, + 'authentication.sso_started': { + type: 'sso', + status: 'started', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'sso'], + }, + 'authentication.sso_succeeded': { + type: 'sso', + status: 'succeeded', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'sso'], + }, + 'authentication.sso_timed_out': { + type: 'sso', + status: 'timed_out', + required: ['type', 'status', 'ip_address', 'user_agent', 'user_id', 'email', 'sso', 'error'], + }, + 'connection.activated': { + required: [ + 'object', + 'id', + 'state', + 'name', + 'connection_type', + 'created_at', + 'updated_at', + 'external_key', + 'status', + 'domains', + ], + }, + 'connection.deactivated': { + required: [ + 'object', + 'id', + 'state', + 'name', + 'connection_type', + 'created_at', + 'updated_at', + 'external_key', + 'status', + 'domains', + ], + }, + 'connection.deleted': { + required: ['object', 'id', 'state', 'name', 'connection_type', 'created_at', 'updated_at'], + }, + 'connection.saml_certificate_renewal_required': { required: ['connection', 'certificate', 'days_until_expiry'] }, + 'connection.saml_certificate_renewed': { required: ['connection', 'certificate', 'renewed_at'] }, + 'dsync.activated': { + required: ['object', 'id', 'type', 'state', 'name', 'created_at', 'updated_at', 'external_key', 'domains'], + }, + 'dsync.deleted': { required: ['object', 'id', 'type', 'state', 'name', 'created_at', 'updated_at'] }, + 'dsync.group.created': { + required: ['object', 'id', 'idp_id', 'directory_id', 'organization_id', 'name', 'created_at', 'updated_at'], + }, + 'dsync.group.deleted': { + required: ['object', 'id', 'idp_id', 'directory_id', 'organization_id', 'name', 'created_at', 'updated_at'], + }, + 'dsync.group.updated': { + required: ['object', 'id', 'idp_id', 'directory_id', 'organization_id', 'name', 'created_at', 'updated_at'], + }, + 'dsync.group.user_added': { required: ['directory_id', 'user', 'group'] }, + 'dsync.group.user_removed': { required: ['directory_id', 'user', 'group'] }, + 'dsync.token.created': { required: ['object', 'id', 'directory_id', 'token_suffix', 'created_at'] }, + 'dsync.token.revoked': { required: ['object', 'id', 'directory_id', 'token_suffix', 'created_at'] }, + 'dsync.user.created': { + required: [ + 'object', + 'id', + 'directory_id', + 'organization_id', + 'idp_id', + 'email', + 'state', + 'raw_attributes', + 'custom_attributes', + 'created_at', + 'updated_at', + ], + }, + 'dsync.user.deleted': { + required: [ + 'object', + 'id', + 'directory_id', + 'organization_id', + 'idp_id', + 'email', + 'state', + 'raw_attributes', + 'custom_attributes', + 'created_at', + 'updated_at', + ], + }, + 'dsync.user.updated': { + required: [ + 'object', + 'id', + 'directory_id', + 'organization_id', + 'idp_id', + 'email', + 'state', + 'raw_attributes', + 'custom_attributes', + 'created_at', + 'updated_at', + ], + }, + 'email_verification.created': { + required: ['object', 'id', 'user_id', 'email', 'expires_at', 'created_at', 'updated_at'], + }, + 'flag.created': { + required: [ + 'object', + 'id', + 'environment_id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', + ], + }, + 'flag.deleted': { + required: [ + 'object', + 'id', + 'environment_id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', + ], + }, + 'flag.rule_updated': { + required: [ + 'object', + 'id', + 'environment_id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', + ], + }, + 'flag.updated': { + required: [ + 'object', + 'id', + 'environment_id', + 'slug', + 'name', + 'description', + 'owner', + 'tags', + 'enabled', + 'default_value', + 'created_at', + 'updated_at', + ], + }, + 'group.created': { + required: ['object', 'id', 'organization_id', 'name', 'description', 'created_at', 'updated_at'], + }, + 'group.deleted': { + required: ['object', 'id', 'organization_id', 'name', 'description', 'created_at', 'updated_at'], + }, + 'group.member_added': { required: ['group_id', 'organization_membership_id'] }, + 'group.member_removed': { required: ['group_id', 'organization_membership_id'] }, + 'group.updated': { + required: ['object', 'id', 'organization_id', 'name', 'description', 'created_at', 'updated_at'], + }, + 'invitation.accepted': { + required: [ + 'object', + 'id', + 'email', + 'state', + 'accepted_at', + 'revoked_at', + 'expires_at', + 'organization_id', + 'inviter_user_id', + 'accepted_user_id', + 'role_slug', + 'created_at', + 'updated_at', + ], + }, + 'invitation.created': { + required: [ + 'object', + 'id', + 'email', + 'state', + 'accepted_at', + 'revoked_at', + 'expires_at', + 'organization_id', + 'inviter_user_id', + 'accepted_user_id', + 'role_slug', + 'created_at', + 'updated_at', + ], + }, + 'invitation.resent': { + required: [ + 'object', + 'id', + 'email', + 'state', + 'accepted_at', + 'revoked_at', + 'expires_at', + 'organization_id', + 'inviter_user_id', + 'accepted_user_id', + 'role_slug', + 'created_at', + 'updated_at', + ], + }, + 'invitation.revoked': { + required: [ + 'object', + 'id', + 'email', + 'state', + 'accepted_at', + 'revoked_at', + 'expires_at', + 'organization_id', + 'inviter_user_id', + 'accepted_user_id', + 'role_slug', + 'created_at', + 'updated_at', + ], + }, + 'magic_auth.created': { required: ['object', 'id', 'user_id', 'email', 'expires_at', 'created_at', 'updated_at'] }, + 'organization_domain.created': { + required: ['object', 'id', 'organization_id', 'domain', 'created_at', 'updated_at'], + }, + 'organization_domain.deleted': { + required: ['object', 'id', 'organization_id', 'domain', 'created_at', 'updated_at'], + }, + 'organization_domain.updated': { + required: ['object', 'id', 'organization_id', 'domain', 'created_at', 'updated_at'], + }, + 'organization_domain.verification_failed': { required: ['reason', 'organization_domain'] }, + 'organization_domain.verified': { + required: ['object', 'id', 'organization_id', 'domain', 'created_at', 'updated_at'], + }, + 'organization_membership.created': { + required: [ + 'object', + 'id', + 'user_id', + 'organization_id', + 'status', + 'role', + 'custom_attributes', + 'directory_managed', + 'created_at', + 'updated_at', + ], + }, + 'organization_membership.deleted': { + required: [ + 'object', + 'id', + 'user_id', + 'organization_id', + 'status', + 'role', + 'custom_attributes', + 'directory_managed', + 'created_at', + 'updated_at', + ], + }, + 'organization_membership.updated': { + required: [ + 'object', + 'id', + 'user_id', + 'organization_id', + 'status', + 'role', + 'custom_attributes', + 'directory_managed', + 'created_at', + 'updated_at', + ], + }, + 'organization_role.created': { + required: [ + 'object', + 'organization_id', + 'slug', + 'name', + 'description', + 'resource_type_slug', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'organization_role.deleted': { + required: [ + 'object', + 'organization_id', + 'slug', + 'name', + 'description', + 'resource_type_slug', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'organization_role.updated': { + required: [ + 'object', + 'organization_id', + 'slug', + 'name', + 'description', + 'resource_type_slug', + 'permissions', + 'created_at', + 'updated_at', + ], + }, + 'organization.created': { + required: ['object', 'id', 'name', 'domains', 'metadata', 'external_id', 'created_at', 'updated_at'], + }, + 'organization.deleted': { + required: ['object', 'id', 'name', 'domains', 'metadata', 'external_id', 'created_at', 'updated_at'], + }, + 'organization.updated': { + required: ['object', 'id', 'name', 'domains', 'metadata', 'external_id', 'created_at', 'updated_at'], + }, + 'password_reset.created': { required: ['object', 'id', 'user_id', 'email', 'expires_at', 'created_at'] }, + 'password_reset.succeeded': { required: ['object', 'id', 'user_id', 'email', 'expires_at', 'created_at'] }, + 'permission.created': { + required: ['object', 'id', 'slug', 'name', 'description', 'system', 'created_at', 'updated_at'], + }, + 'permission.deleted': { + required: ['object', 'id', 'slug', 'name', 'description', 'system', 'created_at', 'updated_at'], + }, + 'permission.updated': { + required: ['object', 'id', 'slug', 'name', 'description', 'system', 'created_at', 'updated_at'], + }, + 'pipes.connected_account.connected': { + required: [ + 'object', + 'id', + 'data_integration_id', + 'provider_slug', + 'user_id', + 'organization_id', + 'scopes', + 'state', + 'created_at', + 'updated_at', + ], + }, + 'pipes.connected_account.disconnected': { + required: [ + 'object', + 'id', + 'data_integration_id', + 'provider_slug', + 'user_id', + 'organization_id', + 'scopes', + 'state', + 'created_at', + 'updated_at', + ], + }, + 'pipes.connected_account.reauthorization_needed': { + required: [ + 'object', + 'id', + 'data_integration_id', + 'provider_slug', + 'user_id', + 'organization_id', + 'scopes', + 'state', + 'created_at', + 'updated_at', + ], + }, + 'role.created': { required: ['object', 'slug', 'resource_type_slug', 'created_at', 'updated_at'] }, + 'role.deleted': { required: ['object', 'slug', 'resource_type_slug', 'created_at', 'updated_at'] }, + 'role.updated': { required: ['object', 'slug', 'resource_type_slug', 'created_at', 'updated_at'] }, + 'session.created': { + required: [ + 'object', + 'id', + 'ip_address', + 'user_agent', + 'user_id', + 'auth_method', + 'status', + 'expires_at', + 'ended_at', + 'created_at', + 'updated_at', + ], + }, + 'session.revoked': { + required: [ + 'object', + 'id', + 'ip_address', + 'user_agent', + 'user_id', + 'auth_method', + 'status', + 'expires_at', + 'ended_at', + 'created_at', + 'updated_at', + ], + }, + 'user.created': { + required: [ + 'object', + 'id', + 'first_name', + 'last_name', + 'profile_picture_url', + 'email', + 'email_verified', + 'external_id', + 'last_sign_in_at', + 'created_at', + 'updated_at', + ], + }, + 'user.deleted': { + required: [ + 'object', + 'id', + 'first_name', + 'last_name', + 'profile_picture_url', + 'email', + 'email_verified', + 'external_id', + 'last_sign_in_at', + 'created_at', + 'updated_at', + ], + }, + 'user.updated': { + required: [ + 'object', + 'id', + 'first_name', + 'last_name', + 'profile_picture_url', + 'email', + 'email_verified', + 'external_id', + 'last_sign_in_at', + 'created_at', + 'updated_at', + ], + }, + 'vault.byok_key.deleted': { required: ['organization_id', 'key_provider'] }, + 'vault.byok_key.verification_completed': { required: ['organization_id', 'key_provider', 'verified'] }, + 'vault.data.created': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name', 'key_id', 'key_context'] }, + 'vault.data.deleted': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name'] }, + 'vault.data.read': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name', 'key_id'] }, + 'vault.data.updated': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name', 'key_id', 'key_context'] }, + 'vault.dek.decrypted': { required: ['actor_id', 'actor_source', 'actor_name', 'key_id'] }, + 'vault.dek.read': { required: ['actor_id', 'actor_source', 'actor_name', 'key_ids', 'key_context'] }, + 'vault.kek.created': { required: ['actor_id', 'actor_source', 'actor_name', 'key_name', 'key_id'] }, + 'vault.metadata.read': { required: ['actor_id', 'actor_source', 'actor_name', 'kv_name'] }, + 'vault.names.listed': { required: ['actor_id', 'actor_source', 'actor_name'] }, + 'waitlist_user.approved': { + required: ['object', 'id', 'email', 'state', 'approved_at', 'created_at', 'updated_at'], + }, + 'waitlist_user.created': { + required: ['object', 'id', 'email', 'state', 'approved_at', 'created_at', 'updated_at'], + }, + 'waitlist_user.denied': { required: ['object', 'id', 'email', 'state', 'approved_at', 'created_at', 'updated_at'] }, + }; From 576c8265efac99e3e4f36a5abc3cf4a0e42a0fde Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 10 Jun 2026 16:49:27 -0400 Subject: [PATCH 3/8] feat: emit spec-accurate webhooks across the login flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emulator exists so apps can test their documented login flow locally, but whole flows (SSO, magic auth, email verification, password reset) emitted no events at all, failed logins emitted nothing, and some emitted names (connection.created, directory_user.*) don't exist in the real catalog — webhook handlers verified against the emulator could pass on names production never sends. Every emit site now references the spec-generated catalog, so an invalid event name no longer compiles. Sessions gained the spec-required auth_method/status/expires_at/ended_at fields, and update hooks now receive the previous value so connection.activated/deactivated fire only on real state transitions. Renames for consumers asserting old names: authentication.magicauth_succeeded → authentication.magic_auth_succeeded (same for email_verification), connection.created/updated → connection.activated/deactivated, directory*.* → dsync.*, and the payload's `method` field → spec `type`/`status` fields. --- src/core/store.ts | 4 +- src/workos/constants.ts | 53 +++----------- src/workos/entities.ts | 4 ++ src/workos/helpers.ts | 55 +++++++++++++++ src/workos/index.ts | 77 ++++++++++++++++---- src/workos/routes/auth.ts | 106 +++++++++++++++++++++++----- src/workos/routes/password-reset.ts | 6 ++ src/workos/routes/sessions.ts | 7 +- src/workos/routes/sso.ts | 66 ++++++++++++++++- 9 files changed, 300 insertions(+), 78 deletions(-) diff --git a/src/core/store.ts b/src/core/store.ts index a899698..338904e 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -12,7 +12,7 @@ export type SortFn = (a: T, b: T) => number; export interface CollectionHooks { onInsert?: (item: T) => void; - onUpdate?: (item: T) => void; + onUpdate?: (item: T, previous: T) => void; onDelete?: (item: T) => void; } @@ -101,7 +101,7 @@ export class Collection { } as T; this.items.set(id, updated); this.addToIndex(updated); - this.hooks.onUpdate?.(updated); + this.hooks.onUpdate?.(updated, existing); return updated; } diff --git a/src/workos/constants.ts b/src/workos/constants.ts index cdbda29..59bc01b 100644 --- a/src/workos/constants.ts +++ b/src/workos/constants.ts @@ -16,45 +16,14 @@ export const STORE_KEY_PREFIXES = { radarIpList: 'radar_ip_list', } as const; -/** All WorkOS webhook event names */ -export const EVENTS = { - userCreated: 'user.created', - userUpdated: 'user.updated', - userDeleted: 'user.deleted', - organizationCreated: 'organization.created', - organizationUpdated: 'organization.updated', - organizationDeleted: 'organization.deleted', - organizationDomainCreated: 'organization_domain.created', - organizationDomainVerified: 'organization_domain.verified', - organizationDomainUpdated: 'organization_domain.updated', - organizationDomainDeleted: 'organization_domain.deleted', - organizationMembershipCreated: 'organization_membership.created', - organizationMembershipUpdated: 'organization_membership.updated', - organizationMembershipDeleted: 'organization_membership.deleted', - connectionCreated: 'connection.created', - connectionUpdated: 'connection.updated', - connectionDeleted: 'connection.deleted', - sessionCreated: 'session.created', - sessionRevoked: 'session.revoked', - invitationCreated: 'invitation.created', - invitationAccepted: 'invitation.accepted', - invitationRevoked: 'invitation.revoked', - invitationResent: 'invitation.resent', - roleCreated: 'role.created', - roleUpdated: 'role.updated', - roleDeleted: 'role.deleted', - permissionCreated: 'permission.created', - permissionUpdated: 'permission.updated', - permissionDeleted: 'permission.deleted', - directoryCreated: 'directory.created', - directoryUpdated: 'directory.updated', - directoryDeleted: 'directory.deleted', - directoryUserCreated: 'directory_user.created', - directoryUserUpdated: 'directory_user.updated', - directoryUserDeleted: 'directory_user.deleted', - directoryGroupCreated: 'directory_group.created', - directoryGroupUpdated: 'directory_group.updated', - directoryGroupDeleted: 'directory_group.deleted', -} as const; - -export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS]; +/** + * WorkOS event catalog, generated from the OpenAPI spec. + * Regenerate with: npm run gen:events -- path/to/open-api-spec.yaml + */ +export { + EVENTS, + SUBSCRIBABLE_EVENTS, + EVENT_DATA_REQUIREMENTS, + type WorkOSEventName, + type AuthenticationEventData, +} from './generated/events.js'; diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 2171d07..8355377 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -49,6 +49,10 @@ export interface WorkOSSession extends Entity { organization_id: string | null; ip_address: string | null; user_agent: string | null; + auth_method: string; + status: 'active' | 'expired' | 'revoked'; + expires_at: string; + ended_at: string | null; } export interface WorkOSEmailVerification extends Entity { diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 5b665c0..a227368 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -1,5 +1,6 @@ import { randomBytes, createHash, createCipheriv } from 'node:crypto'; import { WorkOSApiError, type CursorPaginatedResult, type Entity } from '../core/index.js'; +import { EVENTS, type AuthenticationEventData, type WorkOSEventName } from './constants.js'; import type { WorkOSStore } from './store.js'; import type { WorkOSOrganization, @@ -102,6 +103,60 @@ export function formatSession(s: WorkOSSession): Record { return formatEntity(s); } +/** Maps the emulator's PascalCase authentication_method values to the spec's snake_case event `type`. */ +export const AUTH_METHOD_EVENT_TYPES: Record = { + OAuth: 'oauth', + Password: 'password', + MagicAuth: 'magic_auth', + EmailVerification: 'email_verification', + MFA: 'mfa', + SSO: 'sso', +}; + +/** Maps authentication_method values to the session `auth_method` enum (note: magic_code, not magic_auth). */ +export const AUTH_METHOD_SESSION_VALUES: Record = { + OAuth: 'oauth', + Password: 'password', + MagicAuth: 'magic_code', + SSO: 'sso', +}; + +/** authentication.* event names per method, resolved from the spec-generated catalog. */ +export const AUTH_EVENTS: Record = { + OAuth: { succeeded: EVENTS.authenticationOauthSucceeded, failed: EVENTS.authenticationOauthFailed }, + Password: { succeeded: EVENTS.authenticationPasswordSucceeded, failed: EVENTS.authenticationPasswordFailed }, + MagicAuth: { succeeded: EVENTS.authenticationMagicAuthSucceeded, failed: EVENTS.authenticationMagicAuthFailed }, + EmailVerification: { + succeeded: EVENTS.authenticationEmailVerificationSucceeded, + failed: EVENTS.authenticationEmailVerificationFailed, + }, + MFA: { succeeded: EVENTS.authenticationMfaSucceeded, failed: EVENTS.authenticationMfaFailed }, + SSO: { succeeded: EVENTS.authenticationSsoSucceeded, failed: EVENTS.authenticationSsoFailed }, +}; + +export function buildAuthenticationEventData(opts: { + status: 'succeeded' | 'failed'; + method: string; + userId?: string | null; + email?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + error?: { code: string; message: string }; + sso?: { organization_id: string | null; connection_id: string | null; session_id: string | null }; +}): Record { + const data: AuthenticationEventData = { + type: (AUTH_METHOD_EVENT_TYPES[opts.method] ?? opts.method.toLowerCase()) as AuthenticationEventData['type'], + status: opts.status, + user_id: opts.userId ?? null, + email: opts.email ?? null, + ip_address: opts.ipAddress ?? null, + user_agent: opts.userAgent ?? null, + ...(opts.error ? { error: opts.error } : {}), + ...(opts.sso ? { sso: opts.sso } : {}), + }; + return { ...data }; +} + export function formatEmailVerification(ev: WorkOSEmailVerification): Record { return formatEntity(ev); } diff --git a/src/workos/index.ts b/src/workos/index.ts index a5541c3..b08e3d7 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -54,6 +54,11 @@ import { formatDirectoryUser, formatDirectoryGroup, formatDomain, + formatEmailVerification, + formatMagicAuth, + formatPasswordReset, + formatApiKeyRecord, + formatFeatureFlag, } from './helpers.js'; import type { WorkOSConnectionType, PipeProvider, PipeConnectionStatus } from './entities.js'; @@ -402,8 +407,18 @@ export const workosPlugin: ServicePlugin = { onDelete: (m) => eventBus.emit({ event: EVENTS.organizationMembershipDeleted, data: formatMembership(m) }), }); ws.connections.setHooks({ - onInsert: (c) => eventBus.emit({ event: EVENTS.connectionCreated, data: formatConnection(c) }), - onUpdate: (c) => eventBus.emit({ event: EVENTS.connectionUpdated, data: formatConnection(c) }), + // The spec has no connection.created/updated — only activation state transitions + onInsert: (c) => { + if (c.state === 'active') eventBus.emit({ event: EVENTS.connectionActivated, data: formatConnection(c) }); + }, + onUpdate: (c, prev) => { + if (c.state === prev.state) return; + if (c.state === 'active') { + eventBus.emit({ event: EVENTS.connectionActivated, data: formatConnection(c) }); + } else if (c.state === 'inactive') { + eventBus.emit({ event: EVENTS.connectionDeactivated, data: formatConnection(c) }); + } + }, onDelete: (c) => eventBus.emit({ event: EVENTS.connectionDeleted, data: formatConnection(c) }), }); ws.sessions.setHooks({ @@ -413,10 +428,34 @@ export const workosPlugin: ServicePlugin = { ws.invitations.setHooks({ onInsert: (i) => eventBus.emit({ event: EVENTS.invitationCreated, data: formatInvitation(i) }), }); + // Lifecycle resources created during login flows. No delete hooks: codes are + // deleted when consumed, and the spec has no events for that. + ws.emailVerifications.setHooks({ + onInsert: (ev) => eventBus.emit({ event: EVENTS.emailVerificationCreated, data: formatEmailVerification(ev) }), + }); + ws.magicAuths.setHooks({ + onInsert: (ma) => eventBus.emit({ event: EVENTS.magicAuthCreated, data: formatMagicAuth(ma) }), + }); + ws.passwordResets.setHooks({ + onInsert: (pr) => eventBus.emit({ event: EVENTS.passwordResetCreated, data: formatPasswordReset(pr) }), + }); + // Organization-scoped roles share the roles collection but have their own spec events ws.roles.setHooks({ - onInsert: (r) => eventBus.emit({ event: EVENTS.roleCreated, data: formatRole(r) }), - onUpdate: (r) => eventBus.emit({ event: EVENTS.roleUpdated, data: formatRole(r) }), - onDelete: (r) => eventBus.emit({ event: EVENTS.roleDeleted, data: formatRole(r) }), + onInsert: (r) => + eventBus.emit({ + event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleCreated : EVENTS.roleCreated, + data: formatRole(r), + }), + onUpdate: (r) => + eventBus.emit({ + event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleUpdated : EVENTS.roleUpdated, + data: formatRole(r), + }), + onDelete: (r) => + eventBus.emit({ + event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleDeleted : EVENTS.roleDeleted, + data: formatRole(r), + }), }); ws.permissions.setHooks({ onInsert: (p) => eventBus.emit({ event: EVENTS.permissionCreated, data: formatPermission(p) }), @@ -424,19 +463,29 @@ export const workosPlugin: ServicePlugin = { onDelete: (p) => eventBus.emit({ event: EVENTS.permissionDeleted, data: formatPermission(p) }), }); ws.directories.setHooks({ - onInsert: (d) => eventBus.emit({ event: EVENTS.directoryCreated, data: formatDirectory(d) }), - onUpdate: (d) => eventBus.emit({ event: EVENTS.directoryUpdated, data: formatDirectory(d) }), - onDelete: (d) => eventBus.emit({ event: EVENTS.directoryDeleted, data: formatDirectory(d) }), + // The spec has no dsync.updated — only activation and deletion + onInsert: (d) => eventBus.emit({ event: EVENTS.dsyncActivated, data: formatDirectory(d) }), + onDelete: (d) => eventBus.emit({ event: EVENTS.dsyncDeleted, data: formatDirectory(d) }), }); ws.directoryUsers.setHooks({ - onInsert: (u) => eventBus.emit({ event: EVENTS.directoryUserCreated, data: formatDirectoryUser(u) }), - onUpdate: (u) => eventBus.emit({ event: EVENTS.directoryUserUpdated, data: formatDirectoryUser(u) }), - onDelete: (u) => eventBus.emit({ event: EVENTS.directoryUserDeleted, data: formatDirectoryUser(u) }), + onInsert: (u) => eventBus.emit({ event: EVENTS.dsyncUserCreated, data: formatDirectoryUser(u) }), + onUpdate: (u) => eventBus.emit({ event: EVENTS.dsyncUserUpdated, data: formatDirectoryUser(u) }), + onDelete: (u) => eventBus.emit({ event: EVENTS.dsyncUserDeleted, data: formatDirectoryUser(u) }), }); ws.directoryGroups.setHooks({ - onInsert: (g) => eventBus.emit({ event: EVENTS.directoryGroupCreated, data: formatDirectoryGroup(g) }), - onUpdate: (g) => eventBus.emit({ event: EVENTS.directoryGroupUpdated, data: formatDirectoryGroup(g) }), - onDelete: (g) => eventBus.emit({ event: EVENTS.directoryGroupDeleted, data: formatDirectoryGroup(g) }), + onInsert: (g) => eventBus.emit({ event: EVENTS.dsyncGroupCreated, data: formatDirectoryGroup(g) }), + onUpdate: (g) => eventBus.emit({ event: EVENTS.dsyncGroupUpdated, data: formatDirectoryGroup(g) }), + onDelete: (g) => eventBus.emit({ event: EVENTS.dsyncGroupDeleted, data: formatDirectoryGroup(g) }), + }); + ws.apiKeyRecords.setHooks({ + onInsert: (k) => eventBus.emit({ event: EVENTS.apiKeyCreated, data: formatApiKeyRecord(k) }), + onUpdate: (k) => eventBus.emit({ event: EVENTS.apiKeyUpdated, data: formatApiKeyRecord(k) }), + onDelete: (k) => eventBus.emit({ event: EVENTS.apiKeyRevoked, data: formatApiKeyRecord(k) }), + }); + ws.featureFlags.setHooks({ + onInsert: (f) => eventBus.emit({ event: EVENTS.flagCreated, data: formatFeatureFlag(f) }), + onUpdate: (f) => eventBus.emit({ event: EVENTS.flagUpdated, data: formatFeatureFlag(f) }), + onDelete: (f) => eventBus.emit({ event: EVENTS.flagDeleted, data: formatFeatureFlag(f) }), }); ws.webhookEndpoints.setHooks({ onInsert: () => eventBus.rebuildIndex(), diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 3c23cb5..63250c4 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -9,6 +9,9 @@ import { expiresIn, assertLocalRedirectUri, sealSession, + AUTH_EVENTS, + AUTH_METHOD_SESSION_VALUES, + buildAuthenticationEventData, } from '../helpers.js'; import type { EventBus } from '../event-bus.js'; import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; @@ -158,6 +161,34 @@ export function authRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'grant_type is required', 'invalid_request'); } + const requestIp = c.req.header('x-forwarded-for') ?? null; + const requestUserAgent = c.req.header('user-agent') ?? null; + + /** Emit the spec's authentication.*_failed event for a credential failure, then throw. */ + const failAuth: ( + method: string, + info: { email?: string | null; userId?: string | null }, + error: WorkOSApiError, + ) => never = (method, info, error) => { + const eventBus = store.getData(STORE_KEYS.eventBus); + const failedEvent = AUTH_EVENTS[method]?.failed; + if (eventBus && failedEvent) { + eventBus.emit({ + event: failedEvent, + data: buildAuthenticationEventData({ + status: 'failed', + method, + userId: info.userId, + email: info.email, + ipAddress: requestIp, + userAgent: requestUserAgent, + error: { code: error.code, message: error.message }, + }), + }); + } + throw error; + }; + let user; let organizationId: string | null = null; let authMethod: string; @@ -168,9 +199,13 @@ export function authRoutes(ctx: RouteContext): void { if (!code) throw new WorkOSApiError(400, 'code is required', 'invalid_request'); const authCode = ws.authCodes.findOneBy('code', code); - if (!authCode) throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); + if (!authCode) failAuth('OAuth', {}, new WorkOSApiError(400, 'Invalid code', 'invalid_code')); if (isExpired(authCode.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); + failAuth( + 'OAuth', + { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, + new WorkOSApiError(400, 'Code has expired', 'expired_code'), + ); } if (authCode.code_challenge) { @@ -186,7 +221,11 @@ export function authRoutes(ctx: RouteContext): void { challenge = codeVerifier; } if (challenge !== authCode.code_challenge) { - throw new WorkOSApiError(400, 'Invalid code_verifier', 'invalid_code_verifier'); + failAuth( + 'OAuth', + { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, + new WorkOSApiError(400, 'Invalid code_verifier', 'invalid_code_verifier'), + ); } } @@ -206,7 +245,11 @@ export function authRoutes(ctx: RouteContext): void { user = ws.users.findOneBy('email', email); if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { - throw new WorkOSApiError(401, 'Invalid credentials', 'invalid_credentials'); + failAuth( + 'Password', + { email, userId: user?.id }, + new WorkOSApiError(401, 'Invalid credentials', 'invalid_credentials'), + ); } authMethod = 'Password'; break; @@ -223,10 +266,14 @@ export function authRoutes(ctx: RouteContext): void { const magicAuth = ws.magicAuths.all().find((ma) => ma.code === code && ma.email === email); if (!magicAuth) { - throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); + failAuth('MagicAuth', { email }, new WorkOSApiError(400, 'Invalid code', 'invalid_code')); } if (isExpired(magicAuth.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); + failAuth( + 'MagicAuth', + { email: magicAuth.email, userId: magicAuth.user_id }, + new WorkOSApiError(400, 'Code has expired', 'expired_code'), + ); } user = ws.users.get(magicAuth.user_id); @@ -246,10 +293,18 @@ export function authRoutes(ctx: RouteContext): void { const ev = ws.emailVerifications.findBy('user_id', userId).find((v) => v.code === code); if (!ev) { - throw new WorkOSApiError(400, 'Invalid code', 'invalid_code'); + failAuth( + 'EmailVerification', + { userId, email: ws.users.get(userId)?.email }, + new WorkOSApiError(400, 'Invalid code', 'invalid_code'), + ); } if (isExpired(ev.expires_at)) { - throw new WorkOSApiError(400, 'Code has expired', 'expired_code'); + failAuth( + 'EmailVerification', + { email: ev.email, userId: ev.user_id }, + new WorkOSApiError(400, 'Code has expired', 'expired_code'), + ); } ws.users.update(userId, { email_verified: true }); @@ -308,12 +363,20 @@ export function authRoutes(ctx: RouteContext): void { } if (isExpired(challenge.expires_at)) { ws.authChallenges.delete(challenge.id); - throw new WorkOSApiError(400, 'Challenge has expired', 'expired_challenge'); + failAuth( + 'MFA', + { userId: pending.user_id, email: ws.users.get(pending.user_id)?.email }, + new WorkOSApiError(400, 'Challenge has expired', 'expired_challenge'), + ); } // Verify code against the challenge's stored code if (challenge.code && code !== challenge.code) { - throw new WorkOSApiError(400, 'Invalid one-time code', 'invalid_one_time_code'); + failAuth( + 'MFA', + { userId: pending.user_id, email: ws.users.get(pending.user_id)?.email }, + new WorkOSApiError(400, 'Invalid one-time code', 'invalid_one_time_code'), + ); } ws.authChallenges.delete(challenge.id); @@ -390,8 +453,12 @@ export function authRoutes(ctx: RouteContext): void { object: 'session', user_id: user.id, organization_id: organizationId, - ip_address: c.req.header('x-forwarded-for') ?? null, - user_agent: c.req.header('user-agent') ?? null, + ip_address: requestIp, + user_agent: requestUserAgent, + auth_method: AUTH_METHOD_SESSION_VALUES[authMethod] ?? 'unknown', + status: 'active', + expires_at: expiresIn(30 * 24 * 60), // matches refresh token lifetime + ended_at: null, }); // Resolve role + permissions for org-scoped sessions @@ -449,11 +516,18 @@ export function authRoutes(ctx: RouteContext): void { // Emit authentication event (hybrid Option B for action-specific events) const eventBus = store.getData(STORE_KEYS.eventBus); - if (eventBus) { - const authEventType = `authentication.${authMethod.toLowerCase()}_succeeded`; + const succeededEvent = AUTH_EVENTS[authMethod]?.succeeded; + if (eventBus && succeededEvent) { eventBus.emit({ - event: authEventType, - data: { user_id: user.id, email: updatedUser.email, method: authMethod, ip_address: session.ip_address }, + event: succeededEvent, + data: buildAuthenticationEventData({ + status: 'succeeded', + method: authMethod, + userId: user.id, + email: updatedUser.email, + ipAddress: session.ip_address, + userAgent: session.user_agent, + }), }); } diff --git a/src/workos/routes/password-reset.ts b/src/workos/routes/password-reset.ts index 4295b03..8bc7ec9 100644 --- a/src/workos/routes/password-reset.ts +++ b/src/workos/routes/password-reset.ts @@ -1,6 +1,8 @@ import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatPasswordReset, generateVerificationToken, hashPassword, expiresIn, isExpired } from '../helpers.js'; +import { STORE_KEYS, EVENTS } from '../constants.js'; +import type { EventBus } from '../event-bus.js'; export function passwordResetRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -65,6 +67,10 @@ export function passwordResetRoutes(ctx: RouteContext): void { }); ws.passwordResets.delete(pr.id); + // Manual emit: the spec event fires on confirmation, not on a collection insert + const eventBus = store.getData(STORE_KEYS.eventBus); + eventBus?.emit({ event: EVENTS.passwordResetSucceeded, data: formatPasswordReset(pr) }); + return c.json({ user: { object: 'user', id: user.id, email: user.email } }); }); } diff --git a/src/workos/routes/sessions.ts b/src/workos/routes/sessions.ts index bacd1ae..cf571f5 100644 --- a/src/workos/routes/sessions.ts +++ b/src/workos/routes/sessions.ts @@ -28,6 +28,8 @@ export function sessionRoutes(ctx: RouteContext): void { const session = ws.sessions.get(sessionId); if (!session) throw notFound('Session'); + // Sessions have no onUpdate hook, so this only shapes the session.revoked payload + ws.sessions.update(session.id, { status: 'revoked', ended_at: new Date().toISOString() }); ws.sessions.delete(session.id); return c.json({ success: true }); }); @@ -43,7 +45,10 @@ export function sessionRoutes(ctx: RouteContext): void { } const session = ws.sessions.get(sessionId); - if (session) ws.sessions.delete(session.id); + if (session) { + ws.sessions.update(session.id, { status: 'revoked', ended_at: new Date().toISOString() }); + ws.sessions.delete(session.id); + } if (returnTo) { assertLocalRedirectUri(returnTo); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index 6610db0..2f87276 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -1,7 +1,15 @@ import { type RouteContext, parseJsonBody, WorkOSApiError, generateId } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatSSOProfile, expiresIn, isExpired, assertLocalRedirectUri } from '../helpers.js'; +import { + formatSSOProfile, + expiresIn, + isExpired, + assertLocalRedirectUri, + AUTH_EVENTS, + buildAuthenticationEventData, +} from '../helpers.js'; import type { WorkOSConnection } from '../entities.js'; +import type { EventBus } from '../event-bus.js'; import { STORE_KEY_PREFIXES, STORE_KEYS } from '../constants.js'; import { renderLoginPage } from '../login-page.js'; @@ -136,6 +144,37 @@ export function ssoRoutes(ctx: RouteContext): void { const grantType = body.grant_type as string; const code = body.code as string; + const emitSsoEvent = ( + status: 'succeeded' | 'failed', + info: { + email?: string | null; + userId?: string | null; + organizationId?: string | null; + connectionId?: string | null; + }, + error?: WorkOSApiError, + ): void => { + const eventBus = store.getData(STORE_KEYS.eventBus); + if (!eventBus) return; + eventBus.emit({ + event: status === 'succeeded' ? AUTH_EVENTS.SSO.succeeded : AUTH_EVENTS.SSO.failed, + data: buildAuthenticationEventData({ + status, + method: 'SSO', + userId: info.userId, + email: info.email, + ipAddress: c.req.header('x-forwarded-for') ?? null, + userAgent: c.req.header('user-agent') ?? null, + ...(error ? { error: { code: error.code, message: error.message } } : {}), + sso: { + organization_id: info.organizationId ?? null, + connection_id: info.connectionId ?? null, + session_id: null, + }, + }), + }); + }; + if (grantType !== 'authorization_code') { throw new WorkOSApiError(400, 'Unsupported grant_type', 'invalid_request'); } @@ -145,11 +184,24 @@ export function ssoRoutes(ctx: RouteContext): void { const auth = ws.ssoAuthorizations.findOneBy('code', code); if (!auth) { - throw new WorkOSApiError(400, 'Invalid authorization code', 'invalid_code'); + const error = new WorkOSApiError(400, 'Invalid authorization code', 'invalid_code'); + emitSsoEvent('failed', {}, error); + throw error; } if (isExpired(auth.expires_at)) { ws.ssoAuthorizations.delete(auth.id); - throw new WorkOSApiError(400, 'Authorization code has expired', 'expired_code'); + const expiredProfile = ws.ssoProfiles.get(auth.profile_id); + const error = new WorkOSApiError(400, 'Authorization code has expired', 'expired_code'); + emitSsoEvent( + 'failed', + { + email: expiredProfile?.email, + organizationId: auth.organization_id, + connectionId: expiredProfile?.connection_id, + }, + error, + ); + throw error; } const profile = ws.ssoProfiles.get(auth.profile_id); @@ -167,6 +219,14 @@ export function ssoRoutes(ctx: RouteContext): void { store.setData(`${STORE_KEY_PREFIXES.ssoToken}${accessToken}`, profile.id); + // SSO is profile-based; a user-management user may not exist for this email + emitSsoEvent('succeeded', { + email: profile.email, + userId: ws.users.findOneBy('email', profile.email)?.id ?? null, + organizationId: auth.organization_id ?? profile.organization_id, + connectionId: profile.connection_id, + }); + return c.json({ profile: formatSSOProfile(profile), access_token: accessToken, From cd8573fd67db0325cd654ee85bc395e1d32389b6 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 10 Jun 2026 16:49:39 -0400 Subject: [PATCH 4/8] test: prove the end-to-end login story over real HTTP Route specs run in-process and webhook delivery was only covered with a mocked fetch, so nothing verified the actual story: a registered endpoint receiving signed, spec-shaped payloads as a user walks the documented flows. The e2e spec boots the real server plus a local receiver and asserts payloads against the spec-generated required-field metadata, so future catalog drift fails CI here instead of surfacing in user apps. --- src/e2e.spec.ts | 304 +++++++++++++++++++++++ src/workos/routes/auth.spec.ts | 176 ++++++++++++- src/workos/routes/password-reset.spec.ts | 87 +++++++ src/workos/routes/sso.spec.ts | 88 ++++++- 4 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 src/e2e.spec.ts create mode 100644 src/workos/routes/password-reset.spec.ts diff --git a/src/e2e.spec.ts b/src/e2e.spec.ts new file mode 100644 index 0000000..5d01d06 --- /dev/null +++ b/src/e2e.spec.ts @@ -0,0 +1,304 @@ +/** + * End-to-end login flow story, over real HTTP. + * + * Boots the emulator with createEmulator() plus a local webhook receiver, then + * walks the workos.com/docs login flows and asserts that every resource + * creation and authentication outcome delivers a signed webhook whose name and + * payload match the OpenAPI spec (via the generated EVENT_DATA_REQUIREMENTS). + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { createHmac } from 'node:crypto'; +import { createEmulator, type Emulator } from './index.js'; +import { EVENT_DATA_REQUIREMENTS } from './workos/generated/events.js'; + +const WEBHOOK_SECRET = 'whsec_e2e_test_secret'; + +interface ReceivedWebhook { + id: string; + event: string; + data: Record; + created_at: string; + signature: string; + rawBody: string; +} + +interface WebhookReceiver { + url: string; + received: ReceivedWebhook[]; + close: () => Promise; +} + +function startWebhookReceiver(): Promise { + const received: ReceivedWebhook[] = []; + const server: Server = createServer((req, res) => { + let rawBody = ''; + req.on('data', (chunk) => (rawBody += chunk)); + req.on('end', () => { + const parsed = JSON.parse(rawBody); + received.push({ ...parsed, signature: req.headers['workos-signature'] as string, rawBody }); + res.writeHead(200).end(); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ + url: `http://127.0.0.1:${port}/webhooks`, + received, + close: () => new Promise((res2, rej) => server.close((err) => (err ? rej(err) : res2()))), + }); + }); + }); +} + +describe('end-to-end login flow (workos.com/docs story)', () => { + let emulator: Emulator; + let receiver: WebhookReceiver; + let userId: string; + const email = 'alice@e2e-story.test'; + + const api = (path: string, init?: RequestInit) => + fetch(`${emulator.url}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${emulator.apiKey}`, + 'Content-Type': 'application/json', + ...init?.headers, + }, + }); + + /** Deliveries are fire-and-forget — poll until the named webhook arrives past the cursor. */ + function waitForWebhook(event: string, opts?: { after?: number; timeout?: number }): Promise { + return vi.waitFor( + () => { + const hit = receiver.received.slice(opts?.after ?? 0).find((w) => w.event === event); + if (!hit) { + const seen = receiver.received.map((w) => w.event).join(', ') || '(none)'; + throw new Error(`no '${event}' webhook yet; saw: ${seen}`); + } + return hit; + }, + { timeout: opts?.timeout ?? 3000, interval: 25 }, + ); + } + + /** WorkOS-Signature: t=,v1= — same scheme the official SDKs verify. */ + function verifySignature(webhook: ReceivedWebhook): void { + const match = webhook.signature?.match(/^t=(\d+),v1=([a-f0-9]{64})$/); + expect(match, `unexpected signature format: ${webhook.signature}`).toBeTruthy(); + const expected = createHmac('sha256', WEBHOOK_SECRET).update(`${match![1]}.${webhook.rawBody}`).digest('hex'); + expect(match![2]).toBe(expected); + } + + /** Assert the payload carries every field the OpenAPI spec marks required for this event. */ + function expectSpecShape(webhook: ReceivedWebhook): void { + const requirements = EVENT_DATA_REQUIREMENTS[webhook.event]; + expect(requirements, `event '${webhook.event}' is not in the spec catalog`).toBeDefined(); + for (const field of requirements.required) { + expect(webhook.data, `'${webhook.event}' payload is missing required field '${field}'`).toHaveProperty(field); + } + if (requirements.type) expect(webhook.data.type).toBe(requirements.type); + if (requirements.status) expect(webhook.data.status).toBe(requirements.status); + } + + beforeAll(async () => { + receiver = await startWebhookReceiver(); + emulator = await createEmulator({ port: 0 }); + + const res = await api('/webhook_endpoints', { + method: 'POST', + body: JSON.stringify({ endpoint_url: receiver.url, secret: WEBHOOK_SECRET, events: [] }), + }); + expect(res.status).toBe(201); + }); + + afterAll(async () => { + await emulator.close(); + await receiver.close(); + }); + + it('delivers a signed, spec-shaped user.created webhook when a user registers', async () => { + const cursor = receiver.received.length; + + const res = await api('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email, password: 'correct horse battery staple', first_name: 'Alice' }), + }); + expect(res.status).toBe(201); + userId = (await res.json()).id; + + const webhook = await waitForWebhook('user.created', { after: cursor }); + expect(webhook.data.email).toBe(email); + expect(webhook.data.id).toBe(userId); + verifySignature(webhook); + expectSpecShape(webhook); + }); + + it('delivers organization.created and organization_membership.created webhooks', async () => { + const cursor = receiver.received.length; + + const orgRes = await api('/organizations', { + method: 'POST', + body: JSON.stringify({ name: 'E2E Story Org' }), + }); + const org = await orgRes.json(); + + await api('/user_management/organization_memberships', { + method: 'POST', + body: JSON.stringify({ user_id: userId, organization_id: org.id }), + }); + + const orgWebhook = await waitForWebhook('organization.created', { after: cursor }); + expect(orgWebhook.data.name).toBe('E2E Story Org'); + verifySignature(orgWebhook); + expectSpecShape(orgWebhook); + + const membershipWebhook = await waitForWebhook('organization_membership.created', { after: cursor }); + expect(membershipWebhook.data.user_id).toBe(userId); + expect(membershipWebhook.data.organization_id).toBe(org.id); + verifySignature(membershipWebhook); + }); + + it('completes the hosted authorize → authenticate flow with session and oauth webhooks', async () => { + const cursor = receiver.received.length; + + // Step 1 (docs: "Redirect users to AuthKit"): the app sends the browser to /authorize + const authorizeRes = await fetch( + `${emulator.url}/user_management/authorize?` + + new URLSearchParams({ + response_type: 'code', + client_id: 'client_e2e', + redirect_uri: 'http://localhost:3000/callback', + state: 'e2e-state', + login_hint: email, + }), + { redirect: 'manual' }, + ); + expect(authorizeRes.status).toBe(302); + const callback = new URL(authorizeRes.headers.get('location')!); + expect(callback.searchParams.get('state')).toBe('e2e-state'); + const code = callback.searchParams.get('code')!; + expect(code).toBeTruthy(); + + // Step 2 (docs: "Exchange the code"): the callback handler authenticates + const authRes = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: 'client_e2e' }), + }); + expect(authRes.status).toBe(200); + const auth = await authRes.json(); + expect(auth.access_token).toBeTruthy(); + expect(auth.refresh_token).toBeTruthy(); + expect(auth.user.email).toBe(email); + expect(auth.authentication_method).toBe('OAuth'); + + // Step 3: webhooks for the new session and the authentication outcome + const sessionWebhook = await waitForWebhook('session.created', { after: cursor }); + expect(sessionWebhook.data).toMatchObject({ user_id: userId, auth_method: 'oauth', status: 'active' }); + verifySignature(sessionWebhook); + expectSpecShape(sessionWebhook); + + const authWebhook = await waitForWebhook('authentication.oauth_succeeded', { after: cursor }); + expect(authWebhook.data).toMatchObject({ type: 'oauth', status: 'succeeded', user_id: userId, email }); + verifySignature(authWebhook); + expectSpecShape(authWebhook); + }); + + it('signs in with a password and emits authentication.password_succeeded', async () => { + const cursor = receiver.received.length; + + const res = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email, password: 'correct horse battery staple' }), + }); + expect(res.status).toBe(200); + + const webhook = await waitForWebhook('authentication.password_succeeded', { after: cursor }); + expect(webhook.data).toMatchObject({ type: 'password', status: 'succeeded', user_id: userId, email }); + expectSpecShape(webhook); + }); + + it('completes magic auth using the code delivered by the magic_auth.created webhook', async () => { + const cursor = receiver.received.length; + + const res = await api('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email }), + }); + expect(res.status).toBe(201); + + // The story beat: the webhook carries the code your app would have emailed + const createdWebhook = await waitForWebhook('magic_auth.created', { after: cursor }); + expectSpecShape(createdWebhook); + const code = createdWebhook.data.code as string; + expect(code).toBeTruthy(); + + const authRes = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', code, email }), + }); + expect(authRes.status).toBe(200); + + const webhook = await waitForWebhook('authentication.magic_auth_succeeded', { after: cursor }); + expect(webhook.data).toMatchObject({ type: 'magic_auth', status: 'succeeded', user_id: userId, email }); + expectSpecShape(webhook); + }); + + it('emits authentication.password_failed with an error object on a bad password', async () => { + const cursor = receiver.received.length; + + const res = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email, password: 'wrong password' }), + }); + expect(res.status).toBe(401); + + const webhook = await waitForWebhook('authentication.password_failed', { after: cursor }); + expect(webhook.data).toMatchObject({ + type: 'password', + status: 'failed', + email, + error: { code: 'invalid_credentials', message: 'Invalid credentials' }, + }); + verifySignature(webhook); + expectSpecShape(webhook); + }); + + it('completes a password reset driven entirely by webhooks', async () => { + const cursor = receiver.received.length; + + const res = await api('/user_management/password_reset', { + method: 'POST', + body: JSON.stringify({ email }), + }); + expect(res.status).toBe(201); + + const createdWebhook = await waitForWebhook('password_reset.created', { after: cursor }); + expectSpecShape(createdWebhook); + const token = createdWebhook.data.token as string; + expect(token).toBeTruthy(); + + const confirmRes = await api('/user_management/password_reset/confirm', { + method: 'POST', + body: JSON.stringify({ token, new_password: 'an even better passphrase' }), + }); + expect(confirmRes.status).toBe(200); + + await waitForWebhook('password_reset.succeeded', { after: cursor }); + + // The new password works — and emits its own success event + const loginRes = await fetch(`${emulator.url}/user_management/authenticate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email, password: 'an even better passphrase' }), + }); + expect(loginRes.status).toBe(200); + await waitForWebhook('authentication.password_succeeded', { after: cursor }); + }); +}); diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 3c050f8..18db1c3 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -508,9 +508,7 @@ describe('AuthKit interactive auth', () => { impersonator: null, }); - const res = await app.request( - '/user_management/authorize?redirect_uri=http://localhost:3000/callback&state=abc', - ); + const res = await app.request('/user_management/authorize?redirect_uri=http://localhost:3000/callback&state=abc'); expect(res.status).toBe(200); const html = await res.text(); expect(html).toContain('Sign In'); @@ -602,3 +600,175 @@ describe('AuthKit interactive auth', () => { expect(body.access_token).toBeDefined(); }); }); + +describe('authentication events (spec-named, spec-shaped)', () => { + let app: ReturnType['app']; + let store: Store; + + beforeEach(() => { + const server = createTestApp(); + app = server.app; + store = server.store; + }); + + const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); + const json = (res: Response) => res.json() as Promise; + + const eventsNamed = (name: string) => + getWorkOSStore(store) + .events.all() + .filter((e) => e.event === name); + + async function registerUser(email: string, password: string) { + const res = await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); + return json(res); + } + + it('emits authentication.password_succeeded with the spec payload', async () => { + const user = await registerUser('evt-pass@test.com', 'secret'); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'User-Agent': 'spec-agent' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-pass@test.com', password: 'secret' }), + }); + + const [event] = eventsNamed('authentication.password_succeeded'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'password', + status: 'succeeded', + user_id: user.id, + email: 'evt-pass@test.com', + user_agent: 'spec-agent', + }); + expect(event.data).toHaveProperty('ip_address'); + }); + + it('emits authentication.password_failed with a required error object', async () => { + await registerUser('evt-fail@test.com', 'secret'); + + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-fail@test.com', password: 'wrong' }), + }); + expect(res.status).toBe(401); + + const [event] = eventsNamed('authentication.password_failed'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'password', + status: 'failed', + email: 'evt-fail@test.com', + error: { code: 'invalid_credentials', message: 'Invalid credentials' }, + }); + }); + + it('emits authentication.oauth_succeeded for the authorization code flow', async () => { + await registerUser('evt-oauth@test.com', 'secret'); + + const authRes = await app.request( + '/user_management/authorize?redirect_uri=http://localhost:3000/callback&response_type=code', + ); + const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code }), + }); + + const [event] = eventsNamed('authentication.oauth_succeeded'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ type: 'oauth', status: 'succeeded' }); + }); + + it('emits authentication.oauth_failed for an invalid code', async () => { + const res = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code: 'bogus' }), + }); + expect(res.status).toBe(400); + + const [event] = eventsNamed('authentication.oauth_failed'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'oauth', + status: 'failed', + error: { code: 'invalid_code', message: 'Invalid code' }, + }); + }); + + it('emits magic_auth.created on code request and magic_auth_succeeded on exchange', async () => { + const user = await registerUser('evt-magic@test.com', 'secret'); + + await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email: 'evt-magic@test.com' }), + }); + + const [created] = eventsNamed('magic_auth.created'); + expect(created).toBeDefined(); + expect(created.data).toMatchObject({ user_id: user.id, email: 'evt-magic@test.com' }); + const code = created.data.code as string; + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', + code, + email: 'evt-magic@test.com', + }), + }); + + const [succeeded] = eventsNamed('authentication.magic_auth_succeeded'); + expect(succeeded).toBeDefined(); + expect(succeeded.data).toMatchObject({ type: 'magic_auth', status: 'succeeded', user_id: user.id }); + }); + + it('emits email_verification.created and email_verification_succeeded', async () => { + const user = await registerUser('evt-verify@test.com', 'secret'); + + const sendRes = await req(`/user_management/users/${user.id}/email_verification/send`, { method: 'POST' }); + const verification = await json(sendRes); + + const [created] = eventsNamed('email_verification.created'); + expect(created).toBeDefined(); + expect(created.data).toMatchObject({ user_id: user.id, email: 'evt-verify@test.com' }); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:email-verification:code', + code: verification.code, + user_id: user.id, + }), + }); + + const [succeeded] = eventsNamed('authentication.email_verification_succeeded'); + expect(succeeded).toBeDefined(); + expect(succeeded.data).toMatchObject({ type: 'email_verification', status: 'succeeded', user_id: user.id }); + }); + + it('creates sessions with spec-required fields (auth_method, status, expires_at)', async () => { + await registerUser('evt-session@test.com', 'secret'); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-session@test.com', password: 'secret' }), + }); + + const [event] = eventsNamed('session.created'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ auth_method: 'password', status: 'active', ended_at: null }); + expect(event.data.expires_at).toBeTruthy(); + }); +}); diff --git a/src/workos/routes/password-reset.spec.ts b/src/workos/routes/password-reset.spec.ts new file mode 100644 index 0000000..9dbbe62 --- /dev/null +++ b/src/workos/routes/password-reset.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createServer, type ApiKeyMap } from '../../core/index.js'; +import { workosPlugin } from '../index.js'; +import { getWorkOSStore } from '../store.js'; +import type { Store } from '../../core/index.js'; + +const apiKeys: ApiKeyMap = { sk_test_pwreset: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_pwreset', 'Content-Type': 'application/json' }; + +function createTestApp() { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); +} + +describe('Password reset routes', () => { + let app: ReturnType['app']; + let store: Store; + + beforeEach(() => { + const server = createTestApp(); + app = server.app; + store = server.store; + }); + + const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); + const json = (res: Response) => res.json() as Promise; + + const eventsNamed = (name: string) => + getWorkOSStore(store) + .events.all() + .filter((e) => e.event === name); + + async function createUserAndRequestReset() { + const user = await json( + await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email: 'reset@test.com', password: 'oldpassword' }), + }), + ); + const reset = await json( + await req('/user_management/password_reset', { + method: 'POST', + body: JSON.stringify({ email: 'reset@test.com' }), + }), + ); + return { user, reset }; + } + + it('emits password_reset.created when a reset is requested', async () => { + const { user } = await createUserAndRequestReset(); + + const [event] = eventsNamed('password_reset.created'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ user_id: user.id, email: 'reset@test.com' }); + }); + + it('emits password_reset.succeeded on confirm and the new password works', async () => { + const { reset } = await createUserAndRequestReset(); + + const confirmRes = await req('/user_management/password_reset/confirm', { + method: 'POST', + body: JSON.stringify({ token: reset.token, new_password: 'newpassword' }), + }); + expect(confirmRes.status).toBe(200); + + const [event] = eventsNamed('password_reset.succeeded'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ email: 'reset@test.com' }); + + const authRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'reset@test.com', password: 'newpassword' }), + }); + expect(authRes.status).toBe(200); + }); + + it('rejects an invalid token without emitting password_reset.succeeded', async () => { + await createUserAndRequestReset(); + + const confirmRes = await req('/user_management/password_reset/confirm', { + method: 'POST', + body: JSON.stringify({ token: 'bogus', new_password: 'newpassword' }), + }); + expect(confirmRes.status).toBe(400); + expect(eventsNamed('password_reset.succeeded')).toHaveLength(0); + }); +}); diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index ff0159d..cbfb2e7 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createServer, type ApiKeyMap } from '../../core/index.js'; import { workosPlugin } from '../index.js'; +import { getWorkOSStore } from '../store.js'; import { STORE_KEYS } from '../constants.js'; import type { Store } from '../../core/index.js'; @@ -13,9 +14,12 @@ function createTestApp() { describe('SSO routes', () => { let app: ReturnType['app']; + let store: Store; beforeEach(() => { - app = createTestApp().app; + const server = createTestApp(); + app = server.app; + store = server.store; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -216,3 +220,85 @@ describe('SSO interactive auth', () => { expect(body.profile.email).toBe('alice@sso.example.com'); }); }); + +describe('SSO authentication events', () => { + let app: ReturnType['app']; + let store: Store; + + beforeEach(() => { + const server = createTestApp(); + app = server.app; + store = server.store; + }); + + const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); + const json = (res: Response) => res.json() as Promise; + + const eventsNamed = (name: string) => + getWorkOSStore(store) + .events.all() + .filter((e) => e.event === name); + + async function createOrgWithConnection() { + const org = await json( + await req('/organizations', { + method: 'POST', + body: JSON.stringify({ name: 'SSO Events Org' }), + }), + ); + const conn = await json( + await req('/connections', { + method: 'POST', + body: JSON.stringify({ + name: 'Events SSO', + organization_id: org.id, + connection_type: 'GenericSAML', + domains: ['sso-events.example.com'], + }), + }), + ); + return { org, conn }; + } + + it('emits authentication.sso_succeeded with the spec sso object on token exchange', async () => { + const { org, conn } = await createOrgWithConnection(); + + const authRes = await app.request( + `/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback`, + ); + const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; + + await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code }), + }); + + const [event] = eventsNamed('authentication.sso_succeeded'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'sso', + status: 'succeeded', + sso: { organization_id: org.id, connection_id: conn.id, session_id: null }, + }); + expect(event.data).toHaveProperty('user_id'); + expect(event.data).toHaveProperty('email'); + }); + + it('emits authentication.sso_failed with an error object for an invalid code', async () => { + const res = await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code: 'sso_bogus' }), + }); + expect(res.status).toBe(400); + + const [event] = eventsNamed('authentication.sso_failed'); + expect(event).toBeDefined(); + expect(event.data).toMatchObject({ + type: 'sso', + status: 'failed', + error: { code: 'invalid_code', message: 'Invalid authorization code' }, + }); + }); +}); From a1473ecf34bf1bc9f3ebf6c5c9ef4089240a6a35 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 10 Jun 2026 16:49:47 -0400 Subject: [PATCH 5/8] docs: document the end-to-end login flow story The webhook system existed but the README never mentioned it, so users had no way to discover the emulator's core story: driving an entire documented login flow locally, with codes WorkOS would email delivered in webhook payloads instead. --- README.md | 100 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 82 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a8b73c1..5ee7ba8 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,12 @@ The same pattern works for any language with a WorkOS SDK (Ruby, Go, Java, etc.) ## Programmatic API (Node.js) ```ts -import { createEmulator } from "@workos/emulate"; +import { createEmulator } from '@workos/emulate'; const emulator = await createEmulator({ port: 0, seed: { - users: [{ email: "test@example.com", password: "secret" }], + users: [{ email: 'test@example.com', password: 'secret' }], }, }); @@ -137,6 +137,72 @@ permissions: name: Write Posts ``` +## Testing Your Login Flow End-to-End + +The emulator implements the full [workos.com/docs](https://workos.com/docs) login story: every resource creation and authentication outcome fires a signed webhook, with event names and payload shapes generated from the WorkOS OpenAPI spec. You can run your app's entire login flow — hosted authorize, callback, token exchange, webhook handling — against the emulator without touching the real API. + +### 1. Register a webhook endpoint + +Seed it (an empty `events` list subscribes to everything): + +```yaml +webhookEndpoints: + - endpoint_url: http://localhost:5005/webhooks + events: [] +``` + +Or register at runtime and choose your own signing secret: + +```bash +curl -X POST http://localhost:4100/webhook_endpoints \ + -H "Authorization: Bearer sk_test_default" \ + -H "Content-Type: application/json" \ + -d '{"endpoint_url":"http://localhost:5005/webhooks","secret":"whsec_test","events":[]}' +``` + +### 2. Walk the login flow + +Point your SDK's base URL at the emulator and follow the AuthKit quickstart exactly as documented: + +1. **Create a user** — `POST /user_management/users` → a `user.created` webhook arrives. +2. **Redirect to AuthKit** — send the browser to `GET /user_management/authorize?redirect_uri=...&state=...`. By default the emulator immediately redirects back to your callback with a `code`; with `--interactive` it serves a real login page first. +3. **Exchange the code** — your callback calls `POST /user_management/authenticate` with `grant_type=authorization_code`. You get back the user, `access_token`, and `refresh_token` — and `session.created` plus `authentication.oauth_succeeded` webhooks arrive. +4. **Other methods work the same way** — password, Magic Auth, email verification, MFA, and SSO logins all emit their spec-named `authentication.*_succeeded` events; failed attempts emit `authentication.*_failed` with an `error: { code, message }` object. + +Codes that WorkOS would deliver by email are delivered to you in the webhook payload instead: `magic_auth.created` carries the Magic Auth `code`, `password_reset.created` carries the reset `token`, and `email_verification.created` carries the verification `code`. Your test can drive the whole flow from webhooks alone — see `src/e2e.spec.ts` for a complete worked example. + +### 3. Verify signatures + +Webhooks are signed exactly like production WorkOS: `WorkOS-Signature: t=,v1=` where the HMAC-SHA256 is computed over `"{timestamp}.{body}"` with the endpoint's secret. The official SDKs' `webhooks.constructEvent` verifies them unchanged. + +### Emitted events + +Authentication events carry the spec payload `{ type, status, user_id, email, ip_address, user_agent }` (plus `error` on failures and `sso` details on SSO events). + +| Trigger | Events | +| -------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Login success (per method) | `authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_succeeded` | +| Login failure (bad/expired credential) | `authentication.{oauth,password,magic_auth,email_verification,mfa,sso}_failed` | +| Sessions | `session.created`, `session.revoked` | +| Users | `user.created`, `user.updated`, `user.deleted` | +| Login-flow resources | `magic_auth.created`, `email_verification.created`, `password_reset.created`, `password_reset.succeeded` | +| Organizations & domains | `organization.*`, `organization_domain.*` (incl. `organization_domain.verified`) | +| Memberships & invitations | `organization_membership.*`, `invitation.{created,accepted,revoked,resent}` | +| Connections | `connection.activated`, `connection.deactivated`, `connection.deleted` | +| Directory Sync | `dsync.activated`, `dsync.deleted`, `dsync.user.*`, `dsync.group.*` | +| Roles & permissions | `role.*`, `organization_role.*`, `permission.*` | +| API keys & feature flags | `api_key.{created,updated,revoked}`, `flag.{created,updated,deleted}` | + +The full catalog (including names the emulator never emits, like `authentication.passkey_*` and `vault.*`) lives in `src/workos/generated/events.ts`, generated from the [`@workos/openapi-spec`](https://www.npmjs.com/package/@workos/openapi-spec) package. + +All events are also queryable at `GET /events` (filter with `?events[]=user.created`). + +### Caveats + +- Delivery is fire-and-forget with a 5-second timeout and no retries — poll your receiver in tests rather than asserting immediately. +- Resources defined in a seed file record events (visible at `GET /events`) but are not delivered to webhook endpoints from the same seed file — endpoints are registered last, mirroring real WorkOS, where pre-existing data never replays. Register endpoints via the API if you want deliveries for setup data. +- `dsync.group.user_added` / `dsync.group.user_removed` are catalogued but never emitted: the emulator has no directory group membership mutation surface. + ## Error Hooks Error hooks let you force the emulator to return non-200 responses so you can test how your app handles WorkOS API failures (422, 500, etc.). @@ -151,19 +217,19 @@ errorHooks: path: /user_management/users status: 422 body: - message: "Validation failed" - code: "unprocessable_entity" + message: 'Validation failed' + code: 'unprocessable_entity' errors: - field: email code: invalid - message: "must be a valid email" + message: 'must be a valid email' - method: GET path: /user_management/users status: 500 # Fail the first 3 requests, then let them through - - method: "*" + - method: '*' path: /organizations status: 503 count: 3 @@ -201,10 +267,10 @@ const emulator = await createEmulator({ port: 0 }); // Make user creation return a 422 const hook = emulator.addErrorHook({ - method: "POST", - path: "/user_management/users", + method: 'POST', + path: '/user_management/users', status: 422, - body: { message: "Email is invalid", code: "unprocessable_entity" }, + body: { message: 'Email is invalid', code: 'unprocessable_entity' }, }); // Your app code under test handles the error... @@ -233,11 +299,9 @@ workos-emulate --interactive --seed workos-emulate.config.yaml const emulator = await createEmulator({ interactiveAuth: true, seed: { - users: [{ email: "test@example.com", password: "secret" }], - connections: [ - { name: "Test SSO", organization: "Acme", domains: ["example.com"] }, - ], - organizations: [{ name: "Acme" }], + users: [{ email: 'test@example.com', password: 'secret' }], + connections: [{ name: 'Test SSO', organization: 'Acme', domains: ['example.com'] }], + organizations: [{ name: 'Acme' }], }, }); ``` @@ -261,12 +325,12 @@ The `login_hint` parameter pre-fills the email field, so agent browsers can skip ### E2E example with Playwright ```ts -test("SSO login flow", async ({ page }) => { - await page.goto("http://localhost:3000/login"); - await page.click("text=Sign in with SSO"); +test('SSO login flow', async ({ page }) => { + await page.goto('http://localhost:3000/login'); + await page.click('text=Sign in with SSO'); // Emulator serves the login page - await page.fill('input[name="email"]', "alice@example.com"); + await page.fill('input[name="email"]', 'alice@example.com'); await page.click('button[type="submit"]'); // Redirected back to your app with a valid session From 5f19b2f9cd7a701d5cab1bd4f0f958c22043901c Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 10 Jun 2026 17:07:23 -0400 Subject: [PATCH 6/8] ci: add oxlint lint workflow The repo had the oxfmt formatter but no linter and no CI gate enforcing either linting or formatting. oxlint (oxc's linter, pairing with the oxfmt already in use) now runs alongside the format check on every push and PR, so correctness regressions like unused code can't merge unnoticed. The dead-code removals in the spec and source files are the findings oxlint surfaced on its first run; they ship in this commit so the new workflow lands green instead of red. --- .github/workflows/lint.yml | 26 +++ .oxlintrc.json | 11 + package-lock.json | 397 +++++++++++++++++++++++++++++++++ package.json | 3 + src/core/error-hooks.spec.ts | 2 +- src/workos/index.ts | 2 +- src/workos/routes/auth.spec.ts | 1 - src/workos/routes/sso.spec.ts | 2 - 8 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .oxlintrc.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..d66121c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + cache: npm + + - name: Install + run: npm ci + + - name: Lint + run: npm run lint + + - name: Format check + run: npm run fmt:check diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..31e054f --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc"], + "categories": { + "correctness": "error" + }, + "rules": {}, + "env": { + "builtin": true + } +} diff --git a/package-lock.json b/package-lock.json index e9745d0..2502990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@vitest/coverage-v8": "^4.0.18", "@workos/openapi-spec": "^0.6.0", "oxfmt": "^0.54.0", + "oxlint": "^1.69.0", "tsx": "^4.20.3", "typescript": "^5.9.3", "vitest": "^4.0.18" @@ -992,6 +993,353 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.69.0.tgz", + "integrity": "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.69.0.tgz", + "integrity": "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.69.0.tgz", + "integrity": "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.69.0.tgz", + "integrity": "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.69.0.tgz", + "integrity": "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.69.0.tgz", + "integrity": "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.69.0.tgz", + "integrity": "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.69.0.tgz", + "integrity": "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.69.0.tgz", + "integrity": "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.69.0.tgz", + "integrity": "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.69.0.tgz", + "integrity": "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.69.0.tgz", + "integrity": "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.69.0.tgz", + "integrity": "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.69.0.tgz", + "integrity": "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.69.0.tgz", + "integrity": "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.69.0.tgz", + "integrity": "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.69.0.tgz", + "integrity": "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.69.0.tgz", + "integrity": "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.69.0.tgz", + "integrity": "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@redocly/ajv": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.18.3.tgz", @@ -2384,6 +2732,55 @@ } } }, + "node_modules/oxlint": { + "version": "1.69.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.69.0.tgz", + "integrity": "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.69.0", + "@oxlint/binding-android-arm64": "1.69.0", + "@oxlint/binding-darwin-arm64": "1.69.0", + "@oxlint/binding-darwin-x64": "1.69.0", + "@oxlint/binding-freebsd-x64": "1.69.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.69.0", + "@oxlint/binding-linux-arm-musleabihf": "1.69.0", + "@oxlint/binding-linux-arm64-gnu": "1.69.0", + "@oxlint/binding-linux-arm64-musl": "1.69.0", + "@oxlint/binding-linux-ppc64-gnu": "1.69.0", + "@oxlint/binding-linux-riscv64-gnu": "1.69.0", + "@oxlint/binding-linux-riscv64-musl": "1.69.0", + "@oxlint/binding-linux-s390x-gnu": "1.69.0", + "@oxlint/binding-linux-x64-gnu": "1.69.0", + "@oxlint/binding-linux-x64-musl": "1.69.0", + "@oxlint/binding-openharmony-arm64": "1.69.0", + "@oxlint/binding-win32-arm64-msvc": "1.69.0", + "@oxlint/binding-win32-ia32-msvc": "1.69.0", + "@oxlint/binding-win32-x64-msvc": "1.69.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", diff --git a/package.json b/package.json index eba5492..965c457 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "@vitest/coverage-v8": "^4.0.18", "@workos/openapi-spec": "^0.6.0", "oxfmt": "^0.54.0", + "oxlint": "^1.69.0", "tsx": "^4.20.3", "typescript": "^5.9.3", "vitest": "^4.0.18" @@ -67,6 +68,8 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", + "lint": "oxlint", + "lint:fix": "oxlint --fix", "fmt": "oxfmt", "fmt:check": "oxfmt --check", "gen:routes": "tsx scripts/gen-routes.ts", diff --git a/src/core/error-hooks.spec.ts b/src/core/error-hooks.spec.ts index 62f5526..e0827af 100644 --- a/src/core/error-hooks.spec.ts +++ b/src/core/error-hooks.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createServer, type ApiKeyMap } from './index.js'; import { workosPlugin } from '../workos/index.js'; -import { addErrorHook, getErrorHooks, removeErrorHook, setErrorHooks } from './error-hooks.js'; +import { addErrorHook, getErrorHooks, removeErrorHook } from './error-hooks.js'; import type { Store } from './store.js'; const apiKeys: ApiKeyMap = { sk_test_hooks: { environment: 'test' } }; diff --git a/src/workos/index.ts b/src/workos/index.ts index b08e3d7..3dcebf5 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import type { ServicePlugin, Store, RouteContext } from '../core/index.js'; import { generateId } from '../core/index.js'; -import { getWorkOSStore, type WorkOSStore } from './store.js'; +import { getWorkOSStore } from './store.js'; import { organizationRoutes } from './routes/organizations.js'; import { organizationDomainRoutes } from './routes/organization-domains.js'; import { membershipRoutes } from './routes/memberships.js'; diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 18db1c3..a394533 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -488,7 +488,6 @@ describe('AuthKit interactive auth', () => { store.setData(STORE_KEYS.interactiveAuth, true); }); - const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); const json = (res: Response) => res.json() as Promise; it('GET /user_management/authorize returns HTML login page', async () => { diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index cbfb2e7..678c8a2 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -14,12 +14,10 @@ function createTestApp() { describe('SSO routes', () => { let app: ReturnType['app']; - let store: Store; beforeEach(() => { const server = createTestApp(); app = server.app; - store = server.store; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); From f6c83094db074c2167e897f56faa8d7a6a2890e8 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 10 Jun 2026 18:56:20 -0400 Subject: [PATCH 7/8] fix(auth): don't emit a login event on token refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh_token grant set authMethod = 'OAuth' and fell through to the shared success path, so every silent token rotation emitted an authentication.oauth_succeeded webhook. A refresh is not a login, so this was a false positive for any consumer counting authentication events. Gate the emission behind a fresh-login flag that the refresh grant clears. Also pin MFA and email-verification sessions to auth_method 'unknown' explicitly. The spec's session auth_method enum has no value for either (MFA is a second factor, not a primary method), so the prior silent fallthrough was correct but undocumented — and the obvious "fix" of adding 'mfa'/'email_verification' would emit out-of-enum values. The comment and explicit entries guard against that regression. Both address Greptile review feedback on #2. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/workos/helpers.ts | 13 ++++- src/workos/routes/auth.spec.ts | 88 ++++++++++++++++++++++++++++++++++ src/workos/routes/auth.ts | 7 ++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index a227368..4ec115e 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -113,12 +113,23 @@ export const AUTH_METHOD_EVENT_TYPES: Record = { SSO: 'sso', }; -/** Maps authentication_method values to the session `auth_method` enum (note: magic_code, not magic_auth). */ +/** + * Maps authentication_method values to the session `auth_method` enum (note: magic_code, not magic_auth). + * + * MFA and EmailVerification map to 'unknown' on purpose: the spec's session auth_method enum + * (cross_app_auth, external_auth, impersonation, magic_code, migrated_session, oauth, passkey, + * password, sso, unknown) has no value for either. MFA is a second factor rather than a primary + * method, and email verification has no analogue at all. Reporting the originating primary factor + * would require recording it through the pending-auth flow, which the emulator does not yet do — + * left as follow-up. 'unknown' is a valid enum member, so consumers that validate the field pass. + */ export const AUTH_METHOD_SESSION_VALUES: Record = { OAuth: 'oauth', Password: 'password', MagicAuth: 'magic_code', SSO: 'sso', + MFA: 'unknown', + EmailVerification: 'unknown', }; /** authentication.* event names per method, resolved from the spec-generated catalog. */ diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index a394533..9aa3b11 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -770,4 +770,92 @@ describe('authentication events (spec-named, spec-shaped)', () => { expect(event.data).toMatchObject({ auth_method: 'password', status: 'active', ended_at: null }); expect(event.data.expires_at).toBeTruthy(); }); + + it('MFA-completed sessions report auth_method: unknown (no spec enum value)', async () => { + const user = await registerUser('evt-mfa@test.com', 'secret'); + const ws = getWorkOSStore(store); + + const factor = ws.authFactors.insert({ + object: 'authentication_factor', + user_id: user.id, + type: 'totp', + totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + }); + const challenge = ws.authChallenges.insert({ + object: 'authentication_challenge', + user_id: user.id, + factor_id: factor.id, + expires_at: new Date(Date.now() + 600000).toISOString(), + code: '123456', + }); + const pendingToken = 'pending_evt_mfa'; + store.setData(`pending_auth:${pendingToken}`, { user_id: user.id, organization_id: null, auth_method: 'MFA' }); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:mfa-totp', + code: '123456', + pending_authentication_token: pendingToken, + authentication_challenge_id: challenge.id, + }), + }); + + // No 'mfa' value exists in the spec session auth_method enum; we report the valid 'unknown'. + const [session] = eventsNamed('session.created'); + expect(session).toBeDefined(); + expect(session.data).toMatchObject({ auth_method: 'unknown' }); + }); + + it('email-verification sessions report auth_method: unknown (no spec enum value)', async () => { + const user = await registerUser('evt-verify-session@test.com', 'secret'); + + const sendRes = await req(`/user_management/users/${user.id}/email_verification/send`, { method: 'POST' }); + const verification = await json(sendRes); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:email-verification:code', + code: verification.code, + user_id: user.id, + }), + }); + + const [session] = eventsNamed('session.created'); + expect(session).toBeDefined(); + expect(session.data).toMatchObject({ auth_method: 'unknown' }); + }); + + it('token refresh does not emit an authentication event', async () => { + await registerUser('evt-refresh@test.com', 'secret'); + + const loginRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-refresh@test.com', password: 'secret' }), + }); + const { refresh_token } = await json(loginRes); + + const authEventsAfterLogin = getWorkOSStore(store) + .events.all() + .filter((e) => e.event.startsWith('authentication.')).length; + + const refreshRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'refresh_token', refresh_token }), + }); + expect(refreshRes.status).toBe(200); + + // A rotation is not a fresh login, so it must add no authentication.* event... + const authEventsAfterRefresh = getWorkOSStore(store) + .events.all() + .filter((e) => e.event.startsWith('authentication.')).length; + expect(authEventsAfterRefresh).toBe(authEventsAfterLogin); + // ...and specifically no spurious oauth_succeeded, which the OAuth authMethod would otherwise fire. + expect(eventsNamed('authentication.oauth_succeeded')).toHaveLength(0); + }); }); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 63250c4..0f689d0 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -192,6 +192,10 @@ export function authRoutes(ctx: RouteContext): void { let user; let organizationId: string | null = null; let authMethod: string; + // A token refresh rotates credentials for an existing session; it is not a fresh + // login, so it must not emit an authentication.*_succeeded event. Grants that are + // genuine authentications leave this true; refresh_token flips it off. + let isFreshLogin = true; switch (grantType) { case 'authorization_code': { @@ -336,6 +340,7 @@ export function authRoutes(ctx: RouteContext): void { // Rotate: delete old, issue new below ws.refreshTokens.delete(refreshToken.id); authMethod = 'OAuth'; + isFreshLogin = false; break; } @@ -517,7 +522,7 @@ export function authRoutes(ctx: RouteContext): void { // Emit authentication event (hybrid Option B for action-specific events) const eventBus = store.getData(STORE_KEYS.eventBus); const succeededEvent = AUTH_EVENTS[authMethod]?.succeeded; - if (eventBus && succeededEvent) { + if (eventBus && succeededEvent && isFreshLogin) { eventBus.emit({ event: succeededEvent, data: buildAuthenticationEventData({ From 55f1d48811f2be738d44e4f66d6d17d71dfa8692 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Wed, 10 Jun 2026 22:46:43 -0400 Subject: [PATCH 8/8] fix(auth): rotate refresh within session; record MFA primary factor Refresh reused the shared session-creation path, so every token rotation inserted a new session (firing session.created) and minted a fresh session/refresh token instead of rotating within the existing one. Refresh now reuses the session referenced by the old token and emits no session.created; a revoked session can no longer be refreshed (it returns invalid_grant rather than resurrecting itself). For MFA, the session auth_method now records the primary factor the pending token was issued for (e.g. password) rather than the second factor, while the event stays authentication.mfa_succeeded. To make the flow reachable, a password login for a user with enrolled factors now returns a pending token + challenge (mfa_challenge) instead of a session, so completing mfa-totp yields a session keyed to the primary factor. The spec documents the mfa_challenge code but not the response body that carries pending_authentication_token; that shape mirrors WorkOS. Follow-up to Greptile review on #2. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/workos/helpers.ts | 12 ++--- src/workos/routes/auth.spec.ts | 61 +++++++++++++++++++-- src/workos/routes/auth.ts | 98 ++++++++++++++++++++++++++++------ 3 files changed, 144 insertions(+), 27 deletions(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 4ec115e..1b50ebf 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -116,12 +116,12 @@ export const AUTH_METHOD_EVENT_TYPES: Record = { /** * Maps authentication_method values to the session `auth_method` enum (note: magic_code, not magic_auth). * - * MFA and EmailVerification map to 'unknown' on purpose: the spec's session auth_method enum - * (cross_app_auth, external_auth, impersonation, magic_code, migrated_session, oauth, passkey, - * password, sso, unknown) has no value for either. MFA is a second factor rather than a primary - * method, and email verification has no analogue at all. Reporting the originating primary factor - * would require recording it through the pending-auth flow, which the emulator does not yet do — - * left as follow-up. 'unknown' is a valid enum member, so consumers that validate the field pass. + * The spec's session auth_method enum (cross_app_auth, external_auth, impersonation, magic_code, + * migrated_session, oauth, passkey, password, sso, unknown) has no value for MFA or email + * verification. An MFA completion normally records its *primary* factor instead (e.g. 'password'), + * resolved from the pending-auth token via sessionAuthMethod in the authenticate handler. The MFA + * and EmailVerification entries here are fallbacks: when no primary method is known they resolve to + * 'unknown' — a valid enum member, so consumers that validate the field still pass. */ export const AUTH_METHOD_SESSION_VALUES: Record = { OAuth: 'oauth', diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 9aa3b11..0c32aea 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -771,7 +771,7 @@ describe('authentication events (spec-named, spec-shaped)', () => { expect(event.data.expires_at).toBeTruthy(); }); - it('MFA-completed sessions report auth_method: unknown (no spec enum value)', async () => { + it('MFA session falls back to auth_method: unknown when the pending token records no mapped primary', async () => { const user = await registerUser('evt-mfa@test.com', 'secret'); const ws = getWorkOSStore(store); @@ -802,7 +802,8 @@ describe('authentication events (spec-named, spec-shaped)', () => { }), }); - // No 'mfa' value exists in the spec session auth_method enum; we report the valid 'unknown'. + // The pending token here records only 'MFA' (not a primary factor), so the session falls + // back to the valid 'unknown' rather than an out-of-enum value like 'mfa'. const [session] = eventsNamed('session.created'); expect(session).toBeDefined(); expect(session.data).toMatchObject({ auth_method: 'unknown' }); @@ -829,7 +830,7 @@ describe('authentication events (spec-named, spec-shaped)', () => { expect(session.data).toMatchObject({ auth_method: 'unknown' }); }); - it('token refresh does not emit an authentication event', async () => { + it('token refresh rotates tokens without emitting login or session events', async () => { await registerUser('evt-refresh@test.com', 'secret'); const loginRes = await app.request('/user_management/authenticate', { @@ -849,13 +850,65 @@ describe('authentication events (spec-named, spec-shaped)', () => { body: JSON.stringify({ grant_type: 'refresh_token', refresh_token }), }); expect(refreshRes.status).toBe(200); + // Rotation still hands back fresh tokens. + expect((await json(refreshRes)).refresh_token).toBeTruthy(); // A rotation is not a fresh login, so it must add no authentication.* event... const authEventsAfterRefresh = getWorkOSStore(store) .events.all() .filter((e) => e.event.startsWith('authentication.')).length; expect(authEventsAfterRefresh).toBe(authEventsAfterLogin); - // ...and specifically no spurious oauth_succeeded, which the OAuth authMethod would otherwise fire. + // ...no spurious oauth_succeeded, which the OAuth authMethod would otherwise fire... expect(eventsNamed('authentication.oauth_succeeded')).toHaveLength(0); + // ...and it reuses the existing session rather than minting a new one. + expect(eventsNamed('session.created')).toHaveLength(1); + expect(getWorkOSStore(store).sessions.all()).toHaveLength(1); + }); + + it('password login for an MFA-enrolled user challenges, then keys the session to the primary factor', async () => { + const user = await registerUser('evt-mfa-flow@test.com', 'secret'); + const ws = getWorkOSStore(store); + ws.authFactors.insert({ + object: 'authentication_factor', + user_id: user.id, + type: 'totp', + totp: { issuer: 'Test', user: user.email, uri: 'otpauth://...' }, + }); + + // First factor: password returns an mfa_challenge carrying a pending token + challenge, + // and creates neither a session nor a login event. + const challengeRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-mfa-flow@test.com', password: 'secret' }), + }); + expect(challengeRes.status).toBe(403); + const challengeBody = await json(challengeRes); + expect(challengeBody.code).toBe('mfa_challenge'); + expect(challengeBody.pending_authentication_token).toBeTruthy(); + expect(challengeBody.authentication_challenge.id).toBeTruthy(); + expect(eventsNamed('session.created')).toHaveLength(0); + expect(eventsNamed('authentication.password_succeeded')).toHaveLength(0); + + // Second factor: completing mfa-totp issues the session (code read from the store, since + // the spec excludes it from the challenge response). + const challengeId = challengeBody.authentication_challenge.id as string; + const code = ws.authChallenges.get(challengeId)!.code!; + const mfaRes = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:mfa-totp', + code, + pending_authentication_token: challengeBody.pending_authentication_token, + authentication_challenge_id: challengeId, + }), + }); + expect(mfaRes.status).toBe(200); + + // The event is mfa_succeeded, but the session records the primary factor (password). + expect(eventsNamed('authentication.mfa_succeeded')).toHaveLength(1); + const [session] = eventsNamed('session.created'); + expect(session.data).toMatchObject({ auth_method: 'password' }); }); }); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 0f689d0..0b72a27 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -12,6 +12,8 @@ import { AUTH_EVENTS, AUTH_METHOD_SESSION_VALUES, buildAuthenticationEventData, + generateCode, + formatAuthChallenge, } from '../helpers.js'; import type { EventBus } from '../event-bus.js'; import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; @@ -189,13 +191,55 @@ export function authRoutes(ctx: RouteContext): void { throw error; }; + /** + * Initiate the MFA second factor. Records the primary method on a pending-auth token so + * the eventual session reports it (not 'unknown'), creates a challenge for the factor, and + * returns the spec's `mfa_challenge` code plus the fields a client needs to complete the + * urn:workos:oauth:grant-type:mfa-totp grant. (The spec documents the mfa_challenge code but + * not this response body; the pending_authentication_token/challenge fields mirror WorkOS.) + */ + const issueMfaChallenge = ( + mfaUser: { id: string }, + orgId: string | null, + primaryMethod: string, + factor: { id: string }, + ) => { + const pendingToken = generateId('pending'); + store.setData(`${STORE_KEY_PREFIXES.pendingAuth}${pendingToken}`, { + user_id: mfaUser.id, + organization_id: orgId, + auth_method: primaryMethod, + }); + const challenge = ws.authChallenges.insert({ + object: 'authentication_challenge', + user_id: mfaUser.id, + factor_id: factor.id, + expires_at: expiresIn(10), + code: generateCode(), + }); + return c.json( + { + code: 'mfa_challenge', + message: 'Multi-factor authentication is required to continue.', + pending_authentication_token: pendingToken, + authentication_challenge: formatAuthChallenge(challenge), + }, + 403, + ); + }; + let user; let organizationId: string | null = null; let authMethod: string; - // A token refresh rotates credentials for an existing session; it is not a fresh - // login, so it must not emit an authentication.*_succeeded event. Grants that are - // genuine authentications leave this true; refresh_token flips it off. + // The session's auth_method can differ from the event method: an MFA completion emits + // authentication.mfa_succeeded but the session records the primary factor that was + // challenged (e.g. 'password'). Left undefined, the session falls back to authMethod. + let sessionAuthMethod: string | undefined; + // A token refresh rotates credentials for an existing session; it is not a fresh login, + // so it creates no new session and emits no authentication.*_succeeded event. Genuine + // authentications leave this true; refresh_token flips it off and sets refreshSessionId. let isFreshLogin = true; + let refreshSessionId: string | null = null; switch (grantType) { case 'authorization_code': { @@ -256,6 +300,13 @@ export function authRoutes(ctx: RouteContext): void { ); } authMethod = 'Password'; + + // A user with enrolled factors must clear a second factor before a session is issued: + // hand back a pending token (recording 'Password' as the primary method) and a challenge. + const passwordFactors = ws.authFactors.findBy('user_id', user.id); + if (passwordFactors.length > 0) { + return issueMfaChallenge(user, organizationId, 'Password', passwordFactors[0]); + } break; } @@ -337,7 +388,9 @@ export function authRoutes(ctx: RouteContext): void { // Allow body.organization_id to switch org context (switchToOrganization) organizationId = (body.organization_id as string) ?? refreshToken.organization_id; - // Rotate: delete old, issue new below + // Rotate within the existing session: capture it for reuse, delete the old token, + // and issue a new one below — no new session, no authentication event. + refreshSessionId = refreshToken.session_id; ws.refreshTokens.delete(refreshToken.id); authMethod = 'OAuth'; isFreshLogin = false; @@ -389,7 +442,10 @@ export function authRoutes(ctx: RouteContext): void { user = ws.users.get(pending.user_id); organizationId = pending.organization_id; + // Event is authentication.mfa_succeeded; the session records the primary factor the + // pending token was issued for (MFA is a second factor, not a session auth method). authMethod = 'MFA'; + sessionAuthMethod = pending.auth_method; break; } @@ -451,21 +507,29 @@ export function authRoutes(ctx: RouteContext): void { if (!user) throw notFound('User'); - ws.users.update(user.id, { last_sign_in_at: new Date().toISOString() }); + // A fresh login creates a new session (firing session.created); a refresh_token rotation + // reuses the existing session, so it emits neither session.created nor an auth event. + let session; + if (isFreshLogin) { + ws.users.update(user.id, { last_sign_in_at: new Date().toISOString() }); + session = ws.sessions.insert({ + object: 'session', + user_id: user.id, + organization_id: organizationId, + ip_address: requestIp, + user_agent: requestUserAgent, + auth_method: AUTH_METHOD_SESSION_VALUES[sessionAuthMethod ?? authMethod] ?? 'unknown', + status: 'active', + expires_at: expiresIn(30 * 24 * 60), // matches refresh token lifetime + ended_at: null, + }); + } else { + const existing = refreshSessionId ? ws.sessions.get(refreshSessionId) : undefined; + if (!existing) throw new WorkOSApiError(400, 'Invalid refresh token', 'invalid_grant'); + session = existing; + } const updatedUser = ws.users.get(user.id)!; - const session = ws.sessions.insert({ - object: 'session', - user_id: user.id, - organization_id: organizationId, - ip_address: requestIp, - user_agent: requestUserAgent, - auth_method: AUTH_METHOD_SESSION_VALUES[authMethod] ?? 'unknown', - status: 'active', - expires_at: expiresIn(30 * 24 * 60), // matches refresh token lifetime - ended_at: null, - }); - // Resolve role + permissions for org-scoped sessions let roleSlug: string | undefined; let permissionSlugs: string[] | undefined;