セキュアな SkyWay Auth Token を生成してチュートリアルやってみた

# はじめに

SkyWay では JWT（JSON Web Token）形式のトークンを用いた認証・認可機能が提供されています。
トークンの生成には SkyWay コンソールから取得できる「アプリケーションID」と「シークレットキー」が必要となりますが、これらの値をフロントエンドで保持しておくと不正利用されてしまう恐れがあるため危険です。

https://skyway.ntt.com/ja/docs/user-guide/authentication/


SkyWay のチュートリアルにおいては簡単に実装できるようにするため、フロントエンドでトークンが生成されており、以下の注意書きが記載されています。

> 本チュートリアルでは、すぐに通信を試していただくために、トークン生成をフロントエンドで実装していますが、 本来はSkyWay Auth Token はサーバーアプリケーションで生成して client アプリケーションに対して渡すようにするべきです。 第三者に SkyWay Auth Token 生成に必要なシークレットキーを利用して任意の room に入ることができるような Token を作成される可能性があります。

そこで **バックエンドで SkyWay Auth Token を生成して、第三者が不正利用できない構成で** SkyWay のチュートリアルを行ってみました。

# 技術構成

バックエンドは AWS を用いてトークン生成 API を作成します。
また、パブリックにAPIを公開してしまうと悪用されてしまう恐れがあるため、Cognito を用いて許可されたユーザーのみ API 呼び出しを可能とします。

## バックエンド

* AWS Lambda（Node.js）
* Amazon API Gateway
* Amazon Cognito

## フロントエンド

* Vue.js

# 実装手順

## 1. AWS Lambda で関数を作成

Node.js で SkyWay Auth Token を生成するサンプルが提供されておりましたので、そちらを参考に作成しました。

https://github.com/skyway/authentication-samples/blob/main/server/nodejs/index.js

作成したコードは以下の通りです。

```javascript
import jwt from "jsonwebtoken";
import crypto from "crypto";

export const handler = async () => {
  const iat = Math.floor(Date.now() / 1000);
  const exp = iat + 60 * 60 * 10; // 10h
  const payload = {
    jti: crypto.randomUUID(),
    iat,
    exp,
    scope: {
      app: {
        id: process.env.SKYWAY_APP_ID,
        turn: true,
        actions: ["read"],
        channels: [
          {
            id: "*",
            name: "*",
            actions: ["write"],
            members: [
              {
                id: "*",
                name: "*",
                actions: ["write"],
                publication: { actions: ["write"] },
                subscription: { actions: ["write"] },
              },
            ],
            sfuBots: [
              {
                actions: ["write"],
                forwardings: [{ actions: ["write"] }],
              },
            ],
          },
        ],
      },
    },
  };
  const token = jwt.sign(payload, process.env.SKYWAY_SECRET_KEY);
  return { token };
};
```

また、関数を実行するためには追加で以下の操作が必要です。

* jsonwebtoken のパッケージをレイヤーとして追加する。
* 以下2つの環境変数を定義する。
    * SKYWAY_APP_ID ： SkyWay コンソールから取得した「アプリケーションID」
    * SKYWAY_SECRET_KEY ： SkyWay コンソールから取得した「シークレットキー」


## 2. Amazon API Gateway で REST API を作成

フロントエンドから作成した関数を実行するために REST API を作成します。

<details>
<summary>作成手順</summary>
<div>

1. 「メソッドの作成」を行う。
GET メソッドで先ほど作成した Lambda 関数を呼び出すように設定します。
2. 「CORS の有効化」を行う。
プリフライトリクエスト用の OPTIONS メソッドを追加します。
3. 「API のデプロイ」を行う。
生成した URL にアクセスしてトークンが返却されることを確認します。

</div>
</details>

とりあえずバックエンドでトークンを生成して返却することができました。
ただ、このままでは URL さえ知られてしまえば第三者からアクセスできる状態であるため、Cognito を用いて認証機能を追加します。

## 3. Amazon Cognito でユーザーを作成

API にアクセスできるユーザーを管理するため、ユーザープールおよびユーザーを作成します。

### ユーザープールを作成

<details>
<summary>作成手順</summary>
<div>

1. サインインオプションで「ユーザー名」を選択する。 
2. パスワードポリシーで「Cognito のデフォルト」を選択する。
3. 多要素認証で「MFA なし」を選択する。
4. ユーザーアカウントの復旧で「セルフサービスのアカウントの復旧を有効化」のチェックを外す。
5. セルフサービスのサインアップで「自己登録を有効化」のチェックを外す。
6. Cognito アシスト型の検証および確認で「Cognito が検証と確認のためにメッセージを自動的に送信することを許可」のチェックを入れる。また、検証する属性で「E メールのメッセージを送信、E メールアドレスを検証」を選択する。
7. 属性変更の確認で「未完了の更新があるときに元の属性値をアクティブに保つ」のチェックを外す。
8. 必須の属性で何も選択しない。
9. E メールで「Cognito で E メールを送信」を選択する。
10. 任意のユーザープール名を入力する。
11. ホストされた認証ページで「Cognito のホストされた UI を使用」のチェックを外す。
12. 最初のアプリケーションクライアントで「パブリッククライアント」を選択する。また、任意のアプリケーションクライアント名を入力する。このとき、クライアントシークレットは生成しないを選択する。

</div>
</details>

### 作成したユーザープールにユーザーを作成

<details>
<summary>作成手順</summary>
<div>

1. 以下の内容を入力してユーザーを作成する。
招待メッセージ ： 「招待を送信しない」を選択
ユーザー名 ： 任意のユーザー名を入力
仮パスワード ： 「パスワードを設定」を選択

2. AWS CLI でパスワードを変更する。
Cognito で作成したユーザーの仮パスワードは**本来であればユーザー自身がパスワード変更を行う必要がありますが**、今回は簡略化のため AWS CLI を用いてパスワードを変更します。

```bash
aws cognito-idp admin-set-user-password --user-pool-id [ユーザープールID] --username [ユーザー名] --password [パスワード] --permanent
```

</div>
</details>

## 4. API Gateway でオーソライザーを設定

作成した Cognito のユーザープールを API のアクセス制御に用いるため、API Gateway でオーソライザーの設定を行います。

<details>
<summary>設定手順</summary>
<div>

1. API Gateway で 「新しいオーソライザーの作成」を行う。
タイプ : 「Cognito」を選択
Cognito ユーザープール : 作成したユーザープールを選択
トークンのソース : "Authorization" と入力
トークンの検証 : 空欄のまま

2. GET メソッドのメソッドリクエストを開いて、「認可」に先ほど作成したオーソライザーを設定する。

3. API のデプロイを行い、生成した URL にアクセスしてステータスコード 401 の未認証エラーが発生することを確認する。

</div>
</details>

これでバックエンドの実装（トークン生成 API の作成）は完了です。

## 5. フロントエンドの認証画面を作成

SkyWay のビデオ通話画面を作成する前に、Cognitoを用いた認証機能の部分を作成していきます。

まず最初に、ログイン ／ ログアウト ／ セッション取得 の処理を作成します。
Cognito の認証部分は Amazon-cognito-identity-js を用いて行います。

https://github.com/aws-amplify/amplify-js/tree/main/packages/amazon-cognito-identity-js

<details>
<summary>作成したコード（src/utils/cognito.js）</summary>
<div>

```javascript:plugins/cognito.js
import { CognitoUser, CognitoUserPool, AuthenticationDetails } from 'amazon-cognito-identity-js'

const userPool = new CognitoUserPool({
  UserPoolId: import.meta.env.VITE_COGNITO_USER_POOL_ID,
  ClientId: import.meta.env.VITE_COGNITO_CLIENT_ID
})

export default {
  login: async (username, password) => {
    const authenticationDetails = new AuthenticationDetails({
      Username: username,
      Password: password
    })
    const cognitoUser = new CognitoUser({
      Username: username,
      Pool: userPool
    })
    return new Promise((resolve, reject) => {
      cognitoUser.authenticateUser(authenticationDetails, {
        onSuccess: (result) => {
          resolve(result)
        },
        onFailure: (err) => {
          console.error(err.message || JSON.stringify(err))
          reject(err)
        }
      })
    })
  },

  logout: () => {
    const cognitoUser = userPool.getCurrentUser()
    if (cognitoUser === null) {
      throw new Error('UnAuthorized')
    }
    cognitoUser.signOut()
  },

  getIdToken: () => {
    return new Promise((resolve, reject) => {
      const cognitoUser = userPool.getCurrentUser()
      if (cognitoUser === null) {
        reject('UnAuthorized')
      }
      cognitoUser.getSession((err, session) => {
        if (err || !session.isValid()) {
          reject(err)
        } else {
          resolve(session.getIdToken().getJwtToken())
        }
      })
    })
  }
}
```

</div>
</details>

次に、認証画面を作成します。

<details>
<summary>作成したコード（src/views/LoginView.vue）</summary>
<div>

```vuejs:src/views/LoginView.vue
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import cognito from '@/utils/cognito'

const errorMessage = ref('')
const username = ref('')
const password = ref('')

const router = useRouter()
const login = () => {
  errorMessage.value = ''
  cognito
    .login(username.value, password.value)
    .then(() => {
      router.push({ path: '/skyway' })
    })
    .catch(() => {
      errorMessage.value = 'Failed login attempt.'
    })
}
</script>

<template>
  <form @submit.prevent="login()">
    <div>
      <label>username</label>
      <input type="text" v-model="username" required />
    </div>
    <div>
      <label>password</label>
      <input type="password" v-model="password" required />
    </div>
    <button type="submit">Login</button>
    <div v-if="errorMessage">{{ errorMessage }}</div>
  </form>
</template>
```

</div>
</details>

## 6. ビデオ通話画面を作成

最も重要である SkyWay を用いたビデオ通話画面（ログイン後の画面）を作成します。
基本的にはこちらのチュートリアルに沿って作成しました。

https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/

ただし、以下の点においてチュートリアルから変更を加えております。
* Vue.js で実装
* SkyWay Auth Token をフロントエンドで生成せず、バックエンドから取得

```vuejs:src/views/SkyWayView.vue
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { SkyWayContext, SkyWayRoom, SkyWayStreamFactory } from '@skyway-sdk/room'
import cognito from '@/utils/cognito'

const idToken = await cognito.getIdToken()
const { audio, video } = await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream()

const roomName = ref('')
const myId = ref('')

const fetchToken = () => {
  return axios
    .get(import.meta.env.VITE_FETCH_TOKEN_URL, {
      headers: { Authorization: idToken }
    })
    .then((response) => response.data.token)
}

const join = async () => {
  if (roomName.value === '') return

  const token = await fetchToken()
  const context = await SkyWayContext.Create(token)
  const room = await SkyWayRoom.FindOrCreate(context, {
    type: 'p2p',
    name: roomName.value
  })
  const me = await room.join()

  myId.value = me.id

  await me.publish(audio)
  await me.publish(video)

  const subscribeAndAttach = (publication) => {
    if (publication.publisher.id === me.id) return

    const buttonArea = document.getElementById('button-area')
    const subscribeButton = document.createElement('button')
    subscribeButton.textContent = `${publication.publisher.id}: ${publication.contentType}`
    buttonArea.appendChild(subscribeButton)

    subscribeButton.onclick = async () => {
      const { stream } = await me.subscribe(publication.id)

      let newMedia
      switch (stream.track.kind) {
        case 'video':
          newMedia = document.createElement('video')
          newMedia.playsInline = true
          newMedia.autoplay = true
          break
        case 'audio':
          newMedia = document.createElement('audio')
          newMedia.controls = true
          newMedia.autoplay = true
          break
        default:
          return
      }
      stream.attach(newMedia)
      const remoteMediaArea = document.getElementById('remote-media-area')
      remoteMediaArea.appendChild(newMedia)
    }
  }
  room.publications.forEach(subscribeAndAttach)
  room.onStreamPublished.add((e) => subscribeAndAttach(e.publication))
}

const router = useRouter()
const logout = () => {
  cognito.logout()
  router.push('/')
}

onMounted(async () => {
  const localVideo = document.getElementById('local-video')
  video.attach(localVideo)
  await localVideo.play()
})
</script>

<template>
  <p>ID: {{ myId }}</p>
  <div>
    room name: <input id="room-name" type="text" v-model="roomName" />
    <button id="join" @click="join()">join</button>
  </div>
  <video id="local-video" muted playsinline style="width: 400px"></video>
  <div id="button-area"></div>
  <div id="remote-media-area"></div>
  <button @click="logout()">Logout</button>
</template>
```

以上でフロントエンドの実装も完了しました。

# 動作確認

Netlify にデプロイして実施しました。
PC と スマートフォンで互いの映像および音声がやり取りできることを確認しました。

## ログイン画面

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/19840277-817f-e43b-6bab-b7fcf640cea3.png" width="300" />

## ビデオ通話画面

※ カメラを隠しているため、真っ黒で表示されています

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339770/a8fcf6e6-ac5c-e010-a1e7-a62cec962d5f.png" width="450" />

# おわりに

SkyWay のチュートリアルで省略されていた部分を自分なりに変更して動作確認まで行うことができました。
バックエンドでの SkyWay Auth Token の生成において、ルーム名やメンバーで制限すれば、よりセキュリティを高めることができそうです。

# GitHub

完成版の Vue プロジェクトはこちら

https://github.com/falya128/skyway-vue

AWS のデプロイを簡単に行うため、CDK プロジェクトも作成しております。
必要に応じてご利用ください。

https://github.com/falya128/skyway-vue-cdk