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 +4 -4
- data/CHANGELOG.md +8 -0
- data/LICENSE +1 -1
- data/README.md +1 -1
- data/lib/keycardai/oauth/http.rb +7 -7
- data/lib/keycardai/oauth/jwks_keyring.rb +41 -4
- data/lib/keycardai/oauth/version.rb +1 -1
- data/lib/keycardai/oauth.rb +0 -2
- 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: df6d3bc89be61e489dd76756c9c07f3239a7dd5d512f8569a85ab2ade99dbdca
|
|
4
|
+
data.tar.gz: c977ddd06c80a631b36e61c728a77bb097f7ce69d553c338bde680cd8d06abce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
14
|
+
Capabilities:
|
|
15
15
|
|
|
16
16
|
- Token exchange and impersonation (RFC 8693)
|
|
17
17
|
- Client credentials grant (RFC 6749 §4.4)
|
data/lib/keycardai/oauth/http.rb
CHANGED
|
@@ -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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
111
|
-
#
|
|
112
|
-
http.open_timeout = timeout ||
|
|
113
|
-
http.read_timeout = 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 =
|
|
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]
|
data/lib/keycardai/oauth.rb
CHANGED
|
@@ -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
|