diff --git a/package-lock.json b/package-lock.json index 5e4da7ec..717245a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,9 +30,9 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -65,13 +65,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.28.6" }, "bin": { "parser": "bin/babel-parser.js" @@ -91,9 +91,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", "dev": true, "license": "MIT", "dependencies": { @@ -119,8 +119,8 @@ "resolved": "packages/databricks", "link": true }, - "node_modules/@databricks/sdk-examples": { - "resolved": "packages/examples", + "node_modules/@databricks/sdk-databricks": { + "resolved": "packages/databricks", "link": true }, "node_modules/@esbuild/aix-ppc64": { @@ -798,6 +798,34 @@ } } }, + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@inquirer/figures": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", @@ -979,9 +1007,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.41.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", - "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.40.0.tgz", + "integrity": "sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1447,6 +1475,41 @@ "node": ">=18" } }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/user-event": { "version": "14.6.1", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", @@ -1476,9 +1539,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", - "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", "dependencies": { @@ -1802,6 +1865,57 @@ } } }, + "node_modules/@vitest/coverage-v8/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -2120,24 +2234,6 @@ "node": ">=12" } }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2685,6 +2781,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2693,9 +2802,9 @@ "license": "ISC" }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2734,7 +2843,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", "dependencies": { @@ -2991,21 +3100,6 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-reports": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", @@ -3131,13 +3225,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -3230,15 +3317,15 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.12.10", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", - "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.7.tgz", + "integrity": "sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.41.2", + "@mswjs/interceptors": "^0.40.0", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", @@ -3248,7 +3335,7 @@ "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", - "rettime": "^0.10.1", + "rettime": "^0.7.0", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", @@ -3275,9 +3362,9 @@ } }, "node_modules/msw/node_modules/type-fest": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", - "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.3.tgz", + "integrity": "sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA==", "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { @@ -3460,6 +3547,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -3505,13 +3599,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.58.1" }, "bin": { "playwright": "cli.js" @@ -3524,9 +3618,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3536,6 +3630,22 @@ "node": ">=18" } }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3591,34 +3701,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3650,13 +3732,6 @@ ], "license": "MIT" }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3688,9 +3763,9 @@ } }, "node_modules/rettime": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", - "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.7.0.tgz", + "integrity": "sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==", "dev": true, "license": "MIT" }, @@ -3792,9 +3867,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -3834,19 +3909,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -4000,76 +4062,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/test-exclude/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4139,22 +4131,22 @@ } }, "node_modules/tldts": { - "version": "7.0.24", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", - "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "version": "7.0.21", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.21.tgz", + "integrity": "sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.24" + "tldts-core": "^7.0.21" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.24", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", - "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "version": "7.0.21", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.21.tgz", + "integrity": "sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==", "dev": true, "license": "MIT" }, @@ -4812,21 +4804,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/vitest": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", @@ -4937,9 +4914,9 @@ } }, "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4948,7 +4925,10 @@ "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrap-ansi-cjs": { @@ -5099,17 +5079,15 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "zod": "^4.3.6" + "@databricks/sdk-auth": "0.1.0" }, "devDependencies": { - "@types/node": "^22.0.0", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@vitest/browser": "^2.1.0", + "@types/node": "^20.11.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0", "@vitest/coverage-v8": "^2.1.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", - "playwright": "^1.48.0", "prettier": "^3.2.0", "typescript": "^5.7.0", "vitest": "^2.1.0" diff --git a/packages/auth/package.json b/packages/auth/package.json index b918d012..9d412c49 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -8,7 +8,13 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/index.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "import": "./dist/browser.js", + "require": "./dist/browser.js" } }, "files": [ @@ -22,7 +28,10 @@ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", "test": "vitest run", + "test:node": "vitest run", "test:browser": "vitest run --config vitest.config.browser.ts", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index f64e5eba..e957e9ce 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -3,8 +3,6 @@ * * This module is not meant to be used directly by consumers of the SDK * and is subject to change without notice. - * - * @packageDocumentation */ /** @@ -31,8 +29,7 @@ export interface Credentials { export interface Token { /** * The raw value to sign requests with. - * It typically is an access token but can represent other types of tokens - * (e.g., ID tokens). + * It typically is an access token but can represent other types of tokens (e.g., ID tokens). */ value: string; @@ -54,8 +51,7 @@ export interface Token { export interface TokenProvider { /** * Returns a token or throws an error. - * The returned Token should be considered immutable and should not be - * modified. + * The returned Token should be considered immutable and should not be modified. */ token(): Promise; } @@ -76,8 +72,7 @@ export function tokenProviderFn(fn: () => Promise): TokenProvider { export interface TokenCredentials extends TokenProvider, Credentials {} /** - * Creates a TokenCredentials that uses the given TokenProvider to return - * authentication headers. + * Creates a TokenCredentials that uses the given TokenProvider to return authentication headers. */ export function newTokenCredentials(provider: TokenProvider): TokenCredentials { return new TokenCredentialsImpl(provider); @@ -90,13 +85,13 @@ class TokenCredentialsImpl implements TokenCredentials { this.provider = provider; } - async token(): Promise { - return this.provider.token(); + async authHeaders(): Promise { + const token = await this.provider.token(); + const tokenType = token.type ?? 'Bearer'; + return [{key: 'Authorization', value: `${tokenType} ${token.value}`}]; } - async authHeaders(): Promise { - const t = await this.token(); - const scheme = t.type ?? 'Bearer'; - return [{key: 'Authorization', value: `${scheme} ${t.value}`}]; + async token(): Promise { + return this.provider.token(); } } diff --git a/packages/auth/src/browser.ts b/packages/auth/src/browser.ts new file mode 100644 index 00000000..0ad6d1f5 --- /dev/null +++ b/packages/auth/src/browser.ts @@ -0,0 +1,57 @@ +/** + * Browser-compatible subset of the Databricks authentication library. + * + * This entry point only exports modules that work in browser environments. + * For Node.js-only features (file-based tokens, environment variables), + * use the main entry point instead. + * + * @example + * ```typescript + * // Browser-safe imports + * import { newPatCredentials, newDatabricksOidcTokenProvider } from '@databricks/sdk-auth/browser'; + * ``` + * + * @packageDocumentation + */ + +// Core authentication types and utilities - all browser compatible. +export type { + Header, + Token, + Credentials, + TokenProvider, + TokenCredentials, +} from './auth'; +export {tokenProviderFn, newTokenCredentials} from './auth'; + +// Token caching - browser compatible. +export type {CachedTokenProviderOptions} from './cache'; +export {newCachedTokenProvider} from './cache'; + +// PAT credentials - browser compatible. +export {newPatCredentials, TokenRequiredError} from './credentials'; + +// OIDC utilities - browser compatible subset only. +export type {IdToken, IdTokenProvider} from './oidc/oidc'; +export {idTokenProviderFn} from './oidc/oidc'; + +// Databricks OIDC token provider - browser compatible. +export type { + OAuthAuthorizationServer, + DatabricksOidcTokenProviderConfig, + HttpClient as OidcHttpClient, +} from './oidc/tokensource'; +export {newDatabricksOidcTokenProvider} from './oidc/tokensource'; + +// GitHub OIDC - browser compatible (requires injected HTTP client). +export type {HttpClient as GithubHttpClient} from './oidc/github'; +export {newGithubIdTokenProvider} from './oidc/github'; + +// Data plane - browser compatible. +export type {OAuthClient, EndpointTokenProvider} from './dataplane'; +export {newEndpointTokenProvider} from './dataplane'; + +// NOTE: The following are NOT exported from browser entry point: +// - newEnvIdTokenProvider (uses process.env) +// - newFileTokenProvider (uses fs/promises) +// - newAzureDevOpsIdTokenProvider (uses process.env) diff --git a/packages/auth/src/cache.ts b/packages/auth/src/cache.ts new file mode 100644 index 00000000..75cb1604 --- /dev/null +++ b/packages/auth/src/cache.ts @@ -0,0 +1,229 @@ +/** + * Token caching utilities for the Databricks SDK. + */ + +import type {Token, TokenProvider} from './auth'; + +/** + * Default duration for the stale period (3 minutes in milliseconds). + * The number has been set arbitrarily and might be changed in the future. + */ +const DEFAULT_STALE_DURATION_MS = 3 * 60 * 1000; + +/** + * Options for creating a cached token provider. + */ +export interface CachedTokenProviderOptions { + /** + * Initial token to be used by the cached token provider. + */ + cachedToken?: Token; + + /** + * Enables or disables the asynchronous token refresh. Default is true. + */ + asyncRefresh?: boolean; +} + +/** + * Token state represents the state of the token. + * - fresh: The token is valid. + * - stale: The token is valid but will expire soon. + * - expired: The token has expired and cannot be used. + * + * Token state through time: + * + * issue time expiry time + * v v + * | fresh | stale | expired -> time + * | valid | + */ +enum TokenState { + FRESH = 0, + STALE = 1, + EXPIRED = 2, +} + +/** + * Symbol to identify cached token providers. + */ +const CACHED_TOKEN_PROVIDER_SYMBOL = Symbol('CachedTokenProvider'); + +/** + * Wraps a TokenProvider to cache the tokens it returns. + * By default, the cache will refresh tokens asynchronously a few minutes before + * they expire. + * + * The token cache is safe for concurrent use and will guarantee that only one + * token refresh is triggered at a time. + * + * The token cache does not take care of retries in case the token source + * returns an error; it is the responsibility of the provided token source to + * handle retries appropriately. + * + * If the TokenProvider is already a cached token provider (obtained by calling this + * function), it is returned as is. + */ +export function newCachedTokenProvider( + provider: TokenProvider, + options?: CachedTokenProviderOptions +): TokenProvider { + // Avoid double caching of the token source. + if (isCachedTokenProvider(provider)) { + return provider; + } + + return new CachedTokenProvider(provider, options); +} + +function isCachedTokenProvider(provider: TokenProvider): boolean { + return ( + typeof provider === 'object' && CACHED_TOKEN_PROVIDER_SYMBOL in provider + ); +} + +class CachedTokenProvider implements TokenProvider { + // Symbol to identify this as a cached token provider. + readonly [CACHED_TOKEN_PROVIDER_SYMBOL] = true; + + private readonly provider: TokenProvider; + private readonly disableAsync: boolean; + private readonly staleDurationMs: number; + + private cachedToken: Token | null; + + // Indicates that an async refresh is in progress. + private isRefreshing = false; + + // Error returned by the last refresh. Async refreshes are disabled if this + // value is not null so that the cache does not continue sending requests to + // a potentially failing server. + private refreshError: Error | null = null; + + // Promise for the current blocking token fetch, if any. + private blockingPromise: Promise | null = null; + + // For testing purposes. + private timeNow: () => Date; + + constructor(provider: TokenProvider, options?: CachedTokenProviderOptions) { + this.provider = provider; + this.staleDurationMs = DEFAULT_STALE_DURATION_MS; + this.disableAsync = options?.asyncRefresh === false; + this.cachedToken = options?.cachedToken ?? null; + this.timeNow = (): Date => new Date(); + } + + async token(): Promise { + if (this.disableAsync) { + return this.blockingToken(); + } + return this.asyncToken(); + } + + private getTokenState(): TokenState { + if (!this.cachedToken) { + return TokenState.EXPIRED; + } + if (!this.cachedToken.expiry) { + return TokenState.FRESH; // No expiry means valid indefinitely. + } + const lifeSpanMs = + this.cachedToken.expiry.getTime() - this.timeNow().getTime(); + if (lifeSpanMs <= 0) { + return TokenState.EXPIRED; + } + if (lifeSpanMs <= this.staleDurationMs) { + return TokenState.STALE; + } + return TokenState.FRESH; + } + + private async asyncToken(): Promise { + const state = this.getTokenState(); + + // If token is FRESH or STALE, cachedToken is guaranteed to be non-null + // because getTokenState() returns EXPIRED when cachedToken is null. + if (state === TokenState.FRESH) { + return this.getCachedTokenOrThrow(); + } + if (state === TokenState.STALE) { + this.triggerAsyncRefresh(); + return this.getCachedTokenOrThrow(); + } + // Expired. + return this.blockingToken(); + } + + /** + * Returns the cached token or throws if it's null. + * This should only be called when the token state is FRESH or STALE. + */ + private getCachedTokenOrThrow(): Token { + if (this.cachedToken === null) { + throw new Error('cachedToken is null but state is not EXPIRED'); + } + return this.cachedToken; + } + + private async blockingToken(): Promise { + // Reset error state to recover from previous failed attempts. + this.isRefreshing = false; + this.refreshError = null; + + // Check if token was refreshed while waiting. + const state = this.getTokenState(); + if (state !== TokenState.EXPIRED) { + // Token is FRESH or STALE, so cachedToken is guaranteed to be non-null. + return this.getCachedTokenOrThrow(); + } + + // Use existing promise if one is in progress to avoid multiple simultaneous fetches. + if (this.blockingPromise) { + return this.blockingPromise; + } + + this.blockingPromise = this.provider + .token() + .then(token => { + this.cachedToken = token; + this.blockingPromise = null; + return token; + }) + .catch((error: unknown) => { + this.blockingPromise = null; + throw error; + }); + + return this.blockingPromise; + } + + private triggerAsyncRefresh(): void { + if (this.isRefreshing || this.refreshError) { + return; + } + + this.isRefreshing = true; + + // Fire and forget async refresh. + this.provider + .token() + .then(token => { + this.cachedToken = token; + this.isRefreshing = false; + }) + .catch((error: unknown) => { + this.refreshError = + error instanceof Error ? error : new Error(String(error)); + this.isRefreshing = false; + }); + } + + /** + * For testing: allows injecting a custom time function. + * @internal + */ + setTimeNow(fn: () => Date): void { + this.timeNow = fn; + } +} diff --git a/packages/auth/src/credentials/index.ts b/packages/auth/src/credentials/index.ts index 03006fce..d096f180 100644 --- a/packages/auth/src/credentials/index.ts +++ b/packages/auth/src/credentials/index.ts @@ -2,4 +2,4 @@ * Credential implementations for the Databricks SDK. */ -export {newPatCredentials} from './pat'; +export {newPatCredentials, TokenRequiredError} from './pat'; diff --git a/packages/auth/src/credentials/pat.ts b/packages/auth/src/credentials/pat.ts index 46593153..ab4ca1ec 100644 --- a/packages/auth/src/credentials/pat.ts +++ b/packages/auth/src/credentials/pat.ts @@ -7,7 +7,7 @@ import type {Credentials, Header} from '../auth'; /** * Error thrown when a token is required but not provided. */ -class TokenRequiredError extends Error { +export class TokenRequiredError extends Error { constructor() { super('token is required'); this.name = 'TokenRequiredError'; @@ -15,15 +15,14 @@ class TokenRequiredError extends Error { } /** - * Creates a Credentials that can be used to authenticate with a Personal - * Access Token. + * Creates a Credentials that can be used to authenticate with a Personal Access Token. * * @param token - The personal access token. * @returns Credentials for PAT authentication. - * @throws TokenRequiredError if token is empty. + * @throws {TokenRequiredError} If token is empty. */ export function newPatCredentials(token: string): Credentials { - if (token === '') { + if (!token) { throw new TokenRequiredError(); } return new PatCredentials(token); diff --git a/packages/auth/src/dataplane/dataplane.ts b/packages/auth/src/dataplane/dataplane.ts new file mode 100644 index 00000000..f1eae644 --- /dev/null +++ b/packages/auth/src/dataplane/dataplane.ts @@ -0,0 +1,90 @@ +/** + * Data plane token provider for the Databricks SDK. + * + * This module is experimental and subject to change. + */ + +import type {Token, TokenProvider} from '../auth'; +import {newCachedTokenProvider} from '../cache'; + +/** + * OAuth client interface for Databricks data plane. + */ +export interface OAuthClient { + getOAuthToken(authDetails: string, token: Token): Promise; +} + +/** + * Anything that returns tokens given a data plane endpoint and authentication details. + */ +export interface EndpointTokenProvider { + token(endpoint: string, authDetails: string): Promise; +} + +/** + * Creates a new EndpointTokenProvider that uses the given OAuthClient and control plane TokenProvider. + */ +export function newEndpointTokenProvider( + client: OAuthClient, + controlPlaneTokenProvider: TokenProvider +): EndpointTokenProvider { + return new DataPlaneTokenProvider(client, controlPlaneTokenProvider); +} + +class DataPlaneTokenProvider implements EndpointTokenProvider { + private readonly client: OAuthClient; + private readonly controlPlaneTokenProvider: TokenProvider; + private readonly sources = new Map(); + + constructor(client: OAuthClient, controlPlaneTokenProvider: TokenProvider) { + this.client = client; + this.controlPlaneTokenProvider = newCachedTokenProvider( + controlPlaneTokenProvider + ); + } + + async token(endpoint: string, authDetails: string): Promise { + const key = this.makeKey(endpoint, authDetails); + + const existing = this.sources.get(key); + if (existing) { + return existing.token(); + } + + const provider = newCachedTokenProvider( + new InnerTokenProvider( + this.client, + this.controlPlaneTokenProvider, + authDetails + ) + ); + this.sources.set(key, provider); + + return provider.token(); + } + + private makeKey(endpoint: string, authDetails: string): string { + return `${endpoint}::${authDetails}`; + } +} + +class InnerTokenProvider implements TokenProvider { + private readonly client: OAuthClient; + private readonly controlPlaneTokenProvider: TokenProvider; + private readonly authDetails: string; + + constructor( + client: OAuthClient, + controlPlaneTokenProvider: TokenProvider, + authDetails: string + ) { + this.client = client; + this.controlPlaneTokenProvider = controlPlaneTokenProvider; + this.authDetails = authDetails; + } + + async token(): Promise { + const innerToken = await this.controlPlaneTokenProvider.token(); + return this.client.getOAuthToken(this.authDetails, innerToken); + } +} diff --git a/packages/auth/src/dataplane/index.ts b/packages/auth/src/dataplane/index.ts new file mode 100644 index 00000000..9a34f2ef --- /dev/null +++ b/packages/auth/src/dataplane/index.ts @@ -0,0 +1,6 @@ +/** + * Data plane utilities for the Databricks SDK. + */ + +export type {OAuthClient, EndpointTokenProvider} from './dataplane'; +export {newEndpointTokenProvider} from './dataplane'; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index de065e6c..96b69d3a 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,6 +1,27 @@ /** * Databricks authentication library for JavaScript/TypeScript. * + * This library provides authentication utilities for the Databricks SDK, + * including token providers, credentials, and OIDC support. + * + * ## Browser vs Node.js + * + * This entry point includes all features, some of which are Node.js-only. + * For browser environments, use the browser-specific entry point: + * + * ```typescript + * // Browser-safe imports + * import { newPatCredentials, newCachedTokenProvider } from '@databricks/sdk-auth/browser'; + * + * // Full API (Node.js only) + * import { newFileTokenProvider, newEnvIdTokenProvider } from '@databricks/sdk-auth'; + * ``` + * + * ### Node.js-only exports: + * - `newEnvIdTokenProvider` - Uses `process.env` + * - `newFileTokenProvider` - Uses `fs/promises` + * - `newAzureDevOpsIdTokenProvider` - Uses `process.env` + * * @packageDocumentation */ @@ -14,5 +35,59 @@ export type { } from './auth'; export {tokenProviderFn, newTokenCredentials} from './auth'; +// Token caching. +export type {CachedTokenProviderOptions} from './cache'; +export {newCachedTokenProvider} from './cache'; + // Credential implementations. -export {newPatCredentials} from './credentials'; +export {newPatCredentials, TokenRequiredError} from './credentials'; + +// OIDC utilities. +export type {IdToken, IdTokenProvider} from './oidc/oidc'; +export { + idTokenProviderFn, + /** + * Creates an IdTokenProvider that reads the ID token from an environment variable. + * + * **Node.js only** - Uses `process.env`. + * + * @param envVar - The name of the environment variable containing the token. + */ + newEnvIdTokenProvider, + /** + * Creates an IdTokenProvider that reads the ID token from a file. + * + * **Node.js only** - Uses `fs/promises`. + * + * @param path - The path to the file containing the token. + */ + newFileTokenProvider, +} from './oidc/oidc'; + +export type { + OAuthAuthorizationServer, + DatabricksOidcTokenProviderConfig, + HttpClient as OidcHttpClient, +} from './oidc/tokensource'; +export {newDatabricksOidcTokenProvider} from './oidc/tokensource'; + +export type {HttpClient as GithubHttpClient} from './oidc/github'; +export {newGithubIdTokenProvider} from './oidc/github'; + +export type {HttpClient as AzureDevOpsHttpClient} from './oidc/azure_devops'; +export { + MissingAccessTokenError, + NotInAzureDevOpsError, + /** + * Creates an IdTokenProvider for Azure DevOps Pipelines. + * + * **Node.js only** - Uses `process.env` for Azure DevOps environment variables. + * + * @param httpClient - HTTP client for making requests. + */ + newAzureDevOpsIdTokenProvider, +} from './oidc/azure_devops'; + +// Data plane utilities. +export type {OAuthClient, EndpointTokenProvider} from './dataplane'; +export {newEndpointTokenProvider} from './dataplane'; diff --git a/packages/auth/src/oidc/azure_devops.ts b/packages/auth/src/oidc/azure_devops.ts new file mode 100644 index 00000000..64d135a8 --- /dev/null +++ b/packages/auth/src/oidc/azure_devops.ts @@ -0,0 +1,126 @@ +/** + * Azure DevOps OIDC token provider for the Databricks SDK. + */ + +import type {IdToken, IdTokenProvider} from './oidc'; + +/** + * HTTP client interface for Azure DevOps. + */ +export interface HttpClient { + post(url: string, headers: Record): Promise; +} + +/** + * Error thrown when SYSTEM_ACCESSTOKEN is missing. + */ +export class MissingAccessTokenError extends Error { + constructor() { + super( + 'SYSTEM_ACCESSTOKEN env var not found, ' + + 'if calling from Azure DevOps Pipeline, please set this env var following ' + + 'https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#systemaccesstoken' + ); + this.name = 'MissingAccessTokenError'; + } +} + +/** + * Error thrown when not running in Azure DevOps Pipeline. + */ +export class NotInAzureDevOpsError extends Error { + constructor(envVar: string) { + super(`not calling from Azure DevOps Pipeline: missing env var ${envVar}`); + this.name = 'NotInAzureDevOpsError'; + } +} + +/** + * Creates a new IdTokenProvider that retrieves an IdToken from an Azure DevOps environment. + * This IdTokenProvider is only valid when running in Azure DevOps Pipelines. + * + * @param httpClient - HTTP client for making requests. + */ +export function newAzureDevOpsIdTokenProvider( + httpClient: HttpClient +): IdTokenProvider { + const accessToken = process.env.SYSTEM_ACCESSTOKEN; + if (accessToken === undefined || accessToken === '') { + throw new MissingAccessTokenError(); + } + + const teamFoundationCollectionUri = getRequiredEnv( + 'SYSTEM_TEAMFOUNDATIONCOLLECTIONURI' + ); + const planId = getRequiredEnv('SYSTEM_PLANID'); + const jobId = getRequiredEnv('SYSTEM_JOBID'); + const teamProjectId = getRequiredEnv('SYSTEM_TEAMPROJECTID'); + const hostType = getRequiredEnv('SYSTEM_HOSTTYPE'); + + return new AzureDevOpsIdTokenProvider( + httpClient, + accessToken, + teamFoundationCollectionUri, + planId, + jobId, + teamProjectId, + hostType + ); +} + +function getRequiredEnv(envVar: string): string { + const value = process.env[envVar]; + if (value === undefined || value === '') { + throw new NotInAzureDevOpsError(envVar); + } + return value; +} + +class AzureDevOpsIdTokenProvider implements IdTokenProvider { + private readonly httpClient: HttpClient; + private readonly accessToken: string; + private readonly teamFoundationCollectionUri: string; + private readonly planId: string; + private readonly jobId: string; + private readonly teamProjectId: string; + private readonly hostType: string; + + constructor( + httpClient: HttpClient, + accessToken: string, + teamFoundationCollectionUri: string, + planId: string, + jobId: string, + teamProjectId: string, + hostType: string + ) { + this.httpClient = httpClient; + this.accessToken = accessToken; + this.teamFoundationCollectionUri = teamFoundationCollectionUri; + this.planId = planId; + this.jobId = jobId; + this.teamProjectId = teamProjectId; + this.hostType = hostType; + } + + async idToken(_audience: string): Promise { + // Azure DevOps OIDC endpoint format. + // Reference: https://learn.microsoft.com/en-us/rest/api/azure/devops/distributedtask/oidctoken/create + const requestUrl = + `${this.teamFoundationCollectionUri}/${this.teamProjectId}/_apis/distributedtask/hubs/` + + `${this.hostType}/plans/${this.planId}/jobs/${this.jobId}/oidctoken?api-version=7.2-preview.1`; + + const response = await this.httpClient.post<{oidcToken: string}>( + requestUrl, + { + Authorization: `Bearer ${this.accessToken}`, + } + ); + + if (!response.oidcToken) { + throw new Error('empty OIDC token received from Azure DevOps'); + } + + return {value: response.oidcToken}; + } +} diff --git a/packages/auth/src/oidc/github.ts b/packages/auth/src/oidc/github.ts new file mode 100644 index 00000000..0399c291 --- /dev/null +++ b/packages/auth/src/oidc/github.ts @@ -0,0 +1,72 @@ +/** + * GitHub Actions OIDC token provider for the Databricks SDK. + */ + +import type {IdToken, IdTokenProvider} from './oidc'; + +/** + * HTTP client interface for GitHub Actions. + */ +export interface HttpClient { + get(url: string, headers: Record): Promise; +} + +/** + * Creates a new IdTokenProvider that retrieves an IdToken from the GitHub Actions environment. + * This IdTokenProvider is only valid when running in GitHub Actions with OIDC enabled. + * + * @param httpClient - HTTP client for making requests. + * @param actionsIdTokenRequestUrl - The URL to request the ID token from (ACTIONS_ID_TOKEN_REQUEST_URL). + * @param actionsIdTokenRequestToken - The token for authenticating the request (ACTIONS_ID_TOKEN_REQUEST_TOKEN). + */ +export function newGithubIdTokenProvider( + httpClient: HttpClient, + actionsIdTokenRequestUrl: string, + actionsIdTokenRequestToken: string +): IdTokenProvider { + return new GithubIdTokenProvider( + httpClient, + actionsIdTokenRequestUrl, + actionsIdTokenRequestToken + ); +} + +class GithubIdTokenProvider implements IdTokenProvider { + private readonly httpClient: HttpClient; + private readonly actionsIdTokenRequestUrl: string; + private readonly actionsIdTokenRequestToken: string; + + constructor( + httpClient: HttpClient, + actionsIdTokenRequestUrl: string, + actionsIdTokenRequestToken: string + ) { + this.httpClient = httpClient; + this.actionsIdTokenRequestUrl = actionsIdTokenRequestUrl; + this.actionsIdTokenRequestToken = actionsIdTokenRequestToken; + } + + async idToken(audience: string): Promise { + if (!this.actionsIdTokenRequestUrl) { + throw new Error( + 'missing ActionsIdTokenRequestUrl, likely not calling from a GitHub action' + ); + } + if (!this.actionsIdTokenRequestToken) { + throw new Error( + 'missing ActionsIdTokenRequestToken, likely not calling from a GitHub action' + ); + } + + let requestUrl = this.actionsIdTokenRequestUrl; + if (audience) { + requestUrl = `${requestUrl}&audience=${encodeURIComponent(audience)}`; + } + + const response = await this.httpClient.get(requestUrl, { + Authorization: `Bearer ${this.actionsIdTokenRequestToken}`, + }); + + return response; + } +} diff --git a/packages/auth/src/oidc/index.ts b/packages/auth/src/oidc/index.ts new file mode 100644 index 00000000..426316a0 --- /dev/null +++ b/packages/auth/src/oidc/index.ts @@ -0,0 +1,27 @@ +/** + * OIDC utilities for the Databricks SDK. + */ + +export type {IdToken, IdTokenProvider} from './oidc'; +export { + idTokenProviderFn, + newEnvIdTokenProvider, + newFileTokenProvider, +} from './oidc'; + +export type { + OAuthAuthorizationServer, + DatabricksOidcTokenProviderConfig, + HttpClient as OidcHttpClient, +} from './tokensource'; +export {newDatabricksOidcTokenProvider} from './tokensource'; + +export type {HttpClient as GithubHttpClient} from './github'; +export {newGithubIdTokenProvider} from './github'; + +export type {HttpClient as AzureDevOpsHttpClient} from './azure_devops'; +export { + MissingAccessTokenError, + NotInAzureDevOpsError, + newAzureDevOpsIdTokenProvider, +} from './azure_devops'; diff --git a/packages/auth/src/oidc/oidc.ts b/packages/auth/src/oidc/oidc.ts new file mode 100644 index 00000000..782065d7 --- /dev/null +++ b/packages/auth/src/oidc/oidc.ts @@ -0,0 +1,85 @@ +/** + * OIDC ID token utilities for the Databricks SDK. + * + * This module is experimental and subject to change. + */ + +/** + * Represents an OIDC ID token that can be exchanged for a Databricks access token. + */ +export interface IdToken { + value: string; +} + +/** + * Anything that returns an IdToken given an audience. + */ +export interface IdTokenProvider { + /** + * Returns an ID token for the specified audience. + * @param audience - The audience for the ID token. + */ + idToken(audience: string): Promise; +} + +/** + * Adapter to allow the use of ordinary functions as IdTokenProvider. + * + * @example + * const provider = idTokenProviderFn(async (audience) => ({ value: 'my-token' })); + */ +export function idTokenProviderFn( + fn: (audience: string) => Promise +): IdTokenProvider { + return {idToken: fn}; +} + +/** + * Creates an IdTokenProvider that reads the ID token from an environment variable. + * + * Note that the IdTokenProvider does not cache the token and will read the token + * from the environment variable each time. + * + * @param envVar - The name of the environment variable containing the token. + */ +export function newEnvIdTokenProvider(envVar: string): IdTokenProvider { + return idTokenProviderFn((_audience: string): Promise => { + const token = process.env[envVar]; + if (token === undefined || token === '') { + return Promise.reject(new Error(`missing env var "${envVar}"`)); + } + return Promise.resolve({value: token}); + }); +} + +/** + * Creates an IdTokenProvider that reads the ID token from a file. + * The file should contain the token as text. + * + * @param path - The path to the file containing the token. + */ +export function newFileTokenProvider(path: string): IdTokenProvider { + return idTokenProviderFn(async (_audience: string): Promise => { + if (!path) { + throw new Error('missing path'); + } + + const fs = await import('fs/promises'); + + let content: string; + try { + content = await fs.readFile(path, 'utf-8'); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`file "${path}" does not exist`); + } + throw error; + } + + if (!content || content.length === 0) { + throw new Error(`file "${path}" is empty`); + } + + return {value: content}; + }); +} diff --git a/packages/auth/src/oidc/tokensource.ts b/packages/auth/src/oidc/tokensource.ts new file mode 100644 index 00000000..1e8d9483 --- /dev/null +++ b/packages/auth/src/oidc/tokensource.ts @@ -0,0 +1,155 @@ +/** + * Databricks OIDC token provider for the Databricks SDK. + */ + +import type {Token, TokenProvider} from '../auth'; +import type {IdTokenProvider} from './oidc'; + +/** + * OAuth authorization server endpoints. + */ +export interface OAuthAuthorizationServer { + tokenEndpoint: string; +} + +/** + * Configuration for a Databricks OIDC TokenProvider. + */ +export interface DatabricksOidcTokenProviderConfig { + /** + * ClientID of the Databricks OIDC application. + * Corresponds to the Application ID of the Databricks Service Principal. + * + * This field is only required for Workload Identity Federation and should + * be empty for Account-wide token federation. + */ + clientId?: string; + + /** + * AccountID is the account ID of the Databricks Account. + * This field is only required for Account-wide token federation. + */ + accountId?: string; + + /** + * Host is the host of the Databricks account or workspace. + */ + host: string; + + /** + * Returns the token endpoint for the Databricks OIDC application. + */ + tokenEndpointProvider(): Promise; + + /** + * Audience of the Databricks OIDC application. + * This is only used for Workspace level tokens. + */ + audience?: string; + + /** + * Returns the IdToken to be used for the token exchange. + */ + idTokenProvider: IdTokenProvider; + + /** + * HTTP client for making requests (injectable for testing). + */ + httpClient?: HttpClient; +} + +/** + * HTTP client interface for token exchange. + */ +export interface HttpClient { + post( + url: string, + params: URLSearchParams + ): Promise<{access_token: string; token_type: string; expires_in?: number}>; +} + +/** + * Creates a new Databricks OIDC TokenProvider. + */ +export function newDatabricksOidcTokenProvider( + config: DatabricksOidcTokenProviderConfig +): TokenProvider { + return new DatabricksOidcTokenProvider(config); +} + +class DatabricksOidcTokenProvider implements TokenProvider { + private readonly config: DatabricksOidcTokenProviderConfig; + + constructor(config: DatabricksOidcTokenProviderConfig) { + this.config = config; + } + + async token(): Promise { + if (!this.config.host) { + throw new Error('missing Host'); + } + + const endpoints = await this.config.tokenEndpointProvider(); + const audience = this.determineAudience(endpoints); + const idToken = await this.config.idTokenProvider.idToken(audience); + + const params = new URLSearchParams({ + client_id: this.config.clientId ?? '', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token: idToken.value, + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + scope: 'all-apis', + }); + + const client = this.config.httpClient ?? this.getDefaultHttpClient(); + const response = await client.post(endpoints.tokenEndpoint, params); + + return { + value: response.access_token, + type: response.token_type, + expiry: + response.expires_in !== undefined + ? new Date(Date.now() + response.expires_in * 1000) + : undefined, + }; + } + + private determineAudience(endpoints: OAuthAuthorizationServer): string { + if (this.config.audience !== undefined && this.config.audience !== '') { + return this.config.audience; + } + // For Databricks Accounts, the account id is the default audience. + if (this.config.accountId !== undefined && this.config.accountId !== '') { + return this.config.accountId; + } + // For Databricks Workspaces, the auth endpoint is the default audience. + return endpoints.tokenEndpoint; + } + + private getDefaultHttpClient(): HttpClient { + return { + async post( + url: string, + params: URLSearchParams + ): Promise<{ + access_token: string; + token_type: string; + expires_in?: number; + }> { + const response = await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: params.toString(), + }); + if (!response.ok) { + throw new Error(`Token exchange failed: ${response.statusText}`); + } + return response.json() as Promise<{ + access_token: string; + token_type: string; + expires_in?: number; + }>; + }, + }; + } +} diff --git a/packages/auth/tests/auth.test.ts b/packages/auth/tests/auth.test.ts index 816cf87f..86e6b74f 100644 --- a/packages/auth/tests/auth.test.ts +++ b/packages/auth/tests/auth.test.ts @@ -3,69 +3,64 @@ import type {Token, Header} from '../src/auth'; import {tokenProviderFn, newTokenCredentials} from '../src/auth'; describe('tokenProviderFn', () => { - const cases: {name: string; token: Token}[] = [ - {name: 'bearer token', token: {value: 'test-token', type: 'Bearer'}}, - {name: 'token without type', token: {value: 'abc123'}}, - {name: 'token with expiry', token: {value: 'x', expiry: new Date()}}, - ]; + it('should adapt a function to TokenProvider interface', async () => { + const expectedToken: Token = {value: 'test-token', type: 'Bearer'}; + const provider = tokenProviderFn(() => Promise.resolve(expectedToken)); - it.each(cases)( - 'should return the expected token for $name', - async ({token}) => { - const provider = tokenProviderFn(() => Promise.resolve(token)); - const result = await provider.token(); - expect(result).toEqual(token); - } - ); + const token = await provider.token(); + expect(token).toEqual(expectedToken); + }); it('should propagate errors from the function', async () => { - const error = new Error('token fetch failed'); - const provider = tokenProviderFn(() => Promise.reject(error)); - await expect(provider.token()).rejects.toThrow(error); + const expectedError = new Error('token fetch failed'); + const provider = tokenProviderFn(() => Promise.reject(expectedError)); + + await expect(provider.token()).rejects.toThrow(expectedError); }); }); describe('newTokenCredentials', () => { - const headerCases: {name: string; token: Token; expected: Header[]}[] = [ - { - name: 'defaults to Bearer scheme', - token: {value: 'my-token'}, - expected: [{key: 'Authorization', value: 'Bearer my-token'}], - }, - { - name: 'uses custom token type as scheme', - token: {value: 'custom-token', type: 'Basic'}, - expected: [{key: 'Authorization', value: 'Basic custom-token'}], - }, - { - name: 'uses DPoP scheme when specified', - token: {value: 'dpop-token', type: 'DPoP'}, - expected: [{key: 'Authorization', value: 'DPoP dpop-token'}], - }, - ]; + it('should return Bearer authorization header by default', async () => { + const provider = tokenProviderFn(() => + Promise.resolve({value: 'my-token'}) + ); + const credentials = newTokenCredentials(provider); + + const headers = await credentials.authHeaders(); + expect(headers).toEqual([ + {key: 'Authorization', value: 'Bearer my-token'}, + ]); + }); + + it('should use custom token type when provided', async () => { + const provider = tokenProviderFn(() => + Promise.resolve({ + value: 'custom-token', + type: 'Basic', + }) + ); + const credentials = newTokenCredentials(provider); - it.each(headerCases)( - 'should produce correct auth header when $name', - async ({token, expected}) => { - const provider = tokenProviderFn(() => Promise.resolve(token)); - const credentials = newTokenCredentials(provider); - const headers = await credentials.authHeaders(); - expect(headers).toEqual(expected); - } - ); + const headers = await credentials.authHeaders(); + expect(headers).toEqual([ + {key: 'Authorization', value: 'Basic custom-token'}, + ]); + }); - it('should delegate token() to the underlying provider', async () => { + it('should also implement TokenProvider interface', async () => { const expectedToken: Token = {value: 'test-token', expiry: new Date()}; const provider = tokenProviderFn(() => Promise.resolve(expectedToken)); const credentials = newTokenCredentials(provider); + const token = await credentials.token(); expect(token).toEqual(expectedToken); }); it('should propagate errors from the underlying provider', async () => { - const error = new Error('provider error'); - const provider = tokenProviderFn(() => Promise.reject(error)); + const expectedError = new Error('provider error'); + const provider = tokenProviderFn(() => Promise.reject(expectedError)); const credentials = newTokenCredentials(provider); - await expect(credentials.authHeaders()).rejects.toThrow(error); + + await expect(credentials.authHeaders()).rejects.toThrow(expectedError); }); }); diff --git a/packages/auth/tests/cache.test.ts b/packages/auth/tests/cache.test.ts new file mode 100644 index 00000000..187ff0b1 --- /dev/null +++ b/packages/auth/tests/cache.test.ts @@ -0,0 +1,278 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import type {Token} from '../src/auth'; +import {tokenProviderFn} from '../src/auth'; +import {newCachedTokenProvider} from '../src/cache'; + +describe('newCachedTokenProvider', () => { + it('should return same instance if already cached', () => { + const provider = newCachedTokenProvider( + tokenProviderFn(() => Promise.resolve({value: 'test'})) + ); + const cached = newCachedTokenProvider(provider); + expect(cached).toBe(provider); + }); + + it('should use default options when none provided', async () => { + let callCount = 0; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + return Promise.resolve({ + value: 'token', + expiry: new Date(Date.now() + 3600000), + }); + }) + ); + + await provider.token(); + await provider.token(); + + // Should only call once due to caching. + expect(callCount).toBe(1); + }); + + it('should use initial cached token when provided', async () => { + let callCount = 0; + const cachedToken: Token = { + value: 'cached', + expiry: new Date(Date.now() + 3600000), + }; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + return Promise.resolve({value: 'new-token'}); + }), + {cachedToken} + ); + + const token = await provider.token(); + + expect(token.value).toBe('cached'); + expect(callCount).toBe(0); + }); +}); + +describe('CachedTokenProvider token states', () => { + const now = new Date('2024-01-01T00:00:00Z'); + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should return fresh token without refresh', async () => { + let callCount = 0; + const freshToken: Token = { + value: 'fresh', + expiry: new Date(now.getTime() + 3600000), // 1 hour from now. + }; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + return Promise.resolve({value: 'new'}); + }), + {cachedToken: freshToken} + ); + + const token = await provider.token(); + + expect(token.value).toBe('fresh'); + expect(callCount).toBe(0); + }); + + it('should refresh expired token with blocking call', async () => { + let callCount = 0; + const expiredToken: Token = { + value: 'expired', + expiry: new Date(now.getTime() - 1000), // 1 second ago. + }; + const newToken: Token = { + value: 'new', + expiry: new Date(now.getTime() + 3600000), + }; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + return Promise.resolve(newToken); + }), + {cachedToken: expiredToken} + ); + + const token = await provider.token(); + + expect(token.value).toBe('new'); + expect(callCount).toBe(1); + }); + + it('should trigger async refresh for stale token and return cached', async () => { + vi.useRealTimers(); // Use real timers for this test. + + let callCount = 0; + const realNow = new Date(); + const staleToken: Token = { + value: 'stale', + expiry: new Date(realNow.getTime() + 60000), // 1 minute from now (within 3min stale period). + }; + const newToken: Token = { + value: 'new', + expiry: new Date(realNow.getTime() + 3600000), + }; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + return Promise.resolve(newToken); + }), + {cachedToken: staleToken} + ); + + // First call returns stale token immediately. + const token = await provider.token(); + expect(token.value).toBe('stale'); + + // Wait for async refresh to complete. + await new Promise(resolve => setTimeout(resolve, 50)); + + // Async refresh should have been triggered. + expect(callCount).toBe(1); + }); + + it('should treat token with no expiry as fresh', async () => { + let callCount = 0; + const noExpiryToken: Token = {value: 'no-expiry'}; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + return Promise.resolve({value: 'new'}); + }), + {cachedToken: noExpiryToken} + ); + + const token = await provider.token(); + + expect(token.value).toBe('no-expiry'); + expect(callCount).toBe(0); + }); +}); + +describe('CachedTokenProvider blocking mode', () => { + it('should only refresh when expired in blocking mode', async () => { + const now = new Date('2024-01-01T00:00:00Z'); + vi.useFakeTimers(); + vi.setSystemTime(now); + + let callCount = 0; + const staleToken: Token = { + value: 'stale', + expiry: new Date(now.getTime() + 60000), // Stale but not expired. + }; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + return Promise.resolve({ + value: 'new', + expiry: new Date(now.getTime() + 3600000), + }); + }), + {cachedToken: staleToken, asyncRefresh: false} + ); + + // In blocking mode, stale tokens are not refreshed. + const token = await provider.token(); + + expect(token.value).toBe('stale'); + expect(callCount).toBe(0); + + vi.useRealTimers(); + }); + + it('should refresh expired token in blocking mode', async () => { + const now = new Date('2024-01-01T00:00:00Z'); + vi.useFakeTimers(); + vi.setSystemTime(now); + + let callCount = 0; + const expiredToken: Token = { + value: 'expired', + expiry: new Date(now.getTime() - 1000), + }; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + return Promise.resolve({ + value: 'new', + expiry: new Date(now.getTime() + 3600000), + }); + }), + {cachedToken: expiredToken, asyncRefresh: false} + ); + + const token = await provider.token(); + + expect(token.value).toBe('new'); + expect(callCount).toBe(1); + + vi.useRealTimers(); + }); +}); + +describe('CachedTokenProvider error handling', () => { + it('should propagate errors from underlying provider', async () => { + const expectedError = new Error('token fetch failed'); + const provider = newCachedTokenProvider( + tokenProviderFn(() => Promise.reject(expectedError)) + ); + + await expect(provider.token()).rejects.toThrow(expectedError); + }); + + it('should recover from previous errors on next blocking call', async () => { + let callCount = 0; + let shouldFail = true; + const provider = newCachedTokenProvider( + tokenProviderFn(() => { + callCount++; + if (shouldFail) { + return Promise.reject(new Error('temporary error')); + } + return Promise.resolve({value: 'success'}); + }) + ); + + // First call fails. + await expect(provider.token()).rejects.toThrow('temporary error'); + expect(callCount).toBe(1); + + // Second call should retry and succeed. + shouldFail = false; + const token = await provider.token(); + expect(token.value).toBe('success'); + expect(callCount).toBe(2); + }); +}); + +describe('CachedTokenProvider concurrent access', () => { + it('should only make one blocking request when multiple calls are made', async () => { + let callCount = 0; + const provider = newCachedTokenProvider( + tokenProviderFn(async () => { + callCount++; + // Simulate slow token fetch. + await new Promise(resolve => setTimeout(resolve, 50)); + return {value: 'token', expiry: new Date(Date.now() + 3600000)}; + }) + ); + + // Make multiple concurrent calls. + const promises = Array.from({length: 10}, () => provider.token()); + const tokens = await Promise.all(promises); + + // All should get the same token. + expect(tokens.every(t => t.value === 'token')).toBe(true); + // Only one call should have been made. + expect(callCount).toBe(1); + }); +}); diff --git a/packages/auth/tests/credentials/pat.test.ts b/packages/auth/tests/credentials/pat.test.ts index 17779c81..c89a1d2b 100644 --- a/packages/auth/tests/credentials/pat.test.ts +++ b/packages/auth/tests/credentials/pat.test.ts @@ -1,46 +1,50 @@ import {describe, it, expect} from 'vitest'; -import {newPatCredentials} from '../../src/credentials/pat'; +import {newPatCredentials, TokenRequiredError} from '../../src/credentials/pat'; import type {Header} from '../../src/auth'; describe('newPatCredentials', () => { - const validCases: {name: string; token: string; expected: Header[]}[] = [ - { - name: 'databricks PAT', - token: 'dapi1234567890abcdef', - expected: [{key: 'Authorization', value: 'Bearer dapi1234567890abcdef'}], - }, - { - name: 'generic token', - token: 'some-other-token', - expected: [{key: 'Authorization', value: 'Bearer some-other-token'}], - }, - ]; - - it.each(validCases)( - 'should return correct Authorization header for $name', - async ({token, expected}) => { - const credentials = newPatCredentials(token); - const headers = await credentials.authHeaders(); - expect(headers).toEqual(expected); - } - ); + it('should return credentials with valid token', async () => { + const token = 'dapi1234567890abcdef'; + const credentials = newPatCredentials(token); + + const headers = await credentials.authHeaders(); + expect(headers).toEqual([ + {key: 'Authorization', value: `Bearer ${token}`}, + ]); + }); + + it('should throw TokenRequiredError for empty token', () => { + expect(() => newPatCredentials('')).toThrow(TokenRequiredError); + }); + + it('should throw TokenRequiredError with correct message', () => { + expect(() => newPatCredentials('')).toThrow('token is required'); + }); it('should return consistent headers on multiple calls', async () => { - const credentials = newPatCredentials('test-token'); + const token = 'test-token'; + const credentials = newPatCredentials(token); + const headers1 = await credentials.authHeaders(); const headers2 = await credentials.authHeaders(); + expect(headers1).toEqual(headers2); }); +}); + +describe('TokenRequiredError', () => { + it('should have correct name', () => { + const error = new TokenRequiredError(); + expect(error.name).toBe('TokenRequiredError'); + }); + + it('should have correct message', () => { + const error = new TokenRequiredError(); + expect(error.message).toBe('token is required'); + }); - it('should throw for empty token with correct error name and message', () => { - let caught: Error | undefined; - try { - newPatCredentials(''); - } catch (e) { - caught = e as Error; - } - expect(caught).toBeInstanceOf(Error); - expect(caught?.name).toBe('TokenRequiredError'); - expect(caught?.message).toBe('token is required'); + it('should be instanceof Error', () => { + const error = new TokenRequiredError(); + expect(error).toBeInstanceOf(Error); }); }); diff --git a/packages/auth/tests/dataplane/dataplane.test.ts b/packages/auth/tests/dataplane/dataplane.test.ts new file mode 100644 index 00000000..c104234b --- /dev/null +++ b/packages/auth/tests/dataplane/dataplane.test.ts @@ -0,0 +1,157 @@ +import {describe, it, expect, vi} from 'vitest'; +import {newEndpointTokenProvider} from '../../src/dataplane/dataplane'; +import type {Token} from '../../src/auth'; +import {tokenProviderFn} from '../../src/auth'; + +describe('newEndpointTokenProvider', () => { + it('should fetch data plane token using control plane token', async () => { + const controlPlaneToken: Token = { + value: 'cp-token', + expiry: new Date(Date.now() + 3600000), + }; + const dataPlaneToken: Token = { + value: 'dp-token', + expiry: new Date(Date.now() + 3600000), + }; + + const mockOAuthClient = { + getOAuthToken: vi.fn().mockResolvedValue(dataPlaneToken), + }; + + const controlPlaneProvider = tokenProviderFn(() => + Promise.resolve(controlPlaneToken) + ); + + const provider = newEndpointTokenProvider( + mockOAuthClient, + controlPlaneProvider + ); + + const token = await provider.token( + 'https://dataplane.databricks.com', + 'auth-details' + ); + + expect(token.value).toBe('dp-token'); + expect(mockOAuthClient.getOAuthToken).toHaveBeenCalledWith( + 'auth-details', + controlPlaneToken + ); + }); + + it('should cache token providers per endpoint and auth details', async () => { + let callCount = 0; + const dataPlaneToken: Token = { + value: 'dp-token', + expiry: new Date(Date.now() + 3600000), + }; + + const mockOAuthClient = { + getOAuthToken: vi.fn().mockImplementation(() => { + callCount++; + return Promise.resolve(dataPlaneToken); + }), + }; + + const controlPlaneProvider = tokenProviderFn(() => + Promise.resolve({ + value: 'cp-token', + expiry: new Date(Date.now() + 3600000), + }) + ); + + const provider = newEndpointTokenProvider( + mockOAuthClient, + controlPlaneProvider + ); + + // Same endpoint and auth details should use cached provider. + await provider.token('endpoint1', 'auth1'); + await provider.token('endpoint1', 'auth1'); + expect(callCount).toBe(1); + + // Different auth details should create new provider. + await provider.token('endpoint1', 'auth2'); + expect(callCount).toBe(2); + + // Different endpoint should create new provider. + await provider.token('endpoint2', 'auth1'); + expect(callCount).toBe(3); + }); + + it('should cache control plane token provider', async () => { + let controlPlaneCallCount = 0; + const controlPlaneToken: Token = { + value: 'cp-token', + expiry: new Date(Date.now() + 3600000), + }; + + const mockOAuthClient = { + getOAuthToken: vi.fn().mockResolvedValue({ + value: 'dp-token', + expiry: new Date(Date.now() + 3600000), + }), + }; + + const controlPlaneProvider = tokenProviderFn(() => { + controlPlaneCallCount++; + return Promise.resolve(controlPlaneToken); + }); + + const provider = newEndpointTokenProvider( + mockOAuthClient, + controlPlaneProvider + ); + + // Multiple calls should only fetch control plane token once due to caching. + await provider.token('endpoint1', 'auth1'); + await provider.token('endpoint2', 'auth2'); + + // Control plane token provider is cached, so even with two different endpoints, + // the control plane token is only fetched once (due to caching). + expect(controlPlaneCallCount).toBe(1); + }); + + it('should propagate errors from OAuth client', async () => { + const expectedError = new Error('OAuth error'); + const mockOAuthClient = { + getOAuthToken: vi.fn().mockRejectedValue(expectedError), + }; + + const controlPlaneProvider = tokenProviderFn(() => + Promise.resolve({ + value: 'cp-token', + expiry: new Date(Date.now() + 3600000), + }) + ); + + const provider = newEndpointTokenProvider( + mockOAuthClient, + controlPlaneProvider + ); + + await expect(provider.token('endpoint', 'auth')).rejects.toThrow( + expectedError + ); + }); + + it('should propagate errors from control plane provider', async () => { + const expectedError = new Error('Control plane error'); + const mockOAuthClient = { + getOAuthToken: vi.fn(), + }; + + const controlPlaneProvider = tokenProviderFn(() => + Promise.reject(expectedError) + ); + + const provider = newEndpointTokenProvider( + mockOAuthClient, + controlPlaneProvider + ); + + await expect(provider.token('endpoint', 'auth')).rejects.toThrow( + expectedError + ); + }); +}); diff --git a/packages/auth/tests/oidc/azure_devops.test.ts b/packages/auth/tests/oidc/azure_devops.test.ts new file mode 100644 index 00000000..ff65f333 --- /dev/null +++ b/packages/auth/tests/oidc/azure_devops.test.ts @@ -0,0 +1,117 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import { + newAzureDevOpsIdTokenProvider, + MissingAccessTokenError, + NotInAzureDevOpsError, +} from '../../src/oidc/azure_devops'; + +describe('newAzureDevOpsIdTokenProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = {...originalEnv}; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + const setAzureDevOpsEnv = (): void => { + process.env.SYSTEM_ACCESSTOKEN = 'access-token'; + process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI = + 'https://dev.azure.com/myorg'; + process.env.SYSTEM_PLANID = 'plan-123'; + process.env.SYSTEM_JOBID = 'job-456'; + process.env.SYSTEM_TEAMPROJECTID = 'project-789'; + process.env.SYSTEM_HOSTTYPE = 'build'; + }; + + it('should throw MissingAccessTokenError when SYSTEM_ACCESSTOKEN is missing', () => { + delete process.env.SYSTEM_ACCESSTOKEN; + + const mockHttpClient = {post: vi.fn()}; + + expect(() => newAzureDevOpsIdTokenProvider(mockHttpClient)).toThrow( + MissingAccessTokenError + ); + }); + + it('should throw NotInAzureDevOpsError when required env vars are missing', () => { + process.env.SYSTEM_ACCESSTOKEN = 'token'; + delete process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI; + + const mockHttpClient = {post: vi.fn()}; + + expect(() => newAzureDevOpsIdTokenProvider(mockHttpClient)).toThrow( + NotInAzureDevOpsError + ); + }); + + it('should throw NotInAzureDevOpsError with env var name', () => { + process.env.SYSTEM_ACCESSTOKEN = 'token'; + delete process.env.SYSTEM_PLANID; + process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI = 'https://dev.azure.com'; + + const mockHttpClient = {post: vi.fn()}; + + expect(() => newAzureDevOpsIdTokenProvider(mockHttpClient)).toThrow( + 'missing env var SYSTEM_PLANID' + ); + }); + + it('should fetch token from Azure DevOps', async () => { + setAzureDevOpsEnv(); + + const mockHttpClient = { + post: vi.fn().mockResolvedValue({oidcToken: 'azure-oidc-token'}), + }; + + const provider = newAzureDevOpsIdTokenProvider(mockHttpClient); + const token = await provider.idToken('audience'); + + expect(token.value).toBe('azure-oidc-token'); + expect(mockHttpClient.post).toHaveBeenCalledWith( + 'https://dev.azure.com/myorg/project-789/_apis/distributedtask/hubs/build/plans/plan-123/jobs/job-456/oidctoken?api-version=7.2-preview.1', + {Authorization: 'Bearer access-token'} + ); + }); + + it('should throw error when empty token received', async () => { + setAzureDevOpsEnv(); + + const mockHttpClient = { + post: vi.fn().mockResolvedValue({oidcToken: ''}), + }; + + const provider = newAzureDevOpsIdTokenProvider(mockHttpClient); + + await expect(provider.idToken('audience')).rejects.toThrow( + 'empty OIDC token received from Azure DevOps' + ); + }); +}); + +describe('MissingAccessTokenError', () => { + it('should have correct name', () => { + const error = new MissingAccessTokenError(); + expect(error.name).toBe('MissingAccessTokenError'); + }); + + it('should have helpful message', () => { + const error = new MissingAccessTokenError(); + expect(error.message).toContain('SYSTEM_ACCESSTOKEN'); + expect(error.message).toContain('Azure DevOps Pipeline'); + }); +}); + +describe('NotInAzureDevOpsError', () => { + it('should have correct name', () => { + const error = new NotInAzureDevOpsError('TEST_VAR'); + expect(error.name).toBe('NotInAzureDevOpsError'); + }); + + it('should include env var name in message', () => { + const error = new NotInAzureDevOpsError('MY_ENV_VAR'); + expect(error.message).toContain('MY_ENV_VAR'); + }); +}); diff --git a/packages/auth/tests/oidc/github.test.ts b/packages/auth/tests/oidc/github.test.ts new file mode 100644 index 00000000..333f8847 --- /dev/null +++ b/packages/auth/tests/oidc/github.test.ts @@ -0,0 +1,90 @@ +import {describe, it, expect, vi} from 'vitest'; +import {newGithubIdTokenProvider} from '../../src/oidc/github'; + +describe('newGithubIdTokenProvider', () => { + it('should throw error when ActionsIdTokenRequestUrl is missing', async () => { + const mockHttpClient = { + get: vi.fn(), + }; + + const provider = newGithubIdTokenProvider(mockHttpClient, '', 'token'); + + await expect(provider.idToken('audience')).rejects.toThrow( + 'missing ActionsIdTokenRequestUrl, likely not calling from a GitHub action' + ); + }); + + it('should throw error when ActionsIdTokenRequestToken is missing', async () => { + const mockHttpClient = { + get: vi.fn(), + }; + + const provider = newGithubIdTokenProvider( + mockHttpClient, + 'https://actions.github.com/token', + '' + ); + + await expect(provider.idToken('audience')).rejects.toThrow( + 'missing ActionsIdTokenRequestToken, likely not calling from a GitHub action' + ); + }); + + it('should fetch token from GitHub Actions', async () => { + const mockHttpClient = { + get: vi.fn().mockResolvedValue({value: 'github-id-token'}), + }; + + const provider = newGithubIdTokenProvider( + mockHttpClient, + 'https://actions.github.com/token', + 'request-token' + ); + + const token = await provider.idToken('my-audience'); + + expect(token.value).toBe('github-id-token'); + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://actions.github.com/token&audience=my-audience', + {Authorization: 'Bearer request-token'} + ); + }); + + it('should not append audience when empty', async () => { + const mockHttpClient = { + get: vi.fn().mockResolvedValue({value: 'github-id-token'}), + }; + + const provider = newGithubIdTokenProvider( + mockHttpClient, + 'https://actions.github.com/token', + 'request-token' + ); + + await provider.idToken(''); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://actions.github.com/token', + {Authorization: 'Bearer request-token'} + ); + }); + + it('should encode audience in URL', async () => { + const mockHttpClient = { + get: vi.fn().mockResolvedValue({value: 'token'}), + }; + + const provider = newGithubIdTokenProvider( + mockHttpClient, + 'https://actions.github.com/token', + 'request-token' + ); + + await provider.idToken('https://example.com/audience'); + + expect(mockHttpClient.get).toHaveBeenCalledWith( + 'https://actions.github.com/token&audience=https%3A%2F%2Fexample.com%2Faudience', + expect.any(Object) + ); + }); +}); diff --git a/packages/auth/tests/oidc/oidc.test.ts b/packages/auth/tests/oidc/oidc.test.ts new file mode 100644 index 00000000..813afa54 --- /dev/null +++ b/packages/auth/tests/oidc/oidc.test.ts @@ -0,0 +1,118 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import { + idTokenProviderFn, + newEnvIdTokenProvider, + newFileTokenProvider, +} from '../../src/oidc/oidc'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; + +describe('idTokenProviderFn', () => { + it('should adapt a function to IdTokenProvider interface', async () => { + const provider = idTokenProviderFn(audience => + Promise.resolve({ + value: `token-for-${audience}`, + }) + ); + + const token = await provider.idToken('test-audience'); + expect(token.value).toBe('token-for-test-audience'); + }); + + it('should propagate errors from the function', async () => { + const expectedError = new Error('token fetch failed'); + const provider = idTokenProviderFn(() => Promise.reject(expectedError)); + + await expect(provider.idToken('audience')).rejects.toThrow(expectedError); + }); +}); + +describe('newEnvIdTokenProvider', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = {...originalEnv}; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should read token from environment variable', async () => { + process.env.TEST_TOKEN = 'my-token-value'; + const provider = newEnvIdTokenProvider('TEST_TOKEN'); + + const token = await provider.idToken('audience'); + expect(token.value).toBe('my-token-value'); + }); + + it('should throw error when env var is missing', async () => { + delete process.env.MISSING_VAR; + const provider = newEnvIdTokenProvider('MISSING_VAR'); + + await expect(provider.idToken('audience')).rejects.toThrow( + 'missing env var "MISSING_VAR"' + ); + }); + + it('should read fresh value on each call', async () => { + const provider = newEnvIdTokenProvider('CHANGING_TOKEN'); + + process.env.CHANGING_TOKEN = 'first-value'; + const token1 = await provider.idToken('audience'); + + process.env.CHANGING_TOKEN = 'second-value'; + const token2 = await provider.idToken('audience'); + + expect(token1.value).toBe('first-value'); + expect(token2.value).toBe('second-value'); + }); +}); + +describe('newFileTokenProvider', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'auth-test-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, {recursive: true, force: true}); + }); + + it('should read token from file', async () => { + const tokenPath = path.join(tempDir, 'token.txt'); + await fs.writeFile(tokenPath, 'file-token-value'); + + const provider = newFileTokenProvider(tokenPath); + const token = await provider.idToken('audience'); + + expect(token.value).toBe('file-token-value'); + }); + + it('should throw error when path is empty', async () => { + const provider = newFileTokenProvider(''); + + await expect(provider.idToken('audience')).rejects.toThrow('missing path'); + }); + + it('should throw error when file does not exist', async () => { + const provider = newFileTokenProvider('/nonexistent/path/token.txt'); + + await expect(provider.idToken('audience')).rejects.toThrow( + 'file "/nonexistent/path/token.txt" does not exist' + ); + }); + + it('should throw error when file is empty', async () => { + const tokenPath = path.join(tempDir, 'empty.txt'); + await fs.writeFile(tokenPath, ''); + + const provider = newFileTokenProvider(tokenPath); + + await expect(provider.idToken('audience')).rejects.toThrow( + `file "${tokenPath}" is empty` + ); + }); +}); diff --git a/packages/auth/tests/oidc/tokensource.test.ts b/packages/auth/tests/oidc/tokensource.test.ts new file mode 100644 index 00000000..62f1e133 --- /dev/null +++ b/packages/auth/tests/oidc/tokensource.test.ts @@ -0,0 +1,161 @@ +import {describe, it, expect, vi} from 'vitest'; +import {newDatabricksOidcTokenProvider} from '../../src/oidc/tokensource'; +import {idTokenProviderFn} from '../../src/oidc/oidc'; + +describe('newDatabricksOidcTokenProvider', () => { + it('should throw error when host is missing', async () => { + const provider = newDatabricksOidcTokenProvider({ + host: '', + tokenEndpointProvider: () => + Promise.resolve({ + tokenEndpoint: 'https://example.com/token', + }), + idTokenProvider: idTokenProviderFn(() => + Promise.resolve({value: 'id-token'}) + ), + }); + + await expect(provider.token()).rejects.toThrow('missing Host'); + }); + + it('should exchange id token for access token', async () => { + const mockHttpClient = { + post: vi.fn().mockResolvedValue({ + access_token: 'access-token-123', + token_type: 'Bearer', + expires_in: 3600, + }), + }; + + const provider = newDatabricksOidcTokenProvider({ + host: 'https://workspace.databricks.com', + clientId: 'client-123', + tokenEndpointProvider: () => + Promise.resolve({ + tokenEndpoint: 'https://accounts.databricks.com/oidc/token', + }), + idTokenProvider: idTokenProviderFn(() => + Promise.resolve({value: 'my-id-token'}) + ), + httpClient: mockHttpClient, + }); + + const token = await provider.token(); + + expect(token.value).toBe('access-token-123'); + expect(token.type).toBe('Bearer'); + expect(token.expiry).toBeDefined(); + expect(mockHttpClient.post).toHaveBeenCalledWith( + 'https://accounts.databricks.com/oidc/token', + expect.any(URLSearchParams) + ); + }); + + it('should use custom audience when provided', async () => { + let capturedAudience = ''; + const mockHttpClient = { + post: vi.fn().mockResolvedValue({ + access_token: 'token', + token_type: 'Bearer', + }), + }; + + const provider = newDatabricksOidcTokenProvider({ + host: 'https://workspace.databricks.com', + audience: 'custom-audience', + tokenEndpointProvider: () => + Promise.resolve({ + tokenEndpoint: 'https://accounts.databricks.com/oidc/token', + }), + idTokenProvider: idTokenProviderFn(audience => { + capturedAudience = audience; + return Promise.resolve({value: 'id-token'}); + }), + httpClient: mockHttpClient, + }); + + await provider.token(); + + expect(capturedAudience).toBe('custom-audience'); + }); + + it('should use accountId as audience when no custom audience', async () => { + let capturedAudience = ''; + const mockHttpClient = { + post: vi.fn().mockResolvedValue({ + access_token: 'token', + token_type: 'Bearer', + }), + }; + + const provider = newDatabricksOidcTokenProvider({ + host: 'https://accounts.databricks.com', + accountId: 'account-123', + tokenEndpointProvider: () => + Promise.resolve({ + tokenEndpoint: 'https://accounts.databricks.com/oidc/token', + }), + idTokenProvider: idTokenProviderFn(audience => { + capturedAudience = audience; + return Promise.resolve({value: 'id-token'}); + }), + httpClient: mockHttpClient, + }); + + await provider.token(); + + expect(capturedAudience).toBe('account-123'); + }); + + it('should use token endpoint as audience when no other audience', async () => { + let capturedAudience = ''; + const mockHttpClient = { + post: vi.fn().mockResolvedValue({ + access_token: 'token', + token_type: 'Bearer', + }), + }; + + const provider = newDatabricksOidcTokenProvider({ + host: 'https://workspace.databricks.com', + tokenEndpointProvider: () => + Promise.resolve({ + tokenEndpoint: 'https://accounts.databricks.com/oidc/token', + }), + idTokenProvider: idTokenProviderFn(audience => { + capturedAudience = audience; + return Promise.resolve({value: 'id-token'}); + }), + httpClient: mockHttpClient, + }); + + await provider.token(); + + expect(capturedAudience).toBe('https://accounts.databricks.com/oidc/token'); + }); + + it('should handle token without expiry', async () => { + const mockHttpClient = { + post: vi.fn().mockResolvedValue({ + access_token: 'token', + token_type: 'Bearer', + }), + }; + + const provider = newDatabricksOidcTokenProvider({ + host: 'https://workspace.databricks.com', + tokenEndpointProvider: () => + Promise.resolve({ + tokenEndpoint: 'https://accounts.databricks.com/oidc/token', + }), + idTokenProvider: idTokenProviderFn(() => + Promise.resolve({value: 'id-token'}) + ), + httpClient: mockHttpClient, + }); + + const token = await provider.token(); + + expect(token.expiry).toBeUndefined(); + }); +}); diff --git a/packages/auth/tests/tsconfig.json b/packages/auth/tests/tsconfig.json index 211186ad..6d40dca4 100644 --- a/packages/auth/tests/tsconfig.json +++ b/packages/auth/tests/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "noEmit": true, - "rootDir": ".." + "outDir": "../dist", + "rootDir": "..", + "types": ["node"] }, - "include": [".", "../src"], - "exclude": ["../dist", "../node_modules"] + "include": ["**/*.ts", "../src/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 9b8dd1b0..86d44a06 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -4,6 +4,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["src"], - "exclude": ["dist", "node_modules", "tests"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] } diff --git a/packages/auth/vitest.config.browser.ts b/packages/auth/vitest.config.browser.ts index 737359f4..2d7c5e97 100644 --- a/packages/auth/vitest.config.browser.ts +++ b/packages/auth/vitest.config.browser.ts @@ -9,5 +9,20 @@ export default defineConfig({ headless: true, }, include: ['tests/**/*.test.ts'], + // Exclude Node.js-only tests in browser environment. + exclude: [ + 'tests/oidc/oidc.test.ts', // Uses fs/promises + 'tests/oidc/azure_devops.test.ts', // Uses process.env extensively + ], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'html'], + include: ['src/**/*.ts'], + exclude: [ + 'src/**/index.ts', + 'src/oidc/oidc.ts', // Node.js-only (uses fs) + 'src/oidc/azure_devops.ts', // Node.js-only (uses process.env) + ], + }, }, }); diff --git a/packages/auth/vitest.config.ts b/packages/auth/vitest.config.ts new file mode 100644 index 00000000..4782cf19 --- /dev/null +++ b/packages/auth/vitest.config.ts @@ -0,0 +1,15 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + // Default environment is Node.js. + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/index.ts'], + }, + }, +}); diff --git a/packages/databricks/package.json b/packages/databricks/package.json index 0b19c98a..93de27c5 100644 --- a/packages/databricks/package.json +++ b/packages/databricks/package.json @@ -1,7 +1,7 @@ { "name": "@databricks/sdk-databricks", "version": "0.1.0", - "description": "Databricks core library for JavaScript/TypeScript", + "description": "Databricks SDK core infrastructure for JavaScript/TypeScript", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -42,12 +42,15 @@ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", "test": "vitest run", - "test:browser": "vitest run --config vitest.config.browser.ts", + "test:node": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, - "author": "Databricks", - "license": "Apache-2.0", + "dependencies": { + "@databricks/sdk-auth": "0.1.0" + }, "devDependencies": { "@types/node": "^22.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -56,11 +59,12 @@ "@vitest/coverage-v8": "^2.1.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", - "playwright": "^1.48.0", "prettier": "^3.2.0", "typescript": "^5.7.0", "vitest": "^2.1.0" }, + "author": "Databricks", + "license": "Apache-2.0", "engines": { "node": ">=22.0.0" }, diff --git a/packages/databricks/src/api/api.ts b/packages/databricks/src/api/api.ts new file mode 100644 index 00000000..d9b2ab73 --- /dev/null +++ b/packages/databricks/src/api/api.ts @@ -0,0 +1,113 @@ +/** + * Utilities to make API calls against the Databricks API with retry, timeout, + * and rate limiting support. + */ + +import type {Option, Options} from './options'; +import type {Retrier} from './retrier'; + +/** + * A function representing a single API call attempt. Throw an error to + * indicate failure; return normally to indicate success. + */ +export type Call = (signal: AbortSignal) => Promise; + +/** + * Makes an API call using the given options for retry, timeout, and rate + * limiting behavior. + */ +export async function execute(call: Call, ...opts: Option[]): Promise { + const options: Options = {}; + for (const opt of opts) { + opt.apply(options); + } + + await executeImpl(call, options, sleepMs); +} + +/** + * Sleeps for the given duration in milliseconds. Rejects with an + * AbortError if the signal is aborted before the sleep completes. + */ +function sleepMs(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason as Error); + return; + } + + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + function onAbort(): void { + clearTimeout(timer); + reject(signal.reason as Error); + } + + signal.addEventListener('abort', onAbort, {once: true}); + }); +} + +// Convenience type for readability and testability. +type Sleeper = (ms: number, signal: AbortSignal) => Promise; + +/** + * The actual implementation of execute, separated for testability. + */ +async function executeImpl( + apiCall: Call, + opts: Options, + sleep: Sleeper +): Promise { + // Set up abort controller with optional timeout. + const controller = new AbortController(); + const {signal} = controller; + + let timeoutId: ReturnType | undefined; + if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) { + timeoutId = setTimeout(() => { + controller.abort( + new DOMException('The operation timed out.', 'TimeoutError') + ); + }, opts.timeoutMs); + } + + try { + // Lazily instantiated retrier. + let retrier: Retrier | undefined; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition + while (true) { + if (opts.rateLimiter !== undefined) { + await opts.rateLimiter.wait(signal); + } + + try { + await apiCall(signal); + return; // Success — nothing to retry. + } catch (err: unknown) { + if (retrier === undefined) { + if (opts.retrier !== undefined) { + retrier = opts.retrier(); + } + if (retrier === undefined) { + throw err; // No retrier — no retry. + } + } + + const decision = retrier.isRetriable(err); + if (!decision.retriable) { + throw err; // Not retriable. + } + + await sleep(decision.delayMs, signal); + } + } + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +} diff --git a/packages/databricks/src/index.ts b/packages/databricks/src/index.ts index 45c2769b..a9b8066f 100644 --- a/packages/databricks/src/index.ts +++ b/packages/databricks/src/index.ts @@ -1,5 +1,71 @@ /** - * Databricks core library for JavaScript/TypeScript. + * Databricks SDK core infrastructure for JavaScript/TypeScript. + * + * This package provides the foundational building blocks for the Databricks + * SDK: API call execution with retry and rate limiting, structured error + * handling, client configuration, and HTTP transport. * * @packageDocumentation */ + +// API call execution. +export type {Call} from './api'; +export {execute} from './api'; + +// API call options. +export type {Option, Options, Limiter} from './api'; +export { + withRetrier, + withDisableRetry, + withTimeout as withCallTimeout, + withLimiter, +} from './api'; + +// Retry logic. +export type {RetryDecision, Retrier, BackoffPolicyOptions} from './api'; +export {retryOnCodes, retryOn, BackoffPolicy} from './api'; + +// Error codes. +export {Code, codeFromString, codeToString, codeFromHttpStatus} from './apierr'; + +// Structured error details. +export type { + ErrorDetails, + ErrorInfo, + RequestInfo, + RetryInfo, + DebugInfo, + QuotaFailure, + QuotaFailureViolation, + PreconditionFailure, + PreconditionFailureViolation, + BadRequest, + BadRequestFieldViolation, + ResourceInfo, + Help, + HelpLink, +} from './apierr'; +export {parseErrorDetails} from './apierr'; + +// API error class. +export {APIError, errorCode, fromHttpError} from './apierr'; + +// Client options. +export type { + HttpClientFn, + Logger, + ClientOptions, + ClientOption, +} from './options'; +export { + withHost, + withHttpClient, + withCredentials, + withTimeout, + withLogger, + resolveOptions, +} from './options'; + +// HTTP transport. +export type {FetchFn} from './transport'; +export {resolveClientOptions} from './transport'; diff --git a/packages/databricks/src/options/index.ts b/packages/databricks/src/options/index.ts new file mode 100644 index 00000000..6585a58c --- /dev/null +++ b/packages/databricks/src/options/index.ts @@ -0,0 +1,15 @@ +// Client options. +export type { + HttpClientFn, + Logger, + ClientOptions, + ClientOption, +} from './options'; +export { + withHost, + withHttpClient, + withCredentials, + withTimeout, + withLogger, + resolveOptions, +} from './options'; diff --git a/packages/databricks/src/options/options.ts b/packages/databricks/src/options/options.ts new file mode 100644 index 00000000..eaed7eb3 --- /dev/null +++ b/packages/databricks/src/options/options.ts @@ -0,0 +1,111 @@ +/** + * Client options for configuring Databricks API clients. + */ + +import type {Credentials} from '@databricks/sdk-auth'; + +/** + * A fetch-compatible function type for making HTTP requests. + */ +export type HttpClientFn = ( + input: string | URL | Request, + init?: RequestInit +) => Promise; + +/** + * Logger interface for SDK diagnostics. Compatible with console and + * structured loggers. + */ +export interface Logger { + /** Logs a debug-level message. */ + debug(message: string, ...args: unknown[]): void; + /** Logs an informational message. */ + info(message: string, ...args: unknown[]): void; + /** Logs a warning. */ + warn(message: string, ...args: unknown[]): void; + /** Logs an error. */ + error(message: string, ...args: unknown[]): void; +} + +/** + * Internal client options that can be set via ClientOption functions. + */ +export interface ClientOptions { + /** The Databricks host URL. */ + host?: string; + + /** A pre-built fetch-compatible client to use as-is. */ + httpClient?: HttpClientFn; + + /** Credentials for authenticating API requests. */ + credentials?: Credentials; + + /** Overall timeout in milliseconds for API calls. */ + timeoutMs?: number; + + /** Logger for SDK diagnostic messages. */ + logger?: Logger; +} + +/** + * A function that configures a ClientOptions instance. + */ +export type ClientOption = (opts: ClientOptions) => void; + +/** + * Returns a ClientOption that sets the Databricks host URL. + */ +export function withHost(h: string): ClientOption { + return (opts: ClientOptions): void => { + opts.host = h; + }; +} + +/** + * Returns a ClientOption to use a specific fetch-compatible client. + * + * When set, this option takes precedence and ignores other transport options. + */ +export function withHttpClient(c: HttpClientFn): ClientOption { + return (opts: ClientOptions): void => { + opts.httpClient = c; + }; +} + +/** + * Returns a ClientOption that sets the credentials for authentication. + */ +export function withCredentials(c: Credentials): ClientOption { + return (opts: ClientOptions): void => { + opts.credentials = c; + }; +} + +/** + * Returns a ClientOption that sets the default API call timeout. + */ +export function withTimeout(ms: number): ClientOption { + return (opts: ClientOptions): void => { + opts.timeoutMs = ms; + }; +} + +/** + * Returns a ClientOption that sets the logger for SDK diagnostics. + */ +export function withLogger(l: Logger): ClientOption { + return (opts: ClientOptions): void => { + opts.logger = l; + }; +} + +/** + * Applies all ClientOptions and returns the resolved configuration. + */ +export function resolveOptions(...opts: ClientOption[]): ClientOptions { + const result: ClientOptions = {}; + for (const opt of opts) { + opt(result); + } + return result; +} diff --git a/packages/databricks/src/transport/index.ts b/packages/databricks/src/transport/index.ts index 3dcffd2b..e69de29b 100644 --- a/packages/databricks/src/transport/index.ts +++ b/packages/databricks/src/transport/index.ts @@ -1,11 +0,0 @@ -/** - * HTTP transport layer for the Databricks SDK. - * - * WARNING: This module is experimental and its API may change without notice. - * Do not depend on it in production code. - * - * @packageDocumentation - */ - -export {newFetchHttpClient} from './http'; -export type {HttpClient, HttpRequest, HttpResponse} from './http'; diff --git a/packages/databricks/tests/api/api.test.ts b/packages/databricks/tests/api/api.test.ts new file mode 100644 index 00000000..3db8e185 --- /dev/null +++ b/packages/databricks/tests/api/api.test.ts @@ -0,0 +1,153 @@ +import {describe, it, expect} from 'vitest'; +import {execute} from '../../src/api/api'; +import { + withRetrier, + withDisableRetry, + withTimeout, + withLimiter, +} from '../../src/api/options'; +import {BackoffPolicy, retryOn} from '../../src/api/retrier'; +import type {Limiter} from '../../src/api/limiter'; +import type {Retrier, RetryDecision} from '../../src/api/retrier'; + +describe('execute', () => { + it('should succeed when call succeeds on first attempt', async () => { + let called = false; + await execute(() => { + called = true; + return Promise.resolve(); + }); + expect(called).toBe(true); + }); + + it('should propagate errors when no retrier is configured', async () => { + const testError = new Error('test error'); + await expect(execute(() => Promise.reject(testError))).rejects.toBe( + testError + ); + }); + + it('should disable retries with withDisableRetry', async () => { + const testError = new Error('test error'); + let callCount = 0; + await expect( + execute(() => { + callCount++; + return Promise.reject(testError); + }, withDisableRetry()) + ).rejects.toBe(testError); + expect(callCount).toBe(1); + }); + + it('should retry on retriable errors then succeed', async () => { + const retriableError = new Error('retriable'); + let callCount = 0; + const errors: (Error | null)[] = [retriableError, retriableError, null]; + + const retrier = retryOn( + new BackoffPolicy({initialMs: 1, randomInt: (): number => 0}), + err => err === retriableError + ); + + await execute( + () => { + const err = errors[callCount]; + callCount++; + if (err !== null) { + return Promise.reject(err); + } + return Promise.resolve(); + }, + withRetrier(() => retrier) + ); + + expect(callCount).toBe(3); + }); + + it('should stop retrying on non-retriable errors', async () => { + const retriableError = new Error('retriable'); + const fatalError = new Error('fatal'); + let callCount = 0; + const errors = [retriableError, fatalError]; + + const retrier = retryOn( + new BackoffPolicy({initialMs: 1, randomInt: (): number => 0}), + err => err === retriableError + ); + + await expect( + execute( + () => { + const err = errors[callCount]; + callCount++; + return Promise.reject(err); + }, + withRetrier(() => retrier) + ) + ).rejects.toBe(fatalError); + + expect(callCount).toBe(2); + }); + + it('should respect timeout option', async () => { + await expect( + execute( + signal => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, 200); + signal.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason as Error); + }); + }), + withTimeout(10) + ) + ).rejects.toThrow('timed out'); + }); + + it('should call rate limiter before each attempt', async () => { + let limiterCalls = 0; + const limiter: Limiter = { + wait(): Promise { + limiterCalls++; + return Promise.resolve(); + }, + }; + + await execute(() => Promise.resolve(), withLimiter(limiter)); + + expect(limiterCalls).toBe(1); + }); + + it('should propagate rate limiter errors', async () => { + const limiterError = new Error('rate limited'); + const limiter: Limiter = { + wait(): Promise { + return Promise.reject(limiterError); + }, + }; + + await expect( + execute(() => Promise.resolve(), withLimiter(limiter)) + ).rejects.toBe(limiterError); + }); + + it('should lazily instantiate retrier on first error', async () => { + let retrierCreated = false; + const mockRetrier: Retrier = { + isRetriable(): RetryDecision { + return {retriable: false, delayMs: 0}; + }, + }; + + await execute( + () => Promise.resolve(), + withRetrier(() => { + retrierCreated = true; + return mockRetrier; + }) + ); + + expect(retrierCreated).toBe(false); + }); +}); diff --git a/packages/databricks/tests/options/options.test.ts b/packages/databricks/tests/options/options.test.ts new file mode 100644 index 00000000..4975eaa9 --- /dev/null +++ b/packages/databricks/tests/options/options.test.ts @@ -0,0 +1,86 @@ +import {describe, it, expect} from 'vitest'; +import { + withHost, + withCredentials, + withTimeout, + withLogger, + withHttpClient, + resolveOptions, +} from '../../src/options/options'; +import type {Credentials, Header} from '@databricks/sdk-auth'; +import type {Logger} from '../../src/options/options'; + +describe('resolveOptions', () => { + it('should return empty options when no options are provided', () => { + const result = resolveOptions(); + expect(result.host).toBeUndefined(); + expect(result.credentials).toBeUndefined(); + expect(result.timeoutMs).toBeUndefined(); + expect(result.logger).toBeUndefined(); + }); + + it('should set host via withHost', () => { + const result = resolveOptions( + withHost('https://my-workspace.cloud.databricks.com') + ); + expect(result.host).toBe('https://my-workspace.cloud.databricks.com'); + }); + + it('should set credentials via withCredentials', () => { + const creds: Credentials = { + authHeaders(): Promise { + return Promise.resolve([{key: 'Authorization', value: 'Bearer token'}]); + }, + }; + const result = resolveOptions(withCredentials(creds)); + expect(result.credentials).toBe(creds); + }); + + it('should set timeout via withTimeout', () => { + const result = resolveOptions(withTimeout(5000)); + expect(result.timeoutMs).toBe(5000); + }); + + it('should set logger via withLogger', () => { + const logger: Logger = { + debug(): void { + // Intentionally empty. + }, + info(): void { + // Intentionally empty. + }, + warn(): void { + // Intentionally empty. + }, + error(): void { + // Intentionally empty. + }, + }; + const result = resolveOptions(withLogger(logger)); + expect(result.logger).toBe(logger); + }); + + it('should set httpClient via withHttpClient', () => { + const mockFetch = (): Promise => + Promise.resolve(new Response('ok')); + const result = resolveOptions(withHttpClient(mockFetch)); + expect(result.httpClient).toBe(mockFetch); + }); + + it('should allow chaining multiple options', () => { + const result = resolveOptions( + withHost('https://example.com'), + withTimeout(3000) + ); + expect(result.host).toBe('https://example.com'); + expect(result.timeoutMs).toBe(3000); + }); + + it('should allow later options to override earlier ones', () => { + const result = resolveOptions( + withHost('https://first.com'), + withHost('https://second.com') + ); + expect(result.host).toBe('https://second.com'); + }); +}); diff --git a/packages/databricks/tests/tsconfig.json b/packages/databricks/tests/tsconfig.json index 211186ad..6d40dca4 100644 --- a/packages/databricks/tests/tsconfig.json +++ b/packages/databricks/tests/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "noEmit": true, - "rootDir": ".." + "outDir": "../dist", + "rootDir": "..", + "types": ["node"] }, - "include": [".", "../src"], - "exclude": ["../dist", "../node_modules"] + "include": ["**/*.ts", "../src/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/packages/databricks/tsconfig.json b/packages/databricks/tsconfig.json index 9b8dd1b0..86d44a06 100644 --- a/packages/databricks/tsconfig.json +++ b/packages/databricks/tsconfig.json @@ -4,6 +4,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["src"], - "exclude": ["dist", "node_modules", "tests"] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] } diff --git a/packages/databricks/vitest.config.ts b/packages/databricks/vitest.config.ts new file mode 100644 index 00000000..4782cf19 --- /dev/null +++ b/packages/databricks/vitest.config.ts @@ -0,0 +1,15 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + // Default environment is Node.js. + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/index.ts'], + }, + }, +});