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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 377cff115fcfdf240200ae12b5d76d9c395e7a88a7e68cada08eb2bbe2dedaa2
|
|
4
|
+
data.tar.gz: 2d225d8679d1d4a901419aece54de5aceed70ef6998bb6f58966fda86512450d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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)
|