(https://tech.aufomm.com/the-ultimate-guide-of-using-jwt-with-kong/ より翻訳)

:::note この記事は「究極の」ガイドとまでは言えないかもしれませんが、JWT 検証に使える公式の Kong プラグインを徹底的に解説することを目指しています。読み終えるころには、利用可能なオプションを全体的に理解し、それぞれのユースケースに最適なプラグインを選べるようになるはずです。 :::

背景

JSON Webトークン(JWT)は、Web開発に欠かせない要素であり、システム間で重要な情報を安全に伝える役割を果たします。その信頼性を確保するために、JWTの検証は欠かせないステップです。一方、KongのようなAPIゲートウェイは、特にOAuth 2.0やOIDC標準が普及する中で、組織のAPIアーキテクチャにおいて重要な位置を占めています。 image.png

この記事では、KongでJWTを検証するためのツールを詳しく解説し、現代のWeb開発環境でこのプロセスが果たす役割を探ります。

JWTとは

:::note すでにJWTについて理解している方は、どのプラグインを選ぶべきかの詳細を省略し、次のセクションから読み進めてください。 :::

JSON Web Token(JWT)は、JSON Web Signature(JWS)とJSON Web Encryption(JWE)の2つの形式で構成されています。本記事では、主にJWSの検証に焦点を当てています。JWEの検証について知りたい方は、こちらの記事をご覧ください。

RFC7515によると、JSON Web Signature(JWS)は、デジタル署名やメッセージ認証コード(MAC)で保護されたコンテンツを表現するためのJSONベースのデータ構造です。簡単に言えば、JWSは重要なメッセージを含む「署名付きの手紙」のようなものです。この手紙の信頼性を確保するためには、署名を検証する必要があります。

アルゴリズム

JWSで使用される暗号アルゴリズムとその識別子は、別の仕様であるJSON Web Algorithms (JWA) JWAに定義されています。また、これらのアルゴリズムは、JWA仕様に基づいて作成されたIANAレジストリに記載されています。

JWSで使用される暗号アルゴリズムとその識別子は、別の仕様であるJSON Web Algorithms (JWA)仕様に記載されています。

通常、RS256やES256といった非対称アルゴリズムの使用が推奨されます。一方、HS256のような対称アルゴリズムの使用は推奨されていません。

JWTのデータ構造

まず、JWTの構造を見てみましょう。以下に例を示します。

eyJhbGciOiJFUzI1NiIsImtpZCI6Imh1SEE3RDVaTUNKTWhLaVJIZVgwaGZSWG9fX1VBbEpCZ0FkTjhxb0MwMXcifQ.eyJpc3MiOiAiZm9tbSIsICJhdWQiOiAiand0LWRlbW8iLCAic3ViIjogImRlbW8tdXNlciIsICJpYXQiOiAxNjcyNDAzMzc4LCAiZXhwIjogMTY3MjQwMzY3OH0.bxkLGEjN4pXQQ6eymBO_DYl24NGu07FFR1ZXgmdFYHPGsNX10r6iyqDEtCHeXWs7Hsn-QIasV_i4Lw2nCHmlAA```

  • JOSE header JOSEヘッダーには、通常、このJWSを保護するために使用されるハッシュアルゴリズム(alg)やキーID(kid)が記載されています。また、typ(タイプ)が含まれていることも一般的です

    1
    2
    3
    4
    
    {
      "alg": "ES256",
      "kid": "huHA7D5ZMCJMhKiRHeX0hfRXo__UAlJBgAdN8qoC01w"
    }
    
  • Payload ペイロードは、保護されて他の相手に渡されるメッセージです

    1
    2
    3
    4
    5
    6
    7
    
    {
      "iss": "fomm",
      "aud": "jwt-demo",
      "sub": "demo-user",
      "iat": 1672403378,
      "exp": 1672403678
    }
    
  • Signature ユーザーは、JOSEヘッダーで定義されたアルゴリズムを使用して、保護されたヘッダーとペイロードの署名を計算し、署名が一致することを確認する必要があります

JWT生成

JWTは非常に人気が高いため、さまざまなプログラミング言語向けのライブラリが利用可能です。また、KeycloakやAzureのようなOIDCプロバイダーを利用している場合、これらが自動的にJWTトークンを生成してくれます。このセクションでは、jwt-cliとPythonのjwcryptoライブラリを使ってJWTを作成する方法を紹介します。

RSA鍵ペアの生成

ここでは、opensslを使用して、トークンの署名用にRSAの秘密鍵を生成し、その検証用に公開鍵をエクスポートします。

1
2
3
4
5
6
7
8
openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -outform pem -out rsa-private.pem 2>/dev/null

openssl pkey -pubout \
  -in rsa-private.pem \
  -out rsa-public.pem

JWT CLI

:::note nixがインストールされている場合は、nix shell nixpkgs#jwt-cliコマンドを使用してjwt-cliを利用できます。それ以外の場合は、公式のGitリポジトリを参照してください。 :::

kid

RFC 7515 section 4.1.4によると、kid(キーID)の値の構造は特に指定されていません。その値は大文字と小文字を区別する文字列でなければなりません。このヘッダー項目の使用は任意です。しかし、ほとんどの場合、JOSEヘッダーにkidを含めることをお勧めします。これにより、受信者がJWK内のどの公開鍵を使用してトークンを検証すべきかを特定しやすくなります。

今回は、特定の公開鍵に関連付けられるように、ツールを使用してkidを生成します。

1
2
3
4
cat rsa-public.pem \
  | docker run --rm -i danedmunds/pem-to-jwk:latest --jwks-out \
  | jq -r '.keys[].kid' \
  | read JWT_KID

JWT 生成

次に、簡単なペイロードを使用して以下のようにJWTを生成します。

1
2
3
4
5
6
7
jwt encode \
  --alg RS256 \
  --exp=300s \
  --kid $JWT_KID \
  --iss fomm-jwt-cli \
  --secret @rsa-private.pem \
  '{"username":"fomm","roles":["demo"]}'

JWT 検証

RSAの秘密鍵を使用してトークンに署名しているため、その公開鍵を使って検証を行う必要があります。jwt-cliを使用する場合、以下のコマンドで検証できます。

1
jwt decode --secret @rsa-public.pem --alg RS256 <JWT>

出力

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
jwt decode --secret @rsa-public.pem --alg RS256 eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IkNfdTNJemNpSERKWTZ6TkdSeXJZN2prZjZrVWJMRFhEZ0M5TFlEeEtHUXMifQ.eyJleHAiOjE3MzM3MjQyMDAsImlhdCI6MTczMzcyMzkwMCwiaXNzIjoiZm9tbS1qd3QtY2xpIiwicm9sZXMiOlsiZGVtbyJdLCJ1c2VybmFtZSI6IldlbmhhbiJ9.vgVmu3OZXOyyIlmf6sEgsXbWZO88g3a_B9epFIMnFHU0uXhGCvUMiYUsWP8WzfOlWzacJhDsl9MHmQLySXn7IYHSVNBpIQUE-u4FdtKXNIMtvOFNTVZR9TI_ZXBlyvM6wm1wBq-06gwbiA8giGsG4n8Krc-qA9otLEFVCDUP6LtgxoJN_KAHSqEz3vERYEEcXGQeRNBYA6E3x_BzQGRV-royCQk8v-c6x42UQtkcHp1eVphAXTaMOlt-6nsP-nZvkOj1kAk1CbwiehbViDKIb2rJKhWu_aCLGZY0N-E7m6kU_gul9YUmzJqH5DtoAjum3kP3-1tZQk4rlsc7mzMVOw

Token header
------------
{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "C_u3IzciHDJY6zNGRyrY7jkf6kUbLDXDgC9LYDxKGQs"
}

Token claims
------------
{
  "exp": 1733724200,
  "iat": 1733723900,
  "iss": "fomm-jwt-cli",
  "roles": [
    "demo"
  ],
  "username": "Wenhan"
}

Python

PyJWTは、以下のコマンドでpipを使ってインストールできます:

1
pip install PyJWT

私はnix developを使用する方を好みます。nixがインストールされている場合、以下の内容をflake.nixに保存してください。その後、次のコマンドを実行して環境をセットアップできます:

1
nix develop -c $SHELL
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
  description = "Example Python development environment for Zero to Nix";
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs";
  };
  outputs = {
    self,
    nixpkgs,
  }: let
    # Systems supported
    allSystems = [
      "x86_64-linux" # 64-bit Intel/AMD Linux
      "aarch64-linux" # 64-bit ARM Linux
      "x86_64-darwin" # 64-bit Intel macOS
      "aarch64-darwin" # 64-bit ARM macOS
    ];
    forAllSystems = f:
      nixpkgs.lib.genAttrs allSystems (system:
        f {
          pkgs = import nixpkgs {inherit system;};
        });
  in {
    # Development environment output
    devShells = forAllSystems ({pkgs}: {
      default = let
        # Use Python 3.11
        python = pkgs.python311;
      in
        pkgs.mkShell {
          # The Nix packages provided in the environment
          packages = [
            # Python plus helper tools
            (python.withPackages (ps:
              with ps; [
                jwcrypto
              ]))
          ];
        };
    });
  };
}

JWT生成

次に、以下の内容をjwt.pyとして保存します。その後、python jwt.pyを実行することで、トークンを作成できるようになります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from jwcrypto import jwk, jwt
from datetime import datetime

def generate_jwt(private_key_path, algorithm):
  timestamp = int(datetime.now().timestamp())
  payload = {
    "iat": timestamp,
    "exp": timestamp + 300,
    "iss": "fomm-jwtcrypto",
    "username": "fomm",
    "roles": ["demo"],
  }
  with open(private_key_path, "rb") as pemfile:
    private_key = jwk.JWK.from_pem(pemfile.read())
    jwt_token = jwt.JWT(header={"alg": algorithm, "kid": private_key.thumbprint(), "typ":"JWT"}, claims=payload)
    jwt_token.make_signed_token(private_key)
  return jwt_token.serialize()

def main():
  private_key_path = "rsa-private.pem"
  jwt_algorithm = "RS256"
  jwt_token = generate_jwt(private_key_path, jwt_algorithm)
  print(jwt_token)

if __name__ == "__main__":
  main()

JWT検証

jwcryptoライブラリを使用した検証は非常に簡単です。以下に使用できるサンプルを示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from jwcrypto import jwk, jwt

def validate_jwt(public_key_path, jwt_token):
    with open(public_key_path, "rb") as pemfile:
        public_key = jwk.JWK.from_pem(pemfile.read())
        verified_token = jwt.JWT(jwt=jwt_token, key=public_key)
        try:
            verified_token.validate(public_key)
            return True
        except Exception as e:
            return False

def main():
    public_key_path = "rsa-public.pem"
    jwt_token = input("Enter JWT token: ")
    is_valid = validate_jwt(public_key_path, jwt_token)
    if is_valid:
        print("Token is valid.")
    else:
        print("Token is invalid.")

if __name__ == "__main__":
    main()

どのプラグインを使用すべきか?

JWTの検証には、Kongの公式プラグインが3つ提供されています:

  1. JWTプラグイン
  2. JWT Signerプラグイン(エンタープライズ版)
  3. OpenID Connect(OIDC)プラグイン(エンタープライズ版)

エンタープライズライセンスがない場合は、オープンソース版のJWTプラグインのみ使用可能です。このプラグインを実装するには、各コンシューマーごとに個別のjwt_secretsを作成する必要があります。複数のコンシューマーに同じRSA公開鍵を関連付けたい場合は、すべてのjwt_secrets内のキー値が一意であることを確認してください。詳しくは、こちらのブログ記事をご覧ください。

エンタープライズ版で全てのプラグインが利用可能な場合、以下のことを考えてみてください:

  • JWTはどのように生成されますか?
    • IDP(Identity Provider)によりトークンを生成する場合、OIDCプラグインまたはJWT Signerプラグイン
  • 複数のIDPから発行されたJWTを検証する必要がありますか?
    • OIDCプラグインは、複数のJWKから公開鍵の取得と自動ローテーションをサポートするので、JWT検証に利用可能
  • インターネットアクセスがないUpstream APIサービスがある場合は?
    • JWT Signerプラグインを使えば、トークンを再署名できます。upstream サーバーはKongの公開鍵のみを信頼すれば良いため、IDPのJWK取得が不要
  • JWT claimsを検証する必要がありますか?
    • スコープの検証が必要な場合、OIDCプラグインが最適です。同時に最大4つのクレームが検証可能
  • token claim を読み取り、ヘッダーとして上流や下流に渡す必要がありますか?
    • この場合は、OIDCプラグインを使用する必要がある
  • 異なるIDPから発行された2つのトークンを同時に検証する必要がありますか?
    • 例えば、access tokenとchannel tokenのように2つのトークンを検証する場合、JWT Signerプラグインが適切
  • アクセス制御のためにグループマッピングが必要ですか?
    • すべてのプラグインでコンシューマーマッピングが可能
    • ただし、IDPで開発者を管理したい場合、OIDCプラグインを使用することでトークンクレームに基づいた仮想認証情報を作成できます。この仮想認証情報は、レート制限やアクセス制御に活用可能

まだどのプラグインを選ぶべきか分からない場合は、まずOIDCプラグインから始めることをおすすめします。OIDCプラグインはKongの中で最も高度な認証プラグインであり、他の2つのプラグインよりも多くの機能を提供します。例えば、鍵のローテーション再検出、仮想認証情報、スコープ検証などです。

「OIDCプラグインを強く推奨しているけれど、OpenID Connectプロバイダーを使っていない場合でも使えるの?」と疑問に思うかもしれません。答えはYESです。このプロセスについてもガイドします。

OIDC JWT validation

仕組みは非常にシンプルです。OIDCプラグインはJWKを取得し、これらの公開鍵をキャッシュに保存してJWTの検証に使用します。IDPが鍵をローテーションし、新しい鍵でトークンに署名した場合、OIDCプラグインはJWKエンドポイントから公開鍵を再取得します。

Kongが保持している公開鍵を確認するには、<admin_api>/openid-connect/issuersにアクセスしてください:

:::note JWT Signerは似たような仕組みで動作しますが、1つのURLからのみJWKを取得できる点と、トークンを再署名する点が異なります。 詳細については、こちらのブログ記事をご覧ください。 :::

image.png

JWKs 生成

公開鍵JWKを生成するために、danedmunds/pem-to-jwkのDockerイメージを使用します。

1
2
3
4
cat rsa-public.pem \
  | docker run -i danedmunds/pem-to-jwk:latest --jwks-out \
  | jq '.keys[] += {alg:"RS256"}' \
  > jwks.json

docker network 生成

コンテナは同じDockerネットワークkong-net内で実行します。

1
docker network create kong-net

JWKs ホスティング

次に、これらの鍵をホストするためにjson-serverを使用します。

1
2
3
4
5
6
docker run --rm \
  --detach --name jwk \
  --network kong-net \
  -p "3000:3000" \
  --mount type=bind,src="$(pwd)"/jwks.json,dst=/jwk.json \
  williamyeh/json-server --watch /jwk.json

Kong EE起動

デモ用にKongをDBlessモードでデプロイします。Kong Enterpriseの有効なライセンスがKONG_LICENSE_DATAという環境変数に保存されていると仮定します。以下のコマンドで、Kong Enterprise 3.5をDBlessモードで起動できます。

1
2
3
4
5
6
7
8
docker run --rm --detach --name kong \
  --network kong-net \
  -p "8000-8001:8000-8001" \
  -e "KONG_ADMIN_LISTEN=0.0.0.0:8001" \
  -e "KONG_PROXY_LISTEN=0.0.0.0:8000" \
  -e "KONG_DATABASE=off" \
  -e "KONG_LICENSE_DATA=$KONG_LICENSE_DATA" \
  kong/kong-gateway:3.5

Kong 設定内容の準備

以下の内容を /tmp/kong.yamlに保存しましょう

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
_format_version: "3.0"
routes:
- name: upstream-route
  paths:
  - /upstream
  plugins:
  - config:
      echo: true
      status_code: 200
    name: request-termination
services:
- name: test-svc
  host: localhost
  path: /upstream
  port: 8000
  protocol: http
  routes:
  - name: test-route
    paths:
    - /test
    plugins:
    - config:
        auth_methods:
        - bearer
        extra_jwks_uris:
        - http://jwk:3000/db
        issuer: http://jwk:3000/db
        issuers_allowed:
        - fomm-jwt-cli
        - fomm-jwtcrypto
      name: openid-connect

上記の設定で行うことは以下の通りです。

  • request terminationプラグインを使用し、200レスポンスを返し、リクエストをそのままエコーバックします。
  • すべてのJWKはconfig.extra_jwks_urisに配置し、OIDCプラグインが鍵を取得できるようにします。
  • すべてのIssuer (Pythonとjwt-cliコマンド) はconfig.issuers_allowedにリストされています。OIDCプラグインはデフォルトでIssuerを検証します。

最後に、このファイルをKongの/configエンドポイントにプッシュできます。

1
2
3
curl --request POST \
  --url http://localhost:8001/config \
  -F config=@/tmp/kong.yaml

JWT検証

これで準備が整いましたので、セットアップをテストしてみましょう。最初に上記の手順に従ってJWTを生成してください。簡単に参照できるように、以下のコマンドでJWTをtoken変数に格納します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
cat rsa-public.pem \
  | docker run -i danedmunds/pem-to-jwk:latest --jwks-out \
  | jq -r '.keys[].kid' \
  | read JWT_KID

jwt encode \
  --alg RS256 \
  --exp=300s \
  --kid $JWT_KID \
  --iss fomm-jwt-cli \
  --secret @rsa-private.pem \
  '{"username":"fomm","roles":["demo"]}' \
  | read token

次に、このトークンをauthorization bearer tokenとして使用してエンドポイントを呼び出すと、200レスポンスが返されるはずです。

1
2
3
curl --request GET -i \
  --url http://localhost:8000/test \
  --header "authorization:  Bearer $token"

今日はここまでにします。適切なプラグインを選ぶための参考になれば幸いです。