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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/keycardai/oauth/client_credentials_client.rb +8 -3
- data/lib/keycardai/oauth/http.rb +76 -6
- data/lib/keycardai/oauth/token_exchange_client.rb +8 -3
- data/lib/keycardai/oauth/token_requests.rb +30 -9
- 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: b6be65f06dd7f30c0f08b133a505e32646582465a0c29df5b98307839dc9fbc8
|
|
4
|
+
data.tar.gz: b84732c663f023466399b27a6189683a15621c9808f85efc12063d02c29a5b3d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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)
|