diff --git a/clients/client-sso/SSOClient.ts b/clients/client-sso/SSOClient.ts index 6adbd94f5c947..f7b5496ff18c6 100644 --- a/clients/client-sso/SSOClient.ts +++ b/clients/client-sso/SSOClient.ts @@ -35,7 +35,6 @@ import { import { Provider, RegionInfoProvider, - Credentials as __Credentials, Decoder as __Decoder, Encoder as __Encoder, HashConstructor as __HashConstructor, @@ -123,11 +122,6 @@ export interface ClientDefaults extends Partial<__SmithyResolvedConfiguration<__ */ serviceId?: string; - /** - * Default credentials provider; Not available in browser runtime - */ - credentialDefaultProvider?: (input: any) => __Provider<__Credentials>; - /** * The AWS region to which this client will send requests */ diff --git a/clients/client-sso/package.json b/clients/client-sso/package.json index 61acef345920a..de8752a56a977 100644 --- a/clients/client-sso/package.json +++ b/clients/client-sso/package.json @@ -30,7 +30,6 @@ "@aws-crypto/sha256-browser": "^1.0.0", "@aws-crypto/sha256-js": "^1.0.0", "@aws-sdk/config-resolver": "3.6.1", - "@aws-sdk/credential-provider-node": "3.6.1", "@aws-sdk/fetch-http-handler": "3.6.1", "@aws-sdk/hash-node": "3.6.1", "@aws-sdk/invalid-dependency": "3.6.1", diff --git a/clients/client-sso/runtimeConfig.browser.ts b/clients/client-sso/runtimeConfig.browser.ts index 7b50171171d7b..f5a10f32e1120 100644 --- a/clients/client-sso/runtimeConfig.browser.ts +++ b/clients/client-sso/runtimeConfig.browser.ts @@ -20,7 +20,6 @@ export const ClientDefaultValues: Required = { base64Decoder: fromBase64, base64Encoder: toBase64, bodyLengthChecker: calculateBodyLength, - credentialDefaultProvider: (_: unknown) => () => Promise.reject(new Error("Credential is missing")), defaultUserAgentProvider: defaultUserAgent({ serviceId: ClientSharedValues.serviceId, clientVersion: packageInfo.version, diff --git a/clients/client-sso/runtimeConfig.ts b/clients/client-sso/runtimeConfig.ts index 88fc6adf0320c..69379c8100464 100644 --- a/clients/client-sso/runtimeConfig.ts +++ b/clients/client-sso/runtimeConfig.ts @@ -1,7 +1,6 @@ import packageInfo from "./package.json"; import { NODE_REGION_CONFIG_FILE_OPTIONS, NODE_REGION_CONFIG_OPTIONS } from "@aws-sdk/config-resolver"; -import { defaultProvider as credentialDefaultProvider } from "@aws-sdk/credential-provider-node"; import { Hash } from "@aws-sdk/hash-node"; import { NODE_MAX_ATTEMPT_CONFIG_OPTIONS } from "@aws-sdk/middleware-retry"; import { loadConfig as loadNodeConfig } from "@aws-sdk/node-config-provider"; @@ -22,7 +21,6 @@ export const ClientDefaultValues: Required = { base64Decoder: fromBase64, base64Encoder: toBase64, bodyLengthChecker: calculateBodyLength, - credentialDefaultProvider, defaultUserAgentProvider: defaultUserAgent({ serviceId: ClientSharedValues.serviceId, clientVersion: packageInfo.version, diff --git a/package.json b/package.json index 4442165dc3e92..42ee840867124 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,8 @@ "**/karma*", "**/karma*/**", "**/@types/mocha*", - "**/@types/mocha*/**" + "**/@types/mocha*/**", + "**/@aws-sdk/client-sso/**" ] }, "husky": { @@ -117,4 +118,4 @@ ], "**/*.{ts,js,md,json}": "prettier --write" } -} \ No newline at end of file +} diff --git a/packages/credential-provider-node/package.json b/packages/credential-provider-node/package.json index ce80ca3410ed9..5a044a58c38d8 100644 --- a/packages/credential-provider-node/package.json +++ b/packages/credential-provider-node/package.json @@ -33,6 +33,7 @@ "@aws-sdk/property-provider": "3.6.1", "@aws-sdk/shared-ini-file-loader": "3.6.1", "@aws-sdk/types": "3.6.1", + "@aws-sdk/credential-provider-sso": "3.0.0", "tslib": "^1.8.0" }, "devDependencies": { diff --git a/packages/credential-provider-node/src/index.spec.ts b/packages/credential-provider-node/src/index.spec.ts index 6a34ef21017b9..d8c1f31326504 100644 --- a/packages/credential-provider-node/src/index.spec.ts +++ b/packages/credential-provider-node/src/index.spec.ts @@ -21,6 +21,14 @@ jest.mock("@aws-sdk/shared-ini-file-loader", () => ({ })); import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader"; +jest.mock("@aws-sdk/credential-provider-sso", () => { + const ssoProvider = jest.fn(); + return { + fromSSO: jest.fn().mockReturnValue(ssoProvider), + }; +}); +import { fromSSO, FromSSOInit } from "@aws-sdk/credential-provider-sso"; + jest.mock("@aws-sdk/credential-provider-ini", () => { const iniProvider = jest.fn(); return { @@ -81,11 +89,13 @@ beforeEach(() => { }); (fromEnv() as any).mockClear(); + (fromSSO() as any).mockClear(); (fromIni() as any).mockClear(); (fromProcess() as any).mockClear(); (fromContainerMetadata() as any).mockClear(); (fromInstanceMetadata() as any).mockClear(); (fromEnv as any).mockClear(); + (fromSSO as any).mockClear(); (fromIni as any).mockClear(); (fromProcess as any).mockClear(); (fromContainerMetadata as any).mockClear(); @@ -120,6 +130,24 @@ describe("defaultProvider", () => { expect((fromInstanceMetadata() as any).mock.calls.length).toBe(0); }); + it("should stop after the SSO provider if credentials have been found", async () => { + const creds = { + accessKeyId: "foo", + secretAccessKey: "bar", + }; + + (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); + (fromSSO() as any).mockImplementation(() => Promise.resolve(creds)); + + expect(await defaultProvider()()).toEqual(creds); + expect((fromEnv() as any).mock.calls.length).toBe(1); + expect((fromSSO() as any).mock.calls.length).toBe(1); + expect((fromIni() as any).mock.calls.length).toBe(0); + expect((fromProcess() as any).mock.calls.length).toBe(0); + expect((fromContainerMetadata() as any).mock.calls.length).toBe(0); + expect((fromInstanceMetadata() as any).mock.calls.length).toBe(0); + }); + it("should stop after the ini provider if credentials have been found", async () => { const creds = { accessKeyId: "foo", @@ -127,10 +155,12 @@ describe("defaultProvider", () => { }; (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); + (fromSSO() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); (fromIni() as any).mockImplementation(() => Promise.resolve(creds)); expect(await defaultProvider()()).toEqual(creds); expect((fromEnv() as any).mock.calls.length).toBe(1); + expect((fromSSO() as any).mock.calls.length).toBe(1); expect((fromIni() as any).mock.calls.length).toBe(1); expect((fromProcess() as any).mock.calls.length).toBe(0); expect((fromContainerMetadata() as any).mock.calls.length).toBe(0); @@ -144,11 +174,13 @@ describe("defaultProvider", () => { }; (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); + (fromSSO() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); (fromProcess() as any).mockImplementation(() => Promise.resolve(creds)); expect(await defaultProvider()()).toEqual(creds); expect((fromEnv() as any).mock.calls.length).toBe(1); + expect((fromSSO() as any).mock.calls.length).toBe(1); expect((fromIni() as any).mock.calls.length).toBe(1); expect((fromProcess() as any).mock.calls.length).toBe(1); expect((fromContainerMetadata() as any).mock.calls.length).toBe(0); @@ -161,12 +193,14 @@ describe("defaultProvider", () => { secretAccessKey: "bar", }; (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("Keep moving!"))); + (fromSSO() as any).mockImplementation(() => Promise.reject(new ProviderError("Nope!"))); (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); (fromProcess() as any).mockImplementation(() => Promise.reject(new ProviderError("Nor here!"))); (fromInstanceMetadata() as any).mockImplementation(() => Promise.resolve(creds)); expect(await defaultProvider()()).toEqual(creds); expect((fromEnv() as any).mock.calls.length).toBe(1); + expect((fromSSO() as any).mock.calls.length).toBe(1); expect((fromIni() as any).mock.calls.length).toBe(1); expect((fromProcess() as any).mock.calls.length).toBe(1); expect((fromContainerMetadata() as any).mock.calls.length).toBe(0); @@ -180,6 +214,7 @@ describe("defaultProvider", () => { }; (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("Keep moving!"))); + (fromSSO() as any).mockImplementation(() => Promise.reject(new ProviderError("Nope!"))); (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); (fromProcess() as any).mockImplementation(() => Promise.reject(new ProviderError("Nor here!"))); (fromInstanceMetadata() as any).mockImplementation(() => Promise.resolve(creds)); @@ -198,6 +233,7 @@ describe("defaultProvider", () => { }; (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("Keep moving!"))); + (fromSSO() as any).mockImplementation(() => Promise.reject(new ProviderError("Nope!"))); (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); (fromProcess() as any).mockImplementation(() => Promise.reject(new ProviderError("Nor here!"))); (fromInstanceMetadata() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); @@ -207,6 +243,7 @@ describe("defaultProvider", () => { expect(await defaultProvider()()).toEqual(creds); expect((fromEnv() as any).mock.calls.length).toBe(1); + expect((fromSSO() as any).mock.calls.length).toBe(1); expect((fromIni() as any).mock.calls.length).toBe(1); expect((fromProcess() as any).mock.calls.length).toBe(1); expect((fromContainerMetadata() as any).mock.calls.length).toBe(1); @@ -220,6 +257,7 @@ describe("defaultProvider", () => { }; (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("Keep moving!"))); + (fromSSO() as any).mockImplementation(() => Promise.reject(new ProviderError("Nope!"))); (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); (fromProcess() as any).mockImplementation(() => Promise.reject(new ProviderError("Nor here!"))); (fromInstanceMetadata() as any).mockImplementation(() => Promise.resolve(creds)); @@ -227,9 +265,33 @@ describe("defaultProvider", () => { await expect(defaultProvider()()).resolves; expect((loadSharedConfigFiles as any).mock.calls.length).toBe(1); expect((fromIni as any).mock.calls[1][0]).toMatchObject({ loadedConfig: loadSharedConfigFiles() }); + expect((fromSSO as any).mock.calls[1][0]).toMatchObject({ loadedConfig: loadSharedConfigFiles() }); expect((fromProcess as any).mock.calls[1][0]).toMatchObject({ loadedConfig: loadSharedConfigFiles() }); }); + it("should pass configuration on to the SSO provider", async () => { + const ssoConfig: FromSSOInit = { + profile: "foo", + filepath: "/home/user/.secrets/credentials.ini", + configFilepath: "/home/user/.secrets/credentials.ini", + }; + + (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("Keep moving!"))); + (fromSSO() as any).mockImplementation(() => + Promise.resolve({ + accessKeyId: "foo", + secretAccessKey: "bar", + }) + ); + + (fromSSO as any).mockClear(); + + await expect(defaultProvider(ssoConfig)()).resolves; + + expect((fromSSO as any).mock.calls.length).toBe(1); + expect((fromSSO as any).mock.calls[0][0]).toEqual({ ...ssoConfig, loadedConfig }); + }); + it("should pass configuration on to the ini provider", async () => { const iniConfig: FromIniInit = { profile: "foo", @@ -387,60 +449,86 @@ describe("defaultProvider", () => { // CF https://github.com/boto/botocore/blob/1.8.32/botocore/credentials.py#L104 describe("explicit profiles", () => { - it("should only consult the ini provider if a profile has been specified", async () => { + it("should only consult SSO provider if profile has been set", async () => { const creds = { accessKeyId: "foo", secretAccessKey: "bar", }; - (fromEnv() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); - (fromIni() as any).mockImplementation(() => Promise.resolve(creds)); - (fromInstanceMetadata() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); - (fromContainerMetadata() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); + (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromSSO() as any).mockImplementation(() => Promise.resolve(Promise.resolve(creds))); + (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromInstanceMetadata() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromContainerMetadata() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); expect(await defaultProvider({ profile: "foo" })()).toEqual(creds); expect((fromEnv() as any).mock.calls.length).toBe(0); - expect((fromIni() as any).mock.calls.length).toBe(1); + expect((fromSSO() as any).mock.calls.length).toBe(1); + expect((fromIni() as any).mock.calls.length).toBe(0); expect((fromContainerMetadata() as any).mock.calls.length).toBe(0); expect((fromInstanceMetadata() as any).mock.calls.length).toBe(0); }); - it("should only consult the ini provider if the profile environment variable has been set", async () => { + it("should only consult SSO provider if the profile environment variable has been set", async () => { const creds = { accessKeyId: "foo", secretAccessKey: "bar", }; - (fromEnv() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); - (fromIni() as any).mockImplementation(() => Promise.resolve(creds)); - (fromProcess() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); - (fromInstanceMetadata() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); - (fromContainerMetadata() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); + (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromSSO() as any).mockImplementation(() => Promise.resolve(creds)); + (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromProcess() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromInstanceMetadata() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromContainerMetadata() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); process.env[ENV_PROFILE] = "foo"; expect(await defaultProvider()()).toEqual(creds); expect((fromEnv() as any).mock.calls.length).toBe(0); - expect((fromIni() as any).mock.calls.length).toBe(1); + expect((fromSSO() as any).mock.calls.length).toBe(1); + expect((fromIni() as any).mock.calls.length).toBe(0); expect((fromProcess() as any).mock.calls.length).toBe(0); expect((fromContainerMetadata() as any).mock.calls.length).toBe(0); expect((fromInstanceMetadata() as any).mock.calls.length).toBe(0); }); + it("should consult ini provider if no credentials is not found in SSO provider", async () => { + const creds = { + accessKeyId: "foo", + secretAccessKey: "bar", + }; + + (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromSSO() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromIni() as any).mockImplementation(() => Promise.resolve(Promise.resolve(creds))); + (fromInstanceMetadata() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromContainerMetadata() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + + expect(await defaultProvider({ profile: "foo" })()).toEqual(creds); + expect((fromEnv() as any).mock.calls.length).toBe(0); + expect((fromSSO() as any).mock.calls.length).toBe(1); + expect((fromIni() as any).mock.calls.length).toBe(1); + expect((fromContainerMetadata() as any).mock.calls.length).toBe(0); + expect((fromInstanceMetadata() as any).mock.calls.length).toBe(0); + }); + it("should consult the process provider if no credentials are found in the ini provider", async () => { const creds = { accessKeyId: "foo", secretAccessKey: "bar", }; - (fromEnv() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); - (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("Nothing here!"))); + (fromEnv() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromSSO() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromIni() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); (fromProcess() as any).mockImplementation(() => Promise.resolve(creds)); - (fromInstanceMetadata() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); - (fromContainerMetadata() as any).mockImplementation(() => Promise.reject(new Error("PANIC"))); + (fromInstanceMetadata() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); + (fromContainerMetadata() as any).mockImplementation(() => Promise.reject(new ProviderError("PANIC"))); process.env[ENV_PROFILE] = "foo"; expect(await defaultProvider()()).toEqual(creds); expect((fromEnv() as any).mock.calls.length).toBe(0); + expect((fromSSO() as any).mock.calls.length).toBe(1); expect((fromIni() as any).mock.calls.length).toBe(1); expect((fromProcess() as any).mock.calls.length).toBe(1); expect((fromContainerMetadata() as any).mock.calls.length).toBe(0); diff --git a/packages/credential-provider-node/src/index.ts b/packages/credential-provider-node/src/index.ts index 41fa6305a7b71..79d5b4235db1c 100644 --- a/packages/credential-provider-node/src/index.ts +++ b/packages/credential-provider-node/src/index.ts @@ -8,6 +8,7 @@ import { } from "@aws-sdk/credential-provider-imds"; import { ENV_PROFILE, fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini"; import { fromProcess, FromProcessInit } from "@aws-sdk/credential-provider-process"; +import { fromSSO, FromSSOInit } from "@aws-sdk/credential-provider-sso"; import { chain, memoize, ProviderError } from "@aws-sdk/property-provider"; import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader"; import { CredentialProvider } from "@aws-sdk/types"; @@ -33,6 +34,8 @@ export const ENV_IMDS_DISABLED = "AWS_EC2_METADATA_DISABLED"; * * @see fromEnv The function used to source credentials from * environment variables + * @see fromSSO The function used to source credentials from + * resolved SSO token cache * @see fromIni The function used to source credentials from INI * files * @see fromProcess The function used to sources credentials from @@ -42,10 +45,12 @@ export const ENV_IMDS_DISABLED = "AWS_EC2_METADATA_DISABLED"; * @see fromContainerMetadata The function used to source credentials from the * ECS Container Metadata Service */ -export const defaultProvider = (init: FromIniInit & RemoteProviderInit & FromProcessInit = {}): CredentialProvider => { +export const defaultProvider = ( + init: FromIniInit & RemoteProviderInit & FromProcessInit & FromSSOInit = {} +): CredentialProvider => { const options = { profile: process.env[ENV_PROFILE], ...init }; if (!options.loadedConfig) options.loadedConfig = loadSharedConfigFiles(init); - const providers = [fromIni(options), fromProcess(options), remoteProvider(options)]; + const providers = [fromSSO(options), fromIni(options), fromProcess(options), remoteProvider(options)]; if (!options.profile) providers.unshift(fromEnv()); const providerChain = chain(...providers); diff --git a/packages/credential-provider-sso/LICENSE b/packages/credential-provider-sso/LICENSE new file mode 100644 index 0000000000000..f9a667398be9f --- /dev/null +++ b/packages/credential-provider-sso/LICENSE @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/credential-provider-sso/README.md b/packages/credential-provider-sso/README.md new file mode 100644 index 0000000000000..44433be70319e --- /dev/null +++ b/packages/credential-provider-sso/README.md @@ -0,0 +1,97 @@ +# @aws-sdk/credential-provider-sso + +[![NPM version](https://img.shields.io/npm/v/@aws-sdk/credential-provider-sso/latest.svg)](https://www.npmjs.com/package/@aws-sdk/credential-provider-sso) +[![NPM downloads](https://img.shields.io/npm/dm/@aws-sdk/credential-provider-sso.svg)](https://www.npmjs.com/package/@aws-sdk/credential-provider-sso) + +## AWS Credential Provider for Node.js - AWS Single Sign-On (SSO) + +This module provides a function, `fromSSO` that will create `CredentialProvider` +functions that read from [AWS SDKs and Tools shared configuration and credentials files](https://docs.aws.amazon.com/credref/latest/refdocs/creds-config-files.html)(Profile appears +in the credentials file will be given precedence over the profile found in the +config file). This provider will load the _resolved_ access token on local disk, +and then request temporary AWS credentials. For the guidance over AWS Single +Sign-On service, please refer to [the service document](https://aws.amazon.com/single-sign-on/). + +## Supported configuration + +You may customize how credentials are resolved by providing an options hash to +the `fromSSO` factory function. The following options are supported: + +- `profile` - The configuration profile to use. If not specified, the provider + will use the value in the `AWS_PROFILE` environment variable or `default` by + default. +- `filepath` - The path to the shared credentials file. If not specified, the + provider will use the value in the `AWS_SHARED_CREDENTIALS_FILE` environment + variable or `~/.aws/credentials` by default. +- `configFilepath` - The path to the shared config file. If not specified, the + provider will use the value in the `AWS_CONFIG_FILE` environment variable or + `~/.aws/config` by default. +- `ssoClient` - The SSO Client that used to request AWS credentials with the SSO + access token. If not specified, a default SSO client will be created with the + region specified in the profile `sso_region` entry. + +## SSO Login with AWS CLI + +This credential provider relies on [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html#sso-configure-profile) +to login to an AWS SSO session. Here's a brief walk-through: + +1. Create a new AWS SSO enabled profile using AWS CLI. It will ask you to login + to your AWS SSO account and prompt for the name of the profile: + +```console +$ aws configure sso +... +... +CLI profile name [123456789011_ReadOnly]: my-sso-profile +``` + +2. Configure you SDK client with the SSO credential provider: + +```javascript +import { fromSSO } from "@aws-sdk/credential-provider-sso"; // ES6 example +// const { fromSSO } = require(@aws-sdk/credential-provider-sso") // CommonJS example +//... +const client = new FooClient({ credentials: fromSSO({ profile: "my-sso-profile" }); +``` + +Alternatively, the SSO credential provider is supported in default Node.js credential +provider: + +```javascript +import { defaultProvider } from "@aws-sdk/credential-provider-node"; // ES6 example +// const { defaultProvider } = require(@aws-sdk/credential-provider-node") // CommonJS example +//... +const client = new FooClient({ credentials: defaultProvider({ profile: "my-sso-profile" }); +``` + +3. To log out from the current SSO session, use AWS CLI: + +```console +$ aws sso logout +Successfully signed out of all SSO profiles. +``` + +## Sample files + +This credential provider is only applicable if the profile specified in shared +configuration and credentials files contain ALL of the following entries: + +### `~/.aws/credentials` + +```ini +[sample-profile] +sso_account_id = 012345678901 +sso_region = us-east-1 +sso_role_name = SampleRole +sso_start_url = https://d-abc123.awsapps.com/start +``` + +### `~/.aws/config` + +```ini +[profile sample-profile] +sso_account_id = 012345678901 +sso_region = us-east-1 +sso_role_name = SampleRole +sso_start_url = https://d-abc123.awsapps.com/start +``` diff --git a/packages/credential-provider-sso/jest.config.js b/packages/credential-provider-sso/jest.config.js new file mode 100644 index 0000000000000..a8d1c2e499123 --- /dev/null +++ b/packages/credential-provider-sso/jest.config.js @@ -0,0 +1,5 @@ +const base = require("../../jest.config.base.js"); + +module.exports = { + ...base, +}; diff --git a/packages/credential-provider-sso/package.json b/packages/credential-provider-sso/package.json new file mode 100644 index 0000000000000..c07699eb618ad --- /dev/null +++ b/packages/credential-provider-sso/package.json @@ -0,0 +1,56 @@ +{ + "name": "@aws-sdk/credential-provider-sso", + "version": "3.0.0", + "description": "AWS credential provider that exchanges a resolved SSO login token file for temporary AWS credentials", + "main": "./dist/cjs/index.js", + "module": "./dist/es/index.js", + "scripts": { + "prepublishOnly": "yarn build:cjs && yarn build:es", + "pretest": "yarn build:cjs", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:es": "tsc -p tsconfig.es.json", + "build": "yarn build:es && yarn build:cjs", + "postbuild": "downlevel-dts types types/ts3.4", + "test": "jest" + }, + "keywords": [ + "aws", + "credentials" + ], + "author": { + "name": "AWS SDK for JavaScript Team", + "url": "https://aws.amazon.com/javascript/" + }, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-ini": "3.6.1", + "@aws-sdk/property-provider": "3.6.1", + "@aws-sdk/shared-ini-file-loader": "3.6.1", + "@aws-sdk/client-sso": "3.6.1", + "@aws-sdk/types": "3.6.1", + "tslib": "^1.8.0" + }, + "devDependencies": { + "@types/jest": "^26.0.4", + "@types/node": "^10.0.0", + "jest": "^26.1.0", + "typescript": "~4.1.2" + }, + "types": "./types/index.d.ts", + "engines": { + "node": ">= 10.0.0" + }, + "typesVersions": { + "<4.0": { + "types/*": [ + "types/ts3.4/*" + ] + } + }, + "homepage": "https://github.com/aws/aws-sdk-js-v3/tree/master/packages/credential-provider-sso", + "repository": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-js-v3.git", + "directory": "packages/credential-provider-sso" + } +} diff --git a/packages/credential-provider-sso/src/index.spec.ts b/packages/credential-provider-sso/src/index.spec.ts new file mode 100644 index 0000000000000..2a65c55382d13 --- /dev/null +++ b/packages/credential-provider-sso/src/index.spec.ts @@ -0,0 +1,205 @@ +jest.useFakeTimers("modern"); +const now = 1613699814645; +jest.setSystemTime(now); + +const mockParseKnowFiles = jest.fn(); +const mockGetMasterProfileName = jest.fn(); +jest.mock("@aws-sdk/credential-provider-ini", () => ({ + parseKnownFiles: mockParseKnowFiles, + getMasterProfileName: mockGetMasterProfileName, +})); + +const mockReadFileSync = jest.fn(); +jest.mock("fs", () => ({ readFileSync: mockReadFileSync })); + +const mockRoleCredentials = { + roleCredentials: { + accessKeyId: "key", + secretAccessKey: "secret", + sessionToken: "token", + expiration: Date.now(), + }, +}; +const mockSSOSend = jest.fn().mockReturnValue(Promise.resolve(mockRoleCredentials)); +const mockGetRoleCredentialsCommand = jest.fn(); +jest.mock("@aws-sdk/client-sso", () => ({ + SSOClient: function () { + return { send: mockSSOSend }; + }, + GetRoleCredentialsCommand: mockGetRoleCredentialsCommand, +})); + +import { EXPIRE_WINDOW_MS, fromSSO } from "./index"; + +const toRFC3339String = (date: number): string => { + const timestamp = new Date(date).toISOString(); + return timestamp.replace(/\.[\d]+Z$/, "Z"); +}; + +describe("fromSSO", () => { + const ssoConfig = { + sso_start_url: "https:some-url/start", + sso_account_id: "1234567890", + sso_region: "us-foo-1", + sso_role_name: "some-role", + }; + const token = { + startUrl: ssoConfig.sso_start_url, + region: ssoConfig.sso_region, + accessToken: "base64 encoded string", + expiresAt: toRFC3339String(now + 60 * 60 * 1000), + }; + beforeEach(() => { + mockParseKnowFiles.mockClear(); + mockGetMasterProfileName.mockClear(); + mockReadFileSync.mockClear(); + mockSSOSend.mockClear(); + }); + + it("should fetch credentials from resolved token file", async () => { + mockParseKnowFiles.mockReturnValue(Promise.resolve({ default: ssoConfig })); + mockGetMasterProfileName.mockReturnValue("default"); + mockReadFileSync.mockReturnValue(JSON.stringify(token)); + const { roleCredentials } = mockRoleCredentials; + expect(await fromSSO()()).toEqual({ ...roleCredentials, expiration: new Date(roleCredentials.expiration) }); + expect(mockReadFileSync.mock.calls[0][0]).toEqual( + expect.stringMatching(/fcab95d6966151d97d9ee7776a90d895b5e5fbe6.json$/) + ); + expect(mockReadFileSync.mock.calls[0][1]).toMatchObject({ encoding: "utf-8" }); + expect(mockGetRoleCredentialsCommand).toHaveBeenCalledWith({ + accountId: ssoConfig.sso_account_id, + roleName: ssoConfig.sso_role_name, + accessToken: token.accessToken, + }); + }); + + it("should allow supplying custom client", async () => { + mockParseKnowFiles.mockReturnValue(Promise.resolve({ default: ssoConfig })); + mockGetMasterProfileName.mockReturnValue("default"); + mockReadFileSync.mockReturnValue(JSON.stringify(token)); + const newSSOClient = { send: jest.fn().mockReturnValue(Promise.resolve(mockRoleCredentials)) }; + //@ts-expect-error + await fromSSO({ ssoClient: newSSOClient })(); + expect(newSSOClient.send).toHaveBeenCalled(); + expect(mockSSOSend).not.toHaveBeenCalled(); + }); + + it("should throw if profile doesn't exist in the config files", () => { + const profile = "exist"; + mockParseKnowFiles.mockReturnValue(Promise.resolve({ non_exist: { foo: "bar" } })); + mockGetMasterProfileName.mockReturnValue(profile); + return expect(async () => { + await fromSSO()(); + }).rejects.toMatchObject({ + message: `Profile ${profile} could not be found in shared credentials file.`, + tryNextLink: true, + }); + }); + + it("should throw if profile is not configured with SSO credential", () => { + const profile = "exist"; + mockParseKnowFiles.mockReturnValue(Promise.resolve({ [profile]: { foo: "bar" } })); + mockGetMasterProfileName.mockReturnValue(profile); + return expect(async () => { + await fromSSO()(); + }).rejects.toMatchObject({ + message: `Profile ${profile} is not configured with SSO credentials.`, + tryNextLink: true, + }); + }); + + for (let i = 0; i < Object.keys(ssoConfig).length; i++) { + const keyToRemove = Object.keys(ssoConfig)[i]; + it(`should throw if sso configuration is missing ${keyToRemove}`, async () => { + expect.assertions(2); + const config = { ...ssoConfig }; + //@ts-ignore + delete config[keyToRemove]; + mockParseKnowFiles.mockReturnValue(Promise.resolve({ default: config })); + mockGetMasterProfileName.mockReturnValue("default"); + try { + await fromSSO()(); + } catch (e) { + expect(e.message).toContain("Profile default does not have valid SSO credentials."); + expect(e.tryNextLink).toBeFalsy(); + } + }); + } + + it("should throw if token cache file is not found", async () => { + expect.assertions(2); + mockReadFileSync.mockImplementation(() => { + throw new Error("File not found."); + }); + mockParseKnowFiles.mockReturnValue(Promise.resolve({ default: ssoConfig })); + mockGetMasterProfileName.mockReturnValue("default"); + try { + await fromSSO()(); + } catch (e) { + expect(e.message).toContain("The SSO session associated with this profile has expired or is otherwise invalid."); + expect(e.tryNextLink).toBeFalsy(); + } + }); + + it("should throw if token cache file is invalid", async () => { + expect.assertions(2); + mockReadFileSync.mockReturnValue("invalid JSON content"); + mockParseKnowFiles.mockReturnValue(Promise.resolve({ default: ssoConfig })); + mockGetMasterProfileName.mockReturnValue("default"); + try { + await fromSSO()(); + } catch (e) { + expect(e.message).toContain("The SSO session associated with this profile has expired or is otherwise invalid."); + expect(e.tryNextLink).toBeFalsy(); + } + }); + + it("should throw if token cache is expired", async () => { + expect.assertions(2); + mockReadFileSync.mockReturnValue({ ...token, expiration: toRFC3339String(now + EXPIRE_WINDOW_MS - 2) }); + mockParseKnowFiles.mockReturnValue(Promise.resolve({ default: ssoConfig })); + mockGetMasterProfileName.mockReturnValue("default"); + try { + await fromSSO()(); + } catch (e) { + expect(e.message).toContain("The SSO session associated with this profile has expired or is otherwise invalid."); + expect(e.tryNextLink).toBeFalsy(); + } + }); + + it("should throw if SSO client throws", async () => { + expect.assertions(3); + mockParseKnowFiles.mockReturnValue(Promise.resolve({ default: ssoConfig })); + mockGetMasterProfileName.mockReturnValue("default"); + mockReadFileSync.mockReturnValue(JSON.stringify(token)); + const clientError = new Error("No account is found for the user"); + //@ts-ignore + clientError.$fault = "client"; + mockSSOSend.mockImplementation(async () => { + throw clientError; + }); + try { + await fromSSO()(); + } catch (e) { + expect(e.message).toContain(clientError.message); + expect(e.tryNextLink).toBeFalsy(); + expect(e.$fault).toBe("client"); + } + }); + + it("should throw if credentials from SSO client is invalid", async () => { + expect.assertions(2); + mockReadFileSync.mockReturnValue(JSON.stringify(token)); + mockParseKnowFiles.mockReturnValue(Promise.resolve({ default: ssoConfig })); + mockGetMasterProfileName.mockReturnValue("default"); + mockSSOSend.mockResolvedValue({ + roleCredentials: { ...mockRoleCredentials.roleCredentials, accessKeyId: undefined }, + }); + try { + await fromSSO()(); + } catch (e) { + expect(e.message).toContain("SSO returns an invalid temporary credential."); + expect(e.tryNextLink).toBeFalsy(); + } + }); +}); diff --git a/packages/credential-provider-sso/src/index.ts b/packages/credential-provider-sso/src/index.ts new file mode 100755 index 0000000000000..feb11c471ee84 --- /dev/null +++ b/packages/credential-provider-sso/src/index.ts @@ -0,0 +1,100 @@ +import { GetRoleCredentialsCommand, GetRoleCredentialsCommandOutput, SSOClient } from "@aws-sdk/client-sso"; +import { getMasterProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/credential-provider-ini"; +import { ProviderError } from "@aws-sdk/property-provider"; +import { getHomeDir, ParsedIniData } from "@aws-sdk/shared-ini-file-loader"; +import { CredentialProvider, Credentials } from "@aws-sdk/types"; +import { createHash } from "crypto"; +import { readFileSync } from "fs"; +import { join } from "path"; + +/** + * The time window (15 mins) that SDK will treat the SSO token expires in before the defined expiration date in token. + * This is needed because server side may have invalidated the token before the defined expiration date. + * + * @internal + */ +export const EXPIRE_WINDOW_MS = 15 * 60 * 1000; + +const SHOULD_FAIL_CREDENTIAL_CHAIN = false; + +/** + * Cached SSO token retrieved from SSO login flow. + */ +interface SSOToken { + // A base64 encoded string returned by the sso-oidc service. + accessToken: string; + // RFC3339 format timestamp + expiresAt: string; + region?: string; + startUrl?: string; +} + +export interface FromSSOInit extends SourceProfileInit { + ssoClient?: SSOClient; +} + +/** + * Creates a credential provider that will read from a credential_process specified + * in ini files. + */ +export const fromSSO = (init: FromSSOInit = {}): CredentialProvider => async () => { + const profiles = await parseKnownFiles(init); + return resolveSSOCredentials(getMasterProfileName(init), profiles, init); +}; + +const resolveSSOCredentials = async ( + profileName: string, + profiles: ParsedIniData, + options: FromSSOInit +): Promise => { + const profile = profiles[profileName]; + if (!profile) { + throw new ProviderError(`Profile ${profileName} could not be found in shared credentials file.`); + } + const { sso_start_url: startUrl, sso_account_id: accountId, sso_region: region, sso_role_name: roleName } = profile; + if (!startUrl && !accountId && !region && !roleName) { + throw new ProviderError(`Profile ${profileName} is not configured with SSO credentials.`); + } + if (!startUrl || !accountId || !region || !roleName) { + throw new ProviderError( + `Profile ${profileName} does not have valid SSO credentials. Required parameters "sso_account_id", "sso_region", ` + + `"sso_role_name", "sso_start_url". Reference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html`, + SHOULD_FAIL_CREDENTIAL_CHAIN + ); + } + const hasher = createHash("sha1"); + const cacheName = hasher.update(startUrl).digest("hex"); + const tokenFile = join(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`); + let token: SSOToken; + try { + token = JSON.parse(readFileSync(tokenFile, { encoding: "utf-8" })); + if (new Date(token.expiresAt).getTime() - Date.now() <= EXPIRE_WINDOW_MS) { + throw new Error("SSO token is expired."); + } + } catch (e) { + throw new ProviderError( + `The SSO session associated with this profile has expired or is otherwise invalid. To refresh this SSO session ` + + `run aws sso login with the corresponding profile.`, + SHOULD_FAIL_CREDENTIAL_CHAIN + ); + } + const { accessToken } = token; + const sso = options.ssoClient || new SSOClient({ region }); + let ssoResp: GetRoleCredentialsCommandOutput; + try { + ssoResp = await sso.send( + new GetRoleCredentialsCommand({ + accountId, + roleName, + accessToken, + }) + ); + } catch (e) { + throw ProviderError.from(e, SHOULD_FAIL_CREDENTIAL_CHAIN); + } + const { roleCredentials: { accessKeyId, secretAccessKey, sessionToken, expiration } = {} } = ssoResp; + if (!accessKeyId || !secretAccessKey || !sessionToken || !expiration) { + throw new ProviderError("SSO returns an invalid temporary credential.", SHOULD_FAIL_CREDENTIAL_CHAIN); + } + return { accessKeyId, secretAccessKey, sessionToken, expiration: new Date(expiration) }; +}; diff --git a/packages/credential-provider-sso/tsconfig.cjs.json b/packages/credential-provider-sso/tsconfig.cjs.json new file mode 100644 index 0000000000000..297d02289446b --- /dev/null +++ b/packages/credential-provider-sso/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "declarationDir": "./types", + "rootDir": "./src", + "outDir": "./dist/cjs", + "baseUrl": "." + }, + "extends": "../../tsconfig.cjs.json", + "include": ["src/"] +} diff --git a/packages/credential-provider-sso/tsconfig.es.json b/packages/credential-provider-sso/tsconfig.es.json new file mode 100644 index 0000000000000..0e9f433ed92d8 --- /dev/null +++ b/packages/credential-provider-sso/tsconfig.es.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "lib": ["es5", "es2015.promise", "es2015.collection"], + "declarationDir": "./types", + "rootDir": "./src", + "outDir": "./dist/es", + "baseUrl": "." + }, + "extends": "../../tsconfig.es.json", + "include": ["src/"] +} diff --git a/packages/property-provider/src/ProviderError.spec.ts b/packages/property-provider/src/ProviderError.spec.ts index 3df028b35eebc..a9dfd6a21d2d4 100644 --- a/packages/property-provider/src/ProviderError.spec.ts +++ b/packages/property-provider/src/ProviderError.spec.ts @@ -8,4 +8,16 @@ describe("ProviderError", () => { it("should allow errors to halt the chain", () => { expect(new ProviderError("PANIC", false).tryNextLink).toBe(false); }); + + describe("from()", () => { + it("should create ProviderError from existing error", () => { + const error = new Error("PANIC"); + // @ts-expect-error Property 'someValue' does not exist on type 'Error'. + error.someValue = "foo"; + const providerError = ProviderError.from(error); + // @ts-expect-error Property 'someValue' does not exist on type 'ProviderError'. + expect(providerError.someValue).toBe("foo"); + expect(providerError.tryNextLink).toBe(true); + }); + }); }); diff --git a/packages/property-provider/src/ProviderError.ts b/packages/property-provider/src/ProviderError.ts index dee9495e8188d..160cafbf6d0ca 100644 --- a/packages/property-provider/src/ProviderError.ts +++ b/packages/property-provider/src/ProviderError.ts @@ -11,4 +11,13 @@ export class ProviderError extends Error { constructor(message: string, public readonly tryNextLink: boolean = true) { super(message); } + static from(error: Error, tryNextLink = true): ProviderError { + Object.defineProperty(error, "tryNextLink", { + value: tryNextLink, + configurable: false, + enumerable: false, + writable: false, + }); + return error as ProviderError; + } } diff --git a/packages/shared-ini-file-loader/src/index.ts b/packages/shared-ini-file-loader/src/index.ts index 818208aedbcef..340057f61c52a 100644 --- a/packages/shared-ini-file-loader/src/index.ts +++ b/packages/shared-ini-file-loader/src/index.ts @@ -108,7 +108,12 @@ const slurpFile = (path: string): Promise => }); }); -const getHomeDir = (): string => { +/** + * Get the HOME directory for the current runtime. + * + * @internal + */ +export const getHomeDir = (): string => { const { HOME, USERPROFILE, HOMEPATH, HOMEDRIVE = `C:${sep}` } = process.env; if (HOME) return HOME;