diff --git a/packages/credential-provider-http/.gitignore b/packages/credential-provider-http/.gitignore new file mode 100644 index 000000000000..3d1714c9806e --- /dev/null +++ b/packages/credential-provider-http/.gitignore @@ -0,0 +1,8 @@ +/node_modules/ +/build/ +/coverage/ +/docs/ +*.tsbuildinfo +*.tgz +*.log +package-lock.json diff --git a/packages/credential-provider-http/CHANGELOG.md b/packages/credential-provider-http/CHANGELOG.md new file mode 100644 index 000000000000..e4d87c4d45c4 --- /dev/null +++ b/packages/credential-provider-http/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/packages/credential-provider-http/README.md b/packages/credential-provider-http/README.md new file mode 100644 index 000000000000..e8f19f8d9c7f --- /dev/null +++ b/packages/credential-provider-http/README.md @@ -0,0 +1,10 @@ +# @aws-sdk/credential-provider-http + +[![NPM version](https://img.shields.io/npm/v/@aws-sdk/credential-provider-http/latest.svg)](https://www.npmjs.com/package/@aws-sdk/credential-provider-http) +[![NPM downloads](https://img.shields.io/npm/dm/@aws-sdk/credential-provider-http.svg)](https://www.npmjs.com/package/@aws-sdk/credential-provider-http) + +> An internal transitively required package. + +## Usage + +See https://www.npmjs.com/package/@aws-sdk/credential-providers diff --git a/packages/credential-provider-http/jest.config.js b/packages/credential-provider-http/jest.config.js new file mode 100644 index 000000000000..a8d1c2e49912 --- /dev/null +++ b/packages/credential-provider-http/jest.config.js @@ -0,0 +1,5 @@ +const base = require("../../jest.config.base.js"); + +module.exports = { + ...base, +}; diff --git a/packages/credential-provider-http/package.json b/packages/credential-provider-http/package.json new file mode 100644 index 000000000000..8d6808d44666 --- /dev/null +++ b/packages/credential-provider-http/package.json @@ -0,0 +1,67 @@ +{ + "name": "@aws-sdk/credential-provider-http", + "version": "3.418.0", + "description": "AWS credential provider for containers and HTTP sources", + "main": "./dist-cjs/index.js", + "module": "./dist-es/index.js", + "scripts": { + "build": "concurrently 'yarn:build:cjs' 'yarn:build:es' 'yarn:build:types'", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:es": "tsc -p tsconfig.es.json", + "build:include:deps": "lerna run --scope $npm_package_name --include-dependencies build", + "build:types": "tsc -p tsconfig.types.json", + "build:types:downlevel": "downlevel-dts dist-types dist-types/ts3.4", + "clean": "rimraf ./dist-* && rimraf *.tsbuildinfo", + "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/types": "*", + "@smithy/protocol-http": "^3.0.5", + "@smithy/property-provider": "^2.0.0", + "@smithy/fetch-http-handler": "^2.1.5", + "@smithy/node-http-handler": "^2.1.5", + "@smithy/types": "^2.3.3", + "tslib": "^2.5.0" + }, + "devDependencies": { + "@tsconfig/recommended": "1.0.1", + "@types/node": "^14.14.31", + "concurrently": "7.0.0", + "downlevel-dts": "0.10.1", + "rimraf": "3.0.2", + "typedoc": "0.23.23", + "typescript": "~4.9.5" + }, + "types": "./dist-types/index.d.ts", + "engines": { + "node": ">=14.0.0" + }, + "typesVersions": { + "<4.0": { + "dist-types/*": [ + "dist-types/ts3.4/*" + ] + } + }, + "files": [ + "dist-*/**" + ], + "homepage": "https://github.com/aws/aws-sdk-js-v3/tree/main/packages/credential-provider-http", + "repository": { + "type": "git", + "url": "https://github.com/aws/aws-sdk-js-v3.git", + "directory": "packages/credential-provider-http" + }, + "typedoc": { + "entryPoint": "src/index.ts" + } +} diff --git a/packages/credential-provider-http/src/fromHttp/checkUrl.spec.ts b/packages/credential-provider-http/src/fromHttp/checkUrl.spec.ts new file mode 100644 index 000000000000..0e9aad1105f1 --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/checkUrl.spec.ts @@ -0,0 +1,48 @@ +import { CredentialsProviderError } from "@smithy/property-provider"; + +import { checkUrl } from "./checkUrl"; + +describe(checkUrl.name, () => { + it("allows https", () => { + expect(checkUrl(new URL("https://___.com"))).toBeUndefined(); + expect(() => checkUrl(new URL("http://___.com"))).toThrow(CredentialsProviderError); + }); + + it("allows ECS container host", () => { + expect(checkUrl(new URL("http://169.254.170.2/test"))).toBeUndefined(); + expect(() => checkUrl(new URL("http://169.254.170.3/test"))).toThrow(CredentialsProviderError); + }); + + it("allows EKS container host", () => { + expect(checkUrl(new URL("http://169.254.170.23/test"))).toBeUndefined(); + expect(() => checkUrl(new URL("http://169.254.170.24/test"))).toThrow(CredentialsProviderError); + + expect(checkUrl(new URL("http://[fd00:ec2::23]/test"))).toBeUndefined(); + expect(() => checkUrl(new URL("http://[fd00:ec2::24]/test"))).toThrow(CredentialsProviderError); + }); + + it("allows localhost", () => { + expect(checkUrl(new URL("http://localhost/test"))).toBeUndefined(); + expect(checkUrl(new URL("http://127.0.0.0/test"))).toBeUndefined(); + expect(checkUrl(new URL("http://127.0.0.1/test"))).toBeUndefined(); + expect(checkUrl(new URL("http://127.255.255.255/test"))).toBeUndefined(); + expect(checkUrl(new URL("http://[::1]/test"))).toBeUndefined(); + expect(checkUrl(new URL("http://[0000:0000:0000:0000:0000:0000:0000:0001]/test"))).toBeUndefined(); + }); + + it("rejects other http", () => { + expect(() => checkUrl(new URL("http://abcd.com"))).toThrow(CredentialsProviderError); + }); + + describe("additional test cases", () => { + it("rejects forbidden host in full URI", () => { + expect(() => checkUrl(new URL("http://192.168.1.1/endpoint"))).toThrow(CredentialsProviderError); + }); + it("rejects forbidden link-local host in full URI", () => { + expect(() => checkUrl(new URL("http://169.254.170.3/endpoint"))).toThrow(CredentialsProviderError); + }); + it("allows http loopback v4 URI", () => { + expect(() => checkUrl(new URL("http://127.0.0.2/credentials"))).not.toThrow(); + }); + }); +}); diff --git a/packages/credential-provider-http/src/fromHttp/checkUrl.ts b/packages/credential-provider-http/src/fromHttp/checkUrl.ts new file mode 100644 index 000000000000..805706c0c68a --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/checkUrl.ts @@ -0,0 +1,79 @@ +import { CredentialsProviderError } from "@smithy/property-provider"; + +/** + * @internal + * Anything starting with 127. + */ +const LOOPBACK_CIDR_IPv4 = "127.0.0.0/8"; +/** + * @internal + * A single IP equal to + * 0000:0000:0000:0000:0000:0000:0000:0001 + */ +const LOOPBACK_CIDR_IPv6 = "::1/128"; +/** + * @internal + */ +const ECS_CONTAINER_HOST = "169.254.170.2"; +/** + * @internal + */ +const EKS_CONTAINER_HOST_IPv4 = "169.254.170.23"; +/** + * @internal + */ +const EKS_CONTAINER_HOST_IPv6 = "[fd00:ec2::23]"; + +/** + * @internal + * + * @param url - to be validated. + * @throws if not acceptable to this provider. + */ +export const checkUrl = (url: URL): void => { + if (url.protocol === "https:") { + // no additional requirements for HTTPS. + return; + } + + if ( + url.hostname === ECS_CONTAINER_HOST || + url.hostname === EKS_CONTAINER_HOST_IPv4 || + url.hostname === EKS_CONTAINER_HOST_IPv6 + ) { + return; + } + + if (url.hostname.includes("[")) { + // IPv6 + if (url.hostname === "[::1]" || url.hostname === "[0000:0000:0000:0000:0000:0000:0000:0001]") { + return; + } + } else { + // IPv4 + if (url.hostname === "localhost") { + return; + } + const ipComponents = url.hostname.split("."); + const inRange = (component: string): boolean => { + const num = parseInt(component, 10); + return 0 <= num && num <= 255; + }; + if ( + ipComponents[0] === "127" && + inRange(ipComponents[1]) && + inRange(ipComponents[2]) && + inRange(ipComponents[3]) && + ipComponents.length === 4 + ) { + return; + } + } + + throw new CredentialsProviderError( + `URL not accepted. It must either be HTTPS or match one of the following: + - loopback CIDR 127.0.0.0/8 or [::1/128] + - ECS container host 169.254.170.2 + - EKS container host 169.254.170.23 or [fd00:ec2::23]` + ); +}; diff --git a/packages/credential-provider-http/src/fromHttp/fromHttp.browser.ts b/packages/credential-provider-http/src/fromHttp/fromHttp.browser.ts new file mode 100644 index 000000000000..d672d8b45a12 --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/fromHttp.browser.ts @@ -0,0 +1,44 @@ +import { FetchHttpHandler } from "@smithy/fetch-http-handler"; +import { CredentialsProviderError } from "@smithy/property-provider"; +import { AwsCredentialIdentity, AwsCredentialIdentityProvider } from "@smithy/types"; + +import { checkUrl } from "./checkUrl"; +import type { FromHttpOptions } from "./fromHttpTypes"; +import { createGetRequest, getCredentials } from "./requestHelpers"; +import { retryWrapper } from "./retry-wrapper"; + +/** + * Creates a provider that gets credentials via HTTP request. + */ +export const fromHttp = (options: FromHttpOptions): AwsCredentialIdentityProvider => { + let host: string; + + const full = options.credentialsFullUri; + + if (full) { + host = full; + } else { + throw new CredentialsProviderError("No HTTP credential provider host provided."); + } + + // throws if invalid format. + const url = new URL(host); + + // throws if not to spec for provider. + checkUrl(url); + + const requestHandler = new FetchHttpHandler(); + + return retryWrapper( + async (): Promise => { + const request = createGetRequest(url); + if (options.authorizationToken) { + request.headers.Authorization = options.authorizationToken; + } + const result = await requestHandler.handle(request); + return getCredentials(result.response); + }, + options.maxRetries ?? 3, + options.timeout ?? 1000 + ); +}; diff --git a/packages/credential-provider-http/src/fromHttp/fromHttp.spec.ts b/packages/credential-provider-http/src/fromHttp/fromHttp.spec.ts new file mode 100644 index 000000000000..f88c6e0ffd2c --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/fromHttp.spec.ts @@ -0,0 +1,104 @@ +import { HttpResponse } from "@smithy/protocol-http"; +import { Readable } from "stream"; + +import { fromHttp } from "./fromHttp"; +import * as helpers from "./requestHelpers"; + +const credentials = { + accessKeyId: "ABC", + secretAccessKey: "abcd", + sessionToken: "abcde", + expiration: new Date(), +}; + +const mockToken = "abcd"; + +const mockResponse = { + AccessKeyId: credentials.accessKeyId, + SecretAccessKey: credentials.secretAccessKey, + Token: credentials.sessionToken, + AccountId: "123", + Expiration: new Date(credentials.expiration).toISOString(), // rfc3339 +}; + +const mockHandle = jest.fn().mockResolvedValue({ + response: new HttpResponse({ + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: Readable.from([""]), + }), +}); + +jest.mock("@smithy/node-http-handler", () => ({ + NodeHttpHandler: jest.fn().mockImplementation(() => ({ + destroy: () => {}, + handle: mockHandle, + })), + streamCollector: jest.fn(), +})); + +jest.spyOn(helpers, "getCredentials").mockReturnValue(Promise.resolve(credentials)); + +jest.mock("fs/promises", () => ({ + async readFile() { + return mockToken; + }, +})); + +describe(fromHttp.name, () => { + afterAll(() => { + jest.resetAllMocks(); + }); + + it("uses the full uri", async () => { + const provider = fromHttp({ + awsContainerCredentialsFullUri: "https://u1.aws", + awsContainerCredentialsRelativeUri: "", + }); + + await provider(); + + expect(mockHandle).toHaveBeenCalledWith(helpers.createGetRequest(new URL("https://u1.aws"))); + }); + + it("uses the relative uri", async () => { + const provider = fromHttp({ + awsContainerCredentialsFullUri: "", + awsContainerCredentialsRelativeUri: "/some-path", + }); + + await provider(); + + expect(mockHandle).toHaveBeenCalledWith(helpers.createGetRequest(new URL("http://169.254.170.2/some-path"))); + }); + + it("can use the token", async () => { + const provider = fromHttp({ + awsContainerCredentialsFullUri: "https://t1.aws", + awsContainerAuthorizationToken: mockToken, + }); + + const request = helpers.createGetRequest(new URL("https://t1.aws")); + request.headers.Authorization = mockToken; + + await provider(); + + expect(mockHandle).toHaveBeenCalledWith(request); + }); + + it("can use the token file", async () => { + const provider = fromHttp({ + awsContainerCredentialsFullUri: "https://t2.aws", + awsContainerAuthorizationTokenFile: "some-file", + }); + + const request = helpers.createGetRequest(new URL("https://t1.aws")); + request.headers.Authorization = mockToken; + + await provider(); + + expect(mockHandle).toHaveBeenCalledWith(request); + }); +}); diff --git a/packages/credential-provider-http/src/fromHttp/fromHttp.ts b/packages/credential-provider-http/src/fromHttp/fromHttp.ts new file mode 100644 index 000000000000..b38082e27fa7 --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/fromHttp.ts @@ -0,0 +1,77 @@ +import { NodeHttpHandler } from "@smithy/node-http-handler"; +import { CredentialsProviderError } from "@smithy/property-provider"; +import { AwsCredentialIdentity, AwsCredentialIdentityProvider } from "@smithy/types"; +import fs from "fs/promises"; + +import { checkUrl } from "./checkUrl"; +import type { FromHttpOptions } from "./fromHttpTypes"; +import { createGetRequest, getCredentials } from "./requestHelpers"; +import { retryWrapper } from "./retry-wrapper"; + +const AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"; +const DEFAULT_LINK_LOCAL_HOST = "http://169.254.170.2"; +const AWS_CONTAINER_CREDENTIALS_FULL_URI = "AWS_CONTAINER_CREDENTIALS_FULL_URI"; +const AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE = "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE"; +const AWS_CONTAINER_AUTHORIZATION_TOKEN = "AWS_CONTAINER_AUTHORIZATION_TOKEN"; + +/** + * Creates a provider that gets credentials via HTTP request. + */ +export const fromHttp = (options: FromHttpOptions): AwsCredentialIdentityProvider => { + let host: string; + + const relative = options.awsContainerCredentialsRelativeUri ?? process.env[AWS_CONTAINER_CREDENTIALS_RELATIVE_URI]; + const full = options.awsContainerCredentialsFullUri ?? process.env[AWS_CONTAINER_CREDENTIALS_FULL_URI]; + const token = options.awsContainerAuthorizationToken ?? process.env[AWS_CONTAINER_AUTHORIZATION_TOKEN]; + const tokenFile = options.awsContainerAuthorizationTokenFile ?? process.env[AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE]; + + if (relative && full) { + console.warn( + "AWS SDK HTTP credentials provider:", + "you have set both awsContainerCredentialsRelativeUri and awsContainerCredentialsFullUri." + ); + console.warn("awsContainerCredentialsFullUri will take precedence."); + } + + if (token && tokenFile) { + console.warn( + "AWS SDK HTTP credentials provider:", + "you have set both awsContainerAuthorizationToken and awsContainerAuthorizationTokenFile." + ); + console.warn("awsContainerAuthorizationToken will take precedence."); + } + + if (full) { + host = full; + } else if (relative) { + host = `${DEFAULT_LINK_LOCAL_HOST}${relative}`; + } else { + throw new CredentialsProviderError("No HTTP credential provider host provided."); + } + + // throws if invalid format. + const url = new URL(host); + + // throws if not to spec for provider. + checkUrl(url); + + const requestHandler = new NodeHttpHandler(); + + return retryWrapper( + async (): Promise => { + const request = createGetRequest(url); + + if (token) { + request.headers.Authorization = token; + } else if (tokenFile) { + // Note: specification requires a file read on each request + // to allow for updates to the file contents. + request.headers.Authorization = (await fs.readFile(tokenFile)).toString(); + } + const result = await requestHandler.handle(request); + return getCredentials(result.response); + }, + options.maxRetries ?? 3, + options.timeout ?? 1000 + ); +}; diff --git a/packages/credential-provider-http/src/fromHttp/fromHttpTypes.ts b/packages/credential-provider-http/src/fromHttp/fromHttpTypes.ts new file mode 100644 index 000000000000..a25206290777 --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/fromHttpTypes.ts @@ -0,0 +1,76 @@ +/** + * @public + * + * Input for the fromHttp function in the HTTP Credentials Provider for Node.js. + */ +export interface FromHttpOptions { + /** + * If this value is provided, it will be used as-is. + * + * For browser environments, use instead {@link credentialsFullUri}. + */ + awsContainerCredentialsFullUri?: string; + + /** + * If this value is provided instead of the full URI, it + * will be appended to the default link local host of 169.254.170.2. + * + * Not supported in browsers. + */ + awsContainerCredentialsRelativeUri?: string; + + /** + * Will be read on each credentials request to + * add an Authorization request header value. + * + * Not supported in browsers. + */ + awsContainerAuthorizationTokenFile?: string; + + /** + * An alternative to awsContainerAuthorizationTokenFile, + * this is the token value itself. + * + * For browser environments, use instead {@link authorizationToken}. + */ + awsContainerAuthorizationToken?: string; + + /** + * BROWSER ONLY. + * + * In browsers, a relative URI is not allowed, and a full URI must be provided. + * HTTPS is required. + * + * This value is required for the browser environment. + */ + credentialsFullUri?: string; + + /** + * BROWSER ONLY. + * + * Providing this value will set an "Authorization" request + * header value on the GET request. + */ + authorizationToken?: string; + + /** + * Default is 3 retry attempts or 4 total attempts. + */ + maxRetries?: number; + + /** + * Default is 1000ms. Time in milliseconds to spend waiting between retry attempts. + */ + timeout?: number; +} + +/** + * @public + */ +export type HttpProviderCredentials = { + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + AccountId?: string; + Expiration: string; // rfc3339 +}; diff --git a/packages/credential-provider-http/src/fromHttp/requestHelpers.spec.ts b/packages/credential-provider-http/src/fromHttp/requestHelpers.spec.ts new file mode 100644 index 000000000000..ddb1d4b069d1 --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/requestHelpers.spec.ts @@ -0,0 +1,81 @@ +import { CredentialsProviderError } from "@smithy/property-provider"; +import { HttpResponse } from "@smithy/protocol-http"; +import { parseRfc3339DateTime } from "@smithy/smithy-client"; +import { Readable } from "stream"; + +import { createGetRequest, getCredentials } from "./requestHelpers"; + +describe(getCredentials.name, () => { + const data = { + AccessKeyId: "ACCESS_KEY_ID", + SecretAccessKey: "SECRET_ACCESS_KEY", + Token: "TOKEN", + AccountId: "ACCOUNT_ID", + Expiration: new Date().toISOString(), + }; + it("parses the response body for status 200", async () => { + const response = new HttpResponse({ + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: Readable.from(JSON.stringify(data)), + }); + const credentials = await getCredentials(response); + expect(credentials).toEqual({ + accessKeyId: data.AccessKeyId, + secretAccessKey: data.SecretAccessKey, + sessionToken: data.Token, + expiration: parseRfc3339DateTime(data.Expiration), + }); + }); + + it("throws CredentialsProviderError for status 4xx and tries to parse the error body", async () => { + const response = new HttpResponse({ + statusCode: 400, + headers: { + "Content-Type": "application/json", + }, + body: Readable.from( + JSON.stringify({ + Code: "ERROR_CODE", + Message: "ERROR_MESSAGE", + ExtraneousField: "EXTRANEOUS_FIELD", + }) + ), + }); + const credentials = await getCredentials(response).catch((_) => _); + expect(credentials).toEqual( + Object.assign(new CredentialsProviderError("Server responded with status: 400"), { + Code: "ERROR_CODE", + Message: "ERROR_MESSAGE", + }) + ); + }); + + it("throws CredentialsProviderError for status not equal to 200", async () => { + const response = new HttpResponse({ + statusCode: 500, + headers: { + "Content-Type": "json", + }, + body: Readable.from(JSON.stringify({})), + }); + const credentials = await getCredentials(response).catch((_) => _); + expect(credentials).toBeInstanceOf(CredentialsProviderError); + }); +}); + +describe(createGetRequest.name, () => { + it("creates an HttpRequest from a URL", () => { + const request = createGetRequest(new URL("https://a1.aws:333/path?query=v")); + + expect(request.protocol).toEqual("https:"); + expect(request.hostname).toEqual("a1.aws"); + expect(request.port).toEqual(333); + expect(request.path).toEqual("/path"); + expect(request.query).toEqual({ + query: "v", + }); + }); +}); diff --git a/packages/credential-provider-http/src/fromHttp/requestHelpers.ts b/packages/credential-provider-http/src/fromHttp/requestHelpers.ts new file mode 100644 index 000000000000..594de8686817 --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/requestHelpers.ts @@ -0,0 +1,76 @@ +import { AwsCredentialIdentity } from "@aws-sdk/types"; +import { CredentialsProviderError } from "@smithy/property-provider"; +import { HttpRequest } from "@smithy/protocol-http"; +import { parseRfc3339DateTime } from "@smithy/smithy-client"; +import { HttpResponse } from "@smithy/types"; +import { sdkStreamMixin } from "@smithy/util-stream"; + +import { HttpProviderCredentials } from "./fromHttpTypes"; + +/** + * @internal + */ +export function createGetRequest(url: URL): HttpRequest { + return new HttpRequest({ + protocol: url.protocol, + hostname: url.hostname, + port: Number(url.port), + path: url.pathname, + query: Array.from(url.searchParams.entries()).reduce((acc, [k, v]) => { + acc[k] = v; + return acc; + }, {} as Record), + fragment: url.hash, + }); +} + +/** + * @internal + */ +export async function getCredentials(response: HttpResponse): Promise { + const contentType = response?.headers["content-type"] ?? response?.headers["Content-Type"] ?? ""; + + if (!contentType.includes("json")) { + console.warn( + "HTTP credential provider response header content-type was not application/json. Observed: " + contentType + "." + ); + } + + const stream = sdkStreamMixin(response.body); + const str = await stream.transformToString(); + + if (response.statusCode === 200) { + const parsed: HttpProviderCredentials = JSON.parse(str); + + if ( + typeof parsed.AccessKeyId !== "string" || + typeof parsed.SecretAccessKey !== "string" || + typeof parsed.Token !== "string" || + typeof parsed.Expiration !== "string" + ) { + throw new CredentialsProviderError( + "HTTP credential provider response not of the required format, an object matching: " + + "{ AccessKeyId: string, SecretAccessKey: string, Token: string, Expiration: string(rfc3339) }" + ); + } + + return { + accessKeyId: parsed.AccessKeyId, + secretAccessKey: parsed.SecretAccessKey, + sessionToken: parsed.Token, + expiration: parseRfc3339DateTime(parsed.Expiration), + }; + } + if (response.statusCode >= 400 && response.statusCode < 500) { + let parsedBody: { Code?: string; Message?: string } = {}; + try { + parsedBody = JSON.parse(str); + } catch (e) {} + + throw Object.assign(new CredentialsProviderError(`Server responded with status: ${response.statusCode}`), { + Code: parsedBody.Code, + Message: parsedBody.Message, + }); + } + throw new CredentialsProviderError(`Server responded with status: ${response.statusCode}`); +} diff --git a/packages/credential-provider-http/src/fromHttp/retry-wrapper.ts b/packages/credential-provider-http/src/fromHttp/retry-wrapper.ts new file mode 100644 index 000000000000..57784f212323 --- /dev/null +++ b/packages/credential-provider-http/src/fromHttp/retry-wrapper.ts @@ -0,0 +1,26 @@ +/** + * @internal + */ +export interface RetryableProvider { + (): Promise; +} + +/** + * @internal + */ +export const retryWrapper = ( + toRetry: RetryableProvider, + maxRetries: number, + delayMs: number +): RetryableProvider => { + return async () => { + for (let i = 0; i < maxRetries; ++i) { + try { + return await toRetry(); + } catch (e) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + return await toRetry(); + }; +}; diff --git a/packages/credential-provider-http/src/index.ts b/packages/credential-provider-http/src/index.ts new file mode 100644 index 000000000000..02fa66353061 --- /dev/null +++ b/packages/credential-provider-http/src/index.ts @@ -0,0 +1,3 @@ +export * from "./fromHttp/fromHttp"; +export { fromHttp as fromHttpForBrowser } from "./fromHttp/fromHttp.browser"; +export type { FromHttpOptions, HttpProviderCredentials } from "./fromHttp/fromHttpTypes"; diff --git a/packages/credential-provider-http/tsconfig.cjs.json b/packages/credential-provider-http/tsconfig.cjs.json new file mode 100644 index 000000000000..96198be81644 --- /dev/null +++ b/packages/credential-provider-http/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist-cjs", + "rootDir": "src" + }, + "extends": "../../tsconfig.cjs.json", + "include": ["src/"] +} diff --git a/packages/credential-provider-http/tsconfig.es.json b/packages/credential-provider-http/tsconfig.es.json new file mode 100644 index 000000000000..7f162b266e26 --- /dev/null +++ b/packages/credential-provider-http/tsconfig.es.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "lib": [], + "outDir": "dist-es", + "rootDir": "src" + }, + "extends": "../../tsconfig.es.json", + "include": ["src/"] +} diff --git a/packages/credential-provider-http/tsconfig.types.json b/packages/credential-provider-http/tsconfig.types.json new file mode 100644 index 000000000000..6cdf9f52ea06 --- /dev/null +++ b/packages/credential-provider-http/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declarationDir": "dist-types", + "rootDir": "src" + }, + "extends": "../../tsconfig.types.json", + "include": ["src/"] +} diff --git a/packages/credential-providers/README.md b/packages/credential-providers/README.md index 4b55e0386754..fd122b3bdd84 100644 --- a/packages/credential-providers/README.md +++ b/packages/credential-providers/README.md @@ -14,6 +14,7 @@ A collection of all credential providers, with default clients. 1. [Examples](#examples) 1. [From Token File](#fromtokenfile) 1. [From Instance and Container Metadata Service](#fromcontainermetadata-and-frominstancemetadata) +1. [From HTTP(S)](#fromhttp) 1. [From Shared INI files](#fromini) 1. [Sample Files](#sample-files) 1. [From Environmental Variables](#fromenv) @@ -257,6 +258,105 @@ supported. Please see [Configure the instance metadata service][config_instance_metadata] for more information. +## `fromHttp()` + +This creates a provider function that makes a `GET` request to +any provided HTTPS URL. A limited set of HTTP destinations are also accepted. + +This is a general form of the `fromContainerMetadata` function. + +The server is expected to respond with the following format in JSON: + +```ts +type HttpProviderResponse = { + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + AccountId?: string; + Expiration: string; // rfc3339 +}; +``` + +The acceptable non-HTTPS destinations are described in the validation error if encountered: + +``` +URL not accepted. It must either be HTTPS or match one of the following: + - loopback CIDR 127.0.0.0/8 or [::1/128] + - ECS container host 169.254.170.2 + - EKS container host 169.254.170.23 or [fd00:ec2::23] +``` + +Node.js: + +```js +import { fromHttp } from "@aws-sdk/credential-providers"; +// const { fromHttp } = require("@aws-sdk/credential-providers"); + +const client = new FooClient({ + credentials: fromHttp({ + /** + * If this value is provided, it will be used as-is. + */ + awsContainerCredentialsFullUri: "...", + /** + * If this value is provided instead of the full URI, it + * will be appended to the default link local host of 169.254.170.2. + */ + awsContainerCredentialsRelativeUri: "...", + + /** + * Will be read on each credentials request to + * add an Authorization request header value. + */ + awsContainerAuthorizationTokenFile: "...", + + /** + * An alternative to awsContainerAuthorizationTokenFile, + * this is the token value itself. + */ + awsContainerAuthorizationToken: "...", + }), +}); +``` + +If not provided in the JavaScript code, the following process envrionment variables will +be read: + +``` +AWS_CONTAINER_CREDENTIALS_RELATIVE_URI +AWS_CONTAINER_CREDENTIALS_FULL_URI +AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE +AWS_CONTAINER_AUTHORIZATION_TOKEN +``` + +Browsers: + +```js +import { fromHttp } from "@aws-sdk/credential-providers"; + +const client = new FooClient({ + credentials: fromHttp({ + /** + * BROWSER ONLY. + * + * In browsers, a relative URI is not allowed, and a full URI must be provided. + * HTTPS is required. + * + * This value is required for the browser environment. + */ + credentialsFullUri: "...", + + /** + * BROWSER ONLY. + * + * Providing this value will set an "Authorization" request + * header value on the GET request. + */ + authorizationToken: "...", + }), +}); +``` + ## `fromIni()` `fromIni` creates `AwsCredentialIdentityProvider` functions that read from a shared credentials file at @@ -468,7 +568,7 @@ credential_process = /usr/local/bin/awscreds dev The function `fromTokenFile` returns `AwsCredentialIdentityProvider` that reads credentials as follows: -- Reads file location of where the OIDC token is stored from either provided option +- Reads file location of where the OIDC token is stored from either provided option `webIdentityTokenFile` or environment variable `AWS_WEB_IDENTITY_TOKEN_FILE`. - Reads IAM role wanting to be assumed from either provided option `roleArn` or environment variable `AWS_ROLE_ARN`. diff --git a/packages/credential-providers/package.json b/packages/credential-providers/package.json index fbfeeb8cecc1..4ac342fc59d9 100644 --- a/packages/credential-providers/package.json +++ b/packages/credential-providers/package.json @@ -33,6 +33,7 @@ "@aws-sdk/client-sts": "*", "@aws-sdk/credential-provider-cognito-identity": "*", "@aws-sdk/credential-provider-env": "*", + "@aws-sdk/credential-provider-http": "*", "@aws-sdk/credential-provider-ini": "*", "@aws-sdk/credential-provider-node": "*", "@aws-sdk/credential-provider-process": "*", diff --git a/packages/credential-providers/src/index.browser.ts b/packages/credential-providers/src/index.browser.ts index 963a737b13fa..642bdcb7a6a4 100644 --- a/packages/credential-providers/src/index.browser.ts +++ b/packages/credential-providers/src/index.browser.ts @@ -1,4 +1,9 @@ export * from "./fromCognitoIdentity"; export * from "./fromCognitoIdentityPool"; +export { + fromHttpForBrowser as fromHttp, + FromHttpOptions, + HttpProviderCredentials, +} from "@aws-sdk/credential-provider-http"; export * from "./fromTemporaryCredentials"; export * from "./fromWebToken"; diff --git a/packages/credential-providers/src/index.ts b/packages/credential-providers/src/index.ts index 4881d808136b..a1e419ae6188 100644 --- a/packages/credential-providers/src/index.ts +++ b/packages/credential-providers/src/index.ts @@ -2,6 +2,7 @@ export * from "./fromCognitoIdentity"; export * from "./fromCognitoIdentityPool"; export * from "./fromContainerMetadata"; export * from "./fromEnv"; +export { fromHttp, FromHttpOptions, HttpProviderCredentials } from "@aws-sdk/credential-provider-http"; export * from "./fromIni"; export * from "./fromInstanceMetadata"; export * from "./fromNodeProviderChain";