Signing AWS requests with Signature Version 4 within a Pre-Flight Script

Hi!

I have an AppSync API and I am tried of using the AppSync console because it is quite lacking in features and it is VERY buggy.

I am happy to have found Apollo Studio! Pretty awesome so far.

My API has 3 auth modes:

  • IAM (admin all access)
  • API Key (public access)
  • OIDC (Users JWTs)

API Key and OIDC work great out of the box. But I am having trouble with IAM auth.

I found out about Preflight scripts and thought it would be the perfect place to sign my requests and create the relevant header data needed to authorize my requests. I noticed it even comes with CryptoJS! Perfect!

I wrote a small script in node to test out the mechanisms to sign my query with crypto-js and was successful.

However, when I ported that code into the pre-flight script, I was not able to query my API. AWS tells me there is something wrong my signature.

I thought someone might help me out here.

Here is my node code that works:

const crypto = require("crypto-js");
const moment = require("moment");
const axios = require("axios");

const apiUrl =
  "https://<MY_API>.appsync-api.ca-central-1.amazonaws.com/graphql";

const endpoint = new URL(apiUrl).hostname.toString();

const request = `mutation CreateUser($input: CreateUserInput!) {
    createUser(input: $input) {
      id
    }
  }`;
async function send() {
  const access_key = "";
  const secret_key = "";
  const method = "POST";
  const service = "appsync";
  const host = endpoint;
  const region = "ca-central-1";
  const base = "https://";
  const content_type = "application/json";
  const canonical_uri = "/graphql";

  function getSignatureKey(key, dateStamp, regionName, serviceName) {
    var kDate = crypto.HmacSHA256(dateStamp, "AWS4" + key);
    var kRegion = crypto.HmacSHA256(regionName, kDate);
    var kService = crypto.HmacSHA256(serviceName, kRegion);
    var kSigning = crypto.HmacSHA256("aws4_request", kService);
    return kSigning;
  }

  const amz_date = moment().utc().format("yyyyMMDDTHHmmss") + "Z";
  const date_stamp = moment().utc().format("yyyyMMDD");

  const canonical_querystring = "";

  const request_parameters = JSON.stringify({
    query: request,
    variables: { input: {} },
  });
  const payload_hash = crypto.SHA256(request_parameters);

  const canonical_headers =
    ":authority:" +
    host +
    "\n" +
    "x-amz-content-sha256:" +
    payload_hash +
    "\n" +
    "x-amz-date:" +
    amz_date +
    "\n";
  const signed_headers = ":authority;x-amz-content-sha256;x-amz-date";

  const fs = require("fs");

  const canonical_request =
    method +
    "\n" +
    canonical_uri +
    "\n" +
    canonical_querystring +
    "\n" +
    canonical_headers +
    "\n" +
    signed_headers +
    "\n" +
    payload_hash;

  fs.writeFileSync("canonical_request.txt", canonical_request);

  const algorithm = "AWS4-HMAC-SHA256";
  const credential_scope =
    date_stamp + "/" + region + "/" + service + "/" + "aws4_request";
  const string_to_sign =
    algorithm +
    "\n" +
    amz_date +
    "\n" +
    credential_scope +
    "\n" +
    crypto.SHA256(canonical_request);

  console.log(string_to_sign);

  const signing_key = getSignatureKey(secret_key, date_stamp, region, service);
  const signature = crypto.HmacSHA256(string_to_sign, signing_key);
  const authorization_header =
    algorithm +
    " " +
    "Credential=" +
    access_key +
    "/" +
    credential_scope +
    ", " +
    "SignedHeaders=" +
    signed_headers +
    ", " +
    "Signature=" +
    signature;

  const headers = {
    "X-Amz-Content-Sha256": payload_hash.toString(),
    "X-Amz-Date": amz_date,
    Authorization: authorization_header,
    "Content-Type": content_type,
  };
  const response = await axios({
    method: method,
    baseURL: base + host,
    url: canonical_uri,
    data: request_parameters,
    headers: headers,
  });
}

send()

My ported pre-flight script:

const crypto = explorer.CryptoJS


function getAmzDate() {
const utcYear = new Date().getUTCFullYear()
// const utcMonth = new Date().getUTCMonth()
const utcMonth = 11
const utcDay = new Date().getUTCDate()
const utcHour = new Date().getUTCHours()
const utcMinutes = new Date().getUTCMinutes()
const utcSeconds = new Date().getUTCSeconds()

const amz_date_v = `${utcYear}${utcMonth}${utcDay}T${utcHour}${utcMinutes}${utcSeconds}Z`
return amz_date_v
}

function setRequestHeaders() {
  const access_key = "";
  const secret_key = ""
  const method = "POST";
  const service = "appsync";
  const host = "<MY_API>.ca-central-1.amazonaws.com";
  const region = "ca-central-1";
  const canonical_uri = "/graphql";

  function getSignatureKey(key, dateStamp, regionName, serviceName) {
    console.log(key, dateStamp, regionName, serviceName)
    var kDate = crypto.HmacSHA256(dateStamp, "AWS4" + key);
    var kRegion = crypto.HmacSHA256(regionName, kDate);
    var kService = crypto.HmacSHA256(serviceName, kRegion);
    var kSigning = crypto.HmacSHA256("aws4_request", kService);
    return kSigning;
  }

  const amz_date = "20221111T010829Z" //temp until getAmzDate is fixed
  const date_stamp = "20221111"

  const canonical_querystring = "";

  const request_parameters = JSON.stringify({
    query: explorer.request.body.query,
    variables: explorer.request.body.variables,
  });
  const payload_hash = crypto.SHA256(request_parameters);

  const canonical_headers =
    ":authority" +
    host +
    "\n" +
    "x-amz-content-sha256:" +
    payload_hash +
    "\n" +
    "x-amz-date:" +
    amz_date +
    "\n";

  const signed_headers = ":authority;x-amz-content-sha256;x-amz-date";

  const canonical_request =
    method +
    "\n" +
    canonical_uri +
    "\n" +
    canonical_querystring +
    "\n" +
    canonical_headers +
    "\n" +
    signed_headers +
    "\n" +
    payload_hash;

  const algorithm = "AWS4-HMAC-SHA256";
  const credential_scope =
    date_stamp + "/" + region + "/" + service + "/" + "aws4_request";
  const string_to_sign =
    algorithm +
    "\n" +
    amz_date +
    "\n" +
    credential_scope +
    "\n" +
    crypto.SHA256(canonical_request);

  const signing_key = getSignatureKey(secret_key, date_stamp, region, service);

  const signature = crypto.HmacSHA256(string_to_sign, signing_key);

  const authorization_header =
    algorithm +
    " " +
    "Credential=" +
    access_key +
    "/" +
    credential_scope +
    ", " +
    "SignedHeaders=" +
    signed_headers +
    ", " +
    "Signature=" +
    signature;

  explorer.environment.set('authorization', authorization_header)
  explorer.environment.set('date', amz_date)
  explorer.environment.set('sha', payload_hash.toString())
}

setRequestHeaders()

Hi Alex,
sorry, I don’t have answer to your question, but what catch my eyes is:

Can I ask you how do you integrate with OIDC SSO server? Do you integrate somehow complete login flow with access token obtaining and refresh? I was not able to achieve this, I was only able to use JWT access token copied manually from the SSO server and put in Authorization header, which is not optimal/user friendly.

Hi

I have a similar authentication setup to you. I am sure you have got the iAM auth working by now and moved on to other things, but I thought I would post my solution for others.

In my case the relevant schema looks like

type Mutation {
    ...
    publishEvent(projectId: ID!, source: String!, columnId: ID): RealtimeEvent @aws_iam
}

type Subscription {
    eventPublished(projectId: String!): RealtimeEvent @aws_subscribe(mutations: ["publishEvent"]) @aws_api_key
}

type RealtimeEvent @aws_api_key @aws_iam {
    projectId: String!
    columnId: String
    source: String!
}

From this anyone can listen to events with the API key (public), but only with the correct IAM credentials can a publishEvent mutation be performed.

I then have a Lambda function triggered by EventBus which propagates select events to the frontend via the subscription.

import type { EventBridgeEvent } from "aws-lambda"
import { logger } from "@wise/logger"
import { wrapHandler } from "../../domains/o11y/sentry.js"
import { graphql } from "../../gql/__generated__/gql.js"
import { AppSyncApi } from "sst/node/api"
import { Sha256 } from "@aws-crypto/sha256-js"
import { HttpRequest } from "@aws-sdk/protocol-http"
import { fromEnv } from "@aws-sdk/credential-providers"
import { SignatureV4 } from "@smithy/signature-v4"
import { ApolloClient, ApolloLink, DefaultOptions, HttpLink, InMemoryCache, concat } from "@apollo/client"

interface Detail {
    projectId: string
}

export async function handler(event: EventBridgeEvent<string, Detail>) {
    const { projectId } = event.detail
    const createdAt = event.time
    const source = event.source

    const sentProjectEventDocument = graphql(/* GraphQL */ `
        mutation SendProjectEvent($id: ID!, $source: String!) {
            publishEvent(projectId: $id, source: $source) {
                projectId
                source
                columnId
            }
        }
    `)

    const apiUrl = new URL(AppSyncApi.GraphqlApi.url)

    const sigv4 = new SignatureV4({
        service: "appsync",
        region: process.env.AWS_REGION ?? "",
        credentials: fromEnv(),
        sha256: Sha256,
    })

    const httpLink = new HttpLink({
        uri: apiUrl.toString(),
        fetch: async (uri, options) => {
            const request = new HttpRequest({
                hostname: apiUrl.hostname,
                path: apiUrl.pathname,
                body: options?.body,
                method: "POST",
                headers: {
                    accept: "*/*",
                    "content-type": "application/json",
                    host: apiUrl.hostname,
                },
            })

            const { headers, body, method } = await sigv4.sign(request)

            return await fetch(uri, {
                ...options,
                method,
                body,
                headers,
            })
        },
    })

    const client = new ApolloClient({
        link: httpLink,
        cache: new InMemoryCache(),
        defaultOptions: defaultOptions,
    })

    const response = await client.mutate({ mutation: sentProjectEventDocument, variables: { id: projectId, source } })

    return {
        statusCode: 200,
        body: JSON.stringify({
            projectId,
            createdAt,
            source,
        }),
    }
}