diff --git a/aws-toolkit-vscode.code-workspace b/aws-toolkit-vscode.code-workspace index f03aafae2fe..7306117e8c9 100644 --- a/aws-toolkit-vscode.code-workspace +++ b/aws-toolkit-vscode.code-workspace @@ -12,6 +12,15 @@ { "path": "packages/amazonq", }, + { + "path": "../language-servers", + }, + { + "path": "../language-server-runtimes", + }, + { + "path": "../aws-toolkit-common", + }, ], "settings": { "typescript.tsdk": "node_modules/typescript/lib", diff --git a/package-lock.json b/package-lock.json index e0fc5b7e078..e8fa604aaae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10899,41 +10899,46 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws/chat-client-ui-types": "^0.1.12", - "@aws/language-server-runtimes-types": "^0.1.10", + "@aws/chat-client-ui-types": "^0.1.53", + "@aws/language-server-runtimes-types": "^0.1.47", "@aws/mynah-ui": "^4.28.0" } }, "node_modules/@aws/chat-client-ui-types": { - "version": "0.1.26", + "version": "0.1.53", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes-types": "^0.1.22" + "@aws/language-server-runtimes-types": "^0.1.47" } }, "node_modules/@aws/language-server-runtimes": { - "version": "0.2.81", + "version": "0.2.120", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.120.tgz", + "integrity": "sha512-4zu8sEEVr6OhlrgvPX9vl5HoEhfzipMNQJBfLh/V74XURztnphgrTQHplLh9sHmCLvtv9bnK46xVoOxYrfe9Yg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes-types": "^0.1.28", + "@aws/language-server-runtimes-types": "^0.1.52", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.200.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/exporter-logs-otlp-http": "^0.200.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.200.0", - "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/resources": "^2.0.1", "@opentelemetry/sdk-logs": "^0.200.0", - "@opentelemetry/sdk-metrics": "^2.0.0", + "@opentelemetry/sdk-metrics": "^2.0.1", "@smithy/node-http-handler": "^4.0.4", "ajv": "^8.17.1", + "aws-sdk": "^2.1692.0", "hpagent": "^1.2.0", "jose": "^5.9.6", "mac-ca": "^3.1.1", + "os-proxy-config": "^1.1.2", "rxjs": "^7.8.2", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", + "vscode-uri": "^3.1.0", "win-ca": "^3.5.1" }, "engines": { @@ -10941,7 +10946,9 @@ } }, "node_modules/@aws/language-server-runtimes-types": { - "version": "0.1.28", + "version": "0.1.52", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.52.tgz", + "integrity": "sha512-9z2OiEcWc3CZ6u/j/ABeoGhkwmNbiRqqCO5GJDvtcJfEi9UsSMmo4+YcJJj93pYW8CEVJy3DOPwQCrtT7ngwcw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11079,6 +11086,13 @@ "vscode-languageserver-types": "3.17.5" } }, + "node_modules/@aws/language-server-runtimes/node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@aws/mynah-ui": { "version": "4.34.1", "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.34.1.tgz", @@ -14740,6 +14754,58 @@ "node": ">= 8" } }, + "node_modules/aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true, + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz", + "integrity": "sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/are-we-there-yet/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/are-we-there-yet/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/arg": { "version": "4.1.3", "dev": true, @@ -15093,7 +15159,6 @@ "version": "4.1.0", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -15118,7 +15183,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -15642,8 +15706,7 @@ "node_modules/chownr": { "version": "1.1.4", "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/chrome-trace-event": { "version": "1.0.3", @@ -15834,6 +15897,16 @@ "dev": true, "license": "MIT" }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color": { "version": "3.2.1", "license": "MIT", @@ -15980,6 +16053,13 @@ "node": ">=0.8" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, "node_modules/content-disposition": { "version": "0.5.4", "dev": true, @@ -16410,7 +16490,6 @@ "version": "0.6.0", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=4.0.0" } @@ -17480,7 +17559,6 @@ "version": "2.0.3", "dev": true, "license": "(MIT OR WTFPL)", - "optional": true, "engines": { "node": ">=6" } @@ -17935,8 +18013,7 @@ "node_modules/fs-constants": { "version": "1.0.0", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "11.3.0", @@ -18002,6 +18079,75 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha512-14x4kjc6lkD3ltw589k0NrPD6cCNTD6CWoVUNpB85+DrtONoZn+Rug6xZU5RvSC4+TZPxA5AnBibQYAvZn41Hg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/geometry-interfaces": { "version": "1.1.4", "dev": true, @@ -18072,8 +18218,7 @@ "node_modules/github-from-package": { "version": "0.0.0", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/glob": { "version": "10.3.10", @@ -18315,6 +18460,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, "node_modules/hash-base": { "version": "3.1.0", "license": "MIT", @@ -18761,8 +18913,7 @@ "node_modules/ini": { "version": "1.3.8", "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/internal-slot": { "version": "1.0.3", @@ -19976,6 +20127,13 @@ "undici": "^6.16.1" } }, + "node_modules/mac-system-proxy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mac-system-proxy/-/mac-system-proxy-1.0.4.tgz", + "integrity": "sha512-IAkNLxXZrYuM99A2OhPrvUoAxohsxQciJh2D2xnD+R6vypn/AVyOYLsbZsMVCS/fEbLIe67nQ8krEAfqP12BVg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.0", "license": "MIT", @@ -20342,8 +20500,7 @@ "node_modules/mkdirp-classic": { "version": "0.5.3", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/mocha": { "version": "10.1.0", @@ -20654,8 +20811,7 @@ "node_modules/napi-build-utils": { "version": "1.0.2", "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -20765,6 +20921,13 @@ "dev": true, "license": "MIT" }, + "node_modules/noop-logger": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz", + "integrity": "sha512-6kM8CLXvuW5crTxsAtva2YLrRrDaiTIkIePWs9moLHqbFWT94WpNFjwS/5dfLfECg5i/lkmw3aoqVidxt23TEQ==", + "dev": true, + "license": "MIT" + }, "node_modules/normalize-package-data": { "version": "3.0.3", "dev": true, @@ -20808,6 +20971,20 @@ "node": ">=8" } }, + "node_modules/npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "dev": true, @@ -20819,6 +20996,16 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nunjucks": { "version": "3.2.4", "dev": true, @@ -21004,6 +21191,17 @@ "version": "0.3.0", "license": "MIT" }, + "node_modules/os-proxy-config": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/os-proxy-config/-/os-proxy-config-1.1.2.tgz", + "integrity": "sha512-sV7htE8y6NQORU0oKOUGTwQYe1gSFK3a3Z1i4h6YaqdrA9C0JIsUPQAqEkO8ejjYbRrQ+jsnks5qjtisr7042Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mac-system-proxy": "^1.0.0", + "windows-system-proxy": "^1.0.0" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "license": "MIT", @@ -21864,7 +22062,6 @@ "version": "1.2.8", "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -21879,7 +22076,6 @@ "version": "2.0.1", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -22124,6 +22320,126 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/registry-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/registry-js/-/registry-js-1.16.1.tgz", + "integrity": "sha512-pQ2kD36lh+YNtpaXm6HCCb0QZtV/zQEeKnkfEIj5FDSpF/oFts7pwizEUkWSvP8IbGb4A4a5iBhhS9eUearMmQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^3.2.1", + "prebuild-install": "^5.3.5" + } + }, + "node_modules/registry-js/node_modules/decompress-response": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", + "integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/registry-js/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/registry-js/node_modules/mimic-response": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", + "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/registry-js/node_modules/node-abi": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.1.tgz", + "integrity": "sha512-/2D0wOQPgaUWzVSVgRMx+trKJRC2UG4SUc4oCJoXx9Uxjtp0Vy3/kt7zcbxHF8+Z/pK3UloLWzBISg72brfy1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.4.1" + } + }, + "node_modules/registry-js/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/registry-js/node_modules/prebuild-install": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.6.tgz", + "integrity": "sha512-s8Aai8++QQGi4sSbs/M1Qku62PFK49Jm1CbgXklGz4nmHveDq0wzJkg7Na5QbnO1uNH8K7iqx2EQ/mV0MZEmOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^2.7.0", + "noop-logger": "^0.1.1", + "npmlog": "^4.0.1", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^3.0.3", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0", + "which-pm-runs": "^1.0.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/registry-js/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/registry-js/node_modules/simple-get": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz", + "integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-response": "^4.2.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/regjsparser": { "version": "0.10.0", "dev": true, @@ -22717,6 +23033,13 @@ "node": ">= 0.8" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "license": "MIT", @@ -22841,8 +23164,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/simple-get": { "version": "4.0.1", @@ -23382,7 +23704,6 @@ "version": "2.1.1", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -23394,7 +23715,6 @@ "version": "2.2.0", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -23822,7 +24142,6 @@ "version": "0.6.0", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -24899,6 +25218,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-pm-runs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/which-typed-array": { "version": "1.1.8", "license": "MIT", @@ -24917,6 +25246,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wildcard": { "version": "2.0.0", "dev": true, @@ -24945,6 +25284,16 @@ "node": ">=4" } }, + "node_modules/windows-system-proxy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/windows-system-proxy/-/windows-system-proxy-1.0.0.tgz", + "integrity": "sha512-qd1WfyX9gjAqI36RHt95di2+FBr74DhvELd1EASgklCGScjwReHnWnXfUyabp/CJWl/IdnkUzG0Ub6Cv2R4KJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "registry-js": "^1.15.1" + } + }, "node_modules/winston": { "version": "3.11.0", "license": "MIT", @@ -25497,9 +25846,9 @@ "devDependencies": { "@aws-sdk/types": "^3.13.1", "@aws/chat-client": "^0.1.4", - "@aws/chat-client-ui-types": "^0.1.24", - "@aws/language-server-runtimes": "^0.2.81", - "@aws/language-server-runtimes-types": "^0.1.28", + "@aws/chat-client-ui-types": "^0.1.53", + "@aws/language-server-runtimes": "^0.2.120", + "@aws/language-server-runtimes-types": "^0.1.52", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", "@types/adm-zip": "^0.4.34", diff --git a/packages/amazonq/.vscode/launch.json b/packages/amazonq/.vscode/launch.json index b00c5071ce5..cdeabe152a9 100644 --- a/packages/amazonq/.vscode/launch.json +++ b/packages/amazonq/.vscode/launch.json @@ -13,10 +13,10 @@ "args": ["--extensionDevelopmentPath=${workspaceFolder}"], "env": { "SSMDOCUMENT_LANGUAGESERVER_PORT": "6010", - "WEBPACK_DEVELOPER_SERVER": "http://localhost:8080" + "WEBPACK_DEVELOPER_SERVER": "http://localhost:8080", // Below allows for overrides used during development - // "__AMAZONQLSP_PATH": "${workspaceFolder}/../../../language-servers/app/aws-lsp-codewhisperer-runtimes/out/agent-standalone.js", - // "__AMAZONQLSP_UI": "${workspaceFolder}/../../../language-servers/chat-client/build/amazonq-ui.js" + "__AMAZONQLSP_PATH": "${workspaceFolder}/../../../language-servers/app/aws-lsp-codewhisperer-runtimes/out/agent-standalone.js", + "__AMAZONQLSP_UI": "${workspaceFolder}/../../../language-servers/chat-client/build/amazonq-ui.js" }, "envFile": "${workspaceFolder}/.local.env", "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/../core/dist/**/*.js"], diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index f01a40c1110..e5415dc956f 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -1196,106 +1196,134 @@ "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d1" + "fontCharacter": "\\f1d3" } }, "aws-mynah-MynahIconBlack": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d2" + "fontCharacter": "\\f1d4" } }, "aws-mynah-MynahIconWhite": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d3" + "fontCharacter": "\\f1d5" } }, "aws-mynah-logo": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d4" + "fontCharacter": "\\f1d6" } }, "aws-redshift-cluster": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d5" + "fontCharacter": "\\f1d7" } }, "aws-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d6" + "fontCharacter": "\\f1d8" } }, "aws-redshift-database": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d7" + "fontCharacter": "\\f1d9" } }, "aws-redshift-redshift-cluster-connected": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d8" + "fontCharacter": "\\f1da" } }, "aws-redshift-schema": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1d9" + "fontCharacter": "\\f1db" } }, "aws-redshift-table": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1da" + "fontCharacter": "\\f1dc" } }, "aws-s3-bucket": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1db" + "fontCharacter": "\\f1dd" } }, "aws-s3-create-bucket": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1dc" + "fontCharacter": "\\f1de" } }, "aws-schemas-registry": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1dd" + "fontCharacter": "\\f1e1" } }, "aws-schemas-schema": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", - "fontCharacter": "\\f1de" + "fontCharacter": "\\f1e2" } }, "aws-stepfunctions-preview": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e3" + } + }, + "aws-lambda-create-stack": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1d1" + } + }, + "aws-lambda-create-stack-light": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1d2" + } + }, + "aws-sagemaker-code-editor": { "description": "AWS Contributed Icon", "default": { "fontPath": "./resources/fonts/aws-toolkit-icons.woff", "fontCharacter": "\\f1df" } + }, + "aws-sagemaker-jupyter-lab": { + "description": "AWS Contributed Icon", + "default": { + "fontPath": "./resources/fonts/aws-toolkit-icons.woff", + "fontCharacter": "\\f1e0" + } } }, "walkthroughs": [ diff --git a/packages/amazonq/src/extensionNode.ts b/packages/amazonq/src/extensionNode.ts index 576757c36e2..d7f22c2996d 100644 --- a/packages/amazonq/src/extensionNode.ts +++ b/packages/amazonq/src/extensionNode.ts @@ -100,8 +100,11 @@ async function activateAmazonQNode(context: vscode.ExtensionContext) { async function getAuthState(): Promise> { const state = AuthUtil.instance.getAuthState() - if (AuthUtil.instance.isConnected() && !(AuthUtil.instance.isSsoSession() || isSageMaker())) { - getLogger().error('Current Amazon Q connection is not SSO') + if ( + AuthUtil.instance.isConnected() && + !(AuthUtil.instance.isSsoSession() || AuthUtil.instance.isIamSession() || isSageMaker()) + ) { + getLogger().error('Current Amazon Q connection is not SSO nor IAM') } return { diff --git a/packages/amazonq/src/inlineChat/provider/inlineChatProvider.ts b/packages/amazonq/src/inlineChat/provider/inlineChatProvider.ts index 64a67224a2e..4aa4b3f0aaf 100644 --- a/packages/amazonq/src/inlineChat/provider/inlineChatProvider.ts +++ b/packages/amazonq/src/inlineChat/provider/inlineChatProvider.ts @@ -16,6 +16,7 @@ import { ChatTriggerType, EditorContextExtractor, PromptMessage, + TriggerEvent, TriggerEventsStorage, TriggerPayload, triggerPayloadToChatRequest, @@ -30,6 +31,7 @@ import { extractAuthFollowUp } from 'aws-core-vscode/amazonq' import { InlineChatParams, InlineChatResult } from '@aws/language-server-runtimes-types' import { decryptResponse, encryptRequest } from '../../lsp/encryption' import { getCursorState } from '../../lsp/utils' +import { CwsprChatTriggerInteraction, telemetry } from 'aws-core-vscode/telemetry' export class InlineChatProvider { private readonly editorContextExtractor: EditorContextExtractor @@ -68,7 +70,34 @@ export class InlineChatProvider { } } + private getTriggerInteractionFromTriggerEvent(triggerEvent: TriggerEvent | undefined): CwsprChatTriggerInteraction { + switch (triggerEvent?.type) { + case 'editor_context_command': + return triggerEvent.command?.triggerType === 'keybinding' ? 'hotkeys' : 'contextMenu' + case 'follow_up': + case 'chat_message': + default: + return 'click' + } + } + public async processPromptMessageLSP(message: PromptMessage): Promise { + const triggerInteraction = this.getTriggerInteractionFromTriggerEvent( + this.triggerEventsStorage.getLastTriggerEventByTabID(message.tabID) + ) + if (!AuthUtil.instance.isSsoSession()) { + telemetry.amazonq_messageResponseError.emit({ + result: 'Failed', + cwsprChatConversationType: 'Chat', + cwsprChatRequestLength: message.message?.length ?? 0, + cwsprChatResponseCode: 401, + cwsprChatTriggerInteraction: triggerInteraction, + reason: 'AuthenticationError', + reasonDesc: 'Inline chat requires SSO authentication, but current session is not', + }) + throw new ToolkitError('Inline chat is only available with SSO authentication') + } + // TODO: handle partial responses. getLogger().info('Making inline chat request with message %O', message) const params = this.getCurrentEditorParams(message.message ?? '') @@ -83,6 +112,23 @@ export class InlineChatProvider { // TODO: remove in favor of LSP implementation. public async processPromptMessage(message: PromptMessage) { + const triggerInteraction = this.getTriggerInteractionFromTriggerEvent( + this.triggerEventsStorage.getLastTriggerEventByTabID(message.tabID) + ) + if (!AuthUtil.instance.isSsoSession()) { + telemetry.amazonq_messageResponseError.emit({ + result: 'Failed', + cwsprChatConversationType: 'Chat', + cwsprChatRequestLength: message.message?.length ?? 0, + cwsprChatResponseCode: 401, + cwsprChatTriggerInteraction: triggerInteraction, + reason: 'AuthenticationError', + reasonDesc: 'Inline chat requires SSO authentication, but current session is not', + credentialStartUrl: AuthUtil.instance.connection?.startUrl, + }) + throw new ToolkitError('Inline chat is only available with SSO authentication') + } + return this.editorContextExtractor .extractContextForTrigger('ChatMessage') .then((context) => { @@ -143,7 +189,7 @@ export class InlineChatProvider { private async generateResponse( triggerPayload: TriggerPayload & { projectContextQueryLatencyMs?: number }, triggerID: string - ) { + ): Promise { const triggerEvent = this.triggerEventsStorage.getTriggerEvent(triggerID) if (triggerEvent === undefined) { return @@ -182,7 +228,12 @@ export class InlineChatProvider { let response: GenerateAssistantResponseCommandOutput | undefined = undefined session.createNewTokenSource() try { - response = await session.chatSso(request) + if (AuthUtil.instance.isSsoSession()) { + response = await session.chatSso(request) + } else { + // Call sendMessage because Q Developer Streaming Client does not have generateAssistantResponse + throw new ToolkitError('Inline chat is only available with SSO authentication') + } getLogger().info( `response to tab: ${tabID} conversationID: ${session.sessionIdentifier} requestID: ${response.$metadata.requestId} metadata: %O`, response.$metadata diff --git a/packages/amazonq/src/lsp/client.ts b/packages/amazonq/src/lsp/client.ts index 4395ade9a2c..97c5e44af5d 100644 --- a/packages/amazonq/src/lsp/client.ts +++ b/packages/amazonq/src/lsp/client.ts @@ -164,6 +164,9 @@ export async function startLanguageServer( }, credentials: { providesBearerToken: true, + // Add IAM credentials support + providesIamCredentials: true, + supportsAssumeRole: true, }, }, /** @@ -211,9 +214,10 @@ export async function startLanguageServer( /** All must be setup before {@link AuthUtil.restore} otherwise they may not trigger when expected */ AuthUtil.instance.regionProfileManager.onDidChangeRegionProfile(async () => { + const activeProfile = AuthUtil.instance.regionProfileManager.activeRegionProfile void pushConfigUpdate(client, { type: 'profile', - profileArn: AuthUtil.instance.regionProfileManager.activeRegionProfile?.arn, + profileArn: activeProfile?.arn, }) }) diff --git a/packages/amazonq/test/e2e/amazonq/utils/setup.ts b/packages/amazonq/test/e2e/amazonq/utils/setup.ts index ef7ba540198..1521d21ecba 100644 --- a/packages/amazonq/test/e2e/amazonq/utils/setup.ts +++ b/packages/amazonq/test/e2e/amazonq/utils/setup.ts @@ -22,5 +22,5 @@ export async function loginToIdC() { ) } - await AuthUtil.instance.login(startUrl, region) + await AuthUtil.instance.loginSso(startUrl, region) } diff --git a/packages/amazonq/test/unit/codewhisperer/region/regionProfileManager.test.ts b/packages/amazonq/test/unit/codewhisperer/region/regionProfileManager.test.ts index a77e47e33ab..bfc3524abbb 100644 --- a/packages/amazonq/test/unit/codewhisperer/region/regionProfileManager.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/region/regionProfileManager.test.ts @@ -26,11 +26,11 @@ describe('RegionProfileManager', async function () { async function setupConnection(type: 'builderId' | 'idc') { if (type === 'builderId') { - await AuthUtil.instance.login(constants.builderIdStartUrl, region) + await AuthUtil.instance.loginSso(constants.builderIdStartUrl, region) assert.ok(AuthUtil.instance.isSsoSession()) assert.ok(AuthUtil.instance.isBuilderIdConnection()) } else if (type === 'idc') { - await AuthUtil.instance.login(enterpriseSsoStartUrl, region) + await AuthUtil.instance.loginSso(enterpriseSsoStartUrl, region) assert.ok(AuthUtil.instance.isSsoSession()) assert.ok(AuthUtil.instance.isIdcConnection()) } diff --git a/packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts b/packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts index 1795639e1e2..a9bcee7cc14 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/authUtil.test.ts @@ -11,6 +11,7 @@ import { createTestAuthUtil, TestFolder } from 'aws-core-vscode/test' import { constants, cache } from 'aws-core-vscode/auth' import { auth2 } from 'aws-core-vscode/auth' import { mementoUtils, fs } from 'aws-core-vscode/shared' +import { GetIamCredentialResult } from '@aws/language-server-runtimes/protocol' describe('AuthUtil', async function () { let auth: any @@ -26,19 +27,19 @@ describe('AuthUtil', async function () { describe('Auth state', function () { it('login with BuilderId', async function () { - await auth.login(constants.builderIdStartUrl, constants.builderIdRegion) + await auth.loginSso(constants.builderIdStartUrl, constants.builderIdRegion) assert.ok(auth.isConnected()) assert.ok(auth.isBuilderIdConnection()) }) it('login with IDC', async function () { - await auth.login('https://example.awsapps.com/start', 'us-east-1') + await auth.loginSso('https://example.awsapps.com/start', 'us-east-1') assert.ok(auth.isConnected()) assert.ok(auth.isIdcConnection()) }) it('identifies internal users', async function () { - await auth.login(constants.internalStartUrl, 'us-east-1') + await auth.loginSso(constants.internalStartUrl, 'us-east-1') assert.ok(auth.isInternalAmazonUser()) }) @@ -55,7 +56,7 @@ describe('AuthUtil', async function () { describe('Token management', function () { it('can get token when connected with SSO', async function () { - await auth.login(constants.builderIdStartUrl, constants.builderIdRegion) + await auth.loginSso(constants.builderIdStartUrl, constants.builderIdRegion) const token = await auth.getToken() assert.ok(token) }) @@ -68,14 +69,14 @@ describe('AuthUtil', async function () { describe('getTelemetryMetadata', function () { it('returns valid metadata for BuilderId connection', async function () { - await auth.login(constants.builderIdStartUrl, constants.builderIdRegion) + await auth.loginSso(constants.builderIdStartUrl, constants.builderIdRegion) const metadata = await auth.getTelemetryMetadata() assert.strictEqual(metadata.credentialSourceId, 'awsId') assert.strictEqual(metadata.credentialStartUrl, constants.builderIdStartUrl) }) it('returns valid metadata for IDC connection', async function () { - await auth.login('https://example.awsapps.com/start', 'us-east-1') + await auth.loginSso('https://example.awsapps.com/start', 'us-east-1') const metadata = await auth.getTelemetryMetadata() assert.strictEqual(metadata.credentialSourceId, 'iamIdentityCenter') assert.strictEqual(metadata.credentialStartUrl, 'https://example.awsapps.com/start') @@ -96,37 +97,40 @@ describe('AuthUtil', async function () { }) it('returns BuilderId forms when using BuilderId', async function () { - await auth.login(constants.builderIdStartUrl, constants.builderIdRegion) + await auth.loginSso(constants.builderIdStartUrl, constants.builderIdRegion) const forms = await auth.getAuthFormIds() assert.deepStrictEqual(forms, ['builderIdCodeWhisperer']) }) it('returns IDC forms when using IDC without SSO account access', async function () { const session = (auth as any).session - sinon.stub(session, 'getProfile').resolves({ - ssoSession: { - settings: { - sso_registration_scopes: ['codewhisperer:*'], + session && + sinon.stub(session, 'getProfile').resolves({ + ssoSession: { + settings: { + sso_registration_scopes: ['codewhisperer:*'], + }, }, - }, - }) + }) - await auth.login('https://example.awsapps.com/start', 'us-east-1') + await auth.loginSso('https://example.awsapps.com/start', 'us-east-1') const forms = await auth.getAuthFormIds() assert.deepStrictEqual(forms, ['identityCenterCodeWhisperer']) }) it('returns IDC forms with explorer when using IDC with SSO account access', async function () { + await auth.loginSso('https://example.awsapps.com/start', 'us-east-1') const session = (auth as any).session - sinon.stub(session, 'getProfile').resolves({ - ssoSession: { - settings: { - sso_registration_scopes: ['codewhisperer:*', 'sso:account:access'], + + session && + sinon.stub(session, 'getProfile').resolves({ + ssoSession: { + settings: { + sso_registration_scopes: ['codewhisperer:*', 'sso:account:access'], + }, }, - }, - }) + }) - await auth.login('https://example.awsapps.com/start', 'us-east-1') const forms = await auth.getAuthFormIds() assert.deepStrictEqual(forms.sort(), ['identityCenterCodeWhisperer', 'identityCenterExplorer'].sort()) }) @@ -134,6 +138,7 @@ describe('AuthUtil', async function () { it('returns credentials form for IAM credentials', async function () { sinon.stub(auth, 'isSsoSession').returns(false) sinon.stub(auth, 'isConnected').returns(true) + sinon.stub(auth, 'isIamSession').returns(true) const forms = await auth.getAuthFormIds() assert.deepStrictEqual(forms, ['credentials']) @@ -178,7 +183,7 @@ describe('AuthUtil', async function () { }) it('updates bearer token when state is refreshed', async function () { - await auth.login(constants.builderIdStartUrl, 'us-east-1') + await auth.loginSso(constants.builderIdStartUrl, 'us-east-1') await (auth as any).stateChangeHandler({ state: 'refreshed' }) @@ -187,7 +192,7 @@ describe('AuthUtil', async function () { }) it('cleans up when connection expires', async function () { - await auth.login(constants.builderIdStartUrl, 'us-east-1') + await auth.loginSso(constants.builderIdStartUrl, 'us-east-1') await (auth as any).stateChangeHandler({ state: 'expired' }) @@ -197,13 +202,15 @@ describe('AuthUtil', async function () { it('deletes bearer token when disconnected', async function () { await (auth as any).stateChangeHandler({ state: 'notConnected' }) - assert.ok(mockLspAuth.deleteBearerToken.called) + if (auth.isSsoSession(auth.session)) { + assert.ok(mockLspAuth.deleteBearerToken.called) + } }) it('updates bearer token and restores profile on reconnection', async function () { const restoreProfileSelectionSpy = sinon.spy(regionProfileManager, 'restoreProfileSelection') - await auth.login('https://example.awsapps.com/start', 'us-east-1') + await auth.loginSso('https://example.awsapps.com/start', 'us-east-1') await (auth as any).stateChangeHandler({ state: 'connected' }) @@ -215,7 +222,7 @@ describe('AuthUtil', async function () { const invalidateProfileSpy = sinon.spy(regionProfileManager, 'invalidateProfile') const clearCacheSpy = sinon.spy(regionProfileManager, 'clearCache') - await auth.login('https://example.awsapps.com/start', 'us-east-1') + await auth.loginSso('https://example.awsapps.com/start', 'us-east-1') await (auth as any).stateChangeHandler({ state: 'expired' }) @@ -280,12 +287,16 @@ describe('AuthUtil', async function () { await auth.migrateSsoConnectionToLsp('test-client') assert.ok(memento.update.calledWith('auth.profiles', undefined)) - assert.ok(!auth.session.updateProfile?.called) + assert.ok(!auth.session?.updateProfile?.called) }) it('proceeds with migration if LSP token check throws', async function () { memento.get.returns({ profile1: validProfile }) mockLspAuth.getSsoToken.rejects(new Error('Token check failed')) + + if (!(auth as any).session) { + auth.session = new auth2.SsoLogin(auth.profileName, auth.lspAuth, auth.eventEmitter) + } const updateProfileStub = sinon.stub((auth as any).session, 'updateProfile').resolves() await auth.migrateSsoConnectionToLsp('test-client') @@ -297,22 +308,24 @@ describe('AuthUtil', async function () { it('migrates valid SSO connection', async function () { memento.get.returns({ profile1: validProfile }) - const updateProfileStub = sinon.stub((auth as any).session, 'updateProfile').resolves() + if ((auth as any).session) { + const updateProfileStub = sinon.stub((auth as any).session, 'updateProfile').resolves() - await auth.migrateSsoConnectionToLsp('test-client') + await auth.migrateSsoConnectionToLsp('test-client') - assert.ok(updateProfileStub.calledOnce) - assert.ok(memento.update.calledWith('auth.profiles', undefined)) + assert.ok(updateProfileStub.calledOnce) + assert.ok(memento.update.calledWith('auth.profiles', undefined)) - const files = await fs.readdir(cacheDir) - assert.strictEqual(files.length, 2) // Should have both the token and registration file - - // Verify file contents were preserved - const newFiles = files.map((f) => path.join(cacheDir, f[0])) - for (const file of newFiles) { - const content = await fs.readFileText(file) - const parsed = JSON.parse(content) - assert.ok(parsed.test === 'registration' || parsed.test === 'token') + const files = await fs.readdir(cacheDir) + assert.strictEqual(files.length, 2) // Should have both the token and registration file + + // Verify file contents were preserved + const newFiles = files.map((f) => path.join(cacheDir, f[0])) + for (const file of newFiles) { + const content = await fs.readFileText(file) + const parsed = JSON.parse(content) + assert.ok(parsed.test === 'registration' || parsed.test === 'token') + } } }) @@ -351,6 +364,10 @@ describe('AuthUtil', async function () { } memento.get.returns(mockProfiles) + if (!(auth as any).session) { + auth.session = new auth2.SsoLogin(auth.profileName, auth.lspAuth, auth.eventEmitter) + } + const updateProfileStub = sinon.stub((auth as any).session, 'updateProfile').resolves() await auth.migrateSsoConnectionToLsp('test-client') @@ -376,6 +393,10 @@ describe('AuthUtil', async function () { } memento.get.returns(mockProfiles) + if (!(auth as any).session) { + auth.session = new auth2.SsoLogin(auth.profileName, auth.lspAuth, auth.eventEmitter) + } + const updateProfileStub = sinon.stub((auth as any).session, 'updateProfile').resolves() await auth.migrateSsoConnectionToLsp('test-client') @@ -389,4 +410,203 @@ describe('AuthUtil', async function () { ) }) }) + + describe('loginIam', function () { + it('creates IAM session and logs in', async function () { + const mockResponse = { + credential: { + id: 'test-credential-id', + kinds: [], + credentials: { + accessKeyId: 'encrypted-access-key', + secretAccessKey: 'encrypted-secret-key', + sessionToken: 'encrypted-session-token', + }, + }, + updateCredentialsParams: { + data: 'credential-data', + }, + } satisfies GetIamCredentialResult + + const mockIamLogin = { + login: sinon.stub().resolves(mockResponse), + loginType: 'iam', + } + + sinon.stub(auth2, 'IamLogin').returns(mockIamLogin as any) + + const response = await auth.loginIam({ + accessKey: 'testAccessKey', + secretKey: 'testSecretKey', + sessionToken: 'testSessionToken', + }) + + assert.ok(mockIamLogin.login.calledOnce) + assert.ok( + mockIamLogin.login.calledWith({ + accessKey: 'testAccessKey', + secretKey: 'testSecretKey', + sessionToken: 'testSessionToken', + roleArn: undefined, + }) + ) + assert.strictEqual(response, mockResponse) + }) + + it('creates IAM session with role ARN', async function () { + const mockResponse = { + credential: { + id: 'test-credential-id', + kinds: [], + credentials: { + accessKeyId: 'encrypted-access-key', + secretAccessKey: 'encrypted-secret-key', + sessionToken: 'encrypted-session-token', + }, + }, + updateCredentialsParams: { + data: 'credential-data', + }, + } satisfies GetIamCredentialResult + + const mockIamLogin = { + login: sinon.stub().resolves(mockResponse), + loginType: 'iam', + } + + sinon.stub(auth2, 'IamLogin').returns(mockIamLogin as any) + + const opts: auth2.IamProfileOptions = { + accessKey: 'testAccessKey', + secretKey: 'testSecretKey', + sessionToken: 'testSessionToken', + roleArn: 'arn:aws:iam::123456789012:role/TestRole', + } + + const response = await auth.loginIam(opts) + + assert.ok(mockIamLogin.login.calledOnce) + assert.ok(mockIamLogin.login.calledWith(opts)) + assert.strictEqual(response, mockResponse) + }) + }) + + describe('getIamCredential', function () { + it('returns IAM credentials from session', async function () { + const mockCredentials = { + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + sessionToken: 'test-session-token', + } + + const mockSession = { + getCredential: sinon.stub().resolves({ + credential: mockCredentials, + updateCredentialsParams: { data: 'test' }, + }), + loginType: 'iam', + } + + ;(auth as any).session = mockSession + + const result = await auth.getIamCredential() + + assert.ok(mockSession.getCredential.calledOnce) + assert.deepStrictEqual(result, mockCredentials) + }) + + it('throws error for SSO session', async function () { + const mockSession = { + getCredential: sinon.stub().resolves({ + credential: 'sso-token', + updateCredentialsParams: { data: 'test' }, + }), + loginType: 'sso', + } + + ;(auth as any).session = mockSession + + try { + await auth.getIamCredential() + assert.fail('Should have thrown an error') + } catch (err) { + assert.strictEqual((err as Error).message, 'Cannot get credential without logging in with IAM.') + } + }) + + it('throws error when not logged in', async function () { + ;(auth as any).session = undefined + + try { + await auth.getIamCredential() + assert.fail('Should have thrown an error') + } catch (err) { + assert.strictEqual((err as Error).message, 'Cannot get credential without logging in with IAM.') + } + }) + }) + + describe('isIamSession', function () { + it('returns true for IAM session', function () { + const mockSession = { loginType: 'iam' } + ;(auth as any).session = mockSession + + assert.strictEqual(auth.isIamSession(), true) + }) + + it('returns false for SSO session', function () { + const mockSession = { loginType: 'sso' } + ;(auth as any).session = mockSession + + assert.strictEqual(auth.isIamSession(), false) + }) + + it('returns false when no session', function () { + ;(auth as any).session = undefined + + assert.strictEqual(auth.isIamSession(), false) + }) + }) + + describe('IAM session state changes', function () { + let mockLspAuth: any + + beforeEach(function () { + mockLspAuth = (auth as any).lspAuth + }) + + it('updates IAM credential when state is refreshed', async function () { + const mockSession = { + getCredential: sinon.stub().resolves({ + credential: { accessKeyId: 'key', secretAccessKey: 'secret' }, + updateCredentialsParams: { data: 'fake-data' }, + }), + loginType: 'iam', + } + ;(auth as any).session = mockSession + + await (auth as any).stateChangeHandler({ state: 'refreshed' }) + + assert.ok(mockLspAuth.updateIamCredential.called) + assert.strictEqual(mockLspAuth.updateIamCredential.firstCall.args[0].data, 'fake-data') + }) + + it('cleans up IAM credential when connection expires', async function () { + const mockSession = { loginType: 'iam' } + ;(auth as any).session = mockSession + + await (auth as any).stateChangeHandler({ state: 'expired' }) + + assert.ok(mockLspAuth.deleteIamCredential.called) + }) + + it('deletes IAM credential when disconnected', async function () { + const mockSession = { loginType: 'iam' } + ;(auth as any).session = mockSession + + await (auth as any).stateChangeHandler({ state: 'notConnected' }) + + assert.ok(mockLspAuth.deleteIamCredential.called) + }) + }) }) diff --git a/packages/amazonq/test/unit/codewhisperer/util/showSsoPrompt.test.ts b/packages/amazonq/test/unit/codewhisperer/util/showSsoPrompt.test.ts index 1d67db60efc..f5c58fe212c 100644 --- a/packages/amazonq/test/unit/codewhisperer/util/showSsoPrompt.test.ts +++ b/packages/amazonq/test/unit/codewhisperer/util/showSsoPrompt.test.ts @@ -28,7 +28,7 @@ describe('showConnectionPrompt', function () { }) it('can select connect to AwsBuilderId', async function () { - sinon.stub(AuthUtil.instance, 'login').resolves() + sinon.stub(AuthUtil.instance, 'loginSso').resolves() getTestWindow().onDidShowQuickPick(async (picker) => { await picker.untilReady() @@ -44,7 +44,7 @@ describe('showConnectionPrompt', function () { it('connectToAwsBuilderId calls AuthUtil login with builderIdStartUrl', async function () { sinon.stub(vscode.commands, 'executeCommand') - const loginStub = sinon.stub(AuthUtil.instance, 'login').resolves() + const loginStub = sinon.stub(AuthUtil.instance, 'loginSso').resolves() await awsIdSignIn() diff --git a/packages/core/package.json b/packages/core/package.json index 67e20d5feb1..f53e23cb136 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -442,9 +442,9 @@ "devDependencies": { "@aws-sdk/types": "^3.13.1", "@aws/chat-client": "^0.1.4", - "@aws/chat-client-ui-types": "^0.1.24", - "@aws/language-server-runtimes": "^0.2.81", - "@aws/language-server-runtimes-types": "^0.1.28", + "@aws/chat-client-ui-types": "^0.1.53", + "@aws/language-server-runtimes": "^0.2.120", + "@aws/language-server-runtimes-types": "^0.1.52", "@cspotcode/source-map-support": "^0.8.1", "@sinonjs/fake-timers": "^10.0.2", "@types/adm-zip": "^0.4.34", diff --git a/packages/core/src/auth/auth2.ts b/packages/core/src/auth/auth2.ts index 273a644ebbd..ec7f2049aee 100644 --- a/packages/core/src/auth/auth2.ts +++ b/packages/core/src/auth/auth2.ts @@ -9,14 +9,22 @@ import { GetSsoTokenParams, getSsoTokenRequestType, GetSsoTokenResult, + GetIamCredentialParams, + getIamCredentialRequestType, + GetIamCredentialResult, + InvalidateStsCredentialResult, IamIdentityCenterSsoTokenSource, InvalidateSsoTokenParams, + InvalidateStsCredentialParams, invalidateSsoTokenRequestType, + invalidateStsCredentialRequestType, ProfileKind, - UpdateProfileParams, updateProfileRequestType, SsoTokenChangedParams, + StsCredentialChangedParams, + StsCredentialChangedKind, ssoTokenChangedRequestType, + stsCredentialChangedRequestType, AwsBuilderIdSsoTokenSource, UpdateCredentialsParams, AwsErrorCodes, @@ -28,6 +36,7 @@ import { AuthorizationFlowKind, CancellationToken, CancellationTokenSource, + iamCredentialsDeleteNotificationType, bearerCredentialsDeleteNotificationType, bearerCredentialsUpdateRequestType, SsoTokenChangedKind, @@ -36,6 +45,12 @@ import { NotificationType, ConnectionMetadata, getConnectionMetadataRequestType, + iamCredentialsUpdateRequestType, + Profile, + SsoSession, + GetMfaCodeParams, + getMfaCodeRequestType, + GetMfaCodeResult, } from '@aws/language-server-runtimes/protocol' import { LanguageClient } from 'vscode-languageclient' import { getLogger } from '../shared/logger/logger' @@ -43,8 +58,15 @@ import { ToolkitError } from '../shared/errors' import { useDeviceFlow } from './sso/ssoAccessTokenProvider' import { getCacheDir, getCacheFileWatcher, getFlareCacheFileName } from './sso/cache' import { VSCODE_EXTENSION_ID } from '../shared/extensions' +import { IamCredentials } from '@aws/language-server-runtimes-types' +import globals from '../shared/extensionGlobals' +import { getMfaSerialFromUser, getMfaTokenFromUser } from './credentials/utils' export const notificationTypes = { + updateIamCredential: new RequestType( + iamCredentialsUpdateRequestType.method + ), + deleteIamCredential: new NotificationType(iamCredentialsDeleteNotificationType.method), updateBearerToken: new RequestType( bearerCredentialsUpdateRequestType.method ), @@ -64,16 +86,30 @@ export const LoginTypes = { } as const export type LoginType = (typeof LoginTypes)[keyof typeof LoginTypes] -interface BaseLogin { - readonly loginType: LoginType -} - export type cacheChangedEvent = 'delete' | 'create' -export type Login = SsoLogin // TODO: add IamLogin type when supported +export type Login = SsoLogin | IamLogin export type TokenSource = IamIdentityCenterSsoTokenSource | AwsBuilderIdSsoTokenSource +export type IamProfileOptions = { + accessKey?: string + secretKey?: string + sessionToken?: string + roleArn?: string + sourceProfile?: string + process?: string +} + +const IamProfileOptionsDefaults = { + accessKey: '', + secretKey: '', + sessionToken: '', + roleArn: '', + sourceProfile: '', + process: '', +} satisfies IamProfileOptions + /** * Handles auth requests to the Identity Server in the Amazon Q LSP. */ @@ -90,12 +126,31 @@ export class LanguageClientAuth { return this.#ssoCacheWatcher } - getSsoToken( + /** + * Encrypts an object + */ + private async encrypt(request: T): Promise { + const payload = new TextEncoder().encode(JSON.stringify(request)) + const encrypted = await new jose.CompactEncrypt(payload) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .encrypt(this.encryptionKey) + return encrypted + } + + /** + * Decrypts an object + */ + private async decrypt(request: string): Promise { + const result = await jose.compactDecrypt(request, this.encryptionKey) + return JSON.parse(new TextDecoder().decode(result.plaintext)) as T + } + + async getSsoToken( tokenSource: TokenSource, login: boolean = false, cancellationToken?: CancellationToken ): Promise { - return this.client.sendRequest( + const response: GetSsoTokenResult = await this.client.sendRequest( getSsoTokenRequestType.method, { clientName: this.clientName, @@ -107,21 +162,55 @@ export class LanguageClientAuth { } satisfies GetSsoTokenParams, cancellationToken ) + // Decrypt the access token + response.ssoToken.accessToken = await this.decrypt(response.ssoToken.accessToken) + return response + } + + async getIamCredential( + profileName: string, + login: boolean = false, + cancellationToken?: CancellationToken + ): Promise { + const response: GetIamCredentialResult = await this.client.sendRequest( + getIamCredentialRequestType.method, + { + profileName: profileName, + options: { + callStsOnInvalidIamCredential: login, + }, + } satisfies GetIamCredentialParams, + cancellationToken + ) + // Decrypt the response credentials + const { accessKeyId, secretAccessKey, sessionToken, expiration } = response.credential.credentials + response.credential.credentials = { + accessKeyId: await this.decrypt(accessKeyId), + secretAccessKey: await this.decrypt(secretAccessKey), + sessionToken: sessionToken ? await this.decrypt(sessionToken) : undefined, + expiration: expiration, + } + return response } - updateProfile( + async updateSsoProfile( profileName: string, startUrl: string, region: string, scopes: string[] ): Promise { - return this.client.sendRequest(updateProfileRequestType.method, { + // Add SSO settings and delete credentials from profile + const params = await this.encrypt({ profile: { kinds: [ProfileKind.SsoTokenProfile], name: profileName, settings: { - region, + region: region, sso_session: profileName, + aws_access_key_id: '', + aws_secret_access_key: '', + role_arn: '', + credential_process: '', }, }, ssoSession: { @@ -132,15 +221,49 @@ export class LanguageClientAuth { sso_registration_scopes: scopes, }, }, - } satisfies UpdateProfileParams) + }) + return this.client.sendRequest(updateProfileRequestType.method, params) } - listProfiles() { - return this.client.sendRequest(listProfilesRequestType.method, {}) as Promise + async updateIamProfile(profileName: string, opts: IamProfileOptions): Promise { + // Substitute missing fields for defaults + const fields = { ...IamProfileOptionsDefaults, ...opts } + // Get the profile kind matching the provided fields + let kind: ProfileKind + if (fields.process) { + kind = ProfileKind.IamCredentialProcessProfile + } else if (fields.roleArn && fields.sourceProfile) { + kind = ProfileKind.IamSourceProfileProfile + } else if (fields.accessKey && fields.secretKey) { + kind = ProfileKind.IamCredentialsProfile + } else { + kind = ProfileKind.Unknown + } + + const params = await this.encrypt({ + profile: { + kinds: [kind], + name: profileName, + settings: { + aws_access_key_id: fields.accessKey, + aws_secret_access_key: fields.secretKey, + aws_session_token: fields.sessionToken, + role_arn: fields.roleArn, + source_profile: fields.sourceProfile, + credential_process: fields.process, + }, + }, + }) + return this.client.sendRequest(updateProfileRequestType.method, params) + } + + async listProfiles() { + const response: string = await this.client.sendRequest(listProfilesRequestType.method, {}) + return await this.decrypt(response) } /** - * Returns a profile by name along with its linked sso_session. + * Returns a profile by name along with its linked session. * Does not currently exist as an API in the Identity Service. */ async getProfile(profileName: string) { @@ -153,7 +276,7 @@ export class LanguageClientAuth { return { profile, ssoSession } } - updateBearerToken(request: UpdateCredentialsParams) { + updateBearerToken(request: UpdateCredentialsParams | undefined) { return this.client.sendRequest(bearerCredentialsUpdateRequestType.method, request) } @@ -161,16 +284,38 @@ export class LanguageClientAuth { return this.client.sendNotification(bearerCredentialsDeleteNotificationType.method) } + updateIamCredential(request: UpdateCredentialsParams | undefined) { + return this.client.sendRequest(iamCredentialsUpdateRequestType.method, request) + } + + deleteIamCredential() { + return this.client.sendNotification(iamCredentialsDeleteNotificationType.method) + } + invalidateSsoToken(tokenId: string) { return this.client.sendRequest(invalidateSsoTokenRequestType.method, { ssoTokenId: tokenId, } satisfies InvalidateSsoTokenParams) as Promise } + invalidateStsCredential(tokenId: string) { + return this.client.sendRequest(invalidateStsCredentialRequestType.method, { + iamCredentialId: tokenId, + } satisfies InvalidateStsCredentialParams) as Promise + } + registerSsoTokenChangedHandler(ssoTokenChangedHandler: (params: SsoTokenChangedParams) => any) { this.client.onNotification(ssoTokenChangedRequestType.method, ssoTokenChangedHandler) } + registerStsCredentialChangedHandler(stsCredentialChangedHandler: (params: StsCredentialChangedParams) => any) { + this.client.onNotification(stsCredentialChangedRequestType.method, stsCredentialChangedHandler) + } + + registerGetMfaCodeHandler(getMfaCodeHandler: (params: GetMfaCodeParams) => Promise) { + this.client.onRequest(getMfaCodeRequestType.method, getMfaCodeHandler) + } + registerCacheWatcher(cacheChangedHandler: (event: cacheChangedEvent) => any) { this.cacheWatcher.onDidCreate(() => cacheChangedHandler('create')) this.cacheWatcher.onDidDelete(() => cacheChangedHandler('delete')) @@ -178,30 +323,89 @@ export class LanguageClientAuth { } /** - * Manages an SSO connection. + * Abstract class for connection management */ -export class SsoLogin implements BaseLogin { - readonly loginType = LoginTypes.SSO - private readonly eventEmitter = new vscode.EventEmitter() - - // Cached information from the identity server for easy reference - private ssoTokenId: string | undefined - private connectionState: AuthState = 'notConnected' - private _data: { startUrl: string; region: string } | undefined - - private cancellationToken: CancellationTokenSource | undefined +export abstract class BaseLogin { + protected loginType: LoginType | undefined + protected connectionState: AuthState = 'notConnected' + protected cancellationToken: CancellationTokenSource | undefined + protected _data: + | { startUrl?: string; region?: string; accessKey?: string; secretKey?: string; sessionToken?: string } + | undefined constructor( public readonly profileName: string, - private readonly lspAuth: LanguageClientAuth - ) { - lspAuth.registerSsoTokenChangedHandler((params: SsoTokenChangedParams) => this.ssoTokenChangedHandler(params)) - } + protected readonly lspAuth: LanguageClientAuth, + protected readonly eventEmitter: vscode.EventEmitter + ) {} + + abstract login(opts: any): Promise + abstract reauthenticate(): Promise + abstract logout(): void + abstract restore(): void + abstract getCredential(): Promise<{ + credential: string | IamCredentials + updateCredentialsParams: UpdateCredentialsParams + }> get data() { return this._data } + /** + * Cancels running active login flows. + */ + cancelLogin() { + this.cancellationToken?.cancel() + this.cancellationToken?.dispose() + this.cancellationToken = undefined + } + + /** + * Gets the profile and session associated with a profile name + */ + async getProfile(): Promise<{ + profile: Profile | undefined + ssoSession: SsoSession | undefined + }> { + return await this.lspAuth.getProfile(this.profileName) + } + + /** + * Gets the current connection state + */ + getConnectionState(): AuthState { + return this.connectionState + } + + /** + * Sets the connection state and fires an event if the state changed + */ + protected updateConnectionState(state: AuthState) { + const oldState = this.connectionState + const newState = state + + this.connectionState = newState + + if (oldState !== newState) { + this.eventEmitter.fire({ id: this.profileName, state: this.connectionState }) + } + } +} + +/** + * Manages an SSO connection. + */ +export class SsoLogin extends BaseLogin { + // Cached information from the identity server for easy reference + override readonly loginType = LoginTypes.SSO + private ssoTokenId: string | undefined + + constructor(profileName: string, lspAuth: LanguageClientAuth, eventEmitter: vscode.EventEmitter) { + super(profileName, lspAuth, eventEmitter) + lspAuth.registerSsoTokenChangedHandler((params: SsoTokenChangedParams) => this.ssoTokenChangedHandler(params)) + } + async login(opts: { startUrl: string; region: string; scopes: string[] }) { await this.updateProfile(opts) return this._getSsoToken(true) @@ -215,6 +419,7 @@ export class SsoLogin implements BaseLogin { } async logout() { + this.lspAuth.deleteBearerToken() if (this.ssoTokenId) { await this.lspAuth.invalidateSsoToken(this.ssoTokenId) } @@ -223,12 +428,8 @@ export class SsoLogin implements BaseLogin { // TODO: DeleteProfile api in Identity Service (this doesn't exist yet) } - async getProfile() { - return await this.lspAuth.getProfile(this.profileName) - } - async updateProfile(opts: { startUrl: string; region: string; scopes: string[] }) { - await this.lspAuth.updateProfile(this.profileName, opts.startUrl, opts.region, opts.scopes) + await this.lspAuth.updateSsoProfile(this.profileName, opts.startUrl, opts.region, opts.scopes) this._data = { startUrl: opts.startUrl, region: opts.region, @@ -255,24 +456,14 @@ export class SsoLogin implements BaseLogin { } } - /** - * Cancels running active login flows. - */ - cancelLogin() { - this.cancellationToken?.cancel() - this.cancellationToken?.dispose() - this.cancellationToken = undefined - } - /** * Returns both the decrypted access token and the payload to send to the `updateCredentials` LSP API * with encrypted token */ - async getToken() { + async getCredential() { const response = await this._getSsoToken(false) - const decryptedKey = await jose.compactDecrypt(response.ssoToken.accessToken, this.lspAuth.encryptionKey) return { - token: decryptedKey.plaintext.toString().replaceAll('"', ''), + credential: response.ssoToken.accessToken, updateCredentialsParams: response.updateCredentialsParams, } } @@ -331,33 +522,172 @@ export class SsoLogin implements BaseLogin { return response } - getConnectionState() { - return this.connectionState + private ssoTokenChangedHandler(params: SsoTokenChangedParams) { + if (params.ssoTokenId === this.ssoTokenId) { + if (params.kind === SsoTokenChangedKind.Expired) { + this.updateConnectionState('expired') + return + } else if (params.kind === SsoTokenChangedKind.Refreshed) { + this.eventEmitter.fire({ id: this.profileName, state: 'refreshed' }) + } + } } +} + +/** + * Manages an IAM credentials connection. + */ +export class IamLogin extends BaseLogin { + // Cached information from the identity server for easy reference + override readonly loginType = LoginTypes.IAM + private iamCredentialId: string | undefined - onDidChangeConnectionState(handler: (e: AuthStateEvent) => any) { - return this.eventEmitter.event(handler) + constructor(profileName: string, lspAuth: LanguageClientAuth, eventEmitter: vscode.EventEmitter) { + super(profileName, lspAuth, eventEmitter) + lspAuth.registerStsCredentialChangedHandler((params: StsCredentialChangedParams) => + this.stsCredentialChangedHandler(params) + ) + lspAuth.registerGetMfaCodeHandler((params: GetMfaCodeParams) => this.getMfaCodeHandler(params)) } - private updateConnectionState(state: AuthState) { - const oldState = this.connectionState - const newState = state + async login(opts: IamProfileOptions) { + await this.updateProfile(opts) + return this._getIamCredential(true) + } - this.connectionState = newState + async reauthenticate() { + if (this.connectionState === 'notConnected') { + throw new ToolkitError('Cannot reauthenticate when not connected.') + } + return this._getIamCredential(true) + } - if (oldState !== newState) { - this.eventEmitter.fire({ id: this.profileName, state: this.connectionState }) + async logout() { + if (this.iamCredentialId) { + await this.lspAuth.invalidateStsCredential(this.iamCredentialId) } + await this.lspAuth.updateIamProfile(this.profileName, {}) + await this.lspAuth.updateIamProfile(this.profileName + '-source', {}) + this.updateConnectionState('notConnected') + this._data = undefined + // TODO: DeleteProfile api in Identity Service (this doesn't exist yet) } - private ssoTokenChangedHandler(params: SsoTokenChangedParams) { - if (params.ssoTokenId === this.ssoTokenId) { - if (params.kind === SsoTokenChangedKind.Expired) { + async updateProfile(opts: IamProfileOptions) { + if (opts.process) { + // Create the process profile + await this.lspAuth.updateIamProfile(this.profileName, { + process: opts.process, + }) + } else if (opts.roleArn) { + // Create the source and target profiles + const sourceProfile = this.profileName + '-source' + await this.lspAuth.updateIamProfile(sourceProfile, { + accessKey: opts.accessKey, + secretKey: opts.secretKey, + sessionToken: opts.sessionToken, + }) + await this.lspAuth.updateIamProfile(this.profileName, { + roleArn: opts.roleArn, + sourceProfile: sourceProfile, + }) + } else { + // Create the credentials profile + await this.lspAuth.updateIamProfile(this.profileName, { + accessKey: opts.accessKey, + secretKey: opts.secretKey, + sessionToken: opts.sessionToken, + }) + } + } + + /** + * Restore the connection state and connection details to memory, if they exist. + */ + async restore() { + try { + await this._getIamCredential(false) + } catch (err) { + getLogger().error('Restoring connection failed: %s', err) + } + } + + /** + * Returns both the decrypted IAM credential and the payload to send to the `updateCredentials` LSP API + * with encrypted credential + */ + async getCredential() { + const response = await this._getIamCredential(false) + return { + credential: response.credential.credentials, + updateCredentialsParams: response.updateCredentialsParams, + } + } + + /** + * Returns the response from `getSsoToken` LSP API and sets the connection state based on the errors/result + * of the call. + */ + private async _getIamCredential(login: boolean) { + let response: GetIamCredentialResult + this.cancellationToken = new CancellationTokenSource() + + try { + response = await this.lspAuth.getIamCredential(this.profileName, login, this.cancellationToken.token) + } catch (err: any) { + switch (err.data?.awsErrorCode) { + case AwsErrorCodes.E_CANCELLED: + case AwsErrorCodes.E_INVALID_PROFILE: + case AwsErrorCodes.E_PROFILE_NOT_FOUND: + case AwsErrorCodes.E_CANNOT_CREATE_STS_CREDENTIAL: + case AwsErrorCodes.E_INVALID_STS_CREDENTIAL: + this.updateConnectionState('notConnected') + break + default: + getLogger().error('IamLogin: unknown error when requesting token: %s', err) + break + } + throw err + } finally { + this.cancellationToken?.dispose() + this.cancellationToken = undefined + } + + // Update cached credentials and credential ID + if (response.credential.credentials.accessKeyId && response.credential.credentials.secretAccessKey) { + this._data = { + accessKey: response.credential.credentials.accessKeyId, + secretKey: response.credential.credentials.secretAccessKey, + sessionToken: response.credential.credentials.sessionToken, + } + this.iamCredentialId = response.credential.id + } + this.updateConnectionState('connected') + return response + } + + private stsCredentialChangedHandler(params: StsCredentialChangedParams) { + if (params.stsCredentialId === this.iamCredentialId) { + if (params.kind === StsCredentialChangedKind.Expired) { this.updateConnectionState('expired') return - } else if (params.kind === SsoTokenChangedKind.Refreshed) { - this.eventEmitter.fire({ id: this.profileName, state: 'refreshed' }) + } else if (params.kind === StsCredentialChangedKind.Refreshed) { + this.eventEmitter.fire({ id: this.iamCredentialId, state: 'refreshed' }) } } } + + private async getMfaCodeHandler(params: GetMfaCodeParams): Promise { + if (params.mfaSerial) { + await globals.globalState.update('recentMfaSerial', { mfaSerial: params.mfaSerial }) + } + const defaultMfaSerial = globals.globalState.tryGet('recentMfaSerial', Object, { + mfaSerial: '', + }).mfaSerial + let mfaSerial = await getMfaSerialFromUser(defaultMfaSerial, params.profileName) + mfaSerial = mfaSerial.trim() + await globals.globalState.update('recentMfaSerial', { mfaSerial: mfaSerial }) + const mfaCode = await getMfaTokenFromUser(mfaSerial, params.profileName) + return { code: mfaCode ?? '', mfaSerial: mfaSerial ?? '' } + } } diff --git a/packages/core/src/auth/credentials/utils.ts b/packages/core/src/auth/credentials/utils.ts index 885a4fb1f87..4f4eda5027c 100644 --- a/packages/core/src/auth/credentials/utils.ts +++ b/packages/core/src/auth/credentials/utils.ts @@ -103,14 +103,35 @@ export class CredentialsSettings extends fromExtensionManifest('aws', { profile: const errorMessageUserCancelled = localize('AWS.error.mfa.userCancelled', 'User cancelled entering authentication code') /** - * @description Prompts user for MFA token + * @description Prompts user for MFA serial number * - * Entered token is passed to the callback. - * If user cancels out, the callback is passed an error with a fixed message string. + * @param defaultSerial Default MFA serial number to pre-fill + * @param profileName Name of Credentials profile we are asking an MFA serial for + */ +export async function getMfaSerialFromUser(defaultSerial: string, profileName: string): Promise { + const inputBox = createInputBox({ + ignoreFocusOut: true, + placeholder: localize('AWS.prompt.mfa.enterCode.placeholder', 'Enter mfaSerial Number Here'), + title: localize('AWS.prompt.mfa.enterCode.title', 'MFA Challenge for {0}', profileName), + prompt: localize('AWS.prompt.mfa.enterCode.prompt', 'Enter Serial Number for MFA device', defaultSerial), + value: defaultSerial, // Pre-fill with default value + }) + + const token = await inputBox.prompt() + + // Distinguish user cancel vs code entry issues with the error message + if (!isValidResponse(token)) { + throw new Error(errorMessageUserCancelled) + } + + return token +} + +/** + * @description Prompts user for MFA token * * @param mfaSerial Serial arn of MFA device * @param profileName Name of Credentials profile we are asking an MFA Token for - * @param callback tokens/errors are passed through here */ export async function getMfaTokenFromUser(mfaSerial: string, profileName: string): Promise { const inputBox = createInputBox({ diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 2dd361f9804..727277f0fcd 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -22,6 +22,7 @@ export { } from './connection' export { Auth } from './auth' export { CredentialsStore } from './credentials/store' +export { getMfaTokenFromUser, getMfaSerialFromUser } from './credentials/utils' export { LoginManager } from './deprecated/loginManager' export * as constants from './sso/constants' export * as cache from './sso/cache' diff --git a/packages/core/src/codewhisperer/client/codewhisperer.ts b/packages/core/src/codewhisperer/client/codewhisperer.ts index 0a473dfdccd..0459be92d96 100644 --- a/packages/core/src/codewhisperer/client/codewhisperer.ts +++ b/packages/core/src/codewhisperer/client/codewhisperer.ts @@ -110,7 +110,7 @@ export class DefaultCodeWhispererClient { resp.error?.code === 'AccessDeniedException' && resp.error.message.match(/expired/i) ) { - AuthUtil.instance.reauthenticate().catch((e) => { + AuthUtil.instance.reauthenticate()?.catch((e) => { getLogger().error('reauthenticate failed: %s', (e as Error).message) }) resp.error.retryable = true diff --git a/packages/core/src/codewhisperer/ui/codeWhispererNodes.ts b/packages/core/src/codewhisperer/ui/codeWhispererNodes.ts index 1d7d6278d79..70facac1ee0 100644 --- a/packages/core/src/codewhisperer/ui/codeWhispererNodes.ts +++ b/packages/core/src/codewhisperer/ui/codeWhispererNodes.ts @@ -192,7 +192,11 @@ export function createManageSubscription(): DataQuickPickItem<'manageSubscriptio export function createSignout(): DataQuickPickItem<'signout'> { const label = localize('AWS.codewhisperer.signoutNode.label', 'Sign Out') const icon = getIcon('vscode-export') - const connection = AuthUtil.instance.isBuilderIdConnection() ? 'AWS Builder ID' : 'IAM Identity Center' + const connection = AuthUtil.instance.isIamSession() + ? 'IAM Credentials' + : AuthUtil.instance.isBuilderIdConnection() + ? 'AWS Builder ID' + : 'IAM Identity Center' return { data: 'signout', @@ -271,7 +275,7 @@ export function createSignIn(): DataQuickPickItem<'signIn'> { if (isWeb()) { // TODO: nkomonen, call a Command instead onClick = () => { - void AuthUtil.instance.login(builderIdStartUrl, builderIdRegion) + void AuthUtil.instance.loginSso(builderIdStartUrl, builderIdRegion) } } diff --git a/packages/core/src/codewhisperer/util/authUtil.ts b/packages/core/src/codewhisperer/util/authUtil.ts index 1419eaa4772..487cecd2266 100644 --- a/packages/core/src/codewhisperer/util/authUtil.ts +++ b/packages/core/src/codewhisperer/util/authUtil.ts @@ -30,7 +30,17 @@ import { showAmazonQWalkthroughOnce } from '../../amazonq/onboardingPage/walkthr import { setContext } from '../../shared/vscode/setContext' import { openUrl } from '../../shared/utilities/vsCodeUtils' import { telemetry } from '../../shared/telemetry/telemetry' -import { AuthStateEvent, cacheChangedEvent, LanguageClientAuth, LoginTypes, SsoLogin } from '../../auth/auth2' +import { + AuthStateEvent, + cacheChangedEvent, + LanguageClientAuth, + Login, + SsoLogin, + IamLogin, + AuthState, + LoginTypes, + IamProfileOptions, +} from '../../auth/auth2' import { builderIdStartUrl, internalStartUrl } from '../../auth/sso/constants' import { VSCODE_EXTENSION_ID } from '../../shared/extensions' import { RegionProfileManager } from '../region/regionProfileManager' @@ -39,7 +49,13 @@ import { getEnvironmentSpecificMemento } from '../../shared/utilities/mementos' import { getCacheDir, getFlareCacheFileName, getRegistrationCacheFile, getTokenCacheFile } from '../../auth/sso/cache' import { notifySelectDeveloperProfile } from '../region/utils' import { once } from '../../shared/utilities/functionUtils' -import { CancellationTokenSource, SsoTokenSourceKind } from '@aws/language-server-runtimes/server-interface' +import { + CancellationTokenSource, + GetSsoTokenResult, + GetIamCredentialResult, + SsoTokenSourceKind, + IamCredentials, +} from '@aws/language-server-runtimes/server-interface' const localize = nls.loadMessageBundle() @@ -54,9 +70,18 @@ export interface IAuthProvider { isBuilderIdConnection(): boolean isIdcConnection(): boolean isSsoSession(): boolean + isIamSession(): boolean getToken(): Promise + getIamCredential(): Promise readonly profileName: string - readonly connection?: { region: string; startUrl: string } + readonly connection?: { + startUrl?: string + region?: string + accessKey?: string + secretKey?: string + sessionToken?: string + roleArn?: string + } } /** @@ -69,8 +94,8 @@ export class AuthUtil implements IAuthProvider { public readonly regionProfileManager: RegionProfileManager - // IAM login currently not supported - private session: SsoLogin + private session?: Login + private readonly eventEmitter = new vscode.EventEmitter() static create(lspAuth: LanguageClientAuth) { return (this.#instance ??= new this(lspAuth)) @@ -85,7 +110,6 @@ export class AuthUtil implements IAuthProvider { } private constructor(private readonly lspAuth: LanguageClientAuth) { - this.session = new SsoLogin(this.profileName, this.lspAuth) this.onDidChangeConnectionState((e: AuthStateEvent) => this.stateChangeHandler(e)) this.regionProfileManager = new RegionProfileManager(this) @@ -100,8 +124,12 @@ export class AuthUtil implements IAuthProvider { this.#instance = undefined as any } - isSsoSession() { - return this.session.loginType === LoginTypes.SSO + isSsoSession(): boolean { + return this.session?.loginType === LoginTypes.SSO + } + + isIamSession(): boolean { + return this.session?.loginType === LoginTypes.IAM } /** @@ -113,7 +141,24 @@ export class AuthUtil implements IAuthProvider { didStartSignedIn = false async restore() { - await this.session.restore() + // If a session exists, restore it + if (this.session) { + await this.session.restore() + } else { + // Try to restore an SSO session + this.session = new SsoLogin(this.profileName, this.lspAuth, this.eventEmitter) + await this.session.restore() + if (!this.isConnected()) { + await this.session?.logout() + // Try to restore an IAM session + this.session = new IamLogin(this.profileName, this.lspAuth, this.eventEmitter) + await this.session.restore() + if (!this.isConnected()) { + // If both fail, reset the session + await this.session?.logout() + } + } + } this.didStartSignedIn = this.isConnected() // HACK: We noticed that if calling `refreshState()` here when the user was already signed in, something broke. @@ -133,10 +178,29 @@ export class AuthUtil implements IAuthProvider { } } - async login(startUrl: string, region: string) { - const response = await this.session.login({ startUrl, region, scopes: amazonQScopes }) + // Log in using SSO + async loginSso(startUrl: string, region: string): Promise { + // Create SSO login session + if (!this.isSsoSession()) { + this.session = new SsoLogin(this.profileName, this.lspAuth, this.eventEmitter) + } + const response = await (this.session as SsoLogin).login({ + startUrl: startUrl, + region: region, + scopes: amazonQScopes, + }) await showAmazonQWalkthroughOnce() + return response + } + // Log in using IAM or STS credentials + async loginIam(opts: IamProfileOptions): Promise { + // Create IAM login session + if (!this.isIamSession()) { + this.session = new IamLogin(this.profileName, this.lspAuth, this.eventEmitter) + } + const response = await (this.session as IamLogin).login(opts) + await showAmazonQWalkthroughOnce() return response } @@ -145,32 +209,43 @@ export class AuthUtil implements IAuthProvider { throw new ToolkitError('Cannot reauthenticate non-SSO session.') } - return this.session.reauthenticate() + return this.session?.reauthenticate() } logout() { - if (!this.isSsoSession()) { - // Only SSO requires logout - return - } - this.lspAuth.deleteBearerToken() - return this.session.logout() + // session will be nullified the next time refreshState() is called + return this.session?.logout() } async getToken() { if (this.isSsoSession()) { - return (await this.session.getToken()).token + const response = await this.session!.getCredential() + return response.credential as string + } else { + throw new ToolkitError('Cannot get credential without logging in with SSO.') + } + } + + async getIamCredential() { + if (this.isIamSession()) { + const response = await this.session!.getCredential() + return response.credential as IamCredentials } else { - throw new ToolkitError('Cannot get token for non-SSO session.') + throw new ToolkitError('Cannot get credential without logging in with IAM.') } } get connection() { - return this.session.data + return this.session?.data } - getAuthState() { - return this.session.getConnectionState() + getAuthState(): AuthState { + // Check if getConnectionState exists in case of type casts + if (typeof this.session?.getConnectionState === 'function') { + return this.session!.getConnectionState() + } else { + return 'notConnected' + } } isConnected() { @@ -194,7 +269,7 @@ export class AuthUtil implements IAuthProvider { } onDidChangeConnectionState(handler: (e: AuthStateEvent) => any) { - return this.session.onDidChangeConnectionState(handler) + return this.eventEmitter.event(handler) } public async setVscodeContextProps(state = this.getAuthState()) { @@ -290,9 +365,13 @@ export class AuthUtil implements IAuthProvider { private async stateChangeHandler(e: AuthStateEvent) { if (e.state === 'refreshed') { - const params = this.isSsoSession() ? (await this.session.getToken()).updateCredentialsParams : undefined - await this.lspAuth.updateBearerToken(params!) - return + if (this.isSsoSession()) { + const params = await this.session!.getCredential() + await this.lspAuth.updateBearerToken(params.updateCredentialsParams) + } else if (this.isIamSession()) { + const params = await this.session!.getCredential() + await this.lspAuth.updateIamCredential(params.updateCredentialsParams) + } } else { this.logger.info(`codewhisperer: connection changed to ${e.state}`) await this.refreshState(e.state) @@ -301,15 +380,26 @@ export class AuthUtil implements IAuthProvider { private async refreshState(state = this.getAuthState()) { if (state === 'expired' || state === 'notConnected') { - this.lspAuth.deleteBearerToken() + if (this.isSsoSession()) { + this.lspAuth.deleteBearerToken() + } else if (this.isIamSession()) { + this.lspAuth.deleteIamCredential() + } if (this.isIdcConnection()) { await this.regionProfileManager.invalidateProfile(this.regionProfileManager.activeRegionProfile?.arn) await this.regionProfileManager.clearCache() } + // Session should only be nullified after all methods dependent on session are evaluated + this.session = undefined } if (state === 'connected') { - const bearerTokenParams = (await this.session.getToken()).updateCredentialsParams - await this.lspAuth.updateBearerToken(bearerTokenParams) + if (this.isSsoSession()) { + const params = await this.session!.getCredential() + await this.lspAuth.updateBearerToken(params.updateCredentialsParams) + } else if (this.isIamSession()) { + const params = await this.session!.getCredential() + await this.lspAuth.updateIamCredential(params.updateCredentialsParams) + } if (this.isIdcConnection()) { await this.regionProfileManager.restoreProfileSelection() @@ -345,14 +435,14 @@ export class AuthUtil implements IAuthProvider { } if (this.isSsoSession()) { - const ssoSessionDetails = (await this.session.getProfile()).ssoSession?.settings + const ssoSessionDetails = (await this.session!.getProfile()).ssoSession?.settings return { authScopes: ssoSessionDetails?.sso_registration_scopes?.join(','), credentialSourceId: AuthUtil.instance.isBuilderIdConnection() ? 'awsId' : 'iamIdentityCenter', credentialStartUrl: AuthUtil.instance.connection?.startUrl, awsRegion: AuthUtil.instance.connection?.region, } - } else if (!AuthUtil.instance.isSsoSession) { + } else if (this.isIamSession()) { return { credentialSourceId: 'sharedCredentials', } @@ -376,7 +466,7 @@ export class AuthUtil implements IAuthProvider { connType = 'builderId' } else if (this.isIdcConnection()) { connType = 'identityCenter' - const ssoSessionDetails = (await this.session.getProfile()).ssoSession?.settings + const ssoSessionDetails = (await this.session!.getProfile()).ssoSession?.settings if (hasScopes(ssoSessionDetails?.sso_registration_scopes ?? [], scopesSsoAccountAccess)) { authIds.push('identityCenterExplorer') } @@ -446,7 +536,10 @@ export class AuthUtil implements IAuthProvider { scopes: amazonQScopes, } - await this.session.updateProfile(registrationKey) + if (!this.isSsoSession()) { + this.session = new SsoLogin(this.profileName, this.lspAuth, this.eventEmitter) + } + await (this.session as SsoLogin).updateProfile(registrationKey) const cacheDir = getCacheDir() diff --git a/packages/core/src/codewhisperer/util/getStartUrl.ts b/packages/core/src/codewhisperer/util/getStartUrl.ts index f1db38f5f1f..851cd28554f 100644 --- a/packages/core/src/codewhisperer/util/getStartUrl.ts +++ b/packages/core/src/codewhisperer/util/getStartUrl.ts @@ -29,7 +29,7 @@ export const getStartUrl = async () => { export async function connectToEnterpriseSso(startUrl: string, region: Region['id']) { try { - await AuthUtil.instance.login(startUrl, region) + await AuthUtil.instance.loginSso(startUrl, region) } catch (e) { throw ToolkitError.chain(e, CodeWhispererConstants.failedToConnectIamIdentityCenter, { code: 'FailedToConnect', diff --git a/packages/core/src/codewhisperer/util/showSsoPrompt.ts b/packages/core/src/codewhisperer/util/showSsoPrompt.ts index b3d78654745..15dd2b889ac 100644 --- a/packages/core/src/codewhisperer/util/showSsoPrompt.ts +++ b/packages/core/src/codewhisperer/util/showSsoPrompt.ts @@ -47,7 +47,7 @@ export const showCodeWhispererConnectionPrompt = async () => { export async function awsIdSignIn() { getLogger().info('selected AWS ID sign in') try { - await AuthUtil.instance.login(builderIdStartUrl, builderIdRegion) + await AuthUtil.instance.loginSso(builderIdStartUrl, builderIdRegion) } catch (e) { throw ToolkitError.chain(e, failedToConnectAwsBuilderId, { code: 'FailedToConnect' }) } diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index c32f67cdac5..ef0aad6ec25 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -41,7 +41,6 @@ export class ChatSession { } async chatIam(chatRequest: SendMessageRequest): Promise { const client = await createQDeveloperStreamingClient() - const response = await client.sendMessage(chatRequest) if (!response.sendMessageResponse) { throw new ToolkitError( diff --git a/packages/core/src/codewhispererChat/index.ts b/packages/core/src/codewhispererChat/index.ts index b47115fbc4a..a008cd13d72 100644 --- a/packages/core/src/codewhispererChat/index.ts +++ b/packages/core/src/codewhispererChat/index.ts @@ -12,6 +12,6 @@ export { ChatTriggerType, PromptMessage, TriggerPayload } from './controllers/ch export { UserIntentRecognizer } from './controllers/chat/userIntent/userIntentRecognizer' export { EditorContextExtractor } from './editor/context/extractor' export { ChatSessionStorage } from './storages/chatSession' -export { TriggerEventsStorage } from './storages/triggerEvents' +export { TriggerEventsStorage, TriggerEvent } from './storages/triggerEvents' export { ReferenceLogController } from './view/messages/referenceLogController' export { extractLanguageNameFromFile } from './editor/context/file/languages' diff --git a/packages/core/src/login/webview/vue/amazonq/backend_amazonq.ts b/packages/core/src/login/webview/vue/amazonq/backend_amazonq.ts index 0a9dd576d6f..2d73ce23078 100644 --- a/packages/core/src/login/webview/vue/amazonq/backend_amazonq.ts +++ b/packages/core/src/login/webview/vue/amazonq/backend_amazonq.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import * as vscode from 'vscode' -import { AwsConnection, SsoConnection } from '../../../../auth/connection' +import { AwsConnection, IamProfile, SsoConnection } from '../../../../auth/connection' import { AuthUtil } from '../../../../codewhisperer/util/authUtil' import { CommonAuthWebview } from '../backend' import { awsIdSignIn } from '../../../../codewhisperer/util/showSsoPrompt' @@ -15,11 +15,12 @@ import { debounce } from 'lodash' import { AuthError, AuthFlowState, userCancelled } from '../types' import { ToolkitError } from '../../../../shared/errors' import { withTelemetryContext } from '../../../../shared/telemetry/util' +import { Commands } from '../../../../shared/vscode/commands2' import { builderIdStartUrl } from '../../../../auth/sso/constants' -import { RegionProfile } from '../../../../codewhisperer/models/model' +import { RegionProfile, vsCodeState } from '../../../../codewhisperer/models/model' import { randomUUID } from '../../../../shared/crypto' import globals from '../../../../shared/extensionGlobals' -import { telemetry } from '../../../../shared/telemetry/telemetry' +import { CredentialType, telemetry } from '../../../../shared/telemetry/telemetry' import { ProfileSwitchIntent } from '../../../../codewhisperer/region/regionProfileManager' const className = 'AmazonQLoginWebview' @@ -173,10 +174,6 @@ export class AmazonQLoginWebview extends CommonAuthWebview { @withTelemetryContext({ name: 'signout', class: className }) override async signout(): Promise { - if (!AuthUtil.instance.isSsoSession()) { - throw new ToolkitError(`Cannot signout non-SSO connection`) - } - this.storeMetricMetadata({ authEnabledFeatures: 'codewhisperer', isReAuth: true, @@ -196,12 +193,62 @@ export class AmazonQLoginWebview extends CommonAuthWebview { return [] } - override startIamCredentialSetup( + async startIamCredentialSetup( profileName: string, accessKey: string, - secretKey: string + secretKey: string, + sessionToken?: string, + roleArn?: string, + process?: string ): Promise { - throw new Error('Method not implemented.') + getLogger().debug(`called startIamCredentialSetup()`) + // Defining separate auth function to emit telemetry before returning from this method + await globals.globalState.update('recentIamKeys', { accessKey: accessKey }) + await globals.globalState.update('recentRoleArn', { roleArn: roleArn }) + let credentialsType: CredentialType | undefined + if (!sessionToken && !roleArn) { + credentialsType = 'staticProfile' + } else if (roleArn) { + credentialsType = 'assumeRoleProfile' + } else { + credentialsType = 'staticSessionProfile' + } + + const runAuth = async (): Promise => { + try { + await AuthUtil.instance.loginIam({ accessKey, secretKey, sessionToken, roleArn, process }) + } catch (e) { + getLogger().error('Failed submitting credentials %O', e) + const message = e instanceof Error ? e.message : (e as string) + return { id: this.id, text: message } + } + // Enable code suggestions + vsCodeState.isFreeTierLimitReached = false + await Commands.tryExecute('aws.amazonq.enableCodeSuggestions') + + this.storeMetricMetadata(await AuthUtil.instance.getTelemetryMetadata()) + + void vscode.window.showInformationMessage('AmazonQ: Successfully connected to AWS IAM Credentials') + } + + const result = await runAuth() + this.storeMetricMetadata({ + credentialSourceId: 'sharedCredentials', + featureId: 'codewhisperer', + credentialType: credentialsType, + isReAuth: false, + isAggregated: false, + ...this.getResultForMetrics(result), + }) + this.emitAuthMetric() + + return result + } + + async listIamCredentialProfiles(): Promise { + // Amazon Q only supports 1 connection at a time, + // so there isn't a need to de-duplicate connections. + return [] } /** If users are unauthenticated in Q/CW, we should always display the auth screen. */ diff --git a/packages/core/src/login/webview/vue/backend.ts b/packages/core/src/login/webview/vue/backend.ts index edb1980a8c0..93140de3938 100644 --- a/packages/core/src/login/webview/vue/backend.ts +++ b/packages/core/src/login/webview/vue/backend.ts @@ -19,6 +19,7 @@ import { scopesCodeWhispererChat, scopesSsoAccountAccess, SsoConnection, + IamProfile, TelemetryMetadata, } from '../../../auth/connection' import { Auth } from '../../../auth/auth' @@ -33,6 +34,7 @@ import { getLogger } from '../../../shared/logger/logger' import { isValidUrl } from '../../../shared/utilities/uriUtils' import { RegionProfile } from '../../../codewhisperer/models/model' import { ProfileSwitchIntent } from '../../../codewhisperer/region/regionProfileManager' +import { showMessage } from '../../../shared/utilities/messages' export abstract class CommonAuthWebview extends VueWebview { private readonly className = 'CommonAuthWebview' @@ -173,7 +175,10 @@ export abstract class CommonAuthWebview extends VueWebview { abstract startIamCredentialSetup( profileName: string, accessKey: string, - secretKey: string + secretKey: string, + sessionToken?: string, + roleArn?: string, + process?: string ): Promise async showResourceExplorer(): Promise { @@ -183,7 +188,7 @@ export abstract class CommonAuthWebview extends VueWebview { abstract fetchConnections(): Promise async errorNotification(e: AuthError) { - void vscode.window.showInformationMessage(`${e.text}`) + showMessage('error', e.text) } abstract quitLoginScreen(): Promise @@ -207,6 +212,8 @@ export abstract class CommonAuthWebview extends VueWebview { abstract listRegionProfiles(): Promise + abstract listIamCredentialProfiles(): Promise + abstract selectRegionProfile(profile: RegionProfile, source: ProfileSwitchIntent): Promise /** @@ -296,6 +303,19 @@ export abstract class CommonAuthWebview extends VueWebview { return globals.globalState.tryGet('recentSso', Object, { startUrl: '', region: 'us-east-1' }) } + getDefaultIamKeys(): { accessKey: string } { + const devSettings = DevSettings.instance.get('autofillAccessKey', '') + if (devSettings) { + return { accessKey: devSettings } + } + + return globals.globalState.tryGet('recentIamKeys', Object, { accessKey: '' }) + } + + getDefaultRoleArn(): { roleArn: string } { + return globals.globalState.tryGet('recentRoleArn', Object, { roleArn: '' }) + } + cancelAuthFlow() { AuthSSOServer.lastInstance?.cancelCurrentFlow() } diff --git a/packages/core/src/login/webview/vue/login.vue b/packages/core/src/login/webview/vue/login.vue index 312aa18029b..e092f7cf13a 100644 --- a/packages/core/src/login/webview/vue/login.vue +++ b/packages/core/src/login/webview/vue/login.vue @@ -123,6 +123,16 @@ :itemType="LoginOption.ENTERPRISE_SSO" class="selectable-item bottomMargin" > +
-
+
Connecting to IAM...
Authenticating in browser...
@@ -238,18 +248,20 @@
IAM Credentials:
-
Credentials will be added to the appropriate ~/.aws/ files
-
Profile Name
-
The identifier for these credentials
- -
Access Key
+
+
Credentials will be added to the appropriate ~/.aws/ files
+
Profile Name
+
The identifier for these credentials
+ +
+
Access Key ID
-
Secret Key
+
Secret Access Key
+
+
Session Token (Optional)
+ +
Role ARN (Optional)
+ +
Credentials Process (Optional)
+ +
@@ -318,6 +359,10 @@ interface ImportedLogin { type: number startUrl: string region: string + // Add IAM credential fields + profileName?: string + accessKey?: string + secretKey?: string // Note: storing secrets has security implications } export default defineComponent({ @@ -337,6 +382,7 @@ export default defineComponent({ data() { return { existingStartUrls: [] as string[], + existingIamAccessKeys: [] as string[], importedLogins: [] as ImportedLogin[], selectedLoginOption: LoginOption.NONE, stage: 'START' as Stage, @@ -350,12 +396,19 @@ export default defineComponent({ profileName: '', accessKey: '', secretKey: '', + sessionToken: '', + roleArn: '', + process: '', } }, async created() { const defaultSso = await this.getDefaultSso() this.startUrl = defaultSso.startUrl this.selectedRegion = defaultSso.region + const defaultIamAccessKey = await this.getDefaultIamAccessKey() + const defaultRoleArn = await this.getDefaultRoleArn() + this.accessKey = defaultIamAccessKey.accessKey + this.roleArn = defaultRoleArn.roleArn await this.emitUpdate('created') }, @@ -385,6 +438,10 @@ export default defineComponent({ } }, handleDocumentClick(event: any) { + // Only reset selection when in START stage to avoid clearing during authentication + if (this.stage !== 'START') { + return + } const isClickInsideSelectableItems = event.target.closest('.selectable-item') if (!isClickInsideSelectableItems) { this.selectedLoginOption = 0 @@ -425,19 +482,37 @@ export default defineComponent({ const selectedConnection = this.importedLogins[this.selectedLoginOption - LoginOption.IMPORTED_LOGINS] - // Imported connections cannot be Builder IDs, they are filtered out in the client. - const error = await client.startEnterpriseSetup( - selectedConnection.startUrl, - selectedConnection.region, - this.app - ) - if (error) { - this.stage = 'START' - void client.errorNotification(error) - } else { - this.stage = 'CONNECTED' + // Handle both SSO and IAM imported connections + if (selectedConnection.type === LoginOption.ENTERPRISE_SSO) { + const error = await client.startEnterpriseSetup( + selectedConnection.startUrl, + selectedConnection.region, + this.app + ) + if (error) { + this.stage = 'START' + void client.errorNotification(error) + } else { + this.stage = 'CONNECTED' + } + } else if (selectedConnection.type === LoginOption.IAM_CREDENTIAL) { + // Use stored IAM credentials + const error = await client.startIamCredentialSetup( + selectedConnection.profileName || '', + selectedConnection.accessKey || '', + selectedConnection.secretKey || '' + ) + if (error) { + this.stage = 'START' + void client.errorNotification(error) + } else { + this.stage = 'CONNECTED' + } } } else if (this.selectedLoginOption === LoginOption.IAM_CREDENTIAL) { + // Emit telemetry when IAM Credentials option is selected and Continue is clicked + void client.emitUiClick('auth_credentialsOption') + this.stage = 'AWS_PROFILE' this.$nextTick(() => document.getElementById('profileName')!.focus()) } @@ -459,7 +534,14 @@ export default defineComponent({ return } this.stage = 'AUTHENTICATING' - const error = await client.startIamCredentialSetup(this.profileName, this.accessKey, this.secretKey) + const error = await client.startIamCredentialSetup( + this.profileName, + this.accessKey, + this.secretKey, + this.sessionToken, + this.roleArn, + this.process + ) if (error) { this.stage = 'START' void client.errorNotification(error) @@ -569,6 +651,12 @@ export default defineComponent({ async getDefaultSso() { return await client.getDefaultSsoProfile() }, + async getDefaultIamAccessKey() { + return await client.getDefaultIamKeys() + }, + async getDefaultRoleArn() { + return await client.getDefaultRoleArn() + }, handleHelpLinkClick() { void client.emitUiClick('auth_helpLink') }, @@ -587,7 +675,11 @@ export default defineComponent({ return this.startUrl.length == 0 || this.startUrlError.length > 0 || !this.selectedRegion }, shouldDisableIamContinue() { - return this.profileName.length <= 0 || this.accessKey.length <= 0 || this.secretKey.length <= 0 + if (this.app === 'TOOLKIT') { + return this.profileName.length <= 0 || this.accessKey.length <= 0 || this.secretKey.length <= 0 + } else { + return (this.accessKey.length <= 0 || this.secretKey.length <= 0) && this.process.length <= 0 + } }, }, }) diff --git a/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts b/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts index caec2c764bc..fb108fab8a8 100644 --- a/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts +++ b/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts @@ -9,6 +9,7 @@ import { getLogger } from '../../../../shared/logger/logger' import { CommonAuthWebview } from '../backend' import { AwsConnection, + IamProfile, SsoConnection, TelemetryMetadata, createSsoProfile, @@ -90,6 +91,9 @@ export class ToolkitLoginWebview extends CommonAuthWebview { secretKey: string ): Promise { getLogger().debug(`called startIamCredentialSetup()`) + await globals.globalState.update('recentIamKeys', { + accessKey: accessKey, + }) // See submitData() in manageCredentials.vue const runAuth = async () => { const data = { aws_access_key_id: accessKey, aws_secret_access_key: secretKey } @@ -157,6 +161,10 @@ export class ToolkitLoginWebview extends CommonAuthWebview { return (await Auth.instance.listConnections()).filter((conn) => isSsoConnection(conn)) as SsoConnection[] } + async listIamCredentialProfiles(): Promise { + return [] + } + override reauthenticateConnection(): Promise { throw new Error('Method not implemented.') } diff --git a/packages/core/src/shared/clients/qDeveloperChatClient.ts b/packages/core/src/shared/clients/qDeveloperChatClient.ts index d9344b5b406..547591a5faf 100644 --- a/packages/core/src/shared/clients/qDeveloperChatClient.ts +++ b/packages/core/src/shared/clients/qDeveloperChatClient.ts @@ -6,13 +6,12 @@ import { QDeveloperStreaming } from '@amzn/amazon-q-developer-streaming-client' import { getCodewhispererConfig } from '../../codewhisperer/client/codewhisperer' import { getUserAgent } from '../telemetry/util' import { ConfiguredRetryStrategy } from '@smithy/util-retry' +import { AuthUtil } from '../../codewhisperer' // Create a client for featureDev streaming based off of aws sdk v3 export async function createQDeveloperStreamingClient(): Promise { - throw new Error('Do not call this function until IAM is supported by LSP identity server') - const cwsprConfig = getCodewhispererConfig() - const credentials = undefined + const credentials = await AuthUtil.instance.getIamCredential() const streamingClient = new QDeveloperStreaming({ region: cwsprConfig.region, endpoint: cwsprConfig.endpoint, diff --git a/packages/core/src/shared/featureConfig.ts b/packages/core/src/shared/featureConfig.ts index 7cc6a9cbfc7..42ffb15ad66 100644 --- a/packages/core/src/shared/featureConfig.ts +++ b/packages/core/src/shared/featureConfig.ts @@ -118,7 +118,7 @@ export class FeatureConfigProvider { } async fetchFeatureConfigs(): Promise { - if (AuthUtil.instance.isConnectionExpired()) { + if (AuthUtil.instance.isConnectionExpired() || AuthUtil.instance.isIamSession()) { return } diff --git a/packages/core/src/shared/globalState.ts b/packages/core/src/shared/globalState.ts index 65d761412b8..41712b30e87 100644 --- a/packages/core/src/shared/globalState.ts +++ b/packages/core/src/shared/globalState.ts @@ -71,6 +71,9 @@ export type globalKey = | 'lastOsStartTime' | 'recentCredentials' | 'recentSso' + | 'recentIamKeys' + | 'recentRoleArn' + | 'recentMfaSerial' // List of regions enabled in AWS Explorer. | 'region' // TODO: implement this via `PromptSettings` instead of globalState. diff --git a/packages/core/src/shared/settings.ts b/packages/core/src/shared/settings.ts index 4e3e99f8207..96b19e3cbf5 100644 --- a/packages/core/src/shared/settings.ts +++ b/packages/core/src/shared/settings.ts @@ -779,7 +779,9 @@ const devSettings = { amazonqLsp: Record(String, String), amazonqWorkspaceLsp: Record(String, String), ssoCacheDirectory: String, + stsCacheDirectory: String, autofillStartUrl: String, + autofillAccessKey: String, webAuth: Boolean, notificationsPollInterval: Number, } diff --git a/packages/core/src/test/amazonqDoc/utils.ts b/packages/core/src/test/amazonqDoc/utils.ts index d6d74e7ac3c..37c2252918c 100644 --- a/packages/core/src/test/amazonqDoc/utils.ts +++ b/packages/core/src/test/amazonqDoc/utils.ts @@ -107,6 +107,8 @@ export async function sessionWriteFile(session: Session, uri: vscode.Uri, encode export function createMockAuthUtil(sandbox: sinon.SinonSandbox) { const mockLspAuth: Partial = { registerSsoTokenChangedHandler: sinon.stub().resolves(), + registerStsCredentialChangedHandler: sinon.stub().resolves(), + registerGetMfaCodeHandler: sinon.stub().resolves(), } AuthUtil.create(mockLspAuth as LanguageClientAuth) sandbox.stub(AuthUtil.instance.regionProfileManager, 'onDidChangeRegionProfile').resolves() diff --git a/packages/core/src/test/credentials/auth2.test.ts b/packages/core/src/test/credentials/auth2.test.ts index 3f3df667d21..0e0000dc1c8 100644 --- a/packages/core/src/test/credentials/auth2.test.ts +++ b/packages/core/src/test/credentials/auth2.test.ts @@ -5,20 +5,27 @@ import * as sinon from 'sinon' import * as vscode from 'vscode' -import { LanguageClientAuth, SsoLogin } from '../../auth/auth2' +import { LanguageClientAuth, SsoLogin, IamLogin } from '../../auth/auth2' import { LanguageClient } from 'vscode-languageclient' import { GetSsoTokenResult, + GetIamCredentialResult, SsoTokenSourceKind, AuthorizationFlowKind, ListProfilesResult, UpdateCredentialsParams, SsoTokenChangedParams, + StsCredentialChangedParams, bearerCredentialsUpdateRequestType, bearerCredentialsDeleteNotificationType, + iamCredentialsUpdateRequestType, + iamCredentialsDeleteNotificationType, ssoTokenChangedRequestType, + stsCredentialChangedRequestType, SsoTokenChangedKind, + StsCredentialChangedKind, invalidateSsoTokenRequestType, + invalidateStsCredentialRequestType, ProfileKind, AwsErrorCodes, } from '@aws/language-server-runtimes/protocol' @@ -84,7 +91,7 @@ describe('LanguageClientAuth', () => { describe('updateProfile', () => { it('sends correct profile update parameters', async () => { - await auth.updateProfile(profileName, startUrl, region, ['scope1']) + await auth.updateSsoProfile(profileName, startUrl, region, ['scope1']) sinon.assert.calledOnce(client.sendRequest) const requestParams = client.sendRequest.firstCall.args[1] @@ -95,6 +102,28 @@ describe('LanguageClientAuth', () => { sso_region: region, }) }) + + it('sends correct IAM profile update parameters', async () => { + await auth.updateIamProfile(profileName, { + accessKey: 'myAccessKey', + secretKey: 'mySecretKey', + sessionToken: 'mySessionToken', + }) + + sinon.assert.calledOnce(client.sendRequest) + const requestParams = client.sendRequest.firstCall.args[1] + sinon.assert.match(requestParams.profile, { + name: profileName, + kinds: [ProfileKind.IamCredentialsProfile], + }) + sinon.assert.match(requestParams.profile.settings, { + aws_access_key_id: 'myAccessKey', + aws_secret_access_key: 'mySecretKey', + aws_session_token: 'mySessionToken', + role_arn: '', + source_profile: '', + }) + }) }) describe('getProfile', () => { @@ -159,6 +188,81 @@ describe('LanguageClientAuth', () => { }) }) + describe('updateIamCredential', () => { + it('sends request', async () => { + const updateParams: UpdateCredentialsParams = { + data: 'credential-data', + encrypted: true, + } + + await auth.updateIamCredential(updateParams) + + sinon.assert.calledOnce(client.sendRequest) + sinon.assert.calledWith(client.sendRequest, iamCredentialsUpdateRequestType.method, updateParams) + }) + }) + + describe('deleteIamCredential', () => { + it('sends notification', async () => { + auth.deleteIamCredential() + + sinon.assert.calledOnce(client.sendNotification) + sinon.assert.calledWith(client.sendNotification, iamCredentialsDeleteNotificationType.method) + }) + }) + + describe('getIamCredential', () => { + it('sends correct request parameters', async () => { + await auth.getIamCredential(profileName, true) + + sinon.assert.calledOnce(client.sendRequest) + sinon.assert.calledWith( + client.sendRequest, + sinon.match.any, + sinon.match({ + profileName: profileName, + options: { + generateOnInvalidStsCredential: true, + }, + }) + ) + }) + }) + + describe('invalidateStsCredential', () => { + it('sends request', async () => { + client.sendRequest.resolves({ success: true }) + const result = await auth.invalidateStsCredential(profileName) + + sinon.assert.calledOnce(client.sendRequest) + sinon.assert.calledWith(client.sendRequest, invalidateStsCredentialRequestType.method, { + profileName: profileName, + }) + sinon.assert.match(result, { success: true }) + }) + }) + + describe('registerStsCredentialChangedHandler', () => { + it('registers the handler correctly', () => { + const handler = sinon.spy() + + auth.registerStsCredentialChangedHandler(handler) + + sinon.assert.calledOnce(client.onNotification) + sinon.assert.calledWith(client.onNotification, stsCredentialChangedRequestType.method, sinon.match.func) + + const credentialChangedParams: StsCredentialChangedParams = { + kind: StsCredentialChangedKind.Refreshed, + stsCredentialId: 'test-credential-id', + } + const registeredHandler = client.onNotification.firstCall.args[1] + registeredHandler(credentialChangedParams) + + sinon.assert.calledOnce(handler) + sinon.assert.calledWith(handler, credentialChangedParams) + }) + }) + describe('invalidateSsoToken', () => { it('sends request', async () => { client.sendRequest.resolves({ success: true }) @@ -219,7 +323,7 @@ describe('SsoLogin', () => { lspAuth = sinon.createStubInstance(LanguageClientAuth) eventEmitter = new vscode.EventEmitter() fireEventSpy = sinon.spy(eventEmitter, 'fire') - ssoLogin = new SsoLogin(profileName, lspAuth as any) + ssoLogin = new SsoLogin(profileName, lspAuth as any, eventEmitter) ;(ssoLogin as any).eventEmitter = eventEmitter ;(ssoLogin as any).connectionState = 'notConnected' }) @@ -231,14 +335,14 @@ describe('SsoLogin', () => { describe('login', () => { it('updates profile and returns SSO token', async () => { - lspAuth.updateProfile.resolves() + lspAuth.updateSsoProfile.resolves() lspAuth.getSsoToken.resolves(mockGetSsoTokenResponse) const response = await ssoLogin.login(loginOpts) - sinon.assert.calledOnce(lspAuth.updateProfile) + sinon.assert.calledOnce(lspAuth.updateSsoProfile) sinon.assert.calledWith( - lspAuth.updateProfile, + lspAuth.updateSsoProfile, profileName, loginOpts.startUrl, loginOpts.region, @@ -470,20 +574,20 @@ describe('SsoLogin', () => { }) }) - describe('onDidChangeConnectionState', () => { - it('should register handler for connection state changes', () => { - const handler = sinon.spy() - ssoLogin.onDidChangeConnectionState(handler) + // describe('onDidChangeConnectionState', () => { + // it('should register handler for connection state changes', () => { + // const handler = sinon.spy() + // ssoLogin.onDidChangeConnectionState(handler) - // Simulate state change - ;(ssoLogin as any).updateConnectionState('connected') + // // Simulate state change + // ;(ssoLogin as any).updateConnectionState('connected') - sinon.assert.calledWith(handler, { - id: profileName, - state: 'connected', - }) - }) - }) + // sinon.assert.calledWith(handler, { + // id: profileName, + // state: 'connected', + // }) + // }) + // }) describe('ssoTokenChangedHandler', () => { beforeEach(() => { @@ -528,3 +632,195 @@ describe('SsoLogin', () => { }) }) }) + +describe('IamLogin', () => { + let lspAuth: sinon.SinonStubbedInstance + let iamLogin: IamLogin + let eventEmitter: vscode.EventEmitter + let fireEventSpy: sinon.SinonSpy + + const loginOpts = { + accessKey: 'test-access-key', + secretKey: 'test-secret-key', + sessionToken: 'test-session-token', + } + + const mockGetIamCredentialResponse: GetIamCredentialResult = { + credential: { + id: 'test-credential-id', + kinds: [], + credentials: { + accessKeyId: 'encrypted-access-key', + secretAccessKey: 'encrypted-secret-key', + sessionToken: 'encrypted-session-token', + }, + }, + updateCredentialsParams: { + data: 'credential-data', + }, + } + + beforeEach(() => { + lspAuth = sinon.createStubInstance(LanguageClientAuth) + eventEmitter = new vscode.EventEmitter() + fireEventSpy = sinon.spy(eventEmitter, 'fire') + iamLogin = new IamLogin(profileName, lspAuth as any, eventEmitter) + ;(iamLogin as any).eventEmitter = eventEmitter + ;(iamLogin as any).connectionState = 'notConnected' + }) + + afterEach(() => { + sinon.restore() + eventEmitter.dispose() + }) + + describe('login', () => { + it('updates profile and returns IAM credential', async () => { + lspAuth.updateIamProfile.resolves() + lspAuth.getIamCredential.resolves(mockGetIamCredentialResponse) + + const response = await iamLogin.login(loginOpts) + + sinon.assert.calledOnce(lspAuth.updateIamProfile) + sinon.assert.calledWith(lspAuth.updateIamProfile, profileName, loginOpts) + sinon.assert.calledOnce(lspAuth.getIamCredential) + sinon.assert.match(iamLogin.getConnectionState(), 'connected') + sinon.assert.match(response.credential.id, 'test-credential-id') + }) + }) + + describe('reauthenticate', () => { + it('throws when not connected', async () => { + ;(iamLogin as any).connectionState = 'notConnected' + try { + await iamLogin.reauthenticate() + sinon.assert.fail('Should have thrown an error') + } catch (err) { + sinon.assert.match((err as Error).message, 'Cannot reauthenticate when not connected.') + } + }) + + it('returns new IAM credential when connected', async () => { + ;(iamLogin as any).connectionState = 'connected' + lspAuth.getIamCredential.resolves(mockGetIamCredentialResponse) + + const response = await iamLogin.reauthenticate() + + sinon.assert.calledOnce(lspAuth.getIamCredential) + sinon.assert.match(iamLogin.getConnectionState(), 'connected') + sinon.assert.match(response.credential.id, 'test-credential-id') + }) + }) + + describe('logout', () => { + it('invalidates credential and updates state', async () => { + ;(iamLogin as any).iamCredentialId = 'test-credential-id' + lspAuth.invalidateStsCredential.resolves({ success: true }) + lspAuth.updateIamProfile.resolves() + + await iamLogin.logout() + + sinon.assert.calledOnce(lspAuth.invalidateStsCredential) + sinon.assert.calledWith(lspAuth.invalidateStsCredential, 'test-credential-id') + sinon.assert.match(iamLogin.getConnectionState(), 'notConnected') + sinon.assert.match(iamLogin.data, undefined) + }) + }) + + describe('restore', () => { + it('restores connection state', async () => { + lspAuth.getIamCredential.resolves(mockGetIamCredentialResponse) + + await iamLogin.restore() + + sinon.assert.calledOnce(lspAuth.getIamCredential) + sinon.assert.calledWith(lspAuth.getIamCredential, profileName, false) + sinon.assert.match(iamLogin.getConnectionState(), 'connected') + }) + }) + + describe('_getIamCredential', () => { + const testErrorHandling = async (errorCode: string, expectedState: string) => { + const error = new Error('Credential error') + ;(error as any).data = { awsErrorCode: errorCode } + lspAuth.getIamCredential.rejects(error) + + try { + await (iamLogin as any)._getIamCredential(false) + sinon.assert.fail('Should have thrown an error') + } catch (err) { + sinon.assert.match(err, error) + } + + sinon.assert.match(iamLogin.getConnectionState(), expectedState) + } + + const notConnectedErrors = [ + AwsErrorCodes.E_CANCELLED, + AwsErrorCodes.E_INVALID_PROFILE, + AwsErrorCodes.E_PROFILE_NOT_FOUND, + AwsErrorCodes.E_CANNOT_CREATE_STS_CREDENTIAL, + AwsErrorCodes.E_INVALID_STS_CREDENTIAL, + ] + + for (const errorCode of notConnectedErrors) { + it(`handles ${errorCode} error`, async () => { + await testErrorHandling(errorCode, 'notConnected') + }) + } + + it('returns correct response and updates state', async () => { + lspAuth.getIamCredential.resolves(mockGetIamCredentialResponse) + + const response = await (iamLogin as any)._getIamCredential(true) + + sinon.assert.calledWith(lspAuth.getIamCredential, profileName, true) + sinon.assert.match(response, mockGetIamCredentialResponse) + sinon.assert.match(iamLogin.getConnectionState(), 'connected') + sinon.assert.match((iamLogin as any).iamCredentialId, 'test-credential-id') + }) + }) + + describe('stsCredentialChangedHandler', () => { + beforeEach(() => { + ;(iamLogin as any).iamCredentialId = 'test-credential-id' + ;(iamLogin as any).connectionState = 'connected' + }) + + it('updates state when credential expires', () => { + ;(iamLogin as any).stsCredentialChangedHandler({ + kind: StsCredentialChangedKind.Expired, + stsCredentialId: 'test-credential-id', + }) + + sinon.assert.match(iamLogin.getConnectionState(), 'expired') + sinon.assert.calledOnce(fireEventSpy) + sinon.assert.calledWith(fireEventSpy, { + id: profileName, + state: 'expired', + }) + }) + + it('emits refresh event when credential is refreshed', () => { + ;(iamLogin as any).stsCredentialChangedHandler({ + kind: StsCredentialChangedKind.Refreshed, + stsCredentialId: 'test-credential-id', + }) + + sinon.assert.calledOnce(fireEventSpy) + sinon.assert.calledWith(fireEventSpy, { + id: profileName, + state: 'refreshed', + }) + }) + + it('does not emit event for different credential ID', () => { + ;(iamLogin as any).stsCredentialChangedHandler({ + kind: StsCredentialChangedKind.Refreshed, + stsCredentialId: 'different-credential-id', + }) + + sinon.assert.notCalled(fireEventSpy) + }) + }) +}) diff --git a/packages/core/src/test/testAuthUtil.ts b/packages/core/src/test/testAuthUtil.ts index 595f8bf45ef..7e3d2c50345 100644 --- a/packages/core/src/test/testAuthUtil.ts +++ b/packages/core/src/test/testAuthUtil.ts @@ -26,15 +26,31 @@ export async function createTestAuthUtil() { }, } + const fakeCredential = { + credentials: { + accessKeyId: 'fake-access-key-id', + secretAccessKey: 'fake-secret-access-key', + sessionToken: 'fake-session-token', + }, + updateCredentialsParams: { + data: 'fake-data', + }, + } + const mockLspAuth: Partial = { registerSsoTokenChangedHandler: sinon.stub().resolves(), - updateProfile: sinon.stub().resolves(), + registerStsCredentialChangedHandler: sinon.stub().resolves(), + registerGetMfaCodeHandler: sinon.stub().resolves(), + updateSsoProfile: sinon.stub().resolves(), getSsoToken: sinon.stub().resolves(fakeToken), + getIamCredential: sinon.stub().resolves(fakeCredential), getProfile: sinon.stub().resolves({ sso_registration_scopes: ['codewhisperer'], }), deleteBearerToken: sinon.stub().resolves(), + deleteIamCredential: sinon.stub().resolves(), updateBearerToken: sinon.stub().resolves(), + updateIamCredential: sinon.stub().resolves(), invalidateSsoToken: sinon.stub().resolves(), registerCacheWatcher: sinon.stub().resolves(), encryptionKey,