0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHub Appの認証、思ったより罠が多かった

0
Last updated at Posted at 2026-06-03

GitHub Appを使ってリポジトリにpushする機能を実装した。OAuthと似たようなものだろうと思っていたが、全然違った。ドキュメントが分散していて全体像を掴むまでに時間がかかったので、詰まったところを残しておく。

認証が2段階ある

まずここで混乱した。

GitHub Appの認証は「App自体の認証」と「リポジトリへのアクセス」が別になっている。

RSA秘密鍵で署名
  ↓
App JWT(有効10分)
  ↓ POST /app/installations/{id}/access_tokens
Installation Access Token(有効1時間)
  ↓
GitHub API

OAuth Appならアクセストークン1本でGitHub APIを叩けるが、GitHub Appはまず「俺がそのAppだ」というJWTを作り、それを使ってインストール先のトークンを取得するという2段階が必要になる。公式の説明を読んでいると「App JWT」と「Installation Access Token」が別のページに書いてあって、最初はこの2つが同じものだと思い込んでいた。

JWTのiatを現在時刻にすると弾かれる

App JWTを生成するとき、iatint(time.time())にしていたら認証エラーになった。

# これで弾かれた
jwt_payload = {
    "iat": int(time.time()),
    "exp": int(time.time()) + 540,
    "iss": app_id,
}

GitHubのサーバーとの間にわずかな時刻のズレがあると、iatが「未来の時刻」として扱われて弾かれる。対策は60秒前にずらすことで、公式ドキュメントにも書いてあるが見落としていた。

now = int(time.time())
jwt_payload = {
    "iat": now - 60,   # クロックスキュー対策
    "exp": now + 540,  # 上限が10分なので余裕を持って9分
    "iss": app_id,
}

Installation Access Tokenを毎回取得するとレート制限に当たる

Installation Access Tokenは1時間で失効する。毎回APIを叩いて取得していたら、しばらくしてレート制限のエラーが出てきた。当然キャッシュが必要で、ただし「失効ギリギリまで使う」のも危ない。取得してすぐ失効するケースがあるので、5分前に再取得するようにした。

_token_cache: dict[int, tuple[str, float]] = {}

def _get_installation_token(installation_id: int) -> str:
    cached = _token_cache.get(installation_id)
    if cached and time.monotonic() < cached[1] - 300:  # 5分前まで使う
        return cached[0]

    app_jwt = _generate_app_jwt()
    resp = requests.post(
        f"https://api.github.com/app/installations/{installation_id}/access_tokens",
        headers={
            "Authorization": f"Bearer {app_jwt}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
        },
        timeout=10,
    )
    resp.raise_for_status()

    data = resp.json()
    token = data["token"]

    # expires_atがレスポンスに含まれているのでそれを使う
    # 形式は "2024-01-01T00:00:00Z"
    expires_at_str = data.get("expires_at", "")
    try:
        dt = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
        ttl = (dt - datetime.now(timezone.utc)).total_seconds()
        expires_monotonic = time.monotonic() + ttl
    except Exception:
        expires_monotonic = time.monotonic() + 3600

    _token_cache[installation_id] = (token, expires_monotonic)
    return token

expires_atはレスポンスに入っているので、自前で計算するよりズレない。

Webhookは全イベントが1つのエンドポイントに届く

pushだけ受け取るエンドポイントを作れると思っていたが、そうではなかった。インストール・push・PR・Marketplaceの購入など、App宛ての全イベントが同じURLに届く。X-GitHub-Eventヘッダーで種別を判定して自前でルーティングする必要がある。

@app.post("/webhooks/github")
async def github_webhook(
    request: Request,
    x_github_event: str = Header(None),
    x_hub_signature_256: str = Header(None),
):
    payload = await request.body()

    # 署名検証を先にやる
    secret = get_secret("github_app_webhook_secret")
    if not verify_signature(payload, x_hub_signature_256, secret):
        raise HTTPException(status_code=401)

    body = json.loads(payload)

    if x_github_event == "ping":
        return {"status": "ok"}        # App登録直後の疎通確認、即返す

    if x_github_event == "installation":
        handle_installation(body)

    elif x_github_event == "push":
        handle_push(body)

    elif x_github_event == "pull_request":
        handle_pull_request(body)

    return {"message": "ok"}

pingイベントはApp設定を保存したときにGitHubから飛んでくる疎通確認で、これを即座に200で返さないとApp登録が完了しない。署名検証をしてから返すようにしていたら、秘密鍵の設定ミスで署名検証が失敗してApp登録できないという状況にハマった。pingだけは先に返す。

Webhook署名検証はhmac.compare_digestを使う

署名の検証を==でやっていたが、タイミング攻撃の余地があるのでhmac.compare_digestを使う。

def verify_signature(payload: bytes, signature_header: str, secret: str) -> bool:
    if not signature_header or not signature_header.startswith("sha256="):
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        payload,
        hashlib.sha256,
    ).hexdigest()

    actual = signature_header[len("sha256="):]
    return hmac.compare_digest(expected, actual)

==は文字列の一致箇所が増えるほど処理時間が長くなり、タイミングで情報が漏れる。compare_digestは常に一定時間で比較する。

ボット自身のpushでWebhookが無限ループする

GitHub Appがドキュメントを更新してpushすると、そのpushに対してWebhookが届く。そのままドキュメント生成を走らせると永遠にループする。

if x_github_event == "push":
    pusher_name = body.get("pusher", {}).get("name", "")
    if pusher_name.endswith("[bot]"):
        return {"message": "ignored"}

    # ここからドキュメント生成処理

GitHub ActionsのコミットはpusherがLgithub-actions[bot]になる。自前のbotアカウントでpushするなら、そのアカウント名で判定する。


全体的に「OAuthと同じような感覚でやると詰まる」という印象だった。公式ドキュメントはAuthenticating as a GitHub App installationReceiving webhooks with a GitHub Appを最初に読んでおくと全体像が掴めてよかった。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?