Authorization providers

Authorization is probably the most important AppSync API configuration as this defines who can call the API, and as we'll see later what queries, mutations, subscriptions, and fields they can use.

Access control is also defined in the schema using directives, but they don't allow much configuration. You can use the @aws_cognito_user_pools directive and define the user group, but you can't specify which user pool users need to authenticate to. Similarly, you can add API keys and define in the schema what fields are available using these keys, but creating and rotating them is done on the API level, separate from the schema.

AppSync allows the configuration of a default authorization mode as well as additional authorization providers. Each of these can be one of the supported provider.

Default authorization mode configured with a Cognito User Pool

With this two-layered approach, you can combine multiple providers, such as IAM and Cognito (a rather useful combination) or even add multiple user pools to a single API.

IAM configured as an additional authorization mode

AppSync supports several providers:

  • Cognito User Pool
  • IAM
  • OpenID connect
  • API key
  • Lambda

Let's see how each of them works!

Cognito User Pool

A Cognito User Pool is a managed user directory in AWS. It provides a storage for users, defines a flow for authentication, and handles things like passwords, MFA, user groups, and token refresh/revocation. New applications usually use Cognito as it is integrated into the AWS ecosystem and it handles a lot of edge cases out-of-the-box.

Cognito User Pools issue access tokens that services can consume. A logged-in user is identified by its token that it sends with every request made to AppSync.

userCognitoGraphQLbackendloginAuth flowAccess TokenAuthorization: Access Tokenquery MyQuery {allUsers {username}}Validate Access TokenResolve fields...response
Cognito authentication

A successful authentication returns a short-lived AccessToken:

Authentication to Cognito returns an access token
Access Token vs Refresh Token

Access Tokens are short-lived (usually 15 minutes to a few hours), while Refresh Tokens have a long expiration time (months to years). A Refresh Token can request new Access Tokens, so make sure you store it securely.

Then requests to AppSync needs to include this token in the Authorization header:

The user needs to send the access token with the GraphQL request
User Pool vs Identity Pool

Cognito User Pools return access tokens that the consuming services can validate against the user pool. Services that support User Pools (such as AppSync) do this automatically.

Cognito Identity Pools return AWS IAM credentials (Access Key ID and Secret Access Keys), so they provide direct access to the AWS environment. As a result, if you use Identity Pools you need to configure IAM authorization for AppSync.

Usually, you'll want to use User Pools and not Identity Pools.

Configuring a Cognito User Pool for an API allows the use of the @aws_cognito_user_pools and the @aws_auth directives (depending on whether there are multiple providers configured or just a single one) that can restrict access to specific groups:

type Query {
  # everybody can get themselves
  currentUser: User

  # only admins can query all users
  allUsers: [User]
  @aws_cognito_user_pools(cognito_groups: ["admin"])
}

IAM

IAM authorization is useful when the caller is an IAM identity: an IAM user or an IAM role. This is usually the case when using the Management Console, or when you want to call the API from a Lambda function or the AWS CLI.

IAM identities can have IAM policies and those policies can give them access to AWS resources. As an AppSync API is an AWS resource, a policy can give access to its queries, mutations, and subscriptions:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["appsync:GraphQL"],
      "Resource": [
        "arn:aws:appsync:<region>:<accountid>:apis/<apiid>
/types/Query/fields/allUsers",
      ]
    }
  ]
}

An IAM policy is necessary to give access to an API, but it might not be sufficient depending on how the API authorization is configured. If you have multiple providers, you also need to add the @aws_iam directive in the schema:

type Query {
  # everybody can get themselves
  currentUser: User

  # only admins and IAM identities can query all users
  allUsers: [User]
  @aws_cognito_user_pools(cognito_groups: ["admin"])
  @aws_iam
}
Lambda functionExecution roleIAM policyAppSync APIassumescall
IAM permissions to call an AppSync API
AWS Signatures

Under the hood, calls with IAM credentials (Access Key ID and Secret Access Key) use an AWS signature on the HTTP request.

For example, this is an AppSync query, signed with an IAM role's credentials:

HttpRequest {
  method: 'POST',
  hostname: 'zb6sucjqnna73ncwc4juglddxi.appsync-api...',
  port: undefined,
  query: {},
  headers: {
    host: 'zb6sucjqnna73ncwc4juglddxi.appsync-api...',
    'x-amz-date': '20220215T082225Z',
    'x-amz-security-token': 'IQoJb3JpZ2luX2VjE...',
    'x-amz-content-sha256': 'baf7492d61aaacdcb...',
    authorization: 'AWS4-HMAC-SHA256 ... , Signature=21e592...'
  },
  body: '{"query":"\\nquery MyQuery {...}\\n    ",
    "operationName":"MyQuery"}',
  protocol: 'https:',
  path: '/graphql'
}

Notice that it has some extra headers: the x-amz-date, the x-amz-security-token, and the x-amz-content-sha256, and it also has a Signature in the authorization header. These are results of the signature process, and AWS automatically checks that the request contains all the required elements and that it was signed by an identity with sufficient permissions.

Usually, you don't need to know how the signature process works, and AWS provides tools to sign a request.

Calling AppSync from Javascript

The AWS JS SDK v3 provides all the necessary libraries to calculate the signature and sign a request. With it, you can implement Lambda fuctions that call AppSync using their roles' permissions.

First, import the libraries:

import {HttpRequest} from "@aws-sdk/protocol-http";
import {SignatureV4} from "@aws-sdk/signature-v4";
import {defaultProvider}
  from "@aws-sdk/credential-provider-node";
import fetch from "node-fetch";
import {URL} from "url";
import pkg from "@aws-crypto/sha256-js";
const {Sha256} = pkg;

// this is the AppSync API URL
const appsyncAPI =
  "https://syr5fg5g2navzh2u3emfhhlyze.appsync-api" +
  ".eu-central-1.amazonaws.com/graphql";

Then create the HTTP request:

const {host, pathname} = new URL(appsyncAPI);

const request = new HttpRequest({
  body: JSON.stringify({
    // the GraphQL query
    query: `
query MyQuery {
  test_nothing
}
    `,
    // operation name
    operationName: "MyQuery",
    // it also supports variables
  }),
  headers: {
    host,
  },
  hostname: host,
  method: "POST",
  path: pathname,
});

Initialize the signer:

const signer = new SignatureV4({
  credentials: defaultProvider(),
  region: host
    .match(/^[^.]+\.appsync-api\.(?<region>[^.]+)\..*$/)
    .groups.region,
  service: "appsync",
  sha256: Sha256
});

Then generate the signed request and send it:

// generate the signed request
const signedRequest = await signer.sign(request);

// send the request
const res = await fetch(
  signedRequest.protocol +
    "//" +
    signedRequest.hostname +
    signedRequest.path,
  {
    body: signedRequest.body,
    headers: signedRequest.headers,
    method: signedRequest.method,
  }
);

// print the result
console.log(await res.json());

OpenID Connect

If you have users in a directory that supports OpenID Connect then you can configure that with AppSync. For example, Auth0 provides a user directory that is compatible with AppSync.

userUser directorywith OpenID ConnectAppSyncLogin/authorize?...&scope=openidredirect /?code=.../tokenid_tokenCall AppSyncAuthorization: ID Tokenquery MyQuery {allUsers {username}}Validate TokenResolve fields...response
OpenID Connect flow

To configure an Auth0 user directory with AppSync, first create an Application:

Auth0 application on the Auth0 dashboard

Then add its domain for the AppSync API:

Configuring Auth0 for AppSync

This is all the configuration you'll need, now users authenticated on Auth0 can send GraphQL requests to the AppSync API. This requires the OAuth flow to get the ID token first:

Login flow

Then include that token to the GraphQL requests, just like with Cognito:

ID token is included in the GraphQL request

With this, a user in the Auth0 domain can log in and send requests:

The user on the Auth0 dashboard

AppSync resolvers get the information about the signed-in user:

{
  "claims": {
    "sub": "auth0|621203151b635f0070ea9978",
    "aud": "3SxmB0t1p4GQNlBTHHyYnnu3QJVlAOcp",
    "iss": "https://dev-at4in79i.us.auth0.com/",
    "exp": 1645385061,
    "iat": 1645349061
  },
  "issuer": "https://dev-at4in79i.us.auth0.com/",
  "sub": "auth0|621203151b635f0070ea9978"
}

Notice the sub is the identifier of the Auth0 user, the issuer is the domain, and the aud is the Client ID of the application. AppSync verifies the token.

Example code

To deploy a configured AppSync API and an Auth0 application to your account, see the code example in the GitHub repository: https://github.com/sashee/auth0-appsync

Note

AppSync works with any provider that supports OpenID Connect and not just Auth0.

API key

API key authorization provides a simple way to restrict access to an AppSync API. These keys are texts that you can create on the AppSync console (or CLI, or SDK):

API keys can be added to the API

Notice that every API key has an expiration time. This can go up to 365 days, then you need to create a new one.

Then the key is sent in the x-api-key header:

The API key is defined in the x-api-key header

API keys are useful for getting started with AppSync development, but they are good only for a temporary solution. For admin tasks, AWS IAM is a better option, and for clients, a user directory, such as Cognito or OpenID Connect provides a good solution.

Tip

API keys are only good for getting started with AppSync. Make sure you move to a user directory or IAM in the long run.

Lambda

(Example code)

The Lambda authorizer allows a Lambda function with arbitrary code to decide whether a request is allowed or not. This is the most versatile solution as it has the full power of a function: it can call other services and do any type of custom processing to reach the auth decision (allow or deny).

On the other hand, it involves writing custom code and maintaining a function that is run for (potentially) all requests hitting the API. This adds a lot of extra code and complexity, so my recommendation is to use it only when the other solutions are not sufficient for your case.

userAppSyncAuthorizer functionresolversrequesttokenalt[authorized]authorizedqueryresponseresponse[denied]deniedunauthorized
The authorizer Lambda decides if a request is allowed or not

Configuring a Lambda authorizer for the API is straightforward. All you need is to define which function AppSync should run:

Lambda authorizer config

The function then needs to give lambda:InvokeFunction access to the AppSync API. This is done using a resource-based permission on the function:

Resource-based policy to allow AppSync to call the function

The programming model takes the usual process as other Lambdas: the processing can be in any language and it can use any resources as long as it responds in a specific format. And the response is a JSON object with at least an isAuthorized field.

The function gets the authorization token, which is the value the clients sends in the Authorization header when it makes a request:

The authorization token is in the request header

The function gets this value along with some information about the request:

{
  "authorizationToken": "token2",
  "requestContext": {
    "apiId": "q6ggwm5zqfgszjtnz4iatx7sam",
    "accountId": "278868411450",
    "requestId": "82035a43-bb90-4cf4-b498-d50bb74b663e",
    "queryString": "query MyQuery {\n  document(id: \"doc2\")
    {\n    id\n    title\n  }\n}\n",
    "operationName": "MyQuery",
    "variables": {}
  }
}

AppSync supports several fields in the response, but the only required is the isAuthorized. If it is true, AppSync runs the resolvers for the query. If isAuthorized if false, the request is denied without further processing. Note that resolvers can also return an unauthorized error as we've seen in the Resolver-based access control chapter.

For example, a simple access control scheme can use a DynamoDB table to implement an API key-like functionality:

const client = new DynamoDBClient();
const item = await client.send(new GetItemCommand({
  TableName: tokens_table,
  Key: {id: {S: token}},
}));
if (!item.Item) {
  // token missing, deny access
  return {
    isAuthorized: false,
  };
}else {
  // token found
  return {
    isAuthorized: true,
  };
}
deniedFields

AppSync can deny requests based on what fields are in query. This mimics the @aws_cognito_user_pools directive that restricts what data is accessible for Cognito users. Since the Lambda authorizer does not support a similar directive, it would be harder to implement schema-based access control.

That's the motivation behind the deniedFields response value. It is optional, and if it's defined then it's a list of denied fields in the form of TypeName.FieldName and if the query contains any of them it will be denied.

For example, this schema has multiple fields for a type:

type Document {
  id: ID!
  title: String!
  text: String!
}

Then the Lambda can deny access to the text without parsing the query string itself:

return {
  isAuthorized: true,
  deniedFields: ["Document.text"],
};

Note that it can not restrict access to top-level fields (Queries, Mutations, or Subscriptions). This is a serious limitation, and we'll take a look at a possible implementation by combining an authorizer Lambda with resolvers.

resolverContext

The resolverContext field in the result object allows the Lambda function to provide data to the resolvers that process the fields. It is an object with string fields and it is available under the $ctx.identity.resolverContext.

Since it does not support complex types, such as lists, the usual pattern is to stringify the value and then parse it in the resolver:

return {
  isAuthorized: true,
  resolverContext: {
    documents: JSON.stringify(["doc1"]),
  },
};

Then the resolver can use the value, for example, to restrict what objects the user is able to access:

#if(
  !$util.parseJson($ctx.identity.resolverContext.documents)
    .contains($ctx.arguments.id)
)
  $util.unauthorized()
#end

This pattern is also useful to deny access to specific Queries, Mutations, or Subscriptions. The function can provide info for the resolvers in the resolverContext:

return {
  isAuthorized: true,
  resolverContext: {
    allowedQueries: JSON.stringify(["document"]),
  },
  // ...
};

Then the resolver for that field can check this value:

## document resolver
#if(
  !$util.parseJson($ctx.identity.resolverContext.allowedQueries)
    .contains("document")
)
  $util.unauthorized()
#end

Just keep in mind that you need to implement this checking for all resolvers you want to protect:

## file resolver
#if(
  !$util.parseJson($ctx.identity.resolverContext.allowedQueries)
    .contains("file")
)
  $util.unauthorized()
#end
TTL

By default, AppSync caches the result of the authorizer Lambda for 5 minutes (300 seconds). This caching is often useful as without it every single call to the API triggers the function, which can in turn send requests to databases or other systems. This is usually wasteful as if a user is allowed to access an API, it is usually allowed to access it for subsequent requests too. This is where caching comes into play: AppSync only runs the Lambda every once in a while and reuse the result for most requests.

This sounds good, but caching is always a tradeoff and it can create edge cases. Short caching time doesn't save too many requests, while long caching can backfire when, for example, you want to revoke a token and it will still be valid until the cache time passes.

A more problematic case is when the decision is based on other data and not just the authorization token. For example, the function gets the GraphQL query in the requestContext.queryString, but it is not part of the cache key. If the authorization decision is based on the query, a user can send a request that is allowed, then send another one after it and AppSync might allow the request without calling the Lambda at all, which breaks authorization.

Because of this, use a short caching time, such as 5 minutes which is the default. Also, if you use anything from the requestContext, make sure to disable the cache.

Cache time is called TTL (time-to-live). You can set a defalt for the API when you configure the authorizer.

The default cache TTL on the API level is 300 seconds

Also, you can overwrite it for individual responses using the ttlOverride field:

return {
  isAuthorized: true,
  // ...
  ttlOverride: 0,
};
Tip

Caching can drastically speed up authorization, but in some cases it can break authorization too. Make sure you understand how it works, especially if you use any value from the requestContext.

Master AppSync and GraphQL
Support this book and get all future updates and extra chapters in ebook format.