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,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module KnoxCall
6
+ # The route-mode REFUSAL predicate (PARITY §21.1, "Refusal-driven refresh";
7
+ # the cross-language contract is sdk/fixtures/route-refusal.json).
8
+ #
9
+ # A KnoxCall-origin refusal on the route data plane is the one response the
10
+ # pipeline answers by refreshing its manifest ONCE and re-deciding ONCE:
11
+ #
12
+ # - a 401 with no upstream stamp — the credential was refused, or the caller
13
+ # is not authenticated for the route it named. +call+ has already spent its
14
+ # one re-mint by the time we see this.
15
+ # - a 404 whose envelope +error.type+ is +route_not_found+ — since the
16
+ # founder's 2026-09-26 decision an AUTHENTICATED key gets a real 404 for a
17
+ # route that does not resolve, and a stale manifest naming a Route deleted
18
+ # since the poll is exactly this. The +environment_*+ types are refused
19
+ # as-is: a refresh cannot fix an environment.
20
+ #
21
+ # Any response carrying +X-Knox-Upstream-Status+ (the route data plane's
22
+ # response block) or +X-Knox-Destination-Status+ (the ephemeral proxy's older
23
+ # spelling) is the UPSTREAM's answer, whatever its status or body, and never a
24
+ # refusal.
25
+ module RouteRefusal
26
+ module_function
27
+
28
+ # @param status [Integer]
29
+ # @param headers [Hash] raw response headers, any casing; values may be
30
+ # strings or arrays (Net::HTTPResponse#to_hash yields arrays)
31
+ # @param body [String, nil]
32
+ def refusal?(status:, headers:, body:)
33
+ return false if header(headers, "X-Knox-Upstream-Status") || header(headers, "X-Knox-Destination-Status")
34
+ return true if status == 401
35
+ return false unless status == 404
36
+
37
+ envelope_type(body) == "route_not_found"
38
+ end
39
+
40
+ # The envelope's +error.type+ on a 404, or nil for anything that is not the
41
+ # Shape-A envelope +{"error":{"type","message","request_id"}}+.
42
+ def envelope_type(body)
43
+ return nil if body.nil? || body.empty?
44
+
45
+ parsed = JSON.parse(body)
46
+ return nil unless parsed.is_a?(Hash)
47
+
48
+ error = parsed["error"]
49
+ return nil unless error.is_a?(Hash)
50
+
51
+ type = error["type"]
52
+ type.is_a?(String) ? type : nil
53
+ rescue JSON::ParserError
54
+ nil
55
+ end
56
+
57
+ def header(headers, name)
58
+ pair = headers.find { |k, _| k.to_s.casecmp(name).zero? }
59
+ return nil unless pair
60
+
61
+ v = pair.last
62
+ v = v.first if v.is_a?(Array)
63
+ v = v.to_s
64
+ v.empty? ? nil : v
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,122 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module KnoxCall
7
+ # Headless signup — the one /v1 surface that needs no credentials, so these
8
+ # are module-level functions rather than resources on a constructed client.
9
+ #
10
+ # TWO steps since 2026-08-28 (founder decision F-25). +signup+ never returns
11
+ # a credential: it returns a claim handle and emails a sign-in link, and the
12
+ # starter key is minted when that link has been clicked and the claim is
13
+ # collected:
14
+ #
15
+ # accepted = KnoxCall.signup({ email: "dev@example.com", tenant_name: "Acme Inc" })
16
+ # handle = accepted["claim_handle"]
17
+ #
18
+ # # …the account owner clicks the emailed sign-in link…
19
+ # claim = KnoxCall.claim_signup(handle)
20
+ # while claim["status"] == "pending"
21
+ # sleep accepted["poll_after_seconds"]
22
+ # claim = KnoxCall.claim_signup(handle)
23
+ # end
24
+ #
25
+ # # claim["starter"]["api_key"]["api_key"] is shown exactly once — store it now.
26
+ # client = KnoxCall::Client.new(api_key: claim["starter"]["api_key"]["api_key"], sandbox: true)
27
+
28
+ # The shared credential-less POST. Note what it does NOT treat as an error:
29
+ # a 202. Both endpoints use it for a normal, credential-less success, so any
30
+ # sub-400 response carrying a +data+ object is returned unchanged.
31
+ #
32
+ # @api private
33
+ def self._signup_post(path, payload, what, base_url, timeout)
34
+ uri = URI.parse((base_url || DEFAULT_API_BASE).chomp("/") + path)
35
+ req = Net::HTTP::Post.new(uri)
36
+ req["Content-Type"] = "application/json"
37
+ req["Accept"] = "application/json"
38
+ req["User-Agent"] = SDK_VERSION
39
+ req.body = JSON.generate(payload)
40
+
41
+ resp = begin
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.use_ssl = uri.scheme == "https"
44
+ http.open_timeout = timeout
45
+ http.read_timeout = timeout
46
+ http.start { |h| h.request(req) }
47
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
48
+ raise ConnectionTimeoutError, "#{what} request timed out: #{e.message}"
49
+ rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e
50
+ raise NetworkError, "#{what} request failed: #{e.class}: #{e.message}"
51
+ end
52
+
53
+ parsed = begin
54
+ JSON.parse(resp.body.to_s)
55
+ rescue JSON::ParserError
56
+ nil
57
+ end
58
+ status = resp.code.to_i
59
+ data = parsed.is_a?(Hash) && parsed["data"].is_a?(Hash) ? parsed["data"] : nil
60
+ if status >= 400 || data.nil?
61
+ err = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : {}
62
+ message = err["message"].is_a?(String) ? err["message"] : "#{what} failed with status #{status}"
63
+ request_id = err["request_id"] || resp["x-request-id"]
64
+ raise SignupError.new(
65
+ message,
66
+ status_code: status,
67
+ error_type: err["type"].is_a?(String) ? err["type"] : nil,
68
+ request_id: request_id.is_a?(String) ? request_id : nil,
69
+ body: parsed
70
+ )
71
+ end
72
+ data
73
+ end
74
+
75
+ # Start creating a KnoxCall account.
76
+ #
77
+ # Always answers 202 with an opaque +claim_handle+ and emails a sign-in link
78
+ # — no account, tenant or credential exists until that link is clicked.
79
+ # Collect the starter kit afterwards with {claim_signup}. Rate limited to 3
80
+ # signups/hour/IP.
81
+ #
82
+ # Enumeration-safe: the reply is identical for an address that already has an
83
+ # account (it receives a sign-in link and a handle that stays "pending").
84
+ #
85
+ # +input+ fields: +email+ (required), +tenant_name+ (required), +full_name+,
86
+ # +tenant_slug+ (omit to have one derived — recommended), +country+,
87
+ # +region+ ("us"|"eu"|"au").
88
+ #
89
+ # @param input [Hash] the signup fields (symbol or string keys)
90
+ # @param base_url [String, nil] management API base (default https://api.knoxcall.com)
91
+ # @param timeout [Numeric] open/read timeout in seconds (default 30)
92
+ # @return [Hash] +status+, +claim_handle+, +claim_path+, +poll_after_seconds+,
93
+ # +expires_at+, +message+, +documentation+ — and never a credential
94
+ # @raise [SignupError] on any HTTP failure or unexpected body — carries
95
+ # +status_code+, +error_type+ (e.g. "slug_taken"), +request_id+
96
+ # @raise [NetworkError, ConnectionTimeoutError] on transport failure
97
+ def self.signup(input, base_url: nil, timeout: 30)
98
+ _signup_post("/v1/signup", input, "Signup", base_url, timeout)
99
+ end
100
+
101
+ # Poll a claim handle returned by {signup}.
102
+ #
103
+ # Returns <tt>{"status" => "pending", ...}</tt> — a 202, and a normal SUCCESS
104
+ # — until the emailed sign-in link has been clicked, then once returns
105
+ # <tt>{"status" => "ready", ...}</tt> with the tenant and a one-time
106
+ # Test-mode API key. Polling again after that raises {SignupError} (409); an
107
+ # unknown or expired handle raises it with 404.
108
+ #
109
+ # Do not poll faster than the +poll_after_seconds+ that {signup} returned.
110
+ #
111
+ # @param claim_handle [String] the handle returned by {signup}
112
+ # @param base_url [String, nil] management API base (default https://api.knoxcall.com)
113
+ # @param timeout [Numeric] open/read timeout in seconds (default 30)
114
+ # @return [Hash] +status+ plus either the pending fields or +tenant+,
115
+ # +starter+, +sandbox+, +documentation+
116
+ # @raise [SignupError] 404 unknown/expired, 409 already collected, or any
117
+ # other HTTP failure
118
+ # @raise [NetworkError, ConnectionTimeoutError] on transport failure
119
+ def self.claim_signup(claim_handle, base_url: nil, timeout: 30)
120
+ _signup_post("/v1/signup/claim", { claim_handle: claim_handle }, "Signup claim", base_url, timeout)
121
+ end
122
+ end
@@ -0,0 +1,169 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "openssl"
5
+
6
+ require "knoxcall/warnings"
7
+
8
+ module KnoxCall
9
+ # The only grant_type POST /v1/oauth/token accepts.
10
+ TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange".freeze
11
+ # The only subject_token_type it accepts.
12
+ ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token".freeze
13
+ # The default (and only supported) audience.
14
+ KNOXCALL_AUDIENCE = "knoxcall:gateway".freeze
15
+
16
+ # Exchange a CI OIDC token for a short-lived AI-gateway capability token
17
+ # (RFC 8693 token exchange, AIGW-26).
18
+ #
19
+ # res = KnoxCall.exchange_token(subject_token: ci_id_token, tenant: "acme")
20
+ # # res["access_token"] is an agent-kind token for POST /v1/ai/...
21
+ #
22
+ # Like KnoxCall.signup this is a module-level function and NOT a method on a
23
+ # constructed client, and for a stronger reason: the whole point is that CI
24
+ # holds no KnoxCall credential. Constructing a client to reach this endpoint
25
+ # would require the very secret the flow exists to remove — so NO
26
+ # Authorization header is sent. The subject_token IS the credential, verified
27
+ # against the issuer's published JWKS.
28
+ #
29
+ # Pass +resource+ — the +resource+ field of an MCP server's create/get
30
+ # response — to narrow the minted token to +tool+ kind, confined to exactly
31
+ # that one <tt>/v1/mcp/<slug></tt> and refused on <tt>/v1/ai</tt>. Leave it
32
+ # +nil+ (the default) for an +agent+-kind token: an EMPTY STRING is sent
33
+ # through and refused +invalid_target+, because dropping it silently would
34
+ # mint an UNCONFINED token while the caller believes it is
35
+ # audience-restricted.
36
+ #
37
+ # THE HOST MATTERS, and getting it wrong looks like a credential failure.
38
+ # <tt>/v1/oauth/token</tt> is part of the DATA plane: +src/server.ts+ hands
39
+ # <tt>/v1/ai/</tt>, <tt>/v1/mcp/</tt> and <tt>/v1/oauth/</tt> to the proxy
40
+ # router only when the request lands on a tenant data-plane host
41
+ # (<tt>{slug}.knoxcall.com</tt>, <tt>sandbox-{slug}...</tt>). Verified against a
42
+ # running server on 2026-08-25: the same request answers 400 +invalid_grant+ on
43
+ # +acme.knoxcall.com+ and *401* on +api.knoxcall.com+ - a caller who points this
44
+ # at the management host reads that 401 as "my CI token was rejected" when the
45
+ # endpoint is simply not served there. So +tenant+ (or an explicit +base_url+)
46
+ # is REQUIRED: there is no safe default to guess.
47
+ #
48
+ # NOT the tenant OAuth 2.1 token endpoint at
49
+ # <tt>https://api.knoxcall.com/oauth/token</tt> (root host, no +/v1+), which
50
+ # mints +kc_+ MANAGEMENT tokens from +client_credentials+ and friends.
51
+ #
52
+ # Returns the RFC 8693 §2.2.1 body — +access_token+, +issued_token_type+,
53
+ # +token_type+, +expires_in+, +scope+. A BARE OAuth body, not the
54
+ # <tt>{data, meta}</tt> envelope the rest of /v1 returns.
55
+ #
56
+ # @param subject_token [String] the workload's OIDC id_token, JWS-compact
57
+ # @param resource [String, nil] RFC 8707 resource indicator; nil to omit
58
+ # @param audience [String] defaults to KNOXCALL_AUDIENCE
59
+ # @param tenant [String, nil] tenant slug; becomes https://{tenant}.knoxcall.com
60
+ # @param sandbox [Boolean] use https://sandbox-{tenant}.knoxcall.com
61
+ # @param base_url [String, nil] full data-plane origin; wins over +tenant+
62
+ # @param timeout [Numeric] open/read timeout in seconds (default 30)
63
+ # @return [Hash] the RFC 8693 response body
64
+ # @raise [TokenExchangeError] on any refusal — carries +status_code+ and
65
+ # +error_type+ (the RFC 6749 §5.2 code)
66
+ # @raise [ArgumentError] when neither +tenant+ nor +base_url+ is given, or the
67
+ # tenant slug is not a DNS label
68
+ # @raise [NetworkError, ConnectionTimeoutError] on transport failure
69
+ def self.exchange_token(subject_token:, resource: nil, audience: KNOXCALL_AUDIENCE,
70
+ tenant: nil, sandbox: false, base_url: nil, timeout: 30)
71
+ origin = exchange_base_url(tenant, sandbox, base_url)
72
+ # the request carries the workload OIDC id_token, which IS a credential -- the
73
+ # whole point of the flow. PARITY 15 already warns when a CLIENT is constructed
74
+ # against plaintext http to a non-loopback host, and this function deliberately
75
+ # constructs no client, so without this the control exists on one path and is
76
+ # simply absent on the parallel one. A warning rather than a refusal because the
77
+ # acceptance harness and local dev legitimately use http://127.0.0.1.
78
+ if Warnings.insecure_remote_url?(origin)
79
+ Warnings.warn_once(
80
+ "KNOXCALL_INSECURE_TRANSPORT",
81
+ "KnoxCall: exchanging a workload OIDC token over plaintext HTTP to #{origin} - the " \
82
+ "subject token is a credential and is readable on the wire. Use https://."
83
+ )
84
+ end
85
+ uri = URI.parse(origin + "/v1/oauth/token")
86
+ payload = {
87
+ "grant_type" => TOKEN_EXCHANGE_GRANT,
88
+ "subject_token" => subject_token,
89
+ "subject_token_type" => ID_TOKEN_TYPE,
90
+ "audience" => audience
91
+ }
92
+ payload["resource"] = resource unless resource.nil?
93
+
94
+ req = Net::HTTP::Post.new(uri)
95
+ req["Content-Type"] = "application/json"
96
+ req["Accept"] = "application/json"
97
+ req["User-Agent"] = SDK_VERSION
98
+ req.body = JSON.generate(payload)
99
+
100
+ resp = begin
101
+ http = Net::HTTP.new(uri.host, uri.port)
102
+ http.use_ssl = uri.scheme == "https"
103
+ http.open_timeout = timeout
104
+ http.read_timeout = timeout
105
+ http.start { |h| h.request(req) }
106
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
107
+ raise ConnectionTimeoutError, "token exchange timed out: #{e.message}"
108
+ rescue OpenSSL::SSL::SSLError, EOFError, SocketError, SystemCallError, IOError => e
109
+ raise NetworkError, "token exchange failed: #{e.class}: #{e.message}"
110
+ end
111
+
112
+ # A non-JSON error page (a proxy 502) parses to nil and falls through to
113
+ # the status check rather than masking the status.
114
+ parsed = begin
115
+ JSON.parse(resp.body.to_s)
116
+ rescue JSON::ParserError
117
+ nil
118
+ end
119
+ status = resp.code.to_i
120
+ token = parsed.is_a?(Hash) && parsed["access_token"].is_a?(String) ? parsed["access_token"] : nil
121
+
122
+ if status >= 400 || token.nil? || token.empty?
123
+ # AIGW-163: this endpoint is on the TENANT DATA PLANE, so when the AI
124
+ # gateway has failed to boot it is answered by the plane's 503 sentinel —
125
+ # the data-plane envelope with code "ai_gateway_unavailable" and a
126
+ # Retry-After — not by an RFC 6749 error. Typing it means a CI job is told
127
+ # to wait rather than handed a generic exchange failure. The discriminator
128
+ # is exact: an RFC 6749 body carries no `code` at all.
129
+ ai_err = KnoxCall.ai_gateway_error_from(status, parsed, resp)
130
+ raise ai_err if ai_err
131
+
132
+ code = parsed.is_a?(Hash) && parsed["error"].is_a?(String) ? parsed["error"] : "token_exchange_failed"
133
+ message = if parsed.is_a?(Hash) && parsed["error_description"].is_a?(String)
134
+ parsed["error_description"]
135
+ else
136
+ "Token exchange failed with status #{status}"
137
+ end
138
+ raise TokenExchangeError.new(message, status_code: status, error_type: code, body: parsed)
139
+ end
140
+ parsed
141
+ end
142
+
143
+ # A tenant slug becomes a hostname, so it must be a bare DNS label: a slug
144
+ # adopted from config or an environment variable that is not one
145
+ # ("evil.com#") would send the workload OIDC token to an attacker host.
146
+ TENANT_SLUG_RE = /\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i.freeze
147
+
148
+ # The data-plane origin for {exchange_token}. There is no default.
149
+ def self.exchange_base_url(tenant, sandbox, base_url)
150
+ return base_url.chomp("/") if base_url && !base_url.empty?
151
+
152
+ if tenant.nil? || tenant.empty?
153
+ raise ArgumentError,
154
+ "exchange_token needs a tenant slug or a base_url: POST /v1/oauth/token is " \
155
+ "served only on the tenant data-plane host " \
156
+ "(https://{tenant}.knoxcall.com). Pointing it at api.knoxcall.com answers " \
157
+ "401, which reads like a rejected subject_token but means the endpoint is " \
158
+ "not there."
159
+ end
160
+ unless TENANT_SLUG_RE.match?(tenant)
161
+ raise ArgumentError,
162
+ "invalid tenant slug #{tenant.inspect} - expected a DNS label; refusing to " \
163
+ "send a subject token to a host derived from it"
164
+ end
165
+
166
+ host = sandbox ? "sandbox-#{tenant}" : tenant
167
+ "https://#{host}.knoxcall.com"
168
+ end
169
+ end
@@ -0,0 +1,19 @@
1
+ require "securerandom"
2
+
3
+ module KnoxCall
4
+ # ULID generator (Crockford base32: 48-bit timestamp + 80 bits of
5
+ # randomness). Used for X-Idempotency-Key — generated once per logical
6
+ # mutating request and stable across retries.
7
+ module ULID
8
+ ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".freeze
9
+
10
+ def self.generate(time = Time.now)
11
+ ms = (time.to_f * 1000).to_i
12
+ out = +""
13
+ 9.downto(0) { |i| out << ENCODING[(ms >> (i * 5)) & 0x1F] }
14
+ rand = SecureRandom.random_number(2**80)
15
+ 15.downto(0) { |i| out << ENCODING[(rand >> (i * 5)) & 0x1F] }
16
+ out
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,63 @@
1
+ require "uri"
2
+
3
+ module KnoxCall
4
+ # One-time, deduplicated warnings for security-relevant misconfigurations
5
+ # (plaintext transport, world-readable credentials file). Cross-SDK parity
6
+ # with node's warn.ts (PARITY §15): each distinct code fires at most once per
7
+ # process, and the warning is NON-BLOCKING — it never raises and never
8
+ # changes behavior. Messages go to $stderr via Kernel#warn, so they honor
9
+ # `-W0` / a replaced `$stderr` and are trivial to capture in tests.
10
+ module Warnings
11
+ # Per-process dedup of already-emitted warning codes. A Mutex guards the
12
+ # check-then-set so concurrent client construction across threads can never
13
+ # double-warn (or lose a warning).
14
+ @warned = {}
15
+ @warned_mutex = Mutex.new
16
+
17
+ module_function
18
+
19
+ # Test-only: clear the once-per-process dedup so warnings can be re-asserted.
20
+ def _reset_for_tests
21
+ @warned_mutex.synchronize { @warned.clear }
22
+ end
23
+
24
+ # Emit +message+ to $stderr at most once per distinct +code+ per process.
25
+ # The Kernel#warn call happens OUTSIDE the mutex (no I/O under the lock) and
26
+ # is wrapped so a warning can never throw into the caller's path.
27
+ def warn_once(code, message)
28
+ @warned_mutex.synchronize do
29
+ return if @warned.key?(code)
30
+ @warned[code] = true
31
+ end
32
+ begin
33
+ warn(message)
34
+ rescue StandardError
35
+ # A misconfiguration warning must never break construction or a read.
36
+ end
37
+ end
38
+
39
+ # True for a URL whose scheme is plaintext http:// and whose host is NOT
40
+ # loopback. Loopback (localhost, *.localhost, 127.0.0.0/8, ::1, 0.0.0.0) is
41
+ # the normal local-dev case and must NOT warn; every other host over plain
42
+ # http:// sends credentials/tokens in the clear.
43
+ def insecure_remote_url?(url)
44
+ return false unless url.is_a?(String) && url.match?(%r{\Ahttp://}i)
45
+ host = begin
46
+ URI.parse(url).host
47
+ rescue URI::InvalidURIError
48
+ nil
49
+ end
50
+ return false if host.nil? || host.empty?
51
+ host = host.downcase
52
+ # URI#host keeps an IPv6 literal bracketed ("[::1]") — strip for the match.
53
+ host = host[1..-2] if host.start_with?("[") && host.end_with?("]")
54
+ loopback =
55
+ host == "localhost" ||
56
+ host.end_with?(".localhost") ||
57
+ host == "0.0.0.0" ||
58
+ host == "::1" ||
59
+ host.match?(/\A127\./)
60
+ !loopback
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,192 @@
1
+ require "digest"
2
+ require "knoxcall/errors"
3
+ require "knoxcall/token_exchange"
4
+ require "knoxcall/warnings"
5
+
6
+ module KnoxCall
7
+ # Workload-identity credential provider — WIF plan Phase 4.3.
8
+ #
9
+ # Mirrors +auth/workload-provider.ts+ in the Node SDK; +sdk/PARITY.md+ is the
10
+ # authoritative contract for every language.
11
+ #
12
+ # {KnoxCall.exchange_token} is one-shot: it trades one OIDC assertion for one
13
+ # capability token and hands the caller an +expires_in+ to manage. That is
14
+ # fine for a script that makes one call and exits, and wrong for anything
15
+ # long-lived — a Sidekiq worker, a long CI job, an agent process — where the
16
+ # token silently expires mid-run and the caller discovers it as a 401 they
17
+ # then have to interpret.
18
+ #
19
+ # This provider owns that lifecycle: cache the token, refresh it before it
20
+ # dies, and never hand out one that is about to expire.
21
+ #
22
+ # == The part that is not like other refresh loops
23
+ #
24
+ # A KnoxCall workload assertion is SINGLE-USE. The exchange spends the whole
25
+ # assertion — the server claims a hash of it before minting (WIF Phase 1.2),
26
+ # so presenting the same bytes twice is refused with "subject_token has
27
+ # already been exchanged". A refresh therefore cannot re-send the assertion it
28
+ # used last time; it needs a FRESH one from the platform every single time.
29
+ #
30
+ # That makes the obvious implementation — capture the assertion once, reuse it
31
+ # on refresh — not merely suboptimal but broken, and broken in a way that only
32
+ # shows up when the first refresh fires, i.e. minutes into production rather
33
+ # than in anyone's smoke test. So the provider takes a SOURCE it calls before
34
+ # every exchange, and refuses to send an assertion whose bytes it has already
35
+ # spent ({StaleAssertionError}). It fails loudly at the real cause rather than
36
+ # forwarding a doomed request and surfacing the server's replay refusal, which
37
+ # reads as "my credentials were rejected".
38
+ #
39
+ # == The two-tier schedule
40
+ #
41
+ # ADVISORY (expiry − 120s): refresh opportunistically. If it fails, the token
42
+ # in hand is still valid, so the caller is served and the failure is a
43
+ # warning, not an exception. A transient blip near a refresh boundary must not
44
+ # take down a worker that has two minutes of perfectly good credential left.
45
+ #
46
+ # MANDATORY (expiry − 30s): refresh or raise. Below this line the token may
47
+ # die in flight — between the provider handing it over and the request
48
+ # reaching the server — and a 401 from an expired capability token is exactly
49
+ # the confusing failure this provider exists to prevent.
50
+ #
51
+ # The gap between the two tiers is the whole point: it buys 90 seconds in
52
+ # which a failing token source or a flaky network is survivable rather than
53
+ # fatal.
54
+ #
55
+ # provider = KnoxCall::WorkloadCredentialProvider.new(
56
+ # assertion: -> { File.read(ENV.fetch("AWS_WEB_IDENTITY_TOKEN_FILE")) },
57
+ # tenant: "acme"
58
+ # )
59
+ # headers["Authorization"] = "Bearer #{provider.access_token}"
60
+ #
61
+ # Thread-safe: a Mutex gives single-flight semantics, which matters more here
62
+ # than in an ordinary refresh loop — each exchange spends an assertion, and a
63
+ # thundering herd would burn N of them and have N−1 refused.
64
+ class WorkloadCredentialProvider
65
+ # Refresh opportunistically below this much remaining life; failure is survivable.
66
+ ADVISORY_REFRESH_SECONDS = 120
67
+
68
+ # Refresh or raise below this much remaining life; the token may die in flight.
69
+ MANDATORY_REFRESH_SECONDS = 30
70
+
71
+ # @param assertion [#call] called before EVERY exchange; must return a FRESH
72
+ # assertion each time. On GitHub Actions a fetch of
73
+ # ACTIONS_ID_TOKEN_REQUEST_URL; on EKS a read of the projected token file.
74
+ # Returning a value captured once at startup is the failure this provider
75
+ # detects rather than tolerates.
76
+ # @param resource [String, nil] RFC 8707 resource indicator. +nil+ means
77
+ # "not asked for"; an EMPTY string is sent through and refused
78
+ # invalid_target, because dropping it silently would mint an UNCONFINED
79
+ # token while the caller believes it is confined.
80
+ # @param audience [String] defaults to KNOXCALL_AUDIENCE
81
+ # @param tenant [String, nil] tenant slug — one of tenant or base_url is
82
+ # REQUIRED: +/v1/oauth/token+ is served only on the tenant data-plane host.
83
+ # @param sandbox [Boolean] the Test data space. Carried through verbatim:
84
+ # dropping it would send a Test-mode workload's assertion to the Live
85
+ # host, where it matches no binding.
86
+ # @param base_url [String, nil] full data-plane origin; wins over tenant
87
+ # @param timeout [Integer] per-request timeout in seconds
88
+ # @param clock [#call] test seam returning epoch seconds as a Float
89
+ def initialize(assertion:, resource: nil, audience: KNOXCALL_AUDIENCE,
90
+ tenant: nil, sandbox: false, base_url: nil, timeout: 30,
91
+ clock: -> { Time.now.to_f })
92
+ unless assertion.respond_to?(:call)
93
+ raise ArgumentError, "WorkloadCredentialProvider needs an `assertion:` that responds to " \
94
+ "#call and returns the workload's CURRENT OIDC id_token. KnoxCall " \
95
+ "assertions are single-use, so it is called before every exchange."
96
+ end
97
+ # Fail at construction rather than at the first refresh, which may be
98
+ # minutes into a long-running process.
99
+ KnoxCall.exchange_base_url(tenant, sandbox, base_url)
100
+
101
+ @assertion = assertion
102
+ @resource = resource
103
+ @audience = audience
104
+ @tenant = tenant
105
+ @sandbox = sandbox
106
+ @base_url = base_url
107
+ @timeout = timeout
108
+ @clock = clock
109
+ @mutex = Mutex.new
110
+ @token = nil
111
+ @expires_at = 0.0
112
+ # SHA-256 of every assertion this provider has spent. Never the assertion.
113
+ @spent = {}
114
+ end
115
+
116
+ # A capability token with more than MANDATORY_REFRESH_SECONDS of life left.
117
+ #
118
+ # @return [String] the capability token
119
+ # @raise [StaleAssertionError] when the source returns spent or empty bytes
120
+ # @raise [TokenExchangeError] on a refusal inside the mandatory window
121
+ # @raise [NetworkError] on a transport failure inside the mandatory window
122
+ def access_token
123
+ @mutex.synchronize do
124
+ remaining = @token ? @expires_at - @clock.call : -1.0
125
+
126
+ return @token if @token && remaining > ADVISORY_REFRESH_SECONDS
127
+
128
+ if @token && remaining > MANDATORY_REFRESH_SECONDS
129
+ # ADVISORY tier: try, but the token in hand is still good.
130
+ begin
131
+ return refresh
132
+ rescue StandardError => e
133
+ # best-effort: the caller still has a valid credential, and raising
134
+ # here would convert a survivable blip into an outage. The MANDATORY
135
+ # tier raises for real if the condition persists.
136
+ Warnings.warn_once(
137
+ "KNOXCALL_WORKLOAD_ADVISORY_REFRESH",
138
+ "KnoxCall: advisory token refresh failed (#{e.message}); continuing with the " \
139
+ "current token, which expires in #{remaining.round}s"
140
+ )
141
+ return @token
142
+ end
143
+ end
144
+
145
+ # MANDATORY tier, or nothing cached at all.
146
+ refresh
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ # Exchange a fresh assertion. Called with @mutex held, which is what makes
153
+ # the exchange single-flight.
154
+ def refresh
155
+ assertion = @assertion.call
156
+ unless assertion.is_a?(String) && !assertion.empty?
157
+ raise StaleAssertionError, "the workload assertion source returned nothing. It must " \
158
+ "return the workload's current OIDC id_token on every call."
159
+ end
160
+
161
+ fingerprint = Digest::SHA256.hexdigest(assertion)
162
+ if @spent.key?(fingerprint)
163
+ raise StaleAssertionError,
164
+ "the workload assertion source returned an assertion that has already been " \
165
+ "exchanged. KnoxCall assertions are single-use, so each refresh needs a NEWLY " \
166
+ "minted one — call the platform's token endpoint inside the source (for example " \
167
+ "re-fetch ACTIONS_ID_TOKEN_REQUEST_URL, or re-read the projected service-account " \
168
+ "token file) rather than capturing one value at startup."
169
+ end
170
+
171
+ res = KnoxCall.exchange_token(
172
+ subject_token: assertion,
173
+ resource: @resource,
174
+ audience: @audience,
175
+ tenant: @tenant,
176
+ sandbox: @sandbox,
177
+ base_url: @base_url,
178
+ timeout: @timeout
179
+ )
180
+
181
+ # Recorded only after the exchange returns, so a network failure does not
182
+ # burn a fingerprint the caller could legitimately retry with. The server
183
+ # claims the assertion before it mints, so a SUCCESS is what makes those
184
+ # bytes unusable.
185
+ @spent[fingerprint] = true
186
+
187
+ @token = res["access_token"]
188
+ @expires_at = @clock.call + res["expires_in"].to_f
189
+ @token
190
+ end
191
+ end
192
+ end