keycardai-oauth 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fda3b3f30a1e8ccdf5d8c1dad0a9bc3ef99145990b1952c3382be766bffdd753
4
- data.tar.gz: e4bb93fb097bc3dfd4cf894926860c241413c21b8e1cc05aff6432a632062e68
3
+ metadata.gz: b6be65f06dd7f30c0f08b133a505e32646582465a0c29df5b98307839dc9fbc8
4
+ data.tar.gz: b84732c663f023466399b27a6189683a15621c9808f85efc12063d02c29a5b3d
5
5
  SHA512:
6
- metadata.gz: 1a48955a16091ebec04bc7bef9b7effa8e1036a8dd273a9c351724bd86c729cf60f305c2d396dad3bbd50ff8b34af34bf6d8db7db6ccbd9c4d1e4fd91f058b1e
7
- data.tar.gz: 8ed634c0e436e8a6ea9603bce24a7e861a56ac22df7caff2971e089ed641fc698d998c8b5750a2d1c746162c1437243fa0c8524bd31e617096ae00be9a5710f4
6
+ metadata.gz: 7d5800dd06a3fe192e813f75703b1e0a419213fe812bc8328a540447e348f0953d6d21f305ec839e23ac3469fc0ddf7941271f5b241ea73d3e9cf77191c112a8
7
+ data.tar.gz: 9bcd9bdbdc892ced3f104a63fecb868821769f7f5a14897ac25bda322d3404ed3a00efed458ce30130436b8b0ca59a24e0e5253f1ff7aa22962cdb6d8840a42b
data/CHANGELOG.md CHANGED
@@ -15,6 +15,19 @@ 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.0-keycardai-oauth (2026-09-08)
19
+
20
+
21
+ - feat(keycardai-oauth): reuse Net::HTTP sessions in the default transport
22
+ - 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.
23
+ - 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.
24
+
25
+ ## 0.4.0-keycardai-oauth (2026-09-05)
26
+
27
+
28
+ - feat(keycardai-oauth): expire the cached token endpoint after discovery_ttl
29
+ - ECO-366 Ruby leg, audited against keycard-sdk-spec's metadata-failures-are-not-sticky contract. The sticky-failure defect does not exist in Ruby: token-endpoint discovery stored outcomes with ||= under a mutex, so a raise cached nothing and an interrupted caller left nothing behind. The one gap was the endpoint being cached for the client lifetime; it is now cached for discovery_ttl (3600s default, the JWKS keyring's knob and default) with a clock: keyword mirroring TokenVerifier. No negative cache and no retryable field: the spec makes deterministic-failure caching optional and Ruby never stored failures, and retryability classification is ECO-360's Ruby leg. Conformance tests cover the spec's discovery rows on both clients via one shared example group.
30
+
18
31
  ## 0.3.0-keycardai-oauth (2026-09-01)
19
32
 
20
33
 
@@ -8,7 +8,8 @@ module Keycardai
8
8
  # client assertion carried on the request.
9
9
  #
10
10
  # The token endpoint is discovered from the issuer on first use and
11
- # cached. Requests do not retry transparently.
11
+ # cached for discovery_ttl; a failed discovery is not cached. Requests do
12
+ # not retry transparently.
12
13
  class ClientCredentialsClient
13
14
  include TokenRequests
14
15
 
@@ -21,12 +22,16 @@ module Keycardai
21
22
  # provide both or neither
22
23
  # @param http_client [#get, #post_form] pluggable transport
23
24
  # @param timeout [Numeric, nil] request timeout in seconds
25
+ # @param discovery_ttl [Numeric] token-endpoint cache lifetime in seconds
26
+ # @param clock [#call] returns the current Time; override in tests
24
27
  # @raise [ConfigurationError] when only one of client_id/client_secret is
25
28
  # given, or a credential is combined with a raw pair
26
29
  def initialize(issuer:, credential: nil, client_id: nil, client_secret: nil,
27
- http_client: HTTP::NetHTTPClient.new, timeout: nil)
30
+ http_client: HTTP::NetHTTPClient.new, timeout: nil,
31
+ discovery_ttl: TokenRequests::DEFAULT_DISCOVERY_TTL, clock: -> { Time.now })
28
32
  initialize_token_client(issuer: issuer, credential: credential, client_id: client_id,
29
- client_secret: client_secret, http_client: http_client, timeout: timeout)
33
+ client_secret: client_secret, http_client: http_client, timeout: timeout,
34
+ discovery_ttl: discovery_ttl, clock: clock)
30
35
  end
31
36
 
32
37
  # Request a token for the client itself.
@@ -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
@@ -9,7 +9,8 @@ module Keycardai
9
9
  # authentication.
10
10
  #
11
11
  # The token endpoint is discovered from the issuer on first use and
12
- # cached. Requests do not retry transparently.
12
+ # cached for discovery_ttl; a failed discovery is not cached. Requests do
13
+ # not retry transparently.
13
14
  class TokenExchangeClient
14
15
  include TokenRequests
15
16
 
@@ -21,12 +22,16 @@ module Keycardai
21
22
  # provide both or neither
22
23
  # @param http_client [#get, #post_form] pluggable transport
23
24
  # @param timeout [Numeric, nil] request timeout in seconds
25
+ # @param discovery_ttl [Numeric] token-endpoint cache lifetime in seconds
26
+ # @param clock [#call] returns the current Time; override in tests
24
27
  # @raise [ConfigurationError] when only one of client_id/client_secret is
25
28
  # given, or a credential is combined with a raw pair
26
29
  def initialize(issuer:, credential: nil, client_id: nil, client_secret: nil,
27
- http_client: HTTP::NetHTTPClient.new, timeout: nil)
30
+ http_client: HTTP::NetHTTPClient.new, timeout: nil,
31
+ discovery_ttl: TokenRequests::DEFAULT_DISCOVERY_TTL, clock: -> { Time.now })
28
32
  initialize_token_client(issuer: issuer, credential: credential, client_id: client_id,
29
- client_secret: client_secret, http_client: http_client, timeout: timeout)
33
+ client_secret: client_secret, http_client: http_client, timeout: timeout,
34
+ discovery_ttl: discovery_ttl, clock: clock)
30
35
  end
31
36
 
32
37
  # Exchange a subject token (RFC 8693).
@@ -8,6 +8,10 @@ module Keycardai
8
8
  # endpoint discovery with caching, shared-secret (HTTP Basic) client
9
9
  # authentication, and RFC 6749 §5.2 error parsing. Not public API.
10
10
  module TokenRequests
11
+ # Default lifetime of a discovered token endpoint, in seconds; the same
12
+ # knob and default as the JWKS keyring's jwks_uri cache.
13
+ DEFAULT_DISCOVERY_TTL = 3600
14
+
11
15
  # Parse a token-endpoint response: a TokenResponse on 2xx, a raised
12
16
  # typed error otherwise.
13
17
  #
@@ -52,13 +56,16 @@ module Keycardai
52
56
 
53
57
  private
54
58
 
55
- def initialize_token_client(issuer:, credential:, client_id:, client_secret:, http_client:, timeout:)
59
+ def initialize_token_client(issuer:, credential:, client_id:, client_secret:, http_client:, timeout:,
60
+ discovery_ttl: DEFAULT_DISCOVERY_TTL, clock: -> { Time.now })
56
61
  validate_client_auth(credential, client_id, client_secret)
57
62
 
58
63
  @issuer = issuer
59
64
  @credential = credential || (client_id ? ClientSecret.new(client_id, client_secret) : nil)
60
65
  @http_client = http_client
61
66
  @timeout = timeout
67
+ @discovery_ttl = discovery_ttl
68
+ @clock = clock
62
69
  @token_endpoints = {}
63
70
  @token_endpoint_mutex = Mutex.new
64
71
  end
@@ -72,19 +79,33 @@ module Keycardai
72
79
  raise ConfigurationError, "client_id and client_secret must be provided together"
73
80
  end
74
81
 
75
- # Discover a zone's token endpoint once and cache it, keyed by issuer
76
- # so multi-zone clients never reuse another zone's endpoint.
82
+ # Discover a zone's token endpoint and cache it for discovery_ttl, keyed
83
+ # by issuer so multi-zone clients never reuse another zone's endpoint.
84
+ # Only a success is recorded: a failed discovery raises to its caller and
85
+ # the next call discovers again. The mutex serializes cold-cache callers
86
+ # so concurrent first calls perform a single fetch; an interrupted caller
87
+ # releases it and leaves nothing behind for the others.
77
88
  def token_endpoint(issuer = @issuer)
78
89
  @token_endpoint_mutex.synchronize do
79
- @token_endpoints[issuer] ||= begin
80
- metadata = OAuth.fetch_authorization_server_metadata(issuer, http_client: @http_client,
81
- timeout: @timeout)
82
- metadata.token_endpoint ||
83
- raise(ProtocolError.new("metadata for #{issuer} has no token_endpoint", code: "invalid_metadata"))
84
- end
90
+ fresh_token_endpoint(issuer) || discover_token_endpoint(issuer)
85
91
  end
86
92
  end
87
93
 
94
+ def fresh_token_endpoint(issuer)
95
+ entry = @token_endpoints[issuer]
96
+ return nil unless entry
97
+
98
+ entry[:endpoint] if @clock.call - entry[:fetched_at] <= @discovery_ttl
99
+ end
100
+
101
+ def discover_token_endpoint(issuer)
102
+ metadata = OAuth.fetch_authorization_server_metadata(issuer, http_client: @http_client, timeout: @timeout)
103
+ endpoint = metadata.token_endpoint ||
104
+ raise(ProtocolError.new("metadata for #{issuer} has no token_endpoint", code: "invalid_metadata"))
105
+ @token_endpoints[issuer] = { endpoint: endpoint, fetched_at: @clock.call }
106
+ endpoint
107
+ end
108
+
88
109
  def post_token_request(params, issuer: @issuer)
89
110
  headers = { "Accept" => "application/json" }
90
111
  authorization = @credential&.authorization_header(issuer: issuer)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Keycardai
4
4
  module OAuth
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keycard