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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +201 -0
  3. data/README.md +439 -2
  4. data/exe/knoxcall +8 -0
  5. data/lib/knoxcall/bootstrap.rb +70 -0
  6. data/lib/knoxcall/bound_route.rb +47 -0
  7. data/lib/knoxcall/cli/ai.rb +79 -0
  8. data/lib/knoxcall/cli/ai_control.rb +275 -0
  9. data/lib/knoxcall/cli/common.rb +96 -0
  10. data/lib/knoxcall/cli/init.rb +94 -0
  11. data/lib/knoxcall/cli/login.rb +306 -0
  12. data/lib/knoxcall/cli/logout.rb +41 -0
  13. data/lib/knoxcall/cli/whoami.rb +29 -0
  14. data/lib/knoxcall/cli.rb +377 -0
  15. data/lib/knoxcall/client.rb +1025 -0
  16. data/lib/knoxcall/credentials_file.rb +442 -0
  17. data/lib/knoxcall/dpop.rb +79 -0
  18. data/lib/knoxcall/egress_observations.rb +372 -0
  19. data/lib/knoxcall/errors.rb +304 -0
  20. data/lib/knoxcall/intercept_patch.rb +181 -0
  21. data/lib/knoxcall/intercept_pipeline.rb +455 -0
  22. data/lib/knoxcall/intercept_resolver.rb +140 -0
  23. data/lib/knoxcall/intercept_store.rb +203 -0
  24. data/lib/knoxcall/login.rb +144 -0
  25. data/lib/knoxcall/resources/account.rb +12 -0
  26. data/lib/knoxcall/resources/agents.rb +25 -0
  27. data/lib/knoxcall/resources/ai_gateway.rb +417 -0
  28. data/lib/knoxcall/resources/api_keys.rb +35 -0
  29. data/lib/knoxcall/resources/audit_logs.rb +45 -0
  30. data/lib/knoxcall/resources/clients.rb +39 -0
  31. data/lib/knoxcall/resources/crypto.rb +122 -0
  32. data/lib/knoxcall/resources/dynamic_db.rb +68 -0
  33. data/lib/knoxcall/resources/environments.rb +16 -0
  34. data/lib/knoxcall/resources/logs.rb +51 -0
  35. data/lib/knoxcall/resources/oauth_clients.rb +34 -0
  36. data/lib/knoxcall/resources/opportunities.rb +61 -0
  37. data/lib/knoxcall/resources/pki.rb +41 -0
  38. data/lib/knoxcall/resources/roles.rb +27 -0
  39. data/lib/knoxcall/resources/routes.rb +53 -0
  40. data/lib/knoxcall/resources/secrets.rb +98 -0
  41. data/lib/knoxcall/resources/unwraps_envelope.rb +90 -0
  42. data/lib/knoxcall/resources/vaults.rb +77 -0
  43. data/lib/knoxcall/resources/webhooks.rb +48 -0
  44. data/lib/knoxcall/resources/workflows.rb +86 -0
  45. data/lib/knoxcall/resources/wrap.rb +352 -0
  46. data/lib/knoxcall/route_refusal.rb +67 -0
  47. data/lib/knoxcall/signup.rb +122 -0
  48. data/lib/knoxcall/token_exchange.rb +169 -0
  49. data/lib/knoxcall/ulid.rb +19 -0
  50. data/lib/knoxcall/warnings.rb +63 -0
  51. data/lib/knoxcall/workload_provider.rb +192 -0
  52. data/lib/knoxcall/wrap_faraday_adapter.rb +119 -0
  53. data/lib/knoxcall/wrap_faraday_middleware.rb +67 -0
  54. data/lib/knoxcall/wrap_transport.rb +139 -0
  55. data/lib/knoxcall.rb +45 -1
  56. 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