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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40a3e00e3c9bea5de74c7dfec6f5daccb2f90bc7cf778bb792ea737e7e34dead
4
- data.tar.gz: 29be50e3ae28e46aeb2c20f2c93680e4aaf9206e4fa39a092360b5239f7ee1af
3
+ metadata.gz: df6d3bc89be61e489dd76756c9c07f3239a7dd5d512f8569a85ab2ade99dbdca
4
+ data.tar.gz: c977ddd06c80a631b36e61c728a77bb097f7ce69d553c338bde680cd8d06abce
5
5
  SHA512:
6
- metadata.gz: 2b7b2d4fedab4c89db633147d98151dcfb36e2bd84da817025c9ae10be11c39291bebae71d7549063c52371738a8c417c1ba87a1622964dabcac03ff4f36e386
7
- data.tar.gz: bc08654b809ca7863b9a60de9518b65255d303d115819d85620daca16d5bb5f5a5e2ae3ec35b0b34f1173d526413a10faf1bd5b0681fa9d62395895037b2dc1e
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 © 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)
@@ -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
@@ -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]
@@ -15,6 +15,7 @@ module Keycardai
15
15
  module GrantType
16
16
  CLIENT_CREDENTIALS = "client_credentials"
17
17
  AUTHORIZATION_CODE = "authorization_code"
18
+ REFRESH_TOKEN = "refresh_token"
18
19
  TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"
19
20
  end
20
21
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Keycardai
4
4
  module OAuth
5
- VERSION = "0.5.1"
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.5.1
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keycard