※当サイトの記事には、広告・プロモーションが含まれます。

Pythonの標準ライブラリのみでGitHub Appのトークン取得のためのJWTを生成してみる

enterprisezine.jp

 2024年11月13日、独立行政法人情報処理推進機構(以下、IPA)は、「データ利活用・データスペースガイドブック第1.0版」を公開した。同書により、経営戦略策定からIT戦略・企画策定、データスペースの運用、評価までの8フェーズを理解し、実施できるとしている。

IPA「データ利活用・データスペースガイドブック第1.0版」公開 戦略策定から運用までを8段階で提示|EnterpriseZine(エンタープライズジン)

⇧ 全てのプロセスに関わらざるを得ないって、IT部門の負担が大き過ぎんか...

本当に実業務に則したガイドブックになっているのかね?

机上の空論でないことを信じたいが...

Pythonの標準ライブラリのみでGitHub Appのトークン取得のためのJWTを生成してみる

前回、

ts0818.hatenablog.com

Linuxディストリビューションで、デフォルトでPythonがインストールされていることが分かったので、Pythonの標準のライブラリのみでGitHub Appのトークン取得のためのJWT(JSON Web Token)を生成する処理を実装してみる。

事前に、GitHubにログインして

を済ませておく必要があります。

上記の作業が完了している前提で、「GitHub App」での「JWT(JSON Web Token)」の生成の方法を確認してみると、

docs.github.com

⇧ GutHubの公式のドキュメントによると、Pythonの外部ライブラリを利用していらっしゃる...

ChatGPTとの協業で、何とかPython標準のライブラリだけで実現できたので備忘録として。

Linux ディストリビューション側で、OpenSSLコマンドとかインストールされている必要がありますが、デフォルトで利用できると信じて...

ディレクトリ構成は、

qiita.com

⇧ 上記サイト様を参考にtreeコマンドが無い状況を想定して出力。

ディレクトリ構成

[root@Toshinobu-PC jwt_generate]# pwd;find . | sort | sed '1d;s/^\.//;s/\/\([^/]*\)$/|--\1/;s/\/[^/|]*/|  /g'
/home/ts0818/work/jwt_generate
|--src
|  |--main
|  |  |--py
|  |  |  |--jwt_generator
|  |  |  |  |--jwt_generator.py
|  |  |  |--main.py
|  |  |  |--token_generator
|  |  |  |  |--token_generator.py
|  |  |--resources
|  |  |  |--conf
|  |  |  |  |--github-app.conf
|--test-personal-dev-github-app.2024-10-03.private-key.pem    

各ファイルの内容は以下のようになりました。

■/home/ts0818/work/jwt_generate/src/main/resources/conf/github-app.conf

PEM_PATH=<GitHub Appで生成した秘密キーの配置場所※>
CLIENT_ID=<GitHub AppのClient IDの値>

※自分の環境では、「/home/ts0818/work/jwt_generate/test-personal-dev-github-app.2024-10-03.private-key.pem」

■/home/ts0818/work/jwt_generate/src/main/py/jwt_generator/jwt_generator.py

import os
import time
import base64
import json
import subprocess
from pathlib import Path

class JWTGenerator:
    def __init__(self, config_file_path=None):
        """
        初期化メソッド

        @param str config_file_path: 設定ファイルのパス (指定しない場合、デフォルトで 'src/main/resources/conf/github-app.conf' を使用)
        """
        self.config_file_path = Path(config_file_path) if config_file_path else Path(__file__).resolve().parent.parent / 'resources' / 'conf' / 'github-app.conf'
        self.pem_path = None
        self.client_id = None
        self.load_config()

    def load_config(self):
        """
        .conf ファイルを読み込んで環境変数に設定します。

        @raises ValueError: PEM_PATH または CLIENT_ID が設定されていない場合
        """
        if not self.config_file_path.is_file():
            raise ValueError(f"設定ファイル {self.config_file_path} が見つかりません")

        with open(self.config_file_path, 'r') as file:
            for line in file:
                line = line.strip()
                if not line or line.startswith('#'):  # 空行やコメントを無視
                    continue
                key, value = line.split('=', 1)
                os.environ[key.strip()] = value.strip()

        self.pem_path = os.getenv('PEM_PATH')
        self.client_id = os.getenv('CLIENT_ID')

    def read_pem_file(self):
        """
        PEMファイルを読み込み、秘密鍵を取得します。

        @return bytes: PEMファイルの内容(秘密鍵)
        @raises ValueError: PEM_PATH が設定されていない場合
        """
        if not self.pem_path:
            raise ValueError("PEM_PATH が設定されていません")
        pem_file_path = Path(self.pem_path).resolve()  # 絶対パスに解決
        if not pem_file_path.is_file():
            raise ValueError(f"PEMファイル {pem_file_path} が見つかりません")
        with open(pem_file_path, 'rb') as pem_file:
            return pem_file.read()

    def decode_jwt(self, jwt_token):
        # JWTをドットで分割(ヘッダー、ペイロード、署名)
        header, payload, signature = jwt_token.split('.')

        # Base64デコード
        decoded_header = base64.urlsafe_b64decode(header + "==").decode('utf-8')
        decoded_payload = base64.urlsafe_b64decode(payload + "==").decode('utf-8')

        # JSON形式でペイロードとヘッダーを出力
        print("Decoded Header:", json.loads(decoded_header))
        print("Decoded Payload:", json.loads(decoded_payload))

    def base64url_encode(self, data):
        """
        データをBase64URLエンコードします(末尾の `=` を取り除く)。

        @param bytes data: Base64URLエンコードするデータ
        @return str: Base64URLエンコードされた文字列
        """
        return base64.urlsafe_b64encode(data).decode('utf-8').rstrip("=")

    def create_jwt(self):
        """
        JWTトークンを生成します。

        @return str: 生成されたJWTトークン
        @raises ValueError: CLIENT_ID または PEM_PATH が設定されていない場合
        """
        if not self.client_id:
            raise ValueError("CLIENT_ID が設定されていません")

        # JWTペイロードの設定
        payload = {
            'iat': int(time.time()),          # 発行時刻
            'exp': int(time.time()) + 600,    # 有効期限(10分後)
            'iss': self.client_id             # GitHub AppのクライアントID
        }

        # ヘッダーの設定
        header = {
            'alg': 'RS256',  # RS256アルゴリズムを使用
            'typ': 'JWT'
        }

        # ヘッダーをJSON形式でシリアライズしてBase64URLエンコード
        header_json = json.dumps(header, separators=(',', ':'))
        header_base64 = self.base64url_encode(header_json.encode('utf-8'))

        # ペイロードをJSON形式でシリアライズしてBase64URLエンコード
        payload_json = json.dumps(payload, separators=(',', ':'))
        payload_base64 = self.base64url_encode(payload_json.encode('utf-8'))

        # ヘッダーとペイロードをドットで繋げる
        message = f"{header_base64}.{payload_base64}"

        # 秘密鍵を読み込む
        signing_key = self.read_pem_file()

        # OpenSSLコマンドを使って署名を生成
        signature = self.sign_with_openssl(message, signing_key)

        # 署名をBase64URLエンコード
        signature_base64 = self.base64url_encode(signature)

        # 最終的なJWTを作成
        jwt_token = f"{message}.{signature_base64}"
        
        # JWTの確認
        self.decode_jwt(jwt_token)

        return jwt_token

    def sign_with_openssl(self, message, signing_key):
        """
        OpenSSLを使ってRS256署名を生成する。

        @param str message: JWTのヘッダーとペイロードをドットで繋げたメッセージ
        @param bytes signing_key: 秘密鍵(PEM形式)
        @return bytes: 署名データ
        """
        # 一時的にメッセージと秘密鍵をファイルに保存する
        with open("message.txt", "w") as msg_file:
            msg_file.write(message)

        with open("private_key.pem", "wb") as key_file:
            key_file.write(signing_key)

        # OpenSSLで署名を生成する
        command = [
            "openssl", "dgst", "-sha256", "-sign", "private_key.pem", "message.txt"
        ]
        result = subprocess.run(command, capture_output=True, check=True)

        # 署名の結果を取得
        signature = result.stdout
        
        # 一時的に作成したファイルを削除
        os.remove("message.txt")
        os.remove("private_key.pem")

        # 署名を返す
        return signature

    def generate_jwt(self):
        """
        JWTトークンを生成して返します。

        @return str: 生成されたJWTトークン
        @raises ValueError: PEM_PATH または CLIENT_ID が設定されていない場合
        """
        if not self.pem_path or not self.client_id:
            raise ValueError("PEM_PATH または CLIENT_ID が設定されていません")
        
        jwt_token = self.create_jwt()
        return jwt_token

■/home/ts0818/work/jwt_generate/src/main/py/token_generator/token_generator.py

import json
import http.client
import os
from urllib.parse import urlparse
from jwt_generator.jwt_generator import JWTGenerator  # JWTGeneratorクラスをインポート

class TokenGenerator:
    def __init__(self, config_file_path=None):
        """
        GitHub APIを使用してインストールアクセストークンを取得するクラス

        @param str config_file_path: 設定ファイルのパス (オプション)
        """
        self.config_file_path = config_file_path

        # JWTGeneratorインスタンスを作成
        self.jwt_generator = JWTGenerator(config_file_path)

        # インスタンス生成時にJWTを生成して格納
        self.jwt_token = self.jwt_generator.generate_jwt()
        print(f"Generated JWT: {self.jwt_token}")

    def get_installation_id(self):
        """
        GitHub APIを使用してインストールIDを取得します。

        @return str: 取得したインストールID
        @raises Exception: GitHub APIリクエストの失敗
        """
        # GitHub APIのエンドポイント(インストール情報取得)
        url = "/app/installations"
        parsed_url = urlparse("https://api.github.com" + url)

        # リクエストヘッダー
        headers = {
            "Authorization": f"Bearer {self.jwt_token}"
            ,"Accept": "application/vnd.github.v3+json"
            ,"X-GitHub-Api-Version": "2022-11-28"
            ,"User-Agent": "GitHubApp"
        }

        # HTTP接続の準備
        connection = http.client.HTTPSConnection(parsed_url.netloc)
        connection.request("GET", parsed_url.path, headers=headers)

        # レスポンスを取得
        response = connection.getresponse()
        data = response.read()

        if response.status == 200:
            json_response = json.loads(data)
            if json_response:
                # インストールIDを取得(最初のインストールを選択)
                installation_id = json_response[0].get('id')
                print(f"Installation ID: {installation_id}")
                return installation_id
            else:
                raise Exception("インストールが見つかりません")
        else:
            raise Exception(f"GitHub API request failed: {response.status} {response.reason}, Response: {data.decode()}")

    def get_installation_access_token(self, installation_id):
        """
        GitHub APIを使用してインストールアクセストークンを取得します。

        @param str installation_id: インストールID
        @return str: 取得したインストールアクセストークン
        @raises ValueError: INSTALLATION_ID が設定されていない場合
        @raises Exception: GitHub APIリクエストの失敗
        """
        if not installation_id:
            raise ValueError("INSTALLATION_ID が設定されていません")

        # GitHub APIのエンドポイント(インストールアクセストークン取得)
        url = f"/app/installations/{installation_id}/access_tokens"
        parsed_url = urlparse("https://api.github.com" + url)

        # リクエストヘッダー
        headers = {
            "Authorization": f"Bearer {self.jwt_token}"
            ,"Accept": "application/vnd.github.v3+json"
            ,"X-GitHub-Api-Version": "2022-11-28"
            ,"User-Agent": "GitHubApp"
        }

        # HTTP接続の準備
        connection = http.client.HTTPSConnection(parsed_url.netloc)
        connection.request("POST", parsed_url.path, headers=headers)

        # レスポンスを取得
        response = connection.getresponse()
        data = response.read()

        if response.status == 201:
            json_response = json.loads(data)
            access_token = json_response.get('token')
            print(f"Access Token: {access_token}")
            return access_token
        else:
            print(f"Error: {response.status} {response.reason}")
            print(f"Response Body: {data.decode()}")
            raise Exception(f"GitHub API request failed: {response.status} {response.reason}, Response: {data.decode()}")
    

■/home/ts0818/work/jwt_generate/src/main/py/main.py

from token_generator.token_generator import TokenGenerator
from pathlib import Path

def main():
    """
    メイン関数: 設定ファイルを読み込み、JWTを生成し、インストールアクセストークンを取得して表示
    """
    try:

        config_file_path = Path('/home/ts0818/work/jwt_generate/src/main/resources/conf/github-app.conf')

        # TokenGeneratorインスタンスの作成
        token_generator = TokenGenerator(config_file_path)

        # インストールIDを取得
        installation_id = token_generator.get_installation_id()
        print(f"Installation ID: {installation_id}")

        # インストールアクセストークンを取得
        access_token = token_generator.get_installation_access_token(installation_id)

        # アクセストークンを表示
        print(f"Installation Access Token: {access_token}")

    except Exception as e:
        print(f"Error: {e}")

if __name__ == "__main__":
    main()

⇧ で保存。

実行すると、「GitHub App」の「インストールアクセストークン」が取得できたっぽい。

何か、Bashシェルスクリプトでの実装よりコーディングの量が増えてる気が...

Pythonディレクトリ構成とか、コーディングのベストプラクティスとか分からんので、かなり適当なコーディングになってるけども、AIが「幻覚(ハルシネーション)」を頻発してきたせいで、時間はかかりましたな...

AIに振り回されるせいで、Pythonのベストプラクティスを調べる気力と物理的な時間が根こそぎ奪われて徒労感しか無いんだが...

また、今日も、不毛な時間を費やしてしまったわけですな...

毎度モヤモヤ感が半端ない…

今回はこのへんで。