[PYTHON] Implement Custom Authorizer for Firebase Authentication in Chalice

Introduction

When you access your Lambda function via API Gateway, you may want to control that access. For example, the following cases can be considered.

--I want to make Lambda executable only if I know a specific token that I shared in advance --When you pass a token obtained from the outside by an external IDaaS service (Auth0, Firebase Authentication, etc.) to the API, you want to verify the token and make Lambda executable if it is correct.

To solve this problem, when calling a Lambda function via API Gateway, a mechanism is in place so that validation can be performed immediately before the request. This is called the Lambda Authorizer.

The figure in Lambda authorizer description page is quoted below. By calling the Lambda Auth Function in this figure before the Lambda function (which you want to restrict access to) and approving it, you can restrict access to the latter function without writing a particularly large access control.

custom-auth-workflow.png

If you want to use an existing Lambda function as a Lambda Authorizer, chalice calls it a Custom Authorizer. here,

--Create a Lambda Auth Function using Chalice to authenticate your Firebase Authentication JWT token --Use the created Lambda Auth Function as a CustomAuthorizer from another Chalice project

Two methods will be described.

Build-in Authorizer Lambda Auth Function for Firebase Authentication

Advance preparation

--You have already created a user, and you can get a JWT token at the client site. --Use the JWT token obtained by ʻuser.getIdToken (true) in the ʻonAuthStateChanged event. - https://firebase.google.com/docs/auth/web/manage-users?hl=ja --You have a json file (= private key) for firebase-admin - https://firebase.google.com/docs/admin/setup?hl=ja --You can get the json file by pressing the ** "Generate new private key" button from within the service account ** ――Honestly, it took me a long time to understand this because the private key and the json file are not linked at all. What happened to the name here ...

Chalice settings

Create one project for Lambda Auth Function. I will omit the installation etc., but here you should read it as if you installed chalice in a place that can be used globally (you can replace it with the state where virtualenv is activated).

#Create a new project
$ chalice new-project authorizer
$ cd authorizer

# firebase-Put admin in vendor and don't build locally on deploy
#In my environment it took 7 minutes to deploy, so I'm doing it to save time
$ mkdir vendor
$ pip install firebase-admin -t vendor

# firebase-Place the json file for admin
#Fixed files will not be uploaded unless they are placed in chalicelib
#Lambda goes up to S3, so if you are uncertain, it is better to encrypt and decrypt with MKS
$ mkdir chalicelib
$ cp "<firebase-admin settings json>" chalicelib/firebase-adminsdk-dev.json

Main files etc.


authorizer
├── app.py
├── .chalice
│   └── config.json
├── chalicelib
│   └── firebase-adminsdk-dev.json
└── vendor
    └── (Lots of installed)

Now, write the main code as follows. This time we will deploy with stage = dev, but rewrite as needed.

json:.chalice/config.json


{
  "version": "2.0",
  "app_name": "authorizer",
  "stages": {
    "dev": {
      "api_gateway_stage": "api",
      "environment_variables": {
        "FIREBASE_CONFIGFILE": "firebase-adminsdk-dev.json"
      }
    }
  }
}

app.py


#!/usr/bin/python
# -*- coding: utf-8 -*-

import os
import logging

from chalice import Chalice, AuthResponse

import firebase_admin
import firebase_admin.auth as firebase_auth

logger = logging.getLogger()
app = Chalice(app_name='authorizer')

#Load the settings and firebase-Initialize admin
firebase_cred = firebase_admin.credentials.Certificate(
    os.path.join(
        os.path.dirname(__file__), 'chalicelib', 
        os.environ['FIREBASE_CONFIGFILE']))
firebase_admin.initialize_app(firebase_cred)

@app.authorizer()
def authorizer(auth_request):
    '''
The entity of the custom authorizer.
Specify Lambda for this entity from another Chalice Project.
    '''
    #Pass the JWT token obtained from Firebase Auth in the Authorization header
    # curl -s '<API URL>' -H 'Authorization: <JWT Token>' | jq . 
    try:
        jwt_token = auth_request.token
        crimes = firebase_auth.verify_id_token(jwt_token)
        context = dict(uid=crimes['uid'])
        return AuthResponse(routes=['*'], principal_id=crimes['uid'], context=context)
    except Exception as e:
        logger.exception(e)
        return AuthResponse(routes=[], principal_id='deny')


@app.route('/', authorizer=authorizer)
def index():
    '''
For verification and for deploying the authorizer.
If there is no more than one route, the custom authorizer will not be deployed either.
    '''
    return { 'AuthContext': app.current_request.context }

Create a function that processes the JWT token with the @ app.authorizer () decorator and returns ʻAuthResponsedepending on the result. In AuthResponse, create "route accessible by the corresponding token (API Gateway level path)",principal_id that uniquely identifies the user, and context` that you want to acquire additionally at the time of authentication and pass it to the subsequent function. And include these.

For details on what kind of AuthResponse should be created and returned, refer to the following documents.

Also note that ** @ app.authorizer () requires parentheses **. Without parentheses, it becomes a different thing and does not work well.

Deploy

This time deploy with chalice deploy. If you want, you can do a chalice package and then throw it into CloudFormation for deployment.

#Test if it works locally
# @app.authorizer()Only in the case of, it also works locally
#Note that other Authorizers do not work locally
$ chalice local 

#Deploy to AWS
$ chalice deploy --profile chalice
Creating deployment package.
Creating IAM role: authorizer-dev-api_handler
Creating lambda function: authorizer-dev
Creating IAM role: authorizer-dev-authorizer
Creating lambda function: authorizer-dev-authorizer
Creating Rest API
Resources deployed:
  - Lambda ARN: arn:aws:lambda:ap-northeast-1:************:function:authorizer-dev
  - Lambda ARN: arn:aws:lambda:ap-northeast-1:************:function:authorizer-dev-authorizer
  - Rest API URL: https://**********.execute-api.ap-northeast-1.amazonaws.com/api/

So that's all the deployment work.

Testing

Check if you can actually access it. It's not reproducible, but sometimes it's not accessible for a while after deploying, so if that happens (= `` is returned), please wait a moment and try again.

#No Authorization header
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/ | jq .
{
  "message": "Unauthorized"
}

#If authentication fails routes=[]And the endpoint/Cannot access
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/ -H 'Authorization: hoge'| jq .
{
  "Message": "User is not authorized to access this resource"
}

#If you pass a JWT token, you can access it normally
# AuthContext.The value passed in context comes out to authorizer
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/ -H 'Authorization: <Firebase Auth JWT Token>' | jq .
{
  "AuthContext": {
    "resourceId": "........",
    "authorizer": {
      "uid": "**********",
      "principalId": "**********",
      "integrationLatency": 190
    },
    "resourcePath": "/",
    "httpMethod": "GET",
    "extendedRequestId": "*************",
    "requestTime": "07/May/2020:00:33:11 +0000",
    "path": "/api/",
    ... (Omission) ...
  }
}

As you can see, we were able to implement Authorizer in a very easy way.

Firebase Cost and Authorization Caching

If nothing is set, the Authorizer function will be called each time Lambda is accessed.

** All authentication other than Firebase Authorization phone authentication is free according to the price list **, but it is a little to call Lambda There is a charge.

Therefore, the cache of the authentication result can be used for a certain period of time. This can also be seen in "Policy is cached" in the first figure.

custom-auth-workflow.png

To enable the cache, specify ttl_seconds as follows: However, please note that the setting here is the setting in API Gateway, so it is not related to CustomAuthorizer described later.

Excerpt from authorizer implementation


@app.authorizer(ttl_seconds=120)
def authorizer(auth_request):
    ....

Build-in Authorizer vs Customer Authorizer

Writing your own logic in one project and performing authentication is called Build-in Authorizer in Chalice. On the other hand, instead you can use the Authorizer prepared by Chalice. These include IAMAuthorizer, CognitoUserPoolAuthorizer, and CustomAuthorizer, which authenticate using existing AWS resources. Of these, CustomAuthorizer is an Authorizer for using existing Lambda functions as Authorizers.

If you want to complete it as a simple task, you can use Build-in Authorier, but the firebase-admin library alone has a capacity of nearly 10MB (about 20MB when a binary build runs). I don't want to consume Lambda's capacity for a library that is used only in one place for authentication, so let's consider cutting out this part as a CustomAuthorizer as another Lambda function.

As I wrote in the comment of ʻapp.py, the Lambda function is enough to proceed from here. However, even if you write only @ app.authorier, it will not be deployed, so if it is true, it is better to adopt the method of creating a chalice package`, deleting unnecessary resources, and then deploying only the function. It may be good.

Another Chalice Project with CustomAuthorizer

Chalice settings

Make a note of the arn of the authorizer function created in the ʻauthorizer project you created earlier. In this case, I think it looks like ʻarn: aws: lambda: <region>: <aws-account-no>: function: authorizer-dev-authorizer.

Main files etc.


sample
└── app.py

app.py


#!/usr/bin/python
# -*- coding: utf-8 -*-

import os

from chalice import Chalice, CustomAuthorizer

app = Chalice(app_name='sample')

# authorizer_The uri part is.chalice/config.You may cut out to json
region = 'ap-northeast-1'
lambda_arn = '<ARN of the authorizer function created earlier>'
authorizer_uri = f'arn:aws:apigateway:{region}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations'

# ttl_If there is no seconds, it defaults to a 300 second cache
authorizer = CustomAuthorizer(
    'FirebaseAuthorizer', 
    ttl_seconds=60,
    authorizer_uri=authorizer_uri)


@app.route('/private', authorizer=authorizer)
def private_function():
    return {'RequestContext': app.current_request.context}


@app.route('/public')
def public_function():
    return {'message': 'success'}

Create a CustomAuthorizer instance as described above. You can enter any name for the first argument. authorizer_uri specifies the Lambda function to call as Authorizer in the above format.

The URI of the lambda function to use for the custom authorizer. This usually has the form arn:aws:apigateway:{region}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations. Quote-https://chalice.readthedocs.io/en/latest/api.html#CustomAuthorizer.authorizer_uri

Run it locally with the chalice local command to test it, but note that it is designed not to work locally except for Build-in Authorizer. If you want to operate a specific user in the local environment, you can check the execution status by environment_variables of stage and inject the authorizer for local.

#Can be run normally without Authorizer
$ curl -s http://localhost:8000/public/ | jq . 
{
  "message": "success"
}

#CustomAuthorizer cannot be implemented locally
$ curl -s http://localhost:8000/private/ | jq . 
{
  "RequestContext": {
    "httpMethod": "GET",
    "resourcePath": "/private",
    "identity": {
      "sourceIp": "127.0.0.1"
    },
    "path": "/private/"
  }
}

#A message similar to the following is displayed in chalice local
# UserWarning: CustomAuthorizer is not a supported in local mode. All requests made against a route will be authorized to allow local testing.

Testing

After deploying with chalice deploy --profile chalice, I check in order as before, but the behavior becomes strange from the moment I insert the ʻAuthorizationheader. Here you should see"Message ":" User is not authorized to access this resource "`.

#You can access the public
$ curl -s https://********.execute-api.ap-northeast-1.amazonaws.com/api/public/ | jq .
{
  "message": "success"
}

#Private cannot be accessed without authentication
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/private/ | jq .
{
  "message": "Unauthorized"
}

# !?!?
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/private/ -H 'Authorization: hoge'| jq .
{
  "message": null
}

Run Custom Authorizer

Looking into this, the Chalice formula reports a similar problem.

Authorizer won't work on after deployment. - https://github.com/aws/chalice/issues/670

The solution says, "Open API Gateway in Management Console and overwrite the authorizer to use it." In fact, this will allow you to use it.

The reason is that the deployment using CustomAuthorizer alone cannot give ** a resource-based policy ** to call an existing Authorizer Lambda from the default API Gateway (Lambda specified by CustomAuthorizer is the current Chalice project). It's a completely unrelated resource, so it's certainly not good to make changes to that resource). If you overwrite the authorizer by the above procedure, you can automatically grant the Lambda resource-based policy. However, please note that if you change the name of the authorizer, "Delete ⇒ Generate" will be performed and the ID of the authorizer will be changed to another one, so the authority given to the existing Lambda will be lost. If you do not change the name, the ID will not change.

When not using Management Console ʻaws lambda add-permission` etc. allows existing functions to be called from API Gateway so that they can be called normally.

add-An example of permission


$ aws lambda add-permission \
    --function-name authorizer-dev-authorizer \
    --action lambda:InvokeFunction \
    --statement-id <Enter the appropriate UID> \
    --principal apigateway.amazonaws.com

However, in the case of this example, calls from any API Gateway are allowed, so if you want to be more strict, please specify Condition.

Re-Testing

#You can access the public
$ curl -s https://********.execute-api.ap-northeast-1.amazonaws.com/api/public/ | jq .
{
  "message": "success"
}

#Private cannot be accessed without authentication
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/private/ | jq .
{
  "message": "Unauthorized"
}

#If authentication fails routes=[]And the endpoint/Cannot access
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/private/ -H 'Authorization: hoge'| jq .
{
  "Message": "User is not authorized to access this resource"
}

#If you pass a JWT token, you can access it normally
# AuthContext.The value passed in context comes out to authorizer
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/private/ -H 'Authorization: <Firebase Auth JWT Token>' | jq .
{
  "AuthContext": {
    "resourceId": "........",
    "authorizer": {
      "uid": "**********",
      "principalId": "**********",
      "integrationLatency": 153
    },
    "resourcePath": "/private",
    "httpMethod": "GET",
    "extendedRequestId": "**************",
    "requestTime": "07/May/2020:02:12:29 +0000",
    "path": "/api/private/",
    ... (Omission) ...
  }
}

Summary

There are some pitfalls in setting permissions when using CustomAuthorizer, but otherwise I was able to work with Firebase Authentication with very short code.

Since I use Firebase, I think there will be no trouble if it is completed with Firebase in the first place. Or, if you use AWS, why not use Cognito? It's not surprising, but I hope it will be helpful for people like me who want to do it on AWS, and want to use Firebase Authentication, which provides a free UI other than phone authentication, as the basis of IDaaS. is.

Recommended Posts

Implement Custom Authorizer for Firebase Authentication in Chalice
Implement DeepChem's GraphConvLayer in PyTorch's custom layer
Implement a custom View Decorator in Pyramid
Implement DeepChem's GraphGatherLayer in PyTorch's custom layer
Implement a Custom User Model in Django
[Implementation for learning] Implement Stratified Sampling in Python (1)
[Fast API + Firebase] Build an API server for Bearer authentication