ChaliceでFirebase AuthenticationのためのCustomAuthorizerを実装する

# はじめに

API Gateway経由でLambda関数にアクセスする場合、そのアクセスを制御したい場合がある。
例えば、以下のようなケースが考えられる。

- 事前に共有した特定のトークンを知っている場合のみ Lambda を実行可能にしたい
- 外部のIDaaS系のサービス (Auth0, Firebase Authenticationなど) で外部から取得したトークンをAPIに渡した場合、そのトークンを検証して正しければ Lambda を実行可能にしたい。

このような問題を解消するため、API Gateway を経由して Lambda 関数を呼び出す場合には、そのリクエストの直前に検証が行えるような仕組みが整えられている。 これを Lambda オーソライザ (Authorizer) という。

以下に [Lambda オーソライザの説明ページ](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html) 内の図を引用する。
この図の Lambda Auth Function を（アクセスを制限したい）Lambda 関数の前に呼び出して承認を行うことで、後者の関数に特に大きなアクセス制御を記述することなくアクセス制限を行うことができるようになる。

![custom-auth-workflow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/0c5c80a3-1dd7-c1c2-5e68-a94a0800d56c.png)

既に存在するLambda関数を Lambda Authorizerとして利用する場合、これを chalice では CustomAuthorizer と呼んでいる。
ここでは、

- Firebase Authentication の JWTトークンを認証する Lambda Auth Function を Chalice を使って作成する
- 作成した Lambda Auth Function を別の Chalice プロジェクトから CustomAuthorizer として利用する

２つの方法について説明する。


# Build-in Authorizer Lambda Auth Function for Firebase Authentication

## 事前準備

- ユーザー作成などは既に行っており、クライアントサイトでJWTトークンを取得できること
    - `onAuthStateChanged` イベント内で `user.getIdToken(true)` で取得した JWT トークンを利用する
    - https://firebase.google.com/docs/auth/web/manage-users?hl=ja
- firebase-admin 用の json ファイル(=秘密鍵) を入手していること
    - https://firebase.google.com/docs/admin/setup?hl=ja
    - サービスアカウント内から**「新しい秘密鍵を生成」のボタンを押すと json ファイルが取得できる**
        - 正直、秘密鍵とjsonファイルが全く結びつかないのでこれを理解するまでかなり時間がかかった。 ここの名称どうにかならなかったのか…

## Chalice の設定

Lambda Auth Function 用に１プロジェクトを作成する。
インストールなどは省きますが、ここではグローバルで使えるところに chalice をインストールしたものとして読んで下さい (virtualenv を activate した状態、と置き換えてもらってもよいです)。 

```bash
# 新しいプロジェクトを作成
$ chalice new-project authorizer
$ cd authorizer

# firebase-admin を vendor に入れて deploy 時にローカルでビルドしないように
# 自分の環境では deploy に 7分とかかかったので、時短のためにやってます
$ mkdir vendor
$ pip install firebase-admin -t vendor

# firebase-admin 用の json ファイルを配置
# 固定ファイルは chalicelib 内に配置しないとアップロードされない
# LambdaはS3に上がるため、不安な場合は MKS で暗号化・複合化すればより良い
$ mkdir chalicelib
$ cp "<firebase-adminの設定json>" chalicelib/firebase-adminsdk-dev.json
```

```bash:主要なファイルなど
authorizer
├── app.py
├── .chalice
│   └── config.json
├── chalicelib
│   └── firebase-adminsdk-dev.json
└── vendor
    └── (インストールされたものたくさん)
```

これで、メインコードは以下のように書く。
今回は stage = dev でデプロイするが、必要に応じて書き換えること。

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

```

```python: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')

# 設定を読み込んで、firebase-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):
    '''
    カスタムオーソライザの実体。
    別のChalice Projectからこの実体のLambdaを指定する。
    '''
    # Authorization ヘッダで Firebase Auth から取得したJWTトークンを渡す
    # 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():
    '''
    検証用かつオーソライザのデプロイ用。
    routeが1つ以上ないと、カスタムオーソライザもデプロイされない。
    '''
    return { 'AuthContext': app.current_request.context }
```

`@app.authorizer()` デコレータでJWTトークンを処理し、その結果に応じて `AuthResponse` を返す関数を作成する。
AuthResponse では「該当のトークンによってアクセス可能なルート(API Gatewayレベルのパス)」と、ユーザーを一意に識別する `principal_id`、更に認証時に追加で取得して後続の関数に引き渡したい `context` を作成して、これらを含める。

どのような AuthResponse を作って返せばいいかの詳細は、以下のドキュメントなどを参照。

- https://chalice.readthedocs.io/en/latest/api.html#built-in-authorizers
- https://github.com/aws/chalice/blob/master/docs/source/topics/authorizers.rst#built-in-authorizers


また、**`@app.authorizer()` に括弧が必要な点には注意**。 括弧がないと別物になってうまく動かない。


## Deploy

今回は `chalice deploy` でデプロイ。
必要なら `chalice package` をしてから CloudFormation に投げてデプロイしても良い。

```bash
# ローカルでうまく動くかテスト
# @app.authorizer() の場合のみ、localでも動く
# それ以外の Authorizer はローカルでは動かない点に注意
$ chalice local 

# 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/
```

というわけで、デプロイ作業はこれだけです。

## Testing

実際にアクセスできるかチェックします。
再現性がないですが、デプロイしてしばらくアクセスがうまくいかないことがあったので、もしそうなった場合 (= `` が返ってくる場合) はちょっと時間を置いてから試してみて下さい。

```bash
# Authorization ヘッダなし
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/ | jq .
{
  "message": "Unauthorized"
}

# 認証に失敗した場合 routes=[] となり、エンドポイント / にアクセスできない
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/ -H 'Authorization: hoge'| jq .
{
  "Message": "User is not authorized to access this resource"
}

# JWTトークンを渡した場合は正常にアクセスできる
# AuthContext.authorizer に context で渡した値が出てくる
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/ -H 'Authorization: <Firebase Auth の JWTトークン>' | jq .
{
  "AuthContext": {
    "resourceId": "........",
    "authorizer": {
      "uid": "**********",
      "principalId": "**********",
      "integrationLatency": 190
    },
    "resourcePath": "/",
    "httpMethod": "GET",
    "extendedRequestId": "*************",
    "requestTime": "07/May/2020:00:33:11 +0000",
    "path": "/api/",
    ... (後略) ...
  }
}

```

このように、非常に簡単な方法で Authorizer を実装できました。

## Firebase Cost and Authorization Caching 

何も設定しない場合、Lambdaのアクセスの都度 Authorizer 関数が呼び出される。

**[Firebase Authorization の電話認証以外の認証については料金表によれば全て無料](https://firebase.google.com/pricing/?hl=ja)** ですが、Lambdaを呼び出すにも多少料金はかかります。

そこで、一定期間認証結果のキャッシュを利用することができる。 これは最初にしめした図の「Policy is cached」でも確認できます。

![custom-auth-workflow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/0c5c80a3-1dd7-c1c2-5e68-a94a0800d56c.png)

キャッシュを有効にするには以下のように `ttl_seconds` を指定します。
が、ここでの設定は API Gateway での設定なので、後述する CustomAuthorizer とは無関係な設定となる点に注意してください。

```python:authorizerの実装を抜粋
@app.authorizer(ttl_seconds=120)
def authorizer(auth_request):
    ....
```

## Build-in Authorizer vs Customer Authorizer

１つのプロジェクトの中に独自ロジックを書き、認証を行うことを Chalice では Build-in Authorizer と呼びます。 
一方、そうではなくChaliceで準備されているAuthorizerを利用できます。 これには、IAMAuthorizer, CognitoUserPoolAuthorizer, CustomAuthorizer があり、これらは既存のAWSリソースを使って認証を行うものです。 この中で `CustomAuthorizer` は既に存在する Lambda 関数を Authorizer として利用するための Authorizer です。
 
単純な作業として完結させる場合は Build-in Authorier を使うので良いのですが、firebase-admin のライブラリだけで10MB近くの容量(バイナリビルドが走った場合20MBぐらい)になっています。
認証の１か所にしか使わないライブラリに対してLambdaの容量を喰いたくないので、この部分を CustomAuthorizer として別のLambda関数として切り出すことを考えてみます。

なお、`app.py` のコメントにも書いていますが、ここから先に進むには Lambda 関数さえあれば十分です。 ただ、`@app.authorier` のみを書いていてもデプロイされないので、本当であれば `chalice package` を作った後に不要なリソースを消してから関数だけをデプロイする、という方法を採った方がよいかもしれません。


# Another Chalice Project with CustomAuthorizer

## Chaliceの設定

前に作成した `authorizer` プロジェクトで作成された authorizer 関数の arn を記録しておきます。 今回の場合、`arn:aws:lambda:<region>:<aws-account-no>:function:authorizer-dev-authorizer` のようになっていると思います。

```bash:主要なファイルなど
sample
└── app.py
```

```python:app.py
#!/usr/bin/python
# -*- coding: utf-8 -*-

import os

from chalice import Chalice, CustomAuthorizer

app = Chalice(app_name='sample')

# authorizer_uriの部分は.chalice/config.jsonに切り出しても良い
region = 'ap-northeast-1'
lambda_arn = '<先に作成した authorizer 関数のARN>'
authorizer_uri = f'arn:aws:apigateway:{region}:lambda:path/2015-03-31/functions/{lambda_arn}/invocations'

# ttl_seconds がない場合はデフォルトで300秒のキャッシュになる
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'}
```


上記のように CustomAuthorizer インスタンスを生成します。
第一引数は名前で任意のものを入力できます。 [authorizer_uri は Authorizer として呼び出すLambda関数を上記の形式で指定します](https://chalice.readthedocs.io/en/latest/api.html#CustomAuthorizer.authorizer_uri)。

> 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.
> 引用 - https://chalice.readthedocs.io/en/latest/api.html#CustomAuthorizer.authorizer_uri

`chalice local` コマンドでローカル実行してテストするが、Build-in Authorizer 以外はローカルでは動作しないように設計されているので注意。
local環境で特定ユーザーの動作をさせたい場合などは stage の environment_variables によって実行状況を確認し、local 用の authorizer を注入するといった方法が採れます。

```bash
# Authorizer なしなら普通に実行できる
$ curl -s http://localhost:8000/public/ | jq . 
{
  "message": "success"
}

# CustomAuthorizer はローカルでは実施不可
$ curl -s http://localhost:8000/private/ | jq . 
{
  "RequestContext": {
    "httpMethod": "GET",
    "resourcePath": "/private",
    "identity": {
      "sourceIp": "127.0.0.1"
    },
    "path": "/private/"
  }
}

# 以下のようなメッセージが 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

`chalice deploy --profile chalice` でデプロイした後、先の場合と同様に順番にチェックしていくが、`Authorization` ヘッダを入れた瞬間からどうも挙動がおかしくなります。 ここは `"Message": "User is not authorized to access this resource"` が表示されなければいけません。

```bash
# Public にはアクセスできる
$ curl -s https://********.execute-api.ap-northeast-1.amazonaws.com/api/public/ | jq .
{
  "message": "success"
}

# Private に認証がなければアクセスできない
$ 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
}
```


## CustomAuthorizerを動かす

これについて調べていると、Chalice公式でも同様の問題が報告されています。

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

解決法には「Management ConsoleでAPI Gatewayを開いて、オーソライザを上書き保存すれば使えるようになるよ」と書いてある。 実際、これをすると使えるようになります。

何故かというと、CustomAuthorizerを使うというデプロイだけでは **既存のAuthorizer Lambdaを規定のAPI Gatewayから呼び出すためのリソースベースのポリシー** を付与できないためです（CustomAuthorizerで指定するLambdaは現在のChaliceプロジェクトとは全く関係ないリソースなので、そのリソースに変化を加えることは確かに良くない）。 
上記の手順でオーソライザを上書き保存すると、自動的にLambdaのリソースベースポリシーを付与することができます。 ただ、オーソライザの名前を変えると「削除⇒生成」を行ってオーソライザのIDが別物に変わるので、既存のLambdaに与えた権限がきかなくなってしまう点には注意してください。 名前を変えなければIDは変わりません。

Management Consoleを利用しない場合 `aws lambda add-permission` などで既存の関数にAPI Gatewayからの呼び出しを許可すれば正常に呼び出せるようになります。

```bash:add-permissionの一例
$ aws lambda add-permission \
    --function-name authorizer-dev-authorizer \
    --action lambda:InvokeFunction \
    --statement-id <適当なUIDを入力> \
    --principal apigateway.amazonaws.com
```

ただ、この一例の場合は任意のAPI Gatewayからの呼び出しが許可されるため、より厳密する場合は Condition を明記してください。

## Re-Testing

```bash
# Public にはアクセスできる
$ curl -s https://********.execute-api.ap-northeast-1.amazonaws.com/api/public/ | jq .
{
  "message": "success"
}

# Private に認証がなければアクセスできない
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/private/ | jq .
{
  "message": "Unauthorized"
}

# 認証に失敗した場合 routes=[] となり、エンドポイント / にアクセスできない
$ 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"
}

# JWTトークンを渡した場合は正常にアクセスできる
# AuthContext.authorizer に context で渡した値が出てくる
$ curl -s https://**********.execute-api.ap-northeast-1.amazonaws.com/api/private/ -H 'Authorization: <Firebase Auth の JWTトークン>' | jq .
{
  "AuthContext": {
    "resourceId": "........",
    "authorizer": {
      "uid": "**********",
      "principalId": "**********",
      "integrationLatency": 153
    },
    "resourcePath": "/private",
    "httpMethod": "GET",
    "extendedRequestId": "**************",
    "requestTime": "07/May/2020:02:12:29 +0000",
    "path": "/api/private/",
    ... (後略) ...
  }
}
```

# まとめ

CustomAuthorizerを使うときの権限設定に落とし穴がありますが、それ以外は非常に短いコードで Firebase Authentication と連携することができました。

Firebase使うんだからそもそもFirebaseで完結させれば苦労はないのでは？ とか、AWSならCognito使えば？ とか、思わなくもないですが、「基本はAWSでやりたい」「IDaaSの基盤として、電話認証以外無料でUIも提供しているFirebase Authenticationを使いたい」という自分みたいな方の参考になれば幸いです。