keycardai-oauth 0.6.0 → 0.6.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: 3cd4432729ce7f9f1e54d2fe539c92cc86c69b6b8914abd78b407f9aa75cfe72
4
- data.tar.gz: 65378c59aea04d972e50922379d5c45c01361cda5eb210421f00633093982388
3
+ metadata.gz: df6d3bc89be61e489dd76756c9c07f3239a7dd5d512f8569a85ab2ade99dbdca
4
+ data.tar.gz: c977ddd06c80a631b36e61c728a77bb097f7ce69d553c338bde680cd8d06abce
5
5
  SHA512:
6
- metadata.gz: 964ed9c9d520da2470d0f31ca9427da6397752cf5d5265e7f9517367e67719e741d869f38a62042275f9af2049a2921b96d096a76171b7920c407c5be5e72189
7
- data.tar.gz: b02efcd68eef6b896b7b1cfde8e29e0b537fcf86397bd33e1d3ac9d7322ddf8015319c5d544feec0766f4e1032eec5d6d679f4e11662797a5fb06dbe4c93946c
6
+ metadata.gz: '0112832ebca175689a4ef54071d1720178884a869af1835297a8ef777b069f83dced903f0626a9aa8be2a5e5efd466e65756260111894ea770299eaef68452fa'
7
+ data.tar.gz: e5b5c1a8d5d973570cb6049baa8109f4e0495a71f83ac38683d4741569f00a44cd01c3621d0c688e8da343d50febc601aa69bbfeab522ab9a97da776b95f1be0
data/CHANGELOG.md CHANGED
@@ -15,6 +15,14 @@ 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.6.1-keycardai-oauth (2026-11-06)
19
+
20
+
21
+ - fix(keycardai-oauth): throttle JWKS refetches for unknown key ids, and time out zone requests by default (#49)
22
+ - JWKSKeyring keeps the last key set per jwks_uri for key_ttl, looks a kid up there before fetching, refetches for a missing kid at most once per refetch_cooldown (default 30 seconds, 0 fetches per miss), and drops the set in invalidate. NetHTTPClient gives a request with no timeout: 10 seconds instead of Net::HTTP's 60, so every request to the zone is bounded while callers can still pass timeout:. Follows typescript-sdk #309 and #310. SDK-93.
23
+ - Co-authored-by: devin-ai-keycard <devin-ai@keycard.ai>
24
+ Co-authored-by: Larry Osakwe <larry@keycard.ai>
25
+
18
26
  ## 0.6.0-keycardai-oauth (2026-10-09)
19
27
 
20
28
 
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT LICENSE
2
2
 
3
- Copyright © 2026 Keycard Labs, inc.
3
+ Copyright (c) 2026 Keycard Labs, Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
 
data/README.md CHANGED
@@ -11,7 +11,7 @@ OAuth 2.0 primitives for the Keycard platform. The foundation gem of the
11
11
  bundle add keycardai-oauth
12
12
  ```
13
13
 
14
- Capabilities (per [keycard-sdk-spec](https://github.com/keycardai/keycard-sdk-spec)):
14
+ Capabilities:
15
15
 
16
16
  - Token exchange and impersonation (RFC 8693)
17
17
  - Client credentials grant (RFC 6749 §4.4)
@@ -41,9 +41,9 @@ module Keycardai
41
41
  # module-level function defaults, keeps its sessions until it is garbage
42
42
  # collected, which closes the underlying sockets.
43
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
44
+ # Open and read timeout for a request whose caller passed no timeout:
45
+ # 10 seconds, so a request to the zone cannot wait for Net::HTTP's 60.
46
+ DEFAULT_TIMEOUT = 10
47
47
 
48
48
  def initialize
49
49
  @registry = {}
@@ -107,10 +107,10 @@ module Keycardai
107
107
 
108
108
  def perform(uri, request, timeout)
109
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
110
+ # Timeouts are per request; a request without one gets the default
111
+ # back rather than the previous request's values.
112
+ http.open_timeout = timeout || DEFAULT_TIMEOUT
113
+ http.read_timeout = timeout || DEFAULT_TIMEOUT
114
114
  http.start unless http.started?
115
115
  response = http.request(request)
116
116
  Response.new(status: response.code.to_i, headers: response.to_hash, body: response.body.to_s)
@@ -21,23 +21,31 @@ module Keycardai
21
21
  DEFAULT_KEY_TTL = 300
22
22
  DEFAULT_DISCOVERY_TTL = 3600
23
23
  DEFAULT_FETCH_TIMEOUT = 10
24
+ # Shortest interval between two JWKS fetches for one jwks_uri caused by a
25
+ # kid the cached key set does not hold. The checks before the key lookup
26
+ # read the unsigned token, so a forged token can name any kid.
27
+ DEFAULT_REFETCH_COOLDOWN = 30
24
28
  MAX_CACHED_KEYS = 256
25
29
 
26
30
  # @param http_client [#get] pluggable transport (see HTTP::NetHTTPClient)
27
31
  # @param key_ttl [Numeric] per-key cache lifetime in seconds
28
32
  # @param discovery_ttl [Numeric] jwks_uri cache lifetime in seconds
29
33
  # @param fetch_timeout [Numeric] timeout for discovery and JWKS fetches
34
+ # @param refetch_cooldown [Numeric] seconds before a kid missing from the
35
+ # cached key set triggers another fetch; 0 fetches on every miss
30
36
  # @param clock [#call] returns the current Time; override in tests
31
37
  def initialize(http_client: HTTP::NetHTTPClient.new, key_ttl: DEFAULT_KEY_TTL,
32
38
  discovery_ttl: DEFAULT_DISCOVERY_TTL, fetch_timeout: DEFAULT_FETCH_TIMEOUT,
33
- clock: -> { Time.now })
39
+ refetch_cooldown: DEFAULT_REFETCH_COOLDOWN, clock: -> { Time.now })
34
40
  @http_client = http_client
35
41
  @key_ttl = key_ttl
36
42
  @discovery_ttl = discovery_ttl
37
43
  @fetch_timeout = fetch_timeout
44
+ @refetch_cooldown = refetch_cooldown
38
45
  @clock = clock
39
46
  @keys = {}
40
47
  @discovery = {}
48
+ @key_sets = {} # jwks_uri => { keys:, fetched_at: }
41
49
  @issuer_locks = {}
42
50
  @mutex = Mutex.new
43
51
  end
@@ -69,10 +77,12 @@ module Keycardai
69
77
  @mutex.synchronize do
70
78
  if issuer
71
79
  @keys.delete_if { |(cached_issuer, _), _| cached_issuer == issuer }
72
- @discovery.delete(issuer)
80
+ entry = @discovery.delete(issuer)
81
+ @key_sets.delete(entry[:jwks_uri]) if entry
73
82
  else
74
83
  @keys.clear
75
84
  @discovery.clear
85
+ @key_sets.clear
76
86
  end
77
87
  end
78
88
  end
@@ -93,14 +103,41 @@ module Keycardai
93
103
 
94
104
  def resolve(issuer, kid)
95
105
  jwks_uri = resolve_jwks_uri(issuer)
96
- jwk = fetch_jwks(jwks_uri).find { |candidate| candidate["kid"] == kid }
97
- raise JWKSKeyNotFoundError, "kid #{kid.inspect} not found in JWKS for #{issuer}" unless jwk
106
+ jwk = key_from_set(issuer, kid, jwks_uri)
98
107
 
99
108
  key = import_key(jwk)
100
109
  cache_key(issuer, kid, key)
101
110
  key
102
111
  end
103
112
 
113
+ # The cached key set first: a kid it holds imports with no fetch, and a kid
114
+ # it lacks refetches at most once per cooldown, so a rotated-in key is found
115
+ # once the cooldown has passed.
116
+ def key_from_set(issuer, kid, jwks_uri)
117
+ cached = fresh_key_set(jwks_uri)
118
+ if cached
119
+ jwk = cached[:keys].find { |candidate| candidate["kid"] == kid }
120
+ return jwk if jwk
121
+
122
+ raise_not_found(issuer, kid) if now - cached[:fetched_at] < @refetch_cooldown
123
+ end
124
+
125
+ keys = fetch_jwks(jwks_uri)
126
+ @mutex.synchronize { @key_sets[jwks_uri] = { keys: keys, fetched_at: now } }
127
+ keys.find { |candidate| candidate["kid"] == kid } || raise_not_found(issuer, kid)
128
+ end
129
+
130
+ def fresh_key_set(jwks_uri)
131
+ @mutex.synchronize do
132
+ entry = @key_sets[jwks_uri]
133
+ entry if entry && now - entry[:fetched_at] <= @key_ttl
134
+ end
135
+ end
136
+
137
+ def raise_not_found(issuer, kid)
138
+ raise JWKSKeyNotFoundError, "kid #{kid.inspect} not found in JWKS for #{issuer}"
139
+ end
140
+
104
141
  def resolve_jwks_uri(issuer)
105
142
  cached = @mutex.synchronize do
106
143
  entry = @discovery[issuer]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Keycardai
4
4
  module OAuth
5
- VERSION = "0.6.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
@@ -33,8 +33,6 @@ module Keycardai
33
33
  # (RFC 7591), authorization server discovery (RFC 8414), UserInfo (OIDC Core
34
34
  # 1.0 §5.3), JWT/JWKS verification, application credentials, and
35
35
  # AccessContext.
36
- #
37
- # Contract: https://github.com/keycardai/keycard-sdk-spec
38
36
  module OAuth
39
37
  end
40
38
  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.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keycard