Cognito を使って認証機能付きのサーバーレスな API を構築する

## はじめに

この記事は、[AWS Lambda と Serverless Advent Calendar 2021](https://qiita.com/advent-calendar/2021/lambda) 3日目の投稿です！

「最近サーバーレスでこんなことやったぜっ！」と意気込んでサーバーレスのアドベントカレンダーにエントリーさせていただいたのですが、
いざ記事を書いてみると「これってサーバーレスじゃなくてもできるのでは」と気づきました。。
Serverless Framework 上で動かしているということは事実なので、お許しいただけると嬉しいです！！

## 構成

front / back 構成の Web アプリケーションを、以下のような組み合わせで構築しました。

front: Vue.js (Vue3) （S3でホスティングする予定）
back: Serverless Framework を利用して Express on Lambda + API Gateway
認証: Cognito

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135266/1392c9ac-fa27-ab0c-7a46-7db580d73e24.png)

サンプルコードは TypeScript ですが、 JavaScript でも参考にできると思います。

## そもそも Cognito とは

> Amazon Cognito は、ウェブおよびモバイルアプリの認証、承認、およびユーザー管理機能を提供します。ユーザーは、ユーザー名とパスワードを使用して直接サインインするか、Facebook、Amazon、Google、Apple などのサードパーティーを通じてサインインできます。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/what-is-amazon-cognito.html

認証/認可を簡単にしてくれるやつ。いわゆる IDaaS。
Firebase Authentication や Auth0 の仲間、という理解です。
ユーザープール（認証）に加えてIDプール（認可）も使えば、 DynamoDB や S3 など、AWSリソースの認可が簡単にできるはず。
今回はユーザープールのみ試しました。

## 手順

### 0. APIを準備する

Serverless Framework を利用して、 Lambda 上の Express を呼び出せる API Gateway のエンドポイントを作成しておきます。
参考情報がたくさんあるので割愛。
[クラスメソッドさんの記事](https://dev.classmethod.jp/articles/deploy-restapi-using-serverless-and-express/)や、[公式のサンプルリポジトリ](https://github.com/serverless/examples/tree/master/aws-node-express-dynamodb-api)がわかりやすいです😊

### 1. Cognito をセットアップする

Cognito のユーザープールを作成し、アプリクライアントを追加しておきます。
こちらも細かい手順は割愛。
以下の記事を参考にしました。
[Amazon Cognitoを使ったサインイン画面をつくってみる](https://www.tdi.co.jp/miso/amazon-cognito-sign-up)

### 2. ログイン画面を作る

今回のフロントは Vue.js。
サーバーレスのテーマから離れるのですが、ここが１番ハマりました😇
静的なフロントで SDK を使うサンプルコードはたくさんあったのですが、 Vue.js は Amplify で実現している事例が多く、なかなかズバリな資料にたどり着けず。（Amplify は今回の要件に too much だったので見送りました）
Cognito 関連のライブラリがいくつかあり色々試してみたところ、最終的には [amazon-cognito-identity-js](https://github.com/aws-amplify/amplify-js) でうまくいきました。
本題からは離れますが、どなたかのお役に立つこともあるかもしれないのでソースコードを貼っておきます。

```Signin.vue
<template>
  <div id="signup">
    <h1>Sign In</h1>
    <form name="form-signup">
      <span>User ID(Email)</span>
      <input
        v-model="state.email"
        type="text"
        placeholder="Email Address"
      >
      <br>
      <span>Password</span>
      <input
        v-model="state.password"
        type="password"
        placeholder="Password"
      >
      <br><br>
      <input
        id="createAccount"
        type="button"
        value="サインイン"
        @click="onSigninButtonClick"
      >
    </form>
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import * as AmazonCognitoIdentity from 'amazon-cognito-identity-js';

export default defineComponent({
  name: 'SigninPage',
  setup() {
    const router = useRouter();
    const state = reactive({
      email: '',
      password: '',
    });
    const onSigninButtonClick = () => {
      const authenticationDetails = new AmazonCognitoIdentity.AuthenticationDetails({
        Username: state.email,
        Password: state.password,
      });
      const userPool = new AmazonCognitoIdentity.CognitoUserPool({
        UserPoolId: 'ユーザープールID',
        ClientId: 'アプリクライアントのID',
      });
      const cognitoUser = new AmazonCognitoIdentity.CognitoUser({
        Username: state.email,
        Pool: userPool,
      });
      // 認証処理
      cognitoUser.authenticateUser(authenticationDetails, {
        onSuccess(result) {
          // とりあえず idToken を console.log() しているけど、実際には storage に保存しよう
          console.log(`idToken: ${result.getIdToken().getJwtToken()}`);

          // サインイン成功の場合、次の画面へ遷移
          router.push('/');
        },
        newPasswordRequired(userAttributes, requiredAttributes) {
          // パスワード再設定画面へ
          router.push('/new-password');
        },
        onFailure(err) {
          console.error(err);
        },
      });
    };
    return {
      state,
      onSigninButtonClick,
    };
  },
});
</script>
```

あらかじめ Cognito のダッシュボードで登録しておいたユーザーでログイン→パスワード再設定→再度ログイン、で `idToken` を取得できました。

### 3. トークンを検証する

ここからは API 側、Serverless Framework 側のプロジェクトを触ります。
まず、JWT トークンの検証用に、[jsonwebtoken](https://github.com/auth0/node-jsonwebtoken) と [jwks-rsa](https://github.com/auth0/node-jwks-rsa) を追加しておきます。

```shell
npm install jsonwebtoken jwks-rsa
```

検証用のミドルウェアを作成します。

```typescript:src/middlewares/verifytoken.ts
import { Request, Response, NextFunction } from 'express';
import jwt, { JwtHeader, SigningKeyCallback } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

// 作成したユーザープールの JWKS を指定 
const client = jwksClient({
  jwksUri:
    `https://cognito-idp.ap-northeast-1.amazonaws.com/${process.env.USER_POOL_ID}/.well-known/jwks.json`,
});

const getKey = (header: JwtHeader, callback: SigningKeyCallback) => {
  if (!header.kid) throw new Error('not found kid!');
  client.getSigningKey(header.kid, (err, key) => {
    if (err) throw err;
    callback(null, key.getPublicKey());
  });
}

export default (req: Request, res: Response, next: NextFunction) => {
  // Authorization ヘッダーから idToken 部分を取り出す
  const authHeader = req.headers.authorization;
  if (authHeader == null) {
    res.status(401).json({ error: 'Missing authorization header.' });
    return;
  }

  const [bearerText, idToken] = authHeader.split(' ');
  if (bearerText !== 'Bearer') {
    res.status(401).json({ error: 'Illegal authorization header format.' });
    return;
  }

  // JWT を検証して、OKだったら Express の Request オブジェクトに格納しておく
  jwt.verify(idToken, getKey, (err, decoded) => {
    if (err) {
      res.status(401).json({ error: err.message });
      return;
    }
    req.auth = decoded;
    next();
  });
};

```

`req.auth = decoded;` の箇所で TypeScript の型エラーが発生してしまったので、以下のように Request インターフェースを拡張しておきました。

```typescript:src/types/main.d.ts
import { JwtPayload } from 'jsonwebtoken';

declare global {
  namespace Express {
    export interface Request {
      auth?: JwtPayload | undefined;
    }
  }
}
```

Router 側で、上記で作成したミドルウェアを指定します。

```typescript:src/routers/authTest.router.ts
import express, { Request, Response } from 'express';
import verifyToken from '../../middlewares/verifyToken';

const router = express();

router.get('/', verifyToken, (req: Request, res: Response) => {
  if (!req.auth) {
    res.json({ message: 'Something went wrong...' });
    return;
  }
  // verifyToken ミドルウェアを通過しているので、 `req.auth` に情報がセットされているはず
  res.json({ message: `Hello! Your sub: ${req.auth.sub}` });
});

export default router;
```

`req.auth.sub` にはユーザープール上で一意なユーザーIDがセットされています。
この値を DB に保管しておけば、認可の機能を持たせることもできます。
（e.g, ログインユーザーが保存した写真のみを表示する）

また、 Cognito 自身にも [IDプール](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/identity-pools.html) という認可の仕組みがあるので、構成やユースケースに応じて使い分けたいですね。


### 4. フロントから呼び出してみる

とりあえず curl から呼んでみましょう。

まずは serverless offline でAPIを起動。

```shell
serverless offline
```

2\.で作成した画面で Cognito にログインし、 `console.log()` された idToken をコピーします。

```shell
curl http://localhost:3000/auth-test -H "Authorization:Bearer コピーしたidToken
```

すると

```shell
{"message":"Hello! Your sub:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
```

ちゃんとミドルウェアを通過して、値が返ってきました。

試しに idToken を適当に変更してみると

```shell
{"error":"invalid signature"}
```

ちゃんとエラーになりました😊

あとは Vue.js 側でログイン時に idToken を保存して、各種 API 呼び出し時のヘッダーにセットすれば、認証機能付きアプリケーションの完成です。
よかったよかった。

## おわりに

Cognito を使うと、「いつも同じような実装してるけど、いつもそれなりに気を遣うな〜」という認証機能をおまかせできちゃうので、開発の負担が減りますね！
IDプール（認可の機能）を使うとより本領を発揮する気がするので、もっといろいろ試してみたいな〜。