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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 377cff115fcfdf240200ae12b5d76d9c395e7a88a7e68cada08eb2bbe2dedaa2
4
- data.tar.gz: 2d225d8679d1d4a901419aece54de5aceed70ef6998bb6f58966fda86512450d
3
+ metadata.gz: 40a3e00e3c9bea5de74c7dfec6f5daccb2f90bc7cf778bb792ea737e7e34dead
4
+ data.tar.gz: 29be50e3ae28e46aeb2c20f2c93680e4aaf9206e4fa39a092360b5239f7ee1af
5
5
  SHA512:
6
- metadata.gz: cbdc7911049fbf06b769cf708b3be1f53c8a5dcfb6f2db3e9a920ad050b31526b245e0558d3a90d405a612713985c82ea4cd6f51cde7e23e0a64acc2591ed18d
7
- data.tar.gz: 84047019c88c419316be53c29b79acee9558cdcf657b1297d2ee840426f5459dbc8759943f9737f8d6f3a86ed08f2be8d349925a99697ab1d9d720477ebc3a5a
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
 
@@ -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 = Net::HTTP.new(uri.host, uri.port)
76
- http.use_ssl = uri.scheme == "https"
77
- if timeout
78
- http.open_timeout = timeout
79
- http.read_timeout = timeout
80
- end
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,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Keycardai
4
4
  module OAuth
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: keycardai-oauth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keycard