keycardai-oauth 0.3.0 → 0.4.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: 377cff115fcfdf240200ae12b5d76d9c395e7a88a7e68cada08eb2bbe2dedaa2
4
+ data.tar.gz: 2d225d8679d1d4a901419aece54de5aceed70ef6998bb6f58966fda86512450d
5
5
  SHA512:
6
- metadata.gz: 1a48955a16091ebec04bc7bef9b7effa8e1036a8dd273a9c351724bd86c729cf60f305c2d396dad3bbd50ff8b34af34bf6d8db7db6ccbd9c4d1e4fd91f058b1e
7
- data.tar.gz: 8ed634c0e436e8a6ea9603bce24a7e861a56ac22df7caff2971e089ed641fc698d998c8b5750a2d1c746162c1437243fa0c8524bd31e617096ae00be9a5710f4
6
+ metadata.gz: cbdc7911049fbf06b769cf708b3be1f53c8a5dcfb6f2db3e9a920ad050b31526b245e0558d3a90d405a612713985c82ea4cd6f51cde7e23e0a64acc2591ed18d
7
+ data.tar.gz: 84047019c88c419316be53c29b79acee9558cdcf657b1297d2ee840426f5459dbc8759943f9737f8d6f3a86ed08f2be8d349925a99697ab1d9d720477ebc3a5a
data/CHANGELOG.md CHANGED
@@ -15,6 +15,12 @@ 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.4.0-keycardai-oauth (2026-09-05)
19
+
20
+
21
+ - feat(keycardai-oauth): expire the cached token endpoint after discovery_ttl
22
+ - 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.
23
+
18
24
  ## 0.3.0-keycardai-oauth (2026-09-01)
19
25
 
20
26
 
@@ -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.
@@ -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.4.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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keycard