Secure AWS Lambda with Google email domain check — Part 1
How to secure AWS Lambda endpoint with Google authentication and making sure only specific email domain will have access to it?
We need to implement simple web application with couple of requirements:
- Application will have login and dashboard screens. After user succesfully logs in there is redirection to dashboard which will fetch some secret data using our endpoint
- REST endpoints will not be used very often so setting up
EC2
machine with server constantly running would be overkill ($$$) - Client can use our REST API only if user is authenticated with email matching
mycompany.com
domain
We will go with AWS Lambda
bundled with custom authorizer
for validing token coming from login process. In addition we will use serverless
for bundling and deployment. Our language choose will be Typescript
for both lambda and client.
Architecture
We need to make sure that client will use Google sign in using email with preferred domain and then call our lambda function using AWS API Gateway
passing Authorization
header:
Before reaching to any secured data we will use custom authorizer which will check if token is associated with email matching our preferred domain:
Finally we can take a look at complete client and lambdas endpoints flow:
Ok, it should be more or less clear, we can…
Start coding!
We will implement two parts of a project — our BE and FE. We will use Typescript
on both. Let's start with our API so we won't block our FE team.
Lambdas
We need three main parts on our serverless back end:
- Authorizer for email domain verification
- Secret data endpoint secured with authorizer
- Helper endpoint for validating if token is valid (so after logging in client can check if there is point in moving user to dashboard)
Authorizer
We will use google-auth-library
for email domain verification, let's start by creating verify
helper method:
const client = new OAuth2Client(CLIENT_ID);
const acceptedDomains = [COMPANY_DOMAIN];
async function verify(token?: string) {
if (!token) {
throw Error("Missing auth token");
}
const ticket = await client.verifyIdToken({
idToken: token,
audience: CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload || !payload.hd) {
throw Error("Missing payload");
}
if (acceptedDomains.includes(payload.hd)) {
return true;
} else {
throw Error("Invalid Google Domain");
}
}
You might notice we are passing CLIENT_ID
for our OAuth2Client
.
In order to create it you need to log to developer console and click through those steps:
- Credentials
- Create Credentials
- OAuth client ID ((might ask you to setup application name first))
- Select Web application
- Put name of your key
- Set authorized origins for debugging purposes (e.g.
http://localhost:3000
)
We will pass both CLIENT_ID
and COMPANY_DOMAIN
via env variable.
You are probably asking yourself what is that weird payload.hd
line.
hd
stands for hosted domain and it is string containing email domain. Once we get it we simply check if it matches our requirements. In any other case we are throwing an Error
.
Once we have our verify
function we can add authorizer
and verifyToken
endpoint:
export const authorizationHandler = async (
event: APIGatewayTokenAuthorizerEvent,
_
) => {
try {
await verify(event.authorizationToken);
return generateAllow("*");
} catch (exception) {
console.error(exception, exception.stack);
return generateDeny(event.methodArn);
}
};
export const verifyTokenHandler = async (
event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
const headers = event.headers || {};
try {
await verify(headers["Authorization"]);
return {
statusCode: 200,
body: "Success",
};
} catch (exception) {
return {
statusCode: 403,
body: "Failure",
};
}
};
authorizationHandler
generates allow
policy for all (thus "*"
) resources, because of the way AWS
caches policy response (based on Authorization
header). If we used event.methodArn
client would be able to execute only on response while getting 403
for all further ones (because of arn
mismatch). We could generate something more strict here, but for now let's keep it simple.
verifyTokenHandler
returns statusCode
according to sent token:
200
if it is valid (domain matches our organization/company)403
if it is invalid
Secured endpoint
Finally, we can implement our dummy secret data endpoint:
const secretData = ["Super Secret String 1", "Super Secret String 2"];
export const getData = async (
event: APIGatewayEvent
): Promise<APIGatewayProxyResult> => {
return {
statusCode: 200,
body: JSON.stringify(secretData),
};
};
Putting all together
Now we can add our lambdas declarations to serverless.yml
file:
functions:
authorizerFunc:
handler: src/auth/controller.authorizationHandler
verify-token:
handler: src/auth/controller.verifyTokenHandler
events:
- http:
path: auth/verify
method: get
cors: true
get-data:
handler: src/data/controller.getData
events:
- http:
path: data
method: get
cors: true
authorizer: authorizerFunc
We expose two endpoints:
GET /data
which points togetData
handler, secured by our custom authorizerGET /auth/verify
which points toverifyTokenHandler
Let’s run our lambdas locally:
serverless offline
You should see them running on localhost
:
Calling /dev/data
should throw 403
status error because of missing authorization header:
$ curl http://localhost:3000/dev/data
{"statusCode":403,"error":"Forbidden","message":"User is not authorized to access this resource"}
Huh, that was pretty easy, right?
We could start playing with our endpoints, but let’s do that by implementing front end part. This is part 2 of our article.