Skip to content

Commit

Permalink
feat(credential-providers): add fromHttp credential provider (#5256)
Browse files Browse the repository at this point in the history
  • Loading branch information
kuhe authored Sep 27, 2023
1 parent 4398bfc commit c720c1c
Show file tree
Hide file tree
Showing 22 changed files with 844 additions and 1 deletion.
8 changes: 8 additions & 0 deletions packages/credential-provider-http/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/node_modules/
/build/
/coverage/
/docs/
*.tsbuildinfo
*.tgz
*.log
package-lock.json
4 changes: 4 additions & 0 deletions packages/credential-provider-http/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/credential-provider-http/README.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions packages/credential-provider-http/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const base = require("../../jest.config.base.js");

module.exports = {
...base,
};
67 changes: 67 additions & 0 deletions packages/credential-provider-http/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
48 changes: 48 additions & 0 deletions packages/credential-provider-http/src/fromHttp/checkUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
79 changes: 79 additions & 0 deletions packages/credential-provider-http/src/fromHttp/checkUrl.ts
Original file line number Diff line number Diff line change
@@ -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]`
);
};
44 changes: 44 additions & 0 deletions packages/credential-provider-http/src/fromHttp/fromHttp.browser.ts
Original file line number Diff line number Diff line change
@@ -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<AwsCredentialIdentity> => {
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
);
};
104 changes: 104 additions & 0 deletions packages/credential-provider-http/src/fromHttp/fromHttp.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading

0 comments on commit c720c1c

Please sign in to comment.