├── .all-contributorsrc ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .eslintignore ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .releaserc.json ├── .tool-versions ├── LICENSE ├── README.md ├── infra ├── .gitignore ├── .npmignore ├── README.md ├── bin │ └── infra.ts ├── cdk.json ├── jest.config.js ├── lib │ ├── api.ts │ └── infra-stack.ts ├── package.json ├── test │ └── infra.test.ts └── tsconfig.json ├── jest.config.it.js ├── jest.config.js ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── __tests__ │ ├── apiGateway.it.ts │ ├── serverless.yml │ ├── setup.ts │ └── src │ │ └── index.js ├── axiosInterceptor.test.ts ├── credentials │ ├── assumeRoleCredentialsProvider.test.ts │ ├── assumeRoleCredentialsProvider.ts │ ├── credentialsProvider.ts │ ├── isCredentialsProvider.ts │ ├── simpleCredentialsProvider.test.ts │ └── simpleCredentialsProvider.ts ├── getAuthErrorMessage.test.ts ├── getAuthErrorMessage.ts ├── index.ts ├── interceptor.test.ts └── interceptor.ts └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "florianbepunkt", 10 | "name": "Florian Bischoff", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/8314202?v=4", 12 | "profile": "https://github.com/florianbepunkt", 13 | "contributions": [ 14 | "code" 15 | ] 16 | }, 17 | { 18 | "login": "rubenvanrooij", 19 | "name": "Ruben van Rooij", 20 | "avatar_url": "https://avatars.githubusercontent.com/u/875349?v=4", 21 | "profile": "https://github.com/rubenvanrooij", 22 | "contributions": [ 23 | "code" 24 | ] 25 | }, 26 | { 27 | "login": "moltar", 28 | "name": "Roman", 29 | "avatar_url": "https://avatars.githubusercontent.com/u/491247?v=4", 30 | "profile": "https://www.ScaleLeap.com", 31 | "contributions": [ 32 | "review" 33 | ] 34 | }, 35 | { 36 | "login": "m-radzikowski", 37 | "name": "Maciej Radzikowski", 38 | "avatar_url": "https://avatars.githubusercontent.com/u/4042673?v=4", 39 | "profile": "http://betterdev.blog", 40 | "contributions": [ 41 | "test", 42 | "code" 43 | ] 44 | }, 45 | { 46 | "login": "ballpointcarrot", 47 | "name": "Christopher Kruse", 48 | "avatar_url": "https://avatars.githubusercontent.com/u/96404?v=4", 49 | "profile": "https://www.ballpointcarrot.net", 50 | "contributions": [ 51 | "test" 52 | ] 53 | }, 54 | { 55 | "login": "james-hu", 56 | "name": "James Hu", 57 | "avatar_url": "https://avatars.githubusercontent.com/u/8565700?v=4", 58 | "profile": "https://plus.google.com/photos/104306225331370072882/albums", 59 | "contributions": [ 60 | "code" 61 | ] 62 | }, 63 | { 64 | "login": "dblock", 65 | "name": "Daniel (dB.) Doubrovkine", 66 | "avatar_url": "https://avatars.githubusercontent.com/u/542335?v=4", 67 | "profile": "https://code.dblock.org", 68 | "contributions": [ 69 | "code" 70 | ] 71 | }, 72 | { 73 | "login": "frtelg", 74 | "name": "Franke Telgenhof", 75 | "avatar_url": "https://avatars.githubusercontent.com/u/43170660?v=4", 76 | "profile": "https://github.com/frtelg", 77 | "contributions": [ 78 | "code" 79 | ] 80 | } 81 | ], 82 | "contributorsPerLine": 7, 83 | "projectName": "aws4-axios", 84 | "projectOwner": "jamesmbourne", 85 | "repoType": "github", 86 | "repoHost": "https://github.com", 87 | "skipCi": true, 88 | "commitType": "docs", 89 | "commitConvention": "angular" 90 | } 91 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.231.2/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="18-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # Ensure the latest NPM version is installed regardless to which Node version we are running. 12 | RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install-latest-npm" 13 | 14 | RUN npm install -g aws-cdk 15 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.231.2/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile" 7 | }, 8 | "customizations": { 9 | "vscode": { 10 | "extensions": [ 11 | "dbaeumer.vscode-eslint", 12 | "EditorConfig.EditorConfig", 13 | "amazonwebservices.aws-toolkit-vscode" // AWS Toolkit Support 14 | ] 15 | } 16 | }, 17 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 18 | // "forwardPorts": [], 19 | // Use 'postCreateCommand' to run commands after the container is created. 20 | "postCreateCommand": "npm ci", 21 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 22 | "remoteUser": "node", 23 | "features": { 24 | "git": "latest", 25 | "github-cli": "latest", 26 | "aws-cli": "latest" 27 | }, 28 | "mounts": [ 29 | "source=${env:HOME}${env:USERPROFILE}/.aws,target=/home/node/.aws,type=bind" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/recommended", 5 | "prettier" 6 | ], 7 | "parser": "@typescript-eslint/parser", 8 | "plugins": ["@typescript-eslint/eslint-plugin"], 9 | "parserOptions": { 10 | "project": "./tsconfig.json" 11 | }, 12 | "ignorePatterns": ["infra/"], 13 | "rules": { 14 | "@typescript-eslint/no-unused-vars": [ 15 | "error", 16 | { "ignoreRestSiblings": true } 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | commit-message: 10 | prefix: fix 11 | prefix-development: chore 12 | include: scope 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | permissions: 10 | id-token: write # This is required for requesting the JWT 11 | contents: read # This is required for actions/checkout 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [20.x, 22.x] 20 | axios-version: ["1.6.0", "1.7.0", "latest"] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | - run: npm ci 29 | - run: npm install axios@${{ matrix.axios-version }} 30 | - run: npm run build 31 | - run: npm run lint 32 | - run: npm run test 33 | env: 34 | CI: true 35 | - name: Configure AWS Credentials 36 | uses: aws-actions/configure-aws-credentials@v4 37 | with: 38 | role-to-assume: ${{ secrets.AWS_ROLE_ARN }} 39 | aws-region: ${{ secrets.AWS_REGION }} 40 | - run: npm run test-it 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | - name: Install dependencies 18 | run: npm ci 19 | - name: Release 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 23 | run: npm run semantic-release 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | .serverless 4 | 5 | # macOS 6 | .DS_Store 7 | 8 | .idea 9 | 10 | dist/ 11 | reports/ 12 | .eslintcache 13 | .idea/ 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | src 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"] 3 | } 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 22.14.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2025 James Bourne 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws4-axios 2 | 3 | 4 | [![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors-) 5 | 6 | 7 | [![npm version](https://img.shields.io/npm/v/aws4-axios.svg?style=flat-square)](https://www.npmjs.org/package/aws4-axios) 8 | [![npm downloads](https://img.shields.io/npm/dm/aws4-axios.svg?style=flat-square)](http://npm-stat.com/charts.html?package=aws4-axios) 9 | 10 | This is a request interceptor for the Axios HTTP request library to allow requests to be signed with an AWSv4 signature. 11 | 12 | This may be useful for accessing AWS services protected with IAM auth such as an API Gateway. 13 | 14 | # Installation 15 | 16 | | yarn | npm | 17 | | --------------------- | ------------------------------- | 18 | | `yarn add aws4-axios` | `npm install --save aws4-axios` | 19 | 20 | # Compatibility 21 | 22 | This interceptor is heavily dependent on Axios internals, so minor changes to them can cause the interceptor to fail. 23 | 24 | Please make sure you are using one of the following versions of Axios before submitting issues etc. 25 | 26 | | Axios Version | Supported? | 27 | | ------------------- | ---------- | 28 | | `< 1.4.0` | ❌ No | 29 | | `>= 1.4.0 <= 1.6.7` | ✅ Yes | 30 | | `> 1.6.7` | Unknown | 31 | 32 | # Usage 33 | 34 | To add an interceptor to the default Axios client: 35 | 36 | ```typescript 37 | import axios from "axios"; 38 | import { aws4Interceptor } from "aws4-axios"; 39 | 40 | const interceptor = aws4Interceptor({ 41 | options: { 42 | region: "eu-west-2", 43 | service: "execute-api", 44 | }, 45 | }); 46 | 47 | axios.interceptors.request.use(interceptor); 48 | 49 | // Requests made using Axios will now be signed 50 | axios.get("https://example.com/foo").then((res) => { 51 | // ... 52 | }); 53 | ``` 54 | 55 | Or you can add the interceptor to a specific instance of an Axios client: 56 | 57 | ```typescript 58 | import axios from "axios"; 59 | import { aws4Interceptor } from "aws4-axios"; 60 | 61 | const client = axios.create(); 62 | 63 | const interceptor = aws4Interceptor({ 64 | options: { 65 | region: "eu-west-2", 66 | service: "execute-api", 67 | }, 68 | }); 69 | 70 | client.interceptors.request.use(interceptor); 71 | 72 | // Requests made using Axios will now be signed 73 | client.get("https://example.com/foo").then((res) => { 74 | // ... 75 | }); 76 | ``` 77 | 78 | You can also pass AWS credentials in explicitly (otherwise taken from process.env) 79 | 80 | ```typescript 81 | const interceptor = aws4Interceptor({ 82 | options: { 83 | region: "eu-west-2", 84 | service: "execute-api", 85 | }, 86 | credentials: { 87 | accessKeyId: "", 88 | secretAccessKey: "", 89 | }, 90 | }); 91 | ``` 92 | 93 | You can also pass a custom `CredentialsProvider` factory instead 94 | 95 | ```typescript 96 | const customCredentialsProvider = { 97 | getCredentials: async () => { 98 | return Promise.resolve({ 99 | accessKeyId: "custom-provider-access-key-id", 100 | secretAccessKey: "custom-provider-secret-access-key", 101 | }); 102 | }, 103 | }; 104 | 105 | const interceptor = aws4Interceptor({ 106 | options: { 107 | region: "eu-west-2", 108 | service: "execute-api", 109 | }, 110 | credentials: customCredentialsProvider, 111 | }); 112 | ``` 113 | 114 | Newer services, such as [Amazon OpenSearch Serverless](https://aws.amazon.com/opensearch-service/features/serverless/), require a content SHA. Pass `addContentSha` to `options` to enable adding an `X-Amz-Content-Sha256` header to the request. 115 | 116 | ```typescript 117 | const interceptor = aws4Interceptor({ 118 | options: { 119 | region: "eu-west-2", 120 | service: "aoss", 121 | addContentSha: true, 122 | }, 123 | credentials: { 124 | accessKeyId: "", 125 | secretAccessKey: "", 126 | }, 127 | }); 128 | ``` 129 | 130 | # Migration to v3 131 | 132 | The interface for options changed in v3. You should now pass a single object with configuration. 133 | 134 | The previous options object is now nested under the property `options`. 135 | 136 | E.g (v2). 137 | 138 | ```typescript 139 | const interceptor = aws4Interceptor({ 140 | region: "eu-west-2", 141 | service: "execute-api", 142 | assumeRoleArn: "arn:aws:iam::111111111111:role/MyRole", 143 | }); 144 | ``` 145 | 146 | would become (v3): 147 | 148 | ```typescript 149 | const interceptor = aws4Interceptor({ 150 | options: { 151 | region: "eu-west-2", 152 | service: "execute-api", 153 | assumeRoleArn: "arn:aws:iam::111111111111:role/MyRole", 154 | }, 155 | }); 156 | ``` 157 | 158 | If you passed a custom credential provider, this is now done via the `credentials` property. 159 | 160 | E.g (v2). 161 | 162 | ```typescript 163 | const interceptor = aws4Interceptor( 164 | { 165 | region: "eu-west-2", 166 | service: "execute-api", 167 | }, 168 | { 169 | accessKeyId: "AKIAIOSFODNN7EXAMPLE", 170 | secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 171 | } 172 | ); 173 | ``` 174 | 175 | would become (v3): 176 | 177 | ```typescript 178 | const interceptor = aws4Interceptor({ 179 | options: { 180 | region: "eu-west-2", 181 | service: "execute-api", 182 | assumeRoleArn: "arn:aws:iam::111111111111:role/MyRole", 183 | }, 184 | credentials: { 185 | accessKeyId: "AKIAIOSFODNN7EXAMPLE", 186 | secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", 187 | }, 188 | }); 189 | ``` 190 | 191 | ## Assuming the IAM Role 192 | 193 | You can pass a parameter to assume the IAM Role with AWS STS 194 | and use the assumed role credentials to sign the request. 195 | This is useful when doing cross-account requests. 196 | 197 | ```typescript 198 | const interceptor = aws4Interceptor({ 199 | options: { 200 | region: "eu-west-2", 201 | service: "execute-api", 202 | assumeRoleArn: "arn:aws:iam::111111111111:role/MyRole", 203 | assumeRoleSessionName: "MyApiClient", // optional, default value is "axios" 204 | }, 205 | }); 206 | ``` 207 | 208 | Obtained credentials are cached and refreshed as needed after they expire. 209 | 210 | You can use `expirationMarginSec` parameter to set the number of seconds 211 | before the received credentials expiration time to invalidate the cache. 212 | This allows setting a safety margin. Default to 5 seconds. 213 | 214 | ## Contributors ✨ 215 | 216 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 |
Florian Bischoff
Florian Bischoff

💻
Ruben van Rooij
Ruben van Rooij

💻
Roman
Roman

👀
Maciej Radzikowski
Maciej Radzikowski

⚠️ 💻
Christopher Kruse
Christopher Kruse

⚠️
James Hu
James Hu

💻
Daniel (dB.) Doubrovkine
Daniel (dB.) Doubrovkine

💻
Franke Telgenhof
Franke Telgenhof

💻
237 | 238 | 239 | 240 | 241 | 242 | 243 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 244 | -------------------------------------------------------------------------------- /infra/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /infra/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /infra/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `cdk deploy` deploy this stack to your default AWS account/region 13 | * `cdk diff` compare deployed stack with current state 14 | * `cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /infra/bin/infra.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import "source-map-support/register"; 3 | import * as cdk from "aws-cdk-lib"; 4 | import { AWSv4AxiosInfraStack } from "../lib/infra-stack"; 5 | 6 | const app = new cdk.App(); 7 | new AWSv4AxiosInfraStack(app, "AWSv4AxiosInfraStack", { 8 | /* If you don't specify 'env', this stack will be environment-agnostic. 9 | * Account/Region-dependent features and context lookups will not work, 10 | * but a single synthesized template can be deployed anywhere. */ 11 | /* Uncomment the next line to specialize this stack for the AWS Account 12 | * and Region that are implied by the current CLI configuration. */ 13 | // env: { 14 | // account: process.env.CDK_DEFAULT_ACCOUNT, 15 | // region: process.env.CDK_DEFAULT_REGION, 16 | // }, 17 | /* Uncomment the next line if you know exactly what Account and Region you 18 | * want to deploy the stack to. */ 19 | // env: { account: '123456789012', region: 'us-east-1' }, 20 | /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ 21 | }); 22 | -------------------------------------------------------------------------------- /infra/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/infra.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 21 | "@aws-cdk/core:checkSecretUsage": true, 22 | "@aws-cdk/core:target-partitions": [ 23 | "aws", 24 | "aws-cn" 25 | ], 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 34 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 35 | "@aws-cdk/core:enablePartitionLiterals": true, 36 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 37 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 38 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 39 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 40 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 41 | "@aws-cdk/aws-route53-patters:useCertificate": true, 42 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 43 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 44 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 45 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 46 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 47 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 48 | "@aws-cdk/aws-redshift:columnId": true, 49 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 50 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 51 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /infra/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /infra/lib/api.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyHandlerV2 } from "aws-lambda"; 2 | 3 | export const handler: APIGatewayProxyHandlerV2 = async (event, _context) => { 4 | return { 5 | body: JSON.stringify(event), 6 | statusCode: 200, 7 | }; 8 | }; 9 | -------------------------------------------------------------------------------- /infra/lib/infra-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from "aws-cdk-lib"; 2 | import * as apigatewayv2 from "@aws-cdk/aws-apigatewayv2-alpha"; 3 | import { HttpIamAuthorizer } from "@aws-cdk/aws-apigatewayv2-authorizers-alpha"; 4 | import { HttpLambdaIntegration } from "@aws-cdk/aws-apigatewayv2-integrations-alpha"; 5 | import * as iam from "aws-cdk-lib/aws-iam"; 6 | import * as lambda from "aws-cdk-lib/aws-lambda-nodejs"; 7 | import { Construct } from "constructs"; 8 | 9 | export class AWSv4AxiosInfraStack extends cdk.Stack { 10 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 11 | const routeArn = ({ 12 | apiId, 13 | stage, 14 | httpMethod, 15 | path, 16 | }: { 17 | apiId: string; 18 | stage?: string; 19 | httpMethod: apigatewayv2.HttpMethod; 20 | path?: string; 21 | }): string => { 22 | const iamHttpMethod = 23 | httpMethod === apigatewayv2.HttpMethod.ANY ? "*" : httpMethod; 24 | 25 | // When the user has provided a path with path variables, we replace the 26 | // path variable and all that follows with a wildcard. 27 | const iamPath = (path ?? "/").replace(/\{.*?\}.*/, "*"); 28 | const iamStage = stage ?? "*"; 29 | 30 | return `arn:${cdk.Aws.PARTITION}:execute-api:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:${apiId}/${iamStage}/${iamHttpMethod}${iamPath}`; 31 | }; 32 | 33 | super(scope, id, props); 34 | 35 | const apiHandler = new lambda.NodejsFunction(this, "ApiHandler", { 36 | entry: "lib/api.ts", 37 | handler: "handler", 38 | }); 39 | 40 | const authorizer = new HttpIamAuthorizer(); 41 | 42 | const api = new apigatewayv2.HttpApi(this, "Api", { 43 | defaultAuthorizer: authorizer, 44 | defaultIntegration: new HttpLambdaIntegration( 45 | "ApiHandlerIntegration", 46 | apiHandler 47 | ), 48 | }); 49 | 50 | if (!api.url) { 51 | throw new Error("api.url is undefined"); 52 | } 53 | 54 | // output URL as HttpApiUrl 55 | new cdk.CfnOutput(this, "HttpApiUrl", { 56 | value: api.url, 57 | }); 58 | 59 | const clientRole = new iam.Role(this, "ClientRole", { 60 | assumedBy: new iam.AccountRootPrincipal(), 61 | }); 62 | 63 | // grant the client role access to the API 64 | clientRole.addToPolicy( 65 | new iam.PolicyStatement({ 66 | actions: ["execute-api:Invoke"], 67 | resources: [ 68 | routeArn({ 69 | apiId: api.httpApiId, 70 | stage: api.defaultStage?.stageName, 71 | httpMethod: apigatewayv2.HttpMethod.ANY, 72 | path: "/*", 73 | }), 74 | ], 75 | }) 76 | ); 77 | 78 | // output the client role ARN 79 | new cdk.CfnOutput(this, "ClientRoleArn", { 80 | value: clientRole.roleArn, 81 | }); 82 | 83 | // create another role, assumable by the first 84 | const assumedClientRole = new iam.Role(this, "AssumedClientRole", { 85 | assumedBy: new iam.ArnPrincipal(clientRole.roleArn), 86 | }); 87 | 88 | // set up an IAM role assumable by GitHub Actions using web identity federation 89 | const githubActionsRole = new iam.Role(this, "GitHubActionsRole", { 90 | assumedBy: new iam.WebIdentityPrincipal( 91 | `arn:aws:iam::${cdk.Aws.ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com`, 92 | { 93 | StringLike: { 94 | "token.actions.githubusercontent.com:sub": 95 | "repo:jamesmbourne/aws4-axios:*", 96 | }, 97 | StringEquals: { 98 | "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", 99 | }, 100 | } 101 | ), 102 | // conditions 103 | }); 104 | 105 | this.stackId; 106 | 107 | // grant the GitHub Actions role access to CloudFormation describeStacks this stack 108 | githubActionsRole.addToPolicy( 109 | new iam.PolicyStatement({ 110 | actions: ["cloudformation:DescribeStacks"], 111 | resources: [this.stackId], 112 | }) 113 | ); 114 | 115 | clientRole.grantAssumeRole(githubActionsRole); 116 | 117 | // output the GitHub Actions role ARN 118 | new cdk.CfnOutput(this, "GitHubActionsRoleArn", { 119 | value: githubActionsRole.roleArn, 120 | }); 121 | 122 | // grant the assumed role access to the API 123 | assumedClientRole.addToPolicy( 124 | new iam.PolicyStatement({ 125 | actions: ["execute-api:Invoke"], 126 | resources: [ 127 | routeArn({ 128 | apiId: api.httpApiId, 129 | stage: api.defaultStage?.stageName, 130 | httpMethod: apigatewayv2.HttpMethod.ANY, 131 | path: "/*", 132 | }), 133 | ], 134 | }) 135 | ); 136 | 137 | // output the assumed client role ARN 138 | new cdk.CfnOutput(this, "AssumedClientRoleArn", { 139 | value: assumedClientRole.roleArn, 140 | }); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /infra/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "infra", 3 | "version": "0.1.0", 4 | "private": true, 5 | "bin": { 6 | "infra": "bin/infra.js" 7 | }, 8 | "scripts": { 9 | "build": "tsc", 10 | "watch": "tsc -w", 11 | "test": "jest", 12 | "cdk": "cdk" 13 | }, 14 | "devDependencies": { 15 | "@types/aws-lambda": "^8.10.115", 16 | "@types/node": "^22.0.0", 17 | "aws-cdk": "2.1018.0", 18 | "ts-node": "^10.9.1", 19 | "@aws-cdk/aws-apigatewayv2-authorizers-alpha": "^2.78.0-alpha.0", 20 | "@aws-cdk/aws-apigatewayv2-integrations-alpha": "^2.78.0-alpha.0", 21 | "aws-cdk-lib": "2.200.1", 22 | "constructs": "^10.0.0", 23 | "source-map-support": "^0.5.21" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /infra/test/infra.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Infra from '../lib/infra-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/infra-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Infra.InfraStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /infra/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /jest.config.it.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | ...require("./jest.config"), 4 | testMatch: ["**/__tests__/**/*.it.[jt]s?(x)"], 5 | globalSetup: "./src/__tests__/setup.ts", 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | testPathIgnorePatterns: ["./dist/"], 6 | testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws4-axios", 3 | "version": "0.0.0-development", 4 | "description": "Axios request interceptor for signing requests with AWSv4", 5 | "author": "James Bourne", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/jamesmbourne/aws4-axios.git" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "license": "MIT", 14 | "homepage": "https://github.com/jamesmbourne/aws4-axios", 15 | "bugs": { 16 | "url": "https://github.com/jamesmbourne/aws4-axios/issues" 17 | }, 18 | "keywords": [ 19 | "aws4", 20 | "awsv4", 21 | "signature", 22 | "axios", 23 | "interceptor" 24 | ], 25 | "main": "dist/index.js", 26 | "types": "dist/index.d.ts", 27 | "scripts": { 28 | "build": "tsc", 29 | "test": "jest", 30 | "test-it": "jest --config jest.config.it.js", 31 | "test-it:deploy": "cd src/__tests__ && serverless deploy", 32 | "prepublishOnly": "npm run build", 33 | "semantic-release": "semantic-release", 34 | "lint:eslint": "eslint . --cache --ext .ts,.tsx", 35 | "lint:prettier": "prettier --check src/**/*.ts", 36 | "lint": "run-s lint:*" 37 | }, 38 | "devDependencies": { 39 | "@aws-sdk/client-cloudformation": "^3.4.1", 40 | "@aws-sdk/client-sts": "^3.4.1", 41 | "@smithy/smithy-client": "^3.1.1", 42 | "@types/aws4": "^1.11.2", 43 | "@types/jest": "^29.0.0", 44 | "@types/node": "^22.0.0", 45 | "@typescript-eslint/eslint-plugin": "^7.0.0", 46 | "@typescript-eslint/parser": "^7.0.0", 47 | "axios": "^1.7.7", 48 | "axios-mock-adapter": "^2.1.0", 49 | "eslint": "^8.0.0", 50 | "eslint-config-prettier": "^10.0.0", 51 | "husky": "^9.0.0", 52 | "jest": "^29.0.0", 53 | "lint-staged": "^16.0.0", 54 | "nock": "^14.0.0", 55 | "npm-run-all2": "^8.0.0", 56 | "prettier": "^3.0.0", 57 | "semantic-release": "^24.0.0", 58 | "serverless": "^4.0.0", 59 | "ts-jest": "^29.0.0", 60 | "typescript": "^5.0.0", 61 | "zod": "^3.21.4" 62 | }, 63 | "dependencies": { 64 | "@aws-sdk/client-sts": "^3.4.1", 65 | "aws4": "^1.12.0" 66 | }, 67 | "peerDependencies": { 68 | "axios": ">=1.6.0" 69 | }, 70 | "husky": { 71 | "hooks": { 72 | "pre-commit": "lint-staged" 73 | } 74 | }, 75 | "lint-staged": { 76 | "*.{ts,tsx}": [ 77 | "eslint --cache --fix", 78 | "prettier --write" 79 | ] 80 | }, 81 | "engines": { 82 | "node": ">=16" 83 | }, 84 | "workspaces": [ 85 | "infra" 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", ":dependencyDashboard"], 4 | "packageRules": [ 5 | { 6 | "matchDepTypes": ["devDependencies", "dependencies"], 7 | "automerge": true 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/__tests__/apiGateway.it.ts: -------------------------------------------------------------------------------- 1 | import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; 2 | import axios, { AxiosInstance, Method } from "axios"; 3 | import { Credentials, aws4Interceptor, getAuthErrorMessage } from ".."; 4 | 5 | const methods: Method[] = ["GET", "DELETE"]; 6 | const dataMethods: Method[] = ["POST", "PATCH", "PUT"]; 7 | 8 | const region = process.env.AWS_REGION; 9 | const apiGateway = process.env.API_GATEWAY_URL; 10 | const clientRoleArn = process.env.CLIENT_ROLE_ARN; 11 | const assumedClientRoleArn = process.env.ASSUMED_CLIENT_ROLE_ARN; 12 | const service = "execute-api"; 13 | 14 | let clientCredentials: Credentials; 15 | 16 | beforeAll(async () => { 17 | const sts = new STSClient({ region }); 18 | const { Credentials: credentials } = await sts.send( 19 | new AssumeRoleCommand({ 20 | RoleArn: clientRoleArn, 21 | RoleSessionName: "integration-tests", 22 | }), 23 | ); 24 | 25 | clientCredentials = { 26 | accessKeyId: credentials?.AccessKeyId || "", 27 | secretAccessKey: credentials?.SecretAccessKey || "", 28 | sessionToken: credentials?.SessionToken || "", 29 | }; 30 | 31 | cleanEnvCredentials(); 32 | }); 33 | 34 | const setEnvCredentials = () => { 35 | process.env.AWS_ACCESS_KEY_ID = clientCredentials?.accessKeyId; 36 | process.env.AWS_SECRET_ACCESS_KEY = clientCredentials?.secretAccessKey; 37 | process.env.AWS_SESSION_TOKEN = clientCredentials?.sessionToken; 38 | }; 39 | 40 | const cleanEnvCredentials = () => { 41 | delete process.env.AWS_PROFILE; 42 | delete process.env.AWS_ACCESS_KEY_ID; 43 | delete process.env.AWS_SECRET_ACCESS_KEY; 44 | delete process.env.AWS_SESSION_TOKEN; 45 | }; 46 | 47 | describe("check that API is actually protected", () => { 48 | it.each([...methods, ...dataMethods])( 49 | "checks that HTTP %s is protected", 50 | async (method) => { 51 | await expect( 52 | axios.request({ url: apiGateway, method }), 53 | ).rejects.toMatchObject({ 54 | response: { 55 | status: 403, 56 | }, 57 | }); 58 | }, 59 | ); 60 | }); 61 | 62 | describe("with credentials from environment variables", () => { 63 | let client: AxiosInstance; 64 | const data = { 65 | foo: "bar", 66 | }; 67 | 68 | beforeAll(() => { 69 | setEnvCredentials(); 70 | }); 71 | afterAll(() => { 72 | cleanEnvCredentials(); 73 | }); 74 | 75 | beforeEach(() => { 76 | client = axios.create(); 77 | 78 | client.interceptors.request.use( 79 | aws4Interceptor({ options: { region, service }, instance: client }), 80 | ); 81 | }); 82 | 83 | it.each(methods)("HTTP %s", async (method: Method) => { 84 | let error; 85 | let result; 86 | try { 87 | result = await client.request({ url: apiGateway, method }); 88 | } catch (err) { 89 | error = getAuthErrorMessage(err); 90 | } 91 | 92 | expect(error).toBe(undefined); 93 | expect(result?.status).toEqual(200); 94 | expect(result && result.data.requestContext.http.method).toBe(method); 95 | expect(result && result.data.requestContext.http.path).toBe("/"); 96 | }); 97 | 98 | it.each(dataMethods)("HTTP %s", async (method: Method) => { 99 | let error; 100 | let result; 101 | try { 102 | result = await client.request({ 103 | url: apiGateway, 104 | method, 105 | data, 106 | headers: { foo: "bar" }, 107 | }); 108 | } catch (err) { 109 | error = getAuthErrorMessage(err); 110 | } 111 | 112 | expect(error).toBe(undefined); 113 | expect(result?.status).toEqual(200); 114 | expect(result?.data.requestContext.http.method).toBe(method); 115 | expect(result?.data.requestContext.http.path).toBe("/"); 116 | expect(result && JSON.parse(result.data.body)).toStrictEqual(data); 117 | expect(result?.data.headers.foo).toEqual("bar"); 118 | }); 119 | 120 | it("handles path", async () => { 121 | let error; 122 | let result; 123 | try { 124 | result = await client.request({ 125 | url: apiGateway + "/some/path", 126 | }); 127 | } catch (err) { 128 | error = getAuthErrorMessage(err); 129 | } 130 | 131 | expect(error).toBe(undefined); 132 | expect(result?.status).toEqual(200); 133 | expect(result && result.data.requestContext.http.path).toBe("/some/path"); 134 | }); 135 | 136 | it("handles query parameters", async () => { 137 | let error; 138 | let result; 139 | try { 140 | result = await client.request({ 141 | url: apiGateway, 142 | params: { 143 | lorem: 42, 144 | }, 145 | }); 146 | } catch (err) { 147 | error = getAuthErrorMessage(err); 148 | } 149 | 150 | expect(error).toBe(undefined); 151 | expect(result?.status).toEqual(200); 152 | expect(result && result.data.rawQueryString).toBe("lorem=42"); 153 | }); 154 | 155 | it("handles custom headers", async () => { 156 | let error; 157 | let result; 158 | try { 159 | result = await client.request({ 160 | url: apiGateway, 161 | method: "POST", 162 | headers: { "X-Custom-Header": "Baz" }, 163 | data, 164 | }); 165 | } catch (err) { 166 | error = getAuthErrorMessage(err); 167 | } 168 | 169 | expect(error).toBe(undefined); 170 | expect(result?.status).toEqual(200); 171 | expect(result?.data.headers["x-custom-header"]).toBe("Baz"); 172 | }); 173 | 174 | it("handles custom Content-Type header", async () => { 175 | let error; 176 | let result; 177 | try { 178 | result = await client.request({ 179 | url: apiGateway, 180 | method: "POST", 181 | headers: { "Content-Type": "application/xml" }, 182 | data, 183 | }); 184 | } catch (err) { 185 | error = getAuthErrorMessage(err); 186 | } 187 | 188 | expect(error).toBe(undefined); 189 | expect(result?.status).toEqual(200); 190 | expect(result?.data.headers["content-type"]).toBe("application/xml"); 191 | }); 192 | 193 | it("sets content type as application/json when the body is an object", async () => { 194 | let error; 195 | let result; 196 | try { 197 | result = await client.request({ 198 | url: apiGateway, 199 | method: "POST", 200 | data, 201 | }); 202 | } catch (err) { 203 | error = getAuthErrorMessage(err); 204 | } 205 | 206 | expect(error).toBe(undefined); 207 | expect(result?.status).toEqual(200); 208 | expect(result?.data.headers["content-type"]).toBe("application/json"); 209 | }); 210 | }); 211 | 212 | describe("signQuery", () => { 213 | beforeAll(() => { 214 | setEnvCredentials(); 215 | }); 216 | 217 | afterAll(() => { 218 | cleanEnvCredentials(); 219 | }); 220 | 221 | it("respects signQuery option", async () => { 222 | const client = axios.create(); 223 | client.interceptors.request.use( 224 | aws4Interceptor({ 225 | instance: client, 226 | options: { 227 | region, 228 | service, 229 | signQuery: true, 230 | }, 231 | }), 232 | ); 233 | 234 | const result = await client.request({ 235 | url: apiGateway + "/some/path", 236 | method: "GET", 237 | params: { foo: "bar" }, 238 | }); 239 | 240 | expect(result?.status).toEqual(200); 241 | }); 242 | }); 243 | 244 | describe("with role to assume", () => { 245 | let client: AxiosInstance; 246 | const assumedRoleName = assumedClientRoleArn?.substr( 247 | assumedClientRoleArn.indexOf("/") + 1, 248 | ); 249 | 250 | beforeAll(() => { 251 | setEnvCredentials(); 252 | }); 253 | afterAll(() => { 254 | cleanEnvCredentials(); 255 | }); 256 | 257 | beforeEach(() => { 258 | client = axios.create(); 259 | client.interceptors.request.use( 260 | aws4Interceptor({ 261 | options: { region, service, assumeRoleArn: assumedClientRoleArn }, 262 | instance: client, 263 | }), 264 | ); 265 | }); 266 | 267 | it.each([...methods, ...dataMethods])( 268 | "signs HTTP %s request with assumed role credentials", 269 | async (method) => { 270 | const result = await client.request({ url: apiGateway, method }); 271 | 272 | expect(result?.status).toEqual(200); 273 | expect( 274 | result && result.data.requestContext.authorizer.iam.userArn, 275 | ).toContain("/" + assumedRoleName + "/"); 276 | }, 277 | ); 278 | }); 279 | -------------------------------------------------------------------------------- /src/__tests__/serverless.yml: -------------------------------------------------------------------------------- 1 | service: aws4AxiosIT 2 | 3 | custom: 4 | baseName: ${self:service}-${self:provider.stage} 5 | 6 | provider: 7 | name: aws 8 | stage: ${opt:stage, 'dev'} 9 | region: ${opt:region, 'eu-west-2'} 10 | stackName: ${self:custom.baseName} 11 | deploymentBucket: 12 | blockPublicAccess: true 13 | runtime: nodejs18.x 14 | memorySize: 256 15 | 16 | functions: 17 | http: 18 | handler: src/index.handler 19 | name: ${self:custom.baseName}-http 20 | events: 21 | - httpApi: "*" 22 | 23 | resources: 24 | Resources: 25 | # Override generated resources 26 | 27 | HttpApi: 28 | Type: AWS::ApiGatewayV2::Api 29 | Properties: 30 | Name: ${self:custom.baseName} 31 | 32 | HttpApiRouteDefault: 33 | Type: AWS::ApiGatewayV2::Route 34 | Properties: 35 | AuthorizationType: AWS_IAM 36 | 37 | # IAM 38 | 39 | ClientRole: 40 | Type: AWS::IAM::Role 41 | Properties: 42 | RoleName: ${self:custom.baseName}-clientRole 43 | AssumeRolePolicyDocument: 44 | Version: "2012-10-17" 45 | Statement: 46 | - Effect: Allow 47 | Principal: 48 | AWS: !Ref AWS::AccountId 49 | Action: sts:AssumeRole 50 | Policies: 51 | - PolicyName: http 52 | PolicyDocument: 53 | Version: "2012-10-17" 54 | Statement: 55 | - Effect: Allow 56 | Action: 57 | - execute-api:Invoke 58 | Resource: 59 | - !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/* 60 | - PolicyName: assume-role 61 | PolicyDocument: 62 | Version: "2012-10-17" 63 | Statement: 64 | - Effect: Allow 65 | Action: 66 | - sts:AssumeRole 67 | Resource: 68 | - !Sub arn:aws:iam::${AWS::AccountId}:role/${self:custom.baseName}-assumedClientRole 69 | 70 | AssumedClientRole: 71 | Type: AWS::IAM::Role 72 | DependsOn: [ClientRole] 73 | Properties: 74 | RoleName: ${self:custom.baseName}-assumedClientRole 75 | AssumeRolePolicyDocument: 76 | Version: "2012-10-17" 77 | Statement: 78 | - Effect: Allow 79 | Principal: 80 | AWS: !GetAtt ClientRole.Arn 81 | Action: sts:AssumeRole 82 | Policies: 83 | - PolicyName: http 84 | PolicyDocument: 85 | Version: "2012-10-17" 86 | Statement: 87 | - Effect: Allow 88 | Action: 89 | - execute-api:Invoke 90 | Resource: 91 | - !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${HttpApi}/* 92 | 93 | Outputs: 94 | ClientRoleArn: 95 | Value: !GetAtt ClientRole.Arn 96 | AssumedClientRoleArn: 97 | Value: !GetAtt AssumedClientRole.Arn 98 | -------------------------------------------------------------------------------- /src/__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CloudFormationClient, 3 | DescribeStacksCommand, 4 | } from "@aws-sdk/client-cloudformation"; 5 | 6 | const region = process.env.AWS_REGION || "eu-west-2"; 7 | 8 | module.exports = async () => { 9 | const stackName = `AWSv4AxiosInfraStack`; 10 | 11 | const cf = new CloudFormationClient({ region }); 12 | const stacks = await cf.send( 13 | new DescribeStacksCommand({ 14 | StackName: stackName, 15 | }), 16 | ); 17 | const stack = stacks.Stacks?.[0]; 18 | 19 | if (stack === undefined) { 20 | throw new Error( 21 | `Couldn't find CloudFormation stack with name ${stackName}`, 22 | ); 23 | } 24 | 25 | process.env.API_GATEWAY_URL = stack.Outputs?.find( 26 | (o) => o.OutputKey === "HttpApiUrl", 27 | )?.OutputValue?.replace(/\/$/, ""); 28 | process.env.CLIENT_ROLE_ARN = stack.Outputs?.find( 29 | (o) => o.OutputKey === "ClientRoleArn", 30 | )?.OutputValue; 31 | process.env.ASSUMED_CLIENT_ROLE_ARN = stack.Outputs?.find( 32 | (o) => o.OutputKey === "AssumedClientRoleArn", 33 | )?.OutputValue; 34 | 35 | process.env.AWS_REGION = region; 36 | }; 37 | -------------------------------------------------------------------------------- /src/__tests__/src/index.js: -------------------------------------------------------------------------------- 1 | exports.handler = async (event) => { 2 | return event; 3 | }; 4 | -------------------------------------------------------------------------------- /src/axiosInterceptor.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import nock from "nock"; 3 | 4 | import aws4Interceptor from "."; 5 | 6 | describe("axios interceptor", () => { 7 | beforeAll(() => { 8 | nock.disableNetConnect(); 9 | }); 10 | 11 | beforeEach(() => { 12 | nock.cleanAll(); 13 | }); 14 | 15 | afterAll(() => { 16 | nock.enableNetConnect(); 17 | }); 18 | 19 | it("should not mutate request config object", async () => { 20 | // Arrange 21 | const client = axios.create(); 22 | 23 | client.interceptors.request.use( 24 | aws4Interceptor({ options: { region: "local" }, instance: axios }), 25 | ); 26 | 27 | const url = "http://localhost/foo"; 28 | const config = { 29 | headers: { "X-Custom-Header": "foo", "Content-Type": "application/json" }, 30 | params: { foo: "bar" }, 31 | }; 32 | 33 | // setup nock to return a 34 | nock(url).get("").query(config.params).reply(200, {}); 35 | 36 | // Act 37 | await client.get(url, config); 38 | 39 | // Assert 40 | expect(nock.isDone()).toBe(true); 41 | }); 42 | 43 | it("should preserve headers", async () => { 44 | // Arrange 45 | const client = axios.create(); 46 | 47 | client.interceptors.request.use( 48 | aws4Interceptor({ options: { region: "local" }, instance: axios }), 49 | ); 50 | 51 | const data = { foo: "bar" }; 52 | 53 | const url = "https://localhost/foo"; 54 | 55 | nock(url) 56 | .post("") 57 | .matchHeader("X-Custom-Header", "foo") 58 | .matchHeader("Content-Type", "application/json") 59 | .matchHeader("Authorization", /AWS/) 60 | .reply(200, {}); 61 | 62 | // Act 63 | await client.post(url, data, { 64 | headers: { "X-Custom-Header": "foo", "Content-Type": "application/json" }, 65 | }); 66 | 67 | // Assert 68 | expect(nock.isDone()).toBe(true); 69 | }); 70 | 71 | it("should preserve default headers - without interceptor", async () => { 72 | // Arrange 73 | const client = axios.create(); 74 | 75 | const data = { foo: "bar" }; 76 | 77 | const url = "https://localhost/foo"; 78 | 79 | nock(url) 80 | .matchHeader("Content-Type", "application/json") 81 | .post("") 82 | .reply(200, {}); 83 | 84 | // Act 85 | await client.post(url, data, {}); 86 | 87 | // Assert 88 | expect(nock.isDone()).toBe(true); 89 | }); 90 | 91 | it("should preserve default headers - with interceptor", async () => { 92 | // Arrange 93 | const client = axios.create(); 94 | 95 | client.interceptors.request.use( 96 | aws4Interceptor({ options: { region: "local" }, instance: axios }), 97 | ); 98 | 99 | const data = { foo: "bar" }; 100 | 101 | const url = "https://localhost/foo"; 102 | 103 | nock(url) 104 | .matchHeader("Content-Type", "application/json") 105 | .post("") 106 | .reply(200, {}); 107 | 108 | // Act 109 | await client.post(url, data, {}); 110 | 111 | // Assert 112 | expect(nock.isDone()).toBe(true); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/credentials/assumeRoleCredentialsProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { AssumeRoleCredentialsProvider } from "./assumeRoleCredentialsProvider"; 2 | import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; 3 | import { MetadataBearer } from "@aws-sdk/types"; 4 | import { 5 | Client, 6 | Command, 7 | SmithyResolvedConfiguration, 8 | } from "@smithy/smithy-client"; 9 | 10 | jest.mock("@aws-sdk/client-sts"); 11 | let mockSTSSend: jest.Mock; 12 | 13 | const oneHourMs = 1000 * 60 * 60; 14 | 15 | beforeAll(() => { 16 | process.env.AWS_REGION = "eu-central-1"; 17 | jest.useFakeTimers(); 18 | }); 19 | 20 | afterAll(() => { 21 | jest.useRealTimers(); 22 | }); 23 | 24 | beforeEach(() => { 25 | mockSTSSend = mockSend(STSClient); 26 | mockSTSSend.mockImplementation((command) => { 27 | if (command instanceof AssumeRoleCommand) { 28 | const expiration = new Date(); 29 | expiration.setHours(expiration.getHours() + 1); 30 | 31 | return { 32 | Credentials: { 33 | AccessKeyId: "MOCK_ACCESS_KEY_ID", 34 | SecretAccessKey: "MOCK_SECRET_ACCESS_KEY", 35 | SessionToken: "MOCK_SESSION_TOKEN", 36 | Expiration: expiration, 37 | }, 38 | }; 39 | } 40 | }); 41 | }); 42 | 43 | it("returns credentials from assumed role", async () => { 44 | const provider = new AssumeRoleCredentialsProvider({ 45 | roleArn: "arn:aws:iam::111111111111:role/MockRole", 46 | region: "eu-west-1", 47 | }); 48 | 49 | const credentials = await provider.getCredentials(); 50 | 51 | expect(credentials).toStrictEqual({ 52 | accessKeyId: "MOCK_ACCESS_KEY_ID", 53 | secretAccessKey: "MOCK_SECRET_ACCESS_KEY", 54 | sessionToken: "MOCK_SESSION_TOKEN", 55 | }); 56 | }); 57 | 58 | it("uses provided region", async () => { 59 | new AssumeRoleCredentialsProvider({ 60 | roleArn: "arn:aws:iam::111111111111:role/MockRole", 61 | region: "eu-west-1", 62 | }); 63 | 64 | expect(STSClient as jest.Mock).toBeCalledWith({ region: "eu-west-1" }); 65 | }); 66 | 67 | it("uses region from env if not provided", async () => { 68 | new AssumeRoleCredentialsProvider({ 69 | roleArn: "arn:aws:iam::111111111111:role/MockRole", 70 | }); 71 | 72 | expect(STSClient as jest.Mock).toBeCalledWith({ region: "eu-central-1" }); 73 | }); 74 | 75 | it("does not assume role again with active credentials", async () => { 76 | const provider = new AssumeRoleCredentialsProvider({ 77 | roleArn: "arn:aws:iam::111111111111:role/MockRole", 78 | }); 79 | 80 | await provider.getCredentials(); 81 | await provider.getCredentials(); 82 | 83 | expect(mockSTSSend).toBeCalledTimes(1); 84 | }); 85 | 86 | it("assumes role again when credentials expired", async () => { 87 | const provider = new AssumeRoleCredentialsProvider({ 88 | roleArn: "arn:aws:iam::111111111111:role/MockRole", 89 | expirationMarginSec: 5, 90 | }); 91 | 92 | await provider.getCredentials(); 93 | jest.advanceTimersByTime(oneHourMs); 94 | await provider.getCredentials(); 95 | 96 | expect(mockSTSSend).toBeCalledTimes(2); 97 | }); 98 | 99 | it("assumes role again in credentials expiration margin", async () => { 100 | const provider = new AssumeRoleCredentialsProvider({ 101 | roleArn: "arn:aws:iam::111111111111:role/MockRole", 102 | expirationMarginSec: 15, 103 | }); 104 | 105 | await provider.getCredentials(); 106 | jest.advanceTimersByTime(oneHourMs - 1000 * 10); 107 | await provider.getCredentials(); 108 | 109 | expect(mockSTSSend).toBeCalledTimes(2); 110 | }); 111 | 112 | export const mockSend = < 113 | HandlerOptions, 114 | ClientInput extends object, // eslint-disable-line @typescript-eslint/ban-types 115 | ClientOutput extends MetadataBearer, 116 | ResolvedClientConfiguration extends 117 | SmithyResolvedConfiguration, 118 | InputType extends ClientInput, 119 | OutputType extends ClientOutput, 120 | >( 121 | client: new ( 122 | config: never, 123 | ) => Client< 124 | HandlerOptions, 125 | ClientInput, 126 | ClientOutput, 127 | ResolvedClientConfiguration 128 | >, 129 | ): jest.Mock< 130 | unknown, 131 | [Command] 132 | > => { 133 | const mock = jest.fn< 134 | unknown, 135 | [Command] 136 | >(); 137 | (client as jest.Mock).mockImplementation(() => ({ 138 | send: mock, 139 | })); 140 | return mock; 141 | }; 142 | -------------------------------------------------------------------------------- /src/credentials/assumeRoleCredentialsProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AssumeRoleCommand, 3 | Credentials as STSCredentials, 4 | STSClient, 5 | } from "@aws-sdk/client-sts"; 6 | import { CredentialsProvider } from "./credentialsProvider"; 7 | import { Credentials } from "../interceptor"; 8 | 9 | export class AssumeRoleCredentialsProvider implements CredentialsProvider { 10 | private options: ResolvedAssumeRoleCredentialsProviderOptions; 11 | private sts: STSClient; 12 | private credentials?: Credentials; 13 | private expiration?: Date; 14 | 15 | constructor(options: AssumeRoleCredentialsProviderOptions) { 16 | this.options = { 17 | ...options, 18 | region: options.region || process.env.AWS_REGION, 19 | expirationMarginSec: options.expirationMarginSec || 5, 20 | roleSessionName: options.roleSessionName || "axios", 21 | }; 22 | 23 | this.sts = new STSClient({ region: this.options.region }); 24 | } 25 | 26 | async getCredentials(): Promise { 27 | if (!this.credentials || this.areCredentialsExpired()) { 28 | const stsCredentials = await this.assumeRole(); 29 | this.credentials = { 30 | accessKeyId: stsCredentials.AccessKeyId || "", 31 | secretAccessKey: stsCredentials.SecretAccessKey || "", 32 | sessionToken: stsCredentials.SessionToken, 33 | }; 34 | this.expiration = stsCredentials.Expiration; 35 | } 36 | 37 | return this.credentials; 38 | } 39 | 40 | private areCredentialsExpired(): boolean { 41 | return ( 42 | this.expiration !== undefined && 43 | new Date().getTime() + this.options.expirationMarginSec * 1000 >= 44 | this.expiration.getTime() 45 | ); 46 | } 47 | 48 | private async assumeRole(): Promise { 49 | const res = await this.sts.send( 50 | new AssumeRoleCommand({ 51 | RoleArn: this.options.roleArn, 52 | RoleSessionName: this.options.roleSessionName, 53 | }), 54 | ); 55 | 56 | if (!res.Credentials) { 57 | throw new Error("Failed to get credentials from the assumed role"); 58 | } 59 | 60 | return res.Credentials; 61 | } 62 | } 63 | 64 | export interface AssumeRoleCredentialsProviderOptions { 65 | roleArn: string; 66 | region?: string; 67 | expirationMarginSec?: number; 68 | roleSessionName?: string; 69 | } 70 | 71 | export interface ResolvedAssumeRoleCredentialsProviderOptions { 72 | roleArn: string; 73 | region?: string; 74 | expirationMarginSec: number; 75 | roleSessionName: string; 76 | } 77 | -------------------------------------------------------------------------------- /src/credentials/credentialsProvider.ts: -------------------------------------------------------------------------------- 1 | import { Credentials } from "../interceptor"; 2 | 3 | export interface CredentialsProvider { 4 | getCredentials(): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/credentials/isCredentialsProvider.ts: -------------------------------------------------------------------------------- 1 | import { CredentialsProvider } from "./credentialsProvider"; 2 | 3 | // type guard 4 | export const isCredentialsProvider = ( 5 | variableToCheck: unknown, 6 | ): variableToCheck is CredentialsProvider => 7 | (variableToCheck as CredentialsProvider)?.getCredentials !== undefined; 8 | -------------------------------------------------------------------------------- /src/credentials/simpleCredentialsProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { SimpleCredentialsProvider } from "./simpleCredentialsProvider"; 2 | 3 | it("returns undefined for not provided credentials", async () => { 4 | const provider = new SimpleCredentialsProvider(); 5 | 6 | const credentials = await provider.getCredentials(); 7 | 8 | expect(credentials).toBeUndefined(); 9 | }); 10 | 11 | it("returns credentials for provided credentials", async () => { 12 | const provider = new SimpleCredentialsProvider({ 13 | accessKeyId: "MOCK_ACCESS_KEY_ID", 14 | secretAccessKey: "MOCK_SECRET_ACCESS_KEY", 15 | sessionToken: "MOCK_SESSION_TOKEN", 16 | }); 17 | 18 | const credentials = await provider.getCredentials(); 19 | 20 | expect(credentials).toStrictEqual({ 21 | accessKeyId: "MOCK_ACCESS_KEY_ID", 22 | secretAccessKey: "MOCK_SECRET_ACCESS_KEY", 23 | sessionToken: "MOCK_SESSION_TOKEN", 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/credentials/simpleCredentialsProvider.ts: -------------------------------------------------------------------------------- 1 | import { CredentialsProvider } from "./credentialsProvider"; 2 | import { Credentials } from "../interceptor"; 3 | 4 | export class SimpleCredentialsProvider implements CredentialsProvider { 5 | constructor(private readonly credentials?: Credentials) {} 6 | 7 | getCredentials(): Promise { 8 | return Promise.resolve(this.credentials); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/getAuthErrorMessage.test.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError, AxiosHeaders } from "axios"; 2 | import { getAuthErrorMessage } from "."; 3 | 4 | describe("getAuthErrorMessage", () => { 5 | it("should return the message from the error", () => { 6 | // Arrange 7 | const message = "Fake envalid credentials error"; 8 | 9 | const error = new AxiosError( 10 | "Fake envalid credentials error", 11 | undefined, 12 | undefined, 13 | undefined, 14 | { 15 | data: { 16 | message, 17 | }, 18 | statusText: "Forbidden", 19 | status: 403, 20 | headers: {}, 21 | config: { headers: new AxiosHeaders() }, 22 | }, 23 | ); 24 | 25 | // Act 26 | const actual = getAuthErrorMessage(error as AxiosError); 27 | 28 | // Assert 29 | expect(actual).toEqual(message); 30 | }); 31 | 32 | it("should return undefined if no error is present", () => { 33 | // Arrange 34 | const error = new AxiosError( 35 | "Just some other error", 36 | undefined, 37 | undefined, 38 | undefined, 39 | { 40 | data: {}, 41 | statusText: "OK", 42 | status: 200, 43 | headers: {}, 44 | config: { headers: new AxiosHeaders() }, 45 | }, 46 | ); 47 | 48 | // Act 49 | const actual = getAuthErrorMessage(error as AxiosError); 50 | 51 | // Assert 52 | expect(actual).toBeUndefined(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/getAuthErrorMessage.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | interface APIGatewayAuthResponse { 4 | message?: string; 5 | } 6 | /** 7 | * Utility method for extracting the error message from an API gateway 403 8 | * 9 | * @param error The error thrown by Axios 10 | */ 11 | export const getAuthErrorMessage = (error: unknown): string | undefined => { 12 | if (axios.isAxiosError(error)) { 13 | const data: APIGatewayAuthResponse = error.response && error.response.data; 14 | 15 | if (data) { 16 | return data.message; 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | aws4Interceptor, 3 | Credentials, 4 | InterceptorOptions, 5 | } from "./interceptor"; 6 | import { CredentialsProvider } from "./credentials/credentialsProvider"; 7 | import { getAuthErrorMessage } from "./getAuthErrorMessage"; 8 | 9 | /** 10 | * @deprecated Please use the alternative export of `aws4Interceptor` 11 | */ 12 | export const interceptor = aws4Interceptor; 13 | 14 | export default aws4Interceptor; 15 | 16 | export { 17 | getAuthErrorMessage, 18 | aws4Interceptor, 19 | Credentials, 20 | CredentialsProvider, 21 | InterceptorOptions, 22 | }; 23 | -------------------------------------------------------------------------------- /src/interceptor.test.ts: -------------------------------------------------------------------------------- 1 | import { sign } from "aws4"; 2 | 3 | import axios, { 4 | AxiosHeaders, 5 | AxiosRequestHeaders, 6 | InternalAxiosRequestConfig, 7 | } from "axios"; 8 | 9 | import { aws4Interceptor } from "."; 10 | import { CredentialsProvider } from "./credentials/credentialsProvider"; 11 | 12 | jest.mock("aws4"); 13 | 14 | jest.mock("./credentials/assumeRoleCredentialsProvider", () => ({ 15 | AssumeRoleCredentialsProvider: jest.fn(() => ({ 16 | getCredentials: jest.fn().mockResolvedValue({ 17 | accessKeyId: "assumed-access-key-id", 18 | secretAccessKey: "assumed-secret-access-key", 19 | sessionToken: "assumed-session-token", 20 | }), 21 | })), 22 | })); 23 | 24 | const mockCustomProvider: CredentialsProvider = { 25 | getCredentials: async () => { 26 | return Promise.resolve({ 27 | accessKeyId: "custom-provider-access-key-id", 28 | secretAccessKey: "custom-provider-secret-access-key", 29 | sessionToken: "custom-provider-session-token", 30 | }); 31 | }, 32 | }; 33 | 34 | const getDefaultHeaders = (): AxiosRequestHeaders => new AxiosHeaders(); 35 | 36 | const getDefaultTransformRequest = () => axios.defaults.transformRequest; 37 | 38 | beforeEach(() => { 39 | (sign as jest.Mock).mockReset(); 40 | }); 41 | 42 | 43 | describe("interceptor", () => { 44 | it("signs GET requests", async () => { 45 | // Arrange 46 | const request: InternalAxiosRequestConfig = { 47 | method: "GET", 48 | url: "https://example.com/foobar", 49 | headers: getDefaultHeaders(), 50 | transformRequest: getDefaultTransformRequest(), 51 | }; 52 | 53 | const interceptor = aws4Interceptor({ 54 | options: { 55 | region: "local", 56 | service: "execute-api", 57 | }, 58 | instance: axios, 59 | }); 60 | 61 | // Act 62 | await interceptor(request); 63 | 64 | // Assert 65 | expect(sign).toBeCalledWith( 66 | { 67 | service: "execute-api", 68 | path: "/foobar", 69 | method: "GET", 70 | region: "local", 71 | host: "example.com", 72 | headers: {}, 73 | }, 74 | undefined, 75 | ); 76 | }); 77 | 78 | it("signs url query parameters in GET requests", async () => { 79 | // Arrange 80 | const request: InternalAxiosRequestConfig = { 81 | method: "GET", 82 | url: "https://example.com/foobar?foo=bar", 83 | headers: getDefaultHeaders(), 84 | transformRequest: getDefaultTransformRequest(), 85 | }; 86 | 87 | const interceptor = aws4Interceptor({ 88 | options: { 89 | region: "local", 90 | service: "execute-api", 91 | }, 92 | instance: axios, 93 | }); 94 | 95 | // Act 96 | await interceptor(request); 97 | 98 | // Assert 99 | expect(sign).toBeCalledWith( 100 | { 101 | service: "execute-api", 102 | path: "/foobar?foo=bar", 103 | method: "GET", 104 | region: "local", 105 | host: "example.com", 106 | headers: {}, 107 | }, 108 | undefined, 109 | ); 110 | }); 111 | 112 | it("signs query parameters in GET requests", async () => { 113 | // Arrange 114 | const request: InternalAxiosRequestConfig = { 115 | method: "GET", 116 | url: "https://example.com/foobar", 117 | params: { foo: "bar" }, 118 | headers: getDefaultHeaders(), 119 | transformRequest: getDefaultTransformRequest(), 120 | }; 121 | 122 | const interceptor = aws4Interceptor({ 123 | options: { 124 | region: "local", 125 | service: "execute-api", 126 | }, 127 | instance: axios, 128 | }); 129 | 130 | // Act 131 | await interceptor(request); 132 | 133 | // Assert 134 | expect(sign).toBeCalledWith( 135 | { 136 | service: "execute-api", 137 | path: "/foobar?foo=bar", 138 | method: "GET", 139 | region: "local", 140 | host: "example.com", 141 | headers: {}, 142 | }, 143 | undefined, 144 | ); 145 | }); 146 | 147 | it("signs POST requests with an object payload", async () => { 148 | // Arrange 149 | const data = { foo: "bar" }; 150 | 151 | const request: InternalAxiosRequestConfig = { 152 | method: "POST", 153 | url: "https://example.com/foobar", 154 | data, 155 | headers: getDefaultHeaders(), 156 | transformRequest: getDefaultTransformRequest(), 157 | }; 158 | 159 | const interceptor = aws4Interceptor({ 160 | options: { 161 | region: "local", 162 | service: "execute-api", 163 | }, 164 | instance: axios, 165 | }); 166 | 167 | // Act 168 | await interceptor(request); 169 | 170 | // Assert 171 | expect(sign).toBeCalledWith( 172 | { 173 | service: "execute-api", 174 | path: "/foobar", 175 | method: "POST", 176 | region: "local", 177 | host: "example.com", 178 | body: '{"foo":"bar"}', 179 | headers: { "Content-Type": "application/json" }, 180 | }, 181 | undefined, 182 | ); 183 | }); 184 | 185 | it("signs POST requests with a string payload", async () => { 186 | // Arrange 187 | const data = "foobar"; 188 | const request: InternalAxiosRequestConfig = { 189 | method: "POST", 190 | url: "https://example.com/foobar", 191 | data, 192 | headers: getDefaultHeaders(), 193 | transformRequest: getDefaultTransformRequest(), 194 | }; 195 | 196 | const interceptor = aws4Interceptor({ 197 | options: { 198 | region: "local", 199 | service: "execute-api", 200 | }, 201 | instance: axios, 202 | }); 203 | 204 | // Act 205 | await interceptor(request); 206 | 207 | // Assert 208 | expect(sign).toBeCalledWith( 209 | { 210 | service: "execute-api", 211 | method: "POST", 212 | path: "/foobar", 213 | region: "local", 214 | host: "example.com", 215 | body: "foobar", 216 | headers: {}, 217 | }, 218 | undefined, 219 | ); 220 | 221 | expect(request.headers['X-Amz-Content-Sha256']).toBeUndefined() 222 | }); 223 | 224 | it("adds X-Amz-Content-Sha256 for a string payload", async () => { 225 | // Arrange 226 | const data = "foobar"; 227 | const request: InternalAxiosRequestConfig = { 228 | method: "POST", 229 | url: "https://example.com/foobar", 230 | data, 231 | headers: getDefaultHeaders(), 232 | transformRequest: getDefaultTransformRequest(), 233 | }; 234 | 235 | const interceptor = aws4Interceptor({ 236 | options: { 237 | region: "local", 238 | service: "execute-api", 239 | addContentSha: true 240 | }, 241 | instance: axios, 242 | }); 243 | 244 | // Act 245 | await interceptor(request); 246 | 247 | // Assert 248 | expect(request.headers['X-Amz-Content-Sha256']).toEqual('c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2') 249 | }); 250 | 251 | it("passes Content-Type header to be signed", async () => { 252 | // Arrange 253 | const data = "foobar"; 254 | const request: InternalAxiosRequestConfig = { 255 | method: "POST", 256 | url: "https://example.com/foobar", 257 | data, 258 | headers: new AxiosHeaders({ 259 | ...getDefaultHeaders(), 260 | "Content-Type": "application/xml", 261 | }), 262 | transformRequest: getDefaultTransformRequest(), 263 | }; 264 | 265 | const interceptor = aws4Interceptor({ 266 | instance: axios, 267 | options: { 268 | region: "local", 269 | service: "execute-api", 270 | }, 271 | }); 272 | 273 | // Act 274 | await interceptor(request); 275 | 276 | // Assert 277 | expect(sign).toBeCalledWith( 278 | { 279 | service: "execute-api", 280 | method: "POST", 281 | path: "/foobar", 282 | region: "local", 283 | host: "example.com", 284 | body: "foobar", 285 | headers: { "Content-Type": "application/xml" }, 286 | }, 287 | undefined, 288 | ); 289 | }); 290 | 291 | it("works with baseURL config", async () => { 292 | // Arrange 293 | const data = "foobar"; 294 | const request: InternalAxiosRequestConfig = { 295 | method: "POST", 296 | baseURL: "https://example.com/foo", 297 | url: "bar", 298 | data, 299 | headers: new AxiosHeaders({ 300 | ...getDefaultHeaders(), 301 | "Content-Type": "application/xml", 302 | }), 303 | transformRequest: getDefaultTransformRequest(), 304 | }; 305 | 306 | const interceptor = aws4Interceptor({ 307 | options: { 308 | region: "local", 309 | service: "execute-api", 310 | }, 311 | instance: axios, 312 | }); 313 | 314 | // Act 315 | await interceptor(request); 316 | 317 | // Assert 318 | expect(sign).toBeCalledWith( 319 | { 320 | service: "execute-api", 321 | method: "POST", 322 | path: "/foo/bar", 323 | region: "local", 324 | host: "example.com", 325 | body: "foobar", 326 | headers: { "Content-Type": "application/xml" }, 327 | }, 328 | undefined, 329 | ); 330 | }); 331 | 332 | it("passes option to sign the query instead of adding header", async () => { 333 | // Arrange 334 | const request: InternalAxiosRequestConfig = { 335 | method: "GET", 336 | url: "https://example.com/foobar", 337 | headers: getDefaultHeaders(), 338 | transformRequest: getDefaultTransformRequest(), 339 | }; 340 | 341 | const interceptor = aws4Interceptor({ 342 | instance: axios, 343 | options: { 344 | region: "local", 345 | service: "execute-api", 346 | signQuery: true, 347 | }, 348 | }); 349 | 350 | // Act 351 | await interceptor(request); 352 | 353 | // Assert 354 | expect(sign).toBeCalledWith( 355 | { 356 | service: "execute-api", 357 | method: "GET", 358 | path: "/foobar", 359 | region: "local", 360 | host: "example.com", 361 | headers: {}, 362 | signQuery: true, 363 | }, 364 | undefined, 365 | ); 366 | }); 367 | 368 | it("passes option to add a content SHA", async () => { 369 | // Arrange 370 | const request: InternalAxiosRequestConfig = { 371 | method: "GET", 372 | url: "https://example.com/foobar", 373 | headers: getDefaultHeaders(), 374 | transformRequest: getDefaultTransformRequest(), 375 | }; 376 | 377 | const interceptor = aws4Interceptor({ 378 | instance: axios, 379 | options: { 380 | region: "local", 381 | service: "execute-api", 382 | addContentSha: true, 383 | }, 384 | }); 385 | 386 | // Act 387 | await interceptor(request); 388 | 389 | // Assert 390 | expect(request.headers['X-Amz-Content-Sha256']).toEqual('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') 391 | }); 392 | }); 393 | 394 | describe("credentials", () => { 395 | it("passes provided credentials", async () => { 396 | // Arrange 397 | const request: InternalAxiosRequestConfig = { 398 | method: "GET", 399 | url: "https://example.com/foobar", 400 | headers: getDefaultHeaders(), 401 | transformRequest: getDefaultTransformRequest(), 402 | }; 403 | 404 | const interceptor = aws4Interceptor({ 405 | instance: axios, 406 | options: { 407 | region: "local", 408 | service: "execute-api", 409 | }, 410 | credentials: { 411 | accessKeyId: "access-key-id", 412 | secretAccessKey: "secret-access-key", 413 | sessionToken: "session-token", 414 | }, 415 | }); 416 | 417 | // Act 418 | await interceptor(request); 419 | 420 | // Assert 421 | expect(sign).toBeCalledWith( 422 | { 423 | service: "execute-api", 424 | path: "/foobar", 425 | method: "GET", 426 | region: "local", 427 | host: "example.com", 428 | headers: {}, 429 | }, 430 | { 431 | accessKeyId: "access-key-id", 432 | secretAccessKey: "secret-access-key", 433 | sessionToken: "session-token", 434 | }, 435 | ); 436 | }); 437 | 438 | it("gets credentials for given role", async () => { 439 | // Arrange 440 | const request: InternalAxiosRequestConfig = { 441 | method: "GET", 442 | url: "https://example.com/foobar", 443 | headers: getDefaultHeaders(), 444 | transformRequest: getDefaultTransformRequest(), 445 | }; 446 | 447 | const interceptor = aws4Interceptor({ 448 | instance: axios, 449 | options: { 450 | region: "local", 451 | service: "execute-api", 452 | assumeRoleArn: "arn:aws:iam::111111111111:role/MockRole", 453 | }, 454 | }); 455 | 456 | // Act 457 | await interceptor(request); 458 | 459 | // Assert 460 | expect(sign).toBeCalledWith( 461 | { 462 | service: "execute-api", 463 | path: "/foobar", 464 | method: "GET", 465 | region: "local", 466 | host: "example.com", 467 | headers: {}, 468 | }, 469 | { 470 | accessKeyId: "assumed-access-key-id", 471 | secretAccessKey: "assumed-secret-access-key", 472 | sessionToken: "assumed-session-token", 473 | }, 474 | ); 475 | }); 476 | 477 | it("prioritizes provided credentials provider over the role", async () => { 478 | // Arrange 479 | const request: InternalAxiosRequestConfig = { 480 | method: "GET", 481 | url: "https://example.com/foobar", 482 | headers: getDefaultHeaders(), 483 | transformRequest: getDefaultTransformRequest(), 484 | }; 485 | 486 | const interceptor = aws4Interceptor({ 487 | options: { 488 | region: "local", 489 | service: "execute-api", 490 | assumeRoleArn: "arn:aws:iam::111111111111:role/MockRole", 491 | }, 492 | credentials: mockCustomProvider, 493 | instance: axios, 494 | }); 495 | 496 | // Act 497 | await interceptor(request); 498 | 499 | // Assert 500 | expect(sign).toBeCalledWith( 501 | { 502 | service: "execute-api", 503 | path: "/foobar", 504 | method: "GET", 505 | region: "local", 506 | host: "example.com", 507 | headers: {}, 508 | }, 509 | { 510 | accessKeyId: "custom-provider-access-key-id", 511 | secretAccessKey: "custom-provider-secret-access-key", 512 | sessionToken: "custom-provider-session-token", 513 | }, 514 | ); 515 | }); 516 | 517 | it("prioritizes provided credentials over the role", async () => { 518 | // Arrange 519 | const request: InternalAxiosRequestConfig = { 520 | method: "GET", 521 | url: "https://example.com/foobar", 522 | headers: getDefaultHeaders(), 523 | transformRequest: getDefaultTransformRequest(), 524 | }; 525 | 526 | const interceptor = aws4Interceptor({ 527 | options: { 528 | region: "local", 529 | service: "execute-api", 530 | assumeRoleArn: "arn:aws:iam::111111111111:role/MockRole", 531 | }, 532 | credentials: { 533 | accessKeyId: "access-key-id", 534 | secretAccessKey: "secret-access-key", 535 | sessionToken: "session-token", 536 | }, 537 | instance: axios, 538 | }); 539 | 540 | // Act 541 | await interceptor(request); 542 | 543 | // Assert 544 | expect(sign).toBeCalledWith( 545 | { 546 | service: "execute-api", 547 | path: "/foobar", 548 | method: "GET", 549 | region: "local", 550 | host: "example.com", 551 | headers: {}, 552 | }, 553 | { 554 | accessKeyId: "access-key-id", 555 | secretAccessKey: "secret-access-key", 556 | sessionToken: "session-token", 557 | }, 558 | ); 559 | }); 560 | 561 | it("allows empty URL when baseURL is set", async () => { 562 | // Arrange 563 | const request: InternalAxiosRequestConfig = { 564 | method: "GET", 565 | url: "", 566 | headers: getDefaultHeaders(), 567 | transformRequest: getDefaultTransformRequest(), 568 | }; 569 | 570 | const client = axios.create({ 571 | baseURL: "https://example.com", 572 | }); 573 | 574 | const interceptor = aws4Interceptor({ 575 | options: { 576 | region: "local", 577 | service: "execute-api", 578 | assumeRoleArn: "arn:aws:iam::111111111111:role/MockRole", 579 | }, 580 | credentials: { 581 | accessKeyId: "access-key-id", 582 | secretAccessKey: "secret-access-key", 583 | sessionToken: "session-token", 584 | }, 585 | instance: client, 586 | }); 587 | 588 | // Act 589 | await expect(interceptor(request)).resolves.toBeDefined(); 590 | expect(sign).toBeCalledWith( 591 | { 592 | service: "execute-api", 593 | path: "/", 594 | method: "GET", 595 | region: "local", 596 | host: "example.com", 597 | headers: {}, 598 | signQuery: undefined, 599 | }, 600 | { 601 | accessKeyId: "access-key-id", 602 | secretAccessKey: "secret-access-key", 603 | sessionToken: "session-token", 604 | }, 605 | ); 606 | }); 607 | 608 | it("supports retries", async () => { 609 | // Arrange 610 | const data = { foo: "bar" }; 611 | 612 | const request: InternalAxiosRequestConfig = { 613 | method: "POST", 614 | url: "https://example.com/foobar", 615 | data, 616 | headers: getDefaultHeaders(), 617 | transformRequest: getDefaultTransformRequest(), 618 | }; 619 | 620 | 621 | (sign as jest.Mock).mockImplementation((request) => { 622 | // neither call should contain the previous Authorization header 623 | expect(request).toEqual({ 624 | service: "execute-api", 625 | path: "/foobar", 626 | method: "POST", 627 | region: "local", 628 | host: "example.com", 629 | body: '{"foo":"bar"}', 630 | headers: { "Content-Type": "application/json" }, 631 | signQuery: undefined 632 | }) 633 | request.headers['Authorization'] = 'signed'; 634 | return request; 635 | }) 636 | 637 | const interceptor = aws4Interceptor({ 638 | options: { 639 | region: "local", 640 | service: "execute-api", 641 | }, 642 | instance: axios, 643 | }); 644 | 645 | // Act 646 | await interceptor(request); 647 | await interceptor(request); 648 | await interceptor(request); 649 | 650 | expect(sign).toBeCalledTimes(3) 651 | }); 652 | }); 653 | -------------------------------------------------------------------------------- /src/interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Request as AWS4Request, sign } from "aws4"; 2 | import axios, { 3 | AxiosHeaders, 4 | AxiosInstance, 5 | AxiosRequestConfig, 6 | AxiosRequestHeaders, 7 | InternalAxiosRequestConfig, 8 | Method, 9 | } from "axios"; 10 | 11 | import { OutgoingHttpHeaders } from "http"; 12 | import { CredentialsProvider } from "."; 13 | import { AssumeRoleCredentialsProvider } from "./credentials/assumeRoleCredentialsProvider"; 14 | import { isCredentialsProvider } from "./credentials/isCredentialsProvider"; 15 | import { SimpleCredentialsProvider } from "./credentials/simpleCredentialsProvider"; 16 | import { createHash } from "crypto"; 17 | 18 | export interface InterceptorOptions { 19 | /** 20 | * Target service. Will use default aws4 behavior if not given. 21 | */ 22 | service?: string; 23 | /** 24 | * AWS region name. Will use default aws4 behavior if not given. 25 | */ 26 | region?: string; 27 | /** 28 | * Whether to sign query instead of adding Authorization header. Default to false. 29 | */ 30 | signQuery?: boolean; 31 | /** 32 | * Whether to add a X-Amz-Content-Sha256 header. 33 | */ 34 | addContentSha?: boolean; 35 | /** 36 | * ARN of the IAM Role to be assumed to get the credentials from. 37 | * The credentials will be cached and automatically refreshed as needed. 38 | * Will not be used if credentials are provided. 39 | */ 40 | assumeRoleArn?: string; 41 | /** 42 | * Number of seconds before the assumed Role expiration 43 | * to invalidate the cache. 44 | * Used only if assumeRoleArn is provided. 45 | */ 46 | assumedRoleExpirationMarginSec?: number; 47 | /** 48 | * An identifier for the assumed role session. 49 | * Use the role session name to uniquely identify a session when the same role is 50 | * assumed by different principals or for different reasons. 51 | * In cross-account scenarios, the role session name is visible to, 52 | * and can be logged by the account that owns the role. 53 | */ 54 | assumeRoleSessionName?: string; 55 | } 56 | 57 | export interface SigningOptions { 58 | host?: string; 59 | headers?: AxiosRequestHeaders; 60 | path?: string; 61 | body?: unknown; 62 | region?: string; 63 | service?: string; 64 | signQuery?: boolean; 65 | method?: string; 66 | } 67 | 68 | export interface Credentials { 69 | accessKeyId: string; 70 | secretAccessKey: string; 71 | sessionToken?: string; 72 | } 73 | 74 | export type InternalAxiosHeaders = Record< 75 | Method | "common", 76 | Record 77 | >; 78 | 79 | const removeUndefined = (obj: Record) => { 80 | const newObj: Record = {}; 81 | 82 | for (const [key, value] of Object.entries(obj)) { 83 | if (value !== undefined) { 84 | newObj[key] = value; 85 | } 86 | } 87 | 88 | return newObj; 89 | }; 90 | 91 | interface Aws4Config extends InternalAxiosRequestConfig { 92 | _originalHeaders: AxiosHeaders; 93 | } 94 | 95 | /** 96 | * Create an interceptor to add to the Axios request chain. This interceptor 97 | * will sign requests with the AWSv4 signature. 98 | * 99 | * @example 100 | * axios.interceptors.request.use( 101 | * aws4Interceptor({ region: "eu-west-2", service: "execute-api" }) 102 | * ); 103 | * 104 | * @param options The options to be used when signing a request 105 | * @param credentials Credentials to be used to sign the request 106 | */ 107 | // this would be a breaking change to the API 108 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 109 | export const aws4Interceptor = ({ 110 | instance = axios, 111 | credentials, 112 | options, 113 | }: { 114 | instance?: AxiosInstance; 115 | options?: InterceptorOptions; 116 | credentials?: Credentials | CredentialsProvider; 117 | }): (( 118 | config: InternalAxiosRequestConfig, 119 | ) => Promise>) => { 120 | let credentialsProvider: CredentialsProvider; 121 | 122 | if (isCredentialsProvider(credentials)) { 123 | credentialsProvider = credentials; 124 | } else if (options?.assumeRoleArn && !credentials) { 125 | credentialsProvider = new AssumeRoleCredentialsProvider({ 126 | roleArn: options.assumeRoleArn, 127 | region: options.region, 128 | expirationMarginSec: options.assumedRoleExpirationMarginSec, 129 | roleSessionName: options.assumeRoleSessionName, 130 | }); 131 | } else { 132 | credentialsProvider = new SimpleCredentialsProvider(credentials); 133 | } 134 | 135 | return async (config) => { 136 | const url = instance.getUri(config); 137 | 138 | if (!url) { 139 | throw new Error( 140 | "No URL present in request config, unable to sign request", 141 | ); 142 | } 143 | 144 | const { host, pathname, search } = new URL(url); 145 | const { data, method } = config; 146 | 147 | const transformRequest = getTransformer(config); 148 | 149 | transformRequest.bind(config); 150 | 151 | // Save, and reset headers on retry 152 | if ((config as Aws4Config)._originalHeaders) { 153 | config.headers = new AxiosHeaders((config as Aws4Config)._originalHeaders); 154 | } else { 155 | (config as Aws4Config)._originalHeaders = new AxiosHeaders(config.headers); 156 | } 157 | 158 | const headers = config.headers; 159 | 160 | // @ts-expect-error we bound the function to the config object 161 | const transformedData = transformRequest(data, headers); 162 | 163 | // Remove all the default Axios headers 164 | const { 165 | common, 166 | delete: _delete, // 'delete' is a reserved word 167 | get, 168 | head, 169 | post, 170 | put, 171 | patch, 172 | ...headersToSign 173 | } = headers as unknown as InternalAxiosHeaders; 174 | // Axios type definitions do not match the real shape of this object 175 | 176 | const signingOptions: AWS4Request = { 177 | method: method && method.toUpperCase(), 178 | host, 179 | path: pathname + search, 180 | region: options?.region, 181 | service: options?.service, 182 | signQuery: options?.signQuery, 183 | body: transformedData, 184 | headers: removeUndefined(headersToSign) as unknown as OutgoingHttpHeaders, 185 | }; 186 | 187 | const resolvedCredentials = await credentialsProvider.getCredentials(); 188 | sign(signingOptions, resolvedCredentials); 189 | 190 | if (signingOptions.headers) { 191 | for (const [key, value] of Object.entries(signingOptions.headers)) { 192 | config.headers.set(key, value); 193 | } 194 | } 195 | 196 | if (signingOptions.signQuery) { 197 | const originalUrl = new URL(url); 198 | const signedUrl = new URL(originalUrl.origin + signingOptions.path); 199 | 200 | config.params = { 201 | ...config.params, 202 | ...Object.fromEntries(signedUrl.searchParams.entries()) 203 | } 204 | } 205 | 206 | if (options?.addContentSha) { 207 | config.headers.set('X-Amz-Content-Sha256', createHash('sha256').update(transformedData ?? '', 'utf8').digest('hex')) 208 | } 209 | 210 | return config; 211 | }; 212 | }; 213 | 214 | const getTransformer = (config: AxiosRequestConfig) => { 215 | const { transformRequest } = config; 216 | 217 | if (transformRequest) { 218 | if (typeof transformRequest === "function") { 219 | return transformRequest; 220 | } else if (transformRequest.length) { 221 | return transformRequest[0]; 222 | } 223 | } 224 | 225 | throw new Error( 226 | "Could not get default transformRequest function from Axios defaults", 227 | ); 228 | }; 229 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2015", 5 | "declaration": true, 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src/**/*"] 11 | } 12 | --------------------------------------------------------------------------------