diff --git a/.github/semantic.yml b/.github/semantic.yml index 02bbdb2bd7..2b7ba5537f 100644 --- a/.github/semantic.yml +++ b/.github/semantic.yml @@ -35,6 +35,7 @@ scopes: - deps-dev - roadmap - kafka + - signer # Always validate the PR title # and ignore the commits to lower the entry bar for contribution diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 9056e7451b..4955dcf3d1 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -27,6 +27,7 @@ jobs: packages/event-handler, packages/tracer, packages/batch, + packages/signer, layers, ] version: [22, 24] diff --git a/docs/features/index.md b/docs/features/index.md index 3460132579..346db482e7 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -95,4 +95,12 @@ description: Features of Powertools for AWS Lambda [:octicons-arrow-right-24: Read more](./kafka.md) +- __Signer__ + + --- + + Sign HTTP requests to AWS services using the AWS Signature Version 4 (SigV4) signing process, with an optional drop-in signed `fetch`. + + [:octicons-arrow-right-24: Read more](./signer.md) + diff --git a/docs/features/signer.md b/docs/features/signer.md new file mode 100644 index 0000000000..c323690c8b --- /dev/null +++ b/docs/features/signer.md @@ -0,0 +1,85 @@ +--- +title: Signer +descrition: Utility +--- + + + +This utility provides a way to sign HTTP requests to AWS services using the [AWS Signature Version 4 (SigV4)](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv4_signing.html){target="_blank"} signing process, so you can call IAM-authenticated endpoints such as Amazon API Gateway, AWS Lambda function URLs, or AWS AppSync from within your Lambda functions. + +## Key features + +* Sign web-standard `Request` objects with AWS Signature Version 4 +* Drop-in signed `fetch` for sending signed requests in one step +* Works with any HTTP client by exposing the signed request and headers +* Reads credentials and region from the Lambda runtime by default, with no extra dependencies + +## Getting started + +```bash +npm install @aws-lambda-powertools/signer +``` + +The signer takes a web-standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request){target="_blank"} (or anything you can pass to `fetch`, like a URL string) and returns a new, signed `Request` with the SigV4 headers added. It performs no network I/O, so you stay in control of how the request is sent. + +```typescript hl_lines="1 3 7" +--8<-- "examples/snippets/signer/gettingStarted.ts" +``` + +By default, the signer reads the AWS credentials and region from the [environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime){target="_blank"} that the Lambda runtime always provides, so no additional configuration is required when running in Lambda. + +!!! note "The `service` is required" + The service name (for example `execute-api`, `lambda`, or `appsync`) cannot be reliably determined from the request URL, since custom domains and Amazon CloudFront hide the underlying service. You must always provide it. + +## Sending signed requests + +If all you want is to sign and immediately send the request, use `createSignedFetcher`. It consumes a signer instance and returns a function with the same signature as the global `fetch`, signing each request before sending it. + +```typescript hl_lines="1 2 5" +--8<-- "examples/snippets/signer/fetcher.ts" +``` + +Because the returned function is a drop-in `fetch`, you can also pass it to libraries that accept a custom `fetch` implementation. + +## Using other HTTP clients + +Signing and sending are deliberately kept separate, so you can use the signed request with any HTTP client (for example `axios`, `got`, a generated SDK client, or a request interceptor). Call `sign()` to obtain a signed `Request`, then read its `url`, `method`, and `headers`. + +```typescript hl_lines="11-15" +--8<-- "examples/snippets/signer/headers.ts" +``` + +## Advanced + +### Configuring the region + +The region defaults to the `AWS_REGION` environment variable that Lambda sets. You can override it, for example to sign requests for a service in a different region. + +```typescript hl_lines="5" +--8<-- "examples/snippets/signer/region.ts" +``` + +1. The `region` option takes precedence over the `AWS_REGION` environment variable. + +### Configuring credentials + +When running outside of Lambda, the standard AWS credentials environment variables may not be set. In that case, pass your own credentials or a credential provider, such as `fromNodeProviderChain()` from [`@aws-sdk/credential-provider-node`](https://www.npmjs.com/package/@aws-sdk/credential-provider-node){target="_blank"}, which you install yourself. + +```typescript hl_lines="6-12" +--8<-- "examples/snippets/signer/credentials.ts" +``` + +### Handling errors + +The signer throws typed errors that all extend `SignerError`: + +| Error | When it is thrown | +| --------------------- | ---------------------------------------------------------------------------------------------------------- | +| `SignerConfigError` | The region cannot be determined (at construction), or credentials are missing or cannot be resolved. | +| `RequestSigningError` | Signing the request fails, for example because the request body cannot be read or replayed. | +| `SignerError` | Base class for the errors above. Catch this to handle any signer error. | + +You can import them from the `@aws-lambda-powertools/signer/errors` subpath. + +!!! note "Request bodies" + To compute the request signature, the request body is buffered and hashed. Strings, buffers, and finite streams are handled transparently. A body that cannot be read or replayed β€” for example a stream that errors mid-read β€” cannot be signed and raises a `RequestSigningError`. diff --git a/docs/index.md b/docs/index.md index d3da99e342..979aa222cd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,6 +55,7 @@ Powertools for AWS Lambda (TypeScript) is built as a modular toolkit, so you can | [Parser](./features/parser.md) | Utility to parse and validate AWS Lambda event payloads using Zod, a TypeScript-first schema declaration and validation library. | | [Validation](./features/validation.md) | JSON Schema validation for events and responses, including JMESPath support to unwrap events before validation. | | [Kafka](./features/kafka.md) | Utility to easily handle message deserialization and parsing of Kafka events in AWS Lambda functions. | +| [Signer](./features/signer.md) | Sign HTTP requests to AWS services using the AWS Signature Version 4 (SigV4) signing process, with an optional drop-in signed `fetch`. | ## Examples diff --git a/examples/snippets/package.json b/examples/snippets/package.json index c8937d7114..ee2e5ffbec 100644 --- a/examples/snippets/package.json +++ b/examples/snippets/package.json @@ -33,6 +33,7 @@ "@aws-lambda-powertools/metrics": "^2.33.1", "@aws-lambda-powertools/parameters": "^2.33.1", "@aws-lambda-powertools/parser": "^2.33.1", + "@aws-lambda-powertools/signer": "^2.33.1", "@aws-lambda-powertools/tracer": "^2.33.1", "@aws-sdk/client-appconfigdata": "^3.1067.0", "@aws-sdk/client-dynamodb": "^3.1067.0", diff --git a/examples/snippets/signer/credentials.ts b/examples/snippets/signer/credentials.ts new file mode 100644 index 0000000000..2d9f814bdf --- /dev/null +++ b/examples/snippets/signer/credentials.ts @@ -0,0 +1,21 @@ +import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + +// By default, credentials are read from the standard AWS environment variables +// that the Lambda runtime injects. When running outside of Lambda, pass your +// own credentials or a credential provider, for example +// `fromNodeProviderChain()` from `@aws-sdk/credential-provider-node`. +const signer = new SigV4Signer({ + service: 'execute-api', + credentials: { + accessKeyId: process.env.MY_ACCESS_KEY_ID ?? '', + secretAccessKey: process.env.MY_SECRET_ACCESS_KEY ?? '', + }, +}); + +export const handler = async () => { + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items' + ); + + await fetch(signed); +}; diff --git a/examples/snippets/signer/fetcher.ts b/examples/snippets/signer/fetcher.ts new file mode 100644 index 0000000000..e4ae730fe0 --- /dev/null +++ b/examples/snippets/signer/fetcher.ts @@ -0,0 +1,18 @@ +import { createSignedFetcher } from '@aws-lambda-powertools/signer/fetch'; +import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + +const signer = new SigV4Signer({ service: 'execute-api' }); +const signedFetch = createSignedFetcher(signer); + +export const handler = async () => { + // `signedFetch` is a drop-in `fetch` that signs each request before sending it + const response = await signedFetch( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { + method: 'POST', + body: JSON.stringify({ name: 'powertools' }), + } + ); + + await response.json(); +}; diff --git a/examples/snippets/signer/gettingStarted.ts b/examples/snippets/signer/gettingStarted.ts new file mode 100644 index 0000000000..6585f49623 --- /dev/null +++ b/examples/snippets/signer/gettingStarted.ts @@ -0,0 +1,13 @@ +import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + +const signer = new SigV4Signer({ service: 'execute-api' }); + +export const handler = async () => { + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items' + ); + + // `signed` is a standard `Request` with the SigV4 headers added + const response = await fetch(signed); + await response.json(); +}; diff --git a/examples/snippets/signer/headers.ts b/examples/snippets/signer/headers.ts new file mode 100644 index 0000000000..acfa90e47a --- /dev/null +++ b/examples/snippets/signer/headers.ts @@ -0,0 +1,20 @@ +import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + +const signer = new SigV4Signer({ service: 'execute-api' }); + +export const handler = async () => { + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { method: 'POST', body: JSON.stringify({ name: 'powertools' }) } + ); + + // Extract the signed headers to use them with any HTTP client, e.g. an + // interceptor for axios, got, or a generated SDK client. + const headers: Record = {}; + for (const [key, value] of signed.headers) { + headers[key] = value; + } + + // `signed.url`, `signed.method`, and `headers` can now be passed to the + // client of your choice. +}; diff --git a/examples/snippets/signer/region.ts b/examples/snippets/signer/region.ts new file mode 100644 index 0000000000..1d26adecf2 --- /dev/null +++ b/examples/snippets/signer/region.ts @@ -0,0 +1,16 @@ +import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + +const signer = new SigV4Signer({ + service: 'execute-api', + region: 'eu-west-1', // (1)! +}); + +export const handler = async () => { + const signed = await signer.sign( + 'https://example.execute-api.eu-west-1.amazonaws.com/items' + ); + + await fetch(signed); +}; + +export { signer }; diff --git a/mkdocs.yml b/mkdocs.yml index 79ccff71b1..7b5ade55e3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -58,6 +58,7 @@ nav: - features/parser.md - features/validation.md - features/kafka.md + - features/signer.md - features/metadata.md - Environment variables: environment-variables.md - Upgrade guide: upgrade.md @@ -194,6 +195,7 @@ plugins: - features/parser.md - features/validation.md - features/kafka.md + - features/signer.md - features/metadata.md Environment variables: - environment-variables.md diff --git a/package-lock.json b/package-lock.json index b2f2a378ce..1b0a69d778 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,8 @@ "examples/app", "packages/event-handler", "packages/validation", - "packages/kafka" + "packages/kafka", + "packages/signer" ], "devDependencies": { "@biomejs/biome": "^2.5.0", @@ -95,6 +96,7 @@ "@aws-lambda-powertools/metrics": "^2.33.1", "@aws-lambda-powertools/parameters": "^2.33.1", "@aws-lambda-powertools/parser": "^2.33.1", + "@aws-lambda-powertools/signer": "^2.33.1", "@aws-lambda-powertools/tracer": "^2.33.1", "@aws-sdk/client-appconfigdata": "^3.1067.0", "@aws-sdk/client-dynamodb": "^3.1067.0", @@ -838,6 +840,10 @@ "resolved": "packages/parser", "link": true }, + "node_modules/@aws-lambda-powertools/signer": { + "resolved": "packages/signer", + "link": true + }, "node_modules/@aws-lambda-powertools/testing-utils": { "resolved": "packages/testing", "link": true @@ -9083,6 +9089,18 @@ } } }, + "packages/signer": { + "name": "@aws-lambda-powertools/signer", + "version": "2.33.1", + "license": "MIT-0", + "dependencies": { + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3" + }, + "devDependencies": { + "@aws-lambda-powertools/testing-utils": "file:../testing" + } + }, "packages/testing": { "name": "@aws-lambda-powertools/testing-utils", "version": "2.33.1", diff --git a/package.json b/package.json index dfd022a424..f5844c496a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "examples/app", "packages/event-handler", "packages/validation", - "packages/kafka" + "packages/kafka", + "packages/signer" ], "type": "module", "scripts": { diff --git a/packages/signer/README.md b/packages/signer/README.md new file mode 100644 index 0000000000..553cbd3f24 --- /dev/null +++ b/packages/signer/README.md @@ -0,0 +1,150 @@ +# Powertools for AWS Lambda (TypeScript) - Signer Utility + +Powertools for AWS Lambda (TypeScript) is a developer toolkit to implement Serverless [best practices and increase developer velocity](https://docs.aws.amazon.com/powertools/typescript/latest/#features). + +You can use the package in both TypeScript and JavaScript code bases. + +- [Intro](#intro) +- [Key features](#key-features) +- [Usage](#usage) + - [Signing a request](#signing-a-request) + - [Sending signed requests](#sending-signed-requests) + - [Using other HTTP clients](#using-other-http-clients) +- [Contribute](#contribute) +- [Roadmap](#roadmap) +- [Connect](#connect) +- [How to support Powertools for AWS Lambda (TypeScript)?](#how-to-support-powertools-for-aws-lambda-typescript) + - [Becoming a reference customer](#becoming-a-reference-customer) + - [Sharing your work](#sharing-your-work) + - [Using Lambda Layer](#using-lambda-layer) +- [License](#license) + +## Intro + +This utility provides a way to sign HTTP requests to AWS services using the [AWS Signature Version 4 (SigV4)](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv4_signing.html) signing process, so you can call IAM-authenticated endpoints such as Amazon API Gateway, AWS Lambda function URLs, or AWS AppSync from within your Lambda functions. + +## Key features + +- Sign web-standard `Request` objects with AWS Signature Version 4 +- Drop-in signed `fetch` for sending signed requests in one step +- Works with any HTTP client by exposing the signed request and headers +- Reads credentials and region from the Lambda runtime by default, with no extra dependencies + +## Usage + +To get started, install the library by running: + +```sh +npm install @aws-lambda-powertools/signer +``` + +### Signing a request + +The signer takes a web-standard `Request` (or anything you can pass to `fetch`, like a URL string) and returns a new, signed `Request` with the SigV4 headers added. It performs no network I/O, so you stay in control of how the request is sent. + +```typescript +import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + +const signer = new SigV4Signer({ service: 'execute-api' }); + +export const handler = async () => { + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items' + ); + + const response = await fetch(signed); + await response.json(); +}; +``` + +By default, the signer reads the AWS credentials and region from the environment variables that the Lambda runtime always provides, so no additional configuration is required when running in Lambda. + +### Sending signed requests + +If all you want is to sign and immediately send the request, use `createSignedFetcher`. It consumes a signer instance and returns a function with the same signature as the global `fetch`, signing each request before sending it. + +```typescript +import { createSignedFetcher } from '@aws-lambda-powertools/signer/fetch'; +import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + +const signer = new SigV4Signer({ service: 'execute-api' }); +const signedFetch = createSignedFetcher(signer); + +export const handler = async () => { + const response = await signedFetch( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { + method: 'POST', + body: JSON.stringify({ name: 'powertools' }), + } + ); + + await response.json(); +}; +``` + +### Using other HTTP clients + +Signing and sending are deliberately kept separate, so you can use the signed request with any HTTP client (for example `axios`, `got`, a generated SDK client, or a request interceptor). Call `sign()` to obtain a signed `Request`, then read its `url`, `method`, and `headers`. + +See the [documentation](https://docs.aws.amazon.com/powertools/typescript/latest/features/signer/) for more details on configuring credentials and region, handling errors, and using other HTTP clients. + +## Contribute + +If you are interested in contributing to this project, please refer to our [Contributing Guidelines](https://github.com/aws-powertools/powertools-lambda-typescript/blob/main/CONTRIBUTING.md). + +## Roadmap + +The roadmap of Powertools for AWS Lambda (TypeScript) is driven by customers’ demand. +Help us prioritize upcoming functionalities or utilities by [upvoting existing RFCs and feature requests](https://github.com/aws-powertools/powertools-lambda-typescript/issues), or [creating new ones](https://github.com/aws-powertools/powertools-lambda-typescript/issues/new/choose), in this GitHub repository. + +## Connect + +- **Powertools for AWS Lambda on Discord**: `#typescript` - **[Invite link](https://discord.gg/B8zZKbbyET)** +- **Email**: + +## How to support Powertools for AWS Lambda (TypeScript)? + +### Becoming a reference customer + +Knowing which companies are using this library is important to help prioritize the project internally. If your company +is using Powertools for AWS Lambda (TypeScript), you can request to have your name and logo added to the README file by +raising a [Support Powertools for AWS Lambda (TypeScript) (become a reference)](https://s12d.com/become-reference-pt-ts) +issue. + +The following companies, among others, use Powertools: + +- [Alma Media](https://www.almamedia.fi) +- [AppYourself](https://appyourself.net) +- [Bailey Nelson](https://www.baileynelson.com.au) +- [Banxware](https://www.banxware.com) +- [Caylent](https://caylent.com/) +- [Certible](https://www.certible.com/) +- [Codeac](https://www.codeac.io/) +- [EF Education First](https://www.ef.com/) +- [Elva](https://elva-group.com) +- [Flyweight](https://flyweight.io/) +- [FraudFalcon](https://fraudfalcon.app) +- [globaldatanet](https://globaldatanet.com/) +- [Guild](https://guild.com) +- [Hashnode](https://hashnode.com/) +- [Instil](https://instil.co/) +- [LocalStack](https://localstack.cloud/) +- [Ours Privacy](https://oursprivacy.com/) +- [Perfect Post](https://www.perfectpost.fr) +- [Sennder](https://sennder.com/) +- [tecRacer GmbH & Co. KG](https://www.tecracer.com/) +- [Trek10](https://www.trek10.com/) +- [WeSchool](https://www.weschool.com) + +### Sharing your work + +Share what you did with Powertools for AWS Lambda (TypeScript) πŸ’žπŸ’ž. Blog post, workshops, presentation, sample apps and others. Check out what the community has [already shared](https://docs.aws.amazon.com/powertools/typescript/latest/we_made_this) about Powertools for AWS Lambda (TypeScript). + +### Using Lambda Layer + +This helps us understand who uses Powertools for AWS Lambda (TypeScript) in a non-intrusive way, and helps us gain future investments for other Powertools for AWS Lambda languages. When [using Layers](https://docs.aws.amazon.com/powertools/typescript/latest/getting-started/lambda-layers/), you can add Powertools as a dev dependency to not impact the development process. + +## License + +This library is licensed under the MIT-0 License. See the LICENSE file. diff --git a/packages/signer/package.json b/packages/signer/package.json new file mode 100644 index 0000000000..326d9ea854 --- /dev/null +++ b/packages/signer/package.json @@ -0,0 +1,125 @@ +{ + "name": "@aws-lambda-powertools/signer", + "version": "2.33.1", + "description": "The signer package for the Powertools for AWS Lambda (TypeScript) library.", + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "vitest --run tests/unit", + "test:unit": "vitest --run tests/unit", + "test:unit:coverage": "vitest --run tests/unit --coverage.enabled --coverage.thresholds.100 --coverage.include='src/**'", + "test:unit:types": "vitest --run tests/types --typecheck", + "test:unit:watch": "vitest tests/unit", + "test:e2e:nodejs22x": "RUNTIME=nodejs22x vitest --run tests/e2e", + "test:e2e:nodejs24x": "RUNTIME=nodejs24x vitest --run tests/e2e", + "test:e2e": "vitest --run tests/e2e", + "build:cjs": "tsc --build tsconfig.cjs.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", + "build:esm": "tsc --build tsconfig.json && echo '{ \"type\": \"module\" }' > lib/esm/package.json", + "build:tests": "tsc --noEmit -p tests/tsconfig.json", + "build": "npm run build:esm & npm run build:cjs", + "lint": "biome lint .", + "lint:fix": "biome check --write .", + "lint:ci": "biome ci .", + "prepack": "node ../../.github/scripts/release_patch_package_json.js ." + }, + "homepage": "https://github.com/aws-powertools/powertools-lambda-typescript/tree/main/packages/signer#readme", + "license": "MIT-0", + "type": "module", + "exports": { + "./sigv4": { + "require": { + "types": "./lib/cjs/sigv4.d.ts", + "default": "./lib/cjs/sigv4.js" + }, + "import": { + "types": "./lib/esm/sigv4.d.ts", + "default": "./lib/esm/sigv4.js" + } + }, + "./fetch": { + "require": { + "types": "./lib/cjs/fetch.d.ts", + "default": "./lib/cjs/fetch.js" + }, + "import": { + "types": "./lib/esm/fetch.d.ts", + "default": "./lib/esm/fetch.js" + } + }, + "./errors": { + "require": { + "types": "./lib/cjs/errors.d.ts", + "default": "./lib/cjs/errors.js" + }, + "import": { + "types": "./lib/esm/errors.d.ts", + "default": "./lib/esm/errors.js" + } + }, + "./types": { + "require": { + "types": "./lib/cjs/types/index.d.ts", + "default": "./lib/cjs/types/index.js" + }, + "import": { + "types": "./lib/esm/types/index.d.ts", + "default": "./lib/esm/types/index.js" + } + } + }, + "typesVersions": { + "*": { + "sigv4": [ + "lib/cjs/sigv4.d.ts", + "lib/esm/sigv4.d.ts" + ], + "fetch": [ + "lib/cjs/fetch.d.ts", + "lib/esm/fetch.d.ts" + ], + "errors": [ + "lib/cjs/errors.d.ts", + "lib/esm/errors.d.ts" + ], + "types": [ + "lib/cjs/types/index.d.ts", + "lib/esm/types/index.d.ts" + ] + } + }, + "files": [ + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/aws-powertools/powertools-lambda-typescript.git" + }, + "bugs": { + "url": "https://github.com/aws-powertools/powertools-lambda-typescript/issues" + }, + "keywords": [ + "aws", + "lambda", + "powertools", + "signer", + "sigv4", + "signature-v4", + "iam", + "fetch", + "serverless", + "typescript", + "nodejs" + ], + "dependencies": { + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3" + }, + "devDependencies": { + "@aws-lambda-powertools/testing-utils": "file:../testing" + } +} diff --git a/packages/signer/src/Sha256.ts b/packages/signer/src/Sha256.ts new file mode 100644 index 0000000000..04ef533075 --- /dev/null +++ b/packages/signer/src/Sha256.ts @@ -0,0 +1,39 @@ +import { createHash, createHmac, type Hash, type Hmac } from 'node:crypto'; +import type { Checksum, SourceData } from '@smithy/types'; + +/** + * A {@link Checksum | `Checksum`} implementation backed by `node:crypto`. + * + * The underlying signing process expects a hash constructor that supports both + * plain SHA-256 (for hashing payloads) and HMAC-SHA256 with a secret (for + * deriving the signing key). Using `node:crypto` avoids pulling in a + * third-party crypto dependency, relying instead on the Node.js runtime that + * AWS Lambda provides. + * + * @internal + */ +class Sha256 implements Checksum { + readonly #hash: Hash | Hmac; + + public constructor(secret?: SourceData) { + this.#hash = + secret === undefined + ? createHash('sha256') + : createHmac('sha256', secret as Uint8Array | string); + } + + public update(data: SourceData): void { + this.#hash.update(data as Uint8Array | string); + } + + public async digest(): Promise { + return new Uint8Array(this.#hash.digest()); + } + + public reset(): void { + // `node:crypto` hashes are single-use; reset is a no-op because a new + // instance is created per hashing operation by the signing process. + } +} + +export { Sha256 }; diff --git a/packages/signer/src/SigV4Signer.ts b/packages/signer/src/SigV4Signer.ts new file mode 100644 index 0000000000..9a792035da --- /dev/null +++ b/packages/signer/src/SigV4Signer.ts @@ -0,0 +1,186 @@ +import { SignatureV4 } from '@smithy/signature-v4'; +import type { + AwsCredentialIdentity, + AwsCredentialIdentityProvider, + HttpRequest, +} from '@smithy/types'; +import { RequestSigningError, SignerConfigError } from './errors.js'; +import { Sha256 } from './Sha256.js'; +import type { Signer, SigV4SignerOptions } from './types/index.js'; + +/** + * Credential provider that reads AWS credentials from the standard environment + * variables that the AWS Lambda runtime always injects. + * + * Throws a {@link SignerConfigError | `SignerConfigError`} when the required + * variables are not present, e.g. when running outside of Lambda. + */ +const credentialsFromEnv: AwsCredentialIdentityProvider = async () => { + const accessKeyId = process.env.AWS_ACCESS_KEY_ID; + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + if (accessKeyId === undefined || secretAccessKey === undefined) { + throw new SignerConfigError( + 'Unable to resolve AWS credentials to sign the request with. Ensure the standard AWS credentials environment variables are set, or pass the `credentials` option.' + ); + } + + return { + accessKeyId, + secretAccessKey, + sessionToken: process.env.AWS_SESSION_TOKEN, + }; +}; + +/** + * A {@link Signer | `Signer`} that signs requests using the AWS Signature + * Version 4 (SigV4) signing process. + * + * The signer takes and returns web-standard {@link Request | `Request`} + * objects and performs no network I/O. To send signed requests, pass an + * instance to `createSignedFetcher` from `@aws-lambda-powertools/signer/fetch`. + * + * @example + * ```ts + * import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + * + * const signer = new SigV4Signer({ service: 'execute-api' }); + * const signed = await signer.sign('https://example.execute-api.us-east-1.amazonaws.com/items'); + * ``` + */ +class SigV4Signer implements Signer { + readonly #service: string; + readonly #region: string; + readonly #credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider; + + public constructor(options: SigV4SignerOptions) { + this.#service = options.service; + + const region = options.region ?? process.env.AWS_REGION; + if (region === undefined || region === '') { + throw new SignerConfigError( + 'Unable to determine the AWS region to sign the request with. Set the `region` option or the `AWS_REGION` environment variable.' + ); + } + this.#region = region; + + this.#credentials = options.credentials ?? credentialsFromEnv; + } + + public async sign( + input: string | URL | Request, + init?: RequestInit + ): Promise { + const request = new Request(input, init); + const credentials = await this.#resolveCredentials(); + const httpRequest = await this.#toHttpRequest(request); + + const signer = new SignatureV4({ + service: this.#service, + region: this.#region, + credentials, + sha256: Sha256, + }); + + let signed: HttpRequest; + try { + signed = (await signer.sign(httpRequest)) as HttpRequest; + } catch (error) { + throw new RequestSigningError('Failed to sign the request', { + cause: error, + }); + } + + return this.#toRequest(signed, request); + } + + /** + * Resolve the configured credentials, supporting both static values and + * async providers. The default env-var provider throws a configuration error + * when no credentials are available. + */ + async #resolveCredentials(): Promise { + return typeof this.#credentials === 'function' + ? await this.#credentials() + : this.#credentials; + } + + /** + * Adapt a web-standard `Request` into the `HttpRequest` shape expected by the + * underlying signing process, buffering the body so it can be hashed. + * + * The signing process accepts a plain object matching the `HttpRequest` + * interface, so we avoid depending on `@smithy/protocol-http` just to + * construct one. + */ + async #toHttpRequest(request: Request): Promise { + const url = new URL(request.url); + + const headers: Record = {}; + for (const [key, value] of request.headers) { + headers[key] = value; + } + headers.host = url.host; + + const query: Record = {}; + for (const [key, value] of url.searchParams) { + query[key] = value; + } + + const body = await this.#readBody(request); + + return { + method: request.method, + protocol: url.protocol, + hostname: url.hostname, + port: url.port ? Number(url.port) : undefined, + path: url.pathname, + query, + headers, + body, + }; + } + + /** + * Buffer the request body so it can be both hashed for signing and replayed + * when the signed request is sent. + * + * Most bodies (strings, buffers, and finite streams) are buffered + * transparently. A body that cannot be read or replayed β€” for example a + * stream that errors mid-read β€” cannot be signed and results in a + * {@link RequestSigningError | `RequestSigningError`}. + */ + async #readBody(request: Request): Promise { + if (request.body === null || request.body === undefined) { + return undefined; + } + + try { + const buffer = await request.clone().arrayBuffer(); + return buffer.byteLength === 0 ? undefined : new Uint8Array(buffer); + } catch (error) { + throw new RequestSigningError( + 'Unable to read the request body to sign it. The body could not be buffered, for example because it is a stream that cannot be replayed.', + { cause: error } + ); + } + } + + /** + * Adapt a signed `HttpRequest` back into a web-standard `Request`, carrying + * over the signed headers and the original (buffered) body. + */ + #toRequest(signed: HttpRequest, original: Request): Request { + const headers = new Headers(); + for (const [key, value] of Object.entries(signed.headers)) { + headers.set(key, value); + } + + return new Request(original.url, { + method: signed.method, + headers, + body: signed.body as BodyInit | null | undefined, + }); + } +} + +export { SigV4Signer }; diff --git a/packages/signer/src/errors.ts b/packages/signer/src/errors.ts new file mode 100644 index 0000000000..87ada9dd8e --- /dev/null +++ b/packages/signer/src/errors.ts @@ -0,0 +1,45 @@ +/** + * Base error for all errors thrown by the signer utility. + * + * Generally this error should not be thrown directly; prefer the more specific + * {@link SignerConfigError | `SignerConfigError`} and + * {@link RequestSigningError | `RequestSigningError`} subclasses. + */ +class SignerError extends Error { + public constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'SignerError'; + } +} + +/** + * Error thrown when the signer is misconfigured. + * + * This is thrown eagerly at construction when the region cannot be determined, + * and lazily during signing when credentials are missing or cannot be resolved. + */ +class SignerConfigError extends SignerError { + public constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'SignerConfigError'; + } +} + +/** + * Error thrown when signing a request fails. + * + * This wraps errors thrown by the underlying signing process. The original + * error, when available, is preserved on the `cause` property. + */ +class RequestSigningError extends SignerError { + public constructor(message: string, options?: ErrorOptions) { + const errorMessage = + options?.cause instanceof Error + ? `${message}. This error was caused by: ${options.cause.message}.` + : message; + super(errorMessage, options); + this.name = 'RequestSigningError'; + } +} + +export { RequestSigningError, SignerConfigError, SignerError }; diff --git a/packages/signer/src/fetch.ts b/packages/signer/src/fetch.ts new file mode 100644 index 0000000000..7f195b2bb9 --- /dev/null +++ b/packages/signer/src/fetch.ts @@ -0,0 +1,38 @@ +import type { SignedFetcherOptions, Signer } from './types/index.js'; + +/** + * Create a drop-in `fetch` function that signs every request with the given + * {@link Signer | `Signer`} before sending it. + * + * The returned function has the same signature as the global `fetch`, so it can + * be passed to libraries that accept a custom `fetch` implementation, or used + * directly. + * + * @example + * ```ts + * import { SigV4Signer } from '@aws-lambda-powertools/signer/sigv4'; + * import { createSignedFetcher } from '@aws-lambda-powertools/signer/fetch'; + * + * const signer = new SigV4Signer({ service: 'execute-api' }); + * const signedFetch = createSignedFetcher(signer); + * + * const response = await signedFetch('https://example.execute-api.us-east-1.amazonaws.com/items'); + * ``` + * + * @param signer - The signer used to sign each request before it is sent. + * @param options - Optional configuration, e.g. a custom `fetch` implementation. + */ +const createSignedFetcher = ( + signer: Signer, + options: SignedFetcherOptions = {} +): typeof fetch => { + const fetchImpl = options.fetch ?? fetch; + + return async (input, init) => { + const signed = await signer.sign(input, init); + return fetchImpl(signed); + }; +}; + +export type { SignedFetcherOptions, Signer } from './types/index.js'; +export { createSignedFetcher }; diff --git a/packages/signer/src/sigv4.ts b/packages/signer/src/sigv4.ts new file mode 100644 index 0000000000..7ba21879aa --- /dev/null +++ b/packages/signer/src/sigv4.ts @@ -0,0 +1,2 @@ +export { SigV4Signer } from './SigV4Signer.js'; +export type { Signer, SigV4SignerOptions } from './types/index.js'; diff --git a/packages/signer/src/types/index.ts b/packages/signer/src/types/index.ts new file mode 100644 index 0000000000..d9ea2728c5 --- /dev/null +++ b/packages/signer/src/types/index.ts @@ -0,0 +1,66 @@ +import type { + AwsCredentialIdentity, + AwsCredentialIdentityProvider, +} from '@smithy/types'; + +/** + * A request signer. + * + * This is the structural contract that every signing variant (e.g. SigV4, and + * future variants such as SigV4a) implements, and the contract that the + * `createSignedFetcher` factory depends on. + * + * Implementations take a web-standard request and return a signed + * web-standard {@link Request | `Request`}, performing no network I/O. + */ +interface Signer { + /** + * Sign a request and return a new, signed {@link Request | `Request`}. + * + * @param input - The resource to sign, as a URL string, {@link URL | `URL`}, or {@link Request | `Request`}. + * @param init - Optional request options, matching the `fetch` `init` argument. + */ + sign(input: string | URL | Request, init?: RequestInit): Promise; +} + +/** + * Options for constructing a {@link Signer | `Signer`} that uses the AWS + * Signature Version 4 signing process. + */ +interface SigV4SignerOptions { + /** + * The service name to use when signing the request, e.g. `execute-api`, + * `lambda`, or `appsync`. + * + * This value cannot be reliably derived at runtime (custom domains and + * CloudFront hide the underlying service), so it is required. + */ + service: string; + /** + * The AWS region to use when signing the request. + * + * @default process.env.AWS_REGION + */ + region?: string; + /** + * The credentials to use when signing the request, either as a static + * value or as a provider that resolves them. + * + * @default credentials read from the standard Lambda environment variables + */ + credentials?: AwsCredentialIdentity | AwsCredentialIdentityProvider; +} + +/** + * Options for the `createSignedFetcher` factory. + */ +interface SignedFetcherOptions { + /** + * The `fetch` implementation to use when sending the signed request. + * + * @default the global `fetch` + */ + fetch?: typeof fetch; +} + +export type { SignedFetcherOptions, Signer, SigV4SignerOptions }; diff --git a/packages/signer/tests/e2e/constants.ts b/packages/signer/tests/e2e/constants.ts new file mode 100644 index 0000000000..06101cccf2 --- /dev/null +++ b/packages/signer/tests/e2e/constants.ts @@ -0,0 +1,14 @@ +/** + * Resource name prefix for signer e2e test stacks + */ +export const RESOURCE_NAME_PREFIX = 'Signer'; + +/** + * Stack output key for the IAM-protected API Gateway URL + */ +export const STACK_OUTPUT_API_URL = 'ApiUrl'; + +/** + * Stack output key for the caller Lambda function name + */ +export const STACK_OUTPUT_FUNCTION_NAME = 'SignerCaller'; diff --git a/packages/signer/tests/e2e/signer.test.functionCode.ts b/packages/signer/tests/e2e/signer.test.functionCode.ts new file mode 100644 index 0000000000..d541fee372 --- /dev/null +++ b/packages/signer/tests/e2e/signer.test.functionCode.ts @@ -0,0 +1,114 @@ +import { RequestSigningError, SignerError } from '../../src/errors.js'; +import { createSignedFetcher } from '../../src/fetch.js'; +import { SigV4Signer } from '../../src/sigv4.js'; + +// region + credentials (incl. AWS_SESSION_TOKEN) are read from the Lambda +// runtime environment variables. +const signer = new SigV4Signer({ service: 'execute-api' }); +const signedFetch = createSignedFetcher(signer); + +const region = process.env.AWS_REGION as string; + +export const handler = async () => { + const base = process.env.TARGET_API_URL; // .../test/ + if (!base) { + throw new Error('TARGET_API_URL is not set'); + } + + const getUrl = `${base}items`; + const postUrl = `${base}items`; + const queryUrl = `${base}items?foo=bar&baz=qux`; + + const results: Record = {}; + + // 1) Unsigned GET -> expect 403 (proves IAM auth is enforced). + results.unsignedGet = (await fetch(getUrl)).status; + + // 2) Signed GET via createSignedFetcher -> expect 200. + results.signedFetcherGet = (await signedFetch(getUrl)).status; + + // 3) Signed GET via sign() + standard fetch -> expect 200. + results.signedManualGet = (await fetch(await signer.sign(getUrl))).status; + + // 4) Signed POST with a JSON body (exercises body buffering + hashing). + results.signedPost = ( + await signedFetch(postUrl, { + method: 'POST', + body: JSON.stringify({ hello: 'world' }), + headers: { 'content-type': 'application/json' }, + }) + ).status; + + // 5) Signed GET with query-string params (params participate in the canonical request). + results.signedQuery = (await signedFetch(queryUrl)).status; + + // 6) Reuse the same signer instance across several requests (no state leak). + const reuse = await Promise.all([ + signedFetch(getUrl), + signedFetch(getUrl), + signedFetch(getUrl), + ]); + results.signerReuseAllOk = reuse.every((r) => r.status === 200); + + // 7) Drop-in fetch: pass createSignedFetcher's result to code that expects `typeof fetch`. + const makeClient = (fetchImpl: typeof fetch) => ({ + get: (u: string) => fetchImpl(u), + }); + results.dropInClientGet = (await makeClient(signedFetch).get(getUrl)).status; + + // 8) Explicit, correct region -> still signs successfully (200). + const explicitRegionSigner = new SigV4Signer({ + service: 'execute-api', + region, + }); + results.explicitRegionGet = ( + await fetch(await explicitRegionSigner.sign(getUrl)) + ).status; + + // 9) Wrong region -> signature computed for the wrong scope -> expect 403. + const wrongRegion = region === 'us-east-1' ? 'us-west-2' : 'us-east-1'; + const wrongRegionSigner = new SigV4Signer({ + service: 'execute-api', + region: wrongRegion, + }); + results.wrongRegionGet = ( + await fetch(await wrongRegionSigner.sign(getUrl)) + ).status; + + // 10) Explicit (static) credentials path instead of the runtime env default. + const staticSigner = new SigV4Signer({ + service: 'execute-api', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID as string, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string, + sessionToken: process.env.AWS_SESSION_TOKEN, + }, + }); + results.staticCredentialsGet = ( + await fetch(await staticSigner.sign(getUrl)) + ).status; + + // 11) RequestSigningError on a body that cannot be read (a stream that errors mid-read). + try { + const stream = new ReadableStream({ + pull(controller) { + controller.error(new Error('stream read failure')); + }, + }); + await signer.sign(postUrl, { + method: 'POST', + body: stream, + duplex: 'half', + } as RequestInit); + results.streamingError = 'NO_ERROR_THROWN'; + } catch (err) { + results.streamingError = + err instanceof RequestSigningError ? 'RequestSigningError' : String(err); + results.streamingErrorIsSignerError = err instanceof SignerError; + } + + // Log the structured result so the e2e test can parse it from CloudWatch. + console.log(JSON.stringify(results)); + + return results; +}; diff --git a/packages/signer/tests/e2e/signer.test.ts b/packages/signer/tests/e2e/signer.test.ts new file mode 100644 index 0000000000..3ed38222ae --- /dev/null +++ b/packages/signer/tests/e2e/signer.test.ts @@ -0,0 +1,124 @@ +import { join } from 'node:path'; +import { + invokeFunctionOnce, + TestStack, +} from '@aws-lambda-powertools/testing-utils'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { SignerTestFunction } from '../helpers/resources.js'; +import { + RESOURCE_NAME_PREFIX, + STACK_OUTPUT_FUNCTION_NAME, +} from './constants.js'; + +const lambdaFunctionCodeFilePath = join( + __dirname, + 'signer.test.functionCode.ts' +); + +/** + * End-to-end tests for the signer. + * + * Deploys a caller Lambda that uses the signer, plus an IAM-protected REST API + * (mock integration). The caller signs requests against the API and reports the + * resulting status codes; the test parses those from the function's logs. + * + * This validates that signing works in a live Lambda using the execution-role + * credentials read from the runtime environment (incl. `AWS_SESSION_TOKEN`), + * that unsigned requests are denied, and that the documented error behaviour + * holds. + */ +describe('Signer E2E tests', () => { + const testStack = new TestStack({ + stackNameProps: { + stackNamePrefix: RESOURCE_NAME_PREFIX, + testName: 'SignedRequests', + }, + }); + + let results: Record; + + beforeAll(async () => { + // Prepare + new SignerTestFunction( + testStack, + { entry: lambdaFunctionCodeFilePath }, + { nameSuffix: STACK_OUTPUT_FUNCTION_NAME } + ); + + // Act + await testStack.deploy(); + + const functionName = testStack.findAndGetStackOutputValue( + STACK_OUTPUT_FUNCTION_NAME + ); + const logs = await invokeFunctionOnce({ functionName }); + + // The handler logs a single line with all check results as JSON. Depending + // on the runtime's log format, the line may be prefixed with a timestamp, + // request id, and level (tab-separated), or wrapped in a JSON log envelope. + // We locate the line by a known marker and parse from the first `{`, then + // unwrap a log envelope if necessary. + const resultLine = logs + .getFunctionLogs() + .find((log) => log.includes('"unsignedGet"')); + if (!resultLine) { + throw new Error( + `Could not find the results log line. Logs:\n${logs.getAllFunctionLogs().join('\n')}` + ); + } + const parsed = JSON.parse(resultLine.slice(resultLine.indexOf('{'))); + // If the runtime wrapped the console output in a JSON envelope, the actual + // payload is stringified under `message`. + results = ( + 'unsignedGet' in parsed ? parsed : JSON.parse(parsed.message) + ) as Record; + }); + + it('denies an unsigned request', () => { + expect(results.unsignedGet).toBe(403); + }); + + it('signs a GET request with the fetcher and with sign()', () => { + expect(results.signedFetcherGet).toBe(200); + expect(results.signedManualGet).toBe(200); + }); + + it('signs a request with a body', () => { + expect(results.signedPost).toBe(200); + }); + + it('signs a request with query-string parameters', () => { + expect(results.signedQuery).toBe(200); + }); + + it('reuses a single signer instance across requests', () => { + expect(results.signerReuseAllOk).toBe(true); + }); + + it('works as a drop-in fetch for other clients', () => { + expect(results.dropInClientGet).toBe(200); + }); + + it('honours an explicit, correct region', () => { + expect(results.explicitRegionGet).toBe(200); + }); + + it('denies a request signed for the wrong region', () => { + expect(results.wrongRegionGet).toBe(403); + }); + + it('honours explicit static credentials', () => { + expect(results.staticCredentialsGet).toBe(200); + }); + + it('throws a RequestSigningError for an unreadable body', () => { + expect(results.streamingError).toBe('RequestSigningError'); + expect(results.streamingErrorIsSignerError).toBe(true); + }); + + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await testStack.destroy(); + } + }); +}); diff --git a/packages/signer/tests/helpers/resources.ts b/packages/signer/tests/helpers/resources.ts new file mode 100644 index 0000000000..e215b20dc8 --- /dev/null +++ b/packages/signer/tests/helpers/resources.ts @@ -0,0 +1,103 @@ +import { randomUUID } from 'node:crypto'; +import { + concatenateResourceName, + type TestStack, +} from '@aws-lambda-powertools/testing-utils'; +import { TestNodejsFunction } from '@aws-lambda-powertools/testing-utils/resources/lambda'; +import type { + ExtraTestProps, + TestNodejsFunctionProps, +} from '@aws-lambda-powertools/testing-utils/types'; +import { CfnOutput } from 'aws-cdk-lib'; +import { + AuthorizationType, + MockIntegration, + PassthroughBehavior, + RestApi, +} from 'aws-cdk-lib/aws-apigateway'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; + +/** + * Creates a caller Lambda function plus an IAM-protected REST API for signer + * e2e tests. + * + * The API exposes an `/items` resource with `GET` and `POST` methods, both + * requiring IAM (SigV4) authorization. The methods use a **mock integration** + * that returns a static `200` response, so no backing Lambda is needed β€” the + * authorization decision happens at the API Gateway method level, which is all + * the signer needs to be validated against (unsigned β†’ 403, signed β†’ 200). + * + * The caller function's execution role is granted `execute-api:Invoke` on both + * methods, and the API URL is injected as `TARGET_API_URL`. + */ +class SignerTestFunction extends TestNodejsFunction { + public readonly api: RestApi; + public readonly apiUrl: string; + + public constructor( + scope: TestStack, + props: TestNodejsFunctionProps, + extraProps: ExtraTestProps + ) { + super(scope, props, extraProps); + + this.api = new RestApi( + scope.stack, + concatenateResourceName({ + testName: scope.testName, + resourceName: 'RestApi', + }), + { + restApiName: concatenateResourceName({ + testName: scope.testName, + resourceName: extraProps.nameSuffix, + }), + deployOptions: { stageName: 'test' }, + } + ); + + // Mock integration that returns a static 200 with a small JSON body. + const integration = new MockIntegration({ + passthroughBehavior: PassthroughBehavior.NEVER, + requestTemplates: { + 'application/json': '{"statusCode": 200}', + }, + integrationResponses: [ + { + statusCode: '200', + responseTemplates: { + 'application/json': '{"ok": true}', + }, + }, + ], + }); + const methodOptions = { + authorizationType: AuthorizationType.IAM, + methodResponses: [{ statusCode: '200' }], + }; + + const items = this.api.root.addResource('items'); + items.addMethod('GET', integration, methodOptions); + items.addMethod('POST', integration, methodOptions); + + // Allow the caller's execution role to invoke the IAM-protected methods. + this.addToRolePolicy( + new PolicyStatement({ + actions: ['execute-api:Invoke'], + resources: [ + this.api.arnForExecuteApi('GET', '/items', 'test'), + this.api.arnForExecuteApi('POST', '/items', 'test'), + ], + }) + ); + + this.apiUrl = this.api.url; + this.addEnvironment('TARGET_API_URL', this.apiUrl); + + new CfnOutput(scope.stack, `api-${randomUUID().substring(0, 5)}`, { + value: this.apiUrl, + }); + } +} + +export { SignerTestFunction }; diff --git a/packages/signer/tests/tsconfig.json b/packages/signer/tests/tsconfig.json new file mode 100644 index 0000000000..45ba862a85 --- /dev/null +++ b/packages/signer/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../", + "noEmit": true + }, + "include": ["../src/**/*", "./**/*"] +} diff --git a/packages/signer/tests/unit/Sha256.test.ts b/packages/signer/tests/unit/Sha256.test.ts new file mode 100644 index 0000000000..547cdb904a --- /dev/null +++ b/packages/signer/tests/unit/Sha256.test.ts @@ -0,0 +1,43 @@ +import { createHash, createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { Sha256 } from '../../src/Sha256.js'; + +describe('Class: Sha256', () => { + it('computes a plain SHA-256 digest matching node:crypto', async () => { + // Prepare + const hash = new Sha256(); + hash.update('abc'); + + // Act + const digest = await hash.digest(); + + // Assess + const expected = new Uint8Array( + createHash('sha256').update('abc').digest() + ); + expect(digest).toEqual(expected); + }); + + it('computes an HMAC-SHA256 digest when given a secret', async () => { + // Prepare + const hash = new Sha256('secret'); + hash.update('message'); + + // Act + const digest = await hash.digest(); + + // Assess + const expected = new Uint8Array( + createHmac('sha256', 'secret').update('message').digest() + ); + expect(digest).toEqual(expected); + }); + + it('exposes a no-op reset', () => { + // Prepare + const hash = new Sha256(); + + // Act & Assess + expect(() => hash.reset()).not.toThrow(); + }); +}); diff --git a/packages/signer/tests/unit/SigV4Signer.test.ts b/packages/signer/tests/unit/SigV4Signer.test.ts new file mode 100644 index 0000000000..6e27c954b5 --- /dev/null +++ b/packages/signer/tests/unit/SigV4Signer.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RequestSigningError, SignerConfigError } from '../../src/errors.js'; +import { SigV4Signer } from '../../src/sigv4.js'; + +describe('Class: SigV4Signer', () => { + const ENVIRONMENT_VARIABLES = process.env; + + beforeEach(() => { + process.env = { + ...ENVIRONMENT_VARIABLES, + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'AKIAEXAMPLE', + AWS_SECRET_ACCESS_KEY: 'secret-example', + AWS_SESSION_TOKEN: 'session-example', + }; + }); + + afterEach(() => { + process.env = ENVIRONMENT_VARIABLES; + vi.clearAllMocks(); + }); + + describe('configuration', () => { + it('throws a config error when no region can be determined', () => { + // Prepare + process.env.AWS_REGION = undefined; + + // Act & Assess + expect(() => new SigV4Signer({ service: 'execute-api' })).toThrow( + SignerConfigError + ); + }); + + it('uses the AWS_REGION environment variable by default', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items' + ); + + // Assess + expect(signed.headers.get('authorization')).toContain('us-east-1'); + }); + + it('uses the region passed in the options over the environment variable', async () => { + // Prepare + const signer = new SigV4Signer({ + service: 'execute-api', + region: 'eu-west-1', + }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.eu-west-1.amazonaws.com/items' + ); + + // Assess + expect(signed.headers.get('authorization')).toContain('eu-west-1'); + }); + + it('throws a config error when credentials cannot be resolved from the environment', async () => { + // Prepare + process.env.AWS_ACCESS_KEY_ID = undefined; + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act & Assess + await expect( + signer.sign('https://example.execute-api.us-east-1.amazonaws.com/items') + ).rejects.toThrow(SignerConfigError); + }); + + it('resolves credentials from an async provider', async () => { + // Prepare + process.env.AWS_ACCESS_KEY_ID = undefined; + process.env.AWS_SECRET_ACCESS_KEY = undefined; + const credentials = vi.fn().mockResolvedValue({ + accessKeyId: 'AKIAFROMPROVIDER', + secretAccessKey: 'secret-from-provider', + }); + const signer = new SigV4Signer({ service: 'execute-api', credentials }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items' + ); + + // Assess + expect(credentials).toHaveBeenCalledTimes(1); + expect(signed.headers.has('authorization')).toBe(true); + }); + + it('accepts static credentials', async () => { + // Prepare + process.env.AWS_ACCESS_KEY_ID = undefined; + process.env.AWS_SECRET_ACCESS_KEY = undefined; + const signer = new SigV4Signer({ + service: 'execute-api', + credentials: { + accessKeyId: 'AKIASTATIC', + secretAccessKey: 'secret-static', + }, + }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items' + ); + + // Assess + expect(signed.headers.get('authorization')).toContain('AKIASTATIC'); + }); + }); + + describe('signing', () => { + it('adds the expected SigV4 headers', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items' + ); + + // Assess + expect(signed.headers.get('authorization')).toContain('AWS4-HMAC-SHA256'); + expect(signed.headers.has('x-amz-date')).toBe(true); + expect(signed.headers.has('x-amz-content-sha256')).toBe(true); + }); + + /** + * Guard test: we pass a plain object (typed as `HttpRequest` from + * `@smithy/types`) to the underlying signing process instead of a + * `@smithy/protocol-http` `HttpRequest` instance. This ensures a valid + * signature is produced from that structural input, so a future smithy + * upgrade tightening the contract is caught here rather than in production. + */ + it('signs successfully from a structural (plain object) request', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { method: 'POST', body: 'payload' } + ); + + // Assess + expect(signed.headers.get('authorization')).toContain('AWS4-HMAC-SHA256'); + expect(signed.headers.has('x-amz-content-sha256')).toBe(true); + expect(await signed.text()).toBe('payload'); + }); + + it('includes the session token when present', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items' + ); + + // Assess + expect(signed.headers.get('x-amz-security-token')).toBe( + 'session-example' + ); + }); + + it('signs requests with a body and preserves it', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + const body = JSON.stringify({ hello: 'world' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { method: 'POST', body } + ); + + // Assess + expect(signed.method).toBe('POST'); + expect(await signed.text()).toBe(body); + expect(signed.headers.has('authorization')).toBe(true); + }); + + it('accepts a Request object as input', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + const request = new Request( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { method: 'PUT', body: 'payload' } + ); + + // Act + const signed = await signer.sign(request); + + // Assess + expect(signed.method).toBe('PUT'); + expect(signed.headers.has('authorization')).toBe(true); + }); + + it('accepts a URL object as input', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + const url = new URL( + 'https://example.execute-api.us-east-1.amazonaws.com/items?foo=bar' + ); + + // Act + const signed = await signer.sign(url); + + // Assess + expect(signed.headers.has('authorization')).toBe(true); + }); + + it('does not consume a body passed via init, leaving inputs reusable', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { method: 'POST', body: 'original' } + ); + + // Assess: the signed request carries the body, read exactly once here + expect(await signed.text()).toBe('original'); + }); + + it('wraps signing failures in a RequestSigningError', async () => { + // Prepare + const signer = new SigV4Signer({ + service: 'execute-api', + credentials: { + // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid to force a signing failure + accessKeyId: undefined as any, + // biome-ignore lint/suspicious/noExplicitAny: deliberately invalid to force a signing failure + secretAccessKey: undefined as any, + }, + }); + + // Act & Assess + await expect( + signer.sign('https://example.execute-api.us-east-1.amazonaws.com/items') + ).rejects.toThrow(RequestSigningError); + }); + + it('signs a request without a body', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { method: 'GET' } + ); + + // Assess + expect(signed.headers.has('authorization')).toBe(true); + expect(signed.body).toBeNull(); + }); + + it('signs a request with query string parameters', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items?foo=bar&baz=qux' + ); + + // Assess + expect(signed.headers.has('authorization')).toBe(true); + }); + + it('signs a request against a URL with an explicit port', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign('https://example.com:8443/items'); + + // Assess + expect(signed.headers.has('authorization')).toBe(true); + }); + + it('treats an empty body as no body', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + + // Act + const signed = await signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { method: 'POST', body: '' } + ); + + // Assess + expect(signed.headers.has('authorization')).toBe(true); + }); + + it('throws a RequestSigningError when the body cannot be buffered', async () => { + // Prepare + const signer = new SigV4Signer({ service: 'execute-api' }); + // Force the internal buffering (clone().arrayBuffer()) to reject, as it + // would for a non-replayable streaming body. + const cloneSpy = vi.spyOn(Request.prototype, 'clone').mockImplementation( + () => + ({ + arrayBuffer: () => + Promise.reject(new Error('stream cannot be read')), + }) as unknown as Request + ); + + // Act & Assess + try { + await expect( + signer.sign( + 'https://example.execute-api.us-east-1.amazonaws.com/items', + { method: 'POST', body: 'data' } + ) + ).rejects.toThrow(RequestSigningError); + } finally { + cloneSpy.mockRestore(); + } + }); + }); +}); diff --git a/packages/signer/tests/unit/errors.test.ts b/packages/signer/tests/unit/errors.test.ts new file mode 100644 index 0000000000..f18ea6eab8 --- /dev/null +++ b/packages/signer/tests/unit/errors.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + RequestSigningError, + SignerConfigError, + SignerError, +} from '../../src/errors.js'; + +describe('Signer errors', () => { + it('SignerError sets its name and message', () => { + // Act + const error = new SignerError('boom'); + + // Assess + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('SignerError'); + expect(error.message).toBe('boom'); + }); + + it('SignerConfigError extends SignerError', () => { + // Act + const error = new SignerConfigError('missing region'); + + // Assess + expect(error).toBeInstanceOf(SignerError); + expect(error.name).toBe('SignerConfigError'); + }); + + it('RequestSigningError extends SignerError', () => { + // Act + const error = new RequestSigningError('failed'); + + // Assess + expect(error).toBeInstanceOf(SignerError); + expect(error.name).toBe('RequestSigningError'); + }); + + it('RequestSigningError appends the cause message when the cause is an Error', () => { + // Prepare + const cause = new Error('underlying problem'); + + // Act + const error = new RequestSigningError('failed', { cause }); + + // Assess + expect(error.message).toBe( + 'failed. This error was caused by: underlying problem.' + ); + expect(error.cause).toBe(cause); + }); + + it('RequestSigningError keeps the message as-is when there is no Error cause', () => { + // Act + const error = new RequestSigningError('failed'); + + // Assess + expect(error.message).toBe('failed'); + }); +}); diff --git a/packages/signer/tests/unit/fetch.test.ts b/packages/signer/tests/unit/fetch.test.ts new file mode 100644 index 0000000000..3fd49b63a7 --- /dev/null +++ b/packages/signer/tests/unit/fetch.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createSignedFetcher } from '../../src/fetch.js'; +import type { Signer } from '../../src/types/index.js'; + +describe('Function: createSignedFetcher', () => { + const makeSigner = (): Signer => ({ + sign: vi.fn(async (input, init) => { + const request = new Request(input as string | URL | Request, init); + const headers = new Headers(request.headers); + headers.set('authorization', 'AWS4-HMAC-SHA256 signed'); + return new Request(request.url, { + method: request.method, + headers, + }); + }), + }); + + it('signs the request before sending it', async () => { + // Prepare + const signer = makeSigner(); + const fetchImpl = vi.fn(async (_request: Request) => new Response('ok')); + const signedFetch = createSignedFetcher(signer, { + fetch: fetchImpl as unknown as typeof fetch, + }); + + // Act + await signedFetch('https://example.com/items'); + + // Assess + expect(signer.sign).toHaveBeenCalledTimes(1); + const sentRequest = fetchImpl.mock.calls[0]?.[0] as Request; + expect(sentRequest.headers.get('authorization')).toBe( + 'AWS4-HMAC-SHA256 signed' + ); + }); + + it('returns the response from the underlying fetch', async () => { + // Prepare + const signer = makeSigner(); + const fetchImpl = vi.fn(async () => new Response('payload')); + const signedFetch = createSignedFetcher(signer, { fetch: fetchImpl }); + + // Act + const response = await signedFetch('https://example.com/items'); + + // Assess + expect(await response.text()).toBe('payload'); + }); + + it('uses the global fetch when no custom fetch is provided', async () => { + // Prepare + const signer = makeSigner(); + const globalFetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('from global')); + const signedFetch = createSignedFetcher(signer); + + // Act + const response = await signedFetch('https://example.com/items'); + + // Assess + expect(globalFetch).toHaveBeenCalledTimes(1); + expect(await response.text()).toBe('from global'); + }); + + it('propagates transport errors from the underlying fetch untouched', async () => { + // Prepare + const signer = makeSigner(); + const transportError = new Error('network down'); + const fetchImpl = vi.fn(async () => { + throw transportError; + }); + const signedFetch = createSignedFetcher(signer, { fetch: fetchImpl }); + + // Act & Assess + await expect(signedFetch('https://example.com/items')).rejects.toBe( + transportError + ); + }); + + it('forwards the request init to the signer', async () => { + // Prepare + const signer = makeSigner(); + const fetchImpl = vi.fn(async () => new Response('ok')); + const signedFetch = createSignedFetcher(signer, { fetch: fetchImpl }); + + // Act + await signedFetch('https://example.com/items', { method: 'POST' }); + + // Assess + expect(signer.sign).toHaveBeenCalledWith('https://example.com/items', { + method: 'POST', + }); + }); +}); diff --git a/packages/signer/tsconfig.cjs.json b/packages/signer/tsconfig.cjs.json new file mode 100644 index 0000000000..a8fadd417f --- /dev/null +++ b/packages/signer/tsconfig.cjs.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.cjs.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "outDir": "./lib/cjs/", + "rootDir": "./src", + "tsBuildInfoFile": ".tsbuildinfo/cjs.json" + }, + "include": ["./src/**/*"] +} diff --git a/packages/signer/tsconfig.json b/packages/signer/tsconfig.json new file mode 100644 index 0000000000..7baf7d1c7f --- /dev/null +++ b/packages/signer/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./lib/esm", + "rootDir": "./src", + "tsBuildInfoFile": ".tsbuildinfo/esm.json", + "composite": true, + "declaration": true + }, + "include": ["./src/**/*"] +} diff --git a/packages/signer/vitest.config.ts b/packages/signer/vitest.config.ts new file mode 100644 index 0000000000..706a41c803 --- /dev/null +++ b/packages/signer/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + environment: 'node', + setupFiles: ['../testing/src/setupEnv.ts'], + hookTimeout: 1_000 * 60 * 10, // 10 minutes + testTimeout: 1_000 * 60 * 3, // 3 minutes + typecheck: { + tsconfig: './tests/tsconfig.json', + include: ['./tests/types/**/*.ts'], + }, + }, +});