ふらっとAWS CloudFrontの署名付きCookieを使って認証画面を作った話

## はじめに
以前、AWS Cognitoを使って認証画面を作りました。

https://qiita.com/Kodak_tmo/items/f3c40abad36b7e4ea13f

その後、そういえばCloud Frontには署名付きCookieやURLを設定できるので、認証画面っぽいのを作ることができるなぁと思ったので作ってみることにしました。

## AWS構成図
AWSの構成図を書くと認証の仕組みは、大体このようなイメージ。

![gs_drawio_-_diagrams_net.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241956/b23ebff0-53a3-d3a3-01b8-7fc04e472e0e.png)


構成としては、前回のAWS Cognitoとほぼ同じ。

プライベートページへのアクセスはCloudFrontの署名付きCookieを使うので、Lambda@Edgeがいりません。
固定のIDとパスワードであれば、API GatewayとLambdaだけで実装可能です。
IDとパスワードをどこかに保管しておきたいのであれば、保管用にプラスでAWS CognitoやDynamo DB等を使うと良いと思います。

## 実装
実装の前に、CloudFrontの署名付きCookieの作り方を整理しておきます。

### CloudFrontの署名付きCookieの作り方

1. `openssl`で秘密鍵と公開鍵を作成する。[^1]
1. CloudFrontに公開鍵を登録する。（パブリックIDが作られる）
1. パブリックIDに対してキーグループを作成して、任意のCloudFrontのビヘイビアへ登録する。

以上の設定で、キーグループを指定した任意のCloudFrontのビヘイビアへアクセスするには、署名付きCookieが必要になります。
署名付きCookieが必要なページへのアクセスは、以下3つのCookieを事前に取得、ブラウザに保存しておく必要があります。

- CloudFront-Policy => 対象や期限を指定するポリシー
- CloudFront-Signature => キーペアで署名したポリシー
- CloudFront-Key-Pair-Id => パブリックID

#### CloudFront-Policy
AWS IAMのポリシーのJSON定義。これをBase64に変換したもの。

```
{
    "Statement":
    [
        {
            "Resource": ${署名付きCookieが必要となるURL},
            "Condition":
            {
                "DateLessThan":
                {
                    "AWS:EpochTime":1648738799 // 有効期限
                }
            }
        }
    ]
}
```

#### CloudFront-Signature
**CloudFront-Policy**のJSON定義に対して、`RSA-SHA1`で暗号化。秘密鍵を使って署名して、Base64に変換したもの。

#### CloudFront-Key-Pair-Id
パブリックID。


### API Lambdaの処理
署名付きCookieの作り方は理解できたので、さっそくライブラリを探すことにしました。
しかし、Node.jsの`aws-sdk v3`には、署名付きCookieを作る機能がまだ無いようです。

https://github.com/aws/aws-sdk-js-v3/issues/1822

別のライブラリ[^2]を使うことも検討しましたが、試しに自分で作ることにしました。

```ts
export const handler: APIGatewayProxyHandlerV2 = async event => {
  try {
   return await new CreateSignedCookies().handler(event)
  } catch (error: unknown) {
   return {
      isBase64Encoded: false,
      500,
      body: 'Internal Server Error',
      headers: {
        'content-type': 'application/json',
      },
    }
  }
}

interface AuthData {
  Username: string
  Password: string
}

interface Cookies {
  "CloudFront-Policy": string
  "CloudFront-Signature": string
  "CloudFront-Key-Pair-Id": string
}

interface Policy {
  Statement: Array<{
    Resource: string
    Condition: {
      DateLessThan: {
        'AWS:EpochTime': number,
      },
    },
  }>,
}

export default class CreateSignedCookies {
  public async handler(event: APIGatewayProxyEventV2): Promise<ApiResponse> {
    if (!event.body) throw new Error('event.body is empty')
    const data: AuthData = JSON.parse(event.body)

    const authenticationDetails = new AmazonCognitoIdentity.AuthenticationDetails({
      Username: data.Username,
      Password: data.Password,
    })

    const userPool = new AmazonCognitoIdentity.CognitoUserPool({
      UserPoolId: process.env.USER_POOL_ID ?? '',
      ClientId: process.env.CLIENT_ID ?? '',
    })

    const cognitoUser = new AmazonCognitoIdentity.CognitoUser({
      Username: data.Username,
      Pool: userPool,
    })

    await new Promise((resolve, reject) =>
      cognitoUser.authenticateUser(authenticationDetails, {
        // 認証成功
        onSuccess: result => {
          Log.debug('onSuccess')
          resolve(result)
        },

        // 認証失敗
        onFailure: (error: unknown) => {
          if (error instanceof Error) {
            reject(new Error(error.message))
          }
        },
      }),
    )

    const cfUrl = process.env.CF_BEHAVIOR_URL ?? 'undefined'
    const privateKey = process.env.PRIVATE_KEY ?? 'undefined'

    return {
      isBase64Encoded: false,
      200,
      body: this.getSignedCookies(cfUrl, privateKey),
      headers: {
        'content-type': 'application/json',
      },
    }
  }

  private getSignedCookies(cfUrl: string, keypairId: string): Cookies {
    const privateKey = this._getPrivateKey() // 秘密鍵の読み込み
    const policy = this._createPolicy(cfUrl) // AWS IAMポリシー作成
    const signature = this._createPolicySignature(policy, privateKey) // CloudFront-Signatureの生成
    const policyStr = Buffer.from(JSON.stringify(policy)).toString('base64') // CloudFront-Policyの生成
    return {
      'CloudFront-Policy': this.normalizeBase64(policyStr),
      'CloudFront-Signature': this.normalizeBase64(signature),
      'CloudFront-Key-Pair-Id': keypairId,
    }
  }

  private normalizeBase64(str: string): string {
    return str.replace(/\+/g, '-').replace(/=/g, '_').replace(/\//g, '~')
  }

  private _createPolicySignature(policy: Policy, privateKey: string): string {
    const sign = crypto.createSign('RSA-SHA1')
    sign.update(JSON.stringify(policy))

    return sign.sign(privateKey, 'base64')
  }

  private _createPolicy(cfUrl: string): Policy {
    const halfTime = 1000 * 60 * 30 * 1
    const expireTime = Math.round((new Date().getTime() + halfTime) / 1000)
    const policy = {
      Statement: [
        {
          Resource: `${cfUrl}*`,
          Condition: {
            DateLessThan: {
              'AWS:EpochTime': expireTime,
            },
          },
        },
      ],
    }
    return policy
  }

  private _getPrivateKey(): string {
    return fs.readFileSync(resolve(__dirname, 'cookie.pem'), 'utf-8')
  }
}

```

あとは、フロント側でAPIから取得したCookieをブラウザへ保存すれば、署名付きCookieが必要となるページへのアクセスが可能になります。

## さいごに

実装して思ったのは、この方法だと、`AWS Cognito`のように、誰がログインしたのかがわからないということです。
つまり、`AWS Cognito`で発行する`idToken`にはユーザー情報が格納されているので、ログイン時に「XXXさん。ようこそ！」みたいな文言を出すことができますが、署名付きCookieにはユーザー情報を含んでいないので、そういった文言を出すことができません。

しかし、`Lambda@Edge`を使わないことで、コスト的にも労力的にも簡易にプライベートなページを作れるという意味では、良い方法だと思うので、選択肢の1つにはなるかなぁと思います。

では:raised_hand:


[^1]: 秘密鍵: `openssl genrsa -out cookie.pem 2048`、公開鍵: `openssl rsa -pubout -in cookie.pem -out cookie.pub`で作できる。
[^2]: https://github.com/jasonsims/aws-cloudfront-sign