API Gateway の Lambda オーソライザーをやってみた

# はじめに

API Gateway を使うとインターネット上に REST API を公開できます。インターネット上に公開する際に、特定のユーザーやシステムにのみアクセスを制限させたい場合があります。そういったときには、API Gateway の認証機能が便利に使えます。

API Gateway の認証機能にはいくつか種類があります。

- リソースポリシー
- IAM アクセス許可
- Lambda オーソライザー
- Cognito オーソライザー

それぞれの詳細が気になる方は、次の AWS Document をチェックしてみてください。
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-control-access-to-api.html

今回は、Lambda オーソライザーを触っていきたいと思います。Lambda オーソライザーを使うことで、API Gateway に独自の認証機能を付与することが出来ます。

Lambda オーソライザーのざっくりイメージはこんな感じです。

![image-20220604182004368.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/82802855-87a5-3ed8-f3ed-e54ba02155b8.png)



独自の認証ロジックを自分たちで作り上げることが出来るので、柔軟な認証機能を提供できます。Lambda としてプログラムが動くため、背後にある DynamoDB や RDS などのデータストアと連携も出来ます。

記事を作成するにあたり、参考 URL に記載させていただいている記事を参考にしました。(ありがとうございます!!!)

# SAM を使って作成

今回の記事では、AWS Serverless Application Model (SAM) を使って、API Gateway と Lambda オーソライザー、Lambda 関数を作っていきます。

`sam init` コマンドで、新しいプロジェクトを作成します。

```
sam init \
    --runtime python3.9 \
    --name Lambda-Authorizer-Sample \
    --app-template hello-world \
    --package-type Zip
```



SAM の `template.yaml` を指定します。ポイントは次の通りです

- API Gateway にある `Auth` で、Lambda オーソライザーに関する指定をする
- `AuthorizerFunction` で、Lambda オーソライザーを定義する。Python のファイル名を `authorizer` とする
- `HelloWorldFunction` で、呼び出したい Lambda 関数を定義する。Python のファイル名を `app` とする

```yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  Lambda-Authorizer-Sample

  Sample SAM Template for Lambda-Authorizer-Sample

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 30

Resources:
  MyApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: dev
      MethodSettings:
        - ResourcePath: /
          HttpMethod: GET
      Auth:
        DefaultAuthorizer: MyLambdaAuthorizer
        Authorizers:
          MyLambdaAuthorizer:
            FunctionArn: !GetAtt AuthorizerFunction.Arn
            Identity:
              ReauthorizeEvery: 0

  # Lambda Authorizer
  AuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: authorizer.lambda_handler
      Runtime: python3.9
      Timeout: 30

  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.9
      Architectures:
        - x86_64
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get
            RestApiId: !Ref MyApi

Outputs:
  HelloWorldApi:
    Value: !Sub "https://${MyApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
```



Lambda 関数の Python プログラムを次のように作成します。

![image-20220604183408666.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/24cee6ef-398f-c3a5-556c-9af2a0135835.png)



`app.py` 

- 実際に動すための Lambda 関数
- hello world を出力する単純なもの

```python
import json
from logging import getLogger, INFO

logger = getLogger(__name__)
logger.setLevel(INFO)


def lambda_handler(event, context):
    print("============ event の出力 ============")
    logger.info(json.dumps(event))

    return {
        'statusCode': 200,
        'body': json.dumps({
            'message': 'hello world',
        }),
    }
```



`authorizer.py`

- Lambda オーソライザー。ユーザーからのリクエストに含まれる HTTP ヘッダーからトークンを抜き出して、トークンが正当なものか確認する。
- HTTP ヘッダーに埋め込まれたトークンは、`event['authorizationToken']` で取得可能
- この Python プログラムでは、トークンが `abc` だったら正当なものと判断するようにハードコーディングされている。実際のプロダクトでは、データストアなどに問い合わせるようなロジックを入れても良い。
- `policyDocument` の `Effect` の文字列が `Allow` で Return すると、認証が通ったことを表現している。`Deny` は認証が通らなかったことを表現している。

```python
import json
from logging import getLogger, INFO

logger = getLogger(__name__)
logger.setLevel(INFO)


def lambda_handler(event, context):
    print("============ event の出力 ============")
    logger.info(json.dumps(event))

    token = event['authorizationToken']
    effect = 'Deny'
    if token == 'abc':
        effect = 'Allow'

    return {
        'principalId': '*',
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [
                {
                    'Action': 'execute-api:Invoke',
                    'Effect': effect,
                    'Resource': event['methodArn']
                }
            ]
        }
    }
```



それでは、SAM で Build を行った後に、AWS 上にでデプロイをします。

```
cd Lambda-Authorizer-Sample/
sam build
sam deploy --guided
```



SAM Stack の名前など、適当に指定します。

```
        Setting default arguments for 'sam deploy'
        =========================================
        Stack Name [sam-app]: lambda-authorizer-stack
        AWS Region [ap-northeast-1]: 
        #Shows you resources changes to be deployed and require a 'Y' to initiate deploy
        Confirm changes before deploy [y/N]: 
        #SAM needs permission to be able to create roles to connect to the resources in your template
        Allow SAM CLI IAM role creation [Y/n]: 
        #Preserves the state of previously provisioned resources when an operation fails
        Disable rollback [y/N]: 
        HelloWorldFunction may not have authorization defined, Is this okay? [y/N]: Y
        Save arguments to configuration file [Y/n]: 
        SAM configuration file [samconfig.toml]: 
        SAM configuration environment [default]: 
```



SAM Deploy の結果、API Gateway と、Lambda オーソライザー、Lambda 関数がデプロイされています。

API Gateway の Lambda Authorizer はこのような設定になっています。

- Token Source  が `Authorization ` となっている。API Gateway にリクエストするときに渡すトークンは、`Authorization ` のキーで渡す必要がある。

![image-20220604164920931.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1002774/8ec61e5f-a382-5306-5e2e-a372e5dd619b.png)



# Lambda オーソライザーの動作確認

まずは、なにもトークンを渡さない状態で、API Gateway を実行してみます。すると、`Unauthorized` と返ってきました。Lambda オーソライザーによって、アクセスが拒否されました。

```
> curl https://hyt7k1r2o7.execute-api.ap-northeast-1.amazonaws.com/dev/hello
{"message":"Unauthorized"}
```



次に、実際にリクエストでトークンを渡してみます。`--header 'authorization: abc'` が大事なポイントです。

次の実行結果のように、正常に Lambda 関数の実行が出来ました。

```
> curl https://hyt7k1r2o7.execute-api.ap-northeast-1.amazonaws.com/dev/hello \
      --header 'authorization: abc'
{"message": "hello world"}
```



このときに、Lambda オーソライザーの関数が受け取るイベントの中身は次の通りです。

- `"authorizationToken": "abc"` の部分で、トークンを受け取っていることがわかります。

```json
{
    "type": "TOKEN",
    "methodArn": "arn:aws:execute-api:ap-northeast-1:xxxxxxxxxxxx:hyt7k1r2o7/dev/GET/hello",
    "authorizationToken": "abc"
}
```



また、後続の Lambda 関数が受け取るイベントです。(Lambda オーソライザーの次に呼び出される Lambda 関数)

```json
{
    "resource": "/hello",
    "path": "/hello",
    "httpMethod": "GET",
    "headers": {
        "Accept": "*/*",
        "Authorization": "abc",
        "CloudFront-Forwarded-Proto": "https",
        "CloudFront-Is-Desktop-Viewer": "true",
        "CloudFront-Is-Mobile-Viewer": "false",
        "CloudFront-Is-SmartTV-Viewer": "false",
        "CloudFront-Is-Tablet-Viewer": "false",
        "CloudFront-Viewer-Country": "JP",
        "Host": "hyt7k1r2o7.execute-api.ap-northeast-1.amazonaws.com",
        "User-Agent": "curl/7.79.1",
        "Via": "2.0 f79910dd066cb79d5b224ab3f88841e4.cloudfront.net (CloudFront)",
        "X-Amz-Cf-Id": "xxBlqNZHhj7XXRVc7-QPmmnFoubexBFWcZMTtxc-c7krIaEeBzW6qQ==",
        "X-Amzn-Trace-Id": "Root=1-629b1028-35cb88da516a260f5063e428",
        "X-Forwarded-For": "35.75.xx.xx, 130.176.xx.xx",
        "X-Forwarded-Port": "443",
        "X-Forwarded-Proto": "https"
    },
    "multiValueHeaders": {
        "Accept": [
            "*/*"
        ],
        "Authorization": [
            "abc"
        ],
        "CloudFront-Forwarded-Proto": [
            "https"
        ],
        "CloudFront-Is-Desktop-Viewer": [
            "true"
        ],
        "CloudFront-Is-Mobile-Viewer": [
            "false"
        ],
        "CloudFront-Is-SmartTV-Viewer": [
            "false"
        ],
        "CloudFront-Is-Tablet-Viewer": [
            "false"
        ],
        "CloudFront-Viewer-Country": [
            "JP"
        ],
        "Host": [
            "hyt7k1r2o7.execute-api.ap-northeast-1.amazonaws.com"
        ],
        "User-Agent": [
            "curl/7.79.1"
        ],
        "Via": [
            "2.0 f79910dd066cb79d5b224ab3f88841e4.cloudfront.net (CloudFront)"
        ],
        "X-Amz-Cf-Id": [
            "xxBlqNZHhj7XXRVc7-QPmmnFoubexBFWcZMTtxc-c7krIaEeBzW6qQ=="
        ],
        "X-Amzn-Trace-Id": [
            "Root=1-629b1028-35cb88da516a260f5063e428"
        ],
        "X-Forwarded-For": [
            "35.75.xx.xx, 130.176.xx.xx"
        ],
        "X-Forwarded-Port": [
            "443"
        ],
        "X-Forwarded-Proto": [
            "https"
        ]
    },
    "queryStringParameters": null,
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "stageVariables": null,
    "requestContext": {
        "resourceId": "7zv8eo",
        "authorizer": {
            "principalId": "*",
            "integrationLatency": 259
        },
        "resourcePath": "/hello",
        "httpMethod": "GET",
        "extendedRequestId": "TL92UG8TtjMFy9w=",
        "requestTime": "04/Jun/2022:07:56:24 +0000",
        "path": "/dev/hello",
        "accountId": "xxxxxxxxxxxx",
        "protocol": "HTTP/1.1",
        "stage": "dev",
        "domainPrefix": "hyt7k1r2o7",
        "requestTimeEpoch": 1654329384284,
        "requestId": "0854995a-b772-4645-a725-462e6e93e2ee",
        "identity": {
            "cognitoIdentityPoolId": null,
            "accountId": null,
            "cognitoIdentityId": null,
            "caller": null,
            "sourceIp": "35.75.xx.xx",
            "principalOrgId": null,
            "accessKey": null,
            "cognitoAuthenticationType": null,
            "cognitoAuthenticationProvider": null,
            "userArn": null,
            "userAgent": "curl/7.79.1",
            "user": null
        },
        "domainName": "hyt7k1r2o7.execute-api.ap-northeast-1.amazonaws.com",
        "apiId": "hyt7k1r2o7"
    },
    "body": null,
    "isBase64Encoded": false
}
```



# オーソライザーから、本来の Lambda 関数にデータを渡す

Lambda オーソライザーで認証が通ったあとに、本来動かしたい Lambda 関数にデータを渡したいときがあります。例えば、Lambda オーソライザーで、渡されたトークンを基にユーザーやグループを特定した後に、その情報を後続の Lambda 関数に渡したい状況があります。

後続の Lambda 関数にデータを渡す方法を確認していきましょう。



Lambda オーソライザーのプログラムを、次のように変更します。

- return しているデータに含まれている `context` に、`watasu_data` という名前でデータを渡す。この名前は好きな名前で問題ない

```python
import json
import base64
from logging import getLogger, INFO

logger = getLogger(__name__)
logger.setLevel(INFO)


def lambda_handler(event, context):
    print("============ event の出力 ============")
    logger.info(json.dumps(event))

    token = event['authorizationToken']
    effect = 'Deny'
    context = {}

    if token == 'abc':
        effect = 'Allow'

        context = {
            "parent1": "val1",
            "parent2": {
                "child1": "val1",
                "child2": "val2",
            }
        }

    json_context = json.dumps(context)
    base64_context = base64.b64encode(json_context.encode('utf8'))

    return {
        'principalId': '*',
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [
                {
                    'Action': 'execute-api:Invoke',
                    'Effect': effect,
                    'Resource': event['methodArn']
                }
            ]
        },
        'context': {
            'watasu_data': base64_context.decode('utf-8')
        }
    }
```



これによって、本来呼びだしたい Lambda 関数側は次のようなイベントを受け取ることが出来ます。 

```json
{
    "resource": "/hello",
    "path": "/hello",
    "httpMethod": "GET",
    "headers": {
        "Accept": "*/*",
        "Authorization": "abc",
        "CloudFront-Forwarded-Proto": "https",
        "CloudFront-Is-Desktop-Viewer": "true",
        "CloudFront-Is-Mobile-Viewer": "false",
        "CloudFront-Is-SmartTV-Viewer": "false",
        "CloudFront-Is-Tablet-Viewer": "false",
        "CloudFront-Viewer-Country": "JP",
        "Host": "hyt7k1r2o7.execute-api.ap-northeast-1.amazonaws.com",
        "User-Agent": "curl/7.79.1",
        "Via": "2.0 3793d7fea64206c86c6da516357453b6.cloudfront.net (CloudFront)",
        "X-Amz-Cf-Id": "qqoD8clhvMHNK3S4flpZqITRccibyNEL-54vWIrf3WjDeHJeCiS3jQ==",
        "X-Amzn-Trace-Id": "Root=1-629b1753-6374f54037aaf34f25cad3e1",
        "X-Forwarded-For": "35.75.xx.xx, 64.252.172.142",
        "X-Forwarded-Port": "443",
        "X-Forwarded-Proto": "https"
    },
    "multiValueHeaders": {
        "Accept": [
            "*/*"
        ],
        "Authorization": [
            "abc"
        ],
        "CloudFront-Forwarded-Proto": [
            "https"
        ],
        "CloudFront-Is-Desktop-Viewer": [
            "true"
        ],
        "CloudFront-Is-Mobile-Viewer": [
            "false"
        ],
        "CloudFront-Is-SmartTV-Viewer": [
            "false"
        ],
        "CloudFront-Is-Tablet-Viewer": [
            "false"
        ],
        "CloudFront-Viewer-Country": [
            "JP"
        ],
        "Host": [
            "hyt7k1r2o7.execute-api.ap-northeast-1.amazonaws.com"
        ],
        "User-Agent": [
            "curl/7.79.1"
        ],
        "Via": [
            "2.0 3793d7fea64206c86c6da516357453b6.cloudfront.net (CloudFront)"
        ],
        "X-Amz-Cf-Id": [
            "qqoD8clhvMHNK3S4flpZqITRccibyNEL-54vWIrf3WjDeHJeCiS3jQ=="
        ],
        "X-Amzn-Trace-Id": [
            "Root=1-629b1753-6374f54037aaf34f25cad3e1"
        ],
        "X-Forwarded-For": [
            "35.75.xx.xx, 64.252.xx.xx"
        ],
        "X-Forwarded-Port": [
            "443"
        ],
        "X-Forwarded-Proto": [
            "https"
        ]
    },
    "queryStringParameters": null,
    "multiValueQueryStringParameters": null,
    "pathParameters": null,
    "stageVariables": null,
    "requestContext": {
        "resourceId": "7zv8eo",
        "authorizer": {
            "watasu_data": "eyJwYXJlbnQxIjogInZhbDEiLCAicGFyZW50MiI6IHsiY2hpbGQxIjogInZhbDEiLCAiY2hpbGQyIjogInZhbDIifX0=",
            "principalId": "*",
            "integrationLatency": 21
        },
        "resourcePath": "/hello",
        "httpMethod": "GET",
        "extendedRequestId": "TMCVFH6UNjMFa_Q=",
        "requestTime": "04/Jun/2022:08:26:59 +0000",
        "path": "/dev/hello",
        "accountId": "xxxxxxxxxxxx",
        "protocol": "HTTP/1.1",
        "stage": "dev",
        "domainPrefix": "hyt7k1r2o7",
        "requestTimeEpoch": 1654331219561,
        "requestId": "869bfa65-de36-4ee4-b2b8-074b70cfeb20",
        "identity": {
            "cognitoIdentityPoolId": null,
            "accountId": null,
            "cognitoIdentityId": null,
            "caller": null,
            "sourceIp": "35.75.xx.xx",
            "principalOrgId": null,
            "accessKey": null,
            "cognitoAuthenticationType": null,
            "cognitoAuthenticationProvider": null,
            "userArn": null,
            "userAgent": "curl/7.79.1",
            "user": null
        },
        "domainName": "hyt7k1r2o7.execute-api.ap-northeast-1.amazonaws.com",
        "apiId": "hyt7k1r2o7"
    },
    "body": null,
    "isBase64Encoded": false
}
```



大事な部分をピックアップすると、次の部分です。

- `authorizer` に、`watasu_data` が格納されており、Base64 でエンコードされたデータが入っています。

```json
    "requestContext": {
        "resourceId": "7zv8eo",
        "authorizer": {
            "watasu_data": "eyJwYXJlbnQxIjogInZhbDEiLCAicGFyZW50MiI6IHsiY2hpbGQxIjogInZhbDEiLCAiY2hpbGQyIjogInZhbDIifX0=",
            "principalId": "*",
            "integrationLatency": 21
        },
```



この文字をデコードしてみると、Lambda オーソライザーで渡したデータを、正しく受け取れていることがわかります。

```
> echo -n "eyJwYXJlbnQxIjogInZhbDEiLCAicGFyZW50MiI6IHsiY2hpbGQxIjogInZhbDEiLCAiY2hpbGQyIjogInZhbDIifX0=" | base64 -d
{"parent1": "val1", "parent2": {"child1": "val1", "child2": "val2"}}⏎    
```



# 検証を通じてわかったこと

- Lambda オーソライザーを使うことで、独自の認証ロジックを API Gateway に設定することが出来る。
- Lambda オーソライザーが、Return するときの文字列で、認証が通った or 通らなかった を指定できる。
  - `policyDocument` の `Effect` の文字列が `Allow` で Return すると、認証が通ったことを表現している。`Deny` は認証が通らなかったことを表現している。
- Lambda オーソライザー から、本体の Lambda 関数にデータを渡すことが出来る。
  - return の時に、context にデータを渡すと、本体の Lambda がデータを受け取ることが可能。
  - context に渡すデータのキー値は、好きな値を指定して良い



# 参考 URL

API Gateway Lambda オーソライザーを使用する
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html

API GatewayのLambda オーソライザーから後続のLambdaにデータを引き渡す
https://dev.classmethod.jp/articles/lambda-authorizer/

AWS SAMでLambdaオーソライザーを「適用するAPI」と「適用しないAPI」を作ってみた
https://dev.classmethod.jp/articles/aws-sam-api-gateway-lambda-authorizer/

API Gateway LambdaオーソライザーでJWTトークン認証をやってみる
https://www.blog.danishi.net/2021/08/19/post-5336/