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,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
|