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点を実装しています。
- JWTヘッダーのsignerがVerified Access インスタンスのARNとなっていること
- AWSが用意している公開鍵を利用してシグニチャの検証ができること(改ざんされてないこと)
- 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がアプリケーションに渡された時の検証方法の参考になれば幸いです。