From f800c6035ae8293fdb86409bb6143393f16546b7 Mon Sep 17 00:00:00 2001 From: Aleks Marchenko Date: Sat, 8 Mar 2025 14:30:56 +0100 Subject: [PATCH 1/5] feat: add AWS Signature v4 for HttpClientRequest --- .projenrc.ts | 8 ++ packages/signature-v4/.gitattributes | 21 ++++ packages/signature-v4/.gitignore | 44 +++++++ packages/signature-v4/.npmignore | 19 +++ packages/signature-v4/.projen/deps.json | 65 ++++++++++ packages/signature-v4/.projen/files.json | 19 +++ packages/signature-v4/.projen/tasks.json | 120 +++++++++++++++++++ packages/signature-v4/LICENSE | 19 +++ packages/signature-v4/README.md | 1 + packages/signature-v4/docgen.json | 7 ++ packages/signature-v4/package.json | 60 ++++++++++ packages/signature-v4/src/Aws4Fetch.ts | 53 ++++++++ packages/signature-v4/src/Credentials.ts | 12 ++ packages/signature-v4/src/Signer.ts | 15 +++ packages/signature-v4/src/Smithy.ts | 61 ++++++++++ packages/signature-v4/src/helpers.ts | 84 +++++++++++++ packages/signature-v4/src/index.ts | 5 + packages/signature-v4/test/Aws4Fetch.test.ts | 22 ++++ packages/signature-v4/test/Smithy.test.ts | 22 ++++ packages/signature-v4/test/fixtures.ts | 18 +++ packages/signature-v4/tsconfig.cjs.json | 10 ++ packages/signature-v4/tsconfig.dev.json | 20 ++++ packages/signature-v4/tsconfig.esm.json | 10 ++ packages/signature-v4/tsconfig.json | 13 ++ packages/signature-v4/tsconfig.src.json | 12 ++ packages/signature-v4/vitest.config.ts | 6 + pnpm-lock.yaml | 43 ++++++- pnpm-workspace.yaml | 1 + tsconfig.base.json | 9 ++ tsconfig.build.json | 3 + tsconfig.json | 3 + vitest.shared.ts | 1 + 32 files changed, 803 insertions(+), 3 deletions(-) create mode 100644 packages/signature-v4/.gitattributes create mode 100644 packages/signature-v4/.gitignore create mode 100644 packages/signature-v4/.npmignore create mode 100644 packages/signature-v4/.projen/deps.json create mode 100644 packages/signature-v4/.projen/files.json create mode 100644 packages/signature-v4/.projen/tasks.json create mode 100644 packages/signature-v4/LICENSE create mode 100644 packages/signature-v4/README.md create mode 100644 packages/signature-v4/docgen.json create mode 100644 packages/signature-v4/package.json create mode 100644 packages/signature-v4/src/Aws4Fetch.ts create mode 100644 packages/signature-v4/src/Credentials.ts create mode 100644 packages/signature-v4/src/Signer.ts create mode 100644 packages/signature-v4/src/Smithy.ts create mode 100644 packages/signature-v4/src/helpers.ts create mode 100644 packages/signature-v4/src/index.ts create mode 100644 packages/signature-v4/test/Aws4Fetch.test.ts create mode 100644 packages/signature-v4/test/Smithy.test.ts create mode 100644 packages/signature-v4/test/fixtures.ts create mode 100644 packages/signature-v4/tsconfig.cjs.json create mode 100644 packages/signature-v4/tsconfig.dev.json create mode 100644 packages/signature-v4/tsconfig.esm.json create mode 100644 packages/signature-v4/tsconfig.json create mode 100644 packages/signature-v4/tsconfig.src.json create mode 100644 packages/signature-v4/vitest.config.ts diff --git a/.projenrc.ts b/.projenrc.ts index 81721433..622297d7 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -124,6 +124,14 @@ new TypeScriptLibProject({ workspacePeerDeps: [secretsManagerClient], }); +new TypeScriptLibProject({ + parent: project, + name: "signature-v4", + description: "AWS Signature V4 for Effect HttpClientRequest", + devDeps: [...effectDeps, "@smithy/signature-v4@^5", "@smithy/types@^4", "aws4fetch@^1", "@aws-crypto/sha256-js@^5"], + peerDeps: [...commonPeerDeps, "@smithy/signature-v4@^5", "@smithy/types@^4", "aws4fetch@^1", "@aws-crypto/sha256-js@^5"], +}); + new TypeScriptLibProject({ parent: project, name: "ssm", diff --git a/packages/signature-v4/.gitattributes b/packages/signature-v4/.gitattributes new file mode 100644 index 00000000..44ee1f36 --- /dev/null +++ b/packages/signature-v4/.gitattributes @@ -0,0 +1,21 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +* text=auto eol=lf +/.gitattributes linguist-generated +/.gitignore linguist-generated +/.npmignore linguist-generated +/.npmrc linguist-generated +/.projen/** linguist-generated +/.projen/deps.json linguist-generated +/.projen/files.json linguist-generated +/.projen/tasks.json linguist-generated +/docgen.json linguist-generated +/LICENSE linguist-generated +/package.json linguist-generated +/pnpm-lock.yaml linguist-generated +/tsconfig.cjs.json linguist-generated +/tsconfig.dev.json linguist-generated +/tsconfig.esm.json linguist-generated +/tsconfig.json linguist-generated +/tsconfig.src.json linguist-generated +/vitest.config.ts linguist-generated \ No newline at end of file diff --git a/packages/signature-v4/.gitignore b/packages/signature-v4/.gitignore new file mode 100644 index 00000000..9b3e7285 --- /dev/null +++ b/packages/signature-v4/.gitignore @@ -0,0 +1,44 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +!/.gitattributes +!/.projen/tasks.json +!/.projen/deps.json +!/.projen/files.json +!/package.json +!/LICENSE +!/.npmignore +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +pids +*.pid +*.seed +*.pid.lock +lib-cov +coverage +*.lcov +.nyc_output +build/Release +node_modules/ +jspm_packages/ +*.tsbuildinfo +.eslintcache +*.tgz +.yarn-integrity +.cache +!/.npmrc +!/test/ +!/tsconfig.json +!/src/ +/build +/dist/ +!/tsconfig.src.json +!/tsconfig.dev.json +!/tsconfig.esm.json +!/tsconfig.cjs.json +!/docgen.json +docs/ +!/vitest.config.ts diff --git a/packages/signature-v4/.npmignore b/packages/signature-v4/.npmignore new file mode 100644 index 00000000..fe4e41d6 --- /dev/null +++ b/packages/signature-v4/.npmignore @@ -0,0 +1,19 @@ +# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +/.projen/ +/test/ +/src/ +!/build/ +!/build/**/*.js +!/build/**/*.d.ts +dist +/tsconfig.json +/.github/ +/.vscode/ +/.idea/ +/.projenrc.js +tsconfig.tsbuildinfo +/tsconfig.src.json +/tsconfig.dev.json +/tsconfig.esm.json +/tsconfig.cjs.json +/.gitattributes diff --git a/packages/signature-v4/.projen/deps.json b/packages/signature-v4/.projen/deps.json new file mode 100644 index 00000000..81a0b893 --- /dev/null +++ b/packages/signature-v4/.projen/deps.json @@ -0,0 +1,65 @@ +{ + "dependencies": [ + { + "name": "@aws-crypto/sha256-js", + "version": "^5", + "type": "build" + }, + { + "name": "@smithy/signature-v4", + "version": "^5", + "type": "build" + }, + { + "name": "@smithy/types", + "version": "^4", + "type": "build" + }, + { + "name": "@types/node", + "version": "ts5.4", + "type": "build" + }, + { + "name": "aws4fetch", + "version": "^1", + "type": "build" + }, + { + "name": "effect", + "version": "3.10.16", + "type": "build" + }, + { + "name": "typescript", + "version": "^5.4.2", + "type": "build" + }, + { + "name": "@aws-crypto/sha256-js", + "version": "^5", + "type": "peer" + }, + { + "name": "@smithy/signature-v4", + "version": "^5", + "type": "peer" + }, + { + "name": "@smithy/types", + "version": "^4", + "type": "peer" + }, + { + "name": "aws4fetch", + "version": "^1", + "type": "peer" + }, + { + "name": "effect", + "version": ">=3.0.4 <4.0.0", + "type": "peer" + } + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/signature-v4/.projen/files.json b/packages/signature-v4/.projen/files.json new file mode 100644 index 00000000..e57ad5f8 --- /dev/null +++ b/packages/signature-v4/.projen/files.json @@ -0,0 +1,19 @@ +{ + "files": [ + ".gitattributes", + ".gitignore", + ".npmignore", + ".projen/deps.json", + ".projen/files.json", + ".projen/tasks.json", + "docgen.json", + "LICENSE", + "tsconfig.cjs.json", + "tsconfig.dev.json", + "tsconfig.esm.json", + "tsconfig.json", + "tsconfig.src.json", + "vitest.config.ts" + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/signature-v4/.projen/tasks.json b/packages/signature-v4/.projen/tasks.json new file mode 100644 index 00000000..ad983f13 --- /dev/null +++ b/packages/signature-v4/.projen/tasks.json @@ -0,0 +1,120 @@ +{ + "tasks": { + "build": { + "name": "build", + "description": "Full release build", + "steps": [ + { + "spawn": "pre-compile" + }, + { + "spawn": "compile" + }, + { + "spawn": "post-compile" + }, + { + "spawn": "test" + }, + { + "spawn": "package" + } + ] + }, + "compile": { + "name": "compile", + "description": "Only compile", + "steps": [ + { + "exec": "tsc -b ./tsconfig.cjs.json ./tsconfig.esm.json" + } + ] + }, + "default": { + "name": "default", + "description": "Synthesize project files" + }, + "eslint": { + "name": "eslint", + "description": "Runs eslint against the codebase", + "steps": [ + { + "exec": "eslint $@ src test", + "receiveArgs": true + } + ] + }, + "install": { + "name": "install", + "description": "Install project dependencies and update lockfile (non-frozen)", + "steps": [ + { + "exec": "pnpm i --no-frozen-lockfile" + } + ] + }, + "install:ci": { + "name": "install:ci", + "description": "Install project dependencies using frozen lockfile", + "steps": [ + { + "exec": "pnpm i --frozen-lockfile" + } + ] + }, + "package": { + "name": "package", + "description": "Creates the distribution package", + "steps": [ + { + "exec": "build-utils pack-v2" + } + ] + }, + "post-compile": { + "name": "post-compile", + "description": "Runs after successful compilation" + }, + "pre-compile": { + "name": "pre-compile", + "description": "Prepare the project for compilation", + "steps": [ + { + "spawn": "eslint" + } + ] + }, + "test": { + "name": "test", + "description": "Run tests", + "steps": [ + { + "exec": "vitest run --reporter verbose", + "receiveArgs": true + } + ] + }, + "test:watch": { + "name": "test:watch", + "description": "Run tests in watch mode", + "steps": [ + { + "exec": "vitest --reporter verbose" + } + ] + }, + "watch": { + "name": "watch", + "description": "Watch & compile in the background", + "steps": [ + { + "exec": "tsc --build -w" + } + ] + } + }, + "env": { + "PATH": "$(pnpm -c exec \"node --print process.env.PATH\")" + }, + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/signature-v4/LICENSE b/packages/signature-v4/LICENSE new file mode 100644 index 00000000..ced0788c --- /dev/null +++ b/packages/signature-v4/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025 Victor Korzunin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/signature-v4/README.md b/packages/signature-v4/README.md new file mode 100644 index 00000000..b3fa7ddc --- /dev/null +++ b/packages/signature-v4/README.md @@ -0,0 +1 @@ +# replace this \ No newline at end of file diff --git a/packages/signature-v4/docgen.json b/packages/signature-v4/docgen.json new file mode 100644 index 00000000..f5283437 --- /dev/null +++ b/packages/signature-v4/docgen.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/@effect/docgen/schema.json", + "exclude": [ + "src/Errors.ts" + ], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/signature-v4/package.json b/packages/signature-v4/package.json new file mode 100644 index 00000000..f1423397 --- /dev/null +++ b/packages/signature-v4/package.json @@ -0,0 +1,60 @@ +{ + "name": "@effect-aws/signature-v4", + "description": "AWS Signature V4 for Effect HttpClientRequest", + "repository": { + "type": "git", + "url": "github:floydspace/effect-aws", + "directory": "packages/signature-v4" + }, + "scripts": { + "build": "npx projen build", + "compile": "npx projen compile", + "default": "npx projen default", + "eslint": "npx projen eslint", + "package": "npx projen package", + "post-compile": "npx projen post-compile", + "pre-compile": "npx projen pre-compile", + "test": "npx projen test", + "test:watch": "npx projen test:watch", + "watch": "npx projen watch", + "docgen": "docgen" + }, + "author": { + "name": "Victor Korzunin", + "email": "ifloydrose@gmail.com", + "organization": false + }, + "devDependencies": { + "@aws-crypto/sha256-js": "^5", + "@smithy/signature-v4": "^5", + "@smithy/types": "^4", + "@types/node": "ts5.4", + "aws4fetch": "^1", + "effect": "3.10.16", + "typescript": "^5.4.2" + }, + "peerDependencies": { + "@aws-crypto/sha256-js": "^5", + "@smithy/signature-v4": "^5", + "@smithy/types": "^4", + "aws4fetch": "^1", + "effect": ">=3.0.4 <4.0.0" + }, + "main": "build/cjs/index.js", + "license": "MIT", + "homepage": "https://floydspace.github.io/effect-aws/docs/signature-v4", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "exports": { + "./Smithy": "./Smithy.ts", + "./Aws4Fetch": "./Aws4Fetch.ts" + }, + "version": "0.0.0", + "types": "build/dts/index.d.ts", + "type": "module", + "module": "build/esm/index.js", + "sideEffects": [], + "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." +} diff --git a/packages/signature-v4/src/Aws4Fetch.ts b/packages/signature-v4/src/Aws4Fetch.ts new file mode 100644 index 00000000..864592f0 --- /dev/null +++ b/packages/signature-v4/src/Aws4Fetch.ts @@ -0,0 +1,53 @@ +import { HttpClient } from '@effect/platform' +import { RequestError } from '@effect/platform/HttpClientError' +import * as HttpClientRequest from '@effect/platform/HttpClientRequest' +import { AwsV4Signer } from 'aws4fetch' +import * as Effect from 'effect/Effect' +import * as Option from 'effect/Option' +import * as Ref from 'effect/Ref' +import { Credentials } from './Credentials.js' +import { Signer, SignerOptions } from './Signer.js' +import { getBody } from './helpers.js' + +/** + * AWS Signature v4 implementation using aws4fetch package + */ +export class SignatureV4 extends Effect.Service()(`@effect-aws/signature-v4/SignatureV4`, { + effect: Effect.gen(function* () { + const signRequest = (request: HttpClientRequest.HttpClientRequest, options?: SignerOptions) => { + const body: BodyInit = getBody(request.body) + + return Credentials.pipe(Effect.flatMap(Ref.get)).pipe( + Effect.andThen( + Option.match({ + onNone: () => Effect.succeed(request), + onSome: ({ accessKeyId, secretAccessKey, sessionToken }) => { + const signer = new AwsV4Signer({ + ...options, + method: request.method, + url: request.url, + headers: request.headers, + body, + accessKeyId, + secretAccessKey, + sessionToken: sessionToken! + }) + + return Effect.tryPromise(() => signer.sign()).pipe( + Effect.andThen((result) => + request.pipe(HttpClientRequest.setHeaders(result.headers), HttpClientRequest.setUrl(result.url)), + ), + Effect.mapError(() => new RequestError({ reason: `Encode`, request, description: `Failed to sign request` })) + ) + }, + }), + ), + ) + } + + return { + signRequest, + transformClient: (client: HttpClient.HttpClient, options?: SignerOptions) => HttpClient.mapRequestEffect(client, (r) => signRequest(r, options)), + } as Signer + }), +}) {} diff --git a/packages/signature-v4/src/Credentials.ts b/packages/signature-v4/src/Credentials.ts new file mode 100644 index 00000000..2d7f0d99 --- /dev/null +++ b/packages/signature-v4/src/Credentials.ts @@ -0,0 +1,12 @@ +import * as Context from 'effect/Context' +import * as Option from 'effect/Option' +import * as Ref from 'effect/Ref' + +export interface AWSCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + expiration?: Date; +} + +export class Credentials extends Context.Tag(`@effect-aws/signature-v4/Credentials`)>>() {} diff --git a/packages/signature-v4/src/Signer.ts b/packages/signature-v4/src/Signer.ts new file mode 100644 index 00000000..e76d1ede --- /dev/null +++ b/packages/signature-v4/src/Signer.ts @@ -0,0 +1,15 @@ +import { HttpClient, HttpClientError, HttpClientRequest } from '@effect/platform' +import { RequestError } from '@effect/platform/HttpClientError' +import * as Effect from 'effect/Effect' +import { Credentials } from './Credentials.js' +import { Scope } from 'effect/Scope' + +export interface SignerOptions { + service: string + region: string +} + +export interface Signer { + signRequest: (request: HttpClientRequest.HttpClientRequest, options?: SignerOptions) => Effect.Effect + transformClient: (client: HttpClient.HttpClient, options?: SignerOptions) => HttpClient.HttpClient +} diff --git a/packages/signature-v4/src/Smithy.ts b/packages/signature-v4/src/Smithy.ts new file mode 100644 index 00000000..1bf81f91 --- /dev/null +++ b/packages/signature-v4/src/Smithy.ts @@ -0,0 +1,61 @@ +import { HttpClient } from '@effect/platform' +import { RequestError } from '@effect/platform/HttpClientError' +import * as HttpClientRequest from '@effect/platform/HttpClientRequest' +import { Sha256 } from "@aws-crypto/sha256-js" +import { SignatureV4 as AwsSignatureV4 } from '@smithy/signature-v4' +import * as Effect from 'effect/Effect' +import * as Option from 'effect/Option' +import * as Ref from 'effect/Ref' +import { Credentials } from './Credentials.js' +import { Signer, SignerOptions } from './Signer.js' +import { getBody, guessServiceRegion } from './helpers.js' +import { HttpRequest } from '@smithy/types' + +/** + * AWS Signature v4 implementation @smithy/signature-4 + */ +export class SignatureV4 extends Effect.Service()(`@effect-aws/signature-v4/SignatureV4`, { + scoped: Effect.gen(function* () { + const signRequest = (request: HttpClientRequest.HttpClientRequest, options?: SignerOptions) => { + return Credentials.pipe(Effect.flatMap(Ref.get)).pipe( + Effect.andThen( + Option.match({ + onNone: () => Effect.succeed(request), + onSome: (credentials) => { + const url = new URL(request.url) + const [service, region] = options + ? [options.region, options.service] + : guessServiceRegion(url, request.headers) + + const signer = new AwsSignatureV4({ + credentials, + service, + region, + sha256: Sha256, + }) + + const requestAlike = { + body: getBody(request.body), + headers: request.headers, + path: url.pathname, + } as unknown as HttpRequest + + + return Effect.tryPromise(() => signer.sign(requestAlike)).pipe( + Effect.andThen((result) => + request.pipe(HttpClientRequest.setHeaders(result.headers)), + ), + Effect.mapError(() => new RequestError({ reason: `Encode`, request, description: `Failed to sign request` })) + ) + }, + }), + ), + ) + } + + return { + signRequest, + transformClient: (client: HttpClient.HttpClient, options?: SignerOptions) => HttpClient.mapRequestEffect(client, (r) => signRequest(r, options)), + } as Signer + }), +}) {} diff --git a/packages/signature-v4/src/helpers.ts b/packages/signature-v4/src/helpers.ts new file mode 100644 index 00000000..785ab70e --- /dev/null +++ b/packages/signature-v4/src/helpers.ts @@ -0,0 +1,84 @@ +import { Headers } from '@effect/platform' +import * as HttpBody from '@effect/platform/HttpBody' +import * as Match from 'effect/Match' + +const HOST_SERVICES: Record = { + appstream2: 'appstream', + cloudhsmv2: 'cloudhsm', + email: 'ses', + marketplace: 'aws-marketplace', + mobile: 'AWSMobileHubService', + pinpoint: 'mobiletargeting', + queue: 'sqs', + 'git-codecommit': 'codecommit', + 'mturk-requester-sandbox': 'mturk-requester', + 'personalize-runtime': 'personalize', +} + +/** + * @internal + */ +export const getBody = Match.type().pipe( + Match.tag(`Empty`, (): XMLHttpRequestBodyInit => ``), + Match.tag(`FormData`, (body): XMLHttpRequestBodyInit => body.formData), + Match.tag(`Raw`, (body): XMLHttpRequestBodyInit => body.body as string), + Match.tag(`Stream`, (): XMLHttpRequestBodyInit => ``), + Match.tag(`Uint8Array`, (body): XMLHttpRequestBodyInit => body.body), + Match.exhaustive, +) + + +/** + * @internal + * + * Snatched from https://github.com/mhart/aws4fetch/blob/master/src/main.js + */ +export function guessServiceRegion(url: URL, headers: Headers.Headers): [service: string, region: string] { + const { hostname, pathname } = url + + if (hostname.endsWith('.on.aws')) { + const match = hostname.match(/^[^.]{1,63}\.lambda-url\.([^.]{1,63})\.on\.aws$/) + return match != null ? ['lambda', match[1] || ''] : ['', ''] + } + if (hostname.endsWith('.r2.cloudflarestorage.com')) { + return ['s3', 'auto'] + } + if (hostname.endsWith('.backblazeb2.com')) { + const match = hostname.match(/^(?:[^.]{1,63}\.)?s3\.([^.]{1,63})\.backblazeb2\.com$/) + return match != null ? ['s3', match[1] || ''] : ['', ''] + } + const match = hostname.replace('dualstack.', '').match(/([^.]{1,63})\.(?:([^.]{0,63})\.)?amazonaws\.com(?:\.cn)?$/) + let service = (match && match[1]) || '' + let region = match && match[2] + + if (region === 'us-gov') { + region = 'us-gov-west-1' + } else if (region === 's3' || region === 's3-accelerate') { + region = 'us-east-1' + service = 's3' + } else if (service === 'iot') { + if (hostname.startsWith('iot.')) { + service = 'execute-api' + } else if (hostname.startsWith('data.jobs.iot.')) { + service = 'iot-jobs-data' + } else { + service = pathname === '/mqtt' ? 'iotdevicegateway' : 'iotdata' + } + } else if (service === 'autoscaling') { + const targetPrefix = (headers['X-Amz-Target'] || '').split('.')[0] + if (targetPrefix === 'AnyScaleFrontendService') { + service = 'application-autoscaling' + } else if (targetPrefix === 'AnyScaleScalingPlannerFrontendService') { + service = 'autoscaling-plans' + } + } else if (region == null && service.startsWith('s3-')) { + region = service.slice(3).replace(/^fips-|^external-1/, '') + service = 's3' + } else if (service.endsWith('-fips')) { + service = service.slice(0, -5) + } else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) { + ;[service, region] = [region, service] + } + + return [HOST_SERVICES[service] || service, region || ''] +} diff --git a/packages/signature-v4/src/index.ts b/packages/signature-v4/src/index.ts new file mode 100644 index 00000000..92c94b81 --- /dev/null +++ b/packages/signature-v4/src/index.ts @@ -0,0 +1,5 @@ +export class Hello { + public sayHello() { + return 'hello, world!'; + } +} \ No newline at end of file diff --git a/packages/signature-v4/test/Aws4Fetch.test.ts b/packages/signature-v4/test/Aws4Fetch.test.ts new file mode 100644 index 00000000..c0492fb0 --- /dev/null +++ b/packages/signature-v4/test/Aws4Fetch.test.ts @@ -0,0 +1,22 @@ +import * as Layer from 'effect/Layer' +import * as Effect from 'effect/Effect' +import { SignatureV4 } from '@effect-aws/signature-v4/Aws4Fetch' +import { it } from '@effect/vitest' +import { expect } from 'vitest' +import { credentialsLayer, request } from './fixtures.js' + +const credsLayer = Layer.mergeAll( + SignatureV4.Default, + credentialsLayer +) + +it.layer(credsLayer)(({ effect }) => + effect(`Appends AWS v4 signature headers to the request`, () => Effect.gen(function* () { + const signer = yield* SignatureV4 + const req = yield* request.pipe( + Effect.andThen(signer.signRequest) + ) + + expect(req.headers[`authorization`]).toMatchInlineSnapshot(`"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250309/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;host;x-amz-date;x-amz-security-token, Signature=c467cfb4191950cb8e1264c46ad448b847f1264edd9cbe7b84ccbd71144720f7"`) + })) +) diff --git a/packages/signature-v4/test/Smithy.test.ts b/packages/signature-v4/test/Smithy.test.ts new file mode 100644 index 00000000..62344d86 --- /dev/null +++ b/packages/signature-v4/test/Smithy.test.ts @@ -0,0 +1,22 @@ +import * as Layer from 'effect/Layer' +import * as Effect from 'effect/Effect' +import { SignatureV4 } from '@effect-aws/signature-v4/Smithy' +import { it } from '@effect/vitest' +import { expect } from 'vitest' +import { credentialsLayer, request } from './fixtures.js' + +const credsLayer = Layer.mergeAll( + SignatureV4.Default, + credentialsLayer +) + +it.layer(credsLayer)(({ effect }) => + effect(`Appends AWS v4 signature headers to the request`, () => Effect.gen(function* () { + const signer = yield* SignatureV4 + const req = yield* request.pipe( + Effect.andThen(signer.signRequest) + ) + + expect(req.headers[`authorization`]).toMatchInlineSnapshot(`"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250309/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;content-length;content-type;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=a668b731afc0f94e47a9225d405c62b7fd3f1be5dce24bb77c532b660c95179f"`) + })) +) diff --git a/packages/signature-v4/test/fixtures.ts b/packages/signature-v4/test/fixtures.ts new file mode 100644 index 00000000..46e453fa --- /dev/null +++ b/packages/signature-v4/test/fixtures.ts @@ -0,0 +1,18 @@ +import * as Effect from 'effect/Effect' +import * as Layer from 'effect/Layer' +import * as Ref from 'effect/Ref' +import * as Option from 'effect/Option' +import { HttpBody, HttpClientRequest } from '@effect/platform' +import { AWSCredentials, Credentials } from '../src/Credentials.js' + +export const credentials: AWSCredentials = { + accessKeyId: `AKIAIOSFODNN7EXAMPLE`, + secretAccessKey: `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`, + sessionToken: `AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4Olgk` +} + +export const credentialsLayer = Layer.succeed(Credentials, Ref.unsafeMake(Option.some(credentials))) + +export const request = HttpBody.json(JSON.stringify({ searchText: `needle` })).pipe( + Effect.andThen((body) => HttpClientRequest.make(`POST`)(`https://api-id1276237.execute-api.eu-west-1.amazonaws.com/search`, { acceptJson: true, body })) +) diff --git a/packages/signature-v4/tsconfig.cjs.json b/packages/signature-v4/tsconfig.cjs.json new file mode 100644 index 00000000..5d9330be --- /dev/null +++ b/packages/signature-v4/tsconfig.cjs.json @@ -0,0 +1,10 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/cjs.tsbuildinfo", + "outDir": "build/cjs", + "moduleResolution": "node", + "module": "CommonJS" + } +} diff --git a/packages/signature-v4/tsconfig.dev.json b/packages/signature-v4/tsconfig.dev.json new file mode 100644 index 00000000..f97384ef --- /dev/null +++ b/packages/signature-v4/tsconfig.dev.json @@ -0,0 +1,20 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "noEmit": true, + "rootDir": "test", + "types": [ + "../../vitest.d.ts" + ] + }, + "include": [ + "test" + ], + "references": [ + { + "path": "tsconfig.src.json" + } + ] +} diff --git a/packages/signature-v4/tsconfig.esm.json b/packages/signature-v4/tsconfig.esm.json new file mode 100644 index 00000000..0725cdeb --- /dev/null +++ b/packages/signature-v4/tsconfig.esm.json @@ -0,0 +1,10 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "./tsconfig.src.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/esm.tsbuildinfo", + "outDir": "build/esm", + "declarationDir": "build/dts", + "stripInternal": true + } +} diff --git a/packages/signature-v4/tsconfig.json b/packages/signature-v4/tsconfig.json new file mode 100644 index 00000000..c1c93439 --- /dev/null +++ b/packages/signature-v4/tsconfig.json @@ -0,0 +1,13 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { + "path": "tsconfig.src.json" + }, + { + "path": "tsconfig.dev.json" + } + ] +} diff --git a/packages/signature-v4/tsconfig.src.json b/packages/signature-v4/tsconfig.src.json new file mode 100644 index 00000000..dd8b4304 --- /dev/null +++ b/packages/signature-v4/tsconfig.src.json @@ -0,0 +1,12 @@ +// ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "outDir": "build/src", + "rootDir": "src" + }, + "include": [ + "src" + ] +} diff --git a/packages/signature-v4/vitest.config.ts b/packages/signature-v4/vitest.config.ts new file mode 100644 index 00000000..2cf045fa --- /dev/null +++ b/packages/signature-v4/vitest.config.ts @@ -0,0 +1,6 @@ +import { mergeConfig, type UserConfigExport } from "vitest/config"; +import configShared from "../../vitest.shared.js"; + +const config: UserConfigExport = {}; + +export default mergeConfig(configShared, config); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c437444a..1c0a516d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1344,6 +1344,31 @@ importers: version: 5.4.5 publishDirectory: dist + packages/signature-v4: + devDependencies: + '@aws-crypto/sha256-js': + specifier: ^5 + version: 5.2.0 + '@smithy/signature-v4': + specifier: ^5 + version: 5.0.1 + '@smithy/types': + specifier: ^4 + version: 4.1.0 + '@types/node': + specifier: ts5.4 + version: 22.13.10 + aws4fetch: + specifier: ^1 + version: 1.0.20 + effect: + specifier: 3.10.16 + version: 3.10.16 + typescript: + specifier: ^5.4.2 + version: 5.4.5 + publishDirectory: dist + packages/ssm: devDependencies: '@aws-sdk/client-ssm': @@ -2713,6 +2738,9 @@ packages: '@types/node@20.17.16': resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==} + '@types/node@22.13.10': + resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==} + '@types/node@22.13.9': resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==} @@ -2909,6 +2937,9 @@ packages: aws-sdk-client-mock@4.0.2: resolution: {integrity: sha512-saFLXQPqHuMH0A1peNIGoAFEq9B0bpS5y5qrr+Y5F86MasVkCctggHKhHPRVjGr852Nz7cLg/PBxKs6lQoK3mg==} + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -7785,7 +7816,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.13.9 + '@types/node': 22.13.10 '@types/yargs': 17.0.33 chalk: 4.1.2 @@ -8346,7 +8377,7 @@ snapshots: '@types/glob@7.1.3': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 22.13.9 + '@types/node': 22.13.10 '@types/istanbul-lib-coverage@2.0.6': {} @@ -8378,6 +8409,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/node@22.13.10': + dependencies: + undici-types: 6.20.0 + '@types/node@22.13.9': dependencies: undici-types: 6.20.0 @@ -8639,6 +8674,8 @@ snapshots: sinon: 18.0.1 tslib: 2.7.0 + aws4fetch@1.0.20: {} + balanced-match@1.0.2: {} better-path-resolve@1.0.0: @@ -9657,7 +9694,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.13.9 + '@types/node': 22.13.10 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 39737a00..a031b279 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -49,4 +49,5 @@ packages: - packages/lib-dynamodb - packages/powertools-logger - packages/secrets-manager + - packages/signature-v4 - packages/ssm diff --git a/tsconfig.base.json b/tsconfig.base.json index 842a10a7..90b61417 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -480,6 +480,15 @@ "@effect-aws/secrets-manager/test/*": [ "./packages/secrets-manager/test/*.js" ], + "@effect-aws/signature-v4": [ + "./packages/signature-v4/src/index.js" + ], + "@effect-aws/signature-v4/*": [ + "./packages/signature-v4/src/*.js" + ], + "@effect-aws/signature-v4/test/*": [ + "./packages/signature-v4/test/*.js" + ], "@effect-aws/ssm": [ "./packages/ssm/src/index.js" ], diff --git a/tsconfig.build.json b/tsconfig.build.json index 8f76635a..377750d4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -147,6 +147,9 @@ { "path": "packages/secrets-manager/tsconfig.esm.json" }, + { + "path": "packages/signature-v4/tsconfig.esm.json" + }, { "path": "packages/ssm/tsconfig.esm.json" } diff --git a/tsconfig.json b/tsconfig.json index f3b6ad5c..fdf82dc6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -152,6 +152,9 @@ { "path": "packages/secrets-manager" }, + { + "path": "packages/signature-v4" + }, { "path": "packages/ssm" } diff --git a/vitest.shared.ts b/vitest.shared.ts index c9dabfb9..4d201b24 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -66,6 +66,7 @@ const config: UserConfig = { ...alias("lambda"), ...alias("powertools-logger"), ...alias("secrets-manager"), + ...alias("signature-v4"), ...alias("ssm"), } } From f3c9732b2c555b54f70ccc5b5835b0d4c7a56eab Mon Sep 17 00:00:00 2001 From: Aleks Marchenko Date: Sun, 9 Mar 2025 18:01:01 +0100 Subject: [PATCH 2/5] chore: improve API, add docs --- packages/signature-v4/README.md | 79 +++++++++++++++++++- packages/signature-v4/package.json | 5 +- packages/signature-v4/src/Aws4Fetch.ts | 7 +- packages/signature-v4/src/Credentials.ts | 36 ++++++++- packages/signature-v4/src/Smithy.ts | 8 +- packages/signature-v4/src/index.ts | 16 ++-- packages/signature-v4/test/Aws4Fetch.test.ts | 4 +- packages/signature-v4/test/Smithy.test.ts | 4 +- packages/signature-v4/test/fixtures.ts | 5 +- 9 files changed, 146 insertions(+), 18 deletions(-) diff --git a/packages/signature-v4/README.md b/packages/signature-v4/README.md index b3fa7ddc..3ebe4599 100644 --- a/packages/signature-v4/README.md +++ b/packages/signature-v4/README.md @@ -1 +1,78 @@ -# replace this \ No newline at end of file +## Installation + +```bash +npm install --save @effect-aws/signature-v4 +``` + +_Note_: depending on the chosen implementation either `aws4fetch` or `@smithy/signature-v4` dependency is required + +## Usage + +1. Let's start with creating AWS credentials layer. +Credentials have type `Ref>`, making it possible to update it's value at any moment during app lifetime + +```typescript +import { Credentials } from '@effect-aws/signature-v4/Credentials' +import awsCredentials from './credentials.ts' + + +const CredentialsLayer = Credentials.layer(awsCredentials) + +/** + * We can access or modify credentials directly from within our Effect app like this + * + */ +Effect.gen(function* () { + /** + * Get credentials + */ + const credentials: Option = yield* Credentials.pipe( + Effect.andThen(Ref.get) + ) + /** + * Populate credentials with new value + */ + yield* Credentials.pipe( + Effect.andThen(Ref.set(Option.some(newCredentials))) + ) + + // or via handy API + const credentials = yield* Credentials.get + yield* Credentials.update(newCredentials) +}).pipe( + Effect.provide(CredentialsLayer) +) +``` + +_Note_: secretAccessKey and sessionToken credentials values are wrapped with `Redacted` + +2. Use signer of choice to sign HttpClientRequest +```typescript +import { SignatureV4 } from '@effect-aws/signature-v4/Smithy' +// or +import { SignatureV4 } from '@effect-aws/signature-v4/Aws4Fetch' +``` + +_Note_: avoid importing an implementation via barrel-import due to different set of peer dependencies + +```typescript +const SigV4 = SignatureV4.Default.pipe( + Effect.provideMerge(CredentialsLayer) // or Effect.provide if you're not planning to access credentials directly in your code +) + +Effect.gen(function* () { + // you got a hold of an HttpClientRequest + const signedRequest = yield* SignatureV4.signRequest(request) + + // or use transformClient to modify and HttpClient to automatically sign requests + + const { transformClient } = yield* SignatureV4 + + const apiClient = yield* HttpApiClient.make(Api, { + baseUrl, + transformClient, + }) +}).pipe( + Layer.provide(SigV4) +) +``` diff --git a/packages/signature-v4/package.json b/packages/signature-v4/package.json index f1423397..1474afd8 100644 --- a/packages/signature-v4/package.json +++ b/packages/signature-v4/package.json @@ -48,8 +48,9 @@ "directory": "dist" }, "exports": { - "./Smithy": "./Smithy.ts", - "./Aws4Fetch": "./Aws4Fetch.ts" + "./Smithy": "./Smithy.js", + "./Credentials": "./Credentials.js", + "./Aws4Fetch": "./Aws4Fetch.js" }, "version": "0.0.0", "types": "build/dts/index.d.ts", diff --git a/packages/signature-v4/src/Aws4Fetch.ts b/packages/signature-v4/src/Aws4Fetch.ts index 864592f0..d6cb308a 100644 --- a/packages/signature-v4/src/Aws4Fetch.ts +++ b/packages/signature-v4/src/Aws4Fetch.ts @@ -3,6 +3,7 @@ import { RequestError } from '@effect/platform/HttpClientError' import * as HttpClientRequest from '@effect/platform/HttpClientRequest' import { AwsV4Signer } from 'aws4fetch' import * as Effect from 'effect/Effect' +import * as Redacted from 'effect/Redacted' import * as Option from 'effect/Option' import * as Ref from 'effect/Ref' import { Credentials } from './Credentials.js' @@ -13,6 +14,7 @@ import { getBody } from './helpers.js' * AWS Signature v4 implementation using aws4fetch package */ export class SignatureV4 extends Effect.Service()(`@effect-aws/signature-v4/SignatureV4`, { + accessors: true, effect: Effect.gen(function* () { const signRequest = (request: HttpClientRequest.HttpClientRequest, options?: SignerOptions) => { const body: BodyInit = getBody(request.body) @@ -29,8 +31,9 @@ export class SignatureV4 extends Effect.Service()(`@effect-aws/sign headers: request.headers, body, accessKeyId, - secretAccessKey, - sessionToken: sessionToken! + secretAccessKey: secretAccessKey.pipe(Redacted.value), + sessionToken: sessionToken?.pipe(Redacted.value), + allHeaders: true }) return Effect.tryPromise(() => signer.sign()).pipe( diff --git a/packages/signature-v4/src/Credentials.ts b/packages/signature-v4/src/Credentials.ts index 2d7f0d99..6e9f1c0c 100644 --- a/packages/signature-v4/src/Credentials.ts +++ b/packages/signature-v4/src/Credentials.ts @@ -1,5 +1,8 @@ import * as Context from 'effect/Context' +import * as Effect from 'effect/Effect' import * as Option from 'effect/Option' +import * as Layer from 'effect/Layer' +import * as Redacted from 'effect/Redacted' import * as Ref from 'effect/Ref' export interface AWSCredentials { @@ -9,4 +12,35 @@ export interface AWSCredentials { expiration?: Date; } -export class Credentials extends Context.Tag(`@effect-aws/signature-v4/Credentials`)>>() {} +export interface AWSCredentialsRedacted { + accessKeyId: string; + secretAccessKey: Redacted.Redacted; + sessionToken?: Redacted.Redacted; + expiration?: Date; +} + +const redactCredentials = (creds?: AWSCredentials): Option.Option => Option.fromNullable(creds).pipe( + Option.map((creds) => ({ + ...creds, + secretAccessKey: + Redacted.make(creds.secretAccessKey), + sessionToken: Option.fromNullable(creds.sessionToken).pipe( + Option.map(Redacted.make), + Option.getOrUndefined + ) + })) +) + +export class Credentials extends Context.Tag(`@effect-aws/signature-v4/Credentials`)>>() { + static layer = (credentials?: AWSCredentials) => Layer.effect(Credentials, Ref.make(redactCredentials(credentials))) + + static current = Credentials.pipe( + Effect.flatMap(Ref.get) + ) + + static update = (credentials?: AWSCredentials) => + Credentials.pipe( + Effect.flatMap(Ref.set(redactCredentials(credentials))) + ) + +} diff --git a/packages/signature-v4/src/Smithy.ts b/packages/signature-v4/src/Smithy.ts index 1bf81f91..8d0d0ef5 100644 --- a/packages/signature-v4/src/Smithy.ts +++ b/packages/signature-v4/src/Smithy.ts @@ -4,6 +4,7 @@ import * as HttpClientRequest from '@effect/platform/HttpClientRequest' import { Sha256 } from "@aws-crypto/sha256-js" import { SignatureV4 as AwsSignatureV4 } from '@smithy/signature-v4' import * as Effect from 'effect/Effect' +import * as Redacted from 'effect/Redacted' import * as Option from 'effect/Option' import * as Ref from 'effect/Ref' import { Credentials } from './Credentials.js' @@ -15,6 +16,7 @@ import { HttpRequest } from '@smithy/types' * AWS Signature v4 implementation @smithy/signature-4 */ export class SignatureV4 extends Effect.Service()(`@effect-aws/signature-v4/SignatureV4`, { + accessors: true, scoped: Effect.gen(function* () { const signRequest = (request: HttpClientRequest.HttpClientRequest, options?: SignerOptions) => { return Credentials.pipe(Effect.flatMap(Ref.get)).pipe( @@ -28,7 +30,11 @@ export class SignatureV4 extends Effect.Service()(`@effect-aws/sign : guessServiceRegion(url, request.headers) const signer = new AwsSignatureV4({ - credentials, + credentials: { + ...credentials, + secretAccessKey: credentials.secretAccessKey.pipe(Redacted.value), + sessionToken: credentials.sessionToken?.pipe(Redacted.value) + }, service, region, sha256: Sha256, diff --git a/packages/signature-v4/src/index.ts b/packages/signature-v4/src/index.ts index 92c94b81..d0b5563e 100644 --- a/packages/signature-v4/src/index.ts +++ b/packages/signature-v4/src/index.ts @@ -1,5 +1,11 @@ -export class Hello { - public sayHello() { - return 'hello, world!'; - } -} \ No newline at end of file +import * as Credentials from './Credentials.js' +import * as Aws4Fetch from './Aws4Fetch.js' +import * as Smithy from './Smithy.js' +import * as Signer from './Signer.js' + +export { + Credentials, + Aws4Fetch, + Smithy, + Signer +} diff --git a/packages/signature-v4/test/Aws4Fetch.test.ts b/packages/signature-v4/test/Aws4Fetch.test.ts index c0492fb0..8781c8da 100644 --- a/packages/signature-v4/test/Aws4Fetch.test.ts +++ b/packages/signature-v4/test/Aws4Fetch.test.ts @@ -17,6 +17,8 @@ it.layer(credsLayer)(({ effect }) => Effect.andThen(signer.signRequest) ) - expect(req.headers[`authorization`]).toMatchInlineSnapshot(`"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250309/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;host;x-amz-date;x-amz-security-token, Signature=c467cfb4191950cb8e1264c46ad448b847f1264edd9cbe7b84ccbd71144720f7"`) + expect(req.headers[`authorization`]).toEqual( + expect.stringContaining(`AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250309/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;content-length;content-type;host;x-amz-date;x-amz-security-token`) + ) })) ) diff --git a/packages/signature-v4/test/Smithy.test.ts b/packages/signature-v4/test/Smithy.test.ts index 62344d86..7228786b 100644 --- a/packages/signature-v4/test/Smithy.test.ts +++ b/packages/signature-v4/test/Smithy.test.ts @@ -17,6 +17,8 @@ it.layer(credsLayer)(({ effect }) => Effect.andThen(signer.signRequest) ) - expect(req.headers[`authorization`]).toMatchInlineSnapshot(`"AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250309/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;content-length;content-type;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=a668b731afc0f94e47a9225d405c62b7fd3f1be5dce24bb77c532b660c95179f"`) + expect(req.headers[`authorization`]).toEqual( + expect.stringContaining(`AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250309/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;content-length;content-type;x-amz-content-sha256;x-amz-date;x-amz-security-token`) + ) })) ) diff --git a/packages/signature-v4/test/fixtures.ts b/packages/signature-v4/test/fixtures.ts index 46e453fa..48a7a855 100644 --- a/packages/signature-v4/test/fixtures.ts +++ b/packages/signature-v4/test/fixtures.ts @@ -1,7 +1,4 @@ import * as Effect from 'effect/Effect' -import * as Layer from 'effect/Layer' -import * as Ref from 'effect/Ref' -import * as Option from 'effect/Option' import { HttpBody, HttpClientRequest } from '@effect/platform' import { AWSCredentials, Credentials } from '../src/Credentials.js' @@ -11,7 +8,7 @@ export const credentials: AWSCredentials = { sessionToken: `AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4Olgk` } -export const credentialsLayer = Layer.succeed(Credentials, Ref.unsafeMake(Option.some(credentials))) +export const credentialsLayer = Credentials.layer(credentials) export const request = HttpBody.json(JSON.stringify({ searchText: `needle` })).pipe( Effect.andThen((body) => HttpClientRequest.make(`POST`)(`https://api-id1276237.execute-api.eu-west-1.amazonaws.com/search`, { acceptJson: true, body })) From 06b47601de44d87e4a94ada9c033a2241f4bf95c Mon Sep 17 00:00:00 2001 From: Aleks Marchenko Date: Mon, 10 Mar 2025 08:43:19 +0100 Subject: [PATCH 3/5] doc: fix README typo --- packages/signature-v4/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/signature-v4/README.md b/packages/signature-v4/README.md index 3ebe4599..32976ef4 100644 --- a/packages/signature-v4/README.md +++ b/packages/signature-v4/README.md @@ -37,7 +37,7 @@ Effect.gen(function* () { ) // or via handy API - const credentials = yield* Credentials.get + const credentials = yield* Credentials.current yield* Credentials.update(newCredentials) }).pipe( Effect.provide(CredentialsLayer) From 974c623de696ff0789ab2006384d33cab35d59f0 Mon Sep 17 00:00:00 2001 From: Victor Korzunin <5180700+floydspace@users.noreply.github.com> Date: Tue, 11 Mar 2025 18:28:49 +0100 Subject: [PATCH 4/5] test: fix unit tests --- packages/signature-v4/test/Aws4Fetch.test.ts | 2 +- packages/signature-v4/test/Smithy.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/signature-v4/test/Aws4Fetch.test.ts b/packages/signature-v4/test/Aws4Fetch.test.ts index 8781c8da..7c9d4721 100644 --- a/packages/signature-v4/test/Aws4Fetch.test.ts +++ b/packages/signature-v4/test/Aws4Fetch.test.ts @@ -18,7 +18,7 @@ it.layer(credsLayer)(({ effect }) => ) expect(req.headers[`authorization`]).toEqual( - expect.stringContaining(`AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250309/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;content-length;content-type;host;x-amz-date;x-amz-security-token`) + expect.stringMatching(`AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/\\d{8}/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;content-length;content-type;host;x-amz-date;x-amz-security-token, Signature=\\w+`) ) })) ) diff --git a/packages/signature-v4/test/Smithy.test.ts b/packages/signature-v4/test/Smithy.test.ts index 7228786b..241aae97 100644 --- a/packages/signature-v4/test/Smithy.test.ts +++ b/packages/signature-v4/test/Smithy.test.ts @@ -18,7 +18,7 @@ it.layer(credsLayer)(({ effect }) => ) expect(req.headers[`authorization`]).toEqual( - expect.stringContaining(`AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20250309/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;content-length;content-type;x-amz-content-sha256;x-amz-date;x-amz-security-token`) + expect.stringMatching(`AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/\\d{8}/eu-west-1/execute-api/aws4_request, SignedHeaders=accept;content-length;content-type;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=\\w+`) ) })) ) From f070eaa5e9c2f5a9d2397fbaca9189dd04d969f2 Mon Sep 17 00:00:00 2001 From: Aleks Marchenko Date: Sun, 23 Mar 2025 08:59:14 +0100 Subject: [PATCH 5/5] fix: better API, fix flaky tests --- .projenrc.ts | 15 +++++++++++--- packages/signature-v4/.projen/deps.json | 25 ------------------------ packages/signature-v4/package.json | 24 +++++++++++++---------- packages/signature-v4/src/Aws4Fetch.ts | 2 +- packages/signature-v4/src/Credentials.ts | 25 +++++++++++++++--------- packages/signature-v4/src/Smithy.ts | 3 +-- pnpm-lock.yaml | 11 ++++++----- 7 files changed, 50 insertions(+), 55 deletions(-) diff --git a/.projenrc.ts b/.projenrc.ts index 2b9e24e8..140c2f85 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -125,14 +125,23 @@ new TypeScriptLibProject({ workspacePeerDeps: [secretsManagerClient], }); -new TypeScriptLibProject({ +const proj = new TypeScriptLibProject({ parent: project, name: "signature-v4", description: "AWS Signature V4 for Effect HttpClientRequest", - devDeps: [...effectDeps, "@smithy/signature-v4@^5", "@smithy/types@^4", "aws4fetch@^1", "@aws-crypto/sha256-js@^5"], - peerDeps: [...commonPeerDeps, "@smithy/signature-v4@^5", "@smithy/types@^4", "aws4fetch@^1", "@aws-crypto/sha256-js@^5"], + peerDeps: [...commonPeerDeps, "@smithy/signature-v4@^5", "@smithy/types@^4", "aws4fetch@^1", "@aws-crypto/sha256-js@^5"] }); + +proj.addFields({ + peerDependenciesMeta: { + '@smithy/signature-v4': { optional: true }, + '@smithy/types': { optional: true }, + 'aws4fetch': { optional: true }, + '@aws-crypto/sha256-js': { optional: true }, + } +}) + new TypeScriptLibProject({ parent: project, name: "ssm", diff --git a/packages/signature-v4/.projen/deps.json b/packages/signature-v4/.projen/deps.json index 81a0b893..1d46cc0c 100644 --- a/packages/signature-v4/.projen/deps.json +++ b/packages/signature-v4/.projen/deps.json @@ -1,35 +1,10 @@ { "dependencies": [ - { - "name": "@aws-crypto/sha256-js", - "version": "^5", - "type": "build" - }, - { - "name": "@smithy/signature-v4", - "version": "^5", - "type": "build" - }, - { - "name": "@smithy/types", - "version": "^4", - "type": "build" - }, { "name": "@types/node", "version": "ts5.4", "type": "build" }, - { - "name": "aws4fetch", - "version": "^1", - "type": "build" - }, - { - "name": "effect", - "version": "3.10.16", - "type": "build" - }, { "name": "typescript", "version": "^5.4.2", diff --git a/packages/signature-v4/package.json b/packages/signature-v4/package.json index 1474afd8..87a18f9b 100644 --- a/packages/signature-v4/package.json +++ b/packages/signature-v4/package.json @@ -25,12 +25,7 @@ "organization": false }, "devDependencies": { - "@aws-crypto/sha256-js": "^5", - "@smithy/signature-v4": "^5", - "@smithy/types": "^4", "@types/node": "ts5.4", - "aws4fetch": "^1", - "effect": "3.10.16", "typescript": "^5.4.2" }, "peerDependencies": { @@ -47,15 +42,24 @@ "access": "public", "directory": "dist" }, - "exports": { - "./Smithy": "./Smithy.js", - "./Credentials": "./Credentials.js", - "./Aws4Fetch": "./Aws4Fetch.js" - }, "version": "0.0.0", "types": "build/dts/index.d.ts", "type": "module", "module": "build/esm/index.js", "sideEffects": [], + "peerDependenciesMeta": { + "@smithy/signature-v4": { + "optional": true + }, + "@smithy/types": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "@aws-crypto/sha256-js": { + "optional": true + } + }, "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"npx projen\"." } diff --git a/packages/signature-v4/src/Aws4Fetch.ts b/packages/signature-v4/src/Aws4Fetch.ts index d6cb308a..9846355f 100644 --- a/packages/signature-v4/src/Aws4Fetch.ts +++ b/packages/signature-v4/src/Aws4Fetch.ts @@ -19,7 +19,7 @@ export class SignatureV4 extends Effect.Service()(`@effect-aws/sign const signRequest = (request: HttpClientRequest.HttpClientRequest, options?: SignerOptions) => { const body: BodyInit = getBody(request.body) - return Credentials.pipe(Effect.flatMap(Ref.get)).pipe( + return Credentials.current.pipe( Effect.andThen( Option.match({ onNone: () => Effect.succeed(request), diff --git a/packages/signature-v4/src/Credentials.ts b/packages/signature-v4/src/Credentials.ts index 6e9f1c0c..b2ab2667 100644 --- a/packages/signature-v4/src/Credentials.ts +++ b/packages/signature-v4/src/Credentials.ts @@ -31,16 +31,23 @@ const redactCredentials = (creds?: AWSCredentials): Option.Option>>() { - static layer = (credentials?: AWSCredentials) => Layer.effect(Credentials, Ref.make(redactCredentials(credentials))) +export interface CredentialsType { + current: Effect.Effect> + update: (credentials?: AWSCredentials) => Effect.Effect +} - static current = Credentials.pipe( - Effect.flatMap(Ref.get) - ) - static update = (credentials?: AWSCredentials) => - Credentials.pipe( - Effect.flatMap(Ref.set(redactCredentials(credentials))) - ) +export class Credentials extends Context.Tag(`@effect-aws/signature-v4/Credentials`)() { + static layer = (credentials?: AWSCredentials) => { + const ref = Ref.unsafeMake(redactCredentials(credentials)) + const service = { + current: Ref.get(ref), + update: (credentials) => Ref.set(ref, redactCredentials(credentials)) + } satisfies CredentialsType + + return Layer.succeed(Credentials, service) + } + static current = Credentials.pipe(Effect.flatMap((a) => a.current)) + static update = (credentials?: AWSCredentials) => Credentials.pipe(Effect.flatMap((a) => a.update(credentials))) } diff --git a/packages/signature-v4/src/Smithy.ts b/packages/signature-v4/src/Smithy.ts index 8d0d0ef5..9ea393b0 100644 --- a/packages/signature-v4/src/Smithy.ts +++ b/packages/signature-v4/src/Smithy.ts @@ -6,7 +6,6 @@ import { SignatureV4 as AwsSignatureV4 } from '@smithy/signature-v4' import * as Effect from 'effect/Effect' import * as Redacted from 'effect/Redacted' import * as Option from 'effect/Option' -import * as Ref from 'effect/Ref' import { Credentials } from './Credentials.js' import { Signer, SignerOptions } from './Signer.js' import { getBody, guessServiceRegion } from './helpers.js' @@ -19,7 +18,7 @@ export class SignatureV4 extends Effect.Service()(`@effect-aws/sign accessors: true, scoped: Effect.gen(function* () { const signRequest = (request: HttpClientRequest.HttpClientRequest, options?: SignerOptions) => { - return Credentials.pipe(Effect.flatMap(Ref.get)).pipe( + return Credentials.current.pipe( Effect.andThen( Option.match({ onNone: () => Effect.succeed(request), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 205c7a47..6b319938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1429,7 +1429,7 @@ importers: publishDirectory: dist packages/signature-v4: - devDependencies: + dependencies: '@aws-crypto/sha256-js': specifier: ^5 version: 5.2.0 @@ -1439,15 +1439,16 @@ importers: '@smithy/types': specifier: ^4 version: 4.1.0 - '@types/node': - specifier: ts5.4 - version: 22.13.10 aws4fetch: specifier: ^1 version: 1.0.20 effect: - specifier: 3.10.16 + specifier: '>=3.0.4 <4.0.0' version: 3.10.16 + devDependencies: + '@types/node': + specifier: ts5.4 + version: 22.13.10 typescript: specifier: ^5.4.2 version: 5.4.5