🔑

Socket.IOはどのようにアクセストークンを送っているのか

に公開

はじめに

標準の new WebSocket()(RFC 6455)はブラウザ実装で任意のリクエストヘッダー追加を禁止しています。そのため、ハンドシェイクの HTTP リクエストで任意の HTTP ヘッダーを付けることができません。
https://github.com/whatwg/websockets/issues/16#issuecomment-332065542

WebSocket でアクセストークンを送る方法を調べるといくつか選択肢があることが分かりました。ふとSocket.IOはどうしているのかと思って見てみたら下記の記法が使えるようでした。

// クライアント
const socket = io("https://example.com/namespace", {
  auth: {
    token: "123",
  },
});
// サーバー
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  // ...
});

HTTP の Authorization ヘッダーの感覚で送信ができて理想的です!
使う側は便利ですが、内部ではどのようにアクセストークンを送信しているのかが気になるのでリポジトリから調べてみたいと思います。
https://github.com/socketio/socket.io

この記事で触れないこと

  • Cookie でのトークン送信
  • 再接続、短命トークンに関連するフロー

最初に結論

接続確立後、最初の message で token を送信します。

Engine.IO について

Socket.IO は内部で Engine.IO という下位プロトコルを使って、実際の WebSocket や HTTP ロングポーリングとの接続確立・維持などを行っています。

トークンの渡し方

クライアントの初期化部分を見てみます。
https://github.com/socketio/socket.io/blob/6f9b198bc8b3abd3c6a5e5c0b81b7a89d181dff0/packages/socket.io-client/lib/socket.ts#L279-L285
最初の例のようにauthが渡されていれば Socket インスタンスに保存しています。

接続完了直後に呼ばれるonopenとその中で使われている_sendConnectPacketを見てみます。

https://github.com/socketio/socket.io/blob/6f9b198bc8b3abd3c6a5e5c0b81b7a89d181dff0/packages/socket.io-client/lib/socket.ts#L613-L642

_sendConnectPacketauthを受け取り、dataとして接続時のパケットを組み立てています。
PacketType.CONNECTとありますが、これは Socket.IO Protocol のパケットタイプで、CONNECTは接続時のパケットであることを表します。

Socket.IO Protocol についてはリポジトリ内にドキュメントがありました。
https://github.com/socketio/socket.io/blob/6f9b198bc8b3abd3c6a5e5c0b81b7a89d181dff0/docs/socket.io-protocol/v5-current.md

Type ID Usage
CONNECT 0 Used during the connection to a namespace.
DISCONNECT 1 Used when disconnecting from a namespace.
EVENT 2 Used to send data to the other side.
ACK 3 Used to acknowledge an event.
CONNECT_ERROR 4 Used during the connection to a namespace.
BINARY_EVENT 5 Used to send binary data to the other side.
BINARY_ACK 6 Used to acknowledge an event (the response includes binary data).

Engine.IO Protocol もあり、Engine.IO レベルでもパケットタイプがあります。
https://github.com/socketio/socket.io/blob/6f9b198bc8b3abd3c6a5e5c0b81b7a89d181dff0/docs/engine.io-protocol/v4-current.md

Type ID Usage
open 0 Used during the handshake.
close 1 Used to indicate that a transport can be closed.
ping 2 Used in the heartbeat mechanism.
pong 3 Used in the heartbeat mechanism.
message 4 Used to send a payload to the other side.
upgrade 5 Used during the upgrade process.
noop 6 Used during the upgrade process.

この時点で最初の送信とそれ以降で区別していることが分かります。

組み立てられたパケットは最終的にpacket()が呼ばれて送信されます。
https://github.com/socketio/socket.io/blob/6f9b198bc8b3abd3c6a5e5c0b81b7a89d181dff0/packages/socket.io-client/lib/socket.ts#L602-L611

ここまでをまとめると、以下のようになります。

  1. クライアント初期化時にauthを受け取り、インスタンスに保持
  2. サーバーと接続確立後、Engine.IO の open が呼ばれ、authを含めてパケットを組み立てる
  3. 組み立てたパケットが初回の message として送信される

本題のアクセストークンをどのように送っているかに対しては接続確立後の最初の message 送信で送っていることが分かりました!

トークンの受け取り

サーバーでトークンを受け取るまでのフローも追ってみます。

Socket.IO Protocol を読んでいくと、実際に送信される文字列についても記載があります。

Please also note that each Socket.IO packet is sent as a Engine.IO message packet (more information here),
so the encoded result will be prefixed by the character "4" when sent over the wire (in the request/response body with HTTP
long-polling, or in the WebSocket frame).

Format

<packet type>[<# of binary attachments>-][<namespace>,][<acknowledgment id>][JSON-stringified payload without binary]

+ binary attachments extracted

例えば下記のような初期化があります。

const socket = io("https://example.com/namespace", {
  auth: { token: "123" },
});

これは次のようなパケットに組み立てられます。

{ type: CONNECT, namespace: "/namespace", data: { token: "123" } }

パケットがエンコードされ、実際には次の形になります。

0/namespace,{"token":"123"}

Engine.IO レベルで見ると実際は先頭に message パケットタイプの 4 が付き、次のようになります。

40/namespace,{"token":"123"}
  • 4: Engine.IO パケットタイプ: message
  • 0: Socket.IO パケットタイプ: CONNECT
  • /namespace: 名前空間
  • {"token":"123"}: CONNECT パケットのdataペイロード

これが実際に送信されるデータになります。

サーバーではこれらをデコードしてSocketをインスタンス化しています。
https://github.com/socketio/socket.io/blob/6f9b198bc8b3abd3c6a5e5c0b81b7a89d181dff0/packages/socket.io/lib/socket.ts#L190
https://github.com/socketio/socket.io/blob/6f9b198bc8b3abd3c6a5e5c0b81b7a89d181dff0/packages/socket.io/lib/socket.ts#L196-L215

buildHandshakeがコンストラクタの中で呼ばれauthを含めたhandshakeオブジェクトが作られます。

これによりサーバー側でも下記の形式で取得できることがわかりました。

io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  // ...
});

まとめ

Socket.IO は接続確立後、最初の message で token を送信します。これにより、簡単にトークンの送信が行えることが分かりました!

chot Inc. tech blog

Discussion