|
| 1 | +# This class provides a wrapper around the Octokit client for GitHub App authentication. |
| 2 | +# It handles token generation and refreshing, and delegates method calls to the Octokit client. |
| 3 | +# Helpful: https://github.com/octokit/handbook?tab=readme-ov-file#github-app-authentication-json-web-token |
| 4 | +# |
| 5 | +# Usage (examples): |
| 6 | +# github = GitHubApp.new |
| 7 | +# github.get "/meta" |
| 8 | +# github.get "/repos/<org>/<repo>" |
| 9 | +# github.user "grantbirki" |
| 10 | + |
| 11 | +# Why? In some cases, you may not want to have a static long lived token like a GitHub PAT when authenticating... |
| 12 | +# Most importantly, this class will handle automatic token refreshing for you out-of-the-box. Simply provide the... |
| 13 | +# correct environment variables, call `GitHubApp.new`, and then use the returned object as you would an Octokit client. |
| 14 | + |
| 15 | +require "../../src/octokit" |
| 16 | +require "jwt" |
| 17 | +require "openssl" |
| 18 | +require "json" |
| 19 | + |
| 20 | +class GitHubApp |
| 21 | + TOKEN_EXPIRATION_TIME = 2700 # 45 minutes |
| 22 | + JWT_EXPIRATION_TIME = 600 # 10 minutes |
| 23 | + |
| 24 | + @client : Octokit::Client |
| 25 | + @app_id : Int32 |
| 26 | + @installation_id : Int32 |
| 27 | + @app_key : String |
| 28 | + |
| 29 | + def initialize |
| 30 | + @app_id = fetch_env_var("GITHUB_APP_ID").to_i |
| 31 | + @installation_id = fetch_env_var("GITHUB_APP_INSTALLATION_ID").to_i |
| 32 | + @app_key = fetch_env_var("GITHUB_APP_PRIVATE_KEY").gsub(/\\+n/, "\n") |
| 33 | + @token_refresh_time = Time.unix(0) |
| 34 | + @client = create_client |
| 35 | + end |
| 36 | + |
| 37 | + private def fetch_env_var(key : String) : String |
| 38 | + ENV[key]? || raise "environment variable #{key} is not set" |
| 39 | + end |
| 40 | + |
| 41 | + private def client |
| 42 | + if @client.nil? || token_expired? |
| 43 | + @client = create_client |
| 44 | + end |
| 45 | + @client |
| 46 | + end |
| 47 | + |
| 48 | + private def jwt_token : String |
| 49 | + private_key = OpenSSL::PKey::RSA.new(@app_key) |
| 50 | + payload = { |
| 51 | + "iat" => Time.utc.to_unix - 60, |
| 52 | + "exp" => Time.utc.to_unix + JWT_EXPIRATION_TIME, |
| 53 | + "iss" => @app_id, |
| 54 | + } |
| 55 | + JWT.encode(payload, private_key.to_pem, JWT::Algorithm::RS256) |
| 56 | + end |
| 57 | + |
| 58 | + private def create_client |
| 59 | + tmp_client = Octokit.client(bearer_token: jwt_token) |
| 60 | + response = tmp_client.create_app_installation_access_token(@installation_id, **{headers: {authorization: "Bearer #{tmp_client.bearer_token}"}}) |
| 61 | + access_token = JSON.parse(response)["token"].to_s |
| 62 | + |
| 63 | + client = Octokit.client(access_token: access_token) |
| 64 | + client.auto_paginate = true |
| 65 | + client.per_page = 100 |
| 66 | + @token_refresh_time = Time.utc |
| 67 | + client |
| 68 | + end |
| 69 | + |
| 70 | + private def token_expired? : Bool |
| 71 | + Time.utc.to_unix - @token_refresh_time.to_unix > TOKEN_EXPIRATION_TIME |
| 72 | + end |
| 73 | + |
| 74 | + macro method_missing(call) |
| 75 | + {% if call.block %} |
| 76 | + client.{{call.name}}({{*call.args}}) do |{{call.block.args}}| |
| 77 | + {{call.block.body}} |
| 78 | + end |
| 79 | + {% else %} |
| 80 | + client.{{call.name}}({{*call.args}}) |
| 81 | + {% end %} |
| 82 | + end |
| 83 | + |
| 84 | + def respond_to_missing?(method_name : Symbol, include_private : Bool = false) : Bool |
| 85 | + client.respond_to?(method_name) || super |
| 86 | + end |
| 87 | +end |
0 commit comments