WebSocketの最適な認証をいろんな手段から考えてみる


この記事は[NTTコムウェア Advent Calendar 2022](https://qiita.com/advent-calendar/2022/nttcomware) 17日目の記事です。

# はじめに
NTTコムウェアの寺島です。
考えようと思って、考えられていなかったことを記事にしてまとめようと思い、今年のアドベントカレンダーに参加しました。
私が考えたかったことはWebSocketの認証になります。よく双方向通信を行うユースケースとしてWebSocketを採用することが多いですが、「認証ってどんなように実現したら良いだろうか？」といった所を考えて纏めてみました。

# WebSocketとは何か。
WebSocketはWebにおいて、クライアント及びサーバ間で双方向通信するためのプロトコルです。Slack等のチャットシステムやリアルタイムダッシュボードなどのユースケースで利用されています。
WebSocket誕生前は、既存のHTTP通信を利用したポーリング方式やロングポーリング方式がありましたが、通信のオーバーヘッドが大きく効率性が低かったです。
WebSocketは80/443の既存のHTTP通信ポートを利用しつつ、ハンドシェイク後からHTTPと異なるプロトコルで通信を行う事で双方向通信の効率を向上させています。

これは余談ですが、今年、勧告されましたHTTP3をベースにしたWebTransportといった新たな双方向通信プロトコルの仕様の提案がなされています。
主な違いとしてWebSocketはトランスポート層においてTCPベースを利用してますが、WebTransportはトランスポート層としてUDPを利用しています（HTTP3がUDP利用のため）。これはWebSocketの課題（Head of Line Blocking/映像配信などの複合的なユースケースの適用）解決を図り、より効率的な双方向通信のプロトコルとなるものです。

# WebSocketのプロトコル
認証を考える前にWebSocketがどのようなプロトコルなのか振り返って見たいと思います（HTTP1.1の仕様で）。
WebSocketの通信は、ハンドシェイクとデータ転送の2つのパートに分けられます。
![WebSocket通信.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2303038/e98f74b5-17b3-9579-a84d-714e1f00a022.png)

ハンドシェイクはまずクライアントから以下のような通信が行われます。

```
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
```

これを受けたサーバ応答は次のようなものです。

```
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
```

ここのハンドシェイクでポイントとなるのは`Sec-WebSocket-Key`と`Sec-WebSocket-Accept`となります。
`Sec-WebSocket-Key`はクライアントが生成したnonceをbase64コード化したものを設定します。サーバは受け取ったnoceを元に特定のアルゴリズムをもとにハッシュコードを生成し、`Sec-WebSocket-Accept`に設定してクライアントに返却します。
この値があることによってクライアントはサーバがWebSocketで通信ができるかどうか検査できます。

ハンドシェイク後は、データ転送は以下のようなフレームで行われます（詳細は割愛）。

```
0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+
```
見てわかる通り、ハンドシェイクまではHTTP通信でしたが、ハンドシェイク確立後は利用ポートは同じまま全く異なる通信方式になります。
企業内でブラウザからインターネットへの通信をフォワードプロキシで検査する製品を導入している場合がありますが、WebSocketのこのデータフレームにプロキシが対応していないことで通信が遮断されることがあります。
弊社内でも同様にフォワードプロキシにて通信が検査されており、社内で利用しているチャットシステムが利用できないと言った事がありました。そこで、WebSocketを利用した特定通信のみプロキシを透過させると言った対応をプロキシ管理者にお願いしたことがあります。

# WebSocketの認証を考える
ようやく本題です。WebSocketの認証をどうすればよいか？考えて行きたいと思います。

まずRFCの仕様を確認してみます。

> 10.5. WebSocket クライアント認証
> このプロトコルは、 WebSocket ハンドシェイクの間にサーバがクライアントを認証できるような，ある特定の仕方は規定しない。 WebSocket サーバは、 汎用 HTTP サーバにて可用なクライアント認証の仕組み — クッキー, HTTP 認証, TLS 認証など — のうち，どれでも利用できる。

特に規定されていないようです。
認証としてはハンドシェイクのタイミングで既存のHTTPの認証方式でクライアントを認証するのが良さそうに見えます。
ハンドシェイク確立後のデータ通信のタイミングで認証のやり取りも不可能ではないですが、認証と処理ロジックを分離してGatewayで認証させたいやHTTPの認証の仕組みをそのまま利用したいと言った要件がある場合は、HTTPで通信のやり取りが行われているハンドシェイク確立時に行うのが良さそうです。

ハンドシェイク確立時の認証方式としてやりやすい方法は、Cookieを用いた認証と思います。ハンドシェイクのクライアント要求時にCookieを付与して送信し、Cookieを検証後に認証OK・NGを実施する形です。Cookieを利用して認証と認可を行っているシステムであれば、導入しやすいかと思います。
最近のシステム構成ではマイクロサービス構成を取ることもあり、Token(JWT)を利用してステートレスにクライアントを認証・認可するシステムが多いと思います。
そのため、以下Tokenを利用した認証に関して考えてみたいと思います。

## Authorizationヘッダーは利用できる？
残念ながら利用できません。
よくAPI通信の時にHTTPヘッダーに`Authorization: Bearer XXXX`とTokenを付与してサーバにて認証・認可を行います。
しかし、ブラウザでWebsocket通信を利用する場合はWebSocketAPIを利用します。WebSocketAPIの仕様を見ると特定のヘッダーは利用できないようです。

```javascript
socket = new WebSocket(url [, protocols ])
```
このprotocolsは`Sec-WebSocket-Protocol`ヘッダーの設定に利用するため、使えるかもですが仕様としては正しくない値を設定してしまうように思います。

またWebSocket利用で有名なライブラリのSocket.ioを確認した所、extraheaderに設定すればヘッダー付与可能のようですが、上記と同じ理由でブラウザは利用できないようです。
https://socket.io/docs/v3/client-initialization/#extraheaders

## QueryStringの利用
現状のブラウザのWebSocketAPIを利用して、Tokenを送信するにはQueryStringを利用して送信する方法しかないようです。
利用イメージは次のようなイメージです。

```
socket = new WebSocket('ws://example.com?token=xxxxxx')
```

この利用で気になるのはTokenがURLにつくのでサーバのアクセスログ等にTokenが記録される点です。
そのため、Tokenは有効時間が短い一時的なものを利用する事が推奨されます。

ここからは、WebSocketを利用しているサービスの認証の実装方法がどうなっているのかAWSのサービスを例に確認したいと思います。
WebSocketを利用しているAWSサービスはAppSyncとAPI Gatewayとなります。

### AppSync
AppSyncはハンドシェイク確立時に以下を送ると記載されています。

> header: AWS AppSync エンドポイントと認証に関連する情報が含まれます。これは、文字列化された JSON オブジェクトからの base64 エンコードされた文字列です。JSON オブジェクトのコンテンツは、認証モードによって異なります。

具体的に「Amazon Cognito ユーザープールと OpenID 接続 (OIDC)」の場合のheaderとリクエストURLは次の通りです。

header
``` javascript
{
    "Authorization":"eyEXAMPLEiJjbG5xb3A5eW5MK09QYXIrMTJHWEFLSXBieU5WNHhsQjEXAMPLEnM2WldvPSIsImFsZyI6IlEXAMPLEn0.eyEXAMPLEiJhNmNmMjcwNy0xNjgxLTQ1NDItOWYxOC1lNjY0MTg2NjlkMzYiLCJldmVudF9pZCI6ImVkMzM5MmNkLWNjYTMtNGM2OC1hNDYyLTJlZGI3ZTNmY2FjZiIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoiYXdzLmNvZ25pdG8uc2lnbmluLnVzZXIuYWRtaW4iLCJhdXRoX3RpbWUiOjE1Njk0NTc3MTgsImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC5hcC1zb3V0aGVhc3QtMi5hbWF6b25hd3MuY29tXC9hcC1zb3V0aGVhc3QtMl83OHY0SVZibVAiLCJleHAiOjE1Njk0NjEzMjAsImlhdCI6MTU2OTQ1NzcyMCwianRpIjoiNTgzZjhmYmMtMzk2MS00YzA4LWJhZTAtYzQyY2IxMTM5NDY5IiwiY2xpZW50X2lkIjoiM3FlajVlMXZmMzd1N3RoZWw0dG91dDJkMWwiLCJ1c2VybmFtZSI6ImVsb3EXAMPLEn0.B4EXAMPLEFNpJ6ikVp7e6DRee95V6Qi-zEE2DJH7sHOl2zxYi7f-SmEGoh2AD8emxQRYajByz-rE4Jh0QOymN2Ys-ZIkMpVBTPgu-TMWDyOHhDUmUj2OP82yeZ3wlZAtr_gM4LzjXUXmI_K2yGjuXfXTaa1mvQEBG0mQfVd7SfwXB-jcv4RYVi6j25qgow9Ew52ufurPqaK-3WAKG32KpV8J4-Wejq8t0c-yA7sb8EnB551b7TU93uKRiVVK3E55Nk5ADPoam_WYE45i3s5qVAP_-InW75NUoOCGTsS8YWMfb6ecHYJ-1j-bzA27zaT9VjctXn9byNFZmEXAMPLExw",
    "host":"example1234567890000.appsync-api.us-east-1.amazonaws.com"
}
```

リクエストURL
```
wss://example1234567890000.appsync-realtime-api.us-east-1.amazonaws.com/graphql?header=eyJBdXRob3JpemF0aW9uIjoiZXlKcmFXUWlPaUpqYkc1eGIzQTVlVzVNSzA5UVlYSXJNVEpIV0VGTFNYQmllVTVXTkhoc1FqaFBWVzlZTW5NMldsZHZQU0lzSW1Gc1p5STZJbEpUTWpVMkluMC5leUp6ZFdJaU9pSmhObU5tTWpjd055MHhOamd4TFRRMU5ESXRPV1l4T0MxbE5qWTBNVGcyTmpsa016WWlMQ0psZG1WdWRGOXBaQ0k2SW1Wa016TTVNbU5rTFdOallUTXROR00yT0MxaE5EWXlMVEpsWkdJM1pUTm1ZMkZqWmlJc0luUnZhMlZ1WDNWelpTSTZJbUZqWTJWemN5SXNJbk5qYjNCbElqb2lZWGR6TG1OdloyNXBkRzh1YzJsbmJtbHVMblZ6WlhJdVlXUnRhVzRpTENKaGRYUm9YM1JwYldVaU9qRTFOamswTlRjM01UZ3NJbWx6Y3lJNkltaDBkSEJ6T2x3dlhDOWpiMmR1YVhSdkxXbGtjQzVoY0MxemIzVjBhR1ZoYzNRdE1pNWhiV0Y2YjI1aGQzTXVZMjl0WEM5aGNDMXpiM1YwYUdWaGMzUXRNbDgzT0hZMFNWWmliVkFpTENKbGVIQWlPakUxTmprME5qRXpNakFzSW1saGRDSTZNVFUyT1RRMU56Y3lNQ3dpYW5ScElqb2lOVGd6WmpobVltTXRNemsyTVMwMFl6QTRMV0poWlRBdFl6UXlZMkl4TVRNNU5EWTVJaXdpWTJ4cFpXNTBYMmxrSWpvaU0zRmxhalZsTVhabU16ZDFOM1JvWld3MGRHOTFkREprTVd3aUxDSjFjMlZ5Ym1GdFpTSTZJbVZzYjNKNllXWmxJbjAuQjRjZEp0aDNLRk5wSjZpa1ZwN2U2RFJlZTk1VjZRaS16RUUyREpIN3NIT2wyenhZaTdmLVNtRUdvaDJBRDhlbXhRUllhakJ5ei1yRTRKaDBRT3ltTjJZcy1aSWtNcFZCVFBndS1UTVdEeU9IaERVbVVqMk9QODJ5ZVozd2xaQXRyX2dNNEx6alhVWG1JX0syeUdqdVhmWFRhYTFtdlFFQkcwbVFmVmQ3U2Z3WEItamN2NFJZVmk2ajI1cWdvdzlFdzUydWZ1clBxYUstM1dBS0czMktwVjhKNC1XZWpxOHQwYy15QTdzYjhFbkI1NTFiN1RVOTN1S1JpVlZLM0U1NU5rNUFEUG9hbV9XWUU0NWkzczVxVkFQXy1Jblc3NU5Vb09DR1RzUzhZV01mYjZlY0hZSi0xai1iekEyN3phVDlWamN0WG45YnlORlptS0xwQTJMY3h3IiwiaG9zdCI6ImV4YW1wbGUxMjM0NTY3ODkwMDAwLmFwcHN5bmMtYXBpLnVzLWVhc3QtMS5hbWF6b25hd3MuY29tIn0=&payload=e30=
```

このように、header情報をbase64に変換した後にQueryStringでエンドポイントにリクエストしています。

### API Gateway
API GateWayではIAMを利用しない場合はLambda オーソライザーでのアクセスコントロールとなります。
以下はLambda オーソライザーでの認証のサンプルコードになりますが、QueryStringから認証情報を取り出していることがわかります。

``` javascript
export const handler: APIGatewayRequestAuthorizerHandler = async (event, context) => {
    try {
        const verifier = CognitoJwtVerifier.create({
            userPoolId: UserPoolId,
            tokenUse: "id",
            clientId: AppClientId,
        });

        const encodedToken = event.queryStringParameters!.idToken!;
        const payload = await verifier.verify(encodedToken);
        console.log("Token is valid. Payload:", payload);

        return allowPolicy(event.methodArn, payload);
    } catch (error: any) {
        console.log(error.message);
        return denyAllPolicy();
    }
};
```

# まとめ
今回は、サーバと双方向通信を行えるWebSocketの認証はどのように実現したら良いか考えてみました。
結論としてはハンドシェイク確立時にCookieやTokenを利用したHTTPの認証方式を採用することが良いと考えられます。またToken利用時には流出のリスクを考慮して、Tokenの有効期限を短く設定することを推奨します。
最後に、この記事がWebSocketの認証の検討の助けになれば幸いです。

# 参考URL
- [RFC 6455 — The WebSocket Protocol](https://triple-underscore.github.io/RFC6455-ja.html)
- [WebSocket API](https://developer.mozilla.org/ja/docs/Web/API/WebSocket)
- [WebSocketのセキュリティ](https://devcenter.heroku.com/ja/articles/websocket-security)
- [リアルタイム環境の構築 WebSocket クライアント](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-websocket-api-lambda-auth.html)
- [Lambda REQUEST オーソライザーの関数の作成](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/real-time-websocket-client.html)
- [aws-samples/websocket-api-cognito-auth-sample](https://github.com/aws-samples/websocket-api-cognito-auth-sample)