diff --git a/.projenrc.ts b/.projenrc.ts index ef86ae79..140c2f85 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -125,6 +125,23 @@ new TypeScriptLibProject({ workspacePeerDeps: [secretsManagerClient], }); +const proj = new TypeScriptLibProject({ + parent: project, + name: "signature-v4", + description: "AWS Signature V4 for Effect HttpClientRequest", + 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/.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..1d46cc0c --- /dev/null +++ b/packages/signature-v4/.projen/deps.json @@ -0,0 +1,40 @@ +{ + "dependencies": [ + { + "name": "@types/node", + "version": "ts5.4", + "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..32976ef4 --- /dev/null +++ b/packages/signature-v4/README.md @@ -0,0 +1,78 @@ +## 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.current + 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/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..87a18f9b --- /dev/null +++ b/packages/signature-v4/package.json @@ -0,0 +1,65 @@ +{ + "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": { + "@types/node": "ts5.4", + "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" + }, + "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 new file mode 100644 index 00000000..9846355f --- /dev/null +++ b/packages/signature-v4/src/Aws4Fetch.ts @@ -0,0 +1,56 @@ +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 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 } 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) + + return Credentials.current.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: secretAccessKey.pipe(Redacted.value), + sessionToken: sessionToken?.pipe(Redacted.value), + allHeaders: true + }) + + 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..b2ab2667 --- /dev/null +++ b/packages/signature-v4/src/Credentials.ts @@ -0,0 +1,53 @@ +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 { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + expiration?: Date; +} + +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 interface CredentialsType { + current: Effect.Effect> + update: (credentials?: AWSCredentials) => Effect.Effect +} + + +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/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..9ea393b0 --- /dev/null +++ b/packages/signature-v4/src/Smithy.ts @@ -0,0 +1,66 @@ +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 Redacted from 'effect/Redacted' +import * as Option from 'effect/Option' +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`, { + accessors: true, + scoped: Effect.gen(function* () { + const signRequest = (request: HttpClientRequest.HttpClientRequest, options?: SignerOptions) => { + return Credentials.current.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: { + ...credentials, + secretAccessKey: credentials.secretAccessKey.pipe(Redacted.value), + sessionToken: credentials.sessionToken?.pipe(Redacted.value) + }, + 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..d0b5563e --- /dev/null +++ b/packages/signature-v4/src/index.ts @@ -0,0 +1,11 @@ +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 new file mode 100644 index 00000000..7c9d4721 --- /dev/null +++ b/packages/signature-v4/test/Aws4Fetch.test.ts @@ -0,0 +1,24 @@ +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`]).toEqual( + 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 new file mode 100644 index 00000000..241aae97 --- /dev/null +++ b/packages/signature-v4/test/Smithy.test.ts @@ -0,0 +1,24 @@ +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`]).toEqual( + 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+`) + ) + })) +) diff --git a/packages/signature-v4/test/fixtures.ts b/packages/signature-v4/test/fixtures.ts new file mode 100644 index 00000000..48a7a855 --- /dev/null +++ b/packages/signature-v4/test/fixtures.ts @@ -0,0 +1,15 @@ +import * as Effect from 'effect/Effect' +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 = 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 })) +) 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 bc3968e6..6b319938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1428,6 +1428,32 @@ importers: version: 5.4.5 publishDirectory: dist + packages/signature-v4: + dependencies: + '@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 + aws4fetch: + specifier: ^1 + version: 1.0.20 + effect: + 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 + publishDirectory: dist + packages/ssm: devDependencies: '@aws-sdk/client-ssm': @@ -3005,6 +3031,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==} @@ -8887,6 +8916,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: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7c08a69b..5cc2afeb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -52,4 +52,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 a6a23c21..b5b897d3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -507,6 +507,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 86ed1268..f7819483 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -156,6 +156,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 76b1400b..6be2fe6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -161,6 +161,9 @@ { "path": "packages/secrets-manager" }, + { + "path": "packages/signature-v4" + }, { "path": "packages/ssm" } diff --git a/vitest.shared.ts b/vitest.shared.ts index 77b195fc..d8178c2c 100644 --- a/vitest.shared.ts +++ b/vitest.shared.ts @@ -69,6 +69,7 @@ const config: UserConfig = { ...alias("lambda"), ...alias("powertools-logger"), ...alias("secrets-manager"), + ...alias("signature-v4"), ...alias("ssm"), } }