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,442 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "rbconfig"
|
|
9
|
+
|
|
10
|
+
module KnoxCall
|
|
11
|
+
# Shared credentials file (+~/.knoxcall/credentials.json+) — read, write,
|
|
12
|
+
# lock, refresh.
|
|
13
|
+
#
|
|
14
|
+
# The file is written by `knoxcall login` and consumed by every SDK through
|
|
15
|
+
# the StoredCredentials bootstrap. Format, lock protocol, and refresh rules
|
|
16
|
+
# are cross-SDK identical (PARITY §2). The server's refresh tokens are
|
|
17
|
+
# SINGLE-USE with family revocation on reuse, so any refresh MUST:
|
|
18
|
+
#
|
|
19
|
+
# 1. hold the sibling +credentials.json.lock+ file (exclusive-create, 100ms
|
|
20
|
+
# retry up to 10s; a lock older than the stale window is broken by atomic
|
|
21
|
+
# rename and retried once — ownership-aware so a peer's live lock is never
|
|
22
|
+
# deleted, and the window stays above the bounded refresh timeout),
|
|
23
|
+
# 2. RE-READ the file after acquiring the lock (another process may have
|
|
24
|
+
# already refreshed), and
|
|
25
|
+
# 3. atomically (temp file + rename) write back the rotated refresh token
|
|
26
|
+
# before releasing the lock.
|
|
27
|
+
module CredentialsFile
|
|
28
|
+
DEFAULT_PROFILE = "default"
|
|
29
|
+
|
|
30
|
+
# A stored access token is "fresh" while it has more than this much
|
|
31
|
+
# validity left; below the threshold the provider refreshes under the
|
|
32
|
+
# file lock.
|
|
33
|
+
FRESH_WINDOW_SECONDS = 60.0
|
|
34
|
+
|
|
35
|
+
# The locked refresh HTTP call is bounded so the lock is provably released
|
|
36
|
+
# well within the lock's stale window (Lock#stale_after) — otherwise a slow
|
|
37
|
+
# token endpoint could hold the lock long enough for a peer to break it and
|
|
38
|
+
# double-refresh the single-use token. Stays below STALE_AFTER_SECONDS.
|
|
39
|
+
REFRESH_TIMEOUT_SECONDS = 30.0
|
|
40
|
+
|
|
41
|
+
RELOGIN_MESSAGE =
|
|
42
|
+
"stored CLI credentials are no longer valid — run `knoxcall login` again"
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# -- Path / profile resolution ------------------------------------------------
|
|
47
|
+
|
|
48
|
+
# Credentials file path: explicit override > KNOXCALL_CREDENTIALS_FILE > default.
|
|
49
|
+
def resolve_path(override = nil)
|
|
50
|
+
return override.to_s if override && !override.to_s.empty?
|
|
51
|
+
env = ENV["KNOXCALL_CREDENTIALS_FILE"]
|
|
52
|
+
return env if env && !env.empty?
|
|
53
|
+
File.join(Dir.home, ".knoxcall", "credentials.json")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Profile name: explicit override > KNOXCALL_PROFILE > "default".
|
|
57
|
+
def resolve_profile(override = nil)
|
|
58
|
+
value = override || ENV["KNOXCALL_PROFILE"]
|
|
59
|
+
value && !value.to_s.empty? ? value.to_s : DEFAULT_PROFILE
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# -- File primitives (atomic writes, tolerant reads) ---------------------------
|
|
63
|
+
|
|
64
|
+
# Parse the whole file; nil on missing/malformed/unexpected shape.
|
|
65
|
+
def read_document(path)
|
|
66
|
+
warn_if_loose_permissions(path)
|
|
67
|
+
doc = JSON.parse(File.read(path, encoding: "utf-8"))
|
|
68
|
+
return nil unless doc.is_a?(Hash) && doc["profiles"].is_a?(Hash)
|
|
69
|
+
doc
|
|
70
|
+
rescue SystemCallError, IOError, JSON::ParserError, EncodingError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Warn (once) if the credentials file — which holds a single-use refresh
|
|
75
|
+
# token — is readable by group/other. POSIX only: on Windows the mode bits
|
|
76
|
+
# are advisory and confidentiality rests on the %USERPROFILE% ACL, so the
|
|
77
|
+
# check is skipped. Never raises; a missing/unreadable file is a no-op (the
|
|
78
|
+
# read below handles absence).
|
|
79
|
+
def warn_if_loose_permissions(path)
|
|
80
|
+
return if windows_platform?
|
|
81
|
+
mode = begin
|
|
82
|
+
File.stat(path).mode
|
|
83
|
+
rescue SystemCallError
|
|
84
|
+
return # missing/unreadable — nothing to warn about
|
|
85
|
+
end
|
|
86
|
+
return if (mode & 0o077).zero?
|
|
87
|
+
Warnings.warn_once(
|
|
88
|
+
"KNOXCALL_CREDENTIALS_FILE_PERMS",
|
|
89
|
+
"KnoxCall credentials file #{path} is accessible to group/other " \
|
|
90
|
+
"(mode #{format('%03o', mode & 0o777)}) and holds a refresh token. " \
|
|
91
|
+
"Restrict it: chmod 600 #{path}"
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def windows_platform?
|
|
96
|
+
Gem.win_platform? ||
|
|
97
|
+
(RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/i ? true : false)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One profile's record, or nil (missing file, malformed JSON, unknown profile).
|
|
101
|
+
def read_profile(path, profile)
|
|
102
|
+
doc = read_document(path)
|
|
103
|
+
return nil unless doc
|
|
104
|
+
record = doc["profiles"][profile]
|
|
105
|
+
record.is_a?(Hash) ? record.dup : nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Atomic write: temp file in the same directory → fsync → rename over the
|
|
109
|
+
# target. The directory is created 0700 and the file written 0600
|
|
110
|
+
# (best-effort — the mode bits are advisory on Windows).
|
|
111
|
+
def write_document(path, doc)
|
|
112
|
+
directory = File.dirname(path)
|
|
113
|
+
FileUtils.mkdir_p(directory, mode: 0o700)
|
|
114
|
+
tmp_path = File.join(
|
|
115
|
+
directory, ".credentials-#{Process.pid}-#{format('%08x', rand(2**32))}.tmp"
|
|
116
|
+
)
|
|
117
|
+
begin
|
|
118
|
+
File.open(tmp_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f|
|
|
119
|
+
f.write(JSON.pretty_generate(doc) + "\n")
|
|
120
|
+
f.flush
|
|
121
|
+
f.fsync
|
|
122
|
+
end
|
|
123
|
+
begin
|
|
124
|
+
File.chmod(0o600, tmp_path)
|
|
125
|
+
rescue SystemCallError
|
|
126
|
+
# best-effort on platforms without POSIX modes
|
|
127
|
+
end
|
|
128
|
+
File.rename(tmp_path, path)
|
|
129
|
+
rescue Exception
|
|
130
|
+
begin
|
|
131
|
+
File.unlink(tmp_path)
|
|
132
|
+
rescue SystemCallError
|
|
133
|
+
# already gone / never created
|
|
134
|
+
end
|
|
135
|
+
raise
|
|
136
|
+
end
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Merge one profile into the file (other profiles untouched), atomically.
|
|
141
|
+
def write_profile(path, profile, record)
|
|
142
|
+
doc = read_document(path) || { "version" => 1, "profiles" => {} }
|
|
143
|
+
doc["version"] ||= 1
|
|
144
|
+
doc["profiles"][profile.to_s] = record.reject { |_k, v| v.nil? }
|
|
145
|
+
write_document(path, doc)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Remove one profile; delete the file when it was the last one.
|
|
149
|
+
def remove_profile(path, profile)
|
|
150
|
+
doc = read_document(path)
|
|
151
|
+
return false unless doc && doc["profiles"].key?(profile)
|
|
152
|
+
doc["profiles"].delete(profile)
|
|
153
|
+
if doc["profiles"].empty?
|
|
154
|
+
begin
|
|
155
|
+
File.unlink(path)
|
|
156
|
+
rescue SystemCallError
|
|
157
|
+
# already gone
|
|
158
|
+
end
|
|
159
|
+
else
|
|
160
|
+
write_document(path, doc)
|
|
161
|
+
end
|
|
162
|
+
true
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Auto-detect presence check: file exists AND the selected profile parses.
|
|
166
|
+
def profile_available?(path, profile)
|
|
167
|
+
!read_profile(path, profile).nil?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Chain-slot check for zero-arg construction: anything missing/malformed
|
|
171
|
+
# (including an unresolvable home directory) skips the provider silently.
|
|
172
|
+
def available?
|
|
173
|
+
profile_available?(resolve_path, resolve_profile)
|
|
174
|
+
rescue StandardError
|
|
175
|
+
false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# -- Expiry formatting ----------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
def format_expiry(time)
|
|
181
|
+
time.getutc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def parse_expiry(value)
|
|
185
|
+
return nil unless value.is_a?(String) && !value.empty?
|
|
186
|
+
Time.iso8601(value)
|
|
187
|
+
rescue ArgumentError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# -- Cross-process lock ----------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
# Sibling +.lock+ file held by exclusive-create (O_CREAT|O_EXCL).
|
|
194
|
+
#
|
|
195
|
+
# Protocol (identical in every SDK): retry every 100ms up to 10s; a lock
|
|
196
|
+
# file older than the stale window is broken and retried once.
|
|
197
|
+
#
|
|
198
|
+
# Ownership-aware break/release: the lock file carries a unique owner tag
|
|
199
|
+
# (+pid time nonce+) written at acquire. A stale lock is broken by ATOMIC
|
|
200
|
+
# RENAME (only one racer wins the rename, so a competitor's freshly-created
|
|
201
|
+
# lock can never be deleted by path), and release only unlinks a lock whose
|
|
202
|
+
# on-disk content still matches what this instance wrote. This closes the
|
|
203
|
+
# double-acquire -> double-refresh race that would replay the single-use
|
|
204
|
+
# refresh token and trip server-side family revocation. The stale window is
|
|
205
|
+
# also kept safely above the bounded refresh HTTP timeout
|
|
206
|
+
# (REFRESH_TIMEOUT_SECONDS) so a live-but-slow refresh is never mistaken for
|
|
207
|
+
# a dead holder.
|
|
208
|
+
class Lock
|
|
209
|
+
# Must exceed the bounded refresh HTTP timeout (REFRESH_TIMEOUT_SECONDS)
|
|
210
|
+
# so a legitimately in-flight refresh is never broken as "stale".
|
|
211
|
+
STALE_AFTER_SECONDS = 60.0
|
|
212
|
+
|
|
213
|
+
attr_reader :lock_path
|
|
214
|
+
|
|
215
|
+
def initialize(target, timeout: 10.0, retry_interval: 0.1, stale_after: STALE_AFTER_SECONDS)
|
|
216
|
+
@lock_path = "#{target}.lock"
|
|
217
|
+
@timeout = timeout
|
|
218
|
+
@retry_interval = retry_interval
|
|
219
|
+
@stale_after = stale_after
|
|
220
|
+
@held = false
|
|
221
|
+
@own_content = nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def acquire
|
|
225
|
+
deadline = monotonic_now + @timeout
|
|
226
|
+
stale_broken = false
|
|
227
|
+
loop do
|
|
228
|
+
return if try_acquire
|
|
229
|
+
if !stale_broken && break_stale
|
|
230
|
+
stale_broken = true
|
|
231
|
+
return if try_acquire
|
|
232
|
+
end
|
|
233
|
+
if monotonic_now >= deadline
|
|
234
|
+
raise Error, "timed out waiting for the credentials file lock (#{@lock_path})"
|
|
235
|
+
end
|
|
236
|
+
sleep @retry_interval
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def release
|
|
241
|
+
return unless @held
|
|
242
|
+
@held = false
|
|
243
|
+
own = @own_content
|
|
244
|
+
@own_content = nil
|
|
245
|
+
begin
|
|
246
|
+
# Only remove the lock if it is still OURS — if our lock was broken as
|
|
247
|
+
# stale and re-taken by another process while we were suspended,
|
|
248
|
+
# unlink by path would delete their live lock.
|
|
249
|
+
if own && File.read(@lock_path) == own
|
|
250
|
+
File.unlink(@lock_path)
|
|
251
|
+
end
|
|
252
|
+
rescue SystemCallError
|
|
253
|
+
# already gone or unreadable
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def with_lock
|
|
258
|
+
acquire
|
|
259
|
+
begin
|
|
260
|
+
yield
|
|
261
|
+
ensure
|
|
262
|
+
release
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private
|
|
267
|
+
|
|
268
|
+
def monotonic_now
|
|
269
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def try_acquire
|
|
273
|
+
# Unique owner tag (pid time nonce) so break/release can prove ownership.
|
|
274
|
+
content = "#{Process.pid} #{Time.now.to_f.round(3)} #{SecureRandom.hex(8)}\n"
|
|
275
|
+
File.open(@lock_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |f|
|
|
276
|
+
f.write(content)
|
|
277
|
+
end
|
|
278
|
+
@own_content = content
|
|
279
|
+
@held = true
|
|
280
|
+
true
|
|
281
|
+
rescue SystemCallError
|
|
282
|
+
false
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Break a stale lock via ATOMIC RENAME, so a competing process's fresh
|
|
286
|
+
# lock is never removed by path. True when a retry is worthwhile now.
|
|
287
|
+
def break_stale
|
|
288
|
+
age = Time.now - File.mtime(@lock_path)
|
|
289
|
+
return false if age <= @stale_after
|
|
290
|
+
# Claim the break atomically: rename() has exactly one winner, so if a
|
|
291
|
+
# competitor already broke-and-recreated the lock, our rename fails
|
|
292
|
+
# (source gone) and we never touch their live lock.
|
|
293
|
+
graveyard = "#{@lock_path}.stale-#{Process.pid}-#{SecureRandom.hex(6)}"
|
|
294
|
+
begin
|
|
295
|
+
File.rename(@lock_path, graveyard)
|
|
296
|
+
File.unlink(graveyard)
|
|
297
|
+
rescue SystemCallError
|
|
298
|
+
# someone else already broke it (or it vanished) — just retry
|
|
299
|
+
end
|
|
300
|
+
true
|
|
301
|
+
rescue SystemCallError
|
|
302
|
+
true # lock vanished between attempts — retry immediately
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# -- Token fetch (fast path + locked refresh) -------------------------------------
|
|
307
|
+
|
|
308
|
+
# Produce a usable token hash (the Client token-cache shape) from the
|
|
309
|
+
# credentials file.
|
|
310
|
+
#
|
|
311
|
+
# Fast path: stored access token with >60s validity, no HTTP. Otherwise
|
|
312
|
+
# lock → re-read → re-check → refresh-token grant → atomic write-back.
|
|
313
|
+
# The caller's in-process token mutex (Client#token) wraps this whole
|
|
314
|
+
# method, so the file lock is only ever contended across processes.
|
|
315
|
+
def fetch_stored_token(path:, profile:, token_endpoint:, timeout: 30)
|
|
316
|
+
record = read_profile(path, profile)
|
|
317
|
+
# Detection saw the profile but it has since vanished/corrupted.
|
|
318
|
+
raise relogin_error unless record
|
|
319
|
+
cached = cached_from_record(record)
|
|
320
|
+
return cached if cached
|
|
321
|
+
|
|
322
|
+
Lock.new(path).with_lock do
|
|
323
|
+
record = read_profile(path, profile)
|
|
324
|
+
raise relogin_error unless record
|
|
325
|
+
cached = cached_from_record(record)
|
|
326
|
+
# another process refreshed while we waited
|
|
327
|
+
return cached if cached
|
|
328
|
+
refresh_and_write_back(record, path: path, profile: profile,
|
|
329
|
+
token_endpoint: token_endpoint, timeout: timeout)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def relogin_error(status = 401, headers: nil, body: nil)
|
|
334
|
+
AuthenticationError.new(RELOGIN_MESSAGE, status, headers: headers, body: body)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# The fresh-token fast path: use the stored access token while >60s valid.
|
|
338
|
+
#
|
|
339
|
+
# No :refresh_token in the returned hash on purpose — the file is the sole
|
|
340
|
+
# refresh authority, so no in-process fallback can ever replay a consumed
|
|
341
|
+
# (rotated) refresh token. No :lifetime either: the Client's refresh-ahead
|
|
342
|
+
# window then falls back to its 300s default, and re-entering this method
|
|
343
|
+
# inside that window just re-reads the file (still no HTTP until <60s).
|
|
344
|
+
def cached_from_record(record)
|
|
345
|
+
token = record["access_token"]
|
|
346
|
+
expires_at = parse_expiry(record["access_token_expires_at"])
|
|
347
|
+
return nil unless token.is_a?(String) && !token.empty? && expires_at
|
|
348
|
+
return nil if expires_at - Time.now <= FRESH_WINDOW_SECONDS
|
|
349
|
+
{
|
|
350
|
+
access_token: token,
|
|
351
|
+
token_type: "Bearer",
|
|
352
|
+
expires_at: expires_at,
|
|
353
|
+
lifetime: nil,
|
|
354
|
+
tenant: presence(record["tenant"])
|
|
355
|
+
}
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def refresh_and_write_back(record, path:, profile:, token_endpoint:, timeout:)
|
|
359
|
+
refresh_token = presence(record["refresh_token"])
|
|
360
|
+
client_id = presence(record["client_id"])
|
|
361
|
+
raise relogin_error unless refresh_token && client_id
|
|
362
|
+
|
|
363
|
+
uri = URI.parse(token_endpoint)
|
|
364
|
+
req = Net::HTTP::Post.new(uri)
|
|
365
|
+
req["Content-Type"] = "application/x-www-form-urlencoded"
|
|
366
|
+
req["Accept"] = "application/json"
|
|
367
|
+
req["User-Agent"] = SDK_VERSION
|
|
368
|
+
req.body = URI.encode_www_form(
|
|
369
|
+
grant_type: "refresh_token",
|
|
370
|
+
refresh_token: refresh_token,
|
|
371
|
+
client_id: client_id # the tenant's real CLI client (public, no secret)
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Bound the refresh call so the lock is provably released within the
|
|
375
|
+
# stale window: never wait longer than REFRESH_TIMEOUT_SECONDS even if the
|
|
376
|
+
# caller configured a larger client timeout (a smaller one still wins).
|
|
377
|
+
refresh_timeout = [timeout, REFRESH_TIMEOUT_SECONDS].min
|
|
378
|
+
resp = begin
|
|
379
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
380
|
+
http.use_ssl = uri.scheme == "https"
|
|
381
|
+
http.open_timeout = refresh_timeout
|
|
382
|
+
http.read_timeout = refresh_timeout
|
|
383
|
+
http.start { |h| h.request(req) }
|
|
384
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
385
|
+
raise ConnectionTimeoutError, "token refresh timed out: #{e.message}"
|
|
386
|
+
rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e
|
|
387
|
+
raise NetworkError, "token refresh failed: #{e.class}: #{e.message}"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
data = begin
|
|
391
|
+
JSON.parse(resp.body.to_s)
|
|
392
|
+
rescue JSON::ParserError
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
if resp.code.to_i >= 400
|
|
397
|
+
if data.is_a?(Hash) && data["error"] == "invalid_grant"
|
|
398
|
+
# Revoked family or expired refresh token — unrecoverable here.
|
|
399
|
+
headers = {}
|
|
400
|
+
resp.each_header { |k, v| headers[k.downcase] = v }
|
|
401
|
+
raise relogin_error(resp.code.to_i, headers: headers, body: data)
|
|
402
|
+
end
|
|
403
|
+
raise KnoxCall.error_from_response(resp)
|
|
404
|
+
end
|
|
405
|
+
unless data.is_a?(Hash) && data["access_token"].is_a?(String) && !data["access_token"].empty?
|
|
406
|
+
# e.g. an HTML page from an edge proxy with a 200 status
|
|
407
|
+
raise TokenError, "token endpoint returned an unexpected response (status #{resp.code})"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
lifetime = begin
|
|
411
|
+
Float(data["expires_in"] || 3600)
|
|
412
|
+
rescue ArgumentError, TypeError
|
|
413
|
+
3600.0
|
|
414
|
+
end
|
|
415
|
+
now = Time.now
|
|
416
|
+
|
|
417
|
+
# Write back the rotated refresh token BEFORE releasing the lock (the
|
|
418
|
+
# caller holds it) — the old one is already consumed server-side.
|
|
419
|
+
updated = record.dup
|
|
420
|
+
updated["access_token"] = data["access_token"]
|
|
421
|
+
updated["access_token_expires_at"] = format_expiry(now + lifetime)
|
|
422
|
+
updated["refresh_token"] = data["refresh_token"] if presence(data["refresh_token"])
|
|
423
|
+
updated["scope"] = data["scope"] if presence(data["scope"])
|
|
424
|
+
# extension members (RFC 6749 §5.1): tenant slug + the real per-tenant CLI client id
|
|
425
|
+
updated["tenant"] = data["tenant"] if presence(data["tenant"])
|
|
426
|
+
updated["client_id"] = data["client_id"] if presence(data["client_id"])
|
|
427
|
+
write_profile(path, profile, updated)
|
|
428
|
+
|
|
429
|
+
{
|
|
430
|
+
access_token: data["access_token"],
|
|
431
|
+
token_type: "Bearer",
|
|
432
|
+
expires_at: now + lifetime,
|
|
433
|
+
lifetime: lifetime,
|
|
434
|
+
tenant: presence(updated["tenant"])
|
|
435
|
+
}
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def presence(value)
|
|
439
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module KnoxCall
|
|
6
|
+
# DPoP proof generation (RFC 9449) — client side. Ruby port of
|
|
7
|
+
# knoxcall-node/src/auth/dpop.ts (see ../../PARITY.md §7).
|
|
8
|
+
#
|
|
9
|
+
# Generates an ES256 (P-256) keypair and signs a fresh proof JWT per
|
|
10
|
+
# request (new jti/iat every call, htu stripped of query and fragment,
|
|
11
|
+
# ath bound to the access token when one is presented). Uses the openssl
|
|
12
|
+
# stdlib already required by the client — no new gem. The private key
|
|
13
|
+
# never leaves the process.
|
|
14
|
+
class DpopKeyPair
|
|
15
|
+
attr_reader :public_jwk
|
|
16
|
+
|
|
17
|
+
def self.generate
|
|
18
|
+
new(OpenSSL::PKey::EC.generate("prime256v1"))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(key)
|
|
22
|
+
@key = key
|
|
23
|
+
point = key.public_key.to_bn.to_s(2) # uncompressed: 0x04 || X || Y
|
|
24
|
+
unless point.bytesize == 65 && point.getbyte(0) == 4
|
|
25
|
+
raise Error, "unexpected EC public key encoding"
|
|
26
|
+
end
|
|
27
|
+
# Member order crv, kty, x, y matches the RFC 7638 canonical order, so
|
|
28
|
+
# the same hash serves both the proof header and the thumbprint input.
|
|
29
|
+
@public_jwk = {
|
|
30
|
+
"crv" => "P-256",
|
|
31
|
+
"kty" => "EC",
|
|
32
|
+
"x" => self.class.b64url(point.byteslice(1, 32)),
|
|
33
|
+
"y" => self.class.b64url(point.byteslice(33, 32))
|
|
34
|
+
}.freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Sign a DPoP proof JWT for one request (RFC 9449 §4.2). The signature is
|
|
38
|
+
# ECDSA P-256 + SHA-256 in JOSE P1363 form (r||s, 64 bytes), matching the
|
|
39
|
+
# server verifier in src/lib/dpop-verifier.ts.
|
|
40
|
+
def sign(method, url, access_token: nil, nonce: nil)
|
|
41
|
+
htu = url.split("#", 2).first.split("?", 2).first
|
|
42
|
+
header = { "alg" => "ES256", "typ" => "dpop+jwt", "jwk" => @public_jwk }
|
|
43
|
+
payload = {
|
|
44
|
+
"htm" => method.to_s.upcase,
|
|
45
|
+
"htu" => htu,
|
|
46
|
+
"iat" => Time.now.to_i,
|
|
47
|
+
"jti" => self.class.b64url(SecureRandom.random_bytes(16))
|
|
48
|
+
}
|
|
49
|
+
if access_token && !access_token.empty?
|
|
50
|
+
payload["ath"] = self.class.b64url(OpenSSL::Digest::SHA256.digest(access_token))
|
|
51
|
+
end
|
|
52
|
+
payload["nonce"] = nonce if nonce && !nonce.to_s.empty?
|
|
53
|
+
|
|
54
|
+
signing_input = "#{self.class.b64url(JSON.generate(header))}.#{self.class.b64url(JSON.generate(payload))}"
|
|
55
|
+
der = @key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
|
|
56
|
+
"#{signing_input}.#{self.class.b64url(self.class.der_to_p1363(der))}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# RFC 7638 JWK thumbprint of the public key — the value the server binds
|
|
60
|
+
# tokens to as cnf.jkt.
|
|
61
|
+
def thumbprint
|
|
62
|
+
self.class.b64url(OpenSSL::Digest::SHA256.digest(JSON.generate(@public_jwk)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Unpadded base64url via pack, not the base64 gem — base64 left the
|
|
66
|
+
# stdlib default set in Ruby 3.4, and the README promises stdlib only.
|
|
67
|
+
def self.b64url(bytes)
|
|
68
|
+
[bytes].pack("m0").tr("+/", "-_").delete("=")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Convert an ASN.1/DER ECDSA signature to JOSE P1363 (r||s, 64 bytes).
|
|
72
|
+
def self.der_to_p1363(der)
|
|
73
|
+
seq = OpenSSL::ASN1.decode(der)
|
|
74
|
+
r, s = seq.value.map { |int| int.value.to_s(2) } # BN → big-endian binary, no leading zeros
|
|
75
|
+
raise Error, "invalid DER signature" if r.bytesize > 32 || s.bytesize > 32
|
|
76
|
+
r.rjust(32, "\0") + s.rjust(32, "\0")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|