knoxcall 0.0.1 → 1.0.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/LICENSE +201 -0
- data/README.md +439 -2
- data/exe/knoxcall +8 -0
- data/lib/knoxcall/bootstrap.rb +70 -0
- data/lib/knoxcall/bound_route.rb +47 -0
- data/lib/knoxcall/cli/ai.rb +79 -0
- data/lib/knoxcall/cli/ai_control.rb +275 -0
- data/lib/knoxcall/cli/common.rb +96 -0
- data/lib/knoxcall/cli/init.rb +94 -0
- data/lib/knoxcall/cli/login.rb +306 -0
- data/lib/knoxcall/cli/logout.rb +41 -0
- data/lib/knoxcall/cli/whoami.rb +29 -0
- data/lib/knoxcall/cli.rb +377 -0
- data/lib/knoxcall/client.rb +1025 -0
- data/lib/knoxcall/credentials_file.rb +442 -0
- data/lib/knoxcall/dpop.rb +79 -0
- data/lib/knoxcall/egress_observations.rb +372 -0
- data/lib/knoxcall/errors.rb +304 -0
- data/lib/knoxcall/intercept_patch.rb +181 -0
- data/lib/knoxcall/intercept_pipeline.rb +455 -0
- data/lib/knoxcall/intercept_resolver.rb +140 -0
- data/lib/knoxcall/intercept_store.rb +203 -0
- data/lib/knoxcall/login.rb +144 -0
- data/lib/knoxcall/resources/account.rb +12 -0
- data/lib/knoxcall/resources/agents.rb +25 -0
- data/lib/knoxcall/resources/ai_gateway.rb +417 -0
- data/lib/knoxcall/resources/api_keys.rb +35 -0
- data/lib/knoxcall/resources/audit_logs.rb +45 -0
- data/lib/knoxcall/resources/clients.rb +39 -0
- data/lib/knoxcall/resources/crypto.rb +122 -0
- data/lib/knoxcall/resources/dynamic_db.rb +68 -0
- data/lib/knoxcall/resources/environments.rb +16 -0
- data/lib/knoxcall/resources/logs.rb +51 -0
- data/lib/knoxcall/resources/oauth_clients.rb +34 -0
- data/lib/knoxcall/resources/opportunities.rb +61 -0
- data/lib/knoxcall/resources/pki.rb +41 -0
- data/lib/knoxcall/resources/roles.rb +27 -0
- data/lib/knoxcall/resources/routes.rb +53 -0
- data/lib/knoxcall/resources/secrets.rb +98 -0
- data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
- data/lib/knoxcall/resources/vaults.rb +77 -0
- data/lib/knoxcall/resources/webhooks.rb +48 -0
- data/lib/knoxcall/resources/workflows.rb +86 -0
- data/lib/knoxcall/resources/wrap.rb +352 -0
- data/lib/knoxcall/route_refusal.rb +67 -0
- data/lib/knoxcall/signup.rb +122 -0
- data/lib/knoxcall/token_exchange.rb +169 -0
- data/lib/knoxcall/ulid.rb +19 -0
- data/lib/knoxcall/warnings.rb +63 -0
- data/lib/knoxcall/workload_provider.rb +192 -0
- data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
- data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
- data/lib/knoxcall/wrap_transport.rb +139 -0
- data/lib/knoxcall.rb +45 -1
- metadata +70 -9
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
require "socket"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
module KnoxCall
|
|
8
|
+
module CLI
|
|
9
|
+
# `knoxcall login` — auth-code+PKCE via loopback redirect, or device flow.
|
|
10
|
+
module Login
|
|
11
|
+
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# -- PKCE (RFC 7636, S256 only) --------------------------------------------
|
|
16
|
+
|
|
17
|
+
# Return [code_verifier, code_challenge] — S256, unpadded base64url.
|
|
18
|
+
def generate_pkce_pair
|
|
19
|
+
verifier = base64url(SecureRandom.bytes(48))
|
|
20
|
+
challenge = base64url(OpenSSL::Digest::SHA256.digest(verifier))
|
|
21
|
+
[verifier, challenge]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def base64url(bytes)
|
|
25
|
+
[bytes].pack("m0").tr("+/", "-_").delete("=")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_authorize_url(base_url, redirect_uri:, state:, code_challenge:, tenant: nil)
|
|
29
|
+
params = {
|
|
30
|
+
"response_type" => "code",
|
|
31
|
+
"client_id" => CLI_CLIENT_ID,
|
|
32
|
+
"redirect_uri" => redirect_uri,
|
|
33
|
+
"state" => state,
|
|
34
|
+
"code_challenge" => code_challenge,
|
|
35
|
+
"code_challenge_method" => "S256"
|
|
36
|
+
}
|
|
37
|
+
params["tenant"] = tenant if CredentialsFile.presence(tenant)
|
|
38
|
+
"#{base_url}/oauth/authorize?#{URI.encode_www_form(params)}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# -- Loopback redirect receiver (RFC 8252 §7.3) ------------------------------
|
|
42
|
+
|
|
43
|
+
# One-shot loopback HTTP server on 127.0.0.1:0 for the authorize redirect.
|
|
44
|
+
#
|
|
45
|
+
# Serves until one GET /callback arrives (anything else — favicon probes
|
|
46
|
+
# and the like — gets a 404 and the listener keeps going).
|
|
47
|
+
class LoopbackServer
|
|
48
|
+
attr_reader :port
|
|
49
|
+
|
|
50
|
+
def initialize(host = "127.0.0.1")
|
|
51
|
+
@server = TCPServer.new(host, 0)
|
|
52
|
+
@port = @server.addr[1]
|
|
53
|
+
@mutex = Mutex.new
|
|
54
|
+
@cond = ConditionVariable.new
|
|
55
|
+
@result = nil
|
|
56
|
+
@thread = Thread.new { serve }
|
|
57
|
+
@thread.report_on_exception = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Block until the browser hits /callback; validate state, return the code.
|
|
61
|
+
def wait_for_code(expected_state:, timeout: 300.0)
|
|
62
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
63
|
+
result = @mutex.synchronize do
|
|
64
|
+
while @result.nil?
|
|
65
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
raise Error, "timed out waiting for the browser sign-in to complete" if remaining <= 0
|
|
67
|
+
@cond.wait(@mutex, remaining)
|
|
68
|
+
end
|
|
69
|
+
@result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if CredentialsFile.presence(result["error"])
|
|
73
|
+
detail = CredentialsFile.presence(result["error_description"]) || result["error"]
|
|
74
|
+
raise Error, "authorization failed: #{detail}"
|
|
75
|
+
end
|
|
76
|
+
unless OpenSSL.secure_compare(result["state"].to_s, expected_state)
|
|
77
|
+
raise Error, "state mismatch in the OAuth callback — possible CSRF, aborting"
|
|
78
|
+
end
|
|
79
|
+
code = CredentialsFile.presence(result["code"])
|
|
80
|
+
raise Error, "no authorization code in the OAuth callback" unless code
|
|
81
|
+
code
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def close
|
|
85
|
+
begin
|
|
86
|
+
@server.close
|
|
87
|
+
rescue IOError
|
|
88
|
+
# already closed
|
|
89
|
+
end
|
|
90
|
+
@thread.join(2) || @thread.kill
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def serve
|
|
96
|
+
loop do
|
|
97
|
+
socket = @server.accept
|
|
98
|
+
begin
|
|
99
|
+
handle(socket)
|
|
100
|
+
ensure
|
|
101
|
+
begin
|
|
102
|
+
socket.close
|
|
103
|
+
rescue IOError, SystemCallError
|
|
104
|
+
# peer already gone
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
break unless @mutex.synchronize { @result.nil? }
|
|
108
|
+
end
|
|
109
|
+
rescue IOError, SystemCallError
|
|
110
|
+
# listener closed — shutting down
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def handle(socket)
|
|
114
|
+
request_line = socket.gets
|
|
115
|
+
return unless request_line
|
|
116
|
+
while (line = socket.gets) # drain headers
|
|
117
|
+
break if line.strip.empty?
|
|
118
|
+
end
|
|
119
|
+
method, target, = request_line.split(" ", 3)
|
|
120
|
+
path, query = target.to_s.split("?", 2)
|
|
121
|
+
unless method == "GET" && path == "/callback"
|
|
122
|
+
respond(socket, "404 Not Found", page(failed: true))
|
|
123
|
+
return
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
result = {}
|
|
127
|
+
begin
|
|
128
|
+
URI.decode_www_form(query.to_s).each { |k, v| result[k] = v unless result.key?(k) }
|
|
129
|
+
rescue ArgumentError
|
|
130
|
+
# malformed query — treated as an empty callback below
|
|
131
|
+
end
|
|
132
|
+
failed = result.key?("error") || result["code"].to_s.empty?
|
|
133
|
+
respond(socket, "200 OK", page(failed: failed))
|
|
134
|
+
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
@result ||= result
|
|
137
|
+
@cond.signal
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def page(failed:)
|
|
142
|
+
"<!doctype html><meta charset='utf-8'><title>KnoxCall CLI</title>" \
|
|
143
|
+
"<body style='font-family:system-ui;margin:4rem auto;max-width:28rem'>" +
|
|
144
|
+
(if failed
|
|
145
|
+
"<h1>Sign-in failed</h1><p>Return to your terminal for details.</p>"
|
|
146
|
+
else
|
|
147
|
+
"<h1>Signed in</h1><p>You can close this window and return to your terminal.</p>"
|
|
148
|
+
end) + "</body>"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def respond(socket, status_line, html)
|
|
152
|
+
body = html.encode("utf-8")
|
|
153
|
+
socket.write(
|
|
154
|
+
"HTTP/1.1 #{status_line}\r\n" \
|
|
155
|
+
"Content-Type: text/html; charset=utf-8\r\n" \
|
|
156
|
+
"Content-Length: #{body.bytesize}\r\n" \
|
|
157
|
+
"Connection: close\r\n\r\n"
|
|
158
|
+
)
|
|
159
|
+
socket.write(body)
|
|
160
|
+
rescue IOError, SystemCallError
|
|
161
|
+
# browser hung up mid-response — the query is what matters
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# -- Flows -------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def auth_code_flow(base_url, tenant: nil, open_browser: nil, timeout: 300.0)
|
|
168
|
+
verifier, challenge = generate_pkce_pair
|
|
169
|
+
state = SecureRandom.urlsafe_base64(24)
|
|
170
|
+
server = LoopbackServer.new
|
|
171
|
+
begin
|
|
172
|
+
redirect_uri = "http://127.0.0.1:#{server.port}/callback"
|
|
173
|
+
url = build_authorize_url(base_url, redirect_uri: redirect_uri, state: state,
|
|
174
|
+
code_challenge: challenge, tenant: tenant)
|
|
175
|
+
puts "Opening your browser to sign in. If it does not open, visit:\n\n #{url}\n\n"
|
|
176
|
+
begin
|
|
177
|
+
(open_browser || method(:open_browser_default)).call(url)
|
|
178
|
+
rescue StandardError
|
|
179
|
+
# URL is printed; a broken browser launcher is not fatal
|
|
180
|
+
end
|
|
181
|
+
code = server.wait_for_code(expected_state: state, timeout: timeout)
|
|
182
|
+
ensure
|
|
183
|
+
server.close
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
status, body = Common.post_form(
|
|
187
|
+
"#{base_url}/oauth/token",
|
|
188
|
+
{
|
|
189
|
+
"grant_type" => "authorization_code",
|
|
190
|
+
"code" => code,
|
|
191
|
+
"redirect_uri" => redirect_uri,
|
|
192
|
+
"client_id" => CLI_CLIENT_ID,
|
|
193
|
+
"code_verifier" => verifier
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
if status >= 400 || !CredentialsFile.presence(body["access_token"])
|
|
197
|
+
raise Error, Common.token_error_message(status, body)
|
|
198
|
+
end
|
|
199
|
+
body
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Open the system browser; failures are non-fatal (the URL is always
|
|
203
|
+
# printed first). start/open/xdg-open per OS.
|
|
204
|
+
def open_browser_default(url)
|
|
205
|
+
clean = url.gsub('"', "%22")
|
|
206
|
+
case RbConfig::CONFIG["host_os"]
|
|
207
|
+
when /mswin|mingw|cygwin/
|
|
208
|
+
# single-string form runs through cmd.exe, where `start` lives; the
|
|
209
|
+
# quotes keep &-separated query params inside one argument
|
|
210
|
+
system(%(start "" "#{clean}"), out: File::NULL, err: File::NULL)
|
|
211
|
+
when /darwin/
|
|
212
|
+
system("open", url, out: File::NULL, err: File::NULL)
|
|
213
|
+
else
|
|
214
|
+
system("xdg-open", url, out: File::NULL, err: File::NULL)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Poll the token endpoint per RFC 8628 §3.5, honoring interval + slow_down.
|
|
219
|
+
# Sleeps BEFORE the first poll; `sleeper` is injectable for tests.
|
|
220
|
+
def poll_device_token(base_url, device_code, client_id: CLI_CLIENT_ID, interval: 5,
|
|
221
|
+
expires_in: 900.0, sleeper: ->(s) { sleep(s) })
|
|
222
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + expires_in
|
|
223
|
+
loop do
|
|
224
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
225
|
+
raise Error, "device authorization expired — run `knoxcall login` again"
|
|
226
|
+
end
|
|
227
|
+
sleeper.call(interval)
|
|
228
|
+
status, body = Common.post_form(
|
|
229
|
+
"#{base_url}/oauth/token",
|
|
230
|
+
{ "grant_type" => DEVICE_GRANT, "device_code" => device_code, "client_id" => client_id }
|
|
231
|
+
)
|
|
232
|
+
return body if status < 400 && CredentialsFile.presence(body["access_token"])
|
|
233
|
+
|
|
234
|
+
case body["error"]
|
|
235
|
+
when "authorization_pending"
|
|
236
|
+
next
|
|
237
|
+
when "slow_down"
|
|
238
|
+
interval += 5
|
|
239
|
+
when "expired_token"
|
|
240
|
+
raise Error, "the device code expired — run `knoxcall login` again"
|
|
241
|
+
when "access_denied"
|
|
242
|
+
raise Error, "sign-in was denied"
|
|
243
|
+
else
|
|
244
|
+
raise Error, Common.token_error_message(status, body)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def device_flow(base_url, sleeper: ->(s) { sleep(s) })
|
|
250
|
+
status, body = Common.post_form(
|
|
251
|
+
"#{base_url}/oauth/device_authorization", { "client_id" => CLI_CLIENT_ID }
|
|
252
|
+
)
|
|
253
|
+
if status >= 400 || !CredentialsFile.presence(body["device_code"])
|
|
254
|
+
raise Error, Common.token_error_message(status, body)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
verification_uri = body["verification_uri"].to_s
|
|
258
|
+
user_code = body["user_code"].to_s
|
|
259
|
+
puts "To sign in, open:\n\n #{verification_uri}\n\nand enter the code:\n\n #{user_code}\n\n"
|
|
260
|
+
complete = CredentialsFile.presence(body["verification_uri_complete"])
|
|
261
|
+
puts "(or open #{complete} directly)\n\n" if complete
|
|
262
|
+
puts "Waiting for approval…"
|
|
263
|
+
|
|
264
|
+
interval = begin
|
|
265
|
+
Integer(body["interval"] || 5)
|
|
266
|
+
rescue ArgumentError, TypeError
|
|
267
|
+
5
|
|
268
|
+
end
|
|
269
|
+
expires_in = begin
|
|
270
|
+
Float(body["expires_in"] || 900)
|
|
271
|
+
rescue ArgumentError, TypeError
|
|
272
|
+
900.0
|
|
273
|
+
end
|
|
274
|
+
poll_device_token(base_url, body["device_code"],
|
|
275
|
+
interval: interval, expires_in: expires_in, sleeper: sleeper)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# -- Command entry -------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def run(options, sleeper: nil, open_browser: nil)
|
|
281
|
+
base_url = (options[:base_url] || Common.default_base_url(options[:sandbox])).chomp("/")
|
|
282
|
+
path = CredentialsFile.resolve_path
|
|
283
|
+
profile = CredentialsFile.resolve_profile(options[:profile])
|
|
284
|
+
|
|
285
|
+
token_body =
|
|
286
|
+
if options[:device] || options[:no_browser]
|
|
287
|
+
device_flow(base_url, sleeper: sleeper || ->(s) { sleep(s) })
|
|
288
|
+
else
|
|
289
|
+
auth_code_flow(base_url, tenant: options[:tenant], open_browser: open_browser)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
record = Common.persist_login(path: path, profile: profile, base_url: base_url,
|
|
293
|
+
token_body: token_body, fallback_tenant: options[:tenant])
|
|
294
|
+
tenant = CredentialsFile.presence(record["tenant"]) || "(tenant not reported)"
|
|
295
|
+
puts "\nLogged in to #{tenant} (#{base_url})"
|
|
296
|
+
scope = CredentialsFile.presence(record["scope"])
|
|
297
|
+
puts "Scopes: #{scope}" if scope
|
|
298
|
+
puts "Credentials written to #{path} (profile '#{profile}')"
|
|
299
|
+
unless CredentialsFile.presence(record["refresh_token"])
|
|
300
|
+
puts "Warning: no refresh token was issued — access will expire without renewal."
|
|
301
|
+
end
|
|
302
|
+
0
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module CLI
|
|
3
|
+
# `knoxcall logout` — best-effort revoke, then remove the stored profile.
|
|
4
|
+
module Logout
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(options)
|
|
8
|
+
path = CredentialsFile.resolve_path
|
|
9
|
+
profile = CredentialsFile.resolve_profile(options[:profile])
|
|
10
|
+
record = CredentialsFile.read_profile(path, profile)
|
|
11
|
+
if record.nil?
|
|
12
|
+
puts "No stored credentials for profile '#{profile}' — nothing to do."
|
|
13
|
+
return 0
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
refresh_token = CredentialsFile.presence(record["refresh_token"])
|
|
17
|
+
base_url = record["base_url"].to_s.chomp("/")
|
|
18
|
+
if refresh_token && !base_url.empty?
|
|
19
|
+
begin
|
|
20
|
+
Common.post_form(
|
|
21
|
+
"#{base_url}/oauth/revoke",
|
|
22
|
+
{
|
|
23
|
+
"token" => refresh_token,
|
|
24
|
+
"token_type_hint" => "refresh_token",
|
|
25
|
+
"client_id" => CredentialsFile.presence(record["client_id"]) || CLI_CLIENT_ID
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
rescue Error
|
|
29
|
+
# best-effort: removal proceeds even when revocation is unreachable
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
CredentialsFile::Lock.new(path).with_lock do
|
|
34
|
+
CredentialsFile.remove_profile(path, profile)
|
|
35
|
+
end
|
|
36
|
+
puts "Logged out — removed profile '#{profile}' from #{path}."
|
|
37
|
+
0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module KnoxCall
|
|
2
|
+
module CLI
|
|
3
|
+
# `knoxcall whoami` — show the signed-in tenant via the SDK client.
|
|
4
|
+
module Whoami
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(options)
|
|
8
|
+
path = CredentialsFile.resolve_path
|
|
9
|
+
profile = CredentialsFile.resolve_profile(options[:profile])
|
|
10
|
+
if CredentialsFile.read_profile(path, profile).nil?
|
|
11
|
+
raise Error, "not logged in (profile '#{profile}') — run `knoxcall login`"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
client = Client.new(bootstrap: StoredCredentials.new(path: path, profile: profile))
|
|
15
|
+
account = client.account.get || {}
|
|
16
|
+
|
|
17
|
+
slug = account["slug"].to_s
|
|
18
|
+
name = (account["name"] || account["company_name"]).to_s
|
|
19
|
+
plan = (account["plan"] || account["subscription_plan"]).to_s
|
|
20
|
+
tenant = name.empty? ? slug : name
|
|
21
|
+
puts "Tenant: #{tenant.empty? ? '(unknown)' : tenant}"
|
|
22
|
+
puts "Slug: #{slug}" unless slug.empty?
|
|
23
|
+
puts "Plan: #{plan}" unless plan.empty?
|
|
24
|
+
puts "Profile: #{profile} (#{path})"
|
|
25
|
+
0
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|