keycardai-oauth 0.4.0 → 0.5.1
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/CHANGELOG.md +27 -0
- data/lib/keycardai/oauth/http.rb +76 -6
- data/lib/keycardai/oauth/token_verifier.rb +8 -1
- data/lib/keycardai/oauth/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40a3e00e3c9bea5de74c7dfec6f5daccb2f90bc7cf778bb792ea737e7e34dead
|
|
4
|
+
data.tar.gz: 29be50e3ae28e46aeb2c20f2c93680e4aaf9206e4fa39a092360b5239f7ee1af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b7b2d4fedab4c89db633147d98151dcfb36e2bd84da817025c9ae10be11c39291bebae71d7549063c52371738a8c417c1ba87a1622964dabcac03ff4f36e386
|
|
7
|
+
data.tar.gz: bc08654b809ca7863b9a60de9518b65255d303d115819d85620daca16d5bb5f5a5e2ae3ec35b0b34f1173d526413a10faf1bd5b0681fa9d62395895037b2dc1e
|
data/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,33 @@ the loopback flow (RFC 8252), JWT signing and verification with a caching JWKS
|
|
|
15
15
|
keyring, the three application credentials (ClientSecret with multi-zone,
|
|
16
16
|
WebIdentity, WorkloadIdentity with pluggable token sources), and AccessContext.
|
|
17
17
|
|
|
18
|
+
## 0.5.1-keycardai-oauth (2026-09-17)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
- fix(keycardai-oauth): warn once when a TokenVerifier is built without audiences (SDK-4) (#39)
|
|
22
|
+
- * fix(keycardai-oauth): warn once when a TokenVerifier is built without audiences (SDK-4)
|
|
23
|
+
- Co-Authored-By: Larry Osakwe <larry@keycard.ai>
|
|
24
|
+
- * fix(keycardai-oauth): bind the example server's verifier to KEYCARD_RESOURCE_ID
|
|
25
|
+
- The example used KEYCARD_RESOURCE_ID as a display name and built its verifier
|
|
26
|
+
without audiences. It now keeps SERVER_NAME for display, passes the resource
|
|
27
|
+
identifier as audiences, and its selftest mints tokens for that identifier,
|
|
28
|
+
sends the wrong-scope token with the right aud so it reaches the scope check,
|
|
29
|
+
and asserts the proxied authorization_endpoint passes through unmodified
|
|
30
|
+
(the resource= rewrite it still expected was retired in #37).
|
|
31
|
+
- Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
|
32
|
+
- ---------
|
|
33
|
+
- Co-authored-by: devin-ai-keycard <devin-ai@keycard.ai>
|
|
34
|
+
Co-authored-by: Larry Osakwe <larry@keycard.ai>
|
|
35
|
+
Co-authored-by: Larry-Osakwe <larryosak@gmail.com>
|
|
36
|
+
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
|
37
|
+
|
|
38
|
+
## 0.5.0-keycardai-oauth (2026-09-08)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
- feat(keycardai-oauth): reuse Net::HTTP sessions in the default transport
|
|
42
|
+
- ECO-383. NetHTTPClient#perform built a fresh Net::HTTP per request, so every oauth call (and every verify and token exchange in the mcp gem, whose AuthProvider holds one NetHTTPClient) paid a new TCP and TLS handshake. Sessions are now kept per instance, per thread, per (host, port, scheme): the request path takes no lock, a small mutex guards only the registry of per-thread session maps, and dead threads are swept when the registry is touched. close finishes every session; an unclosed throwaway instance keeps its sessions until garbage collection, the same abandonment doctrine as the Python fix (python-sdk #291). Timeouts are applied per request, restoring Net::HTTP's own defaults when absent. No new dependencies; the gem stays stdlib-only.
|
|
43
|
+
- Behavior change: a keepalive connection the server dropped while idle surfaces as NetworkError on the next request, where a fresh-connection design could not fail that way; the session reconnects on the attempt after. The Ruby retryability classification (ECO-360) is the consumer-side answer when it lands.
|
|
44
|
+
|
|
18
45
|
## 0.4.0-keycardai-oauth (2026-09-05)
|
|
19
46
|
|
|
20
47
|
|
data/lib/keycardai/oauth/http.rb
CHANGED
|
@@ -27,7 +27,41 @@ module Keycardai
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Default transport backed by Net::HTTP. TLS is used for https URLs.
|
|
30
|
+
#
|
|
31
|
+
# Sessions are kept open and reused per instance, per thread, per
|
|
32
|
+
# (host, port, scheme). A thread only ever touches its own sessions, so
|
|
33
|
+
# the request path takes no lock; a small mutex guards only the registry
|
|
34
|
+
# of per-thread session maps, and dead threads are swept from it whenever
|
|
35
|
+
# it is touched. There is no transparent retry: a keepalive connection
|
|
36
|
+
# the server dropped while idle surfaces as NetworkError on the next
|
|
37
|
+
# request, the same as any other failure.
|
|
38
|
+
#
|
|
39
|
+
# Call {#close} when no requests are in flight to finish every session.
|
|
40
|
+
# An instance that is never closed, such as the throwaway client built by
|
|
41
|
+
# module-level function defaults, keeps its sessions until it is garbage
|
|
42
|
+
# collected, which closes the underlying sockets.
|
|
30
43
|
class NetHTTPClient
|
|
44
|
+
DEFAULT_OPEN_TIMEOUT = Net::HTTP.new("localhost").open_timeout
|
|
45
|
+
DEFAULT_READ_TIMEOUT = Net::HTTP.new("localhost").read_timeout
|
|
46
|
+
private_constant :DEFAULT_OPEN_TIMEOUT, :DEFAULT_READ_TIMEOUT
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@registry = {}
|
|
50
|
+
@registry_mutex = Mutex.new
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Finish every open session and clear the registry. The client stays
|
|
54
|
+
# usable; the next request opens fresh sessions. Call only when no
|
|
55
|
+
# requests are in flight on any thread.
|
|
56
|
+
#
|
|
57
|
+
# @return [void]
|
|
58
|
+
def close
|
|
59
|
+
maps = @registry_mutex.synchronize do
|
|
60
|
+
@registry.values.tap { @registry.clear }
|
|
61
|
+
end
|
|
62
|
+
maps.each { |sessions| sessions.each_value { |http| finish(http) } }
|
|
63
|
+
end
|
|
64
|
+
|
|
31
65
|
# @param url [String]
|
|
32
66
|
# @param headers [Hash{String => String}]
|
|
33
67
|
# @param timeout [Numeric, nil] open/read timeout in seconds
|
|
@@ -72,17 +106,53 @@ module Keycardai
|
|
|
72
106
|
private
|
|
73
107
|
|
|
74
108
|
def perform(uri, request, timeout)
|
|
75
|
-
http =
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
109
|
+
http = session_for(uri)
|
|
110
|
+
# Timeouts are per request; a request without one gets Net::HTTP's
|
|
111
|
+
# defaults back rather than the previous request's values.
|
|
112
|
+
http.open_timeout = timeout || DEFAULT_OPEN_TIMEOUT
|
|
113
|
+
http.read_timeout = timeout || DEFAULT_READ_TIMEOUT
|
|
114
|
+
http.start unless http.started?
|
|
81
115
|
response = http.request(request)
|
|
82
116
|
Response.new(status: response.code.to_i, headers: response.to_hash, body: response.body.to_s)
|
|
83
117
|
rescue SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, EOFError => e
|
|
84
118
|
raise NetworkError, "request to #{uri.host} failed: #{e.class}"
|
|
85
119
|
end
|
|
120
|
+
|
|
121
|
+
def session_for(uri)
|
|
122
|
+
sessions = sessions_for_current_thread
|
|
123
|
+
key = [uri.host, uri.port, uri.scheme]
|
|
124
|
+
http = sessions[key]
|
|
125
|
+
return http if http&.started?
|
|
126
|
+
|
|
127
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
128
|
+
http.use_ssl = uri.scheme == "https"
|
|
129
|
+
sessions[key] = http
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A thread's own map is read without the mutex once it exists; the mutex
|
|
133
|
+
# is taken only to register a new thread or to close.
|
|
134
|
+
def sessions_for_current_thread
|
|
135
|
+
@registry[Thread.current] || @registry_mutex.synchronize do
|
|
136
|
+
sweep_dead_threads
|
|
137
|
+
@registry[Thread.current] ||= {}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Caller holds @registry_mutex.
|
|
142
|
+
def sweep_dead_threads
|
|
143
|
+
@registry.delete_if do |thread, sessions|
|
|
144
|
+
next false if thread.alive?
|
|
145
|
+
|
|
146
|
+
sessions.each_value { |http| finish(http) }
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def finish(http)
|
|
152
|
+
http.finish if http.started?
|
|
153
|
+
rescue IOError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
86
156
|
end
|
|
87
157
|
end
|
|
88
158
|
end
|
|
@@ -77,7 +77,8 @@ module Keycardai
|
|
|
77
77
|
class TokenVerifier
|
|
78
78
|
# @param issuers [String, Array<String>] trusted zone issuer URL(s)
|
|
79
79
|
# @param audiences [String, Array<String>, nil] when set, tokens must
|
|
80
|
-
# carry an intersecting aud
|
|
80
|
+
# carry an intersecting aud; when nil, the verifier accepts a token
|
|
81
|
+
# minted for any resource in the zone and warns once at construction
|
|
81
82
|
# @param http_client [#get] pluggable transport
|
|
82
83
|
# @param key_ttl [Numeric] JWKS key cache lifetime in seconds
|
|
83
84
|
# @param discovery_ttl [Numeric] jwks_uri cache lifetime in seconds
|
|
@@ -90,6 +91,12 @@ module Keycardai
|
|
|
90
91
|
@issuers = Array(issuers).reject { |issuer| issuer.nil? || issuer.empty? }
|
|
91
92
|
raise ConfigurationError, "TokenVerifier requires at least one trusted issuer" if @issuers.empty?
|
|
92
93
|
|
|
94
|
+
if audiences.nil?
|
|
95
|
+
warn "Keycardai::OAuth::TokenVerifier has no audiences configured, so it accepts a token minted " \
|
|
96
|
+
"for any resource in the zone; pass audiences: with this server's registered resource identifier.",
|
|
97
|
+
uplevel: 1
|
|
98
|
+
end
|
|
99
|
+
|
|
93
100
|
@audiences = audiences
|
|
94
101
|
@clock = clock
|
|
95
102
|
@keyring = JWKSKeyring.new(http_client: http_client, key_ttl: key_ttl,
|