keycardai-oauth 0.5.1 → 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 +14 -0
- data/LICENSE +1 -1
- data/README.md +21 -1
- data/lib/keycardai/oauth/authorization_code.rb +41 -0
- data/lib/keycardai/oauth/http.rb +7 -7
- data/lib/keycardai/oauth/jwks_keyring.rb +41 -4
- data/lib/keycardai/oauth/token_types.rb +1 -0
- 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,20 @@ 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
|
+
|
|
26
|
+
## 0.6.0-keycardai-oauth (2026-10-09)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
- feat(keycardai-oauth): refresh a grant from the authorization-code flow (SDK-15) (#42)
|
|
30
|
+
- Adds Keycardai::OAuth.refresh_authorization(issuer, refresh_token:, client_id:, client_secret:, resources:, scopes:), the refresh step from authorization-code-pkce.md spec-version 4. Client authentication follows exchange_authorization_code: HTTP Basic with no body client_id for a confidential client, body client_id for a public one. A rotated refresh_token comes back on the TokenResponse for the caller to store; invalid_grant raises OAuthError and means the user must authorize again.
|
|
31
|
+
|
|
18
32
|
## 0.5.1-keycardai-oauth (2026-09-17)
|
|
19
33
|
|
|
20
34
|
|
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)
|
|
@@ -104,6 +104,26 @@ issuer's `userinfo_endpoint` rather than in the token. Pass `metadata:` to reuse
|
|
|
104
104
|
metadata you already discovered; nothing is cached, because group membership can
|
|
105
105
|
change server-side.
|
|
106
106
|
|
|
107
|
+
### Refresh a stored grant
|
|
108
|
+
|
|
109
|
+
A token response from the authorization-code flow may carry a `refresh_token`.
|
|
110
|
+
Redeem it with `refresh_authorization`, which authenticates the client the way
|
|
111
|
+
`exchange_authorization_code` does. The gem keeps no state: when the response
|
|
112
|
+
carries a new `refresh_token`, the server rotated it and you store it in place
|
|
113
|
+
of the old one. `OAuthError` with `error == "invalid_grant"` means the user must
|
|
114
|
+
authorize again.
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
token = Keycardai::OAuth.refresh_authorization(
|
|
118
|
+
"https://your-zone.keycard.cloud",
|
|
119
|
+
refresh_token: stored_refresh_token,
|
|
120
|
+
client_id: ENV.fetch("KEYCARD_CLIENT_ID"),
|
|
121
|
+
client_secret: ENV["KEYCARD_CLIENT_SECRET"], # omit for a public client
|
|
122
|
+
resources: ["https://api.acme.test"],
|
|
123
|
+
)
|
|
124
|
+
stored_refresh_token = token.refresh_token if token.refresh_token
|
|
125
|
+
```
|
|
126
|
+
|
|
107
127
|
### Act as a named user
|
|
108
128
|
|
|
109
129
|
```ruby
|
|
@@ -75,5 +75,46 @@ module Keycardai
|
|
|
75
75
|
|
|
76
76
|
TokenRequests.parse_response(http_client.post_form(endpoint, params, headers: headers, timeout: timeout))
|
|
77
77
|
end
|
|
78
|
+
|
|
79
|
+
# Redeem a refresh token for a new access token (RFC 6749 §6). Client
|
|
80
|
+
# authentication follows the code exchange: a confidential client
|
|
81
|
+
# authenticates with HTTP Basic and omits client_id from the body, a public
|
|
82
|
+
# client carries client_id in the body. The gem keeps no state: when the
|
|
83
|
+
# response carries a refresh_token the server rotated it, and the caller
|
|
84
|
+
# stores it in place of the old one.
|
|
85
|
+
#
|
|
86
|
+
# @param issuer [String] the zone's issuer URL; supplies the token endpoint
|
|
87
|
+
# @param refresh_token [String] the refresh token from an earlier token response
|
|
88
|
+
# @param client_id [String]
|
|
89
|
+
# @param client_secret [String, nil] confidential-client secret
|
|
90
|
+
# @param resources [Array<String>] RFC 8707 resource indicators, one resource
|
|
91
|
+
# parameter per entry
|
|
92
|
+
# @param scopes [Array<String>] scopes to request, space-joined into scope
|
|
93
|
+
# @param http_client [#get, #post_form] pluggable transport
|
|
94
|
+
# @param timeout [Numeric, nil]
|
|
95
|
+
# @return [TokenResponse]
|
|
96
|
+
# @raise [OAuthError] an RFC 6749 §5.2 error response; error == "invalid_grant"
|
|
97
|
+
# means the refresh token is expired, revoked, or unknown and the user must
|
|
98
|
+
# authorize again
|
|
99
|
+
# @raise [HTTPError] a non-2xx response with no OAuth error body (5xx, 429)
|
|
100
|
+
# @raise [NetworkError, ProtocolError]
|
|
101
|
+
def self.refresh_authorization(issuer, refresh_token:, client_id:, client_secret: nil, resources: [],
|
|
102
|
+
scopes: [], http_client: HTTP::NetHTTPClient.new, timeout: nil)
|
|
103
|
+
metadata = fetch_authorization_server_metadata(issuer, http_client: http_client, timeout: timeout)
|
|
104
|
+
endpoint = metadata.token_endpoint ||
|
|
105
|
+
raise(ProtocolError.new("metadata for #{issuer} has no token_endpoint", code: "invalid_metadata"))
|
|
106
|
+
|
|
107
|
+
params = {
|
|
108
|
+
"grant_type" => GrantType::REFRESH_TOKEN,
|
|
109
|
+
"refresh_token" => refresh_token,
|
|
110
|
+
"client_id" => client_secret ? nil : client_id,
|
|
111
|
+
"scope" => Array(scopes).empty? ? nil : Array(scopes).join(" ")
|
|
112
|
+
}.compact
|
|
113
|
+
params = params.to_a + Array(resources).map { |resource| ["resource", resource] }
|
|
114
|
+
headers = { "Accept" => "application/json" }
|
|
115
|
+
headers["Authorization"] = HTTP.basic_authorization(client_id, client_secret) if client_secret
|
|
116
|
+
|
|
117
|
+
TokenRequests.parse_response(http_client.post_form(endpoint, params, headers: headers, timeout: timeout))
|
|
118
|
+
end
|
|
78
119
|
end
|
|
79
120
|
end
|
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
|