diff --git a/cspell.json b/cspell.json index 7181203fad..8aa54f8759 100644 --- a/cspell.json +++ b/cspell.json @@ -62,13 +62,17 @@ "msgspec", "mypackage", "mydb", + "myvarname", "nbformat", "nbinsx", "numpy", "pgsql", "pids", "Pids", + "PKCE", + "pkce", "plotly", + "pyformat", "pyenv", "pylsp", "PYTHONHOME", @@ -77,6 +81,7 @@ "rootpass", "scikit", "scipy", + "Segoe", "sklearn", "slugification", "slugified", @@ -91,6 +96,7 @@ "Trino", "unconfigured", "Unconfigured", + "unuse", "unittests", "vegalite", "venv", diff --git a/package-lock.json b/package-lock.json index cb2372257b..57df1cdd8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "cross-fetch": "^3.1.5", "d3-format": "^3.1.0", "encoding": "^0.1.13", + "express": "^5.2.1", "fast-deep-equal": "^2.0.1", "format-util": "^1.0.5", "fs-extra": "^4.0.3", @@ -58,6 +59,8 @@ "node-fetch": "^2.6.7", "node-gyp-build": "^4.6.0", "node-stream-zip": "^1.6.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "path-browserify": "^1.0.1", "pdfkit": "^0.13.0", "pidtree": "^0.6.0", @@ -88,6 +91,7 @@ "tailwind-merge": "^3.3.1", "tcp-port-used": "^1.0.1", "tmp": "^0.2.4", + "ts-dedent": "^2.2.0", "url-parse": "^1.5.10", "uuid": "^13.0.2", "vega": "^6.2.0", @@ -119,6 +123,7 @@ "@types/dedent": "^0.7.0", "@types/del": "^4.0.0", "@types/event-stream": "^3.3.33", + "@types/express": "^5.0.6", "@types/format-util": "^1.0.2", "@types/fs-extra": "^5.0.1", "@types/get-port": "^3.2.0", @@ -132,6 +137,8 @@ "@types/nock": "^10.0.3", "@types/node": "^22.15.1", "@types/node-fetch": "^2.6.12", + "@types/passport": "^1.0.17", + "@types/passport-google-oauth20": "^2.0.17", "@types/pdfkit": "^0.11.0", "@types/promisify-node": "^0.4.0", "@types/react": "^16.4.14", @@ -7162,6 +7169,17 @@ "@types/underscore": "*" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz", @@ -7186,6 +7204,16 @@ "@types/chai": "*" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.12", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", @@ -7485,6 +7513,31 @@ "@types/node": "*" } }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/format-util": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/format-util/-/format-util-1.0.2.tgz", @@ -7540,6 +7593,13 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -7716,6 +7776,50 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "license": "MIT" }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/pdfkit": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.11.2.tgz", @@ -7753,6 +7857,20 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "16.14.24", "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.24.tgz", @@ -7822,6 +7940,27 @@ "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==", "dev": true }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/sinon": { "version": "10.0.15", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.15.tgz", @@ -10027,6 +10166,15 @@ } ] }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.8.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", @@ -10182,6 +10330,46 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -10556,6 +10744,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/c8": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", @@ -10818,7 +11015,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -11682,7 +11878,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "dev": true, "engines": { "node": ">=18" }, @@ -11695,7 +11890,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -11723,6 +11917,24 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -13296,7 +13508,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -13642,7 +13853,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -13703,7 +13913,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -14128,7 +14337,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { @@ -14974,6 +15182,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -15169,6 +15386,105 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ext": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", @@ -15396,6 +15712,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -15670,6 +16007,15 @@ "node": ">=12.20.0" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -17082,20 +17428,23 @@ "optional": true }, "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/http-proxy-agent": { @@ -17360,6 +17709,15 @@ "node": ">=8" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -17818,6 +18176,12 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -21985,12 +22349,23 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-source-map": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz", @@ -23720,6 +24095,12 @@ "node": ">=6" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -23886,7 +24267,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -24270,7 +24650,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -24285,6 +24664,64 @@ "node": ">=0.10.0" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -24399,6 +24836,11 @@ "node": "*" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pbkdf2": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", @@ -25230,6 +25672,19 @@ "node": ">= 8" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -25318,7 +25773,6 @@ "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -25403,6 +25857,46 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -26339,6 +26833,32 @@ "points-on-path": "^0.2.1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -26790,6 +27310,72 @@ "node": ">= 10.13.0" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", @@ -26805,6 +27391,25 @@ "node": ">=20.0.0" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -26851,7 +27456,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, "license": "ISC" }, "node_modules/sha.js": { @@ -26928,7 +27532,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -26947,7 +27550,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -26963,7 +27565,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -26981,7 +27582,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -27587,10 +28187,9 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -28672,7 +29271,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -29077,7 +29675,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -29092,7 +29689,6 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -29102,7 +29698,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -29241,6 +29836,12 @@ "integrity": "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==", "license": "MIT" }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -29391,6 +29992,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -29504,6 +30114,15 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", @@ -29592,7 +30211,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -36304,6 +36922,16 @@ "@types/underscore": "*" } }, + "@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, "@types/chai": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.6.tgz", @@ -36328,6 +36956,15 @@ "@types/chai": "*" } }, + "@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/cors": { "version": "2.8.12", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", @@ -36594,6 +37231,29 @@ "@types/node": "*" } }, + "@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "@types/format-util": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/format-util/-/format-util-1.0.2.tgz", @@ -36647,6 +37307,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -36804,6 +37470,46 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==" }, + "@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, + "@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "@types/pdfkit": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.11.2.tgz", @@ -36839,6 +37545,18 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==" }, + "@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, "@types/react": { "version": "16.14.24", "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.24.tgz", @@ -36908,6 +37626,25 @@ "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==", "dev": true }, + "@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "requires": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "@types/sinon": { "version": "10.0.15", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.15.tgz", @@ -38480,6 +39217,11 @@ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "baseline-browser-mapping": { "version": "2.8.15", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.15.tgz", @@ -38590,6 +39332,32 @@ "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "dev": true }, + "body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "requires": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "dependencies": { + "iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -38880,6 +39648,11 @@ "run-applescript": "^5.0.0" } }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, "c8": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", @@ -39061,7 +39834,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "requires": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -39679,14 +40451,12 @@ "content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "dev": true + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==" }, "content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" }, "continuation-local-storage": { "version": "3.2.1", @@ -39712,6 +40482,16 @@ } } }, + "cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, + "cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==" + }, "cookies": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", @@ -40839,8 +41619,7 @@ "depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, "dequal": { "version": "2.0.3", @@ -41113,8 +41892,7 @@ "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "electron-to-chromium": { "version": "1.5.233", @@ -41165,8 +41943,7 @@ "encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, "encoding": { "version": "0.1.13", @@ -41504,8 +42281,7 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "escape-string-regexp": { "version": "1.0.5", @@ -42109,6 +42885,11 @@ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, "event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -42250,6 +43031,75 @@ } } }, + "express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "requires": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "dependencies": { + "accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "requires": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + } + }, + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + }, + "negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" + } + } + }, "ext": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.6.0.tgz", @@ -42418,6 +43268,19 @@ "to-regex-range": "^5.0.1" } }, + "finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "requires": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + } + }, "find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -42617,6 +43480,11 @@ "fetch-blob": "^3.1.2" } }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, "fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -43619,16 +44487,15 @@ "optional": true }, "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" } }, "http-proxy-agent": { @@ -43805,6 +44672,11 @@ "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==" }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, "is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", @@ -44107,6 +44979,11 @@ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, + "is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" + }, "is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -46970,8 +47847,12 @@ "media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==" + }, + "merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" }, "merge-source-map": { "version": "1.0.4", @@ -48236,6 +49117,11 @@ } } }, + "oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -48354,7 +49240,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "requires": { "ee-first": "1.1.1" } @@ -48608,14 +49493,48 @@ "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, "pascalcase": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==" }, + "passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + } + }, + "passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, + "passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "requires": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==" + }, "path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -48699,6 +49618,11 @@ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "pbkdf2": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", @@ -49278,6 +50202,15 @@ "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -49358,7 +50291,6 @@ "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "dev": true, "requires": { "side-channel": "^1.1.0" } @@ -49417,6 +50349,32 @@ "safe-buffer": "^5.1.0" } }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "requires": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -50106,6 +51064,25 @@ "points-on-path": "^0.2.1" } }, + "router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "requires": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "dependencies": { + "path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" + } + } + }, "rsvp": { "version": "4.8.5", "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", @@ -50427,6 +51404,49 @@ "sver": "^1.8.3" } }, + "send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "requires": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "dependencies": { + "fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" + }, + "mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" + }, + "mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "requires": { + "mime-db": "^1.54.0" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", @@ -50438,6 +51458,17 @@ "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true }, + "serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "requires": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + } + }, "set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -50477,8 +51508,7 @@ "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "sha.js": { "version": "2.4.12", @@ -50535,7 +51565,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "requires": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -50548,7 +51577,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "requires": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" @@ -50558,7 +51586,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "requires": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -50570,7 +51597,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "requires": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -51005,10 +52031,9 @@ } }, "statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" }, "stdin-discarder": { "version": "0.2.2", @@ -51822,8 +52847,7 @@ "toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, "topojson-client": { "version": "3.1.0", @@ -52118,7 +53142,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, "requires": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -52128,14 +53151,12 @@ "mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" }, "mime-types": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "dev": true, "requires": { "mime-db": "^1.54.0" } @@ -52240,6 +53261,11 @@ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.2.tgz", "integrity": "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==" }, + "uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -52373,6 +53399,11 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, "untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -52454,6 +53485,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, "uuid": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", @@ -52528,8 +53564,7 @@ "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, "vega": { "version": "6.2.0", diff --git a/package.json b/package.json index 6f84ce4fc7..ba1a371099 100644 --- a/package.json +++ b/package.json @@ -144,6 +144,11 @@ "category": "Deepnote", "icon": "$(plug)" }, + { + "command": "deepnote.authenticateIntegration", + "title": "%deepnote.commands.authenticateIntegration.title%", + "category": "Deepnote" + }, { "command": "deepnote.openInDeepnote", "title": "Open in Deepnote", @@ -1199,6 +1204,10 @@ "title": "%deepnote.commandPalette.deepnote.replayPylanceLog.title%", "when": "deepnote.development && isWorkspaceTrusted" }, + { + "command": "deepnote.authenticateIntegration", + "when": "false" + }, { "command": "deepnote.manageAccessToKernels", "title": "%deepnote.command.manageAccessToKernels%" @@ -2704,6 +2713,7 @@ "cross-fetch": "^3.1.5", "d3-format": "^3.1.0", "encoding": "^0.1.13", + "express": "^5.2.1", "fast-deep-equal": "^2.0.1", "format-util": "^1.0.5", "fs-extra": "^4.0.3", @@ -2720,6 +2730,8 @@ "node-fetch": "^2.6.7", "node-gyp-build": "^4.6.0", "node-stream-zip": "^1.6.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "path-browserify": "^1.0.1", "pdfkit": "^0.13.0", "pidtree": "^0.6.0", @@ -2750,6 +2762,7 @@ "tailwind-merge": "^3.3.1", "tcp-port-used": "^1.0.1", "tmp": "^0.2.4", + "ts-dedent": "^2.2.0", "url-parse": "^1.5.10", "uuid": "^13.0.2", "vega": "^6.2.0", @@ -2781,6 +2794,7 @@ "@types/dedent": "^0.7.0", "@types/del": "^4.0.0", "@types/event-stream": "^3.3.33", + "@types/express": "^5.0.6", "@types/format-util": "^1.0.2", "@types/fs-extra": "^5.0.1", "@types/get-port": "^3.2.0", @@ -2794,6 +2808,8 @@ "@types/nock": "^10.0.3", "@types/node": "^22.15.1", "@types/node-fetch": "^2.6.12", + "@types/passport": "^1.0.17", + "@types/passport-google-oauth20": "^2.0.17", "@types/pdfkit": "^0.11.0", "@types/promisify-node": "^0.4.0", "@types/react": "^16.4.14", diff --git a/package.nls.json b/package.nls.json index 35ee95ae66..79fdb1ed8e 100644 --- a/package.nls.json +++ b/package.nls.json @@ -253,6 +253,7 @@ "deepnote.commands.enableSnapshots.title": "Enable Snapshots", "deepnote.commands.disableSnapshots.title": "Disable Snapshots", "deepnote.commands.manageIntegrations.title": "Manage Integrations", + "deepnote.commands.authenticateIntegration.title": "Authenticate Integration", "deepnote.commands.newProject.title": "New Project", "deepnote.commands.importNotebook.title": "Import Notebook", "deepnote.commands.importJupyterNotebook.title": "Import Jupyter Notebook", diff --git a/src/kernels/execution/cellExecution.federatedAuth.unit.test.ts b/src/kernels/execution/cellExecution.federatedAuth.unit.test.ts new file mode 100644 index 0000000000..361df44b82 --- /dev/null +++ b/src/kernels/execution/cellExecution.federatedAuth.unit.test.ts @@ -0,0 +1,327 @@ +// Unit tests for the federated-auth branch of `CellExecution.execute()`; surrounding VS Code machinery is stubbed and `requestExecute` is captured on a Sinon spy (no socket simulation). + +import type { Kernel, KernelMessage } from '@jupyterlab/services'; +import type { IKernelConnection } from '@jupyterlab/services/lib/kernel/kernel'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; +import { NotebookCell, NotebookCellKind, Uri } from 'vscode'; + +import { CancellationTokenSource } from 'vscode'; +import { dispose } from '../../platform/common/utils/lifecycle'; +import { createDeferred, Deferred } from '../../platform/common/utils/async'; +import { IDisposable } from '../../platform/common/types'; +import { + IFederatedAuthSqlBlockCodeGenerator, + NotAuthenticatedError, + OAuthClientMisconfiguredError +} from '../../notebooks/deepnote/integrations/types'; +import { IKernelController, IKernelSession, KernelConnectionMetadata } from '../types'; +import { createKernelController } from '../../test/datascience/notebook/executionHelper'; +import { CellExecution, CellExecutionFactory } from './cellExecution'; +import { CellExecutionMessageHandlerService } from './cellExecutionMessageHandlerService'; + +const successReply: KernelMessage.IExecuteReplyMsg = { + channel: 'shell', + content: { + execution_count: 1, + status: 'ok', + user_expressions: {} + }, + header: { + msg_id: '1', + msg_type: 'execute_reply', + session: '1', + username: '1', + date: new Date().toString(), + version: '5.0' + } as KernelMessage.IExecuteReplyMsg['header'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parent_header: {} as any +}; + +function createStubMessageHandler(): CellExecutionMessageHandlerService { + const stubListener = { + onErrorHandlingExecuteRequestIOPubMessage: () => ({ dispose: () => undefined }), + completed: Promise.resolve(), + dispose: () => undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + return { + registerListenerForExecution: () => stubListener, + registerListenerForResumingExecution: () => stubListener, + dispose: () => undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as CellExecutionMessageHandlerService; +} + +suite('CellExecution federated-auth branch', () => { + let disposables: IDisposable[] = []; + let controller: IKernelController; + let requestListener: CellExecutionMessageHandlerService; + let session: IKernelSession; + let kernel: IKernelConnection; + let request: Kernel.IShellFuture; + let requestDone: Deferred; + let preludeRequest: Kernel.IShellFuture; + let preludeDone: Deferred; + let requestExecuteSpy: sinon.SinonSpy; + let connectionMetadata: KernelConnectionMetadata; + let cell: NotebookCell; + + /** Build a minimal mocked NotebookCell populated for `CellExecution`'s constructor + execute. */ + function buildCell(opts: { + content: string; + languageId?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + metadata?: Record; + }): NotebookCell { + const document = { + getText: () => opts.content, + languageId: opts.languageId ?? 'sql', + isClosed: false, + uri: Uri.parse(`untitled:test-cell-${Math.random()}.py`) + }; + const notebook = { + isClosed: false, + uri: Uri.parse('untitled:test-notebook.deepnote') + }; + return { + index: 0, + kind: NotebookCellKind.Code, + document, + notebook, + metadata: opts.metadata ?? {}, + outputs: [], + executionSummary: undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as NotebookCell; + } + + setup(() => { + disposables = []; + const tokenSource = new CancellationTokenSource(); + disposables.push(tokenSource); + + controller = createKernelController(); + requestListener = createStubMessageHandler(); + + session = mock(); + kernel = mock(); + request = mock>(); + preludeRequest = mock>(); + requestDone = createDeferred(); + preludeDone = createDeferred(); + + when(request.dispose()).thenReturn(); + when(request.done).thenReturn(requestDone.promise); + when(preludeRequest.dispose()).thenReturn(); + when(preludeRequest.done).thenReturn(preludeDone.promise); + + when(session.kernel).thenReturn(instance(kernel)); + when(session.isDisposed).thenReturn(false); + when(session.kind).thenReturn('localRaw'); + when(session.status).thenReturn('idle'); + when(kernel.isDisposed).thenReturn(false); + + // Federated branch: prelude = `requestExecute(args, true)`, main = `requestExecute(args, false, metadata)`. Differentiate by dispose flag. + requestExecuteSpy = sinon.spy( + (_args: KernelMessage.IExecuteRequestMsg['content'], disposeOnDone: boolean, _metadata: unknown) => { + return disposeOnDone ? instance(preludeRequest) : instance(request); + } + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (when(kernel.requestExecute(anything(), anything(), anything())) as any).thenCall(requestExecuteSpy); + // Main execute resolves immediately; prelude deferred is left pending so individual tests drive its resolution explicitly. + requestDone.resolve(successReply); + + connectionMetadata = { + id: 'test-kernel', + kind: 'startUsingLocalKernelSpec', + interpreter: undefined + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any as KernelConnectionMetadata; + + cell = buildCell({ content: 'SELECT 1', languageId: 'sql' }); + }); + + teardown(() => { + disposables = dispose(disposables); + }); + + function createExecution(generator?: IFederatedAuthSqlBlockCodeGenerator) { + const factory = new CellExecutionFactory(controller, requestListener, generator); + const execution = factory.create(cell, undefined, connectionMetadata) as CellExecution; + disposables.push(execution); + return execution; + } + + function assertMainExecuteShape(call: sinon.SinonSpyCall): void { + const [args, dispose] = call.args; + const content = args as KernelMessage.IExecuteRequestMsg['content']; + assert.deepStrictEqual( + { silent: content.silent, store_history: content.store_history, dispose }, + { silent: false, store_history: true, dispose: false } + ); + } + + test('when generator is undefined (web): never calls generate, single requestExecute', async () => { + const execution = createExecution(undefined); + await execution.start(instance(session)); + await execution.result.catch(() => undefined); + + const calls = requestExecuteSpy.getCalls(); + assert.strictEqual(calls.length, 1, `expected exactly 1 requestExecute call, got ${calls.length}`); + assertMainExecuteShape(calls[0]); + }); + + test('when generate() returns undefined: no silent pre-execute, single main requestExecute', async () => { + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().resolves(undefined) + }; + const execution = createExecution(generator); + await execution.start(instance(session)); + await execution.result.catch(() => undefined); + + sinon.assert.calledOnce(generator.generate as sinon.SinonStub); + const calls = requestExecuteSpy.getCalls(); + assert.strictEqual(calls.length, 1, `expected exactly 1 requestExecute call, got ${calls.length}`); + assertMainExecuteShape(calls[0]); + }); + + test('when generate() returns {prelude, cellCode}: main requestExecute waits for prelude .done, omits the access token', async () => { + // Catches: dropping the `await` on prelude `.done` would let the main execute fire before the prelude completes, + // and leaking the access token into `cellCode` would let it surface in execution-history JSON. + const ACCESS_TOKEN = 'access-token-secret-do-not-log'; + const prelude = `__deepnote_federated_sql_connection__abc = '{"params":{"access_token":"${ACCESS_TOKEN}"}}'`; + const cellCode = `_dntk.execute_sql_with_connection_json('SELECT 1', __deepnote_federated_sql_connection__abc)`; + + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().resolves({ prelude, cellCode }) + }; + const execution = createExecution(generator); + + // Kick off without awaiting; the main execute must NOT issue while prelude is unresolved. + const startPromise = execution.start(instance(session)); + + // Flush pending microtasks; I/O is mocked. + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + + sinon.assert.calledOnce(requestExecuteSpy); + const [preludeArgs, preludeDispose] = requestExecuteSpy.getCalls()[0].args; + const preludeContent = preludeArgs as KernelMessage.IExecuteRequestMsg['content']; + assert.deepStrictEqual( + { + code: preludeContent.code, + silent: preludeContent.silent, + store_history: preludeContent.store_history, + allow_stdin: preludeContent.allow_stdin, + stop_on_error: preludeContent.stop_on_error, + dispose: preludeDispose + }, + { + code: prelude, + silent: true, + store_history: false, + allow_stdin: false, + stop_on_error: true, + dispose: true + } + ); + + // Resolve the prelude — the main `requestExecute` should fire and the cell should complete. + preludeDone.resolve(successReply); + if (startPromise) { + await startPromise.catch(() => undefined); + } + await execution.result.catch(() => undefined); + + sinon.assert.calledTwice(requestExecuteSpy); + const [mainArgs, mainDispose] = requestExecuteSpy.getCalls()[1].args; + const mainContent = mainArgs as KernelMessage.IExecuteRequestMsg['content']; + assert.deepStrictEqual( + { + code: mainContent.code, + silent: mainContent.silent, + store_history: mainContent.store_history, + dispose: mainDispose + }, + { code: cellCode, silent: false, store_history: true, dispose: false } + ); + + // Critical M3 invariant: the access token must not appear in the main execute's code. + assert.isFalse( + mainContent.code.includes(ACCESS_TOKEN), + `Main execute code unexpectedly contains the access token: ${mainContent.code}` + ); + }); + + test('when prelude requestExecute rejects: main requestExecute is NOT called and cell fails', async () => { + // Catches: dropping the try/catch around `await prelude.done` in `execute()` would either swallow the rejection or let the main execute through. + const prelude = `__deepnote_federated_sql_connection__abc = '{}'`; + const cellCode = `_dntk.execute_sql_with_connection_json('SELECT 1', __deepnote_federated_sql_connection__abc)`; + + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().resolves({ prelude, cellCode }) + }; + const execution = createExecution(generator); + + const preludeRejection = new Error('kernel error during prelude'); + // Pre-reject so the implementation observes it on its first await. + preludeDone.reject(preludeRejection); + let caught: unknown; + const startPromise = execution.start(instance(session)); + if (startPromise) { + await startPromise.catch((err) => { + caught = err; + }); + } + await execution.result.catch(() => undefined); + + // Exactly one `requestExecute` call (prelude); main must NOT be called. + sinon.assert.calledOnce(requestExecuteSpy); + const [preludeArgs, preludeDispose] = requestExecuteSpy.getCalls()[0].args; + assert.deepStrictEqual( + { + silent: (preludeArgs as KernelMessage.IExecuteRequestMsg['content']).silent, + dispose: preludeDispose + }, + { silent: true, dispose: true }, + 'the single call should be the silent prelude' + ); + + // The cell-execution failure should surface the underlying error. + assert(caught instanceof Error); + assert.strictEqual(caught.message, preludeRejection.message); + }); + + ( + [ + ['NotAuthenticatedError', () => new NotAuthenticatedError('My BigQuery'), 'not authenticated'], + ['OAuthClientMisconfiguredError', () => new OAuthClientMisconfiguredError('My BigQuery'), 'misconfigured'] + ] as const + ).forEach(([label, buildError, expectedFragment]) => { + test(`when generate() throws ${label}: cell fails with the typed message and main requestExecute is NOT called`, async () => { + const generator: IFederatedAuthSqlBlockCodeGenerator = { + generate: sinon.stub().rejects(buildError()) + }; + const execution = createExecution(generator); + + let caught: unknown; + const startPromise = execution.start(instance(session)); + if (startPromise) { + await startPromise.catch((err) => { + caught = err; + }); + } + + assert(caught instanceof Error); + assert.include(caught.message.toLowerCase(), expectedFragment); + sinon.assert.notCalled(requestExecuteSpy); + }); + }); +}); diff --git a/src/kernels/execution/cellExecution.ts b/src/kernels/execution/cellExecution.ts index 5bedc16970..8012310623 100644 --- a/src/kernels/execution/cellExecution.ts +++ b/src/kernels/execution/cellExecution.ts @@ -34,14 +34,20 @@ import { getCachedSysPrefix } from '../../platform/interpreter/helpers'; import { getCellMetadata } from '../../platform/common/utils'; import { NotebookCellExecutionState, notebookCellExecutions } from '../../platform/notebooks/cellExecutionStateService'; import { DeepnoteDataConverter } from '../../notebooks/deepnote/deepnoteDataConverter'; +import { + IFederatedAuthSqlBlockCodeGenerator, + NotAuthenticatedError, + OAuthClientMisconfiguredError +} from '../../notebooks/deepnote/integrations/types'; +import { Integrations } from '../../platform/common/utils/localize'; -/** - * Factory for CellExecution objects. - */ +/** Factory for CellExecution objects. Built outside inversify (see `NotebookKernelExecution`); optional deps are plain ctor args. */ export class CellExecutionFactory { constructor( private readonly controller: IKernelController, - private readonly requestListener: CellExecutionMessageHandlerService + private readonly requestListener: CellExecutionMessageHandlerService, + /** Federated-auth generator; `undefined` on web (symbol unbound) so the federated branch in {@link CellExecution.execute} is skipped via optional chaining. */ + private readonly federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) {} public create( @@ -51,7 +57,15 @@ export class CellExecutionFactory { info?: ResumeCellExecutionInformation ) { // eslint-disable-next-line @typescript-eslint/no-use-before-define - return CellExecution.fromCell(cell, code, metadata, this.controller, this.requestListener, info); + return CellExecution.fromCell( + cell, + code, + metadata, + this.controller, + this.requestListener, + info, + this.federatedAuthSqlBlockCodeGenerator + ); } } @@ -99,7 +113,8 @@ export class CellExecution implements ICellExecution, IDisposable { private readonly kernelConnection: Readonly, private readonly controller: IKernelController, private readonly requestListener: CellExecutionMessageHandlerService, - private readonly resumeExecution?: ResumeCellExecutionInformation + private readonly resumeExecution?: ResumeCellExecutionInformation, + private readonly federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) { workspace.onDidCloseTextDocument( (e) => { @@ -147,9 +162,18 @@ export class CellExecution implements ICellExecution, IDisposable { metadata: Readonly, controller: IKernelController, requestListener: CellExecutionMessageHandlerService, - info?: ResumeCellExecutionInformation + info?: ResumeCellExecutionInformation, + federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) { - return new CellExecution(cell, code, metadata, controller, requestListener, info); + return new CellExecution( + cell, + code, + metadata, + controller, + requestListener, + info, + federatedAuthSqlBlockCodeGenerator + ); } public async start(session: IKernelSession) { this.session = session; @@ -393,6 +417,26 @@ export class CellExecution implements ICellExecution, IDisposable { return !this.cell.document.isClosed; } + /** Surfaces a federated-auth `generate()` failure as a cell-execution failure with a clear message. */ + private handleFederatedGenerateError(ex: unknown) { + if (ex instanceof NotAuthenticatedError) { + logger.warn( + `Federated BigQuery integration "${ex.integrationName}" is not authenticated; cell Index ${this.cell.index} cannot run.` + ); + return this.completedWithErrors(new Error(Integrations.bigQueryNotAuthenticated(ex.integrationName))); + } + if (ex instanceof OAuthClientMisconfiguredError) { + // `invalid_client` / `unauthorized_client`: wrong clientId/clientSecret. Surface a dedicated message; re-auth won't help. + logger.warn( + `Federated BigQuery integration "${ex.integrationName}" has misconfigured OAuth client; cell Index ${this.cell.index} cannot run.` + ); + return this.completedWithErrors(new Error(Integrations.federatedAuthOAuthClientMisconfigured)); + } + logger.error(`Federated SQL code generation failed for cell Index ${this.cell.index}`, ex); + // Wrap non-Error throws so `completedWithErrors` gets a `Partial` without an `as` cast. + return this.completedWithErrors(ex instanceof Error ? ex : new Error(String(ex))); + } + private async execute(code: string, session: IKernelSession) { if (!session.kernel) { throw new Error('No kernel available to execute code'); @@ -420,9 +464,6 @@ export class CellExecution implements ICellExecution, IDisposable { const dataConverter = new DeepnoteDataConverter(); const deepnoteBlock = dataConverter.convertCellToBlock(cellData, this.cell.index); - logger.info(`Cell ${this.cell.index}: Using createPythonCode for ${deepnoteBlock.type} block`); - code = createPythonCode(deepnoteBlock); - // Generate metadata from our cell (some kernels expect this.) // eslint-disable-next-line @typescript-eslint/no-explicit-any const metadata: any = { @@ -431,6 +472,38 @@ export class CellExecution implements ICellExecution, IDisposable { }; const kernelConnection = session.kernel; + + // Federated-auth (BigQuery + google-oauth): silent pre-execute sets a kernel-global with the connection JSON (token kept out of `In[]`), then main execute uses the variable. `undefined` means non-federated or web — fall back to `createPythonCode`. + let federated: { prelude: string; cellCode: string } | undefined; + try { + federated = await this.federatedAuthSqlBlockCodeGenerator?.generate(deepnoteBlock); + } catch (ex) { + return this.handleFederatedGenerateError(ex); + } + if (federated) { + logger.info(`Cell ${this.cell.index}: Using federated BigQuery code path`); + try { + await kernelConnection.requestExecute( + { + code: federated.prelude, + silent: true, + store_history: false, + allow_stdin: false, + stop_on_error: true + }, + /* dispose: */ true, + /* metadata: */ undefined + ).done; + } catch (ex) { + logger.error(`Federated pre-execute failed for cell Index ${this.cell.index}`, ex); + return this.completedWithErrors(ex); + } + code = federated.cellCode; + } else { + logger.info(`Cell ${this.cell.index}: Using createPythonCode for ${deepnoteBlock.type} block`); + code = createPythonCode(deepnoteBlock); + } + try { // At this point we're about to ACTUALLY execute some code. Fire an event to indicate that notebookCellExecutions.changeCellState(this.cell, NotebookCellExecutionState.Executing); diff --git a/src/kernels/kernelExecution.ts b/src/kernels/kernelExecution.ts index 9158d7a2cf..99b36ce964 100644 --- a/src/kernels/kernelExecution.ts +++ b/src/kernels/kernelExecution.ts @@ -46,6 +46,7 @@ import { CodeExecution } from './execution/codeExecution'; import type { ICodeExecution } from './execution/types'; import { NotebookCellExecutionState, notebookCellExecutions } from '../platform/notebooks/cellExecutionStateService'; import { ISnapshotMetadataService } from '../notebooks/deepnote/snapshots/snapshotService'; +import { IFederatedAuthSqlBlockCodeGenerator } from '../notebooks/deepnote/integrations/types'; /** * Everything in this classes gets disposed via the `onWillCancel` hook. @@ -67,7 +68,9 @@ export class NotebookKernelExecution implements INotebookKernelExecution { context: IExtensionContext, formatters: ITracebackFormatter[], private readonly notebook: NotebookDocument, - private readonly snapshotService?: ISnapshotMetadataService + private readonly snapshotService?: ISnapshotMetadataService, + /** Federated-auth generator; `undefined` on web (symbol unbound). Threaded into `CellExecutionFactory` below. */ + private readonly federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) { const requestListener = new CellExecutionMessageHandlerService( kernel.controller, @@ -76,7 +79,11 @@ export class NotebookKernelExecution implements INotebookKernelExecution { notebook ); this.disposables.push(requestListener); - this.executionFactory = new CellExecutionFactory(kernel.controller, requestListener); + this.executionFactory = new CellExecutionFactory( + kernel.controller, + requestListener, + this.federatedAuthSqlBlockCodeGenerator + ); notebookCellExecutions.onDidChangeNotebookCellExecutionState((e) => { if ( e.cell.notebook === kernel.notebook && diff --git a/src/kernels/kernelProvider.node.ts b/src/kernels/kernelProvider.node.ts index caf3c30028..9d149e5a4e 100644 --- a/src/kernels/kernelProvider.node.ts +++ b/src/kernels/kernelProvider.node.ts @@ -34,6 +34,8 @@ import { getDisplayPath } from '../platform/common/platform/fs-paths.node'; import { IRawNotebookSupportedService } from './raw/types'; // eslint-disable-next-line import/no-restricted-paths import { ISnapshotMetadataService } from '../notebooks/deepnote/snapshots/snapshotService'; +// eslint-disable-next-line import/no-restricted-paths +import { IFederatedAuthSqlBlockCodeGenerator } from '../notebooks/deepnote/integrations/types'; /** * Node version of a kernel provider. Needed in order to create the node version of a kernel. @@ -54,7 +56,10 @@ export class KernelProvider extends BaseCoreKernelProvider { @inject(IReplNotebookTrackerService) private readonly replTracker: IReplNotebookTrackerService, @inject(IKernelWorkingDirectory) private readonly kernelWorkingDirectory: IKernelWorkingDirectory, @inject(IRawNotebookSupportedService) private readonly rawKernelSupported: IRawNotebookSupportedService, - @inject(ISnapshotMetadataService) @optional() private readonly snapshotService?: ISnapshotMetadataService + @inject(ISnapshotMetadataService) @optional() private readonly snapshotService?: ISnapshotMetadataService, + @inject(IFederatedAuthSqlBlockCodeGenerator) + @optional() + private readonly federatedAuthSqlBlockCodeGenerator?: IFederatedAuthSqlBlockCodeGenerator ) { super(asyncDisposables, disposables); disposables.push(jupyterServerUriStorage.onDidRemove(this.handleServerRemoval.bind(this))); @@ -115,7 +120,14 @@ export class KernelProvider extends BaseCoreKernelProvider { this.executions.set( kernel, - new NotebookKernelExecution(kernel, this.context, this.formatters, notebook, this.snapshotService) + new NotebookKernelExecution( + kernel, + this.context, + this.formatters, + notebook, + this.snapshotService, + this.federatedAuthSqlBlockCodeGenerator + ) ); this.asyncDisposables.push(kernel); this.storeKernel(notebook, options, kernel); diff --git a/src/messageTypes.ts b/src/messageTypes.ts index 5ef6c37248..1ae9482fa3 100644 --- a/src/messageTypes.ts +++ b/src/messageTypes.ts @@ -228,6 +228,28 @@ export type LocalizedMessages = { integrationsBigQueryCredentialsLabel: string; integrationsBigQueryCredentialsPlaceholder: string; integrationsBigQueryCredentialsRequired: string; + // BigQuery federated-auth form strings + integrationsBigQueryAuthMethodLabel: string; + integrationsBigQueryAuthMethodServiceAccount: string; + integrationsBigQueryAuthMethodGoogleOauth: string; + integrationsBigQueryProjectLabel: string; + integrationsBigQueryProjectPlaceholder: string; + integrationsBigQueryClientIdLabel: string; + integrationsBigQueryClientIdPlaceholder: string; + integrationsBigQueryClientSecretLabel: string; + integrationsBigQueryClientSecretPlaceholder: string; + integrationsBigQueryGoogleOauthHelp: string; + // Federated-auth integration management strings + integrationsAuthenticate: string; + integrationsReauthenticate: string; + integrationsTokenStatusAuthenticated: string; + integrationsTokenStatusDisconnected: string; + integrationsAuthenticating: string; + integrationsAuthenticationSucceeded: string; + integrationsAuthenticationFailed: string; + integrationsBigQueryNotAuthenticated: string; + integrationsFederatedAuthNotSupportedInWeb: string; + integrationsFederatedAuthNotSupportedInRemote: string; // Snowflake form strings integrationsSnowflakeNameLabel: string; integrationsSnowflakeNamePlaceholder: string; diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.ts new file mode 100644 index 0000000000..6eee35d379 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.ts @@ -0,0 +1,135 @@ +import { inject, injectable } from 'inversify'; +import { CancellationError, CancellationToken, ProgressLocation, Uri, commands, env, window } from 'vscode'; + +import { BigQueryAuthMethods } from '@deepnote/database-integrations'; + +import { IExtensionSyncActivationService } from '../../../../platform/activation/types'; +import { Commands } from '../../../../platform/common/constants'; +import { IExtensionContext } from '../../../../platform/common/types'; +import { Integrations } from '../../../../platform/common/utils/localize'; +import { logger } from '../../../../platform/logging'; +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { IFederatedAuthTokenStorage, type FederatedAuthTokenEntry } from '../types'; +import { buildBigQueryGoogleOAuthStrategy, createInMemoryPKCEStore } from './googleOAuthProvider.node'; +import { computeMetadataFingerprint } from './federatedAuthTokenStorage.node'; +import { runOAuthFlow, type RunOAuthFlowParams } from './oauthLoopbackFlow.node'; + +/** Signature of {@link runOAuthFlow}, exposed as a constructor seam so tests can stub the loopback server. */ +export type RunOAuthFlowFn = (params: RunOAuthFlowParams) => Promise<{ refreshToken: string }>; + +/** + * Node-side command handler for `deepnote.authenticateIntegration`: validates the integration, runs the + * loopback OAuth flow, persists the refresh token. Remote VS Code is unsupported because Google "Desktop app" + * OAuth clients only accept `http://127.0.0.1:/auth/callback` redirects. + */ +@injectable() +export class FederatedAuthCommandHandlerNode implements IExtensionSyncActivationService { + constructor( + @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, + @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, + @inject(IFederatedAuthTokenStorage) private readonly tokenStorage: IFederatedAuthTokenStorage, + private readonly runOAuthFlowFn: RunOAuthFlowFn = runOAuthFlow + ) {} + + public activate(): void { + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.AuthenticateIntegration, (integrationId: string) => + this.authenticate(integrationId) + ) + ); + } + + /** Core flow. Public so tests can drive the handler without `commands.executeCommand`. */ + public async authenticate(integrationId: string): Promise { + if (typeof integrationId !== 'string' || integrationId.length === 0) { + logger.warn( + `FederatedAuthCommandHandlerNode: invoked without a valid integrationId (received: ${String( + integrationId + )})` + ); + return; + } + + // Remote VS Code is not supported — see class comment. + if (env.remoteName !== undefined) { + logger.info( + `FederatedAuthCommandHandlerNode: remote scenario detected (${env.remoteName}); aborting federated auth.` + ); + void window.showInformationMessage(Integrations.federatedAuthNotSupportedInRemote); + return; + } + + const integration = await this.integrationStorage.getIntegrationConfig(integrationId); + if (!integration) { + logger.warn(`FederatedAuthCommandHandlerNode: integration "${integrationId}" not found.`); + void window.showErrorMessage(Integrations.federatedAuthIntegrationNotFound(integrationId)); + return; + } + + if (integration.type !== 'big-query' || integration.metadata.authMethod !== BigQueryAuthMethods.GoogleOauth) { + logger.warn( + `FederatedAuthCommandHandlerNode: integration "${integration.name}" is not configured for Google OAuth.` + ); + void window.showErrorMessage(Integrations.federatedAuthIntegrationNotConfiguredForOAuth(integration.name)); + return; + } + + const { clientId, clientSecret, project } = integration.metadata; + const { strategy, completion } = buildBigQueryGoogleOAuthStrategy({ + clientId, + clientSecret, + store: createInMemoryPKCEStore() + }); + + try { + const refreshTokenResult = await window.withProgress( + { + location: ProgressLocation.Notification, + title: Integrations.authenticating(integration.name), + cancellable: true + }, + async (_progress, token: CancellationToken) => { + return this.runOAuthFlowFn({ + integrationId, + strategy, + completion, + token, + onListening: async (startUrl: string) => { + try { + const externalUri = await env.asExternalUri(Uri.parse(startUrl)); + const opened = await env.openExternal(externalUri); + if (!opened) { + logger.warn( + `FederatedAuthCommandHandlerNode: openExternal returned false for ${startUrl}; the user can paste the URL manually.` + ); + } + } catch (err) { + logger.warn( + `FederatedAuthCommandHandlerNode: failed to open browser for ${startUrl}.`, + err + ); + } + } + }); + } + ); + + const entry: FederatedAuthTokenEntry = { + integrationId, + refreshToken: refreshTokenResult.refreshToken, + metadataFingerprint: computeMetadataFingerprint({ clientId, clientSecret, project }) + }; + await this.tokenStorage.save(entry); + + void window.showInformationMessage(Integrations.authenticationSucceeded(integration.name)); + } catch (err) { + if (err instanceof CancellationError) { + logger.info(`FederatedAuthCommandHandlerNode: authentication cancelled for "${integration.name}".`); + return; + } + const message = err instanceof Error ? err.message : String(err); + logger.error(`FederatedAuthCommandHandlerNode: authentication failed for "${integration.name}".`, err); + void window.showErrorMessage(Integrations.authenticationFailed(message)); + } + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.unit.test.ts new file mode 100644 index 0000000000..7ce5b4b569 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node.unit.test.ts @@ -0,0 +1,148 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; +import { CancellationError } from 'vscode'; +import { anyString, anything, instance, mock, when } from 'ts-mockito'; + +import { ConfigurableDatabaseIntegrationConfig } from '../../../../platform/notebooks/deepnote/integrationTypes'; +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { IExtensionContext, IDisposable } from '../../../../platform/common/types'; +import { FederatedAuthCommandHandlerNode } from './federatedAuthCommandHandler.node'; +import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage } from '../types'; +import { computeMetadataFingerprint } from './federatedAuthTokenStorage.node'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../../test/vscode-mock'; +import { + FED_AUTH_FIXTURE, + buildGoogleOauthIntegration, + buildPostgresIntegration, + buildServiceAccountIntegration +} from './federatedAuthTestHelpers'; +import type { RunOAuthFlowParams } from './oauthLoopbackFlow.node'; + +suite('FederatedAuthCommandHandlerNode', () => { + let extensionContext: IExtensionContext; + let integrationStorage: IIntegrationStorage; + let tokenStorage: IFederatedAuthTokenStorage; + let subscriptions: IDisposable[]; + let runOAuthFlowStub: sinon.SinonStub<[RunOAuthFlowParams], Promise<{ refreshToken: string }>>; + let handler: FederatedAuthCommandHandlerNode; + + let integrationStore: Map; + let savedTokens: FederatedAuthTokenEntry[]; + + setup(() => { + resetVSCodeMocks(); + subscriptions = []; + integrationStore = new Map(); + savedTokens = []; + + extensionContext = mock(); + when(extensionContext.subscriptions).thenReturn(subscriptions); + + integrationStorage = { + getIntegrationConfig: async (id: string) => integrationStore.get(id) + } as unknown as IIntegrationStorage; + + tokenStorage = { + save: async (entry: FederatedAuthTokenEntry) => { + savedTokens.push(entry); + } + } as unknown as IFederatedAuthTokenStorage; + + runOAuthFlowStub = sinon.stub<[RunOAuthFlowParams], Promise<{ refreshToken: string }>>(); + runOAuthFlowStub.resolves({ refreshToken: FED_AUTH_FIXTURE.REFRESH_TOKEN }); + + // env.asExternalUri returns the input untouched in the mock. + when(mockedVSCodeNamespaces.env.asExternalUri(anything())).thenCall((uri) => Promise.resolve(uri)); + when(mockedVSCodeNamespaces.env.openExternal(anything())).thenResolve(true as unknown as void); + when(mockedVSCodeNamespaces.env.remoteName).thenReturn(undefined); + + handler = new FederatedAuthCommandHandlerNode( + instance(extensionContext), + integrationStorage, + tokenStorage, + runOAuthFlowStub + ); + }); + + test('shows remote-not-supported toast and does not start the OAuth flow when env.remoteName is set', async () => { + when(mockedVSCodeNamespaces.env.remoteName).thenReturn('ssh-remote'); + integrationStore.set(FED_AUTH_FIXTURE.INTEGRATION_ID, buildGoogleOauthIntegration()); + + await handler.authenticate(FED_AUTH_FIXTURE.INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 0, 'runOAuthFlow should not have been called'); + assert.lengthOf(savedTokens, 0, 'no token should be saved'); + }); + + ( + [ + ['unknown integration id', () => undefined, 'unknown-id'], + ['non-BigQuery integration', () => buildPostgresIntegration({ id: FED_AUTH_FIXTURE.INTEGRATION_ID })], + ['service-account BigQuery integration', () => buildServiceAccountIntegration()] + ] as const + ).forEach(([label, build, lookupId]) => { + test(`skips OAuth flow for ${label}`, async () => { + const config = build(); + if (config) { + integrationStore.set(FED_AUTH_FIXTURE.INTEGRATION_ID, config); + } + await handler.authenticate(lookupId ?? FED_AUTH_FIXTURE.INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 0); + assert.lengthOf(savedTokens, 0); + }); + }); + + test('happy path: saves the captured refresh token with a fresh fingerprint', async () => { + integrationStore.set(FED_AUTH_FIXTURE.INTEGRATION_ID, buildGoogleOauthIntegration()); + + await handler.authenticate(FED_AUTH_FIXTURE.INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 1); + assert.lengthOf(savedTokens, 1); + assert.deepStrictEqual(savedTokens[0], { + integrationId: FED_AUTH_FIXTURE.INTEGRATION_ID, + refreshToken: FED_AUTH_FIXTURE.REFRESH_TOKEN, + metadataFingerprint: computeMetadataFingerprint({ + clientId: FED_AUTH_FIXTURE.CLIENT_ID, + clientSecret: FED_AUTH_FIXTURE.CLIENT_SECRET, + project: FED_AUTH_FIXTURE.PROJECT + }) + }); + + // Sanity-check that the strategy + completion were threaded through. + const callArg = runOAuthFlowStub.firstCall.args[0]; + assert.strictEqual(callArg.integrationId, FED_AUTH_FIXTURE.INTEGRATION_ID); + assert.isFunction(callArg.onListening); + }); + + test('silently returns when the user cancels the flow', async () => { + integrationStore.set(FED_AUTH_FIXTURE.INTEGRATION_ID, buildGoogleOauthIntegration()); + runOAuthFlowStub.rejects(new CancellationError()); + + await handler.authenticate(FED_AUTH_FIXTURE.INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 1); + assert.lengthOf(savedTokens, 0); + }); + + test('surfaces a generic OAuth error via the failure toast and does not save a token', async () => { + integrationStore.set(FED_AUTH_FIXTURE.INTEGRATION_ID, buildGoogleOauthIntegration()); + runOAuthFlowStub.rejects(new Error('boom')); + + await handler.authenticate(FED_AUTH_FIXTURE.INTEGRATION_ID); + + assert.strictEqual(runOAuthFlowStub.callCount, 1); + assert.lengthOf(savedTokens, 0); + }); + + test('activate registers the AuthenticateIntegration command and pushes a disposable', () => { + when(mockedVSCodeNamespaces.commands.registerCommand(anyString(), anything())).thenReturn({ + dispose: () => undefined + } as IDisposable); + + handler.activate(); + + assert.strictEqual(subscriptions.length, 1, 'one disposable subscription should be registered'); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.ts new file mode 100644 index 0000000000..7e0cee8ef9 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.ts @@ -0,0 +1,21 @@ +import { inject, injectable } from 'inversify'; +import { commands, window } from 'vscode'; + +import { IExtensionSyncActivationService } from '../../../../platform/activation/types'; +import { Commands } from '../../../../platform/common/constants'; +import { IExtensionContext } from '../../../../platform/common/types'; +import { Integrations } from '../../../../platform/common/utils/localize'; + +/** Web-side stub for `deepnote.authenticateIntegration` that shows a "not supported in web" toast (the OAuth loopback flow needs Node's `http`/`express`/`passport`). */ +@injectable() +export class FederatedAuthCommandHandlerWeb implements IExtensionSyncActivationService { + constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} + + public activate(): void { + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.AuthenticateIntegration, () => { + void window.showInformationMessage(Integrations.federatedAuthNotSupportedInWeb); + }) + ); + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.unit.test.ts new file mode 100644 index 0000000000..3aa06015f0 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web.unit.test.ts @@ -0,0 +1,49 @@ +import { assert } from 'chai'; +import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; + +import { IDisposable, IExtensionContext } from '../../../../platform/common/types'; +import { Commands } from '../../../../platform/common/constants'; +import { FederatedAuthCommandHandlerWeb } from './federatedAuthCommandHandler.web'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../../test/vscode-mock'; + +suite('FederatedAuthCommandHandlerWeb', () => { + let extensionContext: IExtensionContext; + let subscriptions: IDisposable[]; + let registeredCallback: ((...args: unknown[]) => unknown) | undefined; + let handler: FederatedAuthCommandHandlerWeb; + + setup(() => { + resetVSCodeMocks(); + subscriptions = []; + registeredCallback = undefined; + + extensionContext = mock(); + when(extensionContext.subscriptions).thenReturn(subscriptions); + + when(mockedVSCodeNamespaces.commands.registerCommand(anyString(), anything())).thenCall( + (_command: string, callback: (...args: unknown[]) => unknown) => { + registeredCallback = callback; + return { dispose: () => undefined } as IDisposable; + } + ); + + handler = new FederatedAuthCommandHandlerWeb(instance(extensionContext)); + }); + + test('activate registers the AuthenticateIntegration command with the extension context', () => { + handler.activate(); + + verify(mockedVSCodeNamespaces.commands.registerCommand(Commands.AuthenticateIntegration, anything())).once(); + assert.strictEqual(subscriptions.length, 1); + }); + + test('the registered command surfaces the not-supported-in-web information toast', () => { + handler.activate(); + assert.isDefined(registeredCallback, 'command callback should have been captured'); + + // Invoke the command — should not throw and should show the toast. + registeredCallback!('some-integration-id'); + + verify(mockedVSCodeNamespaces.window.showInformationMessage(anything())).once(); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthInvariants.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthInvariants.unit.test.ts new file mode 100644 index 0000000000..667f29a07a --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthInvariants.unit.test.ts @@ -0,0 +1,25 @@ +import { assert } from 'chai'; + +import { FederatedAuthTokenEntry } from '../types'; + +suite('Federated auth invariants', () => { + test('FederatedAuthTokenEntry does not carry accessToken or expiresAt', () => { + // Catches: a future addition of `accessToken`/`expiresAt` (or any similarly time-bounded credential) to the persisted entry shape. + + // Compile-time check: tsc fails this file if a forbidden key is ever added. + type Forbidden = 'accessToken' | 'expiresAt'; + type AssertEntryOmitsForbidden = Forbidden extends keyof FederatedAuthTokenEntry ? never : true; + const _entryShapeCheck: AssertEntryOmitsForbidden = true; + void _entryShapeCheck; + + // Runtime check: a sample entry literal has exactly the three allowed keys. + const sample: FederatedAuthTokenEntry = { + integrationId: 'integration-1', + refreshToken: 'refresh-token-value', + metadataFingerprint: 'sha256-of-client-meta' + }; + const keys = Object.keys(sample).sort(); + + assert.deepStrictEqual(keys, ['integrationId', 'metadataFingerprint', 'refreshToken']); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts new file mode 100644 index 0000000000..de858ad723 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.ts @@ -0,0 +1,108 @@ +import { inject, injectable } from 'inversify'; +import { NotebookDocument, workspace } from 'vscode'; + +import { IExtensionSyncActivationService } from '../../../../platform/activation/types'; +import { IDisposableRegistry } from '../../../../platform/common/types'; +import { logger } from '../../../../platform/logging'; +import { IKernelProvider } from '../../../../kernels/types'; +import { IDeepnoteNotebookManager } from '../../../types'; +import { IFederatedAuthTokenStorage } from '../types'; + +/** + * Node-only bridge that restarts kernels when a federated integration's token changes, clearing stale + * `os.environ` mutations and kernel globals. Separate from {@link IntegrationKernelRestartHandler} because + * {@link IFederatedAuthTokenStorage} is node-only. + */ +@injectable() +export class FederatedAuthKernelRestartBridge implements IExtensionSyncActivationService { + constructor( + @inject(IFederatedAuthTokenStorage) private readonly tokenStorage: IFederatedAuthTokenStorage, + @inject(IKernelProvider) private readonly kernelProvider: IKernelProvider, + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(IDisposableRegistry) disposables: IDisposableRegistry + ) { + logger.info('FederatedAuthKernelRestartBridge: Initialized'); + + disposables.push( + this.tokenStorage.onDidChangeTokens((integrationId) => { + this.onTokenChanged(integrationId).catch((err) => + logger.error( + `FederatedAuthKernelRestartBridge: Failed to handle token change for integration ${integrationId}`, + err + ) + ); + }) + ); + } + + public activate(): void { + // Service is activated via constructor. + } + + /** Restart kernels for any open Deepnote notebook whose project references {@link integrationId}. */ + private async onTokenChanged(integrationId: string): Promise { + logger.info( + `FederatedAuthKernelRestartBridge: Token changed for integration ${integrationId}, checking affected kernels` + ); + + const notebooksToRestart: NotebookDocument[] = []; + + for (const notebook of workspace.notebookDocuments) { + if (notebook.notebookType !== 'deepnote') { + continue; + } + + const kernel = this.kernelProvider.get(notebook); + if (!kernel || !kernel.startedAtLeastOnce) { + continue; + } + + const projectId = notebook.metadata?.deepnoteProjectId as string | undefined; + if (!projectId) { + continue; + } + + const project = this.notebookManager.getOriginalProject(projectId); + if (!project) { + continue; + } + + const projectIntegrations = project.project.integrations ?? []; + if (projectIntegrations.some((integration) => integration.id === integrationId)) { + notebooksToRestart.push(notebook); + } + } + + if (notebooksToRestart.length === 0) { + logger.info( + `FederatedAuthKernelRestartBridge: No running kernels reference integration ${integrationId}; nothing to restart` + ); + return; + } + + logger.info( + `FederatedAuthKernelRestartBridge: Restarting ${notebooksToRestart.length} kernel(s) for integration ${integrationId}` + ); + + // Per-iteration error handling keeps one failure from stopping the rest. + await Promise.all( + notebooksToRestart.map(async (notebook) => { + const kernel = this.kernelProvider.get(notebook); + if (!kernel) { + return; + } + try { + await kernel.restart(); + logger.info( + `FederatedAuthKernelRestartBridge: Successfully restarted kernel for ${notebook.uri.toString()}` + ); + } catch (error) { + logger.error( + `FederatedAuthKernelRestartBridge: Failed to restart kernel for ${notebook.uri.toString()}`, + error + ); + } + }) + ); + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts new file mode 100644 index 0000000000..dbd76c0f9a --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node.unit.test.ts @@ -0,0 +1,164 @@ +import { Disposable, EventEmitter, NotebookDocument, Uri } from 'vscode'; +import { anyString, instance, mock, verify, when } from 'ts-mockito'; + +import { FederatedAuthKernelRestartBridge } from './federatedAuthKernelRestartBridge.node'; +import { IDeepnoteNotebookManager } from '../../../types'; +import { IDisposable } from '../../../../platform/common/types'; +import { IFederatedAuthTokenStorage } from '../types'; +import { IKernel, IKernelProvider } from '../../../../kernels/types'; +import { dispose } from '../../../../platform/common/utils/lifecycle'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../../test/vscode-mock'; +import { createMockProject, settleAsyncHandlers } from './federatedAuthTestHelpers'; + +suite('FederatedAuthKernelRestartBridge', () => { + let bridge: FederatedAuthKernelRestartBridge; + let tokenStorage: IFederatedAuthTokenStorage; + let kernelProvider: IKernelProvider; + let notebookManager: IDeepnoteNotebookManager; + let disposables: IDisposable[]; + let onDidChangeTokens: EventEmitter; + + function createMockNotebook(notebookType: string, uri: Uri, projectId?: string): NotebookDocument { + const notebook = mock(); + when(notebook.notebookType).thenReturn(notebookType); + when(notebook.uri).thenReturn(uri); + when(notebook.metadata).thenReturn(projectId ? { deepnoteProjectId: projectId } : {}); + return instance(notebook); + } + + function mkKernel(opts: { startedAtLeastOnce?: boolean; restartRejects?: Error } = {}): IKernel { + const kernel = mock(); + when(kernel.startedAtLeastOnce).thenReturn(opts.startedAtLeastOnce ?? true); + if (opts.restartRejects) { + when(kernel.restart()).thenReject(opts.restartRejects); + } else { + when(kernel.restart()).thenResolve(); + } + return kernel; + } + + setup(() => { + resetVSCodeMocks(); + disposables = [new Disposable(() => resetVSCodeMocks())]; + tokenStorage = mock(); + kernelProvider = mock(); + notebookManager = mock(); + onDidChangeTokens = new EventEmitter(); + disposables.push(onDidChangeTokens); + + when(tokenStorage.onDidChangeTokens).thenReturn(onDidChangeTokens.event); + + bridge = new FederatedAuthKernelRestartBridge( + instance(tokenStorage), + instance(kernelProvider), + instance(notebookManager), + disposables + ); + bridge.activate(); + }); + + teardown(() => { + disposables = dispose(disposables); + }); + + test('restarts only the affected notebook when one of many references the integration', async () => { + const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a'); + const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b'); + const kernelA = mkKernel(); + const kernelB = mkKernel(); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebookA, notebookB]); + when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); + when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); + // Only project A references 'bq-shared'. + when(notebookManager.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); + when(notebookManager.getOriginalProject('project-b')).thenReturn(createMockProject('project-b', ['other-bq'])); + + onDidChangeTokens.fire('bq-shared'); + await settleAsyncHandlers(); + + verify(kernelA.restart()).once(); + verify(kernelB.restart()).never(); + }); + + test('restarts both kernels when two notebooks reference the same integration', async () => { + const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a'); + const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b'); + const kernelA = mkKernel(); + const kernelB = mkKernel(); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebookA, notebookB]); + when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); + when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); + when(notebookManager.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); + when(notebookManager.getOriginalProject('project-b')).thenReturn(createMockProject('project-b', ['bq-shared'])); + + onDidChangeTokens.fire('bq-shared'); + await settleAsyncHandlers(); + + verify(kernelA.restart()).once(); + verify(kernelB.restart()).once(); + }); + + ( + [ + [ + 'non-Deepnote notebooks', + () => createMockNotebook('jupyter-notebook', Uri.file('/a.ipynb'), 'project-a'), + () => mkKernel(), + () => createMockProject('project-a', ['bq-1']) + ], + [ + 'notebooks whose kernel has not started', + () => createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a'), + () => mkKernel({ startedAtLeastOnce: false }), + () => createMockProject('project-a', ['bq-1']) + ], + [ + 'notebooks without project metadata', + () => createMockNotebook('deepnote', Uri.file('/a.ipynb')), + () => mkKernel(), + () => undefined + ] + ] as const + ).forEach(([label, buildNotebook, buildKernel, buildProject]) => { + test(`skips ${label}`, async () => { + const notebook = buildNotebook(); + const kernel = buildKernel(); + const project = buildProject(); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebook]); + when(kernelProvider.get(notebook)).thenReturn(instance(kernel)); + if (project) { + when(notebookManager.getOriginalProject('project-a')).thenReturn(project); + } + + onDidChangeTokens.fire('bq-1'); + await settleAsyncHandlers(); + + verify(kernel.restart()).never(); + if (!project) { + verify(notebookManager.getOriginalProject(anyString())).never(); + } + }); + }); + + test('continues restarting other kernels when one fails', async () => { + const notebookA = createMockNotebook('deepnote', Uri.file('/a.ipynb'), 'project-a'); + const notebookB = createMockNotebook('deepnote', Uri.file('/b.ipynb'), 'project-b'); + const kernelA = mkKernel({ restartRejects: new Error('boom') }); + const kernelB = mkKernel(); + + when(mockedVSCodeNamespaces.workspace.notebookDocuments).thenReturn([notebookA, notebookB]); + when(kernelProvider.get(notebookA)).thenReturn(instance(kernelA)); + when(kernelProvider.get(notebookB)).thenReturn(instance(kernelB)); + when(notebookManager.getOriginalProject('project-a')).thenReturn(createMockProject('project-a', ['bq-shared'])); + when(notebookManager.getOriginalProject('project-b')).thenReturn(createMockProject('project-b', ['bq-shared'])); + + onDidChangeTokens.fire('bq-shared'); + await settleAsyncHandlers(); + + verify(kernelA.restart()).once(); + verify(kernelB.restart()).once(); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthOrphanedTokenCleaner.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthOrphanedTokenCleaner.node.ts new file mode 100644 index 0000000000..c81cd274e5 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthOrphanedTokenCleaner.node.ts @@ -0,0 +1,72 @@ +import { inject, injectable } from 'inversify'; + +import { IExtensionSyncActivationService } from '../../../../platform/activation/types'; +import { IDisposableRegistry } from '../../../../platform/common/types'; +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { IFederatedAuthTokenStorage } from '../types'; +import { logger } from '../../../../platform/logging'; + +/** + * Node-only listener that prunes federated-auth tokens when an integration is deleted: subscribes to + * `onDidChangeIntegrations` and diffs current IDs against {@link IFederatedAuthTokenStorage.listIntegrationIds}. + */ +@injectable() +export class FederatedAuthOrphanedTokenCleaner implements IExtensionSyncActivationService { + constructor( + @inject(IFederatedAuthTokenStorage) private readonly tokenStorage: IFederatedAuthTokenStorage, + @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, + @inject(IDisposableRegistry) disposables: IDisposableRegistry + ) { + logger.info('FederatedAuthOrphanedTokenCleaner: Initialized'); + + disposables.push( + this.integrationStorage.onDidChangeIntegrations(() => { + this.cleanupOrphanedTokens().catch((err) => + logger.error('FederatedAuthOrphanedTokenCleaner: Failed to clean up orphaned tokens', err) + ); + }) + ); + } + + public activate(): void { + // Service is activated via constructor. + } + + private async cleanupOrphanedTokens(): Promise { + const [tokenIds, integrations] = await Promise.all([ + this.tokenStorage.listIntegrationIds(), + this.integrationStorage.getAll() + ]); + + if (tokenIds.length === 0) { + logger.debug('FederatedAuthOrphanedTokenCleaner: No federated tokens stored, nothing to clean up.'); + return; + } + + const currentIntegrationIds = new Set(integrations.map((integration) => integration.id)); + const orphanedIds = tokenIds.filter((id) => !currentIntegrationIds.has(id)); + + if (orphanedIds.length === 0) { + logger.debug('FederatedAuthOrphanedTokenCleaner: No orphaned tokens to clean up.'); + return; + } + + logger.info( + `FederatedAuthOrphanedTokenCleaner: Cleaning up ${orphanedIds.length} orphaned token(s): ${orphanedIds.join( + ', ' + )}` + ); + + for (const id of orphanedIds) { + try { + await this.tokenStorage.delete(id); + logger.debug(`FederatedAuthOrphanedTokenCleaner: Deleted orphaned token for integration ${id}`); + } catch (error) { + logger.error( + `FederatedAuthOrphanedTokenCleaner: Failed to delete orphaned token for integration ${id}`, + error + ); + } + } + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthOrphanedTokenCleaner.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthOrphanedTokenCleaner.node.unit.test.ts new file mode 100644 index 0000000000..dda6fcbc90 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthOrphanedTokenCleaner.node.unit.test.ts @@ -0,0 +1,111 @@ +import { assert } from 'chai'; +import { Disposable, EventEmitter } from 'vscode'; + +import { FederatedAuthOrphanedTokenCleaner } from './federatedAuthOrphanedTokenCleaner.node'; +import { IDisposable } from '../../../../platform/common/types'; +import { IFederatedAuthTokenStorage } from '../types'; +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { dispose } from '../../../../platform/common/utils/lifecycle'; +import { buildGoogleOauthIntegration, settleAsyncHandlers } from './federatedAuthTestHelpers'; + +suite('FederatedAuthOrphanedTokenCleaner', () => { + let onDidChangeIntegrations: EventEmitter; + let disposables: IDisposable[]; + + let integrationIds: Set; + let storedTokenIds: Set; + let deletedTokenIds: string[]; + + function buildIntegrationStorage(): IIntegrationStorage { + return { + getAll: async () => Array.from(integrationIds).map((id) => buildGoogleOauthIntegration({ id })), + onDidChangeIntegrations: onDidChangeIntegrations.event + } as unknown as IIntegrationStorage; + } + + function buildTokenStorage(opts: { throwOnDelete?: Set } = {}): IFederatedAuthTokenStorage { + return { + listIntegrationIds: async () => Array.from(storedTokenIds), + delete: async (id: string) => { + deletedTokenIds.push(id); + if (opts.throwOnDelete?.has(id)) { + throw new Error('boom'); + } + storedTokenIds.delete(id); + } + } as unknown as IFederatedAuthTokenStorage; + } + + function fireChangeAndWait(): Promise { + onDidChangeIntegrations.fire(); + return settleAsyncHandlers(); + } + + setup(() => { + disposables = []; + onDidChangeIntegrations = new EventEmitter(); + disposables.push(new Disposable(() => onDidChangeIntegrations.dispose())); + + integrationIds = new Set(); + storedTokenIds = new Set(); + deletedTokenIds = []; + }); + + teardown(() => { + disposables = dispose(disposables); + }); + + test('does not call delete when every stored token has a matching integration', async () => { + integrationIds.add('bq-1'); + integrationIds.add('bq-2'); + storedTokenIds.add('bq-1'); + storedTokenIds.add('bq-2'); + + new FederatedAuthOrphanedTokenCleaner(buildTokenStorage(), buildIntegrationStorage(), disposables); + + await fireChangeAndWait(); + + assert.deepStrictEqual(deletedTokenIds, []); + }); + + test('deletes tokens for integrations that no longer exist', async () => { + integrationIds.add('bq-1'); + storedTokenIds.add('bq-1'); + storedTokenIds.add('orphan-a'); + storedTokenIds.add('orphan-b'); + + new FederatedAuthOrphanedTokenCleaner(buildTokenStorage(), buildIntegrationStorage(), disposables); + + await fireChangeAndWait(); + + assert.deepStrictEqual(deletedTokenIds.sort(), ['orphan-a', 'orphan-b']); + }); + + test('no-op when there are no stored tokens at all', async () => { + integrationIds.add('bq-1'); + + new FederatedAuthOrphanedTokenCleaner(buildTokenStorage(), buildIntegrationStorage(), disposables); + + await fireChangeAndWait(); + + assert.deepStrictEqual(deletedTokenIds, []); + }); + + test('continues deleting other orphans when one delete fails', async () => { + integrationIds.add('bq-1'); + storedTokenIds.add('bq-1'); + storedTokenIds.add('orphan-a'); + storedTokenIds.add('orphan-b'); + + new FederatedAuthOrphanedTokenCleaner( + buildTokenStorage({ throwOnDelete: new Set(['orphan-a']) }), + buildIntegrationStorage(), + disposables + ); + + await fireChangeAndWait(); + + // Both attempts must have happened, even after orphan-a's failure. + assert.deepStrictEqual(deletedTokenIds.sort(), ['orphan-a', 'orphan-b']); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.ts new file mode 100644 index 0000000000..ab46c83d6e --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.ts @@ -0,0 +1,192 @@ +// VENDORED: helpers that should land in `@deepnote/blocks` (Step 10 of the upstream-migration plan). +// Adapts upstream's `executeSqlQueryWithConnectionJson` to accept a Python *expression* so the caller +// can reference a kernel-global holding the fresh access token. Delete once upstream exports it. +// TODO(deepnote-followups): remove when @deepnote/blocks exports the expression-form helper. + +import type { DeepnoteBlock } from '@deepnote/blocks'; +import { BigQueryAuthMethods } from '@deepnote/database-integrations'; +import { inject, injectable, optional } from 'inversify'; +import { dedent } from 'ts-dedent'; + +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { + fetchFreshAccessToken, + InvalidClientError, + InvalidGrantError, + computeMetadataFingerprint +} from './federatedAuthTokenStorage.node'; +import { GOOGLE_TOKEN_URL } from './googleOAuthProvider.node'; +import { + createDataFrameConfig, + escapePythonString, + sanitizePythonVariableName, + SqlCacheMode, + SqlCellVariableType +} from './vendoredBlocksHelpers'; +import { + IFederatedAuthSqlBlockCodeGenerator, + IFederatedAuthTokenStorage, + NotAuthenticatedError, + OAuthClientMisconfiguredError +} from '../types'; + +/** Signature of {@link fetchFreshAccessToken}, used as the constructor's optional test seam. */ +type FetchFreshAccessTokenFn = typeof fetchFreshAccessToken; + +/** Per-integration kernel-global variable name holding the fresh SqlAlchemy JSON. Non-identifier chars are replaced with `_` to keep the name valid for UUID-style ids. */ +export function federatedSqlVariableName(integrationId: string): string { + const sanitized = integrationId.replace(/[^A-Za-z0-9_]/g, '_'); + return `__deepnote_federated_sql_connection__${sanitized}`; +} + +/** + * VENDORED: mirrors upstream `executeSqlQueryWithConnectionJson` but emits `connectionJsonExpression` + * as a bare Python identifier (kernel-global ref) instead of a string literal. + * TODO(deepnote-followups): remove when @deepnote/blocks exports the expression-form helper. + */ +function executeSqlQueryWithConnectionJson(params: { + query: string; + auditComment?: string; + connectionJsonExpression: string; + pythonVariableName?: string; + sqlCacheMode: SqlCacheMode; + returnVariableType: SqlCellVariableType; +}): string { + const escapedQuery = escapePythonString(params.query); + const escapedAuditComment = escapePythonString(params.auditComment ?? ''); + const executeSqlFunctionCall = dedent`_dntk.execute_sql_with_connection_json( + ${escapedQuery}, + ${params.connectionJsonExpression}, + audit_sql_comment=${escapedAuditComment}, + sql_cache_mode='${params.sqlCacheMode}', + return_variable_type='${params.returnVariableType}' + )`; + + return params.pythonVariableName === undefined + ? executeSqlFunctionCall + : dedent` + ${params.pythonVariableName} = ${executeSqlFunctionCall} + ${params.pythonVariableName} + `; +} + +/** + * Builds Python prelude + cell code for federated BigQuery SQL blocks. Returns `undefined` for unrelated + * blocks so callers fall back to `@deepnote/blocks.createPythonCode`. Prelude (silent execute, no history) + * sets the SqlAlchemy JSON into a kernel global; cellCode references it by name. Access tokens are never + * cached: every `generate()` triggers a fresh refresh. + */ +@injectable() +export class FederatedAuthSqlBlockCodeGenerator implements IFederatedAuthSqlBlockCodeGenerator { + /** Test seam: stub replacement for {@link fetchFreshAccessToken} passed via the optional 3rd ctor param. */ + private readonly fetchFreshAccessToken: FetchFreshAccessTokenFn; + + constructor( + @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, + @inject(IFederatedAuthTokenStorage) private readonly tokenStorage: IFederatedAuthTokenStorage, + @optional() fetcher?: FetchFreshAccessTokenFn + ) { + this.fetchFreshAccessToken = fetcher ?? fetchFreshAccessToken; + } + + public async generate(block: DeepnoteBlock): Promise<{ prelude: string; cellCode: string } | undefined> { + if (block.type !== 'sql') { + return undefined; + } + + // Discriminator above narrows `block` to `SqlBlock`. + const sqlBlock = block; + const integrationId = sqlBlock.metadata?.sql_integration_id; + if (!integrationId) { + return undefined; + } + + const integration = await this.integrationStorage.getIntegrationConfig(integrationId); + if (!integration || integration.type !== 'big-query') { + return undefined; + } + if (integration.metadata.authMethod !== BigQueryAuthMethods.GoogleOauth) { + return undefined; + } + + // Federated path: any "no usable token" branch must throw NotAuthenticatedError so the UI offers Authenticate. + const entry = await this.tokenStorage.get(integrationId); + if (!entry) { + throw new NotAuthenticatedError(integration.name); + } + + const currentFingerprint = computeMetadataFingerprint({ + clientId: integration.metadata.clientId, + clientSecret: integration.metadata.clientSecret, + project: integration.metadata.project + }); + if (currentFingerprint !== entry.metadataFingerprint) { + // OAuth client metadata edited since save: stored refresh token is bound to a different client. Drop it. + await this.tokenStorage.delete(integrationId); + throw new NotAuthenticatedError(integration.name); + } + + let accessToken: string; + let newRefreshToken: string | undefined; + try { + const result = await this.fetchFreshAccessToken(entry, { + tokenUrl: GOOGLE_TOKEN_URL, + clientId: integration.metadata.clientId, + clientSecret: integration.metadata.clientSecret + }); + accessToken = result.accessToken; + newRefreshToken = result.newRefreshToken; + } catch (error) { + if (error instanceof InvalidGrantError) { + // Refresh token revoked/expired: drop locally, rethrow as NotAuthenticatedError for the re-auth path. + await this.tokenStorage.delete(integrationId); + throw new NotAuthenticatedError(integration.name); + } + if (error instanceof InvalidClientError) { + // Wrong clientId/clientSecret: re-auth won't help. Rethrow as cross-platform sentinel; keep the refresh token. + throw new OAuthClientMisconfiguredError(integration.name); + } + // Other errors: token probably still valid; don't delete. + throw error; + } + + // Persist rotated refresh token silently — firing onDidChangeTokens would restart the kernel mid-cell. + if (newRefreshToken !== undefined && newRefreshToken !== entry.refreshToken) { + await this.tokenStorage.save({ ...entry, refreshToken: newRefreshToken }, { silent: true }); + } + + const connectionJson = JSON.stringify({ + integration_id: integration.id, + url: 'bigquery://?user_supplied_client=true', + params: { access_token: accessToken, project: integration.metadata.project }, + param_style: 'pyformat' + }); + + const variableName = federatedSqlVariableName(integration.id); + + // `escapePythonString` handles `\`, `'`, `\n` so a hostile `integration.id` can't break Python's `json.loads`. + const prelude = `${variableName} = ${escapePythonString(connectionJson)}`; + + // Mirror upstream `createPythonCodeForSqlBlock`'s metadata reads. + const query = sqlBlock.content ?? ''; + const rawVariableName = sqlBlock.metadata?.deepnote_variable_name; + const pythonVariableName = + rawVariableName !== undefined ? sanitizePythonVariableName(rawVariableName) ?? 'input_1' : undefined; + const returnVariableType: SqlCellVariableType = sqlBlock.metadata?.deepnote_return_variable_type ?? 'dataframe'; + const sqlCacheMode: SqlCacheMode = 'cache_disabled'; + + const dataFrameConfig = createDataFrameConfig(sqlBlock); + const executeSqlCall = executeSqlQueryWithConnectionJson({ + query, + auditComment: '', + connectionJsonExpression: variableName, + pythonVariableName, + sqlCacheMode, + returnVariableType + }); + + const cellCode = `${dataFrameConfig}\n\n${executeSqlCall}`; + + return { prelude, cellCode }; + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.unit.test.ts new file mode 100644 index 0000000000..a991a93343 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node.unit.test.ts @@ -0,0 +1,322 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; + +import { ConfigurableDatabaseIntegrationConfig } from '../../../../platform/notebooks/deepnote/integrationTypes'; +import { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import { + FederatedAuthTokenEntry, + IFederatedAuthTokenStorage, + NotAuthenticatedError, + OAuthClientMisconfiguredError +} from '../types'; +import { + FederatedAuthSqlBlockCodeGenerator, + federatedSqlVariableName +} from './federatedAuthSqlBlockCodeGenerator.node'; +import { InvalidClientError, InvalidGrantError, computeMetadataFingerprint } from './federatedAuthTokenStorage.node'; +import { + FED_AUTH_FIXTURE, + buildCodeBlock, + buildGoogleOauthIntegration, + buildPostgresIntegration, + buildServiceAccountIntegration, + buildSqlBlock, + buildTokenEntry, + parsePythonSingleQuoted +} from './federatedAuthTestHelpers'; + +type FetcherFn = ( + entry: FederatedAuthTokenEntry, + oauthConfig: { tokenUrl: string; clientId: string; clientSecret: string } +) => Promise<{ accessToken: string; newRefreshToken?: string }>; + +suite('FederatedAuthSqlBlockCodeGenerator', () => { + const { INTEGRATION_ID, PROJECT, CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN, ACCESS_TOKEN } = FED_AUTH_FIXTURE; + const VALID_FINGERPRINT = computeMetadataFingerprint({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + project: PROJECT + }); + + let integrationStore: Map; + let tokenStore: Map; + let deleteSpy: sinon.SinonSpy; + let saveSpy: sinon.SinonSpy; + let fetcher: sinon.SinonStub, ReturnType>; + let integrationStorage: IIntegrationStorage; + let tokenStorage: IFederatedAuthTokenStorage; + let generator: FederatedAuthSqlBlockCodeGenerator; + + setup(() => { + integrationStore = new Map(); + tokenStore = new Map(); + + integrationStorage = { + getIntegrationConfig: async (id: string) => integrationStore.get(id) + } as unknown as IIntegrationStorage; + + deleteSpy = sinon.spy(async (id: string) => { + tokenStore.delete(id); + }); + saveSpy = sinon.spy(async (entry: FederatedAuthTokenEntry) => { + tokenStore.set(entry.integrationId, entry); + }); + + tokenStorage = { + get: async (id: string) => tokenStore.get(id), + delete: deleteSpy as unknown as IFederatedAuthTokenStorage['delete'], + save: saveSpy as unknown as IFederatedAuthTokenStorage['save'] + } as unknown as IFederatedAuthTokenStorage; + + fetcher = sinon.stub, ReturnType>(); + fetcher.resolves({ accessToken: ACCESS_TOKEN }); + + generator = new FederatedAuthSqlBlockCodeGenerator( + integrationStorage, + tokenStorage, + fetcher as unknown as FetcherFn + ); + }); + + function setupValidFederatedIntegration() { + integrationStore.set(INTEGRATION_ID, buildGoogleOauthIntegration()); + tokenStore.set( + INTEGRATION_ID, + buildTokenEntry({ refreshToken: REFRESH_TOKEN, metadataFingerprint: VALID_FINGERPRINT }) + ); + } + + ( + [ + ['a non-SQL block', () => buildGoogleOauthIntegration(), () => buildCodeBlock()], + [ + 'SQL block with no sql_integration_id', + () => buildGoogleOauthIntegration(), + () => buildSqlBlock({ metadata: {} }) + ], + [ + 'SQL block with id typo (integration not found)', + () => undefined, + () => buildSqlBlock({ sql_integration_id: 'unknown-id' }) + ], + [ + 'integration that is not BigQuery', + () => buildPostgresIntegration({ id: INTEGRATION_ID }), + () => buildSqlBlock() + ], + [ + 'BigQuery integration using service-account auth', + () => buildServiceAccountIntegration(), + () => buildSqlBlock() + ] + ] as const + ).forEach(([label, buildIntegration, buildBlock]) => { + test(`returns undefined for ${label}`, async () => { + const integration = buildIntegration(); + if (integration) { + integrationStore.set(INTEGRATION_ID, integration); + } + const result = await generator.generate(buildBlock()); + assert.strictEqual(result, undefined); + sinon.assert.notCalled(fetcher); + }); + }); + + test('throws NotAuthenticatedError when federated integration has no stored token', async () => { + integrationStore.set(INTEGRATION_ID, buildGoogleOauthIntegration()); + + try { + await generator.generate(buildSqlBlock()); + assert.fail('Expected NotAuthenticatedError'); + } catch (err) { + assert.instanceOf(err, NotAuthenticatedError); + assert.strictEqual((err as NotAuthenticatedError).integrationName, 'My BigQuery'); + } + sinon.assert.notCalled(fetcher); + }); + + test('throws NotAuthenticatedError and deletes the token when the metadata fingerprint is stale', async () => { + setupValidFederatedIntegration(); + tokenStore.set( + INTEGRATION_ID, + buildTokenEntry({ refreshToken: REFRESH_TOKEN, metadataFingerprint: 'stale-fingerprint' }) + ); + + try { + await generator.generate(buildSqlBlock()); + assert.fail('Expected NotAuthenticatedError'); + } catch (err) { + assert.instanceOf(err, NotAuthenticatedError); + } + sinon.assert.calledOnceWithExactly(deleteSpy, INTEGRATION_ID); + sinon.assert.notCalled(fetcher); + }); + + test('returns { prelude, cellCode } for a valid federated SQL block', async () => { + setupValidFederatedIntegration(); + + const result = await generator.generate(buildSqlBlock()); + if (!result) { + throw new Error('expected a non-undefined result'); + } + + const expectedVariableName = federatedSqlVariableName(INTEGRATION_ID); + const escapedVariableName = expectedVariableName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // prelude is exactly ` = ''`. + const preludeRegex = new RegExp(`^${escapedVariableName} = '([^]*)'$`); + const match = preludeRegex.exec(result.prelude); + if (!match) { + throw new Error(`prelude did not match expected shape: ${result.prelude}`); + } + const safeJson = match[1]; + const parsed = JSON.parse(safeJson.replace(/\\'/g, "'")) as Record; + assert.deepStrictEqual(parsed, { + integration_id: INTEGRATION_ID, + url: 'bigquery://?user_supplied_client=true', + params: { access_token: ACCESS_TOKEN, project: PROJECT }, + param_style: 'pyformat' + }); + + // cellCode invokes the connection-json function and references the variable by name (no quotes). + assert.include(result.cellCode, '_dntk.execute_sql_with_connection_json('); + // The variable is referenced unquoted between the commas. + const inlineRef = new RegExp(`,\\s*${escapedVariableName}\\s*,`); + assert.match(result.cellCode, inlineRef, 'cellCode should reference the variable as a bare identifier'); + + // Critical M3 invariant: the access token MUST NOT appear in cellCode. + assert.isFalse( + result.cellCode.includes(ACCESS_TOKEN), + `cellCode unexpectedly contains the access token: ${result.cellCode}` + ); + }); + + test('prelude round-trips through Python+json.loads when integration id contains backslash, newline, and single quote', async () => { + // Catches: regressing to a single-char `\\'` escape would leave `\\`/`\n` undecoded and break `json.loads` at the kernel. + const hostileIntegrationId = "bq-with-\\-and-\n-and-'-id"; + integrationStore.set(hostileIntegrationId, buildGoogleOauthIntegration({ id: hostileIntegrationId })); + tokenStore.set( + hostileIntegrationId, + buildTokenEntry({ + integrationId: hostileIntegrationId, + refreshToken: REFRESH_TOKEN, + metadataFingerprint: VALID_FINGERPRINT + }) + ); + + const result = await generator.generate(buildSqlBlock({ sql_integration_id: hostileIntegrationId })); + if (!result) { + throw new Error('expected a non-undefined result'); + } + + const expectedVariableName = federatedSqlVariableName(hostileIntegrationId); + const assignmentPrefix = `${expectedVariableName} = `; + assert.isTrue( + result.prelude.startsWith(assignmentPrefix), + `prelude did not begin with the expected assignment prefix: ${result.prelude}` + ); + const literal = result.prelude.slice(assignmentPrefix.length); + + const decoded = parsePythonSingleQuoted(literal); + const parsed = JSON.parse(decoded) as Record; + assert.deepStrictEqual(parsed, { + integration_id: hostileIntegrationId, + url: 'bigquery://?user_supplied_client=true', + params: { access_token: ACCESS_TOKEN, project: PROJECT }, + param_style: 'pyformat' + }); + }); + + test('two sequential calls trigger two fetches (no caching)', async () => { + setupValidFederatedIntegration(); + fetcher.onFirstCall().resolves({ accessToken: 'token-1' }); + fetcher.onSecondCall().resolves({ accessToken: 'token-2' }); + + const first = await generator.generate(buildSqlBlock()); + const second = await generator.generate(buildSqlBlock()); + + sinon.assert.calledTwice(fetcher); + assert.notStrictEqual(first?.prelude, second?.prelude); + assert.include(first?.prelude ?? '', 'token-1'); + assert.include(second?.prelude ?? '', 'token-2'); + }); + + test('InvalidGrantError from refresh: throws NotAuthenticatedError and deletes the token', async () => { + setupValidFederatedIntegration(); + fetcher.rejects(new InvalidGrantError()); + + try { + await generator.generate(buildSqlBlock()); + assert.fail('Expected NotAuthenticatedError'); + } catch (err) { + assert.instanceOf(err, NotAuthenticatedError); + } + sinon.assert.calledOnceWithExactly(deleteSpy, INTEGRATION_ID); + }); + + test('InvalidClientError from refresh: throws OAuthClientMisconfiguredError and does NOT delete the token', async () => { + // Generator wraps node-only `InvalidClientError` into cross-platform `OAuthClientMisconfiguredError` so web-bound callers can `instanceof`-check. + setupValidFederatedIntegration(); + fetcher.rejects(new InvalidClientError()); + + try { + await generator.generate(buildSqlBlock()); + assert.fail('Expected OAuthClientMisconfiguredError'); + } catch (err) { + assert.instanceOf(err, OAuthClientMisconfiguredError); + assert.notInstanceOf(err, NotAuthenticatedError); + assert.notInstanceOf(err, InvalidClientError); + assert.equal((err as OAuthClientMisconfiguredError).integrationName, 'My BigQuery'); + } + sinon.assert.notCalled(deleteSpy); + }); + + test('persists a rotated refresh token with { silent: true } so listeners do not restart the in-flight kernel', async () => { + // Catches: a rotation event firing `onDidChangeTokens` would queue a `kernel.restart()` while the prelude+main execute are running. + setupValidFederatedIntegration(); + fetcher.resolves({ accessToken: ACCESS_TOKEN, newRefreshToken: 'new-refresh-token' }); + + await generator.generate(buildSqlBlock()); + + sinon.assert.calledOnce(saveSpy); + const [savedEntry, options] = saveSpy.firstCall.args as [FederatedAuthTokenEntry, { silent?: boolean }]; + assert.deepStrictEqual(savedEntry, { + integrationId: INTEGRATION_ID, + refreshToken: 'new-refresh-token', + metadataFingerprint: VALID_FINGERPRINT + }); + assert.strictEqual(options?.silent, true, 'rotation save must pass { silent: true }'); + }); + + test('does NOT call save when the returned refresh token is identical to the stored one', async () => { + setupValidFederatedIntegration(); + fetcher.resolves({ accessToken: ACCESS_TOKEN, newRefreshToken: REFRESH_TOKEN }); + + await generator.generate(buildSqlBlock()); + + sinon.assert.notCalled(saveSpy); + }); + + test('cellCode honors deepnote_variable_name by emitting an assignment', async () => { + setupValidFederatedIntegration(); + const result = await generator.generate(buildSqlBlock({ deepnote_variable_name: 'my_df' })); + if (!result) { + throw new Error('expected a non-undefined result'); + } + // Match upstream's shape: `my_df = _dntk.execute_sql_with_connection_json(...)` followed by `my_df` on the next line. + assert.include(result.cellCode, 'my_df = _dntk.execute_sql_with_connection_json('); + }); + + suite('federatedSqlVariableName', () => { + ( + [ + ['abc-123-def', '__deepnote_federated_sql_connection__abc_123_def'], + ['abc_123', '__deepnote_federated_sql_connection__abc_123'] + ] as const + ).forEach(([input, expected]) => { + test(`maps ${JSON.stringify(input)} → ${expected}`, () => { + assert.strictEqual(federatedSqlVariableName(input), expected); + }); + }); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTestHelpers.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTestHelpers.node.ts new file mode 100644 index 0000000000..b51dc07f08 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTestHelpers.node.ts @@ -0,0 +1,18 @@ +// Node-only test helpers; pulls in `googleOAuthProvider.node` for the loopback-flow + OAuth-provider tests. + +import { + buildBigQueryGoogleOAuthStrategy, + type BuildBigQueryGoogleOAuthStrategyParams, + createInMemoryPKCEStore +} from './googleOAuthProvider.node'; + +export function buildTestStrategy( + overrides: Partial = {} +): ReturnType { + return buildBigQueryGoogleOAuthStrategy({ + clientId: 'cid', + clientSecret: 'cs', + store: createInMemoryPKCEStore(), + ...overrides + }); +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTestHelpers.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTestHelpers.ts new file mode 100644 index 0000000000..62c9fdbf8f --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTestHelpers.ts @@ -0,0 +1,258 @@ +// Shared test fixtures and helpers for the federated-auth tests + adjacent integration tests. +// Cross-platform: must not import from `.node.ts` modules. Node-only helpers live in `federatedAuthTestHelpers.node.ts`. + +import type { DeepnoteBlock } from '@deepnote/blocks'; +import sinon from 'sinon'; +import { EventEmitter } from 'vscode'; + +import type { ConfigurableDatabaseIntegrationConfig } from '../../../../platform/notebooks/deepnote/integrationTypes'; +import type { DeepnoteProject } from '../../../../platform/deepnote/deepnoteTypes'; +import type { IIntegrationStorage } from '../../../../platform/notebooks/deepnote/types'; +import type { FederatedAuthTokenEntry, IFederatedAuthTokenStorage } from '../types'; + +export const FED_AUTH_FIXTURE = { + INTEGRATION_ID: 'bq-integration-1', + PROJECT: 'my-gcp-project', + CLIENT_ID: 'client-id-abc', + CLIENT_SECRET: 'client-secret-xyz', + REFRESH_TOKEN: 'refresh-token-abc', + ACCESS_TOKEN: 'access-token-secret-do-not-log' +} as const; + +export function buildGoogleOauthIntegration( + overrides: Partial = {} +): ConfigurableDatabaseIntegrationConfig { + return { + id: FED_AUTH_FIXTURE.INTEGRATION_ID, + name: 'My BigQuery', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: FED_AUTH_FIXTURE.PROJECT, + clientId: FED_AUTH_FIXTURE.CLIENT_ID, + clientSecret: FED_AUTH_FIXTURE.CLIENT_SECRET + }, + ...overrides + } as ConfigurableDatabaseIntegrationConfig; +} + +export function buildServiceAccountIntegration( + overrides: Partial = {} +): ConfigurableDatabaseIntegrationConfig { + return { + id: FED_AUTH_FIXTURE.INTEGRATION_ID, + name: 'My SA BigQuery', + type: 'big-query', + metadata: { + authMethod: 'service-account', + service_account: '{"type":"service_account"}' + }, + ...overrides + } as ConfigurableDatabaseIntegrationConfig; +} + +export function buildPostgresIntegration( + overrides: Partial = {} +): ConfigurableDatabaseIntegrationConfig { + return { + id: 'pg-1', + name: 'My Postgres', + type: 'pgsql', + metadata: { + host: 'localhost', + port: '5432', + database: 'db', + user: 'u', + password: 'p', + sslEnabled: false + }, + ...overrides + } as ConfigurableDatabaseIntegrationConfig; +} + +export function buildTokenEntry(overrides: Partial = {}): FederatedAuthTokenEntry { + return { + integrationId: FED_AUTH_FIXTURE.INTEGRATION_ID, + refreshToken: FED_AUTH_FIXTURE.REFRESH_TOKEN, + metadataFingerprint: 'fp', + ...overrides + }; +} + +export function buildSqlBlock( + overrides: { + id?: string; + content?: string; + sql_integration_id?: string; + deepnote_variable_name?: string; + metadata?: Record; + } = {} +): DeepnoteBlock { + return { + id: overrides.id ?? 'block-1', + type: 'sql', + blockGroup: 'group-1', + sortingKey: '0', + content: overrides.content ?? 'SELECT 1 AS one', + metadata: overrides.metadata ?? { + sql_integration_id: overrides.sql_integration_id ?? FED_AUTH_FIXTURE.INTEGRATION_ID, + deepnote_variable_name: overrides.deepnote_variable_name + } + } as unknown as DeepnoteBlock; +} + +export function buildCodeBlock(): DeepnoteBlock { + return { + id: 'block-1', + type: 'code', + blockGroup: 'group-1', + sortingKey: '0', + content: 'print("hi")', + metadata: {} + } as unknown as DeepnoteBlock; +} + +export interface FakeIntegrationStorage { + addIntegration(config: ConfigurableDatabaseIntegrationConfig): void; + integrationStore: Map; + onDidChangeIntegrations: EventEmitter; + removeIntegration(id: string): void; + storage: IIntegrationStorage; +} + +export function createFakeIntegrationStorage(): FakeIntegrationStorage { + const integrationStore = new Map(); + const onDidChangeIntegrations = new EventEmitter(); + const storage = { + getIntegrationConfig: async (id: string) => integrationStore.get(id), + getAll: async () => Array.from(integrationStore.values()), + onDidChangeIntegrations: onDidChangeIntegrations.event + } as unknown as IIntegrationStorage; + return { + addIntegration: (config) => integrationStore.set(config.id, config), + integrationStore, + onDidChangeIntegrations, + removeIntegration: (id) => integrationStore.delete(id), + storage + }; +} + +export interface FakeTokenStorage { + deletedIds: string[]; + deleteSpy: sinon.SinonSpy; + fingerprintForTest(m: { clientId: string; clientSecret: string; project: string }): string; + onDidChangeEmitter: EventEmitter; + saveCallArgs: Array<[FederatedAuthTokenEntry, { silent?: boolean } | undefined]>; + saveSpy: sinon.SinonSpy; + savedTokens: FederatedAuthTokenEntry[]; + storage: IFederatedAuthTokenStorage; + tokens: Map; +} + +export function createFakeTokenStorage(opts?: { + fingerprintForTest?: (m: { clientId: string; clientSecret: string; project: string }) => string; + throwOnDelete?: Set; +}): FakeTokenStorage { + const tokens = new Map(); + const savedTokens: FederatedAuthTokenEntry[] = []; + const saveCallArgs: Array<[FederatedAuthTokenEntry, { silent?: boolean } | undefined]> = []; + const deletedIds: string[] = []; + const onDidChangeEmitter = new EventEmitter(); + const fingerprintForTest = opts?.fingerprintForTest ?? ((m) => `${m.clientId}|${m.clientSecret}|${m.project}`); + + const saveSpy = sinon.spy(async (entry: FederatedAuthTokenEntry, options?: { silent?: boolean }) => { + tokens.set(entry.integrationId, entry); + savedTokens.push(entry); + saveCallArgs.push([entry, options]); + if (!options?.silent) { + onDidChangeEmitter.fire(entry.integrationId); + } + }); + const deleteSpy = sinon.spy(async (id: string) => { + deletedIds.push(id); + if (opts?.throwOnDelete?.has(id)) { + throw new Error(`forced throw on delete: ${id}`); + } + const had = tokens.delete(id); + if (had) { + onDidChangeEmitter.fire(id); + } + }); + + const storage: IFederatedAuthTokenStorage = { + onDidChangeTokens: onDidChangeEmitter.event, + computeMetadataFingerprint(metadata) { + return fingerprintForTest(metadata); + }, + delete: deleteSpy as unknown as IFederatedAuthTokenStorage['delete'], + async get(integrationId: string) { + return tokens.get(integrationId); + }, + async has(integrationId: string) { + return tokens.has(integrationId); + }, + async listIntegrationIds() { + return Array.from(tokens.keys()); + }, + save: saveSpy as unknown as IFederatedAuthTokenStorage['save'] + }; + + return { + deletedIds, + deleteSpy, + fingerprintForTest, + onDidChangeEmitter, + saveCallArgs, + saveSpy, + savedTokens, + storage, + tokens + }; +} + +export function createMockProject(projectId: string, integrationIds: string[] = []): DeepnoteProject { + return { + metadata: { + createdAt: '2023-01-01T00:00:00Z', + modifiedAt: '2023-01-02T00:00:00Z' + }, + project: { + id: projectId, + name: 'Test Project', + notebooks: [], + integrations: integrationIds.map((id) => ({ id, name: id, type: 'big-query' as const })) + }, + version: '1.0.0' + }; +} + +export function settleAsyncHandlers(ms = 10): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Inverse of `escapePythonString`: parses a Python single-quoted literal back to its source string. */ +export function parsePythonSingleQuoted(escaped: string): string { + if (!escaped.startsWith("'") || !escaped.endsWith("'")) { + throw new Error('must be wrapped in single quotes'); + } + const body = escaped.slice(1, -1); + let out = ''; + for (let i = 0; i < body.length; i++) { + if (body[i] === '\\' && i + 1 < body.length) { + const next = body[i + 1]; + if (next === '\\') { + out += '\\'; + } else if (next === "'") { + out += "'"; + } else if (next === 'n') { + out += '\n'; + } else { + out += '\\' + next; + } + i++; + } else { + out += body[i]; + } + } + return out; +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts new file mode 100644 index 0000000000..5c644f02aa --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.ts @@ -0,0 +1,270 @@ +import { createHash } from 'crypto'; +import { inject, injectable } from 'inversify'; +import { EventEmitter } from 'vscode'; +import { z } from 'zod'; + +import { IEncryptedStorage } from '../../../../platform/common/application/types'; +import { IAsyncDisposableRegistry } from '../../../../platform/common/types'; +import { logger } from '../../../../platform/logging'; +import { FederatedAuthTokenEntry, IFederatedAuthTokenStorage } from '../types'; + +const FEDERATED_AUTH_TOKEN_SERVICE_NAME = 'deepnote-federated-auth-tokens'; +const INDEX_KEY = 'index'; +const TOKEN_REFRESH_TIMEOUT_MS = 15_000; // 15s + +/** Schema for Google's OAuth refresh-token endpoint response. Mirrors production at integration-federated-auth.ts:372-433. */ +const tokenEndpointResponseSchema = z.object({ + access_token: z.string().optional(), + refresh_token: z.string().optional(), + expires_in: z.number().optional(), + error: z.string().optional(), + error_description: z.string().optional() +}); + +/** Thrown on `error: 'invalid_grant'` — stored refresh token revoked/expired. Callers drop the entry and re-auth. */ +export class InvalidGrantError extends Error { + constructor(message = 'Refresh token rejected by OAuth provider.') { + super(message); + this.name = 'InvalidGrantError'; + } +} + +/** Thrown on `error: 'invalid_client'` / `unauthorized_client` — OAuth client metadata (clientId/clientSecret) is misconfigured. */ +export class InvalidClientError extends Error { + constructor(message = 'OAuth client credentials rejected by provider.') { + super(message); + this.name = 'InvalidClientError'; + } +} + +/** SHA-256 of `${clientId}|${clientSecret}|${project}`; detects edited OAuth client metadata so the token can be invalidated. */ +export function computeMetadataFingerprint(metadata: { + clientId: string; + clientSecret: string; + project: string; +}): string { + return createHash('sha256') + .update(`${metadata.clientId}|${metadata.clientSecret}|${metadata.project}`) + .digest('hex'); +} + +/** + * POSTs `grant_type=refresh_token` to the OAuth token endpoint and returns the fresh access token (plus + * optional rotated refresh token). Access tokens are never cached: callers must invoke this before every + * SQL cell. `timeoutMs` is a test seam (default {@link TOKEN_REFRESH_TIMEOUT_MS}). Ref: integration-federated-auth.ts:350-434. + */ +export async function fetchFreshAccessToken( + entry: FederatedAuthTokenEntry, + oauthConfig: { tokenUrl: string; clientId: string; clientSecret: string }, + timeoutMs: number = TOKEN_REFRESH_TIMEOUT_MS +): Promise<{ accessToken: string; newRefreshToken?: string }> { + const basicAuth = Buffer.from(`${oauthConfig.clientId}:${oauthConfig.clientSecret}`).toString('base64'); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + // Same AbortController bounds both `fetch` and `response.json()` so a slow body stream also hits the timeout. + let response: Response | undefined; + let rawBody: unknown; + try { + response = await fetch(oauthConfig.tokenUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(entry.refreshToken)}`, + signal: controller.signal + }); + rawBody = await response.json(); + } catch (error) { + if (error instanceof SyntaxError && response !== undefined) { + throw new Error( + `Token refresh response was not valid JSON (HTTP ${response.status} ${response.statusText}).`, + { cause: error } + ); + } + throw error; + } finally { + clearTimeout(timeout); + } + + const parsed = tokenEndpointResponseSchema.safeParse(rawBody); + if (!parsed.success) { + throw new Error(`Token refresh returned invalid response body: ${parsed.error.message}`); + } + + const data = parsed.data; + + if (!response.ok) { + if (data.error === 'invalid_grant') { + throw new InvalidGrantError(); + } + if (data.error === 'invalid_client' || data.error === 'unauthorized_client') { + throw new InvalidClientError(); + } + throw new Error(`Token refresh failed: ${response.status} ${response.statusText}`); + } + + if (!data.access_token) { + throw new Error('Token refresh succeeded but response did not include an access_token.'); + } + + return { + accessToken: data.access_token, + newRefreshToken: data.refresh_token ?? undefined + }; +} + +/** + * Encrypted-storage backed `IFederatedAuthTokenStorage`. Mirrors `IntegrationStorage`'s cache+index pattern. + * Only the long-lived refresh token (plus id + metadata fingerprint) is persisted; access tokens are fetched on demand. + */ +@injectable() +export class FederatedAuthTokenStorage implements IFederatedAuthTokenStorage { + private readonly cache: Map = new Map(); + + private cacheLoaded = false; + + private readonly _onDidChangeTokens = new EventEmitter(); + + public readonly onDidChangeTokens = this._onDidChangeTokens.event; + + constructor( + @inject(IEncryptedStorage) private readonly encryptedStorage: IEncryptedStorage, + @inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry + ) { + // Register for disposal when the extension deactivates. + asyncRegistry.push(this); + } + + /** Instance form of {@link computeMetadataFingerprint} so cross-platform callers avoid importing the node-only helper. */ + public computeMetadataFingerprint(metadata: { clientId: string; clientSecret: string; project: string }): string { + return computeMetadataFingerprint(metadata); + } + + public async delete(integrationId: string): Promise { + await this.ensureCacheLoaded(); + + if (!this.cache.has(integrationId)) { + return; + } + + await this.encryptedStorage.store(FEDERATED_AUTH_TOKEN_SERVICE_NAME, integrationId, undefined); + this.cache.delete(integrationId); + await this.updateIndex(); + + this._onDidChangeTokens.fire(integrationId); + } + + public dispose(): void { + this._onDidChangeTokens.dispose(); + } + + public async get(integrationId: string): Promise { + await this.ensureCacheLoaded(); + return this.cache.get(integrationId); + } + + public async has(integrationId: string): Promise { + await this.ensureCacheLoaded(); + return this.cache.has(integrationId); + } + + public async listIntegrationIds(): Promise { + await this.ensureCacheLoaded(); + return Array.from(this.cache.keys()); + } + + public async save(entry: FederatedAuthTokenEntry, options?: { silent?: boolean }): Promise { + await this.ensureCacheLoaded(); + + await this.encryptedStorage.store( + FEDERATED_AUTH_TOKEN_SERVICE_NAME, + entry.integrationId, + JSON.stringify(entry) + ); + this.cache.set(entry.integrationId, entry); + await this.updateIndex(); + + if (!options?.silent) { + this._onDidChangeTokens.fire(entry.integrationId); + } + } + + /** Hydrate the cache from the `'index'` secret then each entry. Tolerates corrupted/missing data (logs + treats as empty). */ + private async ensureCacheLoaded(): Promise { + if (this.cacheLoaded) { + return; + } + + const indexJson = await this.encryptedStorage.retrieve(FEDERATED_AUTH_TOKEN_SERVICE_NAME, INDEX_KEY); + if (!indexJson) { + this.cacheLoaded = true; + return; + } + + let integrationIds: string[]; + try { + const parsed: unknown = JSON.parse(indexJson); + if (!Array.isArray(parsed)) { + throw new Error('Index is not an array.'); + } + integrationIds = parsed.filter((id): id is string => typeof id === 'string'); + } catch (error) { + logger.error('FederatedAuthTokenStorage: Failed to parse index, treating storage as empty.', error); + this.cacheLoaded = true; + return; + } + + // Mirrors IntegrationStorage:165-241 cleanup: purge malformed entries from storage + rewrite the index. + const malformedIds: string[] = []; + for (const id of integrationIds) { + try { + const entryJson = await this.encryptedStorage.retrieve(FEDERATED_AUTH_TOKEN_SERVICE_NAME, id); + if (!entryJson) { + continue; + } + const parsed = JSON.parse(entryJson) as Partial; + if ( + typeof parsed.integrationId === 'string' && + typeof parsed.refreshToken === 'string' && + typeof parsed.metadataFingerprint === 'string' + ) { + this.cache.set(id, { + integrationId: parsed.integrationId, + refreshToken: parsed.refreshToken, + metadataFingerprint: parsed.metadataFingerprint + }); + } else { + logger.warn(`FederatedAuthTokenStorage: Skipping malformed token entry for ${id}.`); + malformedIds.push(id); + } + } catch (error) { + logger.error(`FederatedAuthTokenStorage: Failed to load token entry for ${id}.`, error); + malformedIds.push(id); + } + } + + if (malformedIds.length > 0) { + logger.info( + `FederatedAuthTokenStorage: Removing ${malformedIds.length} malformed token entry/entries from storage.` + ); + for (const id of malformedIds) { + try { + await this.encryptedStorage.store(FEDERATED_AUTH_TOKEN_SERVICE_NAME, id, undefined); + } catch (error) { + logger.error(`FederatedAuthTokenStorage: Failed to delete malformed token entry for ${id}.`, error); + } + } + await this.updateIndex(); + } + + this.cacheLoaded = true; + } + + private async updateIndex(): Promise { + const integrationIds = Array.from(this.cache.keys()); + await this.encryptedStorage.store(FEDERATED_AUTH_TOKEN_SERVICE_NAME, INDEX_KEY, JSON.stringify(integrationIds)); + } +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.unit.test.ts new file mode 100644 index 0000000000..8046842c1c --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node.unit.test.ts @@ -0,0 +1,462 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; +import { anything, instance, mock, when } from 'ts-mockito'; + +import { IEncryptedStorage } from '../../../../platform/common/application/types'; +import { IAsyncDisposableRegistry } from '../../../../platform/common/types'; +import { FederatedAuthTokenEntry } from '../types'; +import { + computeMetadataFingerprint, + FederatedAuthTokenStorage, + fetchFreshAccessToken, + InvalidClientError, + InvalidGrantError +} from './federatedAuthTokenStorage.node'; + +suite('federatedAuthTokenStorage', () => { + suite('computeMetadataFingerprint', () => { + test('differs when clientId changes', () => { + const a = computeMetadataFingerprint({ clientId: 'c-1', clientSecret: 's', project: 'p' }); + const b = computeMetadataFingerprint({ clientId: 'c-2', clientSecret: 's', project: 'p' }); + assert.notStrictEqual(a, b); + }); + + test('differs when clientSecret changes', () => { + const a = computeMetadataFingerprint({ clientId: 'c', clientSecret: 's-1', project: 'p' }); + const b = computeMetadataFingerprint({ clientId: 'c', clientSecret: 's-2', project: 'p' }); + assert.notStrictEqual(a, b); + }); + + test('differs when project changes', () => { + const a = computeMetadataFingerprint({ clientId: 'c', clientSecret: 's', project: 'p-1' }); + const b = computeMetadataFingerprint({ clientId: 'c', clientSecret: 's', project: 'p-2' }); + assert.notStrictEqual(a, b); + }); + + test('treats the three fields as distinct (no field-boundary confusion)', () => { + // Catches: separator-less concatenation where `a|bc` collides with `ab|c`. + const a = computeMetadataFingerprint({ clientId: 'a', clientSecret: 'b', project: 'c' }); + const b = computeMetadataFingerprint({ clientId: 'a|b', clientSecret: '', project: 'c' }); + assert.notStrictEqual(a, b); + }); + }); + + suite('FederatedAuthTokenStorage', () => { + let storage: FederatedAuthTokenStorage; + let encryptedStorage: IEncryptedStorage; + let asyncRegistry: IAsyncDisposableRegistry; + let storageData: Map; + + setup(() => { + storageData = new Map(); + encryptedStorage = mock(); + asyncRegistry = mock(); + + when(encryptedStorage.store(anything(), anything(), anything())).thenCall( + async (_serviceName: string, key: string, value: string | undefined) => { + if (value === undefined) { + storageData.delete(key); + } else { + storageData.set(key, value); + } + } + ); + when(encryptedStorage.retrieve(anything(), anything())).thenCall( + async (_serviceName: string, key: string) => { + return storageData.get(key); + } + ); + + storage = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + }); + + teardown(() => { + storage.dispose(); + }); + + const sampleEntry = (id = 'integration-1'): FederatedAuthTokenEntry => ({ + integrationId: id, + refreshToken: `refresh-token-for-${id}`, + metadataFingerprint: `fp-${id}` + }); + + test('save then get round-trips the entry', async () => { + const entry = sampleEntry(); + await storage.save(entry); + + const result = await storage.get(entry.integrationId); + assert.deepStrictEqual(result, entry); + }); + + test('save persists exactly the three-field entry shape', async () => { + const entry = sampleEntry(); + await storage.save(entry); + const stored = storageData.get(entry.integrationId); + assert.ok(stored, 'entry should be stored'); + const parsed = JSON.parse(stored!); + assert.deepStrictEqual(Object.keys(parsed).sort(), [ + 'integrationId', + 'metadataFingerprint', + 'refreshToken' + ]); + }); + + test('has returns true after save', async () => { + const entry = sampleEntry(); + await storage.save(entry); + assert.strictEqual(await storage.has(entry.integrationId), true); + }); + + test('listIntegrationIds returns all stored integration ids', async () => { + await storage.save(sampleEntry('integration-a')); + await storage.save(sampleEntry('integration-b')); + await storage.save(sampleEntry('integration-c')); + + const ids = await storage.listIntegrationIds(); + assert.deepStrictEqual(ids.sort(), ['integration-a', 'integration-b', 'integration-c']); + }); + + test('listIntegrationIds reflects deletions', async () => { + await storage.save(sampleEntry('integration-a')); + await storage.save(sampleEntry('integration-b')); + await storage.delete('integration-a'); + + const ids = await storage.listIntegrationIds(); + assert.deepStrictEqual(ids, ['integration-b']); + }); + + test('delete removes the entry', async () => { + const entry = sampleEntry(); + await storage.save(entry); + await storage.delete(entry.integrationId); + assert.deepStrictEqual( + { get: await storage.get(entry.integrationId), has: await storage.has(entry.integrationId) }, + { get: undefined, has: false } + ); + }); + + test('delete on a missing integration does not fire the change event', async () => { + const events: string[] = []; + storage.onDidChangeTokens((id) => events.push(id)); + + await storage.delete('does-not-exist'); + + assert.deepStrictEqual(events, []); + }); + + test('save fires onDidChangeTokens with the correct integration id', async () => { + const events: string[] = []; + storage.onDidChangeTokens((id) => events.push(id)); + + await storage.save(sampleEntry('integration-1')); + await storage.save(sampleEntry('integration-2')); + + assert.deepStrictEqual(events, ['integration-1', 'integration-2']); + }); + + test('delete fires onDidChangeTokens with the deleted integration id', async () => { + const entry = sampleEntry(); + await storage.save(entry); + + const events: string[] = []; + storage.onDidChangeTokens((id) => events.push(id)); + + await storage.delete(entry.integrationId); + + assert.deepStrictEqual(events, [entry.integrationId]); + }); + + test('save with { silent: true } persists the entry but does NOT fire onDidChangeTokens', async () => { + // Catches: a rotation event flipping the kernel-restart bridge mid-cell. + const events: string[] = []; + storage.onDidChangeTokens((id) => events.push(id)); + + const entry = sampleEntry(); + await storage.save(entry, { silent: true }); + + assert.deepStrictEqual(events, [], 'silent save must not fire the change event'); + assert.deepStrictEqual(await storage.get(entry.integrationId), entry, 'silent save must still persist'); + }); + + test('a fresh instance backed by the same storage rehydrates all entries', async () => { + await storage.save(sampleEntry('a')); + await storage.save(sampleEntry('b')); + await storage.save(sampleEntry('c')); + + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + assert.deepStrictEqual( + { a: await reloaded.get('a'), b: await reloaded.has('b'), c: await reloaded.has('c') }, + { a: sampleEntry('a'), b: true, c: true } + ); + } finally { + reloaded.dispose(); + } + }); + + test('handles corrupted index gracefully', async () => { + storageData.set('index', 'not-json'); + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + assert.strictEqual(await reloaded.has('whatever'), false); + } finally { + reloaded.dispose(); + } + }); + + test('reload purges malformed entries from cache, secret store, and index', async () => { + // Catches: orphaned refresh-token secrets persisting + the index keeps referencing a malformed id. + storageData.set('index', JSON.stringify(['malformed-1', 'good-1'])); + storageData.set('malformed-1', JSON.stringify({ integrationId: 'malformed-1' })); + storageData.set( + 'good-1', + JSON.stringify({ + integrationId: 'good-1', + refreshToken: 't', + metadataFingerprint: 'fp' + } satisfies FederatedAuthTokenEntry) + ); + + const reloaded = new FederatedAuthTokenStorage(instance(encryptedStorage), instance(asyncRegistry)); + try { + // Triggers the lazy reload, which purges malformed entries from storageData and the index. + const cacheHasMalformed = await reloaded.has('malformed-1'); + const cacheHasGood = await reloaded.has('good-1'); + const indexJson = storageData.get('index'); + assert.ok(indexJson, 'index should still be present'); + assert.deepStrictEqual( + { + cacheHasMalformed, + cacheHasGood, + secretStoreHasMalformed: storageData.has('malformed-1'), + secretStoreHasGood: storageData.has('good-1'), + index: JSON.parse(indexJson!) + }, + { + cacheHasMalformed: false, + cacheHasGood: true, + secretStoreHasMalformed: false, + secretStoreHasGood: true, + index: ['good-1'] + } + ); + } finally { + reloaded.dispose(); + } + }); + }); + + suite('fetchFreshAccessToken', () => { + let originalFetch: typeof globalThis.fetch | undefined; + + const sampleEntry: FederatedAuthTokenEntry = { + integrationId: 'integration-1', + refreshToken: 'refresh-token-value', + metadataFingerprint: 'fp' + }; + const sampleConfig = { + tokenUrl: 'https://oauth2.googleapis.com/token', + clientId: 'client-id', + clientSecret: 'client-secret' + }; + + setup(() => { + originalFetch = globalThis.fetch; + }); + + teardown(() => { + if (originalFetch === undefined) { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } else { + globalThis.fetch = originalFetch; + } + sinon.restore(); + }); + + function makeResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } + }); + } + + function stubFetchResponse(status: number, body: unknown): sinon.SinonStub { + const stub = sinon.stub().resolves(makeResponse(status, body)); + globalThis.fetch = stub as unknown as typeof fetch; + return stub; + } + + async function expectThrow( + expectedError: ErrorConstructor | typeof InvalidClientError | typeof InvalidGrantError, + extraAssert?: (err: Error) => void + ): Promise { + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.fail('expected throw'); + } catch (err) { + assert.instanceOf(err, expectedError as ErrorConstructor); + assert(err instanceof Error); + extraAssert?.(err); + } + } + + test('sends Basic auth header and form-encoded refresh_token body', async () => { + const fetchStub = stubFetchResponse(200, { access_token: 'fresh-access' }); + + await fetchFreshAccessToken(sampleEntry, sampleConfig); + + sinon.assert.calledOnce(fetchStub); + const [url, init] = fetchStub.firstCall.args as [string, RequestInit]; + const headers = init.headers as Record; + const expectedBasic = Buffer.from(`${sampleConfig.clientId}:${sampleConfig.clientSecret}`).toString( + 'base64' + ); + assert.deepStrictEqual( + { + url, + method: init.method, + authorization: headers.Authorization, + contentType: headers['Content-Type'], + body: init.body + }, + { + url: sampleConfig.tokenUrl, + method: 'POST', + authorization: `Basic ${expectedBasic}`, + contentType: 'application/x-www-form-urlencoded', + body: `grant_type=refresh_token&refresh_token=${sampleEntry.refreshToken}` + } + ); + }); + + test('returns the access token and the rotated refresh token on success', async () => { + stubFetchResponse(200, { access_token: 'fresh-access', refresh_token: 'rotated-refresh' }); + + const result = await fetchFreshAccessToken(sampleEntry, sampleConfig); + assert.deepStrictEqual(result, { + accessToken: 'fresh-access', + newRefreshToken: 'rotated-refresh' + }); + }); + + test('URL-encodes the refresh token in the body', async () => { + const fetchStub = stubFetchResponse(200, { access_token: 'a' }); + + const entryWithSpecial: FederatedAuthTokenEntry = { + integrationId: 'i', + refreshToken: 'a=b&c+d e', + metadataFingerprint: 'fp' + }; + + await fetchFreshAccessToken(entryWithSpecial, sampleConfig); + + const [, init] = fetchStub.firstCall.args as [string, RequestInit]; + assert.strictEqual( + init.body, + `grant_type=refresh_token&refresh_token=${encodeURIComponent(entryWithSpecial.refreshToken)}` + ); + }); + + test('throws InvalidGrantError on HTTP 400 with error=invalid_grant', async () => { + stubFetchResponse(400, { error: 'invalid_grant' }); + await expectThrow(InvalidGrantError); + }); + + (['invalid_client', 'unauthorized_client'] as const).forEach((errorCode) => { + test(`throws InvalidClientError on HTTP 401 with error=${errorCode}`, async () => { + stubFetchResponse(401, { error: errorCode }); + await expectThrow(InvalidClientError); + }); + }); + + test('throws a generic Error on HTTP 500', async () => { + stubFetchResponse(500, { error: 'internal_server_error' }); + await expectThrow(Error, (err) => { + assert.notInstanceOf(err, InvalidGrantError); + assert.notInstanceOf(err, InvalidClientError); + }); + }); + + test('throws on a fetch AbortError (timeout)', async () => { + const abortError = new Error('The user aborted a request.'); + abortError.name = 'AbortError'; + globalThis.fetch = sinon.stub().rejects(abortError) as unknown as typeof fetch; + + await expectThrow(Error, (err) => { + assert.strictEqual(err.name, 'AbortError'); + }); + }); + + test('throws when 2xx response does not include access_token', async () => { + stubFetchResponse(200, { refresh_token: 'r' }); + await expectThrow(Error); + }); + + ( + [ + ['access_token', { access_token: 12345 }], + ['refresh_token', { access_token: 'a', refresh_token: 42 }] + ] as const + ).forEach(([field, body]) => { + test(`throws when ${field} in a 2xx response is not a string`, async () => { + // Locks the zod schema contract on the token-response fields. + stubFetchResponse(200, body); + await expectThrow(Error, (err) => { + assert.include(err.message, 'invalid response body'); + }); + }); + }); + + test('throws with SyntaxError cause when body is invalid JSON, preserving original error', async () => { + const malformedResponse = new Response('not-json', { + status: 200, + headers: { 'content-type': 'application/json' } + }); + globalThis.fetch = sinon.stub().resolves(malformedResponse) as unknown as typeof fetch; + + await expectThrow(Error, (err) => { + assert.include(err.message, 'not valid JSON'); + assert.include(err.message, 'HTTP 200'); + assert.instanceOf(err.cause, SyntaxError); + }); + }); + + test('rejects when response.json() takes longer than the timeout', async () => { + // Catches: a timeout that covers only `fetch()` and not `response.json()`, which would let a slow body hang. + const makeSlowResponse = (signal: AbortSignal | undefined): Response => { + const slowJson = (): Promise => + new Promise((_resolve, reject) => { + if (signal === undefined) { + return; + } + signal.addEventListener('abort', () => { + const abortError = new Error('The body read was aborted.'); + abortError.name = 'AbortError'; + reject(abortError); + }); + }); + return { + ok: true, + status: 200, + statusText: 'OK', + json: slowJson + } as unknown as Response; + }; + + globalThis.fetch = ((_url: string, init?: RequestInit) => { + // Headers arrive immediately; body read stalls until abort. + return Promise.resolve(makeSlowResponse(init?.signal ?? undefined)); + }) as unknown as typeof fetch; + + const start = Date.now(); + try { + await fetchFreshAccessToken(sampleEntry, sampleConfig, 50); + assert.fail('expected throw'); + } catch (err) { + assert(err instanceof Error); + assert.strictEqual(err.name, 'AbortError'); + assert.isBelow(Date.now() - start, 1500); + } + }); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.ts new file mode 100644 index 0000000000..1bfdb54a46 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.ts @@ -0,0 +1,124 @@ +import * as crypto from 'crypto'; +import { Profile as GoogleProfile, Strategy as GoogleStrategy, VerifyCallback } from 'passport-google-oauth20'; + +export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** OAuth scopes for BigQuery federated auth. Mirrors handlers.ts:77; `openid` omitted (refresh tokens come from `access_type=offline` + `prompt=consent`). */ +export const GOOGLE_BIGQUERY_SCOPES = ['email', 'profile', 'https://www.googleapis.com/auth/bigquery'] as const; + +/** Per-flow record in an {@link InMemoryPKCEStore}; `meta` is opaque to us and just round-tripped for passport-oauth2. */ +interface PkceRecord { + codeVerifier: string; + meta: unknown; +} + +/** + * passport-oauth2 state-store with 5-arg `store` / 4-arg `verify` shapes so its arity check picks the PKCE + * branch (strategy.js:218-298). The verify `ok` slot must hold the codeVerifier string (strategy.js:171-173). + */ +export interface InMemoryPKCEStore { + store( + req: unknown, + verifier: string, + state: unknown, + meta: unknown, + cb: (err: Error | null, state?: string) => void + ): void; + verify( + req: unknown, + providedState: string, + meta: unknown, + cb: (err: Error | null, ok: string | false, info?: unknown) => void + ): void; +} + +/** Per-flow PKCE/state store: substitutes for passport-oauth2's built-in `PKCESessionStore` (which requires `req.session`). Each call gets its own store to isolate concurrent flows. */ +export function createInMemoryPKCEStore(): InMemoryPKCEStore { + const records = new Map(); + return { + store(_req, verifier, _state, meta, cb) { + const state = crypto.randomBytes(24).toString('base64url'); + records.set(state, { codeVerifier: verifier, meta }); + cb(null, state); + }, + verify(_req, providedState, _meta, cb) { + const record = records.get(providedState); + if (record === undefined) { + cb(null, false, { message: 'Invalid authorization request state.' }); + return; + } + records.delete(providedState); + // PKCE: `ok` must be the codeVerifier string (strategy.js:171-173). + cb(null, record.codeVerifier); + } + }; +} + +/** Result of {@link buildBigQueryGoogleOAuthStrategy}: configured passport strategy + completion promise (resolves with the captured refresh token; rejects on empty). */ +export interface BigQueryGoogleOAuthStrategy { + completion: Promise<{ refreshToken: string }>; + strategy: GoogleStrategy; +} + +/** Params for {@link buildBigQueryGoogleOAuthStrategy}; `authorizationURL`/`tokenURL` are test seams (defaults to Google's bundled URLs per passport-google-oauth20/strategy.js:49-50). */ +export interface BuildBigQueryGoogleOAuthStrategyParams { + authorizationURL?: string; + clientId: string; + clientSecret: string; + store: InMemoryPKCEStore; + tokenURL?: string; +} + +/** Builds Google OAuth strategy + verify pair. Verify resolves `completion` on a non-empty refresh token; an empty token rejects with the "Revoke the app at my-account.google.com/permissions" guidance. */ +export function buildBigQueryGoogleOAuthStrategy( + params: BuildBigQueryGoogleOAuthStrategyParams +): BigQueryGoogleOAuthStrategy { + let resolveCompletion!: (value: { refreshToken: string }) => void; + let rejectCompletion!: (reason: Error) => void; + const completion = new Promise<{ refreshToken: string }>((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + + const strategyOptions = { + clientID: params.clientId, + clientSecret: params.clientSecret, + // Placeholder; overwritten by runOAuthFlow once a port is bound. + callbackURL: 'http://127.0.0.1:0/auth/callback', + scope: [...GOOGLE_BIGQUERY_SCOPES], + pkce: true, + state: true, + // We only want the refresh token; skip the userinfo fetch (would fail with stub access tokens in tests). + skipUserProfile: true, + // Cast: @types/passport-oauth2 lacks the PKCE 5/4-arg overloads (index.d.ts:37-43) but passport-oauth2 picks them via `Function.length` (strategy.js:218-298). + store: params.store as never, + passReqToCallback: false as const, + ...(params.authorizationURL ? { authorizationURL: params.authorizationURL } : {}), + ...(params.tokenURL ? { tokenURL: params.tokenURL } : {}) + }; + + const verify = ( + _accessToken: string, + refreshToken: string, + _profile: GoogleProfile, + done: VerifyCallback + ): void => { + if (!refreshToken) { + const err = new Error( + 'No refresh token returned. Revoke the app at my-account.google.com/permissions and try again.' + ); + rejectCompletion(err); + done(err); + return; + } + resolveCompletion({ refreshToken }); + // Truthy `user` so passport renders the configured /auth/callback success response. + done(null, { refreshToken } as unknown as Express.User); + }; + + const strategy = new GoogleStrategy(strategyOptions, verify); + + return { strategy, completion }; +} + +export { GoogleStrategy }; diff --git a/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.unit.test.ts new file mode 100644 index 0000000000..0454f975a3 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/googleOAuthProvider.node.unit.test.ts @@ -0,0 +1,196 @@ +import { assert } from 'chai'; + +import { GOOGLE_BIGQUERY_SCOPES, createInMemoryPKCEStore } from './googleOAuthProvider.node'; +import { buildTestStrategy } from './federatedAuthTestHelpers.node'; + +suite('googleOAuthProvider', () => { + suite('GOOGLE_BIGQUERY_SCOPES', () => { + test('exposes email, profile, and the bigquery scope (no openid)', () => { + // openid is omitted; refresh tokens come from `access_type=offline` + `prompt=consent`. + assert.deepStrictEqual( + [...GOOGLE_BIGQUERY_SCOPES], + ['email', 'profile', 'https://www.googleapis.com/auth/bigquery'] + ); + }); + }); + + suite('createInMemoryPKCEStore', () => { + test('store + verify round-trips the code verifier', () => { + const store = createInMemoryPKCEStore(); + const verifier = 'random-verifier-12345'; + + let issuedState: string | undefined; + store.store(undefined, verifier, undefined, undefined, (err, state) => { + assert.isNull(err); + assert.isString(state); + issuedState = state; + }); + + assert.isDefined(issuedState); + + let verifyResult: { ok: string | false; info: unknown } | undefined; + store.verify(undefined, issuedState!, undefined, (err, ok, info) => { + assert.isNull(err); + verifyResult = { ok, info }; + }); + + assert.isDefined(verifyResult); + // PKCE: `ok` must be the codeVerifier string so passport-oauth2 forwards it (strategy.js:171-173). + assert.strictEqual(verifyResult!.ok, verifier); + }); + + test('store generates a non-empty, URL-safe state', () => { + const store = createInMemoryPKCEStore(); + let issuedState: string | undefined; + store.store(undefined, 'v', undefined, undefined, (_err, state) => { + issuedState = state; + }); + assert.isString(issuedState); + assert.isAbove(issuedState!.length, 0); + // base64url alphabet: A-Z, a-z, 0-9, -, _ (no padding). + assert.match(issuedState!, /^[A-Za-z0-9_-]+$/); + }); + + test('store generates distinct states across calls', () => { + const store = createInMemoryPKCEStore(); + const states: string[] = []; + for (let i = 0; i < 5; i++) { + store.store(undefined, `v-${i}`, undefined, undefined, (_err, state) => { + states.push(state!); + }); + } + assert.strictEqual(new Set(states).size, 5); + }); + + test('verify with unknown state returns (null, false, info)', () => { + const store = createInMemoryPKCEStore(); + let result: { ok: string | false; info: unknown } | undefined; + store.verify(undefined, 'never-issued', undefined, (err, ok, info) => { + assert.isNull(err); + result = { ok, info }; + }); + assert.isDefined(result); + assert.strictEqual(result!.ok, false); + assert.isDefined(result!.info); + }); + + test('verify deletes the entry (single-use)', () => { + const store = createInMemoryPKCEStore(); + let issuedState!: string; + store.store(undefined, 'verifier', undefined, undefined, (_err, state) => { + issuedState = state!; + }); + + // First verify: succeeds. + let firstResult: string | false | undefined; + store.verify(undefined, issuedState, undefined, (_err, ok) => { + firstResult = ok; + }); + assert.strictEqual(firstResult, 'verifier'); + + // Second verify with the same state: must fail (entry was deleted). + let secondResult: string | false | undefined; + store.verify(undefined, issuedState, undefined, (_err, ok) => { + secondResult = ok; + }); + assert.strictEqual(secondResult, false); + }); + + test('isolated stores do not share state', () => { + const a = createInMemoryPKCEStore(); + const b = createInMemoryPKCEStore(); + let stateA!: string; + a.store(undefined, 'va', undefined, undefined, (_err, state) => { + stateA = state!; + }); + // Verify stateA against the *other* store: must fail. + let result: string | false | undefined; + b.verify(undefined, stateA, undefined, (_err, ok) => { + result = ok; + }); + assert.strictEqual(result, false); + }); + }); + + suite('buildBigQueryGoogleOAuthStrategy', () => { + test('strategy.name is "google" (the passport-google-oauth20 default)', () => { + const { strategy } = buildTestStrategy(); + assert.strictEqual(strategy.name, 'google'); + }); + + test('uses the GOOGLE_BIGQUERY_SCOPES on the strategy', () => { + const { strategy } = buildTestStrategy(); + // `_scope` is set by passport-oauth2 from options.scope; probe to assert wiring. + const scope = (strategy as unknown as { _scope: string[] })._scope; + assert.deepStrictEqual(scope, [...GOOGLE_BIGQUERY_SCOPES]); + }); + + test('verify resolves the completion promise on a non-empty refresh token', async () => { + const { strategy, completion } = buildTestStrategy(); + + // `_verify` is stored by passport-oauth2 (strategy.js:~70). + const verify = (strategy as unknown as { _verify: Function })._verify; + + verify( + 'access-token', + 'refresh-token-value', + { id: 'user-1', provider: 'google' }, + (_err: unknown, user: unknown) => { + assert.deepStrictEqual(user, { refreshToken: 'refresh-token-value' }); + } + ); + + const result = await completion; + assert.deepStrictEqual(result, { refreshToken: 'refresh-token-value' }); + }); + + test('verify rejects the completion promise on an empty refresh token', async () => { + const { strategy, completion } = buildTestStrategy(); + + const verify = (strategy as unknown as { _verify: Function })._verify; + verify('access-token', '', { id: 'u', provider: 'google' }, () => { + // done() is called with the error — we ignore here. + }); + + try { + await completion; + assert.fail('expected rejection'); + } catch (err) { + assert(err instanceof Error); + assert.include(err.message, 'No refresh token returned'); + assert.include(err.message, 'my-account.google.com/permissions'); + } + }); + + function oauth2Urls(strategy: object): { _authorizeUrl: string; _accessTokenUrl: string } { + return (strategy as unknown as { _oauth2: { _authorizeUrl: string; _accessTokenUrl: string } })._oauth2; + } + + test('authorizationURL and tokenURL overrides land on the strategy', () => { + const { strategy } = buildTestStrategy({ + authorizationURL: 'http://stub/oauth/authorize', + tokenURL: 'http://stub/oauth/token' + }); + + const { _authorizeUrl, _accessTokenUrl } = oauth2Urls(strategy); + assert.deepStrictEqual( + { authorizeUrl: _authorizeUrl, accessTokenUrl: _accessTokenUrl }, + { authorizeUrl: 'http://stub/oauth/authorize', accessTokenUrl: 'http://stub/oauth/token' } + ); + }); + + test('without overrides, the strategy uses Google production URLs', () => { + const { strategy } = buildTestStrategy(); + + // passport-google-oauth20/lib/strategy.js:49-50. + const { _authorizeUrl, _accessTokenUrl } = oauth2Urls(strategy); + assert.deepStrictEqual( + { authorizeUrl: _authorizeUrl, accessTokenUrl: _accessTokenUrl }, + { + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + accessTokenUrl: 'https://www.googleapis.com/oauth2/v4/token' + } + ); + }); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.ts b/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.ts new file mode 100644 index 0000000000..2ff0239abd --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.ts @@ -0,0 +1,221 @@ +import * as crypto from 'crypto'; +import express, { type Express, type Request, type Response } from 'express'; +import * as http from 'http'; +import passport from 'passport'; +import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { type AddressInfo } from 'net'; +import { CancellationError, CancellationToken } from 'vscode'; + +import { logger } from '../../../../platform/logging'; + +/** Default OAuth-flow deadline (5 min) measured from listen(). After this the loopback server is torn down. */ +export const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Inputs for {@link runOAuthFlow}. Strategy + completion pair must come from {@link buildBigQueryGoogleOAuthStrategy}. + * `onListening` fires once with the start URL after the port is bound; `timeoutMs` is a test seam. + */ +export interface RunOAuthFlowParams { + completion: Promise<{ refreshToken: string }>; + integrationId: string; + onListening: (startUrl: string) => Promise; + strategy: GoogleStrategy; + timeoutMs?: number; + token: CancellationToken; +} + +/** Runs the loopback OAuth flow; resolves with the refresh token, or rejects on cancellation/timeout/OAuth error. Cleans up server + passport strategy unconditionally. */ +export async function runOAuthFlow(params: RunOAuthFlowParams): Promise<{ refreshToken: string }> { + const strategyName = `deepnote-google-oauth-${crypto.randomBytes(8).toString('hex')}`; + const timeoutMs = params.timeoutMs ?? OAUTH_FLOW_TIMEOUT_MS; + + const app: Express = express(); + const server = http.createServer(app); + + let cancellationSubscription: { dispose(): void } | undefined; + let timeoutHandle: NodeJS.Timeout | undefined; + + try { + passport.use(strategyName, params.strategy); + + // Loopback-only: Google "Desktop app" OAuth clients only accept redirects on the loopback interface. + server.listen(0, '127.0.0.1'); + + const listening = new Promise((resolve, reject) => { + // Forward-declared with `let` so each handler can `removeListener` the other (avoids use-before-define). + let onError: (err: Error) => void = () => undefined; + let onListening: () => void = () => undefined; + onError = (err: Error) => { + server.removeListener('listening', onListening); + reject(err); + }; + onListening = () => { + server.removeListener('error', onError); + const address = server.address() as AddressInfo | null; + if (!address || typeof address === 'string') { + reject(new Error('Loopback server did not bind a port.')); + return; + } + resolve(address.port); + }; + server.once('error', onError); + server.once('listening', onListening); + }); + + const port = await listening; + const callbackURL = `http://127.0.0.1:${port}/auth/callback`; + const startUrl = `http://127.0.0.1:${port}/auth/start`; + + // Patch the strategy's placeholder `_callbackURL` now that we know the bound port (used in both the authorize redirect and the token-exchange `redirect_uri`). + (params.strategy as unknown as { _callbackURL: string })._callbackURL = callbackURL; + + // /auth/start kicks the authorize redirect with `accessType=offline` + `prompt=consent` so Google issues a refresh token even on re-authorization (passport-google-oauth20 strategy.js:138-143). + app.get( + '/auth/start', + passport.authenticate(strategyName, { + session: false, + accessType: 'offline', + prompt: 'consent' + } as Parameters[1]) + ); + + // /auth/callback runs the verify closure (resolves `completion` on success). Failures land in the error middleware below. + app.get( + '/auth/callback', + passport.authenticate(strategyName, { session: false } as Parameters[1]), + (_req: Request, res: Response) => { + res.status(200).send(renderSuccessPage()); + } + ); + + // Renders an inline error page in the user's browser; the promise rejection comes from the verify closure separately. + app.use((err: unknown, _req: Request, res: Response, _next: unknown) => { + const message = err instanceof Error ? err.message : 'Authentication failed.'; + logger.error('OAuth loopback flow rendered error page.', err); + res.status(400).send(renderErrorPage(message)); + }); + + // Wire cancellation + timeout BEFORE onListening so a fast cancel inside the caller is observed (VSCode events don't replay). + const cancellationPromise = new Promise((_, reject) => { + if (params.token.isCancellationRequested) { + reject(new CancellationError()); + return; + } + cancellationSubscription = params.token.onCancellationRequested(() => { + reject(new CancellationError()); + }); + }); + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`OAuth flow timed out after ${Math.round(timeoutMs / 1000)} seconds.`)); + }, timeoutMs); + }); + + await params.onListening(startUrl); + + const result = await Promise.race<{ refreshToken: string }>([ + params.completion, + timeoutPromise, + cancellationPromise + ]); + + return result; + } finally { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + if (cancellationSubscription !== undefined) { + cancellationSubscription.dispose(); + } + // `closeAllConnections` prevents the server hanging on a half-open TCP connection if the user closed the browser tab mid-flow. + if (typeof (server as { closeAllConnections?: () => void }).closeAllConnections === 'function') { + (server as { closeAllConnections: () => void }).closeAllConnections(); + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); + passport.unuse(strategyName); + } +} + +/** Inline-CSS success page rendered after consent. */ +function renderSuccessPage(): string { + return ` + + + + Authentication succeeded + + + +
+

Authentication succeeded.

+

You can close this window and return to VS Code.

+
+ +`; +} + +/** Inline-CSS error page rendered on OAuth failure; surfaces the underlying message so the user can act on it. */ +function renderErrorPage(message: string): string { + const safeMessage = String(message) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + return ` + + + + Authentication failed + + + +
+

Authentication failed.

+

${safeMessage}

+
+ +`; +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.unit.test.ts new file mode 100644 index 0000000000..c635fe2207 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/oauthLoopbackFlow.node.unit.test.ts @@ -0,0 +1,343 @@ +import { assert } from 'chai'; +import * as crypto from 'crypto'; +import * as http from 'http'; +import { type AddressInfo } from 'net'; +import { CancellationError, CancellationTokenSource } from 'vscode'; + +import { runOAuthFlow } from './oauthLoopbackFlow.node'; +import { buildTestStrategy } from './federatedAuthTestHelpers.node'; + +/** Stub Google OAuth provider for loopback-flow tests; exposes `/oauth/authorize` + `/oauth/token`, capturing the authorize query and token form for assertions. */ +interface StubBehavior { + failTokenWithoutRefresh?: boolean; + tokenError?: { error: string; status: number }; +} + +interface StubCapture { + authorizeQuery?: URLSearchParams; + tokenForm?: URLSearchParams; +} + +class StubOAuthProvider { + public readonly capture: StubCapture = {}; + + private behavior: StubBehavior = {}; + + private codeForVerifier = new Map(); // issued code -> code_challenge + + private server: http.Server; + + public get authorizeURL(): string { + return `${this.baseURL}/oauth/authorize`; + } + + public get baseURL(): string { + const address = this.server.address() as AddressInfo; + return `http://127.0.0.1:${address.port}`; + } + + public get tokenURL(): string { + return `${this.baseURL}/oauth/token`; + } + + public constructor() { + this.server = http.createServer((req, res) => this.handle(req, res)); + } + + public async close(): Promise { + await new Promise((resolve) => { + this.server.close(() => resolve()); + }); + } + + public async listen(): Promise { + await new Promise((resolve) => { + this.server.listen(0, '127.0.0.1', () => resolve()); + }); + } + + public setBehavior(behavior: StubBehavior): void { + this.behavior = behavior; + } + + private handle(req: http.IncomingMessage, res: http.ServerResponse): void { + const url = new URL(req.url ?? '/', this.baseURL); + if (url.pathname === '/oauth/authorize' && req.method === 'GET') { + this.handleAuthorize(url, res); + return; + } + if (url.pathname === '/oauth/token' && req.method === 'POST') { + this.handleToken(req, res); + return; + } + res.statusCode = 404; + res.end('not found'); + } + + private handleAuthorize(url: URL, res: http.ServerResponse): void { + this.capture.authorizeQuery = url.searchParams; + + const redirectUri = url.searchParams.get('redirect_uri') ?? ''; + const state = url.searchParams.get('state') ?? ''; + const codeChallenge = url.searchParams.get('code_challenge') ?? ''; + + const code = crypto.randomBytes(16).toString('hex'); + this.codeForVerifier.set(code, codeChallenge); + + const callback = new URL(redirectUri); + callback.searchParams.set('code', code); + callback.searchParams.set('state', state); + res.statusCode = 302; + res.setHeader('Location', callback.toString()); + res.end(); + } + + private handleToken(req: http.IncomingMessage, res: http.ServerResponse): void { + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString('utf8'); + }); + req.on('end', () => { + const form = new URLSearchParams(body); + this.capture.tokenForm = form; + + if (this.behavior.tokenError) { + res.statusCode = this.behavior.tokenError.status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: this.behavior.tokenError.error })); + return; + } + + // Validate PKCE verifier against the challenge issued at /authorize. + const code = form.get('code') ?? ''; + const verifier = form.get('code_verifier') ?? ''; + const expectedChallenge = this.codeForVerifier.get(code); + const actualChallenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + if (expectedChallenge !== actualChallenge) { + res.statusCode = 400; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: 'invalid_grant', detail: 'PKCE verifier mismatch' })); + return; + } + + const responseBody: Record = { + access_token: 'stub-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'email profile https://www.googleapis.com/auth/bigquery' + }; + if (!this.behavior.failTokenWithoutRefresh) { + responseBody.refresh_token = 'test-refresh-token'; + } + + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(responseBody)); + }); + } +} + +suite('oauthLoopbackFlow', () => { + let stub: StubOAuthProvider; + + setup(async () => { + stub = new StubOAuthProvider(); + await stub.listen(); + }); + + teardown(async () => { + await stub.close(); + }); + + /** Drives the full loopback flow end-to-end against the stub provider: /auth/start → /oauth/authorize → /auth/callback. */ + async function drive(opts: { + token?: CancellationTokenSource; + timeoutMs?: number; + capturedQueries?: (q: URLSearchParams) => void; + onCallback?: (response: Response, body: string) => Promise | void; + observedPorts?: Set; + }): Promise<{ + refreshToken: string; + }> { + const tokenSource = opts.token ?? new CancellationTokenSource(); + try { + const { strategy, completion } = buildTestStrategy({ + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + + return await runOAuthFlow({ + integrationId: 'integration-1', + strategy, + completion, + token: tokenSource.token, + timeoutMs: opts.timeoutMs, + onListening: async (startUrl) => { + if (opts.observedPorts) { + opts.observedPorts.add(parseInt(new URL(startUrl).port, 10)); + } + + // Hit /auth/start to get the redirect to the stub's authorize endpoint. + const startResponse = await fetch(startUrl, { redirect: 'manual' }); + assert.isAtLeast(startResponse.status, 300, 'expected redirect from /auth/start'); + assert.isBelow(startResponse.status, 400); + const authorizeLocation = startResponse.headers.get('location'); + assert.isString(authorizeLocation); + + if (opts.capturedQueries) { + const parsed = new URL(authorizeLocation!); + opts.capturedQueries(parsed.searchParams); + } + + // Follow the authorize redirect (stub returns a redirect to /auth/callback). + const authorizeResponse = await fetch(authorizeLocation!, { redirect: 'manual' }); + assert.isAtLeast(authorizeResponse.status, 300); + assert.isBelow(authorizeResponse.status, 400); + const callbackLocation = authorizeResponse.headers.get('location'); + assert.isString(callbackLocation); + + // /auth/callback drives the verify closure; the completion promise carries the outcome. + const callbackResponse = await fetch(callbackLocation!); + const body = await callbackResponse.text(); + if (opts.onCallback) { + await opts.onCallback(callbackResponse, body); + } + } + }); + } finally { + tokenSource.dispose(); + } + } + + test('end-to-end happy path resolves with the stub refresh token', async () => { + const result = await drive({}); + assert.deepStrictEqual(result, { refreshToken: 'test-refresh-token' }); + }); + + test('authorize redirect carries access_type=offline, prompt=consent, code_challenge, S256, scope, and state', async () => { + let queries: URLSearchParams | undefined; + await drive({ + capturedQueries: (q) => { + queries = q; + } + }); + assert.isDefined(queries); + assert.deepStrictEqual( + { + access_type: queries!.get('access_type'), + prompt: queries!.get('prompt'), + code_challenge_method: queries!.get('code_challenge_method') + }, + { access_type: 'offline', prompt: 'consent', code_challenge_method: 'S256' } + ); + // `state` and `code_challenge` are randomly generated — verify presence + non-empty rather than exact values. + assert.isAbove(queries!.get('state')?.length ?? 0, 0); + assert.isAbove(queries!.get('code_challenge')?.length ?? 0, 0); + assert.include(queries!.get('scope') ?? '', 'https://www.googleapis.com/auth/bigquery'); + }); + + test('two concurrent flows pick different ports', async () => { + const observedPorts = new Set(); + await Promise.all([drive({ observedPorts }), drive({ observedPorts })]); + assert.strictEqual(observedPorts.size, 2, 'concurrent flows should bind distinct ports'); + }); + + test('cancellation rejects with CancellationError and closes the server', async () => { + const tokenSource = new CancellationTokenSource(); + try { + const { strategy, completion } = buildTestStrategy({ + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + + let observedStartUrl!: string; + + const flowPromise = runOAuthFlow({ + integrationId: 'i', + strategy, + completion, + token: tokenSource.token, + onListening: async (startUrl) => { + observedStartUrl = startUrl; + // Cancel after listen() but before user consent. + tokenSource.cancel(); + } + }); + + try { + await flowPromise; + assert.fail('expected rejection'); + } catch (err) { + assert.instanceOf(err, CancellationError); + } + + // Server should be torn down — a fetch attempt should fail. + try { + await fetch(observedStartUrl); + assert.fail('expected fetch to fail against a closed server'); + } catch (err) { + assert.instanceOf(err, Error); + } + } finally { + tokenSource.dispose(); + } + }); + + test('timeout rejects with a timeout error', async () => { + const tokenSource = new CancellationTokenSource(); + try { + const { strategy, completion } = buildTestStrategy({ + authorizationURL: stub.authorizeURL, + tokenURL: stub.tokenURL + }); + + const flowPromise = runOAuthFlow({ + integrationId: 'i', + strategy, + completion, + token: tokenSource.token, + timeoutMs: 100, + onListening: async () => { + // Do nothing — let the flow hit the timeout. + } + }); + + try { + await flowPromise; + assert.fail('expected rejection'); + } catch (err) { + assert(err instanceof Error); + assert.match(err.message, /timed out/i); + } + } finally { + tokenSource.dispose(); + } + }); + + test('missing refresh token rejects and renders the documented error page', async () => { + // Catches: passport routing yielding an unfriendly browser page even though completion has the right message. + stub.setBehavior({ failTokenWithoutRefresh: true }); + + let callbackBody: string | undefined; + let callbackStatus: number | undefined; + + try { + await drive({ + onCallback: (response, body) => { + callbackStatus = response.status; + callbackBody = body; + } + }); + assert.fail('expected rejection'); + } catch (err) { + assert(err instanceof Error); + assert.include(err.message, 'No refresh token returned'); + assert.include(err.message, 'my-account.google.com/permissions'); + } + + assert.strictEqual(callbackStatus, 400, 'callback should render the error page status'); + assert.isString(callbackBody); + assert.include(callbackBody!, 'No refresh token returned'); + assert.include(callbackBody!, 'my-account.google.com/permissions'); + }); +}); diff --git a/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.ts b/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.ts new file mode 100644 index 0000000000..9841e7fceb --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.ts @@ -0,0 +1,72 @@ +// VENDORED from @deepnote/blocks bundled internals. None of these symbols are part of the +// package's public exports (verified against dist/index.d.ts). Track removal in +// /home/ubuntu/.claude/plans/look-at-the-pr-curious-toast.md Step 10 — once @deepnote/blocks +// exports them, delete this file and import directly. +// TODO(deepnote-followups): remove when @deepnote/blocks exports these helpers. + +import type { DeepnoteBlock } from '@deepnote/blocks'; +import { dedent } from 'ts-dedent'; + +/** + * SQL block subtype. Vendored because `@deepnote/blocks` does not export `SqlBlock`. + * TODO(deepnote-followups): replace with `import { SqlBlock } from '@deepnote/blocks'` once exported upstream. + */ +export type SqlBlock = Extract; + +/** + * Valid `sql_cache_mode` values for `_dntk.execute_sql_with_connection_json`. + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export type SqlCacheMode = 'cache_disabled' | 'always_write' | 'read_or_write'; + +/** + * Valid `return_variable_type` values for `_dntk.execute_sql_with_connection_json`. + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export type SqlCellVariableType = 'dataframe' | 'query_preview'; + +/** + * Mirror of upstream's bundled-internal `escapePythonString`: single-quotes the string and escapes `\`, `'`, `\n`. + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export function escapePythonString(value: string): string { + return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n')}'`; +} + +/** + * Mirror of upstream's `sanitizePythonVariableName` (whitespace→`_`, strips non-identifier chars, fallback `'input_1'`). + * Differs from upstream by accepting `undefined` and returning `undefined`. + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export function sanitizePythonVariableName(name: string | undefined): string | undefined { + if (name === undefined) { + return undefined; + } + + let sanitizedVariableName = name + .replace(/\s+/g, '_') + .replace(/[^0-9a-zA-Z_]/g, '') + .replace(/^[^a-zA-Z_]+/g, ''); + + if (sanitizedVariableName === '') { + sanitizedVariableName = 'input_1'; + } + + return sanitizedVariableName; +} + +/** + * Mirror of upstream's `createDataFrameConfig`: produces a two-branch Python snippet configuring the dataframe formatter. + * TODO(deepnote-followups): remove when @deepnote/blocks exports this. + */ +export function createDataFrameConfig(block: SqlBlock): string { + const tableState = block.metadata?.deepnote_table_state ?? {}; + const tableStateAsJson = JSON.stringify(tableState); + + return dedent` + if '_dntk' in globals(): + _dntk.dataframe_utils.configure_dataframe_formatter(${escapePythonString(tableStateAsJson)}) + else: + _deepnote_current_table_attrs = ${escapePythonString(tableStateAsJson)} + `; +} diff --git a/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.unit.test.ts b/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.unit.test.ts new file mode 100644 index 0000000000..31c8d72419 --- /dev/null +++ b/src/notebooks/deepnote/integrations/federatedAuth/vendoredBlocksHelpers.unit.test.ts @@ -0,0 +1,145 @@ +import { createPythonCode } from '@deepnote/blocks'; +import { assert } from 'chai'; + +import { + createDataFrameConfig, + escapePythonString, + sanitizePythonVariableName, + SqlBlock +} from './vendoredBlocksHelpers'; +import { parsePythonSingleQuoted } from './federatedAuthTestHelpers'; + +suite('vendoredBlocksHelpers', () => { + suite('escapePythonString', () => { + test('handles a mixed input combining backslash, quote, and newline', () => { + const input = `a\\b'c\nd`; + // `\\` → `\\\\`, `'` → `\'`, `\n` → `\\n`. + assert.strictEqual(escapePythonString(input), "'a\\\\b\\'c\\nd'"); + }); + + test('does not escape tabs', () => { + // Upstream only escapes `\`, `'`, `\n`; tabs pass through verbatim. + assert.strictEqual(escapePythonString('a\tb'), "'a\tb'"); + }); + + test('output, when interpreted as a Python single-quoted literal, round-trips back to the original SQL query', () => { + // Catches: a future change adding an extra escape (e.g. `\t`/`\r`) without updating the inverse mapping, breaking SQL queries at runtime. + const queries = [ + "SELECT 'a''b' AS x", + 'SELECT * FROM t WHERE path = "C:\\Users\\me"', + 'SELECT\n *\nFROM\n table', + "SELECT 'café' AS greeting, '世界' AS world", + "SELECT 'a\\b' AS literal_backslash", + '' + ]; + for (const query of queries) { + const escaped = escapePythonString(query); + assert.strictEqual( + parsePythonSingleQuoted(escaped), + query, + `round-trip failed for: ${JSON.stringify(query)}` + ); + } + }); + }); + + suite('sanitizePythonVariableName', () => { + test('returns undefined for undefined input', () => { + assert.strictEqual(sanitizePythonVariableName(undefined), undefined); + }); + + test('falls back to "input_1" for an empty string', () => { + assert.strictEqual(sanitizePythonVariableName(''), 'input_1'); + }); + + test('strips leading non-identifier characters but keeps a following underscore', () => { + // Upstream strips `[^a-zA-Z_]+` from the start, so `123_foo` → `_foo` (underscore is a valid leading char) and `1abc` → `abc`. + assert.strictEqual(sanitizePythonVariableName('1abc'), 'abc'); + assert.strictEqual(sanitizePythonVariableName('123_foo'), '_foo'); + }); + + test('converts whitespace to underscores', () => { + assert.strictEqual(sanitizePythonVariableName('my var'), 'my_var'); + }); + + test('strips hyphens and dots', () => { + assert.strictEqual(sanitizePythonVariableName('my-var.name'), 'myvarname'); + }); + + test('passes a valid identifier through unchanged', () => { + assert.strictEqual(sanitizePythonVariableName('valid_name_1'), 'valid_name_1'); + }); + + test('preserves a leading underscore', () => { + assert.strictEqual(sanitizePythonVariableName('_hidden'), '_hidden'); + }); + + test('falls back to "input_1" when only invalid chars are present', () => { + // Upstream behavior: '-' is stripped, then nothing remains, so fallback applies. + assert.strictEqual(sanitizePythonVariableName('---'), 'input_1'); + }); + + test('collapses an all-whitespace string to a single underscore', () => { + // Catches: a future upstream change to the `\s+` → `_` step (e.g. `\W+`) breaking parity. + assert.strictEqual(sanitizePythonVariableName(' '), '_'); + }); + }); + + suite('createDataFrameConfig', () => { + function makeSqlBlock(tableState?: Record): SqlBlock { + return { + type: 'sql', + id: 'block-id', + blockGroup: 'group-id', + sortingKey: 'a', + content: 'SELECT 1', + metadata: tableState === undefined ? {} : { deepnote_table_state: tableState } + } as unknown as SqlBlock; + } + + test('uses an empty JSON object when metadata.deepnote_table_state is missing', () => { + const block = makeSqlBlock(); + const result = createDataFrameConfig(block); + + const expected = + "if '_dntk' in globals():\n" + + " _dntk.dataframe_utils.configure_dataframe_formatter('{}')\n" + + 'else:\n' + + " _deepnote_current_table_attrs = '{}'"; + + assert.strictEqual(result, expected); + }); + + test('JSON-stringifies a non-trivial table state and round-trips through escapePythonString', () => { + const tableState = { + pageSize: 50, + sortBy: [{ column: 'name', direction: 'asc' as const }], + hiddenColumns: ['id'] + }; + const block = makeSqlBlock(tableState); + const result = createDataFrameConfig(block); + + const expectedJson = JSON.stringify(tableState); + assert.include(result, escapePythonString(expectedJson)); + // Both branches must reference the same escaped JSON. + const occurrences = result.split(escapePythonString(expectedJson)).length - 1; + assert.strictEqual(occurrences, 2); + }); + + test('matches the data-frame-config prefix produced by upstream @deepnote/blocks.createPythonCode', () => { + // Catches: an upstream change to the dataframe-config template (indentation, wording, JSON ordering) drifting us out of parity. Upstream emits `\n\n`; we compare the prefix up to the blank line. + const tableState = { + pageSize: 50, + sortBy: [{ column: 'name', direction: 'asc' as const }], + hiddenColumns: ['id'] + }; + const block = makeSqlBlock(tableState); + + const ours = createDataFrameConfig(block); + const upstreamFull = createPythonCode(block); + const upstreamPrefix = upstreamFull.split('\n\n')[0]; + + assert.strictEqual(ours, upstreamPrefix); + }); + }); +}); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.ts b/src/notebooks/deepnote/integrations/integrationWebview.ts index 3daf4dc479..c7c0caf99a 100644 --- a/src/notebooks/deepnote/integrations/integrationWebview.ts +++ b/src/notebooks/deepnote/integrations/integrationWebview.ts @@ -1,14 +1,18 @@ -import { inject, injectable } from 'inversify'; -import { Disposable, l10n, Uri, ViewColumn, WebviewPanel, window } from 'vscode'; +import { inject, injectable, optional } from 'inversify'; +import { commands, Disposable, l10n, Uri, ViewColumn, WebviewPanel, window } from 'vscode'; +import { BigQueryAuthMethods } from '@deepnote/database-integrations'; + +import { Commands } from '../../../platform/common/constants'; import { IExtensionContext } from '../../../platform/common/types'; import * as localize from '../../../platform/common/utils/localize'; import { logger } from '../../../platform/logging'; import { LocalizedMessages, SharedMessages } from '../../../messageTypes'; import { IDeepnoteNotebookManager, ProjectIntegration } from '../../types'; -import { IIntegrationStorage, IIntegrationWebviewProvider } from './types'; +import { IFederatedAuthTokenStorage, IIntegrationStorage, IIntegrationWebviewProvider } from './types'; import { ConfigurableDatabaseIntegrationConfig, + FederatedAuthTokenStatus, IntegrationStatus, IntegrationWithStatus } from '../../../platform/notebooks/deepnote/integrationTypes'; @@ -26,11 +30,29 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { private projectId: string | undefined; + /** Generation counter for `updateWebview()` ("latest call wins"; stale in-flight updates bail). */ + private updateGeneration = 0; + + /** Disposables that must survive panel close/reopen (provider is DI-singleton; `disposables` is torn down on `onDidDispose`). */ + private readonly tokenStorageDisposables: Disposable[] = []; + constructor( @inject(IExtensionContext) private readonly extensionContext: IExtensionContext, @inject(IIntegrationStorage) private readonly integrationStorage: IIntegrationStorage, - @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager - ) {} + @inject(IDeepnoteNotebookManager) private readonly notebookManager: IDeepnoteNotebookManager, + @inject(IFederatedAuthTokenStorage) + @optional() + private readonly tokenStorage?: IFederatedAuthTokenStorage + ) { + // Refresh on token-storage change so the auth pill flips without panel reload. Lives in `tokenStorageDisposables` to survive panel close/reopen. + if (this.tokenStorage) { + this.tokenStorageDisposables.push( + this.tokenStorage.onDidChangeTokens(() => { + void this.updateWebview(); + }) + ); + } + } /** * Show the integration management webview @@ -177,6 +199,28 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { integrationsBigQueryCredentialsLabel: localize.Integrations.bigQueryCredentialsLabel, integrationsBigQueryCredentialsPlaceholder: localize.Integrations.bigQueryCredentialsPlaceholder, integrationsBigQueryCredentialsRequired: localize.Integrations.bigQueryCredentialsRequired, + // BigQuery federated-auth form strings (M4) + integrationsBigQueryAuthMethodLabel: localize.Integrations.bigQueryAuthMethodLabel, + integrationsBigQueryAuthMethodServiceAccount: localize.Integrations.bigQueryAuthMethodServiceAccount, + integrationsBigQueryAuthMethodGoogleOauth: localize.Integrations.bigQueryAuthMethodGoogleOauth, + integrationsBigQueryProjectLabel: localize.Integrations.bigQueryProjectLabel, + integrationsBigQueryProjectPlaceholder: localize.Integrations.bigQueryProjectPlaceholder, + integrationsBigQueryClientIdLabel: localize.Integrations.bigQueryClientIdLabel, + integrationsBigQueryClientIdPlaceholder: localize.Integrations.bigQueryClientIdPlaceholder, + integrationsBigQueryClientSecretLabel: localize.Integrations.bigQueryClientSecretLabel, + integrationsBigQueryClientSecretPlaceholder: localize.Integrations.bigQueryClientSecretPlaceholder, + integrationsBigQueryGoogleOauthHelp: localize.Integrations.bigQueryGoogleOauthHelp, + // Federated-auth integration management strings (M4) + integrationsAuthenticate: localize.Integrations.authenticate, + integrationsReauthenticate: localize.Integrations.reauthenticate, + integrationsTokenStatusAuthenticated: localize.Integrations.tokenStatusAuthenticated, + integrationsTokenStatusDisconnected: localize.Integrations.tokenStatusDisconnected, + integrationsAuthenticating: localize.Integrations.authenticating('{0}'), + integrationsAuthenticationSucceeded: localize.Integrations.authenticationSucceeded('{0}'), + integrationsAuthenticationFailed: localize.Integrations.authenticationFailed('{0}'), + integrationsBigQueryNotAuthenticated: localize.Integrations.bigQueryNotAuthenticated('{0}'), + integrationsFederatedAuthNotSupportedInWeb: localize.Integrations.federatedAuthNotSupportedInWeb, + integrationsFederatedAuthNotSupportedInRemote: localize.Integrations.federatedAuthNotSupportedInRemote, integrationsSnowflakeNameLabel: localize.Integrations.snowflakeNameLabel, integrationsSnowflakeNamePlaceholder: localize.Integrations.snowflakeNamePlaceholder, integrationsSnowflakeAccountLabel: localize.Integrations.snowflakeAccountLabel, @@ -391,22 +435,41 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { }); } - /** - * Update the webview with current integration data - */ + /** Update the webview with current integration data. Each call gets a generation number; stale or post-dispose updates bail at every await. */ private async updateWebview(): Promise { if (!this.currentPanel) { logger.debug('IntegrationWebviewProvider: No current panel, skipping update'); return; } - const integrationsData = Array.from(this.integrations.entries()).map(([id, integration]) => ({ - config: integration.config, - id, - integrationName: integration.integrationName, - integrationType: integration.integrationType, - status: integration.status - })); + this.updateGeneration += 1; + const generation = this.updateGeneration; + + const integrationsData = await Promise.all( + Array.from(this.integrations.entries()).map(async ([id, integration]) => ({ + config: integration.config, + id, + integrationName: integration.integrationName, + integrationType: integration.integrationType, + status: integration.status, + tokenStatus: await this.deriveTokenStatus(id, integration.config) + })) + ); + + // Bail if the panel was disposed during the `tokenStorage.has()` await. + if (!this.currentPanel) { + logger.debug('IntegrationWebviewProvider: Panel disposed during update, skipping postMessage'); + return; + } + + // A newer update started; let it post the fresher state. + if (generation !== this.updateGeneration) { + logger.debug( + `IntegrationWebviewProvider: Superseded by newer update (gen ${generation} < ${this.updateGeneration}), skipping postMessage` + ); + return; + } + logger.debug(`IntegrationWebviewProvider: Sending ${integrationsData.length} integrations to webview`); // Get the project name from the notebook manager @@ -423,9 +486,64 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { }); } - /** - * Handle messages from the webview - */ + /** Drops stale federated tokens when the new config's fingerprint changed or the auth method is no longer `google-oauth`. */ + private async invalidateStaleFederatedToken( + integrationId: string, + newConfig: ConfigurableDatabaseIntegrationConfig + ): Promise { + if (!this.tokenStorage) { + return; + } + + const stored = await this.tokenStorage.get(integrationId); + if (!stored) { + return; + } + + // Switched away from google-oauth (or another integration type): previously-captured token is meaningless. + if (newConfig.type !== 'big-query' || newConfig.metadata.authMethod !== BigQueryAuthMethods.GoogleOauth) { + logger.info( + `IntegrationWebviewProvider: deleting stale federated token for ${integrationId} (auth method changed).` + ); + await this.tokenStorage.delete(integrationId); + return; + } + + // Same auth method but OAuth client metadata changed: stored token was issued against a different client. + const { clientId, clientSecret, project } = newConfig.metadata; + const newFingerprint = this.tokenStorage.computeMetadataFingerprint({ clientId, clientSecret, project }); + if (newFingerprint !== stored.metadataFingerprint) { + logger.info( + `IntegrationWebviewProvider: deleting stale federated token for ${integrationId} (fingerprint changed).` + ); + await this.tokenStorage.delete(integrationId); + } + } + + /** Federated-auth token status: `'unsupported'` for non-BigQuery, non-google-oauth, or web; else `'authenticated'`/`'disconnected'`. */ + private async deriveTokenStatus( + integrationId: string, + config: ConfigurableDatabaseIntegrationConfig | null + ): Promise { + if (!this.tokenStorage) { + return 'unsupported'; + } + if (!config || config.type !== 'big-query' || config.metadata.authMethod !== BigQueryAuthMethods.GoogleOauth) { + return 'unsupported'; + } + try { + const hasToken = await this.tokenStorage.has(integrationId); + return hasToken ? 'authenticated' : 'disconnected'; + } catch (err) { + logger.warn( + `IntegrationWebviewProvider: failed to check token for ${integrationId}; reporting disconnected.`, + err + ); + return 'disconnected'; + } + } + + /** Handle messages from the webview; mirrors the `WebviewOutboundMessage` union in `src/webviews/webview-side/integrations/types.ts`. */ private async handleMessage(message: { type: string; integrationId?: string; @@ -452,6 +570,19 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { await this.deleteConfiguration(message.integrationId); } break; + case 'authenticate': + if (message.integrationId) { + try { + await commands.executeCommand(Commands.AuthenticateIntegration, message.integrationId); + } catch (error) { + // Command handler shows its own toasts; log here to avoid an unhandled-rejection. + logger.error( + `IntegrationWebviewProvider: AuthenticateIntegration command failed for ${message.integrationId}`, + error + ); + } + } + break; } } @@ -481,6 +612,9 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { config: ConfigurableDatabaseIntegrationConfig ): Promise { try { + // Invalidate stale federated tokens before saving (fingerprint change or auth-method switch). + await this.invalidateStaleFederatedToken(integrationId, config); + await this.integrationStorage.save(config); // Update local state @@ -528,6 +662,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { private async resetConfiguration(integrationId: string): Promise { try { await this.integrationStorage.delete(integrationId); + await this.tokenStorage?.delete(integrationId); // Update local state const integration = this.integrations.get(integrationId); @@ -563,6 +698,7 @@ export class IntegrationWebviewProvider implements IIntegrationWebviewProvider { private async deleteConfiguration(integrationId: string): Promise { try { await this.integrationStorage.delete(integrationId); + await this.tokenStorage?.delete(integrationId); // Remove from local state this.integrations.delete(integrationId); diff --git a/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts new file mode 100644 index 0000000000..a146b003e3 --- /dev/null +++ b/src/notebooks/deepnote/integrations/integrationWebview.unit.test.ts @@ -0,0 +1,411 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; +import { EventEmitter, Uri } from 'vscode'; +import { anyString, anything, instance, mock, reset, verify, when } from 'ts-mockito'; + +import { IExtensionContext, IDisposable } from '../../../platform/common/types'; +import { Commands } from '../../../platform/common/constants'; +import { IDeepnoteNotebookManager } from '../../types'; +import { IntegrationWebviewProvider } from './integrationWebview'; +import { IFederatedAuthTokenStorage, IIntegrationStorage } from './types'; +import { + ConfigurableDatabaseIntegrationConfig, + IntegrationStatus, + IntegrationWithStatus +} from '../../../platform/notebooks/deepnote/integrationTypes'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; +import { + buildGoogleOauthIntegration, + buildPostgresIntegration, + buildServiceAccountIntegration, + createFakeTokenStorage, + type FakeTokenStorage +} from './federatedAuth/federatedAuthTestHelpers'; + +interface CapturedMessage { + type: string; + integrations?: Array<{ id: string; tokenStatus?: string }>; + [key: string]: unknown; +} + +interface FakeWebviewPanel { + panel: import('vscode').WebviewPanel; + posted: CapturedMessage[]; + onDidReceiveMessage: (message: unknown) => Promise; + triggerDispose: () => void; + setPostMessageImpl: (impl: (message: CapturedMessage) => Promise) => void; +} + +function createFakeWebviewPanel(): FakeWebviewPanel { + const posted: CapturedMessage[] = []; + let messageHandler: ((message: unknown) => Promise | void) | undefined; + let onDidDisposeCb: (() => void) | undefined; + let postMessageImpl: (message: CapturedMessage) => Promise = async (message) => { + posted.push(message); + return true; + }; + const webview = { + html: '', + cspSource: 'mock-csp', + asWebviewUri: (uri: unknown) => uri, + postMessage: (message: CapturedMessage) => postMessageImpl(message), + onDidReceiveMessage: ( + cb: (message: unknown) => Promise | void, + _thisArg?: unknown, + disposables?: IDisposable[] + ): IDisposable => { + messageHandler = cb; + const disposable: IDisposable = { dispose: () => undefined }; + disposables?.push(disposable); + return disposable; + } + }; + const panel = { + webview, + reveal: () => undefined, + dispose: () => undefined, + onDidDispose: (cb: () => void, _thisArg?: unknown, disposables?: IDisposable[]): IDisposable => { + onDidDisposeCb = cb; + const disposable: IDisposable = { dispose: () => undefined }; + disposables?.push(disposable); + return disposable; + } + }; + return { + panel: panel as unknown as import('vscode').WebviewPanel, + posted, + onDidReceiveMessage: async (message: unknown) => { + if (messageHandler) { + await messageHandler(message); + } + }, + triggerDispose: () => { + if (onDidDisposeCb) { + onDidDisposeCb(); + } + }, + setPostMessageImpl: (impl) => { + postMessageImpl = impl; + } + }; +} + +suite('IntegrationWebviewProvider', () => { + const PROJECT_ID = 'project-id-1'; + + let extensionContext: IExtensionContext; + let integrationStorage: IIntegrationStorage; + let notebookManager: IDeepnoteNotebookManager; + let fakeTokenStorage: FakeTokenStorage; + let extensionSubscriptions: IDisposable[]; + let fakePanel: FakeWebviewPanel; + + setup(() => { + resetVSCodeMocks(); + extensionContext = mock(); + integrationStorage = mock(); + notebookManager = mock(); + extensionSubscriptions = []; + when(extensionContext.subscriptions).thenReturn(extensionSubscriptions); + when(extensionContext.extensionUri).thenReturn(Uri.file('/ext')); + + fakeTokenStorage = createFakeTokenStorage(); + fakePanel = createFakeWebviewPanel(); + + when( + mockedVSCodeNamespaces.window.createWebviewPanel(anyString(), anyString(), anything(), anything()) + ).thenReturn(fakePanel.panel); + }); + + teardown(() => { + reset(mockedVSCodeNamespaces.window); + reset(mockedVSCodeNamespaces.commands); + }); + + function buildProvider(opts: { tokenStorage?: IFederatedAuthTokenStorage } = {}): IntegrationWebviewProvider { + return new IntegrationWebviewProvider( + instance(extensionContext), + instance(integrationStorage), + instance(notebookManager), + opts.tokenStorage + ); + } + + function singleIntegrationMap( + id: string, + config: ConfigurableDatabaseIntegrationConfig + ): Map { + return new Map([[id, { config, status: IntegrationStatus.Connected }]]); + } + + async function show(provider: IntegrationWebviewProvider, integrations: Map) { + await provider.show(PROJECT_ID, integrations); + } + + function lastUpdate(): CapturedMessage { + return fakePanel.posted.filter((m) => m.type === 'update').pop()!; + } + + function preStoreToken(id: string, fingerprint = 'fp'): void { + fakeTokenStorage.tokens.set(id, { + integrationId: id, + refreshToken: 'r', + metadataFingerprint: fingerprint + }); + } + + suite('updateWebview tokenStatus matrix', () => { + ( + [ + { + name: 'no tokenStorage → unsupported', + tokenStorage: false as const, + config: () => buildGoogleOauthIntegration({ id: 'bq-1' }), + storeToken: false, + expected: 'unsupported' + }, + { + name: 'service-account BigQuery → unsupported', + tokenStorage: true as const, + config: () => buildServiceAccountIntegration({ id: 'bq-sa' }), + storeToken: false, + expected: 'unsupported' + }, + { + name: 'Postgres → unsupported', + tokenStorage: true as const, + config: () => buildPostgresIntegration({ id: 'pg-1' }), + storeToken: false, + expected: 'unsupported' + }, + { + name: 'BigQuery + google-oauth + stored token → authenticated', + tokenStorage: true as const, + config: () => buildGoogleOauthIntegration({ id: 'bq-2' }), + storeToken: true, + expected: 'authenticated' + }, + { + name: 'BigQuery + google-oauth + no stored token → disconnected', + tokenStorage: true as const, + config: () => buildGoogleOauthIntegration({ id: 'bq-3' }), + storeToken: false, + expected: 'disconnected' + } + ] as const + ).forEach((row) => { + test(row.name, async () => { + const config = row.config(); + if (row.storeToken) { + preStoreToken(config.id); + } + const provider = buildProvider({ + tokenStorage: row.tokenStorage ? fakeTokenStorage.storage : undefined + }); + await show(provider, singleIntegrationMap(config.id, config)); + + const item = (lastUpdate().integrations || []).find((i) => i.id === config.id); + assert.strictEqual(item?.tokenStatus, row.expected); + }); + }); + }); + + test('handleMessage: "authenticate" → commands.executeCommand(AuthenticateIntegration, integrationId)', async () => { + const executeCommandStub = sinon.stub().resolves(undefined); + when(mockedVSCodeNamespaces.commands.executeCommand(anyString(), anything())).thenCall((command, arg) => + executeCommandStub(command, arg) + ); + when(mockedVSCodeNamespaces.commands.executeCommand(anyString())).thenCall((command) => + executeCommandStub(command) + ); + + const provider = buildProvider({ tokenStorage: fakeTokenStorage.storage }); + const integrationId = 'bq-auth'; + await show(provider, singleIntegrationMap(integrationId, buildGoogleOauthIntegration({ id: integrationId }))); + + await fakePanel.onDidReceiveMessage({ type: 'authenticate', integrationId }); + + assert.isTrue( + executeCommandStub.calledWith(Commands.AuthenticateIntegration, integrationId), + 'expected executeCommand to be called with AuthenticateIntegration and the integration id' + ); + }); + + (['reset', 'delete'] as const).forEach((messageType) => { + test(`${messageType}Configuration: deletes the federated token in addition to the integration config`, async () => { + when(integrationStorage.delete(anyString())).thenResolve(); + + const provider = buildProvider({ tokenStorage: fakeTokenStorage.storage }); + const integrationId = `bq-${messageType}`; + preStoreToken(integrationId); + + await show( + provider, + singleIntegrationMap(integrationId, buildGoogleOauthIntegration({ id: integrationId })) + ); + await fakePanel.onDidReceiveMessage({ type: messageType, integrationId }); + + assert.includeMembers(fakeTokenStorage.deletedIds, [integrationId]); + verify(integrationStorage.delete(integrationId)).once(); + }); + }); + + test('saveConfiguration: deletes the token BEFORE save when fingerprint changes', async () => { + const integrationId = 'bq-save-fp'; + const integrationSaveSpy = sinon.spy(); + when(integrationStorage.save(anything())).thenCall(integrationSaveSpy); + + const provider = buildProvider({ tokenStorage: fakeTokenStorage.storage }); + preStoreToken(integrationId, 'old-fingerprint'); + await show(provider, singleIntegrationMap(integrationId, buildGoogleOauthIntegration({ id: integrationId }))); + + // Save a config that produces a DIFFERENT fingerprint than what's stored. + const newConfig = buildGoogleOauthIntegration({ + id: integrationId, + name: 'New name', + metadata: { + authMethod: 'google-oauth', + project: 'new-proj', + clientId: 'new-client', + clientSecret: 'new-secret' + } + } as ConfigurableDatabaseIntegrationConfig); + + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId, config: newConfig }); + + sinon.assert.calledOnce(fakeTokenStorage.deleteSpy); + sinon.assert.calledOnce(integrationSaveSpy); + assert.isTrue( + fakeTokenStorage.deleteSpy.calledBefore(integrationSaveSpy), + 'token.delete must occur BEFORE storage.save' + ); + }); + + test('saveConfiguration: deletes the token when authMethod switches away from google-oauth', async () => { + const integrationId = 'bq-switch'; + when(integrationStorage.save(anything())).thenResolve(); + + const provider = buildProvider({ tokenStorage: fakeTokenStorage.storage }); + preStoreToken(integrationId, 'fp-1'); + await show(provider, singleIntegrationMap(integrationId, buildGoogleOauthIntegration({ id: integrationId }))); + + const newConfig = buildServiceAccountIntegration({ id: integrationId }); + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId, config: newConfig }); + + assert.includeMembers(fakeTokenStorage.deletedIds, [integrationId]); + assert.isFalse(fakeTokenStorage.tokens.has(integrationId)); + }); + + test('saveConfiguration: leaves the token intact when fingerprint matches', async () => { + const integrationId = 'bq-stable'; + when(integrationStorage.save(anything())).thenResolve(); + + const provider = buildProvider({ tokenStorage: fakeTokenStorage.storage }); + const sameConfig = buildGoogleOauthIntegration({ id: integrationId }); + const stableFingerprint = fakeTokenStorage.fingerprintForTest({ + clientId: 'client-id-abc', + clientSecret: 'client-secret-xyz', + project: 'my-gcp-project' + }); + preStoreToken(integrationId, stableFingerprint); + await show(provider, singleIntegrationMap(integrationId, sameConfig)); + + await fakePanel.onDidReceiveMessage({ type: 'save', integrationId, config: sameConfig }); + + assert.notInclude(fakeTokenStorage.deletedIds, integrationId); + assert.isTrue(fakeTokenStorage.tokens.has(integrationId)); + }); + + test('onDidChangeTokens subscription survives panel close and reopen', async () => { + const provider = buildProvider({ tokenStorage: fakeTokenStorage.storage }); + const integrationId = 'bq-reopen'; + const integrations = singleIntegrationMap(integrationId, buildGoogleOauthIntegration({ id: integrationId })); + + // First open of the panel. + await show(provider, integrations); + assert.isAtLeast(fakePanel.posted.filter((m) => m.type === 'update').length, 1); + + // User closes panel: `onDidDispose` clears `this.disposables`; the token-change subscription must survive in a separate slot. + fakePanel.triggerDispose(); + + // Reopen with a brand-new fake panel; rebind the createWebviewPanel mock. + fakePanel = createFakeWebviewPanel(); + when( + mockedVSCodeNamespaces.window.createWebviewPanel(anyString(), anyString(), anything(), anything()) + ).thenReturn(fakePanel.panel); + + await show(provider, integrations); + const updatesAfterReopen = fakePanel.posted.filter((m) => m.type === 'update').length; + assert.isAtLeast(updatesAfterReopen, 1, 'reopened panel should receive an initial update'); + + // Token change: if the subscription was lost on dispose, the webview wouldn't see an additional update. + await fakeTokenStorage.storage.save({ + integrationId, + refreshToken: 'r', + metadataFingerprint: 'fp' + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const updatesAfterTokenChange = fakePanel.posted.filter((m) => m.type === 'update').length; + assert.isAbove( + updatesAfterTokenChange, + updatesAfterReopen, + 'token-change after reopen should still trigger an update' + ); + }); + + test('updateWebview does not postMessage when panel is disposed during the tokenStorage.has() await', async () => { + // `has()` returns a deferred so we can dispose the panel mid-update. + let resolveHas: ((value: boolean) => void) | undefined; + const deferredHasPromise = new Promise((resolve) => { + resolveHas = resolve; + }); + const onDidChangeEmitter = new EventEmitter(); + const slowTokenStorage: IFederatedAuthTokenStorage = { + onDidChangeTokens: onDidChangeEmitter.event, + async get() { + return undefined; + }, + has: () => deferredHasPromise, + async save() { + /* no-op */ + }, + async delete() { + /* no-op */ + }, + async listIntegrationIds() { + return []; + }, + computeMetadataFingerprint() { + return 'fp'; + } + }; + + const provider = buildProvider({ tokenStorage: slowTokenStorage }); + const integrationId = 'bq-disposed-during-update'; + const integrations = singleIntegrationMap(integrationId, buildGoogleOauthIntegration({ id: integrationId })); + + const allPostedMessages: CapturedMessage[] = []; + fakePanel.setPostMessageImpl(async (message) => { + allPostedMessages.push(message); + return true; + }); + + // Fire `show()` without awaiting; it parks on `has()`. + const showPromise = show(provider, integrations); + + // Yield so `show()` parks. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Dispose mid-update — provider's onDidDispose sets `currentPanel = undefined`. + fakePanel.triggerDispose(); + + // Resolve `has()` so updateWebview finishes; the post-await guard must skip postMessage. + resolveHas?.(false); + await showPromise; + onDidChangeEmitter.dispose(); + + const updateMessages = allPostedMessages.filter((m) => m.type === 'update'); + assert.isEmpty(updateMessages, 'no `update` postMessage should be issued after the panel disposes mid-update'); + }); +}); diff --git a/src/notebooks/deepnote/integrations/types.ts b/src/notebooks/deepnote/integrations/types.ts index d38a3cd2ed..53b3994c25 100644 --- a/src/notebooks/deepnote/integrations/types.ts +++ b/src/notebooks/deepnote/integrations/types.ts @@ -1,3 +1,6 @@ +import type { DeepnoteBlock } from '@deepnote/blocks'; +import { Event } from 'vscode'; + import { IntegrationWithStatus } from '../../../platform/notebooks/deepnote/integrationTypes'; // Re-export IIntegrationStorage from platform layer @@ -38,3 +41,59 @@ export interface IIntegrationManager { */ activate(): void; } + +/** Persisted federated-auth token entry; fingerprints `${clientId}|${clientSecret}|${project}` to detect stale tokens. Only the refresh token is persisted. */ +export interface FederatedAuthTokenEntry { + integrationId: string; + refreshToken: string; + metadataFingerprint: string; +} + +/** OAuth-client metadata fingerprinted by {@link IFederatedAuthTokenStorage.computeMetadataFingerprint}; mirrors the BigQuery `google-oauth` schema. */ +export interface FederatedAuthFingerprintInput { + clientId: string; + clientSecret: string; + project: string; +} + +export const IFederatedAuthTokenStorage = Symbol('IFederatedAuthTokenStorage'); +export interface IFederatedAuthTokenStorage { + /** + * Fires when a token is saved or deleted; the payload is the integration id. + */ + readonly onDidChangeTokens: Event; + /** Canonical fingerprint of OAuth-client metadata. Exposed on the interface so cross-platform callers (e.g. `IntegrationWebviewProvider`) avoid the node-only helper. */ + computeMetadataFingerprint(metadata: FederatedAuthFingerprintInput): string; + delete(integrationId: string): Promise; + get(integrationId: string): Promise; + has(integrationId: string): Promise; + /** All integration IDs with a stored token entry; used for orphaned-token cleanup. */ + listIntegrationIds(): Promise; + /** Persists a token entry. Pass `silent: true` for refresh-token rotation to skip `onDidChangeTokens` (avoids interrupting in-flight SQL cells). */ + save(entry: FederatedAuthTokenEntry, options?: { silent?: boolean }): Promise; +} + +export const IFederatedAuthSqlBlockCodeGenerator = Symbol('IFederatedAuthSqlBlockCodeGenerator'); +export interface IFederatedAuthSqlBlockCodeGenerator { + /** Returns `{prelude, cellCode}` for federated BigQuery SQL blocks (prelude is silent, defines kernel-global with fresh token; cellCode references it); `undefined` for unrelated blocks so callers fall back to `createPythonCode`. */ + generate(block: DeepnoteBlock): Promise<{ prelude: string; cellCode: string } | undefined>; +} + +/** Thrown when a federated integration has no usable refresh token (not authenticated yet, fingerprint mismatch, or `invalid_grant`). */ +export class NotAuthenticatedError extends Error { + constructor(public readonly integrationName: string) { + super(`Integration "${integrationName}" is not authenticated.`); + this.name = 'NotAuthenticatedError'; + } +} + +/** + * Thrown when OAuth client metadata (clientId/clientSecret) is wrong — `invalid_client` / `unauthorized_client`. + * Distinct from {@link NotAuthenticatedError}: re-auth won't fix it. Lives here (not in `.node.ts`) so cross-platform callers can `instanceof`-check. + */ +export class OAuthClientMisconfiguredError extends Error { + constructor(public readonly integrationName: string) { + super(`OAuth client for integration "${integrationName}" is misconfigured.`); + this.name = 'OAuthClientMisconfiguredError'; + } +} diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index cbd8b860fe..3a46a272ec 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -48,11 +48,18 @@ import { IntegrationDetector } from './deepnote/integrations/integrationDetector import { IntegrationManager } from './deepnote/integrations/integrationManager'; import { IntegrationWebviewProvider } from './deepnote/integrations/integrationWebview'; import { + IFederatedAuthSqlBlockCodeGenerator, + IFederatedAuthTokenStorage, IIntegrationDetector, IIntegrationManager, IIntegrationStorage, IIntegrationWebviewProvider } from './deepnote/integrations/types'; +import { FederatedAuthCommandHandlerNode } from './deepnote/integrations/federatedAuth/federatedAuthCommandHandler.node'; +import { FederatedAuthKernelRestartBridge } from './deepnote/integrations/federatedAuth/federatedAuthKernelRestartBridge.node'; +import { FederatedAuthOrphanedTokenCleaner } from './deepnote/integrations/federatedAuth/federatedAuthOrphanedTokenCleaner.node'; +import { FederatedAuthSqlBlockCodeGenerator } from './deepnote/integrations/federatedAuth/federatedAuthSqlBlockCodeGenerator.node'; +import { FederatedAuthTokenStorage } from './deepnote/integrations/federatedAuth/federatedAuthTokenStorage.node'; import { IPlatformNotebookEditorProvider, IPlatformDeepnoteNotebookManager @@ -180,6 +187,23 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea serviceManager.addSingleton(IIntegrationDetector, IntegrationDetector); serviceManager.addSingleton(IIntegrationWebviewProvider, IntegrationWebviewProvider); serviceManager.addSingleton(IIntegrationManager, IntegrationManager); + serviceManager.addSingleton(IFederatedAuthTokenStorage, FederatedAuthTokenStorage); + serviceManager.addSingleton( + IFederatedAuthSqlBlockCodeGenerator, + FederatedAuthSqlBlockCodeGenerator + ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + FederatedAuthCommandHandlerNode + ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + FederatedAuthKernelRestartBridge + ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + FederatedAuthOrphanedTokenCleaner + ); serviceManager.addSingleton( IExtensionSyncActivationService, SqlCellStatusBarProvider diff --git a/src/notebooks/serviceRegistry.web.ts b/src/notebooks/serviceRegistry.web.ts index 2488ff73d7..31afe56ca0 100644 --- a/src/notebooks/serviceRegistry.web.ts +++ b/src/notebooks/serviceRegistry.web.ts @@ -54,6 +54,7 @@ import { DeepnoteBigNumberCellStatusBarProvider } from './deepnote/deepnoteBigNu import { DeepnoteNewCellLanguageService } from './deepnote/deepnoteNewCellLanguageService'; import { SqlCellStatusBarProvider } from './deepnote/sqlCellStatusBarProvider'; import { IntegrationKernelRestartHandler } from './deepnote/integrations/integrationKernelRestartHandler'; +import { FederatedAuthCommandHandlerWeb } from './deepnote/integrations/federatedAuth/federatedAuthCommandHandler.web'; import { DeepnoteFileChangeWatcher } from './deepnote/deepnoteFileChangeWatcher'; export function registerTypes(serviceManager: IServiceManager, isDevMode: boolean) { @@ -137,6 +138,10 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, IntegrationKernelRestartHandler ); + serviceManager.addSingleton( + IExtensionSyncActivationService, + FederatedAuthCommandHandlerWeb + ); serviceManager.addSingleton( IExtensionSyncActivationService, DeepnoteFileChangeWatcher diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index c85719faec..2d650927e3 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -225,6 +225,7 @@ export namespace Commands { export const RevealInDeepnoteExplorer = 'deepnote.revealInExplorer'; export const EnableSnapshots = 'deepnote.enableSnapshots'; export const DisableSnapshots = 'deepnote.disableSnapshots'; + export const AuthenticateIntegration = 'deepnote.authenticateIntegration'; export const ManageIntegrations = 'deepnote.manageIntegrations'; export const AddSqlBlock = 'deepnote.addSqlBlock'; export const AddBigNumberChartBlock = 'deepnote.addBigNumberChartBlock'; diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 12d4a62d01..8f69d80875 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -889,6 +889,47 @@ export namespace Integrations { export const bigQueryCredentialsRequired = l10n.t('Credentials are required'); export const bigQueryInvalidJson = (message: string) => l10n.t('Invalid JSON: {0}', message); + // BigQuery federated-auth form strings (M4) + export const bigQueryAuthMethodLabel = l10n.t('Authentication method'); + export const bigQueryAuthMethodServiceAccount = l10n.t('Service account'); + export const bigQueryAuthMethodGoogleOauth = l10n.t('Google OAuth'); + export const bigQueryProjectLabel = l10n.t('Project'); + export const bigQueryProjectPlaceholder = l10n.t('my-project-id'); + export const bigQueryClientIdLabel = l10n.t('OAuth client ID'); + export const bigQueryClientIdPlaceholder = l10n.t('1234567890-abc.apps.googleusercontent.com'); + export const bigQueryClientSecretLabel = l10n.t('OAuth client secret'); + export const bigQueryClientSecretPlaceholder = l10n.t('GOCSPX-...'); + export const bigQueryGoogleOauthHelp = l10n.t( + "Create a 'Desktop app' OAuth client in Google Cloud Console and paste the client ID and secret above. The redirect URI is configured automatically." + ); + + // Federated-auth integration management strings (M4) + export const authenticate = l10n.t('Authenticate with Google'); + export const reauthenticate = l10n.t('Re-authenticate with Google'); + export const tokenStatusAuthenticated = l10n.t('Authenticated'); + export const tokenStatusDisconnected = l10n.t('Not authenticated'); + export const authenticating = (integrationName: string) => l10n.t('Authenticating {0}...', integrationName); + export const authenticationSucceeded = (integrationName: string) => l10n.t('Authenticated {0}', integrationName); + export const authenticationFailed = (errorMessage: string) => l10n.t('Authentication failed: {0}', errorMessage); + export const bigQueryNotAuthenticated = (integrationName: string) => + l10n.t( + 'BigQuery integration "{0}" is not authenticated. Click Authenticate with Google in Manage Integrations to sign in.', + integrationName + ); + export const federatedAuthNotSupportedInWeb = l10n.t( + 'Federated authentication is not supported in the web extension. Open the workspace in desktop VS Code to authenticate.' + ); + export const federatedAuthNotSupportedInRemote = l10n.t( + 'Federated authentication is not yet supported in remote VS Code. Open the workspace locally to authenticate.' + ); + export const federatedAuthIntegrationNotConfiguredForOAuth = (integrationName: string) => + l10n.t('Integration "{0}" is not configured for Google OAuth authentication.', integrationName); + export const federatedAuthIntegrationNotFound = (integrationId: string) => + l10n.t('Integration "{0}" was not found.', integrationId); + export const federatedAuthOAuthClientMisconfigured = l10n.t( + 'The OAuth client is misconfigured. Verify the client ID and client secret in the integration settings.' + ); + // Snowflake form strings export const snowflakeNameLabel = l10n.t('Name (optional)'); export const snowflakeNamePlaceholder = l10n.t('My Snowflake Database'); diff --git a/src/platform/notebooks/deepnote/integrationTypes.ts b/src/platform/notebooks/deepnote/integrationTypes.ts index 3176e39027..8eb24affc5 100644 --- a/src/platform/notebooks/deepnote/integrationTypes.ts +++ b/src/platform/notebooks/deepnote/integrationTypes.ts @@ -152,6 +152,9 @@ export enum IntegrationStatus { Error = 'error' } +/** Federated-auth token status: `'authenticated'`, `'disconnected'` (federated but no token), or `'unsupported'` (non-federated or web/remote). */ +export type FederatedAuthTokenStatus = 'authenticated' | 'disconnected' | 'unsupported'; + /** * Integration with its current status */ @@ -167,4 +170,6 @@ export interface IntegrationWithStatus { * Type from the project's integrations list (used for prefilling when config is null) */ integrationType?: ConfigurableDatabaseIntegrationType; + /** Federated-auth token status; only meaningful for federated integrations (currently BigQuery + `google-oauth`). */ + tokenStatus?: FederatedAuthTokenStatus; } diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts index 89f5b61dad..caa54b24ba 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.ts @@ -11,7 +11,26 @@ import { IPlatformDeepnoteNotebookManager } from './types'; import { DATAFRAME_SQL_INTEGRATION_ID } from './integrationTypes'; -import { DatabaseIntegrationConfig, getEnvironmentVariablesForIntegrations } from '@deepnote/database-integrations'; +import { + DatabaseIntegrationConfig, + FederatedAuthMethod, + getEnvironmentVariablesForIntegrations, + isFederatedAuthMethod +} from '@deepnote/database-integrations'; + +/** Narrows metadata to the federated-auth variant; upstream `isFederatedAuthMetadata` can't be reused because its generic doesn't unify with our union. Delegates to upstream `isFederatedAuthMethod` at runtime. */ +function isFederatedAuthMetadata( + metadata: DatabaseIntegrationConfig['metadata'] +): metadata is Extract { + if (typeof metadata !== 'object' || metadata === null) { + return false; + } + if (!('authMethod' in metadata)) { + return false; + } + const authMethod = metadata.authMethod; + return typeof authMethod === 'string' && isFederatedAuthMethod(authMethod); +} /** * Provides environment variables for SQL integrations. @@ -88,7 +107,7 @@ export class SqlIntegrationEnvironmentVariablesProvider implements ISqlIntegrati `SqlIntegrationEnvironmentVariablesProvider: Found ${projectIntegrations.length} integrations in project` ); - const projectIntegrationConfigs: Array = ( + const allConfigs: Array = ( await Promise.all( projectIntegrations.map((integration) => { return this.integrationStorage.getIntegrationConfig(integration.id); @@ -96,6 +115,18 @@ export class SqlIntegrationEnvironmentVariablesProvider implements ISqlIntegrati ) ).filter((config) => config != null); + // Skip federated-auth integrations: tokens are fetched per-cell via `CellExecution`'s silent pre-execute, not baked into kernel env. + const projectIntegrationConfigs: Array = []; + for (const config of allConfigs) { + if (isFederatedAuthMetadata(config.metadata)) { + logger.debug( + `SqlIntegrationEnvironmentVariablesProvider: Skipping federated integration ${config.id} (${config.type}); per-cell pre-execute handles its token.` + ); + continue; + } + projectIntegrationConfigs.push(config); + } + // Always add the internal DuckDB integration projectIntegrationConfigs.push({ id: DATAFRAME_SQL_INTEGRATION_ID, diff --git a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts index 497bd6718a..a00da868c1 100644 --- a/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts +++ b/src/platform/notebooks/deepnote/sqlIntegrationEnvironmentVariablesProvider.unit.test.ts @@ -9,9 +9,7 @@ import { DATAFRAME_SQL_INTEGRATION_ID } from './integrationTypes'; import { DatabaseIntegrationConfig } from '@deepnote/database-integrations'; import type { DeepnoteProject } from '../../deepnote/deepnoteTypes'; -/** - * Helper function to create a minimal DeepnoteProject for testing - */ +/** Create a minimal `DeepnoteProject` for tests. */ function createMockProject( projectId: string, integrations: Array<{ id: string; name: string; type: string }> = [] @@ -409,6 +407,52 @@ suite('SqlIntegrationEnvironmentVariablesProvider', () => { }); }); + suite('Federated-auth integrations are skipped', () => { + test('Mixed project: federated integration is skipped, non-federated is included', async () => { + const resource = Uri.file('/test/notebook.deepnote'); + const notebook = mock(); + const postgresConfig: DatabaseIntegrationConfig = { + id: 'pg-1', + name: 'Postgres', + type: 'pgsql', + metadata: { + host: 'localhost', + port: '5432', + database: 'db', + user: 'u', + password: 'p', + sslEnabled: false + } + }; + const federatedConfig: DatabaseIntegrationConfig = { + id: 'bq-oauth', + name: 'OAuth BQ', + type: 'big-query', + metadata: { + authMethod: 'google-oauth', + project: 'oauth-project', + clientId: 'client', + clientSecret: 'secret' + } + }; + const project = createMockProject('project-123', [ + { id: 'pg-1', name: 'Postgres', type: 'pgsql' }, + { id: 'bq-oauth', name: 'OAuth BQ', type: 'big-query' } + ]); + + when(notebook.metadata).thenReturn({ deepnoteProjectId: 'project-123' }); + when(notebookEditorProvider.findAssociatedNotebookDocument(resource)).thenReturn(instance(notebook)); + when(notebookManager.getOriginalProject('project-123')).thenReturn(project); + when(integrationStorage.getIntegrationConfig('pg-1')).thenResolve(postgresConfig); + when(integrationStorage.getIntegrationConfig('bq-oauth')).thenResolve(federatedConfig); + + const result = await provider.getEnvironmentVariables(resource); + + assert.ok(result['SQL_PG_1'], 'Non-federated postgres env var should be present'); + assert.strictEqual(result['SQL_BQ_OAUTH'], undefined, 'Federated integration env var should be omitted'); + }); + }); + suite('onDidChangeEnvironmentVariables event', () => { test('Fires when integration storage changes', (done) => { let eventFired = false; diff --git a/src/webviews/extension-side/dataframe/dataframeController.unit.test.ts b/src/webviews/extension-side/dataframe/dataframeController.unit.test.ts index 7bf6913107..74bcacc240 100644 --- a/src/webviews/extension-side/dataframe/dataframeController.unit.test.ts +++ b/src/webviews/extension-side/dataframe/dataframeController.unit.test.ts @@ -307,7 +307,8 @@ suite('DataframeController', () => { await (controller as any).getDataframeFromDataframeOutput([]); assert.fail('Should have thrown an error'); } catch (error) { - assert.include((error as Error).message, 'No outputs found'); + assert(error instanceof Error); + assert.include(error.message, 'No outputs found'); } }); @@ -318,7 +319,8 @@ suite('DataframeController', () => { await (controller as any).getDataframeFromDataframeOutput(outputs); assert.fail('Should have thrown an error'); } catch (error) { - assert.include((error as Error).message, 'No dataframe output found'); + assert(error instanceof Error); + assert.include(error.message, 'No dataframe output found'); } }); @@ -361,7 +363,8 @@ suite('DataframeController', () => { await (controller as any).handleCopyTable(editor, message); assert.fail('Should have thrown an error'); } catch (error) { - assert.include((error as Error).message, 'No cell identifier'); + assert(error instanceof Error); + assert.include(error.message, 'No cell identifier'); } }); @@ -375,7 +378,8 @@ suite('DataframeController', () => { await (controller as any).handleCopyTable(editor, message); assert.fail('Should have thrown an error'); } catch (error) { - assert.include((error as Error).message, 'Could not find the cell'); + assert(error instanceof Error); + assert.include(error.message, 'Could not find the cell'); } }); @@ -451,7 +455,8 @@ suite('DataframeController', () => { await (controller as any).handleCopyTable(editor, message); assert.fail('Should have thrown an error'); } catch (error) { - assert.include((error as Error).message, 'dataframe is empty'); + assert(error instanceof Error); + assert.include(error.message, 'dataframe is empty'); } }); }); @@ -465,7 +470,8 @@ suite('DataframeController', () => { await (controller as any).handleExportTable(editor, message); assert.fail('Should have thrown an error'); } catch (error) { - assert.include((error as Error).message, 'No cell identifier'); + assert(error instanceof Error); + assert.include(error.message, 'No cell identifier'); } }); @@ -479,7 +485,8 @@ suite('DataframeController', () => { await (controller as any).handleExportTable(editor, message); assert.fail('Should have thrown an error'); } catch (error) { - assert.include((error as Error).message, 'Could not find the cell'); + assert(error instanceof Error); + assert.include(error.message, 'Could not find the cell'); } }); @@ -664,7 +671,8 @@ suite('DataframeController', () => { await (controller as any).handleExportTable(editor, message); assert.fail('Should have thrown an error'); } catch (error) { - assert.include((error as Error).message, 'empty'); + assert(error instanceof Error); + assert.include(error.message, 'empty'); } }); }); diff --git a/src/webviews/webview-side/integrations/BigQueryForm.tsx b/src/webviews/webview-side/integrations/BigQueryForm.tsx index b4ae2b2087..52156fdba1 100644 --- a/src/webviews/webview-side/integrations/BigQueryForm.tsx +++ b/src/webviews/webview-side/integrations/BigQueryForm.tsx @@ -4,11 +4,35 @@ import { BigQueryAuthMethods, DatabaseIntegrationConfig } from '@deepnote/databa import { getDefaultIntegrationName } from './integrationUtils'; type BigQueryConfig = Extract; +type BigQueryAuthMethod = BigQueryConfig['metadata']['authMethod']; -function createEmptyBigQueryConfig(params: { id: string; name?: string }): BigQueryConfig { +function isBigQueryAuthMethod(value: string | undefined): value is BigQueryAuthMethod { + return value === BigQueryAuthMethods.ServiceAccount || value === BigQueryAuthMethods.GoogleOauth; +} + +function createEmptyBigQueryConfig(params: { + id: string; + name?: string; + authMethod?: BigQueryAuthMethod; +}): BigQueryConfig { + const name = (params.name || getDefaultIntegrationName('big-query')).trim(); + const authMethod = params.authMethod ?? BigQueryAuthMethods.ServiceAccount; + if (authMethod === BigQueryAuthMethods.GoogleOauth) { + return { + id: params.id, + name, + type: 'big-query', + metadata: { + authMethod: BigQueryAuthMethods.GoogleOauth, + project: '', + clientId: '', + clientSecret: '' + } + }; + } return { id: params.id, - name: (params.name || getDefaultIntegrationName('big-query')).trim(), + name, type: 'big-query', metadata: { authMethod: BigQueryAuthMethods.ServiceAccount, @@ -17,6 +41,19 @@ function createEmptyBigQueryConfig(params: { id: string; name?: string }): BigQu }; } +function buildInitialConfig( + existingConfig: BigQueryConfig | null, + integrationId: string, + defaultName?: string +): BigQueryConfig { + if (!existingConfig) { + return createEmptyBigQueryConfig({ id: integrationId, name: defaultName }); + } + // Preserve existing config when its auth method is supported. Both + // service-account and google-oauth are editable in this milestone. + return structuredClone(existingConfig); +} + export interface IBigQueryFormProps { integrationId: string; existingConfig: BigQueryConfig | null; @@ -32,29 +69,34 @@ export const BigQueryForm: React.FC = ({ onSave, onCancel }) => { - const [pendingConfig, setPendingConfig] = React.useState( - existingConfig && existingConfig.metadata.authMethod === BigQueryAuthMethods.ServiceAccount - ? structuredClone(existingConfig) - : createEmptyBigQueryConfig({ id: integrationId, name: defaultName }) + const [pendingConfig, setPendingConfig] = React.useState(() => + buildInitialConfig(existingConfig, integrationId, defaultName) ); const [credentialsError, setCredentialsError] = React.useState(null); React.useEffect(() => { - setPendingConfig( - existingConfig && existingConfig.metadata.authMethod === BigQueryAuthMethods.ServiceAccount - ? structuredClone(existingConfig) - : createEmptyBigQueryConfig({ id: integrationId, name: defaultName }) - ); + setPendingConfig(buildInitialConfig(existingConfig, integrationId, defaultName)); setCredentialsError(null); }, [existingConfig, integrationId, defaultName]); + const authMethod = pendingConfig.metadata.authMethod ?? BigQueryAuthMethods.ServiceAccount; + // Extract service account value with proper type narrowing const serviceAccountValue = pendingConfig.metadata.authMethod === BigQueryAuthMethods.ServiceAccount ? pendingConfig.metadata.service_account : ''; + const oauthProject = + pendingConfig.metadata.authMethod === BigQueryAuthMethods.GoogleOauth ? pendingConfig.metadata.project : ''; + const oauthClientId = + pendingConfig.metadata.authMethod === BigQueryAuthMethods.GoogleOauth ? pendingConfig.metadata.clientId : ''; + const oauthClientSecret = + pendingConfig.metadata.authMethod === BigQueryAuthMethods.GoogleOauth + ? pendingConfig.metadata.clientSecret + : ''; + const handleNameChange = (e: React.ChangeEvent) => { const value = e.target.value; setPendingConfig((prev) => ({ @@ -63,6 +105,18 @@ export const BigQueryForm: React.FC = ({ })); }; + const handleAuthMethodChange = (e: React.ChangeEvent) => { + const nextAuthMethod = e.target.value; + if (!isBigQueryAuthMethod(nextAuthMethod)) { + // Defence-in-depth; the