CloudFrontとlambda@EdgeでS3にcognito認証をつけた

## やったこと
S3内で複数のhtmlを管理しており、それぞれのページにアクセスできるユーザーは異なるため、アクセスする際に認証処理をする、という実装をしました。ユーザはcognitoで管理されているため、今回はCloudFront→Lambda@Edge→S3というアーキテクチャで実装することにしました。実装自体はAWS CDK(ts)を使用しています。

## 実装環境

AWS CDK: 2.23.0
Python: 3.7
typescript: 3.5.1

## アーキテクチャについて

![access_to_message_webview_qiita.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/192406/08a449b4-a085-7064-ecf5-41f2204660c0.png)

大まかなアーキテクチャは上図です。
lambdaの内容もざっくり記載しています。
先程の概要をそのまま図にした感じです:sweat:
(一部、実際のものから省略しています、以降も同様)

## cdkの実装

必要箇所のみに絞ってますが...
こんな感じです(ところどころ表記ゆれがありますがご容赦ください)

```ts
const targetBucket: Bucket = new Bucket(this, "createBucket", {
      bucketName: "sampleBucket",
      encryption: BucketEncryption.UNENCRYPTED,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
});
const authFunc: EdgeFunction = new EdgeFunction(this, "createEdgeFunction", {
      functionName: "authenticationToHtml",
      runtime: Runtime.PYTHON_3_7,
      code: Code.fromAsset("resources/authenticator"),
      handler: "index.handler",
      memorySize: 128,
      timeout: 5
    });
const originAccessIdentity = new OriginAccessIdentity(this, "createOAI");
const policyToGetS3Object = new PolicyStatement(
      {
        ["s3:GetObject"],
        aws_iam.Effect.ALLOW,
        principals: [originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId].map((userPrincipal: string) => {
          return new CanonicalUserPrincipal(userPrincipal)
        }),
        [`${targetBucket.bucketArn}/*`]
      }
    );
targetBucket.addToResourcePolicy(policyToGetS3Object);
new aws_cloudfront.Distribution(
      self,
      "createDistribution",
      {
        defaultRootObject: "index.html",
        defaultBehavior: {
          allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_GET_HEAD,
          cachedMethods: aws_cloudfront.CachedMethods.CACHE_GET_HEAD,
          cachePolicy: aws_cloudfront.CachePolicy.CACHING_OPTIMIZED,
          viewerProtocolPolicy: aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          origin: new aws_cloudfront_origins.S3Origin(targetBucket, {originAccessIdentity: originAccessIdentity}),
          edgeLambdas: [authFunc]
        },
        priceClass: aws_cloudfront.PriceClass.PRICE_CLASS_ALL
      }
    );
```


## lambdaの実装

```py
# -*- coding: utf-8 -*-
import boto3

COGNITO = boto3.client(
    'cognito-idp',
    region_name = 'REGION_NAME'
)

def output_log(log_type: str, log_detail):
    """ログ出力する"""
    print('[{}] {}'.format(log_type, log_detail))

def check_method(method: str):
    """メソッドの確認"""
    target_method = 'GET'
    if method == target_method:
        return True
    return False

def get_cognitoID(token: str):
    """
    受け取ったトークンからユーザー名(cognitoID)を取得する
    """
    res = COGNITO.get_user(AccessToken = token)
    return res['Username']

def get_invalid_response():
    """権限エラーのレスポンスを取得"""
    error_content = """
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <title>403 Forbidden</title>
    </head>
    <body>
        <h1>403 Forbidden</h1>
        <p>You have no authority.</p>
    </body>
    </html>
    """
    return {
        'status': 403,
        'statusDescription': 'Forbidden',
        'headers': {
            'cache-control': [
                {
                    'key': 'Cache-Control',
                    'value': 'max-age=100'
                }
            ],
            "content-type": [
                {
                    'key': 'Content-Type',
                    'value': 'text/html'
                }
            ]
        },
        'body': error_content
    }

def handler(event, _):
    try:
        output_log('INFO', event)
        req = event['Records'][0]['cf']['request']
        if not check_method(req['method']):
            raise Exception('invalid method')
        authorizations = req['headers']['authorization']
        token = check_auth(authorizations)
        if not token:
            raise Exception('invalid access token')
        cognito_id = get_cognitoID(token)
        target_cognito_id = req['uri'].split('/')[1]
        if cognito_id == target_cognito_id:
            return req
        else:
            raise Exception('invalid user')
    except Exception as err:
        output_log('ERROR', err)
        return get_invalid_response()
```

## つまづいたところ

### Lambda@Edgeの制約

色々とLambda@Edgeには制約がありましてこれにより設計や実装を修正する必要がありました。
下記以外にも制約がありますのでそちらに関しては[公式](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/edge-functions-restrictions.html)を確認いただければと思います。

- lambdaを北バージニアリージョンに実装する必要がある
- VPC内のリソースにアクセスできない
    - 実装前段階の設計ではS3のファイル構成とLambda内部の設計が異なっておりRDSが必要だったが断念してファイル構成・Lambda内の設計を修正しました
- Layer使えない
    - 他のLambdaではLayer使ってaws_lambda_powertoolsをimportしてログ出力していたけど使えなかった。ので今回は断念してprintでせこせこログ出力を...
- 環境変数が使えない
    - 環境名(prod, staging, dev....)を環境変数として渡したかったができなかったので関数名に含まれている環境名を取得(デフォルトの環境変数は使えました)


### 必要なcognitoのトークンの種類

今までAPIgatewayをcognito認証で実装したことがあったのですが、その際に使用していたトークンは`id_token`でした。ただ、今回必要なトークンは`access_token`とのことでした。
(id_tokenとaccess_tokenの違いがわからん...)

### cognitoの設定変更

cognitoのget_userをする際にcognitoのOAuthのスコープ設定の「aws.cognito.signin.user.admin」にチェックをつけておかないとget_userすることができません。こちらの設定はコンソールの「アプリクライアントの設定 > OAuth2.0 > 許可されている OAuth スコープ」から変更できます。ただし、コンソールのUIはしょっちゅう変わるのでもしかしたら場所が変更になっているかもしれないです...

### トークンのスコープ

今回、AWS SDKでcognitoユーザープールへアクセスしていますが、このアクセスをするためにはトークンのscopeに `aws.cognito.signin.user.admin` が含まれている必要があります。cognitoユーザープールの設定変更だけではダメでトークンを取得する際にscopeに`aws.cognito.signin.user.admin`を追加しましょう。(ただし、リクエスト時にscopeの指定をしていなければユーザープールで設定されているスコープ内容で自動指定されるので、固定で指定していなければ問題ないです。([参考](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/authorization-endpoint.html))こちらをscopeに含めなかった場合、`get_user`を実行した際に以下のエラーが返ってきます。

```sh
botocore.errorfactory.NotAuthorizedException: An error occurred (NotAuthorizedException) when calling the GetUser operation: Access Token does not have required scopes
```

### ログの出力場所

今回、Lambdaはバージニア北部にデプロイされますが、実行ログに関しては、アクセスしたユーザーにとって一番近いリージョンになります。
例えば、日本にいる人がアクセスした場合は東京リージョンです。
ここがわからなくて、実行したのにLambda自体のログを見に行って何も出力されておらず混乱しました。

### boto3の実行リージョン

アクセスするユーザーの存在する場所によってlambda@Edgeが実行されるリージョンは異なるため、
コード内でsdkを使用する場合はリージョンを固定で指定する必要があります。
リージョンを指定しないと、本来はバージニア北部のDDBを見に行きたいのに東京にいる人がアクセスした結果、東京リージョンに存在しないテーブルを見に行こうとしてエラーになります。

## まとめ

Lambda@Edgeの制約に苦しみましたがそれ以外は特に苦労せずにできたような気がします。
(実装したのが2ヶ月ほど前なので記憶の彼方...)
ただ、実装前に内容はあまり知らずともLambda@Edgeは制約が多い、という前情報があったから苦労しなかったのかも...
知らなかったら暴れまわってたかもしれないです。