RustでZoom OAuth認証を実装してみた


こちらは、[LabBaseテックカレンダー Advent Calendar 2024](https://qiita.com/advent-calendar/2024/labbase) の24日目の記事です。

# はじめに

Zoomの`OAuth 2.0`方式を使用してミーティングURLの発行に挑戦しました。思わぬところでかなりハマってしまい、その開発記録として、また同じように苦戦している方の参考になればと思い、この記事を書き残すことにしました。

今回は、Rustのウェブフレームワークである**Axum**を使い、ZoomのOAuthを用いて認証を行い、Zoom APIとの統合を実現しました。

# 実装例
https://github.com/evadcmd/zoom-demo-rs

###  機能紹介

主な機能は以下の通りです：
- ZoomユーザーのOAuth認証
- Zoom APIを使用したデータの取得や操作（`ミーティングURL`の発行など）

これらを実装するためには、OAuth 2.0認証の流れをある程度理解しておく必要があります。以下に、その概要をシンプルに説明します。


# OAuth2.0認証流れ
OAuth2.0認証には、次のような要素を含めます：
1. `ユーザー`（Resource Owner）: 認証を提供する人または主体
2. `アプリケーション`（Client）: ユーザーのデータにアクセスするアプリケーション
3. `認可サーバー`（Authorization Server）: 認可を管理するサーバー（アクセストークンを発行する）
4. `リソースサーバー`（Resource Server）: ユーザーのデータが保存されているサーバー

### 認証フローの図解
以下の図は、これらの要素間でどのように認証が行われるかを示しています：

```
  +--------+                               +---------------+
  |        |--(A)- Authorization Request ->|   Resource    |
  |        |                               |     Owner     |
  |        |<-(B)-- Authorization Grant ---|               |
  |        |                               +---------------+
  |        |
  |        |                               +---------------+
  |        |--(C)-- Authorization Grant -->| Authorization |
  | Client |                               |     Server    |
  |        |<-(D)----- Access Token -------|               |
  |        |                               +---------------+
  |        |
  |        |                               +---------------+
  |        |--(E)----- Access Token ------>|    Resource   |
  |        |                               |     Server    |
  |        |<-(F)--- Protected Resource ---|               |
  +--------+                               +---------------+
  ```

### OAuth 2.0が解決する問題とは？

極めてシンプルに言えば、OAuth 2.0が解決しようとする問題は、アプリケーションが他のサービスからユーザーのデータにアクセスする必要がある際に、どのようにして自分がユーザーの代理であることを証明するか、という点です。

#### 実例：ミーティングスケジュールアプリの場合

今回の実装を例に挙げると、あるミーティングスケジュールアプリがユーザーの代理としてユーザーのZoomアカウントを利用し、ミーティングURLを発行する場合を考えます。このとき、ミーティングスケジュールアプリが正当な代理権を持っていることをどのように証明するのでしょうか？

一つの方法として、ユーザーが自分のZoomアカウントのIDとパスワードをそのアプリに直接渡すことが考えられます。しかし、この方法はリスクが大きすぎます。では、より安全な方法はあるのでしょうか？

#### OAuth 2.0認証を利用する

OAuth 2.0認証を用いる場合、次のような流れで安全な認証が実現します：
1. ユーザーをZoomの認証サーバーにリダイレクト
ユーザーはZoomの認証サーバーでログインし、アプリへのアクセスを許可します。
2. 認証サーバーから許可コードやトークンを発行
許可を示すコードやトークンが発行され、それがミーティングスケジュールアプリにリダイレクトされて戻ります。
3. アプリがそのトークンを利用してリソースにアクセス
アプリはこのトークンを使い、一定期間ユーザーの代理としてZoomのリソース（例：ミーティング予約機能）を安全に使用できるようになります。


上記の流れをPython風の擬似コードで書いてみましょう

```python
@router.get(path="/zoom-auth")
def zoom_auth(token: str | None = None):
    if not token:
        # トークンが存在しない場合:
        # 1. Zoom認証サーバーのURLにリダイレクト
        # 2. ユーザーが認証を完了すると
        # 3. Zoom認証サーバーから https://${domain_name}/zoom_auth?token=${トークン} にリダイレクト
        return RedirectResponse(
            url="${Zoom認証サーバーのURL}"
        )
    else:
        # トークンが存在する場合:
        # 1. トークンを使用してミーティングを作成（トークンの有効期限内）
        # 2. 開催者用URLと参加者用URLを返却
        meeting = zoom.create_meeting(token)
        return {
            "start_url": meeting.start_url,
            "join_url": meeting.join_url,
        }
```

以上で、Zoom OAuth 2.0認証のロジックについての説明は終了です。

ただし、実務で使用する際には、いくつか注意すべき点があります。

# 実務上の注意点

### リダイレクト先の安全性確保について

認証サーバーは、ユーザーの認証後にリダイレクトされるURLの安全性を保証する必要があります。そのため、アプリケーションとリダイレクトURLを事前に認証サーバーに登録する必要があります。

#### 登録手順
1. [Zoom Marketplace](https://marketplace.zoom.us/) にアクセス
2. 右上の「`Develop` > `Build App`」からアプリを登録

#### *開発時の注意点*
ローカル環境でテストを行う場合は、[ngrok](https://ngrok.com/) を使用してローカルサーバーを一時的に公開し、`リダイレクト先`として設定することができます。

### スコープ権限の設定

Zoom APIを利用するために、Zoom Marketplaceで登録したアプリに適切なスコープ権限を設定する必要があります。

ミーティング作成に必要な基本的なスコープ：
- `meeting:read` : `ミーティング情報の読み取り`
- `meeting:write` : `ミーティングの作成・更新`

### トークン取得の流れについて
Zoomでは、トークン取得プロセスが少し複雑です。ユーザーが認証を行った直後にZoom認証サーバーが発行するのは、トークンではなく`code`です。この`code`を使用し、以下の手順でトークンを取得します：
- [Zoom Marketplace](https://marketplace.zoom.us/) でアプリ登録時に取得した`アプリID`と`Secret`を利用し、`base64`で暗号化し`Authorizationヘッダー`に追加
- `code`をペイロードに含めて、`application/x-www-form-urlencoded`仕様でZoomのトークンサーバーにリクエストを送信します
- トークンが返却されます


擬似コードをPythonらしいスタイルに変更
実際に動作するコードに近い形に修正しました。

```python
class Token(BaseModel):
    access_token: str
    token_type: str
    refresh_token: str
    expires_in: int
    scope: str
    api_url: str


def get_access_token(code: str) -> Token:
    payload = {
        "code": code,
        "grant_type": "authorization_code",
        "redirect_uri": secret.redirect_uri,
    }
    credentials = f"{secret.client_id}:{secret.client_secret}"
    base64_credentials = base64.b64encode(credentials.encode()).decode()
    headers = {
        "Authorization": f"Basic {base64_credentials}",
    }
    status, data = http.post(TOKEN_URL, headers, data=payload)
    if status == 200:
        return Token(**data)
    else:
        raise Exception(f"Error: {status}, {data}")


@router.get(path="/zoom-auth")
def zoom_auth(code: str | None = None):
    if not token:
        # トークンが存在しない場合:
        # 1. Zoom認証サーバーのURLにリダイレクト
        # 2. ユーザーが認証を完了すると
        # 3. Zoom認証サーバーから https://${domain_name}/zoom_auth?token=${トークン} 
        return RedirectResponse(
            url="${Zoom認証サーバーのURL}"
        )
    else:
        # トークンが存在する場合:
        # 1. トークンを使用してミーティングを作成（トークンの有効期限内）
        # 2. 開催者用URLと参加者用URLを返却
        token = await zoom.get_access_token(code, APP_ID, APP_SECRET)
        meeting = await zoom.create_meeting(token.access_token)
        return {
            "start_url": meeting.start_url,
            "join_url": meeting.join_url,
        }
```


#### トークンのキャッシュについて
サンプルコードでは簡略化のため毎回認証を行っていますが、実務での運用では以下の方法でトークンを適切に管理することが推奨されます：


##### トークンの保存
  - セキュアなストレージ（データベースなど）にトークンを保存
  - 暗号化した状態で保存することを推奨

##### トークンの再利用
  - 有効期限内のトークンを再利用し、不要な認証を削減
  - パフォーマンスとユーザー体験の向上

##### トークンの更新管理
  - トークンの有効期限を監視
  - 期限切れ前に自動的にリフレッシュトークンを使用して更新
  - エラー時の再認証フローの実装

##### セキュリティ上の注意点
  - トークンの漏洩を防ぐため、環境変数や設定ファイルには保存しない
  - アクセストークンとリフレッシュトークンは別々に管理
  - 定期的なトークンの無効化と再発行の仕組みを検討

# 実装例

https://github.com/evadcmd/zoom-demo-rs

### 起動手順

#### ローカルサーバーの公開

```bash
$ ngrok http 8000
```

表示された**ドメイン名**を記録しておきます

[Zoom Marketplace](https://marketplace.zoom.us/)にアプリを登録し、この**ドメイン名**を**redirect_uri**として設定します

#### 環境変数の設定

[Zoom Marketplace](https://marketplace.zoom.us/)で登録したアプリの認証情報を`.env`ファイルに記載します：
```bash
client_id=1234567890
client_secret=13245678901234567890
redirect_uri=https://12345678.ngrok.io
```

#### アプリケーションの起動

以下のコマンドでアプリケーションを起動します：

```bash
$ cargo r
```

#### 認証フローの確認

1. ブラウザで以下のURLにアクセスします： https://${公開したdomain名}/zoom-auth
2. Zoomのログインページにリダイレクトされます
3. ログイン認証後、ミーティングURLが発行されます

# まとめ
ZoomのOAuth 2.0認証を実装する際には、セキュリティ確保とAPI権限設定を十分に考慮し、正確な認証とトークン取得プロセスを構築することが重要です。本記事が、Zoom APIを使用したアプリケーション開発におけるガイドとして役立つことを願っています。


## 参考資料
  - https://openid-foundation-japan.github.io/rfc6749.ja.html
  - https://developers.zoom.us/docs/api/using-zoom-apis/
  - https://developers.zoom.us/docs/integrations/oauth/