ALBでOpenID Connect認証してオリジンでデコードする (Ruby編)

つい先日、AWSのALBに認証機能が追加されました。
ちょうどOIDCプロバイダの開発プロジェクトにいるので、検証がてらIdP連携を試してみました。  

# ゴール

* ALBでIdPによる認証が行えること
* アプリケーションで認証情報が取得できること

# アプリケーション実装

お試しなので`Sinatra`で実装しました。
適当にEC2を作成してこんな感じでセットアップしました。

```shell
sudo yum groupinstall -y 'Development tools'
sudo yum install -y ruby24 ruby24-devel
sudo alternatives --set ruby /usr/bin/ruby2.4
sudo gem i --no-ri --no-rdoc sinatra json jwt
mkdir ~/app; touch ~/app/main.rb
cat <<-__EOT__ | sudo tee /etc/init/sinatra.conf
description     "Sinatra app"
start on (runlevel [345] and started network)
stop on (runlevel [!345] or stopping network)
respawn
exec /usr/bin/ruby2.4 /home/ec2-user/app/main.rb -p 3000 -o 0.0.0.0
__EOT__
sudo initctl reload-configuration
sudo initctl start sinatra
```


```ruby:~/app/main.rb
# see https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html
require 'sinatra'
require 'json'
require 'base64'
require 'net/http'
require 'uri'
require 'openssl'
require 'jwt'

# とりあえず東京リージョン前提で
AWS_REGION = ENV['AWS_REGION'] || 'ap-northeast-1'

# arn:aws:elasticloadbalancing:region-code:account-id:loadbalancer/app/load-balancer-name/load-balancer-id
EXPECTED_ALB_ARN = ENV['ALB_ARN']

# AWSドキュメントに記載されている、JWT公開鍵を取得するためのURL
def pub_key_url(kid)
  URI.parse("https://public-keys.auth.elb.#{AWS_REGION}.amazonaws.com/#{kid}")
end

def get_pub_key(kid)
  pub_key = Net::HTTP.get(pub_key_url(kid))
  # refs: https://github.com/jwt/ruby-jwt
  ecdsa_public = OpenSSL::PKey::EC.new(pub_key)
  ecdsa_public.private_key = nil
  ecdsa_public
end

def decode_payload(encoded_jwt)
  jwt_headers = encoded_jwt.split('.').first
  decoded_json = JSON.parse(Base64.decode64(jwt_headers))
  pub_key = get_pub_key(decoded_json['kid'])
  # 第三引数の verify は false を指定しないとダメ
  JWT.decode(encoded_jwt, pub_key, false, algorithm: decoded_json['alg'])
end

get '/' do
  content_type :json

  # 参考までに生のHTTPヘッダも返す
  headers = request.env
    .select { |key, val| key.start_with?('HTTP_X_AMZN') }

  encoded_jwt = request.env['HTTP_X_AMZN_OIDC_DATA']

  # ヘルスチェック用に`HTTP_X_AMZN_OIDC_DATA`がないケースも用意
  return headers.to_json unless encoded_jwt

  # HTTP_X_AMZN_OIDC_DATA から JWT の payload を取得
  payload = decode_payload(encoded_jwt)

  # signer が一致しなかったら不正アクセス
  if payload['signer'] != EXPECTED_ALB_ARN
    raise 'invalid signer'
  end
  
  headers.merge(payload: payload).to_json
end
```

# 認証用ALBの作成

認証される側のALBを設定します。
当然のことながらHTTPSでなければいけないので、ALBにドメインを割り当ててTLSの証明書を設定します。
手順としては以下の通りです。

1. ACMでALBに割り当てるFQDNの証明書を発行
1. 新規ALBを作成してHTTPSリスナを作成、ACMを紐付ける
  * ターゲットグループは上で作成したEC2の3000ポート
1. Route53に作成したALBのAレコード(ALIASレコード)を追加する

# IdP側の設定

適当にclient_idとclient_secretを発行します。
ポイントはredirect_uriで、以下のように ALB のドメインによって固定になります。

```bash
https://${ALB_DOMAIN_NAME}/oauth2/idpresponse
# 例: app.example.com の場合
# https://app.example.com/oauth2/idpresponse
```

# ALBのリスナールールを編集する

発行したクライアント情報をALBのリスナルールに設定します。
マネジメントコンソールだと結構わかりづらい位置にあります。(2018/06/04現在)

`EC2` > `ロードバランサー` で対象のALBを選択し、画面下部にあるタブから`リスナー`を選択。
表内にある`ルール`の列に`ルールの表示/編集`というリンクがあるのでクリック。

<img width="409" alt="スクリーンショット 2018-06-04 19.41.50.png" src="https://qiita-image-store.s3.amazonaws.com/0/17735/47acf5f9-e06e-8d1f-3158-5f2dc2adf989.png">

上の画像の鉛筆マークを選択すると、とってもわかりづらいですが、下のようにルールの左端にこれまた鉛筆マークが表示されます。

<img width="603" alt="スクリーンショット 2018-06-04 19.42.06.png" src="https://qiita-image-store.s3.amazonaws.com/0/17735/f45ccec8-18c5-bbd0-91fb-3d7fb9a42b15.png">

あとは公式ドキュメントの通りに以下の情報を設定します。

* IdPの`Issuer`, `Authorization Endpoint`, `Token Endpoint`, `Userinfo Endpoint`
  * これらは`https://`でなければなりません。`http://`を入れるとエラーになりました。
* IdPで発行した `clientId`と`clientSecret`
* スコープや認証失敗時の挙動は任意に

# 実際にアクセスしてみる

実際に ALB のドメインのルートパスにブラウザでアクセスしてみると、IdPの認証画面が表示されて、Sinatraが返しているJSONが表示されました。
ちゃんと復号化したJWTのペイロードも表示されました。やったね。

```json
{
  "HTTP_X_AMZN_TRACE_ID": "これはX-RayのトレースIDなので関係なし",
  "HTTP_X_AMZN_OIDC_DATA": "暗号化されたJWTクレームを含むJWTトークン",
  "HTTP_X_AMZN_OIDC_IDENTITY": "IdPにおけるアカウントのUID",
  "HTTP_X_AMZN_OIDC_ACCESSTOKEN": "OpenID Connectのアクセストークン",
  "payload": [
    {
      "sub": "IdPにおけるアカウントのUID( == HTTP_X_AMZN_OIDC_IDENTITY)",
      "iss": "IdPのIssuer",
      "aud": "発行したクライアントID",
      "nickname": "IdP側のカスタム属性",
      "email": "IdP側のカスタム属性",
      "postcode": "IdP側のカスタム属性",
      "gender": "IdP側のカスタム属性",
      "birthdate": "IdP側のカスタム属性"
    },
    {
      "typ": "JWT",
      "kid": "6685984d-xxxx-xxxx-xxxx-cabd706e228e",
      "alg": "ES256",
      "iss": "ALBに設定したIdPのIssuer",
      "client": "ALBに設定したクライアントID",
      "signer": "arn:aws:elasticloadbalancing:ap-northeast-1:999999999999:loadbalancer/app/hoge-alb/xxxxxxxxxxxxxxxx",
      "exp": 1528108552
    }
  ]
}
```

# ハマりどころ

`Token Endpoint`と`Userinfo Endpoint`にはALBから直接アクセスが来るので、ALBのIPアドレスを許可しないといけない。
本番環境では普通は`0.0.0.0/0`に公開しているから問題ないけど、開発やテスト環境ではIP制限をしていると思います。
その場合はALBのIPアドレスを範囲指定で許可する必要があります。