AWS IoT Core、証明書もIAMも使わずJWTだけで繋ぐカスタム認証の仕組み

  • AWS
  • IoT

この記事は、構成と文章の整理にAIを使っています。
あくまで読みやすくするための補助で、内容そのものをAIにすべて任せて書いているわけではありませんが、ところどころ相談しながら整えています。

モバイルアプリからAWS IoT CoreにMQTT over WebSocketで繋いで、APIログイン後に発行したJWTで認証したい、という要件があった。X.509証明書もIAM/SigV4署名も使わずに、自前のJWT検証だけで接続を許可する構成が組めるか調べた。

Custom Authorizerとは

AWS IoT Coreには「Custom Authorizer」という仕組みがあって、接続時の認証・認可ロジックをLambda関数に丸ごと委譲できる。証明書もIAMクレデンシャルも要らないので、既存のID基盤(今回で言えばJWT)をそのまま持ち込める。

流れはこう。

sequenceDiagram
    participant App as モバイルアプリ
    participant API as ログインAPI
    participant IoT as AWS IoT Core
    participant Lambda as Custom Authorizer(Lambda)

    App->>API: ログイン
    API-->>App: JWT発行
    App->>IoT: MQTT over WebSocket接続(JWTをクエリパラメータで送付)
    IoT->>Lambda: 認証リクエスト
    Lambda->>Lambda: JWT検証(署名/iss/aud/exp/sub)
    Lambda-->>IoT: isAuthenticated + IoTポリシー
    IoT-->>App: 接続確立

JWTはブラウザ/WebView環境だとカスタムHTTPヘッダーが使いづらいので、クエリパラメータ経由でCONNECTのusername側に載せる形になる。

wss://{endpoint}/mqtt?x-amz-customauthorizer-name={authorizerName}&{tokenKeyName}={jwt}

Lambda側の役割

Lambdaは受け取ったJWTを検証して、接続の可否とIoTポリシーを返す。iss / aud / exp / subあたりを見て、Publish/Subscribeできるトピックを動的に組み立てる形が一般的。

レスポンスはこういう形式。

{
"isAuthenticated": true,
"principalId": "user123abc",
"disconnectAfterInSeconds": 1800,
"refreshAfterInSeconds": 300,
"policyDocuments": [
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["iot:Connect"], "Resource": "*" },
{ "Effect": "Allow", "Action": ["iot:Publish"], "Resource": "arn:aws:iot:example:example:topic/app/user123abc/#" },
{ "Effect": "Allow", "Action": ["iot:Subscribe"], "Resource": "arn:aws:iot:example:example:topicfilter/app/user123abc/#" }
]
}
]
}

disconnectAfterInSecondsとrefreshAfterInSecondsは300〜86400秒の範囲で指定する。JWTの有効期限に合わせてdisconnectAfterInSecondsを短めにしておくと、期限切れトークンでの接続維持を防げる。

Lambda関数のタイムアウトは5秒固定。JWKS取得なんかを都度やってると簡単に超えるので、キャッシュは必須。

実装するときにハマりそうな点

構成自体は素直だけど、細部で引っかかりそうなポイントが4つあった。

principalIdは英数字のみ

principalIdは([a-zA-Z0-9]){1,128}という正規表現に一致する必要がある。つまり英数字以外は一切ダメ。

JWTのsubをそのままuser:${sub}のような形で使うとコロンで弾かれるし、subがUUID(ハイフン入り)の場合もそのままだとNG。サニタイズしてから渡す必要がある。

const principalId = sub.replace(/[^a-zA-Z0-9]/g, "");

ALPNはWebSocketなら不要

「443ポート+ALPN拡張でmqttを指定する」という要件は生MQTT(TCP)接続の場合の話で、MQTT over WebSocketではALPNは不要(プロトコル対応表だと「ALPN: N/A」)。WebSocket接続で設計するなら気にしなくていい。

ちなみに2024年10月のアップデートで、SNI拡張だけで認証方式を判定できるようになり、生MQTTでもALPNを使わない構成が可能になったらしい。

signingDisabledの設定を忘れない

AWS IoT Core組み込みの署名検証(x-amz-customauthorizer-signature)はデフォルトで有効になっている。JWT自体がすでに署名済みトークンなので、Authorizer側の署名検証は不要なケースが多いが、その場合は明示的にsigningDisabled: trueを設定しないと接続が弾かれる。

token-key-nameはAuthorizer設定と一致させる

クエリパラメータ名(例:token-key-name=の部分)は、Authorizer作成時に指定する「Token key name」と一致していないといけない。ここがズレていても静かに失敗するので、命名を揃えておく。

まとめ

  • Custom Authorizerを使えば証明書もIAMも使わずJWTベースの認証ができる
  • principalIdは英数字のみ。UUIDやコロン付き文字列は必ずサニタイズする
  • WebSocket接続ならALPNは気にしなくていい
  • signingDisabledとToken key nameの整合は実装前に確認しておく