GraphQL API Authorization
Perquisites
GraphQL helps developers to combine data from multiple sources. We assume that you already know about GraphQL basics, including the schema, queries, mutation, graphQL server, and resolver. You may learn basics from https://www.apollographql.com/tutorials/.
In this blog post, we will focus on GraphQL authentication and authorization; what authorization options do we have and what are the pros and cons.
Anonymous Access vs Authorization
Some web pages may be public, while other web pages may be protected. When we use GraphQL to work with protected resources, authorization is required. In order to do authorization, we need to know whom the user is, meaning the user needs to be authenticated first.
Anonymous access is also a valid use case, meaning no authorization and hence no authentication.
Credentials for Authentication
In general you may pass an opaque token, session cookies or JSON web tokens through HTTP headers to identify the authenticated user.
Authorization: Bearer xydfjk
If you use AWS AppSync, there are multiple options
- IAMs: e.g IAM roles
- OIDC Tokens: These are time-limited and suitable for external clients.
- Cognito User Pool Tokens
API keys are another option, but API keys do not reference an identity and are easily compromised.
Where to Put Authorization
Authorization in GraphQL can be handled in the resolver logic, which allows field-level access to your data, depending on any criteria you can express in the resolver. This allows flexibility, but also introduces complexity in the form of code.
Even though it’s technically possible to do field level authorization from graphQL layer (in resolver or data model), it’s not recommended.
According to AWS re:Invent 2021 — Introduction to GraphQL, authorization logic lives in business layer.
Graphql.io says the same thing.
Why shouldn’t you want to do it the below way instead? You are duplicating the authorization check in GraphQL. In the business logic layer, you will enforce the authorization check anyway. Duplication means more coordination and you need extra effort to ensure consistency.
Even though technology or framework can help you to ensure consistency, for example, reusing common OPA policy rules, it’s still extra effort.
In a future blog, we will look at GraphQL and OPA integration together.
Pass-through
Since the recommendation is to do authorization logic in the business layer, the Graphql layer becomes a pass-through. For example, we simply pass through the authorization headers or cookies to the data sources, which could be REST APIs.
Let’s use the Apollo REST data source as example and see how pass-through can be accomplished.
From the very high level, the Apollo client sends the HTTP request with a bearer token in the authorization header. When you initialize the Apollo server, you can configure it to pass the token as context, which is then available to both the resolver and data sources. In the data sources code, when the code calls the upstream REST API, you can pass the bearer token in the authorization header. The upstream REST API will do the authorization check.
For more details, please refer to the Apollo Git hub, some issue discussion and the official product manual.
Token In Data Source
// server set up, etc.
const MoviesAPI= require('./datasources/movies');
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const token = getTokenFromRequest(req);
// We'll take Apollo Server's cache
// and pass it to each of our data sources
const { cache } = server;
return {
dataSources: {
moviesAPI: new MoviesAPI({ cache, token }),
personalizationAPI: new PersonalizationAPI({ cache }),
},
};
},
});
export {}
// data source code
class MoviesAPI extends RESTDataSource {
baseURL = 'https://person.example.com/';
constructor(options) {
super(options); // this sends our server's `cache` through
this.token = options.token;
}
async getMovie(id) {
return this.get(`movies/${id}`, {<some query parms here>}, {
headers: {
'Authorization': this.token,
},
});
}
}
Apollo RESTDataSource also has willsendrequest method, which can set headers or parms, this method is invoked at the beginning of processing each request.
willSendRequest(request) {
request.headers['authorization'] = this.token;
}
willSendRequest(request) {
request.params.set('api_key', this.token);
}
Token or User Info in Resolver
Just in case you are curious that how context is passed to resolver and how we can use the context, let’s take a look at following example.
- Token or user info can be returned as context in Apollo server configuration
- Context is passed to resolver
server/index.js
try {
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
// 1) Retrieve the Bearer token from the request's Authorization header
// (Note the lowercase "a" in authorization,
// because all headers are transformed to lowercase)
const token = req.headers.authorization || '';
// Get the user token after "Bearer "
const userId = token.split(' ')[1]; // e.g. "user-1"
// Initialize the userInfo object where the user's id and role will be stored
// with a successful authentication
let userInfo = {};
if (userId) {
// 2) Authenticate the user using the accounts API endpoint
const { data } = await axios.get(`http://localhost:4011/login/${userId}`).catch((error) => {
throw AuthenticationError();
});
// 3) After a successful login, store the user's id and role
// in the userInfo object,
// which will be passed to `context` below for the resolvers to use
userInfo = { userId: data.id, userRole: data.role };
}
// Below is the `context` object resolvers will have access to
return {
...userInfo,
dataSources: {
bookingsDb: new BookingsDataSource(),
reviewsDb: new ReviewsDataSource(),
listingsAPI: new ListingsAPI(),
accountsAPI: new AccountsAPI(),
paymentsAPI: new PaymentsAPI(),
},
};
},
listen: {
port,
},
});
console.log(`🚀 Server ready at ${url}`);
} catch (err) {
console.error(err);
}
server/resolvers.js
createListing: async (_, {listing}, {dataSources, userId, userRole}) => {
// the user needs to be logged in to create a listing
if (!userId) throw AuthenticationError();
if (userRole === 'Host') {
// hosts can create listings
} else {
// throw a ForbiddenError
}
};
In Router
We didn’t talk about super graph or federation in this blog, when fronting your subgraph with router, you can simply configure router to pass along the authorization header.
//router's config.yaml file.
headers:
all:
request:
- propagate:
named: "Authorization"
include_subgraph_errors:
all: true # Propagate errors from all subgraphs
No Authorization At All?
When we say Graphql layer is pass-through, does it mean anyone can call Graphql without any control? No, under the zero trust framework, we explicitly authorize every request. For example we could add API-wide authorization and anonymous access is denied.
server/index.js
context: ({ req }) => {
// get the user token from the headers
const token = req.headers.authorization || '';
// try to retrieve a user with the token
const user = getUser(token);
// optionally block the user
// we could also check user roles/permissions here
if (!user) throw new AuthenticationError('you must be logged in');
// add the user to the context
return { user };
},
Error Handling
Query
When queries fail, an error array is in the response. For partial success, it can still return some of the data you requested, both an error key and data key can be returned from the GraphQL server in one response.
You may customize error for unauthorized access, and add arbitrary fields to the error’s extensions
object to provide additional context to the client.
import { GraphQLError } from 'graphql';
const typeDefs = `#graphql
type Query {
userWithID(id: ID!): User
}
type User {
id: ID!
name: String!
}
`;
const resolvers = {
Query: {
// ...fetch correct user...
throw new GraphQLError('You are not authorized to perform this action.', {
extensions: {
code: 'FORBIDDEN',
},
},
},
};
You may also consider defining your own error const.
const { GraphQLError } = require('graphql');
const AuthenticationError = () => {
const authErrMessage = '*** you must be logged in ***';
return new GraphQLError(authErrMessage, {
extensions: {
code: 'UNAUTHENTICATED',
},
});
};
const ForbiddenError = (errMessage) => {
return new GraphQLError(errMessage, {
extensions: {
code: 'FORBIDDEN',
},
});
};
module.exports = { AuthenticationError, ForbiddenError };
You can then simply reference you const in other code block.
throw AuthenticationError();
For partial result, you may consider using nullable types.
Mutation
In mutation, you might also end up with failures. In the mutation resolver, you may add try catch logic, and return a success indicator.
incrementTrackViews: async (_, {id}, {dataSources}) => {
try {
const track = await dataSources.trackAPI.incrementTrackViews(id);
return {
code: 200,
success: true,
message: `Successfully incremented number of views for track ${id}`,
track
};
} catch (err) {
return {
code: err.extensions.response.status,
success: false,
message: err.extensions.response.body,
track: null
};
}
},
Mutation can be partial success as well. In the following example, the bookTrips resolver returns the list of LaunchIds which are not booked.
//Mutation: {
// login: ...
bookTrips: async (_, { launchIds }, { dataSources }) => {
const results = await dataSources.userAPI.bookTrips({ launchIds });
const launches = await dataSources.launchAPI.getLaunchesByIds({
launchIds,
});
return {
success: results && results.length === launchIds.length,
message:
results.length === launchIds.length
? 'trips booked successfully'
: `the following launches couldn't be booked: ${launchIds.filter(
id => !results.includes(id),
)}`,
launches,
};
},
Appendix — What’s Authentication
According to the NIST Authentication and Lifecycle Management, digital authentication is the process of determining the validity of one or more authenticators used to claim a digital identity.
List of Authenticators:
- Memorized Secret (Section 5.1.1)
- Look-Up Secret (Section 5.1.2)
- Out-of-Band Devices (Section 5.1.3)
- Single-Factor One-Time Password (OTP) Device (Section 5.1.4)
- Multi-Factor OTP Device (Section 5.1.5)
- Single-Factor Cryptographic Software (Section 5.1.6)
- Single-Factor Cryptographic Device (Section 5.1.7)
- Multi-Factor Cryptographic Software (Section 5.1.8)
- Multi-Factor Cryptographic Device (Section 5.1.9)
References
Apollo auth: “https://www.apollographql.com/docs/apollo-server/security/authentication/#authorization-methods”
client side: “https://www.apollographql.com/docs/react/networking/authentication/”
- One way clients can authenticate users is by passing an HTTP
Authorization
request header with the GraphQL operations it sends to the server. - Authentication logic can be written within the
context
property of theApolloServer
constructor, so that user information will be available to every resolver.
OPA Integration: “https://www.openpolicyagent.org/docs/latest/graphql-api-authorization/”
“https://github.com/StyraInc/graphql-apollo-example”
AWS 2019 sutff: https://aws.amazon.com/blogs/architecture/things-to-consider-when-you-build-a-graphql-api-with-aws-appsync/
2020 Graphql summit: “https://youtu.be/dBuU61ABEDs”
“https://github.com/apollographql/apollo-server/issues/2631”
“https://www.apollographql.com/docs/apollo-server/data/fetching-rest”
“https://www.apollographql.com/tutorials/lift-off-part4/resolving-a-mutation-with-errors”