ENECHANGE Developer Blog

ENECHANGE開発者ブログ

JWTを検証してAWS Verified Accessの経路判定をやってみた

CTO室のtockeysanです。

弊社のとあるプロダクトではAWS Verified Access(以降Verified Access)を使用してECS環境にアクセスをしています。 ユーザーがVerified Accessの経路でアクセスしたことをECS上で動いているRailsアプリケーション上で判定・検証する要件があったため、その実装を解説していきたいと思います。

構成

ECS環境にはVerified Accessを経由するほか、CloudFrontからALBを通してアクセスするユーザーが存在します。イメージとしては一般ユーザーと管理者といった区別をしていて、管理者はVerified Accessで認証をしてECSにアクセスすることで管理者機能を使うことができます。Verified Accessの信頼プロバイダーは、弊社で全社的なIdPとして利用しているEntraIDを使用しています。

構成図

Verified AccessのターゲットをALBにする場合InternalなLBである必要があります。 それに加え、AWS re:Invent 2024で発表されたCloudFront VPC オリジンにより、CloudFrontからInternal ALBをオリジンに設定することができるようになりました。それぞれ異なる経路ですがALBを1つにまとめることができています。

アクセス経路の判定

実装方法はAWS公式ドキュメントが用意してくれています。
ドキュメント内のコードはPythonですが、この記事ではRubyで実装していきます。 docs.aws.amazon.com

カスタムヘッダー

Verified Access インスタンスがユーザーを正常に認証(今回でいうとEntraIDによる認証)すると、EntraIDが発行したユーザークレームをHTTPヘッダーに追加してくれてアプリケーションで検証することができます。
HTTP ヘッダーは以下です。

x-amzn-ava-user-context

このヘッダーにはJSON Web Token (JWT) 形式のユーザークレームが入っています。 つまりVerified Accessが付与したこのJWTを検証することで、Verified Accessから入ってきたクライアント、という判定が可能です。

JWTの検証

おおまかに以下の3点を実装しています。

  1. JWTヘッダーのsignerがVerified Access インスタンスのARNとなっていること
  2. AWSが用意している公開鍵を利用してシグニチャの検証ができること(改ざんされてないこと)
  3. JWTの有効期限の検証

今回はユーザークレームそのものはアプリケーションでは保存・利用はしません。
以下より簡単なサンプルコードを交えながら解説します。

1. signerの確認

JWTはBase64URLデコードをかければheaderとpayloadを見ることができます。
JWTの取り扱いにはjwt/ruby-jwtというGemを利用します。バージョンは執筆時点で安定最新バージョンである2.10.1です。

jwt = request.env['HTTP_X_AMZN_AVA_USER_CONTEXT']

encoded_token = JWT::EncodedToken.new(jwt)
header = encoded_token.header
payload = encoded_token.payload

実際にアプリケーションで得られたheaderとpayloadはこちらです。値は一部マスクしています。

# header
{
  "typ": "JWT",
  "kid": "12345678-1234-1234-1234-123456789012",
  "alg": "ES384",
  "iss": "https://login.microsoftonline.com/abc/v2.0",
  "client": "12345678-abcd-1234-abcd-123456789012",
  "signer": "arn:aws:ec2:us-east-1:123456789012:verified-access-instance/vai-abc123xzy321a2b3c",
  "exp": 1748919664
}

# payload
{
  "sub": "abc-123",
  "name": "Taro Tanaka",
  "family_name": "Tanaka",
  "given_name": "Taro",
  "picture": "https://graph.microsoft.com/v1.0/me/photo/$value",
  "exp": 1748919664,
  "iss": "https://login.microsoftonline.com/abc/v2.0"
}

headerのうちsignerには認証を行ったVerified Access インスタンスのARNが含まれており、比較することで正当なアクセスであるという妥当性を確認することができます。

if header['signer'] != ENV['AWS_VERIFIED_ACCESS_INSTANCE_ARN']
    logger.warn "JWT signer mismatch."
    return false
end

2. シグニチャの検証

前述の通り、JWTはBase64URLデコードをするとheaderとpayloadを見ることができますが裏を返すと簡単に書き換えることが可能です。
そのため、そのJWTが信頼できる相手から発行されて改ざんをされてないことを検証する必要があります。

そこで登場するのがJSON Web Signature(JWS)という仕組みです。(JWSの詳細な説明は割愛します。)

検証に必要な要素として暗号アルゴリズムと公開鍵を入手する必要があります。
暗号アルゴリズムは、AWS公式ドキュメントに「ES384 (SHA-384 ハッシュアルゴリズムを使用する ECDSA 署名アルゴリズム)」を使用すると記載があるほか、JWT headerのalgからも入手できます。
公開鍵は、JWT headerからキーID(kid)を取得しAWSが用意するエンドポイントを叩いて入手します。

エンドポイントは以下の形式です。

https://public-keys.prod.verified-access.<region>.amazonaws.com/<key-id>

key_id = header['kid']
region = ENV.fetch('AWS_REGION', 'ap-northeast-1')
public_key_url = "https://public-keys.prod.verified-access.#{region}.amazonaws.com/#{key_id}"

uri = URI(public_key_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
response = http.get(uri.path)

key = OpenSSL::PKey::EC.new(response.body)

encoded_token.verify_signature!(algorithm: 'ES384', key: key)

verify_signature!メソッドを使うことでシグニチャの検証ができます。

3. 有効期限の検証

headerとpayloadともにexpという有効期限を示すクレームがあります。

encoded_token.verify_claims!(:exp)

verify_claims!メソッドにexpを渡すことで有効期限の検証ができます。
expの他に検証したいクレームがあれば、相当するシンボルを渡すことでexp同様に検証可能です。

実装例

ここまでは動作が確認できる簡単なサンプルコードでした。 実際のアプリケーションでは例外処理などを含めて実装する必要があります。 アプリケーション上での実装例を記載します。

require 'net/http'
require 'uri'
require 'jwt'

def verify_jwt?
    jwt = request.env['HTTP_X_AMZN_AVA_USER_CONTEXT']
    return false if jwt.nil?

    begin
      encoded_token = JWT::EncodedToken.new(jwt)
      header = encoded_token.header

      if header['signer'] != ENV['AWS_VERIFIED_ACCESS_INSTANCE_ARN']
        logger.warn "JWT signer mismatch."
        return false
      end

      return false if header['kid'].blank?

      public_key = fetch_verified_access_public_key(header['kid'])
      key = OpenSSL::PKey::EC.new(public_key)

      encoded_token.verify_signature!(algorithm: 'ES384', key: key)
      encoded_token.verify_claims!(:exp)

      return true

    rescue JWT::VerificationError, JWT::ExpiredSignature => e
      logger.warn "JWT verification failed: #{e.message}"
      return false
    rescue => e
      logger.error "Unexpected error during JWT verification: #{e.message}"
      return false
    end
  end

  def fetch_verified_access_public_key(key_id)
    region = ENV.fetch('AWS_REGION', 'ap-northeast-1')
    public_key_url = "https://public-keys.prod.verified-access.#{region}.amazonaws.com/#{key_id}"

    uri = URI(public_key_url)
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http.open_timeout = 5
    http.read_timeout = 10

    response = http.get(uri.path)

    if response.code == '200'
      response.body
    else
      logger.error "Failed to fetch public key. HTTP Status: #{response.code}, Body: #{response.body}"
      raise "Failed to fetch public key from AWS: HTTP #{response.code}"
    end
  end

verify_signature!verify_claims!メソッドでは検証に失敗した場合に例外が発生します。 begin-rescueで囲んで適切な例外処理を実装しましょう。
またtimeoutの時間は各アプリケーションの要件に応じて設定すると良いでしょう。

まとめ

Verified AccessからのアクセスをJWTの検証を行うことで判定することができました。 直近OIDCの仕様について調べていたためJWTを実際に検証する機会があって嬉しかったです。 また、Verified Accessを利用していない場合でも、IdPなどからJWTがアプリケーションに渡された時の検証方法の参考になれば幸いです。