Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

github app support #15

Merged
merged 10 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ jobs:

- name: acceptance
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_DB_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENV: acceptance
run: script/acceptance
13 changes: 8 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ vendor/gems
.local/
.DS_Store
.lesshst
*.pem
*.key
*.crt
*.csr
*.secret

/.bundle
/vendor/gems
Expand All @@ -35,3 +30,11 @@ coverage/*
.idea

issue-db-*.gem

!./spec/fixtures/fake_private_key.pem

*.pem
*.key
*.crt
*.csr
*.secret
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ PATH
specs:
issue-db (0.0.2)
faraday-retry (~> 2.2, >= 2.2.1)
jwt (~> 2.9, >= 2.9.3)
octokit (~> 9.2)
redacting-logger (~> 1.4)
retryable (~> 3.0, >= 3.0.5)
Expand Down Expand Up @@ -52,6 +53,8 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.8.2)
jwt (2.9.3)
base64
language_server-protocol (3.17.0.3)
logger (1.6.1)
minitest (5.25.1)
Expand Down
40 changes: 34 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,30 +186,58 @@ This section will go into detail around how you can configure the `issue-db` gem
| `ISSUE_DB_CACHE_EXPIRY` | The number of seconds to cache the database in memory. The database is cached in memory to avoid making a request to the GitHub API for every operation. The default value is 60 seconds. | `60` |
| `ISSUE_DB_SLEEP` | The number of seconds to sleep between requests to the GitHub API in the event of an error | `3` |
| `ISSUE_DB_RETRIES` | The number of retries to make when there is an error making a request to the GitHub API | `10` |
| `GITHUB_TOKEN` | The GitHub personal access token to use for authenticating with the GitHub API. You can also use a GitHub app or pass in your own authenticated Octokit.rb instance | `nil` |
| `ISSUE_DB_GITHUB_TOKEN` | The GitHub personal access token to use for authenticating with the GitHub API. You can also use a GitHub app or pass in your own authenticated Octokit.rb instance | `nil` |

## Authentication 🔒

The `issue-db` gem uses the [`Octokit.rb`](https://github.com/octokit/octokit.rb) library under the hood for interactions with the GitHub API. You have three options for authentication when using the `issue-db` gem:

1. Use a GitHub personal access token by setting the `GITHUB_TOKEN` environment variable
2. Use a GitHub app by setting the `GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and `GITHUB_APP_PRIVATE_KEY` environment variables
3. Pass in your own authenticated `Octokit.rb` instance to the `IssueDB.new` method
> Note: The order displayed below is also the order of priority that this Gem uses to authenticate.

1. Pass in your own authenticated `Octokit.rb` instance to the `IssueDB.new` method
2. Use a GitHub App by setting the `ISSUE_DB_GITHUB_APP_ID`, `ISSUE_DB_GITHUB_APP_INSTALLATION_ID`, and `ISSUE_DB_GITHUB_APP_KEY` environment variables
3. Use a GitHub personal access token by setting the `ISSUE_DB_GITHUB_TOKEN` environment variable

> Using a GitHub App is the suggested method

Here are examples of each of these options:

### Using a GitHub Personal Access Token

```ruby
# Assuming you have a GitHub personal access token set as the GITHUB_TOKEN env var
# Assuming you have a GitHub personal access token set as the ISSUE_DB_GITHUB_TOKEN env var
require "issue_db"

db = IssueDB.new("<org>/<repo>") # THAT'S IT! 🎉
```

### Using a GitHub App

This is the single best way to use the `issue-db` gem because GitHub Apps have increased rate limits, fine-grained permissions, and are more secure than using a personal access token. All you have to do is provide three environment variables and the `issue-db` gem will take care of the rest:

- `ISSUE_DB_GITHUB_APP_ID`
- `ISSUE_DB_GITHUB_APP_INSTALLATION_ID`
- `ISSUE_DB_GITHUB_APP_KEY`

Here is an example of how you can use a GitHub app with the `issue-db` gem:

```ruby
# Assuming you have the following three environment variables set:

# 1: ISSUE_DB_GITHUB_APP_ID
# app ids are found on the App's settings page

# 2: ISSUE_DB_GITHUB_APP_INSTALLATION_ID
# installation ids look like this:
# https://github.com/organizations/<org>/settings/installations/<8_digit_id>

# 3. ISSUE_DB_GITHUB_APP_KEY
# app keys are found on the App's settings page and can be downloaded
# format: "-----BEGIN...key\n...END-----\n" (this will be one super long string and that's okay)
# make sure this key in your env is a single line string with newlines as "\n"

# With all three of these environment variables set, you can proceed with ease!
db = IssueDB.new("<org>/<repo>") # THAT'S IT! 🎉
```

### Using Your Own Authenticated `Octokit.rb` Instance
Expand All @@ -230,7 +258,7 @@ db = IssueDB.new("<org>/<repo>", octokit_client: octokit)
Here is a more advanced example of using the `issue-db` gem that demonstrates many different features of the gem:

```ruby
# Assuming you have a GitHub personal access token set as the GITHUB_TOKEN env var
# Assuming you have a GitHub personal access token set as the ISSUE_DB_GITHUB_TOKEN env var
require "issue_db"

# The GitHub repository to use as the database
Expand Down
1 change: 1 addition & 0 deletions issue-db.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Gem::Specification.new do |spec|
spec.add_dependency "retryable", "~> 3.0", ">= 3.0.5"
spec.add_dependency "octokit", "~> 9.2"
spec.add_dependency "faraday-retry", "~> 2.2", ">= 2.2.1"
spec.add_dependency "jwt", "~> 2.9", ">= 2.9.3"

spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")

Expand Down
4 changes: 2 additions & 2 deletions lib/issue_db.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def initialize(repo, log: nil, octokit_client: nil, label: nil, cache_expiry: ni
@log = log || RedactingLogger.new($stdout, level: ENV.fetch("LOG_LEVEL", "INFO").upcase)
Retry.setup!(log: @log)
@version = VERSION
@client = Authentication.login(octokit_client)
@client = Authentication.login(octokit_client, @log)
@repo = Repository.new(repo)
@label = label || ENV.fetch("ISSUE_DB_LABEL", "issue-db")
@cache_expiry = cache_expiry || ENV.fetch("ISSUE_DB_CACHE_EXPIRY", 60).to_i
Expand All @@ -41,7 +41,7 @@ def create(key, data, options = {})
end

def read(key, options = {})
db.read(key)
db.read(key, options)
end

def update(key, data, options = {})
Expand Down
22 changes: 16 additions & 6 deletions lib/issue_db/authentication.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
# frozen_string_literal: true

require "octokit"
require_relative "utils/github_app"

class AuthenticationError < StandardError; end

module Authentication
def self.login(client = nil)
def self.login(client = nil, log = nil)
# if the client is not nil, use the pre-provided client
return client unless client.nil?
unless client.nil?
log.debug("using pre-provided client") if log
return client
end

# if the client is nil, check for GitHub App env vars
# TODO
# if the client is nil, check for GitHub App env vars first
# first, check if all three of the following env vars are set and have values
# ISSUE_DB_GITHUB_APP_ID, ISSUE_DB_GITHUB_APP_INSTALLATION_ID, ISSUE_DB_GITHUB_APP_KEY
if ENV.fetch("ISSUE_DB_GITHUB_APP_ID", nil) && ENV.fetch("ISSUE_DB_GITHUB_APP_INSTALLATION_ID", nil) && ENV.fetch("ISSUE_DB_GITHUB_APP_KEY", nil)
log.debug("using github app authentication") if log
return GitHubApp.new
end

# if the client is nil and no GitHub App env vars were found, check for the GITHUB_TOKEN
token = ENV.fetch("GITHUB_TOKEN", nil)
# if the client is nil and no GitHub App env vars were found, check for the ISSUE_DB_GITHUB_TOKEN
token = ENV.fetch("ISSUE_DB_GITHUB_TOKEN", nil)
if token
log.debug("using github token authentication") if log
octokit = Octokit::Client.new(access_token: token, page_size: 100)
octokit.auto_paginate = true
return octokit
Expand Down
108 changes: 108 additions & 0 deletions lib/issue_db/utils/github_app.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# frozen_string_literal: true

# This class provides a wrapper around the Octokit client for GitHub App authentication.
# It handles token generation and refreshing, and delegates method calls to the Octokit client.
# Helpful: https://github.com/octokit/handbook?tab=readme-ov-file#github-app-authentication-json-web-token

# Why? In some cases, you may not want to have a static long lived token like a GitHub PAT when authenticating...
# with octokit.rb.
# Most importantly, this class will handle automatic token refreshing for you out-of-the-box. Simply provide the...
# correct environment variables, call `GitHubApp.new`, and then use the returned object as you would an Octokit client.

require "octokit"
require "jwt"

class GitHubApp
TOKEN_EXPIRATION_TIME = 2700 # 45 minutes
JWT_EXPIRATION_TIME = 600 # 10 minutes

def initialize
# app ids are found on the App's settings page
@app_id = fetch_env_var("ISSUE_DB_GITHUB_APP_ID").to_i

# installation ids look like this:
# https://github.com/organizations/<org>/settings/installations/<8_digit_id>
@installation_id = fetch_env_var("ISSUE_DB_GITHUB_APP_INSTALLATION_ID").to_i

# app keys are found on the App's settings page and can be downloaded
# format: "-----BEGIN...key\n...END-----\n"
# make sure this key in your env is a single line string with newlines as "\n"
@app_key = fetch_env_var("ISSUE_DB_GITHUB_APP_KEY").gsub(/\\+n/, "\n")

@client = nil
@token_refresh_time = nil
end

private

# Fetches the value of an environment variable and raises an error if it is not set.
# @param key [String] The name of the environment variable.
# @return [String] The value of the environment variable.
def fetch_env_var(key)
ENV.fetch(key) { raise "environment variable #{key} is not set" }
end

# Caches the octokit client if it is not nil and the token has not expired
# If it is nil or the token has expired, it creates a new client
# @return [Octokit::Client] The octokit client
def client
if @client.nil? || token_expired?
@client = create_client
end

@client
end

# A helper method for generating a JWT token for the GitHub App
# @return [String] The JWT token
def jwt_token
private_key = OpenSSL::PKey::RSA.new(@app_key)

payload = {}.tap do |opts|
opts[:iat] = Time.now.to_i - 60 # issued at time, 60 seconds in the past to allow for clock drift
opts[:exp] = opts[:iat] + JWT_EXPIRATION_TIME # JWT expiration time (10 minute maximum)
opts[:iss] = @app_id # GitHub App ID
end

JWT.encode(payload, private_key, "RS256")
end

# Creates a new octokit client and fetches a new installation access token
# @return [Octokit::Client] The octokit client
def create_client
client = ::Octokit::Client.new(bearer_token: jwt_token)
access_token = client.create_app_installation_access_token(@installation_id)[:token]
client = ::Octokit::Client.new(access_token:)
client.auto_paginate = true
client.per_page = 100
@token_refresh_time = Time.now
client
end

# GitHub App installation access tokens expire after 1h
# This method checks if the token has expired and returns true if it has
# It is very cautious and expires tokens at 45 minutes to account for clock drift
# @return [Boolean] True if the token has expired, false otherwise
def token_expired?
@token_refresh_time.nil? || (Time.now - @token_refresh_time) > TOKEN_EXPIRATION_TIME
end

# This method is called when a method is called on the GitHub class that does not exist.
# It delegates the method call to the Octokit client.
# @param method [Symbol] The name of the method being called.
# @param args [Array] The arguments passed to the method.
# @param block [Proc] An optional block passed to the method.
# @return [Object] The result of the method call on the Octokit client.
def method_missing(method, *args, &block)
client.send(method, *args, &block)
end

# This method is called to check if the GitHub class responds to a method.
# It checks if the Octokit client responds to the method.
# @param method [Symbol] The name of the method being checked.
# @param include_private [Boolean] Whether to include private methods in the check.
# @return [Boolean] True if the Octokit client responds to the method, false otherwise.
def respond_to_missing?(method, include_private = false)
client.respond_to?(method, include_private) || super
end
end
2 changes: 1 addition & 1 deletion spec/acceptance/acceptance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

context "#read" do
it "successfully reads an issue and returns a record even though it is closed" do
record = db.read("event456")
record = db.read("event456", options)
expect(record).to be_a(Record)
expect(record.data).to be_a(Hash)
expect(record.data["cool"]).to eq(true)
Expand Down
27 changes: 27 additions & 0 deletions spec/fixtures/fake_private_key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEArCtJrQ+P59A1Pjaf12EfJqltszDpqO0ufsk7N0WRXUeoYZKF
AihwLMIaHbTc1Jn/QEX8WmZGPKIcRlJ9pk2MxQbXRqxM35n61Cb8mYMne+6VXNyl
ZILvRTMXLGIVy/OTmszafTP8Lws9w8vKKLKCax4kEzbfjiRoCgspO7YudFUCxQ6Q
UUxlLPd4/yUaw5A8nUdWZrvPa3CYXG2355yhigRpnOyawoOsvAHUvQruh+z3k6NZ
g6f3eMjsrpeID84TPw1kRs+T+XNsSP21ZUFKYs54bdxlLPphT4iPKNkRwoP0SqJm
97W98B8k7+mtFPZllYGgiHrA4Egnasg9ULiXCwIDAQABAoIBACMoZ9AuWF2nN+gv
cW6jB6B2gs9P0rdLT+5WG4CK9UdOJcVfDUhGh7msHXcpgtrrY6N1ZzXyoq8pD4sQ
t1XpijCF2Bo3fy8+G2mNWJHkpYB6VQf0itW+oyvHZhkLIpZWdDLtWESvA/V7Xy6H
hA3Rfi5vpkBCOV6mcpRyeQYXit74UzDvojXSf8idsjuVDoIXuaoIJMPwyxTWJY+A
8sQ92ecHJ3rQLspxSmXlZYpRtHTNaZlAv1qx605DP/JlV/6bAA3IIWQtrd9Z0Cw/
rwNtwcP0vB/w6Yq9o8EmJHn7rQuC4NN6lMi/VkwaNCqcPfnRWW7OCtgOhfGAP+kr
vKU5kEECgYEA4hOWtUC4624E4r9NYNOeYcCj9meR5p3FY8vtPLMvpU9r4m3vPldz
ofq/I/Tpo418gWuy02/c4VpHo/8QUBW33yik0YVfL/XWczZwvhabufSTRL3HtIxb
NY5qbgV7yJea1mUuTkiM2obl4x+bR6haHPnHVu+QI480zOzkroF9P2ECgYEAwvUX
dpKebLxzod/UeQLvZFhFXG/qvvAJ7FwxtDRu22znQJR5YVdqd3XDZkfd+64PjDLf
46WMktqu2DclO9eFbKyuLfD0F1OO5z5IHd5dim39/QYo0sJ+y6y6edEM7Of2IxLH
5PkSJLVKAju5t2PJMXDBZBa4HVhNdW+lDHKlCesCgYEAzDvQCVw38g/JACK8P33N
dhe2x9IWv1TGTnqajhx+LYQLPVn9KL+OKcXBSTVmoCcgVDa8LUDANSD+2UuCLCcC
neo0w0cOj+Ax5JFI1qDL+/jT1eTwdc3aVA6dXVk80yEKcyai53upK310TnNuLxUK
m2SWzZXMDCPCGmLj0DYQtOECgYEAs3oIsKsH19ihpxs1MnZGRp2QtSl+9Wpr6EFz
rI88oxqdxfEp0Tg1lmY+jbGJpYI3Y/0N6jfksulJX1ldGLsvZL2P2FFjlPniq/XF
VGH6wU7DLSV3fZd6PSz1uuF+QbbF/MH0blHxpwOSb33mWfMuLCq+jtLvimxZWsx+
KHh+gSMCgYEA22osS9G3tGpG+x3nZRLXc0lOdKQ51sAo955U1BFscZusP/qQ+b12
KTHhoUd264C6Nb42XXs9qIkiKWaVblglJPhui61/iWw4s0k4Q9Yf/0KxREmlLbL0
mm1m2wenfA97PYoheO1esGsatSXNtxPL+C8ywebu38A6hOClQi1BHd4=
-----END RSA PRIVATE KEY-----
15 changes: 13 additions & 2 deletions spec/lib/issue_db/authentication_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,23 @@
expect(Octokit::Client).to receive(:new)
.with(access_token: token, page_size: 100)
.and_return(client)
expect(ENV).to receive(:fetch).with("GITHUB_TOKEN", nil).and_return(token)
expect(ENV).to receive(:fetch).with("ISSUE_DB_GITHUB_APP_ID", nil).and_return(nil)
expect(ENV).to receive(:fetch).with("ISSUE_DB_GITHUB_TOKEN", nil).and_return(token)
expect(Authentication.login).to eq(client)
end

it "returns a hydrated octokit client from a GitHub App" do
expect(ENV).to receive(:fetch).with("ISSUE_DB_GITHUB_APP_ID", nil).and_return("123")
expect(ENV).to receive(:fetch).with("ISSUE_DB_GITHUB_APP_INSTALLATION_ID", nil).and_return("456")
expect(ENV).to receive(:fetch).with("ISSUE_DB_GITHUB_APP_KEY", nil).and_return("-----KEY-----")

expect(GitHubApp).to receive(:new).and_return(client)
expect(Authentication.login).to eq(client)
end

it "raises an authentication error when no auth methods pass for octokit" do
expect(ENV).to receive(:fetch).with("GITHUB_TOKEN", nil).and_return(nil)
expect(ENV).to receive(:fetch).with("ISSUE_DB_GITHUB_APP_ID", nil).and_return(nil)
expect(ENV).to receive(:fetch).with("ISSUE_DB_GITHUB_TOKEN", nil).and_return(nil)
expect do
Authentication.login
end.to raise_error(
Expand Down
2 changes: 1 addition & 1 deletion spec/lib/issue_db/models/record_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require "spec_helper"
require_relative "../../../../lib/issue_db/models/record"

RSpec.describe Record do
describe Record do
let(:valid_issue) do
double(
"issue",
Expand Down
2 changes: 1 addition & 1 deletion spec/lib/issue_db/utils/generate_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require "spec_helper"
require_relative "../../../../lib/issue_db/utils/generate"

RSpec.describe Generate do
describe Generate do
include Generate

let(:data) do
Expand Down
Loading