keycardai-oauth 0.5.0 → 0.6.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: b6be65f06dd7f30c0f08b133a505e32646582465a0c29df5b98307839dc9fbc8
4
- data.tar.gz: b84732c663f023466399b27a6189683a15621c9808f85efc12063d02c29a5b3d
3
+ metadata.gz: 3cd4432729ce7f9f1e54d2fe539c92cc86c69b6b8914abd78b407f9aa75cfe72
4
+ data.tar.gz: 65378c59aea04d972e50922379d5c45c01361cda5eb210421f00633093982388
5
5
  SHA512:
6
- metadata.gz: 7d5800dd06a3fe192e813f75703b1e0a419213fe812bc8328a540447e348f0953d6d21f305ec839e23ac3469fc0ddf7941271f5b241ea73d3e9cf77191c112a8
7
- data.tar.gz: 9bcd9bdbdc892ced3f104a63fecb868821769f7f5a14897ac25bda322d3404ed3a00efed458ce30130436b8b0ca59a24e0e5253f1ff7aa22962cdb6d8840a42b
6
+ metadata.gz: 964ed9c9d520da2470d0f31ca9427da6397752cf5d5265e7f9517367e67719e741d869f38a62042275f9af2049a2921b96d096a76171b7920c407c5be5e72189
7
+ data.tar.gz: b02efcd68eef6b896b7b1cfde8e29e0b537fcf86397bd33e1d3ac9d7322ddf8015319c5d544feec0766f4e1032eec5d6d679f4e11662797a5fb06dbe4c93946c
data/CHANGELOG.md CHANGED
@@ -15,6 +15,32 @@ 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.0-keycardai-oauth (2026-10-09)
19
+
20
+
21
+ - feat(keycardai-oauth): refresh a grant from the authorization-code flow (SDK-15) (#42)
22
+ - 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.
23
+
24
+ ## 0.5.1-keycardai-oauth (2026-09-17)
25
+
26
+
27
+ - fix(keycardai-oauth): warn once when a TokenVerifier is built without audiences (SDK-4) (#39)
28
+ - * fix(keycardai-oauth): warn once when a TokenVerifier is built without audiences (SDK-4)
29
+ - Co-Authored-By: Larry Osakwe <larry@keycard.ai>
30
+ - * fix(keycardai-oauth): bind the example server's verifier to KEYCARD_RESOURCE_ID
31
+ - The example used KEYCARD_RESOURCE_ID as a display name and built its verifier
32
+ without audiences. It now keeps SERVER_NAME for display, passes the resource
33
+ identifier as audiences, and its selftest mints tokens for that identifier,
34
+ sends the wrong-scope token with the right aud so it reaches the scope check,
35
+ and asserts the proxied authorization_endpoint passes through unmodified
36
+ (the resource= rewrite it still expected was retired in #37).
37
+ - Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
38
+ - ---------
39
+ - Co-authored-by: devin-ai-keycard <devin-ai@keycard.ai>
40
+ Co-authored-by: Larry Osakwe <larry@keycard.ai>
41
+ Co-authored-by: Larry-Osakwe <larryosak@gmail.com>
42
+ Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
43
+
18
44
  ## 0.5.0-keycardai-oauth (2026-09-08)
19
45
 
20
46
 
data/README.md CHANGED
@@ -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
@@ -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
 
@@ -77,7 +77,8 @@ module Keycardai
77
77
  class TokenVerifier
78
78
  # @param issuers [String, Array<String>] trusted zone issuer URL(s)
79
79
  # @param audiences [String, Array<String>, nil] when set, tokens must
80
- # carry an intersecting aud
80
+ # carry an intersecting aud; when nil, the verifier accepts a token
81
+ # minted for any resource in the zone and warns once at construction
81
82
  # @param http_client [#get] pluggable transport
82
83
  # @param key_ttl [Numeric] JWKS key cache lifetime in seconds
83
84
  # @param discovery_ttl [Numeric] jwks_uri cache lifetime in seconds
@@ -90,6 +91,12 @@ module Keycardai
90
91
  @issuers = Array(issuers).reject { |issuer| issuer.nil? || issuer.empty? }
91
92
  raise ConfigurationError, "TokenVerifier requires at least one trusted issuer" if @issuers.empty?
92
93
 
94
+ if audiences.nil?
95
+ warn "Keycardai::OAuth::TokenVerifier has no audiences configured, so it accepts a token minted " \
96
+ "for any resource in the zone; pass audiences: with this server's registered resource identifier.",
97
+ uplevel: 1
98
+ end
99
+
93
100
  @audiences = audiences
94
101
  @clock = clock
95
102
  @keyring = JWKSKeyring.new(http_client: http_client, key_ttl: key_ttl,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Keycardai
4
4
  module OAuth
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  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.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keycard