こんにちは。
以前、API GatewayのWebSocket APIをNext.JSから接続する検証を行いましたが、セキュリティ面は一切考慮していないので、どこからでもウェルカムな状態でした。
今回は、実際にAWSクラウド上で動作させるうえでセキュリティ面の強化をしていきます。
1. やりたいこと
Next.JSはLambda Web Adapterで稼働させるため、該当のLambdaからしか接続できないようにしたいです。
ソケットはクライアントサイドから接続する関係で、たぶん接続URLは漏れる前提になります。
他サイト(ドメイン)はもちろん、wscatコマンド等でも弾ければ堅牢と言えるのではないでしょうか??
2. どうやるか?
これらを満たせるであろうモノとして、IAM認証を使います。
API Gatewayの機能で、IAMでこのAPIの実行権限(execute-api:Invoke)を付与されたユーザーやサービスだけが使えるようになります。
APIキーは使用量プランを設定したいなら必須化してもいいと思いますが、セキュリティ面の対策としては不十分かと思ってます。(固定値なので、フロントからつなぐときに見えちゃう)
また、Lambdaオーソライザーもアリだと思いますが、Lambdaを増やすのもなぁ・・コールドスタートを引いちゃったら遅いだろうし・・というのもあって、なるべくならIAM認証を推したいです!
3. IAM認証と権限の設定
API Gatewayの$connectルート > ルートリクエスト > 認可:AWS_IAMにしてデプロイするだけでOKです。


また、Lambda Web AdapterのIAMロール(ポリシー)に「execute-api:Invoke」の権限を付与します。
{
"Action": [
"execute-api:Invoke"
],
"Effect": "Allow",
"Resource": "arn:aws:execute-api:(リージョン):(アカウントID):(API ID)/(ステージ)/*"
},
この時点で、wscatコマンドやブラウザからは接続できなくなります。

4. 署名コードの実装
ここが鬼門です。
権限付与だけで使えるものではなく、接続時に認証情報も送ることで正しい接続元かが判定され、通れば接続できます。
「SigV4」を使って署名付きソケットURLを生成して接続します。
署名ロジックをフロントでやってしまうとブラウザから見えてしまうので、バック側で実装します。
まあ、ロジックが見えてしまったところでIAMポリシーの内容がバレなければ使えないですが、見せないに越したことはないですね!
今回は、署名付きソケットURLを返すAPIを作成し、返ってきたやつに接続します。
「isLocal」はローカルかどうかをうま~く判定してくれるようにしてくださいw
4-1. app/~~/route.ts
import { NextResponse } from "next/server";
import { Hash } from "@aws-sdk/hash-node";
import { HttpRequest } from "@aws-sdk/protocol-http";
import { SignatureV4 } from "@aws-sdk/signature-v4";
import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { AwsCredentialIdentity } from "@aws-sdk/types";
import { isLocal } from "@/app/libs/env";
import { URL } from "url";
export const GET = async (): Promise<NextResponse> => {
const socketUrl = process.env.SOCKET_URL || "";
const urlObject = new URL(socketUrl);
// httpリクエスト
const httpRequest = new HttpRequest({
headers: {
host: urlObject.hostname,
},
hostname: urlObject.hostname,
path: urlObject.pathname,
protocol: urlObject.protocol,
});
// クレデンシャル
let credentials: AwsCredentialIdentity;
if (isLocal()) {
// ローカルの場合はIAMユーザーにexecute-api:Invokeを付与しておいて、アクセスキーをセット
credentials = {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
};
} else {
// クラウドの場合はIAMロールの権限を参照
// defaultProviderの返り値はasync functionなので、awaitをつけて返り値に対して実行する
// 下記と同じ
// const provider = defaultProvider();
// credentials = await provider();
credentials = await defaultProvider()();
}
// 署名
const signatureV4 = new SignatureV4({
credentials,
region: process.env.AWS_SOCKET_REGION || "",
service: "execute-api",
sha256: Hash.bind(null, "sha256"),
});
const signedHttpRequest = await signatureV4.presign(httpRequest, {
// 有効期限はとりあえず10秒
// あくまで接続URLの有効期限であって、API Gateway WebSocket最大接続時間(2時間)とは関係なし
expiresIn: 10,
});
// queryが存在しない場合、エラーを投げる
if (
!signedHttpRequest.query ||
Object.keys(signedHttpRequest.query).length === 0
) {
throw new Error("署名付きURLの取得に失敗");
}
// URLオブジェクトにセット
Object.entries(signedHttpRequest.query).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => urlObject.searchParams.append(key, v));
} else {
urlObject.searchParams.set(key, value ?? "");
}
});
return NextResponse.json(urlObject.toString());
};
4-2. app/~~/page.tsx
// app/~~/page.tsx
"use client";
import { useState, useEffect } from "react";
export default function Home() {
const [ws, setWs] = useState<WebSocket | null>(null);
const [msg, setMsg] = useState<string>("");
const [msgs, setMsgs] = useState<string[]>([]);
useEffect(() => {
(async () => {
// ソケットURLの取得
const res = await fetch("(APIのURL)", {
method: "GET",
});
const socketUrl = await res.json();
// 接続
const socket = new WebSocket(socketUrl);
// 接続時
socket.onopen = () => {
console.log("WebSocket connected");
};
// メッセージ受信時
socket.onmessage = event => {
const data = JSON.parse(event.data);
setMsgs(prevMsgs => [...prevMsgs, data.message]);
};
// 切断時
socket.onclose = () => {
console.log("WebSocket disconnected");
};
setWs(socket);
return () => {
socket.close();
};
})();
}, []);
const sendMessage = () => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ message: msg }));
setMsg("");
}
};
return (
<div>
<input
type="text"
size={30}
value={msg}
onChange={e => setMsg(e.target.value)}
/>
<button type="submit" onClick={sendMessage}>
送信
</button>
<ul>
{msgs.map((m, index) => (
<li key={index}>{m}</li>
))}
</ul>
</div>
);
}
page.tsxは、下記からソケットURLをAPIから取得するよう変更しただけです。
5. 動作確認
5-1. ローカル
アクセスキーを作成し、そのユーザーの権限に「execute-api:Invoke」を付与してください。
よーし。いけた。
5-2. AWSクラウド
Lambda Web Adapterは下記記事を参考に用意してください。
キターー\(^o^)/
たぶんセキュリティ面で最強だと思います。
IAM認証でソケット接続できました!
ローカル環境からはアクセスキーを使うことで、ローカル用のAPI GatewayのWebsocket APIも堅牢にできました🎉
めでたしめでたし。
6. 余談
ここまでできるようになるまで、めっちゃ大変でした・・
最初は大して調べもせずに、API GatewayのIAM認証って呼ぶ側にexecute-api:Invokeをつければいけるんじゃない?とか思って、見事につながらなくなりました。それが正しい挙動なのですがw
SigV4という認証プロセスを経てようやく使えるモノだとわかりましたが、ソケットで実現している参考記事が全然ないんです😭
HTTPのAPIだと実現やってる例はたくさんありますが、リクエストヘッダーに認証情報を付与する形しか見当たらず、ソケットには適用できませんでした。。
AWS公式ドキュメントだと、「IAM 認可を使用する場合は、Signature Version 4 (SigV4) でリクエストに署名する必要があります。」と書いてるので、ソケットにはどう渡せばいいんだ??ってなりましたね。
本記事の最下部にもリンクを貼ってますが、sign.mjsの「authMethod === ‘query’」という分岐を発見したことで流れが変わりました。
ヘッダーは使えないけど、クエリ文字列に付与すればいいのでは??これがビンゴでした‼️
エラーが出ずにただひたすら「failed to connect wss://~~」みたいなメッセージがブラウザのコンソールからしか出ず、API Gateway側??権限??それともロジック??ってなってましたね。
Lambda Web AdapterでNext.JSを動かしつつ、API Gatewayで構築したソケットにIAM認証で接続したいという(いかにコストをかけないかに楽しさを見出してしまっている変態な)エンジニアさんの助けになれば幸いです!
それでは🖐️
7. 参考
- https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-websocket-control-access-iam.html
- https://qiita.com/baochanh18/items/48180b7e6f93987f33ac
- https://loginov-rocks.medium.com/authorize-access-to-websocket-api-gateway-with-aws-signature-v4-f7e6b0e39f0a
- https://github.com/loginov-rocks/WebSocket-API-Gateway-IAM-Signer/blob/main/sign-function/src/sign.mjs
投稿者プロフィール

-
学んだことをアウトプットしていきます!
好きなこと:音楽鑑賞🎵 / ドライブ🚗 / サウナ🧖