ブラウザアプリからAWS IoTのカスタムオーソライザーが使えなかった件

ブラウザからAWS IoTに接続する際、カスタムオーソライザーを利用して良い感じに認証・認可したかったのですが、実現不可能ということが判明したので情報共有させて頂きます。
2018.09.24

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

サーバーレス開発部@大阪の岩田です。 ブラウザからAWS IoTに接続する際、カスタムオーソライザーを利用して良い感じに認証・認可できないか? ということを調べていたのですが、実現不可能だということが分かりました。

カスタムオーソライザーの設定手順と合わせて、なぜ無理だったのか? 情報共有させて頂きます。

なぜ無理だったのか?

まず最初に結論から。

後述しますが、カスタムオーソライザーを利用するには、WebSocketでAWS IoTに接続する際に、x-amz-customauthorizer-signature等のHTTPヘッダを適切に設定する必要があります。 しかし、JavaScriptのWebSocketAPIではHTTPヘッダを操作することができません。 そのため、ブラウザからはカスタムオーソライザーを利用することが出来ないのです。

ブラウザの設定でプロキシにOWASP ZAPを指定し、OWASP ZAPの方でHTTPヘッダを調整するという力技で動作確認はできたので、一応手順もご紹介してきます。

カスタムオーソライザーの概要

カスタムオーソライザーはLambda関数を利用してAWS IoTに独自の認証・認可ロジックを実装する機能です。

  • エンドポイントにカスタムオーソライザーが設定されている
  • HTTP→WebSocketへのプロトコルアップグレードがリクエストされた
  • HTTPリクエストにSigv4の署名が付いていない

という条件を満たした場合にカスタムオーソライザーのワークフローが実行されます。

カスタムオーソライザーのワークフローは下記の通りです。※AWSのブログから引用

https://aws.amazon.com/jp/blogs/security/how-to-use-your-own-identity-and-access-management-systems-to-control-access-to-aws-iot-resources/

API GatewayのLambdaオーソライザーと異なる点として、上記画像の(1)の部分が挙げられます。 デバイスからHTTPリクエストを送信する際に適切なHTTPヘッダを付与してやる必要があります。 必要なヘッダは以下の3つです。

  • x-amz-customauthorizer-name
  • <カスタムオーソライザーに設定された、トークンを設定するためのHTTPヘッダ>
  • x-amz-customauthorizer-signature

それぞれの意味は下記の通りです。

x-amz-customauthorizer-name

実行するカスタムオーソライザーの名前を設定します。 エンドポイントにデフォルトのカスタムオーソライザーが設定されている場合は不要です。

<カスタムオーソライザーに設定された、トークンを設定するためのHTTPヘッダ>

独自の認証・認可サービスから払い出されたトークンを設定します。 HTTPヘッダ自体の名称はカスタムオーソライザーの設定から自由に変更可能です。

x-amz-customauthorizer-signature

トークンに対する署名を設定します。 この署名は認証・認可サービスの秘密鍵を利用して、トークンに対して作成された署名です。 秘密鍵に対応する公開鍵をカスタムオーソライザーに登録しておくことで、AWS IoTがトークンと署名の妥当性を検証します。 トークンと署名の妥当性が検証できなかった場合は、Lambdaが起動されずにエラーのレスポンスが返却されます。

カスタムオーソライザーの設定手順

それでは実際にカスタムオーソライザーを設定してみます。 今回はトークンと署名を作成するためにCognitoを利用してみました。 トークンと署名を取得するのに手頃だったのでCognitoを利用しましたが、Cognito認証とカスタムオーソライザーは全くの別物です!!混同しないようにして下さい。

Cognitoユーザープール等の作成

下記のCFnテンプレートから作成しました。

AWSTemplateFormatVersion: '2010-09-09'
Description: Create Cognito User Pool
Resources:
  CognitoUserPoolMyUserPool:
    Type: "AWS::Cognito::UserPool"
    Properties:
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: false
        UnusedAccountValidityDays: 7
      AutoVerifiedAttributes:
        - email
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: false
          RequireNumbers: true
          RequireSymbols: false
          RequireUppercase: false
      Schema:
        - AttributeDataType: "String"
          DeveloperOnlyAttribute: false
          Mutable: true
          Name: "email"
          StringAttributeConstraints:
            MaxLength: "2048"
            MinLength: "0"
          Required: true
        - AttributeDataType: "String"
          DeveloperOnlyAttribute: false
          Mutable: true
  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName:
        Fn::Join:
          - ""
          - - Ref: AWS::StackName
            - UserPoolClient
      GenerateSecret: false
      RefreshTokenValidity: 7
      UserPoolId:
        Ref: CognitoUserPoolMyUserPool
  MyIdentifyPool:
    Type: "AWS::Cognito::IdentityPool"
    Properties:
      IdentityPoolName: MyIdentifyPool
      AllowUnauthenticatedIdentities: true
      CognitoIdentityProviders:
        - ClientId:
            Ref: UserPoolClient
          ProviderName:
            Fn::Join:
            - ""
            - - cognito-idp.
              - Ref: "AWS::Region"
              - .amazonaws.com/
              - Ref: CognitoUserPoolMyUserPool
          ServerSideTokenCheck: false
  CognitoUnAuthRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Federated:
                - "cognito-identity.amazonaws.com"
            Action:
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals: 
                "cognito-identity.amazonaws.com:aud": !Ref MyIdentifyPool
              ForAnyValue:StringLike:
                "cognito-identity.amazonaws.com:amr": unauthenticated              
  CognitoAuthRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Federated:
                - "cognito-identity.amazonaws.com"
            Action:
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals: 
                "cognito-identity.amazonaws.com:aud": !Ref MyIdentifyPool
              ForAnyValue:StringLike:
                "cognito-identity.amazonaws.com:amr": authenticated
  CognitoRoleMapping:
    Type: AWS::Cognito::IdentityPoolRoleAttachment
    Properties:
      IdentityPoolId: !Ref MyIdentifyPool
      Roles: 
        authenticated: !GetAtt CognitoAuthRole.Arn
        unauthenticated: !GetAtt CognitoUnAuthRole.Arn
                
Outputs:
  UserPoolId:
    Description: "User Poll ID"
    Value:
      Ref: CognitoUserPoolMyUserPool
  UserPoolClient:
    Description: 'User Pool Client ID'
    Value:
      Ref: UserPoolClient    
  MyIdentifyPool:
    Description: 'Identify Pool ID'
    Value:
      Ref: MyIdentifyPool

Lambdaの作成

認証・認可のロジックを実行するLambdaを作成します。 今回はNode.js 8.10で下記のようなLambdaを作成しました。

exports.handler = async (event) => {
    
    const policy = [{
        Version: "2012-10-17",
        Statement:[{
            Action: "iot:*",
            Effect: "Allow",
            Resource: "*"
        }]
    }];

    return JSON.stringify({
        isAuthenticated: true,
        principalId: "1",
        disconnectAfterInSeconds: 86400,
        refreshAfterInSeconds: 300,
        policyDocuments: policy,
        context: {
            username: "hogehoge"
        }
    });
};

ポリシーとして常にiot:*を返却していますが、実際に利用する際はユーザーに応じた適切なポリシーを返却することになります。

公開鍵の取得

AWS IoTがトークンと署名を検証するために、署名に使用した秘密鍵と対になる公開鍵が必要になります。 Cognitoの場合は、https://cognito-idp.<リージョン>.amazonaws.com/<ユーザープールID>/.well-known/jwks.json というURLで公開鍵を取得できます。

上記のURLにアクセスすると

{"keys":[{"alg":"RS256","e":"AQAB","kid":"xxxxxxx","kty":"RSA","n":"xxxxxw","use":"sig"},{"alg":"RS256","e":"AQAB","kid":"xxxxx","kty":"RSA","n":"xxxxx","use":"sig"}]}

このようにJWK形式で公開鍵の情報が取得できます。このままだとAWS IoTで利用できないので、PEM形式に変換します。

JWKからPEMへの変換

Node.jsにjwk-to-pemというモジュールがあるので、これを利用します。 npm install jwk-to-pem jsonwebtokenでモジュールを導入した後、下記のコードで変換します。

jwkToPem.js

const jwkToPem = require('jwk-to-pem'),
jwt = require('jsonwebtoken');

const jwk1 = {"alg":"RS256","e":"AQAB","kid":"xxxxxxx","kty":"RSA","n":"xxxxxxx","use":"sig"}
const pem1 = jwkToPem(jwk1)
console.log(pem1)

const jwk2 = {"alg":"RS256","e":"AQAB","kid":"xxxxxxx","kty":"RSA","n":"xxxxxxx","use":"sig"}
const pem2 = jwkToPem(jwk2)
console.log(pem2)

node jwkToPem.jsで変換を実行すると、PEM形式の公開鍵が出力されます。

-----BEGIN PUBLIC KEY-----
<1つ目の公開鍵>
-----END PUBLIC KEY-----

-----BEGIN PUBLIC KEY-----
<2つ目の公開鍵>
-----END PUBLIC KEY-----

変換成功です。

カスタムオーソライザーの登録

ここからは実際にカスタムオーソライザー を設定していきます。

カスタムオーソライザーの作成

AWSマネジメントコンソールから作成していきます。 まずは「オーソライザーの作成」を選択します。

次に設定画面が開くので必要な項目を入力します。

「Lambda関数」に先ほど作成したLambdaを、「公開鍵に署名するトークン」に先ほど取得したCognitoの公開鍵を設定します。 「オーソライザーを作成じにアクティブに・・・」にチェックを入れないとオーソライザーがアクティブ化されないので注意が必要です。

必要な項目が入力できたら「オーソライザーの作成」を押下し、作成完了です。

AWS IoTにLambdaを呼び出す権限を付与

AWS IoTがLambdaを呼び出せるように権限を付与します。

aws lambda add-permission --function-name <Lambdaのfunction名> --statement-id <適当なSID> \
    --action 'lambda:InvokeFunction' \
        --principal iot.amazonaws.com \
    --source-arn arn:aws:iot:<作成したカスタムオーソライザーのarn>

テスト

カスタムオーソライザーが設定できたのでテストしていきます。 テストにはReactとAmplifyで作った簡易なアプリを用います。

バージョンなど

検証に用いた環境の各種バージョンは下記の通りです。

  • Node.js:v8.10.0
  • aws-amplify:1.0.11
  • aws-amplify-react:2.0.1
  • base64url:3.0.0
  • react:16.5.0
  • react-dom:16.5.0
  • react-scripts:1.1.5
  • paho-mqtt:1.0.4

Reactアプリの作成

まずcreate-react-appでアプリのひな形を作成し、npm install --save aws-amplify aws-amplify-react paho-mqtt base64urlで追加のライブラリを導入します。

ライブラリが導入できたら、App.jsを下記のように修正します。 ハイライト箇所は自分の環境に合わせて修正して下さい。

App.js

import React, { Component } from 'react';
import './App.css';
import { withAuthenticator } from 'aws-amplify-react';
import Amplify, {Auth } from 'aws-amplify';
import base64url from 'base64url'
import { Client } from 'paho-mqtt'

Amplify.configure({
  Auth: {
    region: 'us-east-1',
    identityPoolId: 'us-east-1:xxxxxxxxxx',
    userPoolId: 'us-east-1_xxxxxxxxxx', 
    userPoolWebClientId: 'xxxxxxxxx', 
  }
})

class App extends Component {

  constructor(props) {
    super(props);
    this.state = {
      messages: [],
      topic: '',
      is_subscribe: false
    }
    this.handleClick = this.handleClick.bind(this)
    this.handleClear = this.handleClear.bind(this)
    this.handleChangeTopic = this.handleChangeTopic.bind(this)
    this.handleReceive = this.handleReceive.bind(this)
  }

  render() {
    return (
      <div className="App">
        <input type="text" onChange={this.handleChangeTopic}></input>
        <button onClick={this.handleClick}>{this.state.is_subscribe? 'stop': 'subscribe'}</button>
        <button onClick={this.handleClear}>clear</button>
        <MessageList messages={this.state.messages} />
      </div>
    )
  }
  
  handleClick(topic){
    Auth.currentSession().then(info=>{
      const tokens = info.accessToken.jwtToken.split(".")
      console.log(tokens[0] + '.' + tokens[1])
      console.log(base64url.toBase64(tokens[2]))
    })
    
    if (this.state.is_subscribe){
      console.log("stop subscribe")
      this.client.disconnect()
      this.setState({is_subscribe: false})
      return
    }
    
    this.client = new Client('wss://xxxxxxxxxx.iot.us-east-1.amazonaws.com/mqtt', 'hogehoge')
    this.client.onMessageArrived = this.handleReceive
    this.client.connect({
      useSSL: true,
      mqttVersion: 3,
      onSuccess: () => {
        console.log('connect success!!')
        this.client.subscribe(this.state.topic)
      },
      onFailure: () => console.log('connect failure...')
    })
    this.setState({is_subscribe: true})
  }

  handleClear(event){
    this.setState({messages: []})
  }

  handleChangeTopic(event) {
    this.setState({topic: event.target.value})
  }

  handleReceive(msg){
    const messages = this.state.messages
    messages.push(msg.payloadString)
    this.setState({messages: messages})
  }
}


class MessageList extends React.Component {

  render() {
    let id = 1
    return (
      <ul>
        {this.props.messages.map(message => {
          id += 1
          return (
           <li key={id}>
             <div>
               {message}
             </div>
           </li>
         )
       })}
     </ul>
    )
  }
}

export default withAuthenticator(App);

ポイントとしては、ボタンクリック時の処理

Auth.currentSession().then(info=>{
  const tokens = info.accessToken.jwtToken.split(".")
  console.log(tokens[0] + '.' + tokens[1])
  console.log(base64url.toBase64(tokens[2]))
})

の部分でCognitoのアクセストークンからJWTトークンを取り出し、

  • ヘッダー+クレーム
  • 署名をBASE64形式に変換した値

をそれぞれコンソールに出力しています。 出力した値は後ほどカスタムオーソライザーを呼び出す際に利用します。

AWS CLiを使ってテスト

npm startでアプリを起動すると、サインイン画面が表示されるので、サインアップ&サインインします。

サインイン後に表示された画面でsubscribeボタンを押下すると、コンソールにAWS IoTに送るべきトークンと署名が出力されるのでコピーします。

一緒にエラーも出力されていますが、認証エラーでAWS IoTに接続できていないことが原因です。 カスタムオーソライザーが正しく機能していそうです。

コピーした値を使用し、AWS Cliで下記のコマンドを叩いて動作確認します。

aws iot test-invoke-authorizer --authorizer-name <作成したカスタムオーソライザーの名前> --token <出力されたトークン> --token-signature <出力された署名>

下記のようなポリシードキュメントが返却されれば成功です。

{
    "isAuthenticated": true,
    "principalId": "1",
    "policyDocuments": [
        "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":\"iot:*\",\"Effect\":\"Allow\",\"Resource\":\"*\"}]}"
    ],
    "refreshAfterInSeconds": 300,
    "disconnectAfterInSeconds": 86400
}

Reactアプリからテスト

ここまでで、カスタムオーソライザーがうまく設定できていることが確認できました。 いざ、Reactアプリを実装していこうと思ったのですが、、、 冒頭で説明したようにJavaScriptからWebSocketのHTTPヘッダが操作できないことが判明しました。

ここまで来て諦められないので、OWASP ZAPを使って力技で動作確認してみます。 ブラウザのプロキシにOWASP ZAPを指定し、AWS IoTエンドポイントとの通信に割り込み、手動でHTTPヘッダを挿入してやります。 挿入するHTTPヘッダは下記の通りです。

x-amz-customauthorizer-name: <作成したカスタムオーソライザーの名前>
x-amz-customauthorizer-signature: <先ほどコンソールに出力された署名>
my-token: <先ほどコンソールに出力されたトークン>

ヘッダ名のmy-tokenはカスタムオーソライザーの設定「トークンキー名」と揃えます。

これまでの手順が正しく実施できて入れば、無理矢理ですがブラウザからカスタムオーソライザーで認証・認可してSubscribeすることができます。 マネジメントコンソールからブラウザがSubscribeしているトピックに対してPublishしてみます。

ブラウザ側です。

(無理矢理ですが)無事にSubscribeできています!!

まとめ

AWS IoTのカスタムオーソライザーについて見てきました。 カスタムオーソライザー自体は自由度が広がって便利な機能だと思うのですが、ブラウザで利用できないのは少し残念ですね。 モバイルアプリを作るスキルが無いので試せませんでしたが、モバイルアプリからだと便利に使えるかもしれません。

もしカスタムオーソライザーとブラウザアプリを組み合わせた構成を検討されている方が入ればご注意下さい!!