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.
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.
AppSync supports several providers:
Let's see how each of them works!
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.
A successful authentication returns a short-lived AccessToken
:
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:
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 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
}
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.
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());
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.
To configure an Auth0 user directory with AppSync, first create an Application:
Then add its domain for the AppSync API:
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:
Then include that token to the GraphQL requests, just like with Cognito:
With this, a user in the Auth0 domain can log in and send requests:
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.
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
AppSync works with any provider that supports OpenID Connect and not just Auth0.
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):
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:
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.
API keys are only good for getting started with AppSync. Make sure you move to a user directory or IAM in the long run.
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.
Configuring a Lambda authorizer for the API is straightforward. All you need is to define which function AppSync should run:
The function then needs to give lambda:InvokeFunction
access to the AppSync API. This is done using a resource-based permission on 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 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,
};
}
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.
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
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.
Also, you can overwrite it for individual responses using the ttlOverride
field:
return {
isAuthorized: true,
// ...
ttlOverride: 0,
};
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
.