Cognito Hosted UI を Nuxt.js で利用する方法の比較検討 (amazon-cognito-auth-js vs aws-amplify/auth)

# はじめに

[先にも書いた](https://qiita.com/t-kigi/items/7ead0e8946f69b1eefad) のだが、記事執筆の 2021年3月末現在 Cognito のライブラリ実装は Amplify に取り込まれている。　 

しかし、自分は現時点では [**まだ** Amplify にすべてを任せられる状況ではないと考えている](https://qiita.com/t-kigi/items/cd78a2bfbf80dfa1e2ee) ので、IDaaS として Cognito を Amplify と切り離して使いたい。 そのために色々と調査したので、その過程を記す。

## 前提: Cognito UserPool の設定

UserPool は事前に作成して適時設定し、アプリクライアントの設定として以下の設定を行っている。 URLからもわかる通り、ローカル環境での動作となる。

- アプリクライアントのドメイン: `example.auth.ap-northeast-1.amazoncognito.com` (example を入力したもの)
- コールバックURL: `http://localhost:3000/signin.html`
- サインアウトURL: `http://localhost:3000/`
- 許可するOAuthのフロー: Authorization code grant
- 許可するOAuthのスコープ: openid + email

これで "アプリの統合 > アプリクライアントの設定 > ホストされたUIを起動" としてHosted UI を開くと、

`https://example.auth.ap-northeast-1.amazoncognito.com/login?client_id=**********&response_type=code&scope=email+openid&redirect_uri=http://localhost:3000/signin.html` といったURLでログイン用の画面が開けるはずである。


## 共通: Nuxt.js での Cognito リソースへのアクセス方法について

色々なやり方があるかとは思うが、ここでは plugin に `CognitoHostedUI.ts` という TypeScript ファイルを作ってここで管理することにした。 そして、これをロード時に inject して、各 page 内で `this.$cognito` として呼び出せるように設定している。 この後、２通りの方法を紹介するが、どちらの方法でも inject のタイミングでコンストラクタを呼び出して初期化し、 `this.$cognito` 経由で内部のAuth実装へとアクセスするようにした。

```typescript:plugins/CognitoHostedUI.tsの抜粋
import { Context, NuxtAppOptions } from '@nuxt/types';

interface CognitoAuthConfig {
  // 必要な設定を定義
}

export class CognitoHotedUIAuth {
  app: NuxtAppOptions;

  constructor(app: NuxtAppOptions, config: CognitoAuthConfig) {
    this.app = app;
  }
}

export default ({ app }: Context, inject: (key: string, ref: any) => void) => {
  inject('cognito', new CognitoHotedUIAuth(app, app.$config.cognito));
};
```


# 方法1. amazon-cognito-auth-js

Cognito Hosted UI を使う場合、ログインに成功するとコールバックURLにログイン[Amazon Cognito ユーザープール の Auth API リファレンス](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-userpools-server-contract-reference.html) に紐づいたAPIコールを行う必要がある。 これを実現するのが amazon-cognito-auth-js であり、各APIコールを Wrap してくれている。 ただし、このリポジトリは凍結されて継続開発は amplify-js で行われているため、これは古いやり方ということになる。

https://github.com/amazon-archives/amazon-cognito-auth-js

## 初期化

これを使う場合、`import { CognitoAuth } from 'amazon-cognito-auth-js';` として CognitoAuth を使うようにする。 TypeScript の定義は `@types/amazon-cognito-auth-js` を npm か yarn で `devDependencies` に入れたものを利用している。 この場合、こんな感じで初期化する。 `CognitoAuthConfig` は起動時に渡す定数である。

```typescript
interface CognitoAuthConfig {
  UserPoolId: string;
  ClientId: string;
  AppWebDomain: string;
  RedirectUriSignIn: string;
  RedirectUriSignOut: string;
  TokenScopesArray: Array<string>;
}

export class CognitoHotedUIAuth {
  app: NuxtAppOptions;
  cognitoAuth: CognitoAuth;

  constructor(app: NuxtAppOptions, config: CognitoAuthConfig) {
    this.app = app;
    this.cognitoAuth = new CognitoAuth(config);
    this.cognitoAuth.useCodeGrantFlow();  // Authorization code grant の場合
  }
}
```

## コールバックURLでの処理

コールバックURL （ここでは signin.html) ではページ遷移後に以下の処理を行うとマニュアルにある。 そのため、ここでは `mounted` に合わせて、これを呼び出すようにした。

```javascript:呼び出しの概要
// auth は CognitoAuth のインスタンス
var curUrl = window.location.href;
auth.parseCognitoWebResponse(curUrl);
```

```javascript:singin.vue
  async mounted() {
    const curUrl = window.location.href;
    const session = await this.$cognito.signin(curUrl);
    // 何かセッション (JWTトークン) を使ってやる処理があればここで実施
    this.$router.push(...); // ログイン専用ページに移動
  }
```

```typescript:CognitoHostedUI.ts
export class CognitoHotedUIAuth {
  // ...
  signin(callbackUrl: string): Promise<CognitoAuthSession> {
    return new Promise((resolve, reject) => {
      this.cognitoAuth.userhandler = {
        onSuccess: (result) => {
          resolve(result);
        },
        onFailure: (err) => {
          reject(err);
        },
      };
      this.cognitoAuth.parseCognitoWebResponse(callbackUrl);
    });
  }
  // ...
}
```

## ログイン中セッションの取得

ログイン中の情報はデフォルトでは localStorage に保持されている（別のところに保存する方法は後述）。 そのため、ページをリロードや閉じても、ログイン情報は消えない。
そういったローカルに保持している情報を確認し、トークンの有効期限が切れている場合は自動的にトークン更新リクエストを実行し、最終的に有効な結果が得られた場合に `userhandler.onSuccess` に渡してくれるのが `getSession` 関数である。
getSession 自体は何も返さないので、Promise で包んで必要な情報を await で取れるようにすると、例えば以下のようになる。

```typescript
// usage: const session = await this.$cognito.getSession();
export class CognitoHotedUIAuth {
  // ...
  getSession(): Promise<CognitoAuthSession> {
    return new Promise((resolve, reject) => {
      this.cognitoAuth.userhandler = {
        onSuccess: (result) => {
          resolve(result);
        },
        onFailure: (err) => {
          reject(err);
        },
      };
      this.cognitoAuth.getSession();
      reject(new Error('resolve not called'));
    });
  }
  // ...
}
```

session から JWTトークンが取得できるので、これを使って適切なアクセスが可能。 例えば、API Gateway の認可手法として Cognito UserPool を紐づけておき、Cognito でログインしたユーザーのトークンを使わないとアクセスできない API を作成して使う、といったことができる。

また、このセッション中にユーザーIDやメールアドレスが入っているので、以下のようにして取得できる。 おそらくスコープ次第ではもっと取れると思うが、そのあたりは対象外。

```typescript
interface CognitoUserInfo {
  email?: string;
  userId?: string;
}

interface CognitoPayload {
  email?: string;
  'cognito:username'?: string;
}
export class CognitoHotedUIAuth {
  // ...
  async getUserInfo(): Promise<CognitoUserInfo> {
    const session = await this.getSession();
    const payload: CognitoPayload = session.getIdToken().decodePayload();
    return {
      email: payload.email,
      userId: payload['cognito:username'],
    };
  }
  // ...
}
```

なお、補足として `cognitoAuth.getSignInUserSession()` という関数もあるが、この関数はトークンの有効期限が切れている場合でもそれをそのまま持ってくる（自動更新がない)。 なので、適時最新化するように `getSession` の Wrapper を作成して利用している。

## ログアウト

サインアウト関数を呼び出すだけ。

```typescript
export class CognitoHotedUIAuth {
  // ...
  signout() {
    this.cognitoAuth.signOut();
  }
  // ...
}
```

## この方法のまとめ

と、色々と CognitoAuth の Wrapper を作ればちゃんと動くものはできる。 ただ、割と調べるのが面倒だった。
そして何よりつらいのが、**`@types/amazon-cognito-auth-js` で定義されているクラスが、実装されているリソースを網羅していない。** そのため、TypeScript ベースでやるのはちょっと……となった。


# 方法2. @aws-amplify/auth

統合されてしまった Amplify の中から Auth 部分のみを選んで使う。 これでも相当対象を絞っているのだが、`aws-amplify/core` などが入ってくるのでビルド後のプロジェクトはかなり大きくなる。

## 初期化

Amplify 系の初期化は `Amplify.configure` が紹介されているが、ここでは Amplify は使わない。 Auth のみを初期化するなら、`Auth.configure` を使う。 こっちはもともと TypeScript で書かれているので、型補完が優秀。

```typescript
import Auth from '@aws-amplify/auth';

interface CognitoAuthConfig {
  Region: string;
  UserPoolId: string;
  ClientId: string;
  AppWebDomain: string;
  RedirectUriSignIn: string;
  RedirectUriSignOut: string;
  TokenScopesArray: Array<string>;
}

export class CognitoHotedUIAuth {
  app: NuxtAppOptions;

  constructor(app: NuxtAppOptions, config: CognitoAuthConfig) {
    this.app = app;
    Auth.configure({
      region: config.Region,
      userPoolId: config.UserPoolId,
      userPoolWebClientId: config.ClientId,
      oauth: {
        domain: config.AppWebDomain,
        scope: config.TokenScopesArray,
        redirectSignIn: config.RedirectUriSignIn,
        redirectSignOut: config.RedirectUriSignOut,
        responseType: 'code',
      },
    });
  }
}
```


## コールバックURLでの処理

**何もしなくても良い**。 Auth が勝手に良しなにしてくれる。
なので、コールバックURLでは、ログインしたセッションを使ってから初期化したいその他の処理を実施するなどする。
mounted のタイミングで Auth はセッションを取得できるようにしてくれている。


## ログイン中セッションの取得

`Auth.currentSession()` を使えばその時点での最新セッションを取得できる。　ログインしていない場合など、セッションがない場合は例外が起こり、これを正常形と捉えて例外を出したくなかったので、ここでは非ログイン時には null を返すような Wrapper を作っている。

```typescript
export class CognitoHotedUIAuth {
  async getSession(): Promise<CognitoUserSession | null> {
    try {
      return await Auth.currentSession();
    } catch (error) {
      return null;
    }
  }
}
```

このセッションは amazon-cognito-auth-js のものとほぼ同じなので、セッションからのユーザー情報の取得は先の方法と同様で実現できる。

```typescript
interface CognitoUserInfo {
  email?: string;
  userId?: string;
}

interface CognitoPayload {
  email?: string;
  'cognito:username'?: string;
}
export class CognitoHotedUIAuth {
  // ...
  async getUserInfo(): Promise<CognitoUserInfo> {
    const session = await this.getSession();
    const payload: CognitoPayload = session.getIdToken().decodePayload();
    return {
      email: payload.email,
      userId: payload['cognito:username'],
    };
  }
  // ...
}
```

なお、Auth には `Auth.currentUserInfo()` というのがあるが、これは適切な scope か、ID Pool で認可したものでないと情報が取得できないはずなので注意。


## ログアウト

サインアウト関数を呼び出すだけ。

```typescript
export class CognitoHotedUIAuth {
  // ...
  signout() {
     Auth.signOut();
  }
  // ...
}
```

## まとめ

後発の Wrapper だけあって、細かいことを書かなくてもログインセッションを簡単に管理できる。 その一方で、(Authだけに絞っても)かなりの依存関係が入ってくるので、方法1と比較すると generate の結果は大きくなる。

実際、`yarn add @aws-amplify/auth` だけを実施しても、非常に大量の依存関係を入れることになった。

```bash
info Direct dependencies
└─ @aws-amplify/auth@3.4.29
info All dependencies
├─ @aws-amplify/auth@3.4.29
├─ @aws-amplify/cache@3.1.54
├─ @aws-crypto/ie11-detection@1.0.0
├─ @aws-crypto/sha256-browser@1.1.0
├─ @aws-crypto/sha256-js@1.1.0
├─ @aws-crypto/supports-web-crypto@1.0.0
├─ @aws-sdk/abort-controller@3.6.1
├─ @aws-sdk/config-resolver@3.6.1
├─ @aws-sdk/credential-provider-cognito-identity@3.6.1
├─ @aws-sdk/credential-provider-env@3.6.1
├─ @aws-sdk/credential-provider-imds@3.6.1
├─ @aws-sdk/credential-provider-node@3.6.1
├─ @aws-sdk/credential-provider-process@3.6.1
├─ @aws-sdk/fetch-http-handler@3.6.1
├─ @aws-sdk/hash-node@3.6.1
├─ @aws-sdk/invalid-dependency@3.6.1
├─ @aws-sdk/middleware-content-length@3.6.1
├─ @aws-sdk/middleware-host-header@3.6.1
├─ @aws-sdk/middleware-logger@3.6.1
├─ @aws-sdk/middleware-retry@3.6.1
├─ @aws-sdk/middleware-serde@3.6.1
├─ @aws-sdk/middleware-signing@3.6.1
├─ @aws-sdk/middleware-user-agent@3.6.1
├─ @aws-sdk/node-http-handler@3.6.1
├─ @aws-sdk/service-error-classification@3.6.1
├─ @aws-sdk/smithy-client@3.6.1
├─ @aws-sdk/url-parser-native@3.6.1
├─ @aws-sdk/url-parser@3.6.1
├─ @aws-sdk/util-base64-node@3.6.1
├─ @aws-sdk/util-body-length-browser@3.6.1
├─ @aws-sdk/util-body-length-node@3.6.1
├─ @aws-sdk/util-locate-window@3.10.0
├─ @aws-sdk/util-user-agent-browser@3.6.1
├─ @aws-sdk/util-user-agent-node@3.6.1
├─ @aws-sdk/util-utf8-node@3.6.1
├─ @types/cookie@0.3.3
├─ bowser@2.11.0
├─ react-native-get-random-values@1.6.0
├─ universal-cookie@4.0.4
├─ zen-observable-ts@0.8.19
└─ zen-observable@0.8.15
```

# 方法1. vs 方法2

## バンドルサイズの差

先に示した通り、Amplify の Auth だけを入れても、結構多くの依存関係が入ってくる。
特に a-la-carte は実施していない Vuetify.js を含んでいるが、比較すると Amplify.Auth を入れると (Parsed の場合) 約700KB のプラスとなっている。

### 方法1. `nuxt build --analyze`

![SnapCrab_NoName_2021-3-31_0-32-22_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/82ece52e-186b-ffc1-8e4f-e3f2aeb06eb3.png)

### 方法2. `nuxt build --analyze`

![SnapCrab_NoName_2021-3-31_0-32-39_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/502582/622d1fc4-c7e8-4dae-9d56-5aa641d9c71a.png)

## TypeScript 対応

先にも示した通り、提供されている方法1の型付けが不完全である。　例えば、[CookieStorage](https://github.com/amazon-archives/amazon-cognito-auth-js/blob/master/src/CookieStorage.js) がリポジトリには存在するが、[TypeScriptの定義にはこれがない](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/amazon-cognito-auth-js)。 すでに amazon-cognito-auth-js がアーカイブされて凍結されていることを考えると、今さらこれに文句を言うのも…、という気がしてくる。
というか、私が TypeScript のド初心者なので、こういった場合に手元で適切な型を追加する方法を知っていれば、それを使うのが良いのだが…。

また、先に示した通り aws-amplify/auth は TypeScript で書かれているので、このあたりの型に苦労しない点はそちらの方がよい。


## どちらを使うか

メンテナンスが終わってるリポジトリはあまり使いたくないこと、型が提供されていることから、個人的には Amplify から Auth 部分を抜き出して使うことにした。 バンドルサイズのデメリットは許容範囲とした。

# localStorage に JWTToken を保存することの是非と代替案

セキュリティ事由 (XSSによる流出懸念など) でトークンを localStorage に保存したくないという需要はある。 しかし、デフォルトでは方法1, 2ともにトークンを localStorage に保存する設定になっている。


https://techracho.bpsinc.jp/hachi8833/2019_10_09/80851


これは、`CognitoAuth({Storage})` や `Auth.congigure({storage})` に別のカスタムストレージを設定することで別の場所にセッション情報を保存できるようになる。 CookieStorage であれば Cookie に、SessionStorage であれば SessionStorage に保存できる。 [SessionStorage を使うと、ブラウザを閉じると強制的にログアウトするようになる](https://qiita.com/daikiojm/items/7e7ed3d20991718c152d)。

方法1で CookieStorage を使おうとした理由がこれ。 無理やり自分で `index.d.ts` に `export class CookieStorage` と書き込んでからやれば方法1でも Cookie に保存することはできたが、まあ、あまりやりたくはない。

ちなみに CookieStorage を使う注意点だが、`domain: 'localhost'` として利用する場合、合わせて `secure: false` も指定すること。 `secure` 省略時は `true` になるので、`http://localhost:3000` などの http ベースで検証している場合、Cookieに正常に値が保存されずにログイン関連処理がうまくいかなくなる。


# まとめ

新しい方が便利になってはいるし、そもそも古い方は利用を推奨されないので、新しい方法(方法2)を使うのがいいだろう。

しかし依存関係の肥大化や、Amplifyを使っていないにも関わらず Amplify という名前が出てしまうことは各サービスを独立して使いたい（少数派？の）人間からすると残念ではあるが……