jamesmbourne / aws4-axios

Axios request interceptor for signing requests with AWSv4
MIT License
107 stars 39 forks source link

AWS Signature does not match what is expected #110

Closed joeythomaschaske closed 3 years ago

joeythomaschaske commented 3 years ago

AWS returns a message when using the interceptor.

{ "message": "The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.

The Canonical String for this request should have been
'POST
/dev/api/account

content-length:54
content-type:application/json;charset=UTF-8
host:<myhost>
x-amz-date:20210222T225630Z

content-length;content-type;host;x-amz-date
7f1db2a37a1ddcd7057639f94687508f1df7f243584db71e9e1b9d618f30fce5'

The String-to-Sign should have been
'AWS4-HMAC-SHA256
20210222T225630Z
20210222/us-east-1/execute-api/aws4_request
<hash>'
" }

Chrome also logs the errors:

xhr.js:125 Refused to set unsafe header "Host"
xhr.js:125 Refused to set unsafe header "Content-Length"
VM42:1 POST https://<myhost>/dev/api/account 403

Usage:

import axios, { AxiosInstance } from 'axios';
import { aws4Interceptor } from 'aws4-axios';

const CREATE_SETUP_INTENT_ENDPOINT = 'api/account';

export default class API {
  stage = (process.env.REACT_APP_STAGE || 'dev').toUpperCase();
  client: AxiosInstance;

  constructor(accessKey: string, secretKey: string) {
    const interceptor = aws4Interceptor(
      {
        region: 'us-east-1',
        service: 'execute-api',
      },
      {
        accessKeyId: accessKey,
        secretAccessKey: secretKey,
      },
    );
    this.client = axios.create();
    this.client.interceptors.request.use(interceptor);
  }

  public async createSetupIntent(customer_id: string): Promise<void> {
    // resolves to https://<myhost>/dev/api/account 
    const response = await this.client.post(
      `${process.env[`REACT_APP_API_ENDPOINT_${this.stage}`]}/${CREATE_SETUP_INTENT_ENDPOINT}`,
      { customer_id },
    );
    console.log(response);
  }
}
ncasale commented 3 years ago

I am seeing the same errors when trying to make POST requests to an API Gateway from within a React app. The requests work correctly when testing through the API Gateway testing interface and when using Postman's AWS Signature, so it is not a CORS or deployment issue. Any thoughts on why this is happening? It's worth noting that the signatures for GETs within my app are working as expected - it's only the POST requests that are failing.

joeythomaschaske commented 3 years ago

@ncasale I'm guessing this is by design and this library is meant to be used server side.

Chrome is rejecting the setting of the Host in the headers.

Technically you really should not be exposing the accessKeyId and secretAccessKey client side, so I bet AWS required the Host to be set to prevent you from doing it client side.

ncasale commented 3 years ago

@joeythomaschaske That's reasonable, although in addition to the accessKeyId and secretAccessKey, I also require the sessionToken issued by STS, which is only valid for an hour. So from a security perspective, the longest the creds could be leaked is an hour, and the role assumed by these users can only be used to access this API. Point taken though, this is likely meant to be done server-side.

However, that wouldn't explain why my GETs are working. The same Refused to set unsafe header 'Host' error appears from my GET calls, but the signing still works correctly and the APIs are returning data.

joeythomaschaske commented 3 years ago

@ncasale I would still be cautious of handling the accessKeyId and secretAccessKey client side. Depending on the policies attached to the user, someone could use those with Postman/aws-sdk to modify your account/services with no sessionToken needed

ncasale commented 3 years ago

@joeythomaschaske Using STS to generate temporary credentials is actually standard practice for mobile and client-side apps - you can see an example of this architecture in the AWS docs.. Users get authenticated through web identity federation, Cognito user pools, or some other custom auth flow, and the temporary credentials get generated as needed to access services - in this case, invoking an API Gateway. Of course, if you set up shoddy IAM policies that get attached to the IAM Role end users are assuming, you open yourself up to a security risk. I'd argue that's just bad configuration though and not a reason to avoid using temporary credentials client-side.

joeythomaschaske commented 3 years ago

@ncasale Ah, I wasn't aware you were using cognito for Authorization. That makes it much easier but makes me wonder why you're looking into this library and not amplify?

ncasale commented 3 years ago

@joeythomaschaske Amplify came with its own set of headaches when we were trying to integrate it into our app's existing architecture so we opted for custom authorization because we knew how to get it working. Definitely will be something to revisit in a future refactor, especially since assigning IAM Roles to Cognito Groups is great for fine-grained access control - but for now we're doing the signing manually. Also, managed to figure this out using the aws4fetch library.

import { AwsClient } from "aws4fetch";

export const callAPI = async (params) => {
  // Get credentials for signing
  const { accessKeyId, secretAccessKey, sessionToken } = getCredentials();
  // Create AWS Client to sign request
  const aws = new AwsClient({
    accessKeyId: accessKeyId,
    secretAccessKey: secretAccessKey,
    sessionToken: sessionToken,
    service: "execute-api",
    region: "us-west-2",
  });

  try {
    // Make sure params are stringified
    const resp = await aws.fetch(
      "<your api endpoint>",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(params),
      }
    );
    const respJSON = await resp.json();
    return respJSON;
  } catch (error) {
    // Handle error
  }
};

This may not help you running this from a Node server, but if you're using an environment that supports fetch it gets the job done.

joeythomaschaske commented 3 years ago

@ncasale I might give that a shot. Someone from my network told me about generating an APIGateway sdk for your api that also looked like a promising solution. But the downside is it requires some manual configuration since the sdk can only be generated from the console

ncasale commented 3 years ago

@joeythomaschaske That's a really interesting solution that I didn't know was possible. May have to check that out for future projects. Also, good call on Amplify. I hadn't considered that I could import individual modules. I ended up just importing the Signer module which didn't require any project-level Amplify configuration and allowed me to easily complete the signing.

import { Signer } from "@aws-amplify/core";
import axios from "axios";

const signedClient = axios.create();

const getCredentials = () => {
  // Get user credentials from local store, return as object
};

const createSignedRequest = (method, endpoint, data = "") => {
  const request = {
    method: method,
    url: endpoint,
    data: data,
  };

  const { AccessKeyId, SecretAccessKey, SessionToken } = getCredentials();

  const accessInfo = {
    access_key: AccessKeyId,
    secret_key: SecretAccessKey,
    session_token: SessionToken,
  };

  const serviceInfo = {
    service: "execute-api",
    region: "aws-region-code",
  };

  const signedRequest = Signer.sign(request, accessInfo, serviceInfo);
  delete signedRequest.headers["host"]; //Remove host header
  return signedRequest;
};

const callAPI = async (params) => {
  try {
    const resp = await signedClient(
      createSignedRequest(
        "POST",
        "<your-endpoint-here>",
        JSON.stringify(params)
      )
    );
    return resp.data;
  } catch (error) {
    throw error;
  }
joeythomaschaske commented 3 years ago

@ncasale We do it in a slightly different way for our web app with cognito.

We have an aws config object with our api info:

export const awsConfig = {
  aws_project_region: '<region>',
  aws_cognito_identity_pool_id: process.env[`REACT_APP_IDENTITY_POOL_ID_${stage}`],
  aws_cognito_region: '<region>',
  aws_user_pools_id: process.env[`REACT_APP_USER_POOL_ID_${stage}`],
  aws_user_pools_web_client_id: process.env[`REACT_APP_USER_POOL_CLIENT_ID_${stage}`],
  API: {
    endpoints: [
      {
        name: apiName,
        region: '<region>',
        endpoint: process.env[`REACT_APP_API_ENDPOINT_${stage}`],
      },
    ],
  },
  Auth: {
    identityPoolId: process.env[`REACT_APP_IDENTITY_POOL_ID_${stage}`],
    region: '<region>,
    userPoolId: process.env[`REACT_APP_USER_POOL_ID_${stage}`],
    userPoolWebClientId: process.env[`REACT_APP_USER_POOL_CLIENT_ID_${stage}`],
  },
};

Then somewhere in your app, top level, call Amplify.configure(awsConfig); with your config object.

Then we have an API class that uses the API from import { API } from 'aws-amplify';

That should do everything for you given you authenticate with Cognito/amplify

slimandslam commented 3 years ago

@joeythomaschaske - Yes, that configuration works great for AUTHENTICATED Cognito users. However, I have not found a way to use Amplify for making REST API calls on behalf of UN-AUTHENTICATED Cognito users.

jamesmbourne commented 3 years ago

Hey, @joeythomaschaske - I've managed to reproduce this myself. You're correct in the assumption that this was only designed to be used server-side, although I'm glad to see people are finding alternative ways to use it!

My best suggestion is to use the signQuery: true option when creating the interceptor e.g.

const interceptor = aws4Interceptor({
  region: "eu-west-2",
  service: "execute-api",
  signQuery: true,
});

I've tried this in a React.js project myself and managed to succesfully make both GET and POST requests to an API gateway using IAM auth.

I don't have the time right now to dig into why the default signing method is not working from a browser environment, but please feel free to open a PR if you figure it out!

dvargas10Pearls commented 1 year ago

Hi @jamesmbourne , I tried using the signQuery: true option but i get this error:

TypeError: Cannot set properties of undefined (setting 'X-Amz-Security-Token')
    at eval (interceptor.js:100:36)
    at Generator.next (<anonymous>)
    at fulfilled (interceptor.js:5:58)

Here's how I create my interceptor:

      aws4Interceptor({
        options: {
          region: "us-east-1",
          service: "execute-api",
          signQuery: true
        },
        credentials: {
          accessKeyId: getCredentialsResult.Credentials!.AccessKeyId!,
          secretAccessKey: getCredentialsResult.Credentials!.SecretKey!,
          sessionToken: getCredentialsResult.Credentials!.SessionToken!
        }
      });

Is there something I'm missing when passing the credentials?