lex-github 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +45 -19
- data/README.md +155 -83
- data/lex-github.gemspec +2 -0
- data/lib/legion/extensions/github/app/actor/token_refresh.rb +68 -0
- data/lib/legion/extensions/github/app/actor/webhook_poller.rb +65 -0
- data/lib/legion/extensions/github/app/hooks/setup.rb +19 -0
- data/lib/legion/extensions/github/app/hooks/webhook.rb +19 -0
- data/lib/legion/extensions/github/app/runners/auth.rb +48 -0
- data/lib/legion/extensions/github/app/runners/credential_store.rb +46 -0
- data/lib/legion/extensions/github/app/runners/installations.rb +56 -0
- data/lib/legion/extensions/github/app/runners/manifest.rb +65 -0
- data/lib/legion/extensions/github/app/runners/webhooks.rb +118 -0
- data/lib/legion/extensions/github/app/transport/exchanges/app.rb +17 -0
- data/lib/legion/extensions/github/app/transport/messages/event.rb +18 -0
- data/lib/legion/extensions/github/app/transport/queues/auth.rb +18 -0
- data/lib/legion/extensions/github/app/transport/queues/webhooks.rb +18 -0
- data/lib/legion/extensions/github/cli/app.rb +57 -0
- data/lib/legion/extensions/github/cli/auth.rb +99 -0
- data/lib/legion/extensions/github/client.rb +24 -0
- data/lib/legion/extensions/github/errors.rb +44 -0
- data/lib/legion/extensions/github/helpers/browser_auth.rb +106 -0
- data/lib/legion/extensions/github/helpers/cache.rb +99 -0
- data/lib/legion/extensions/github/helpers/callback_server.rb +89 -0
- data/lib/legion/extensions/github/helpers/client.rb +292 -2
- data/lib/legion/extensions/github/helpers/scope_registry.rb +91 -0
- data/lib/legion/extensions/github/helpers/token_cache.rb +86 -0
- data/lib/legion/extensions/github/middleware/credential_fallback.rb +76 -0
- data/lib/legion/extensions/github/middleware/rate_limit.rb +40 -0
- data/lib/legion/extensions/github/middleware/scope_probe.rb +37 -0
- data/lib/legion/extensions/github/oauth/actor/token_refresh.rb +76 -0
- data/lib/legion/extensions/github/oauth/hooks/callback.rb +19 -0
- data/lib/legion/extensions/github/oauth/runners/auth.rb +111 -0
- data/lib/legion/extensions/github/oauth/transport/exchanges/oauth.rb +17 -0
- data/lib/legion/extensions/github/oauth/transport/queues/auth.rb +18 -0
- data/lib/legion/extensions/github/runners/actions.rb +100 -0
- data/lib/legion/extensions/github/runners/branches.rb +5 -3
- data/lib/legion/extensions/github/runners/checks.rb +84 -0
- data/lib/legion/extensions/github/runners/comments.rb +13 -7
- data/lib/legion/extensions/github/runners/commits.rb +11 -6
- data/lib/legion/extensions/github/runners/contents.rb +3 -1
- data/lib/legion/extensions/github/runners/deployments.rb +76 -0
- data/lib/legion/extensions/github/runners/gists.rb +9 -4
- data/lib/legion/extensions/github/runners/issues.rb +16 -9
- data/lib/legion/extensions/github/runners/labels.rb +16 -9
- data/lib/legion/extensions/github/runners/organizations.rb +10 -8
- data/lib/legion/extensions/github/runners/pull_requests.rb +24 -14
- data/lib/legion/extensions/github/runners/releases.rb +89 -0
- data/lib/legion/extensions/github/runners/repositories.rb +17 -10
- data/lib/legion/extensions/github/runners/repository_webhooks.rb +76 -0
- data/lib/legion/extensions/github/runners/search.rb +11 -8
- data/lib/legion/extensions/github/runners/users.rb +12 -8
- data/lib/legion/extensions/github/version.rb +1 -1
- data/lib/legion/extensions/github.rb +22 -0
- metadata +63 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'rbconfig'
|
|
5
|
+
require 'legion/extensions/github/oauth/runners/auth'
|
|
6
|
+
require 'legion/extensions/github/helpers/callback_server'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Github
|
|
11
|
+
module Helpers
|
|
12
|
+
class BrowserAuth
|
|
13
|
+
DEFAULT_SCOPES = 'repo admin:org admin:repo_hook read:user'
|
|
14
|
+
|
|
15
|
+
attr_reader :client_id, :client_secret, :scopes
|
|
16
|
+
|
|
17
|
+
def initialize(client_id:, client_secret:, scopes: DEFAULT_SCOPES, auth: nil, **)
|
|
18
|
+
@client_id = client_id
|
|
19
|
+
@client_secret = client_secret
|
|
20
|
+
@scopes = scopes
|
|
21
|
+
@auth = auth || Object.new.extend(OAuth::Runners::Auth)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def authenticate
|
|
25
|
+
if gui_available?
|
|
26
|
+
authenticate_browser
|
|
27
|
+
else
|
|
28
|
+
authenticate_device_code
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def gui_available?
|
|
33
|
+
os = host_os
|
|
34
|
+
return true if /darwin|mswin|mingw/.match?(os)
|
|
35
|
+
|
|
36
|
+
!ENV['DISPLAY'].nil? || !ENV['WAYLAND_DISPLAY'].nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def open_browser(url)
|
|
40
|
+
cmd = case host_os
|
|
41
|
+
when /darwin/ then 'open'
|
|
42
|
+
when /linux/ then 'xdg-open'
|
|
43
|
+
when /mswin|mingw/ then 'start'
|
|
44
|
+
end
|
|
45
|
+
return false unless cmd
|
|
46
|
+
|
|
47
|
+
system(cmd, url)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def host_os
|
|
53
|
+
RbConfig::CONFIG['host_os']
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def authenticate_browser
|
|
57
|
+
pkce = @auth.generate_pkce[:result]
|
|
58
|
+
state = SecureRandom.hex(32)
|
|
59
|
+
|
|
60
|
+
server = CallbackServer.new
|
|
61
|
+
server.start
|
|
62
|
+
callback_uri = server.redirect_uri
|
|
63
|
+
|
|
64
|
+
url = @auth.authorize_url(
|
|
65
|
+
client_id: client_id, redirect_uri: callback_uri,
|
|
66
|
+
scope: scopes, state: state,
|
|
67
|
+
code_challenge: pkce[:challenge],
|
|
68
|
+
code_challenge_method: pkce[:challenge_method]
|
|
69
|
+
)[:result]
|
|
70
|
+
|
|
71
|
+
return authenticate_device_code unless open_browser(url)
|
|
72
|
+
|
|
73
|
+
result = server.wait_for_callback(timeout: 120)
|
|
74
|
+
|
|
75
|
+
return { error: 'timeout', description: 'No callback received within timeout' } unless result&.dig(:code)
|
|
76
|
+
|
|
77
|
+
return { error: 'state_mismatch', description: 'CSRF state parameter mismatch' } unless result[:state] == state
|
|
78
|
+
|
|
79
|
+
@auth.exchange_code(
|
|
80
|
+
client_id: client_id, client_secret: client_secret,
|
|
81
|
+
code: result[:code], redirect_uri: callback_uri,
|
|
82
|
+
code_verifier: pkce[:verifier]
|
|
83
|
+
)
|
|
84
|
+
ensure
|
|
85
|
+
server&.shutdown
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def authenticate_device_code
|
|
89
|
+
dc = @auth.request_device_code(client_id: client_id, scope: scopes)
|
|
90
|
+
return { error: dc[:error], description: dc[:description] } if dc[:error]
|
|
91
|
+
|
|
92
|
+
body = dc[:result]
|
|
93
|
+
warn "Go to: #{body[:verification_uri]}"
|
|
94
|
+
warn "Code: #{body[:user_code]}"
|
|
95
|
+
open_browser(body[:verification_uri]) if gui_available?
|
|
96
|
+
|
|
97
|
+
@auth.poll_device_code(
|
|
98
|
+
client_id: client_id,
|
|
99
|
+
device_code: body[:device_code]
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/cache/helper'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Github
|
|
8
|
+
module Helpers
|
|
9
|
+
module Cache
|
|
10
|
+
include Legion::Cache::Helper
|
|
11
|
+
|
|
12
|
+
DEFAULT_TTLS = {
|
|
13
|
+
repo: 600, issue: 120, pull_request: 60, commit: 86_400,
|
|
14
|
+
branch: 120, user: 3600, org: 3600, search: 60
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
DEFAULT_TTL = 300
|
|
18
|
+
|
|
19
|
+
def cached_get(cache_key, ttl: nil)
|
|
20
|
+
if cache_connected?
|
|
21
|
+
result = cache_get(cache_key)
|
|
22
|
+
return result if result
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if local_cache_connected?
|
|
26
|
+
result = local_cache_get(cache_key)
|
|
27
|
+
return result if result
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
result = yield
|
|
31
|
+
effective_ttl = ttl || github_ttl_for(cache_key)
|
|
32
|
+
cache_set(cache_key, result, ttl: effective_ttl) if cache_connected?
|
|
33
|
+
local_cache_set(cache_key, result, ttl: effective_ttl) if local_cache_connected?
|
|
34
|
+
result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def cache_write(cache_key, value, ttl: nil)
|
|
38
|
+
effective_ttl = ttl || github_ttl_for(cache_key)
|
|
39
|
+
cache_set(cache_key, value, ttl: effective_ttl) if cache_connected?
|
|
40
|
+
local_cache_set(cache_key, value, ttl: effective_ttl) if local_cache_connected?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cache_invalidate(cache_key)
|
|
44
|
+
cache_delete(cache_key) if cache_connected?
|
|
45
|
+
local_cache_delete(cache_key) if local_cache_connected?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def github_ttl_for(cache_key)
|
|
49
|
+
configured_ttls = github_cache_ttls
|
|
50
|
+
case cache_key
|
|
51
|
+
when /:commits:/ then configured_ttls[:commit]
|
|
52
|
+
when /:pulls:/ then configured_ttls[:pull_request]
|
|
53
|
+
when /:issues:/ then configured_ttls[:issue]
|
|
54
|
+
when /:branches:/ then configured_ttls[:branch]
|
|
55
|
+
when /\Agithub:user:/ then configured_ttls[:user]
|
|
56
|
+
when /\Agithub:org:/ then configured_ttls[:org]
|
|
57
|
+
when /\Agithub:repo:[^:]+\z/ then configured_ttls[:repo]
|
|
58
|
+
when /:search:/ then configured_ttls[:search]
|
|
59
|
+
else configured_ttls.fetch(:default, DEFAULT_TTL)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def cache_connected?
|
|
64
|
+
::Legion::Cache.connected?
|
|
65
|
+
rescue StandardError => _e
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def local_cache_connected?
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def local_cache_get(_key)
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def local_cache_set(_key, _value, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def local_cache_delete(_key)
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def github_cache_ttls
|
|
88
|
+
return DEFAULT_TTLS.merge(default: DEFAULT_TTL) unless defined?(Legion::Settings)
|
|
89
|
+
|
|
90
|
+
overrides = Legion::Settings.dig(:github, :cache, :ttls) || {}
|
|
91
|
+
DEFAULT_TTLS.merge(default: DEFAULT_TTL).merge(overrides.transform_keys(&:to_sym))
|
|
92
|
+
rescue StandardError => _e
|
|
93
|
+
DEFAULT_TTLS.merge(default: DEFAULT_TTL)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Github
|
|
9
|
+
module Helpers
|
|
10
|
+
class CallbackServer
|
|
11
|
+
RESPONSE_HTML = <<~HTML
|
|
12
|
+
<html><body style="font-family:sans-serif;text-align:center;padding:40px;">
|
|
13
|
+
<h2>GitHub authentication complete</h2><p>You can close this window.</p></body></html>
|
|
14
|
+
HTML
|
|
15
|
+
|
|
16
|
+
attr_reader :port
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@server = nil
|
|
20
|
+
@port = nil
|
|
21
|
+
@result = nil
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@cv = ConditionVariable.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start
|
|
27
|
+
@server = TCPServer.new('127.0.0.1', 0)
|
|
28
|
+
@port = @server.addr[1]
|
|
29
|
+
@thread = Thread.new { listen } # rubocop:disable ThreadSafety/NewThread
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def wait_for_callback(timeout: 120)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@cv.wait(@mutex, timeout) unless @result
|
|
35
|
+
@result
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def shutdown
|
|
40
|
+
@server&.close
|
|
41
|
+
rescue StandardError => _e
|
|
42
|
+
nil
|
|
43
|
+
ensure
|
|
44
|
+
@thread&.join(2)
|
|
45
|
+
@thread&.kill
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def redirect_uri
|
|
49
|
+
"http://127.0.0.1:#{@port}/callback"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def listen
|
|
55
|
+
loop do
|
|
56
|
+
client = @server.accept
|
|
57
|
+
request_line = client.gets
|
|
58
|
+
loop do
|
|
59
|
+
line = client.gets
|
|
60
|
+
break if line.nil? || line.strip.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if request_line&.include?('/callback?')
|
|
64
|
+
query = request_line.split[1].split('?', 2).last
|
|
65
|
+
params = URI.decode_www_form(query).to_h
|
|
66
|
+
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@result = { code: params['code'], state: params['state'] }
|
|
69
|
+
@cv.broadcast
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
client.print "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n#{RESPONSE_HTML}"
|
|
74
|
+
client.close
|
|
75
|
+
break if @result
|
|
76
|
+
end
|
|
77
|
+
rescue IOError # rubocop:disable Legion/RescueLogging/NoCapture
|
|
78
|
+
nil
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
@result ||= { error: e.message }
|
|
82
|
+
@cv.broadcast
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -1,21 +1,311 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
|
+
require 'legion/extensions/github/helpers/token_cache'
|
|
5
|
+
require 'legion/extensions/github/helpers/scope_registry'
|
|
6
|
+
require 'legion/extensions/github/middleware/credential_fallback'
|
|
4
7
|
|
|
5
8
|
module Legion
|
|
6
9
|
module Extensions
|
|
7
10
|
module Github
|
|
8
11
|
module Helpers
|
|
9
12
|
module Client
|
|
10
|
-
|
|
13
|
+
include TokenCache
|
|
14
|
+
include ScopeRegistry
|
|
15
|
+
|
|
16
|
+
CREDENTIAL_RESOLVERS = %i[
|
|
17
|
+
resolve_vault_delegated resolve_settings_delegated
|
|
18
|
+
resolve_vault_app resolve_settings_app
|
|
19
|
+
resolve_vault_pat resolve_settings_pat
|
|
20
|
+
resolve_gh_cli resolve_env
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def connection(owner: nil, repo: nil, api_url: 'https://api.github.com', token: nil, **_opts)
|
|
24
|
+
resolved = token ? { token: token } : resolve_credential(owner: owner, repo: repo)
|
|
25
|
+
resolved_token = resolved&.dig(:token)
|
|
26
|
+
@current_credential = resolved
|
|
27
|
+
@skipped_fingerprints = []
|
|
28
|
+
|
|
11
29
|
Faraday.new(url: api_url) do |conn|
|
|
30
|
+
conn.use :github_credential_fallback, resolver: self
|
|
12
31
|
conn.request :json
|
|
13
32
|
conn.response :json, content_type: /\bjson$/
|
|
33
|
+
conn.response :github_rate_limit, handler: self
|
|
34
|
+
conn.response :github_scope_probe, handler: self
|
|
14
35
|
conn.headers['Accept'] = 'application/vnd.github+json'
|
|
15
|
-
conn.headers['Authorization'] = "Bearer #{
|
|
36
|
+
conn.headers['Authorization'] = "Bearer #{resolved_token}" if resolved_token
|
|
16
37
|
conn.headers['X-GitHub-Api-Version'] = '2022-11-28'
|
|
17
38
|
end
|
|
18
39
|
end
|
|
40
|
+
|
|
41
|
+
def resolve_next_credential(owner: nil, repo: nil)
|
|
42
|
+
fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
|
|
43
|
+
@skipped_fingerprints ||= []
|
|
44
|
+
@skipped_fingerprints << fingerprint if fingerprint
|
|
45
|
+
|
|
46
|
+
CREDENTIAL_RESOLVERS.each do |method|
|
|
47
|
+
next unless respond_to?(method, true)
|
|
48
|
+
|
|
49
|
+
result = send(method)
|
|
50
|
+
next unless result
|
|
51
|
+
|
|
52
|
+
fp = result.dig(:metadata, :credential_fingerprint)
|
|
53
|
+
next if fp && @skipped_fingerprints.include?(fp)
|
|
54
|
+
next if fp && rate_limited?(fingerprint: fp)
|
|
55
|
+
|
|
56
|
+
if owner && fp
|
|
57
|
+
scope = scope_status(fingerprint: fp, owner: owner, repo: repo)
|
|
58
|
+
next if scope == :denied
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@current_credential = result
|
|
62
|
+
return result
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def max_fallback_retries
|
|
68
|
+
CREDENTIAL_RESOLVERS.size
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def on_rate_limit(remaining:, reset_at:, status:, url:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
72
|
+
fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
|
|
73
|
+
return unless fingerprint
|
|
74
|
+
|
|
75
|
+
mark_rate_limited(fingerprint: fingerprint, reset_at: reset_at)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def on_scope_denied(status:, url:, path:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
79
|
+
fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
|
|
80
|
+
owner, repo = extract_owner_repo(path)
|
|
81
|
+
return unless fingerprint && owner
|
|
82
|
+
|
|
83
|
+
register_scope(fingerprint: fingerprint, owner: owner, repo: repo, status: :denied)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def on_scope_authorized(status:, url:, path:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
87
|
+
fingerprint = @current_credential&.dig(:metadata, :credential_fingerprint)
|
|
88
|
+
owner, repo = extract_owner_repo(path)
|
|
89
|
+
return unless fingerprint && owner
|
|
90
|
+
|
|
91
|
+
register_scope(fingerprint: fingerprint, owner: owner, repo: repo, status: :authorized)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_credential(owner: nil, repo: nil)
|
|
95
|
+
CREDENTIAL_RESOLVERS.each do |method|
|
|
96
|
+
next unless respond_to?(method, true)
|
|
97
|
+
|
|
98
|
+
result = send(method)
|
|
99
|
+
next unless result
|
|
100
|
+
|
|
101
|
+
fingerprint = result.dig(:metadata, :credential_fingerprint)
|
|
102
|
+
|
|
103
|
+
next if fingerprint && rate_limited?(fingerprint: fingerprint)
|
|
104
|
+
|
|
105
|
+
if owner && fingerprint
|
|
106
|
+
scope = scope_status(fingerprint: fingerprint, owner: owner, repo: repo)
|
|
107
|
+
next if scope == :denied
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return result
|
|
111
|
+
end
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_vault_delegated
|
|
116
|
+
return nil unless defined?(Legion::Crypt)
|
|
117
|
+
|
|
118
|
+
token_data = vault_get('github/oauth/delegated/token')
|
|
119
|
+
return nil unless token_data&.dig('access_token')
|
|
120
|
+
|
|
121
|
+
fp = credential_fingerprint(auth_type: :oauth_user, identifier: 'vault_delegated')
|
|
122
|
+
{ token: token_data['access_token'], auth_type: :oauth_user,
|
|
123
|
+
expires_at: token_data['expires_at'],
|
|
124
|
+
metadata: { source: :vault, credential_fingerprint: fp } }
|
|
125
|
+
rescue StandardError => _e
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def resolve_settings_delegated
|
|
130
|
+
return nil unless defined?(Legion::Settings)
|
|
131
|
+
|
|
132
|
+
token = Legion::Settings.dig(:github, :oauth, :access_token)
|
|
133
|
+
return nil unless token
|
|
134
|
+
|
|
135
|
+
fp = credential_fingerprint(auth_type: :oauth_user, identifier: 'settings_delegated')
|
|
136
|
+
{ token: token, auth_type: :oauth_user,
|
|
137
|
+
metadata: { source: :settings, credential_fingerprint: fp } }
|
|
138
|
+
rescue StandardError => _e
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resolve_vault_app
|
|
143
|
+
return nil unless defined?(Legion::Crypt)
|
|
144
|
+
|
|
145
|
+
private_key = begin
|
|
146
|
+
vault_get('github/app/private_key')
|
|
147
|
+
rescue StandardError => _e
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
return nil unless private_key
|
|
151
|
+
|
|
152
|
+
app_id = begin
|
|
153
|
+
vault_get('github/app/app_id')
|
|
154
|
+
rescue StandardError => _e
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
installation_id = begin
|
|
158
|
+
vault_get('github/app/installation_id')
|
|
159
|
+
rescue StandardError => _e
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
return nil unless app_id && installation_id
|
|
163
|
+
|
|
164
|
+
fp = credential_fingerprint(auth_type: :app_installation, identifier: "vault_app_#{app_id}")
|
|
165
|
+
cached = fetch_token(auth_type: :app_installation, installation_id: installation_id)
|
|
166
|
+
return cached.merge(metadata: { source: :vault, credential_fingerprint: fp }) if cached
|
|
167
|
+
|
|
168
|
+
jwt = generate_jwt(app_id: app_id, private_key: private_key)[:result]
|
|
169
|
+
token_data = create_installation_token(jwt: jwt, installation_id: installation_id)[:result]
|
|
170
|
+
return nil unless token_data&.dig('token')
|
|
171
|
+
|
|
172
|
+
expires_at = begin
|
|
173
|
+
Time.parse(token_data['expires_at'])
|
|
174
|
+
rescue StandardError => _e
|
|
175
|
+
Time.now + 3600
|
|
176
|
+
end
|
|
177
|
+
result = { token: token_data['token'], auth_type: :app_installation,
|
|
178
|
+
expires_at: expires_at, installation_id: installation_id,
|
|
179
|
+
metadata: { source: :vault, installation_id: installation_id,
|
|
180
|
+
credential_fingerprint: fp } }
|
|
181
|
+
store_token(**result)
|
|
182
|
+
result
|
|
183
|
+
rescue StandardError => _e
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def resolve_settings_app
|
|
188
|
+
return nil unless defined?(Legion::Settings)
|
|
189
|
+
|
|
190
|
+
app_id = begin
|
|
191
|
+
Legion::Settings.dig(:github, :app, :app_id)
|
|
192
|
+
rescue StandardError => _e
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
return nil unless app_id
|
|
196
|
+
|
|
197
|
+
fp = credential_fingerprint(auth_type: :app_installation, identifier: "settings_app_#{app_id}")
|
|
198
|
+
|
|
199
|
+
key_path = begin
|
|
200
|
+
Legion::Settings.dig(:github, :app, :private_key_path)
|
|
201
|
+
rescue StandardError => _e
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
installation_id = begin
|
|
205
|
+
Legion::Settings.dig(:github, :app, :installation_id)
|
|
206
|
+
rescue StandardError => _e
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
return nil unless key_path && installation_id
|
|
210
|
+
|
|
211
|
+
cached = fetch_token(auth_type: :app_installation, installation_id: installation_id)
|
|
212
|
+
return cached.merge(metadata: { source: :settings, credential_fingerprint: fp }) if cached
|
|
213
|
+
|
|
214
|
+
private_key = ::File.read(key_path)
|
|
215
|
+
jwt = generate_jwt(app_id: app_id, private_key: private_key)[:result]
|
|
216
|
+
token_data = create_installation_token(jwt: jwt, installation_id: installation_id)[:result]
|
|
217
|
+
return nil unless token_data&.dig('token')
|
|
218
|
+
|
|
219
|
+
expires_at = begin
|
|
220
|
+
Time.parse(token_data['expires_at'])
|
|
221
|
+
rescue StandardError => _e
|
|
222
|
+
Time.now + 3600
|
|
223
|
+
end
|
|
224
|
+
result = { token: token_data['token'], auth_type: :app_installation,
|
|
225
|
+
expires_at: expires_at, installation_id: installation_id,
|
|
226
|
+
metadata: { source: :settings, installation_id: installation_id,
|
|
227
|
+
credential_fingerprint: fp } }
|
|
228
|
+
store_token(**result)
|
|
229
|
+
result
|
|
230
|
+
rescue StandardError => _e
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def resolve_vault_pat
|
|
235
|
+
return nil unless defined?(Legion::Crypt)
|
|
236
|
+
|
|
237
|
+
token = vault_get('github/token')
|
|
238
|
+
return nil unless token
|
|
239
|
+
|
|
240
|
+
fp = credential_fingerprint(auth_type: :pat, identifier: 'vault_pat')
|
|
241
|
+
{ token: token, auth_type: :pat, metadata: { source: :vault, credential_fingerprint: fp } }
|
|
242
|
+
rescue StandardError => _e
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def resolve_settings_pat
|
|
247
|
+
return nil unless defined?(Legion::Settings)
|
|
248
|
+
|
|
249
|
+
token = Legion::Settings.dig(:github, :token)
|
|
250
|
+
return nil unless token
|
|
251
|
+
|
|
252
|
+
fp = credential_fingerprint(auth_type: :pat, identifier: 'settings_pat')
|
|
253
|
+
{ token: token, auth_type: :pat, metadata: { source: :settings, credential_fingerprint: fp } }
|
|
254
|
+
rescue StandardError => _e
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def resolve_gh_cli
|
|
259
|
+
if cache_connected? || local_cache_connected?
|
|
260
|
+
cached = cache_connected? ? cache_get('github:cli_token') : local_cache_get('github:cli_token')
|
|
261
|
+
return cached if cached
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
output = gh_cli_token_output
|
|
265
|
+
return nil unless output
|
|
266
|
+
|
|
267
|
+
fp = credential_fingerprint(auth_type: :cli, identifier: 'gh_cli')
|
|
268
|
+
result = { token: output, auth_type: :cli, metadata: { source: :gh_cli, credential_fingerprint: fp } }
|
|
269
|
+
cache_set('github:cli_token', result, ttl: 300) if cache_connected?
|
|
270
|
+
local_cache_set('github:cli_token', result, ttl: 300) if local_cache_connected?
|
|
271
|
+
result
|
|
272
|
+
rescue StandardError => _e
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def gh_cli_token_output
|
|
277
|
+
output = `gh auth token 2>/dev/null`.strip
|
|
278
|
+
return nil unless $?&.success? && !output.empty? # rubocop:disable Style/SpecialGlobalVars
|
|
279
|
+
|
|
280
|
+
output
|
|
281
|
+
rescue StandardError => _e
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def resolve_env
|
|
286
|
+
token = ENV.fetch('GITHUB_TOKEN', nil)
|
|
287
|
+
return nil if token.nil? || token.empty?
|
|
288
|
+
|
|
289
|
+
fp = credential_fingerprint(auth_type: :env, identifier: 'env')
|
|
290
|
+
{ token: token, auth_type: :env, metadata: { source: :env, credential_fingerprint: fp } }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def extract_owner_repo(path)
|
|
296
|
+
match = path.match(%r{^/repos/([^/]+)/([^/]+)})
|
|
297
|
+
return [nil, nil] unless match
|
|
298
|
+
|
|
299
|
+
[match[1], match[2]]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def credential_fallback?
|
|
303
|
+
return true unless defined?(Legion::Settings)
|
|
304
|
+
|
|
305
|
+
Legion::Settings.dig(:github, :credential_fallback) != false
|
|
306
|
+
rescue StandardError => _e
|
|
307
|
+
true
|
|
308
|
+
end
|
|
19
309
|
end
|
|
20
310
|
end
|
|
21
311
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Github
|
|
8
|
+
module Helpers
|
|
9
|
+
module ScopeRegistry
|
|
10
|
+
def credential_fingerprint(auth_type:, identifier:)
|
|
11
|
+
Digest::SHA256.hexdigest("#{auth_type}:#{identifier}")[0, 16]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def scope_status(fingerprint:, owner:, repo: nil)
|
|
15
|
+
if repo
|
|
16
|
+
status = scope_cache_get("github:scope:#{fingerprint}:#{owner}/#{repo}")
|
|
17
|
+
return status if status
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
scope_cache_get("github:scope:#{fingerprint}:#{owner}") || :unknown
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register_scope(fingerprint:, owner:, status:, repo: nil)
|
|
24
|
+
key = repo ? "github:scope:#{fingerprint}:#{owner}/#{repo}" : "github:scope:#{fingerprint}:#{owner}"
|
|
25
|
+
ttl = if status == :denied
|
|
26
|
+
scope_denied_ttl
|
|
27
|
+
else
|
|
28
|
+
(repo ? scope_repo_ttl : scope_org_ttl)
|
|
29
|
+
end
|
|
30
|
+
cache_set(key, status, ttl: ttl) if cache_connected?
|
|
31
|
+
local_cache_set(key, status, ttl: ttl) if local_cache_connected?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def rate_limited?(fingerprint:)
|
|
35
|
+
entry = scope_cache_get("github:rate_limit:#{fingerprint}")
|
|
36
|
+
return false unless entry
|
|
37
|
+
|
|
38
|
+
entry[:reset_at] > Time.now
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def mark_rate_limited(fingerprint:, reset_at:)
|
|
42
|
+
ttl = [(reset_at - Time.now).ceil, 1].max
|
|
43
|
+
value = { reset_at: reset_at, remaining: 0 }
|
|
44
|
+
cache_set("github:rate_limit:#{fingerprint}", value, ttl: ttl) if cache_connected?
|
|
45
|
+
local_cache_set("github:rate_limit:#{fingerprint}", value, ttl: ttl) if local_cache_connected?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def invalidate_scope(fingerprint:, owner:, repo: nil)
|
|
49
|
+
key = repo ? "github:scope:#{fingerprint}:#{owner}/#{repo}" : "github:scope:#{fingerprint}:#{owner}"
|
|
50
|
+
cache_delete(key) if cache_connected?
|
|
51
|
+
local_cache_delete(key) if local_cache_connected?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def scope_cache_get(key)
|
|
57
|
+
if cache_connected?
|
|
58
|
+
result = cache_get(key)
|
|
59
|
+
return result if result
|
|
60
|
+
end
|
|
61
|
+
local_cache_get(key) if local_cache_connected?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def scope_org_ttl
|
|
65
|
+
return 3600 unless defined?(Legion::Settings)
|
|
66
|
+
|
|
67
|
+
Legion::Settings.dig(:github, :scope_registry, :org_ttl) || 3600
|
|
68
|
+
rescue StandardError => _e
|
|
69
|
+
3600
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def scope_repo_ttl
|
|
73
|
+
return 300 unless defined?(Legion::Settings)
|
|
74
|
+
|
|
75
|
+
Legion::Settings.dig(:github, :scope_registry, :repo_ttl) || 300
|
|
76
|
+
rescue StandardError => _e
|
|
77
|
+
300
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def scope_denied_ttl
|
|
81
|
+
return 300 unless defined?(Legion::Settings)
|
|
82
|
+
|
|
83
|
+
Legion::Settings.dig(:github, :scope_registry, :denied_ttl) || 300
|
|
84
|
+
rescue StandardError => _e
|
|
85
|
+
300
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|