keycardai-oauth 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 33aa8b51bc6809128ee7546762842456ad9e204d0e60655217e879cb498a0b63
4
+ data.tar.gz: ee37eb4012f1bf46e01058476c6ce4abd4ba23be66c83841fb5114241ec365c4
5
+ SHA512:
6
+ metadata.gz: 6f150494708f23dddb34ecdcb7bdaaac837cd9d13f6ca1e0730df19536cec26db3d497474b8484cfad13f4d640dbb1831295c5172f6203a4e8c67b6e0683adc5
7
+ data.tar.gz: 9edbb0dda964b5f65eb88fae046bcb68b29e0b11f447633c27d0af9178b78ed377af2d7c42a2dcfb2fb75fbcffcda0e5c194e984a6ed8fc332ec5ecdc6e021ac
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ Entries from 0.2.0 onward are generated by commitizen on each bump. This first
4
+ one is written by hand because 0.1.0 is published from a baseline tag rather
5
+ than from a bump.
6
+
7
+ ## 0.1.0
8
+
9
+ Initial release.
10
+
11
+ OAuth 2.0 primitives for the Keycard platform: authorization server discovery
12
+ (RFC 8414), client credentials, token exchange and impersonation (RFC 8693),
13
+ dynamic client registration (RFC 7591), authorization code with PKCE including
14
+ the loopback flow (RFC 8252), JWT signing and verification with a caching JWKS
15
+ keyring, the three application credentials (ClientSecret with multi-zone,
16
+ WebIdentity, WorkloadIdentity with pluggable token sources), and AccessContext.
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT LICENSE
2
+
3
+ Copyright © 2026 Keycard Labs, inc.
4
+
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
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # keycardai-oauth
2
+
3
+ OAuth 2.0 primitives for the Keycard platform. The foundation gem of the
4
+ [Keycard Ruby SDK](https://github.com/keycardai/ruby-sdk); `keycardai-mcp` and
5
+ `keycardai-a2a` build on it.
6
+
7
+ > **Preview.** APIs may change between minor versions while the surface settles.
8
+ > Conformance against the cross-SDK contract is tracked in the
9
+ > [conformance report](https://github.com/keycardai/ruby-sdk/blob/main/docs/conformance-report.md).
10
+
11
+ ```sh
12
+ bundle add keycardai-oauth
13
+ ```
14
+
15
+ Capabilities (per [keycard-sdk-spec](https://github.com/keycardai/keycard-sdk-spec)):
16
+
17
+ - Token exchange and impersonation (RFC 8693)
18
+ - Client credentials grant (RFC 6749 §4.4)
19
+ - Authorization code + PKCE, including the challenge-driven loopback flow (RFC 8252)
20
+ - Dynamic client registration (RFC 7591)
21
+ - Authorization server discovery (RFC 8414)
22
+ - JWT signing and verification, JWKS keyring with caching
23
+ - Application credentials: ClientSecret (incl. multi-zone), WebIdentity (RFC 7523),
24
+ WorkloadIdentity with pluggable identity token sources
25
+ - AccessContext: the non-throwing per-request container for delegated tokens
26
+
27
+ ## Quickstart
28
+
29
+ ### Verify an inbound token
30
+
31
+ ```ruby
32
+ require "keycardai/oauth"
33
+
34
+ verifier = Keycardai::OAuth::TokenVerifier.new(
35
+ issuers: "https://your-zone.keycard.cloud",
36
+ audiences: "your-resource-id",
37
+ )
38
+
39
+ begin
40
+ token = verifier.verify_token(bearer_token)
41
+ puts token.subject
42
+ puts token.scopes
43
+ rescue Keycardai::OAuth::InvalidTokenError => e
44
+ # Fail closed: every verification failure is this one error type.
45
+ warn "rejected: #{e.message}"
46
+ end
47
+ ```
48
+
49
+ The verifier resolves signing keys through a JWKS keyring that caches per
50
+ `(issuer, kid)`, so a second verification of the same token does no network I/O.
51
+
52
+ ### Exchange a caller's token for a downstream resource
53
+
54
+ ```ruby
55
+ client = Keycardai::OAuth::TokenExchangeClient.new(
56
+ issuer: "https://your-zone.keycard.cloud",
57
+ client_id: ENV.fetch("KEYCARD_CLIENT_ID"),
58
+ client_secret: ENV.fetch("KEYCARD_CLIENT_SECRET"),
59
+ )
60
+
61
+ result = client.exchange_token(
62
+ subject_token: bearer_token,
63
+ resource: "https://api.github.com",
64
+ scope: "repo:read",
65
+ )
66
+ result.access_token
67
+ ```
68
+
69
+ Nothing here reads the environment on your behalf. Configuration is read in
70
+ application code and passed in explicitly, which is the cross-SDK contract.
71
+
72
+ ### Several resources at once
73
+
74
+ ```ruby
75
+ context = Keycardai::OAuth.exchange_tokens_for_resources(
76
+ client: client,
77
+ subject_token: bearer_token,
78
+ resources: ["https://api.github.com", "https://slack.com/api"],
79
+ )
80
+
81
+ context.status # "success", "partial_error", or "error"
82
+ context.access("https://api.github.com") # raises if that one resource failed
83
+ context.failed_resources
84
+ ```
85
+
86
+ One resource failing never takes down the others; the failure lands on the
87
+ context rather than raising, and `access` is where you choose to raise.
88
+
89
+ ### Act as a named user
90
+
91
+ ```ruby
92
+ result = client.impersonate(
93
+ user_identifier: "user@example.com",
94
+ resource: "https://api.github.com",
95
+ )
96
+ ```
97
+
98
+ The issued token's `sub` is the target user. It is a leaf credential: a zone
99
+ refuses it as the subject of a further exchange, so impersonate again rather
100
+ than trying to trade it up.
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module OAuth
5
+ # The per-request container of exchanged downstream tokens, keyed by
6
+ # resource, with per-resource error tracking. The grant layer constructs
7
+ # and populates it before the handler runs; handlers read it from the
8
+ # request context. Population is non-throwing by design: failures land on
9
+ # the context so a partial-success flow can proceed when only some
10
+ # exchanges failed.
11
+ #
12
+ # Reading is split between the throwing accessor #access and the
13
+ # non-throwing getters (#resource_error, #error, #errors?, #status, ...),
14
+ # so handlers can inspect state and choose to proceed, degrade, or fail.
15
+ class AccessContext
16
+ # @param tokens [Hash{String => TokenResponse}] seed of successful exchanges
17
+ def initialize(tokens = {})
18
+ @tokens = tokens.dup
19
+ @resource_errors = {}
20
+ @error = nil
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ # The exchanged token for a resource.
25
+ #
26
+ # @param resource [String]
27
+ # @return [TokenResponse]
28
+ # @raise [ResourceAccessError] a global error is set (global_error), the
29
+ # resource recorded an error (resource_error), or no token exists for
30
+ # it (missing_token)
31
+ def access(resource)
32
+ @mutex.synchronize do
33
+ raise_access_error("global_error", resource, details: @error) if @error
34
+ if @resource_errors.key?(resource)
35
+ raise_access_error("resource_error", resource, details: @resource_errors[resource])
36
+ end
37
+
38
+ @tokens[resource] || raise_access_error("missing_token", resource, available: @tokens.keys)
39
+ end
40
+ end
41
+
42
+ # @param resource [String]
43
+ # @return [Object, nil] the recorded error for the resource, nil when it succeeded
44
+ def resource_error(resource)
45
+ @mutex.synchronize { @resource_errors[resource] }
46
+ end
47
+
48
+ # @return [Object, nil] the global (context-wide) error
49
+ def error
50
+ @mutex.synchronize { @error }
51
+ end
52
+
53
+ # @return [Hash] { resources: Hash{String => Object}, error: Object | nil }
54
+ def errors
55
+ @mutex.synchronize { { resources: @resource_errors.dup, error: @error } }
56
+ end
57
+
58
+ # @param resource [String]
59
+ # @return [Boolean]
60
+ def resource_error?(resource)
61
+ @mutex.synchronize { @resource_errors.key?(resource) }
62
+ end
63
+
64
+ # @return [Boolean] whether a global error was set
65
+ def error?
66
+ @mutex.synchronize { !@error.nil? }
67
+ end
68
+
69
+ # @return [Boolean] whether a global error or any per-resource error exists
70
+ def errors?
71
+ @mutex.synchronize { !@error.nil? || !@resource_errors.empty? }
72
+ end
73
+
74
+ # @return ["success", "partial_error", "error"]
75
+ def status
76
+ @mutex.synchronize do
77
+ next "error" unless @error.nil?
78
+ next "partial_error" unless @resource_errors.empty?
79
+
80
+ "success"
81
+ end
82
+ end
83
+
84
+ # @return [Array<String>] resources holding a successful token
85
+ def successful_resources
86
+ @mutex.synchronize { @tokens.keys }
87
+ end
88
+
89
+ # @return [Array<String>] resources with a recorded error
90
+ def failed_resources
91
+ @mutex.synchronize { @resource_errors.keys }
92
+ end
93
+
94
+ # Record a successful token, clearing any prior error for the resource.
95
+ # Called by the grant layer, not by handlers.
96
+ #
97
+ # @return [void]
98
+ def set_token(resource, token)
99
+ @mutex.synchronize do
100
+ @resource_errors.delete(resource)
101
+ @tokens[resource] = token
102
+ end
103
+ end
104
+
105
+ # Record multiple successful tokens at once.
106
+ #
107
+ # @param tokens [Hash{String => TokenResponse}]
108
+ # @return [void]
109
+ def set_bulk_tokens(tokens)
110
+ @mutex.synchronize do
111
+ tokens.each do |resource, token|
112
+ @resource_errors.delete(resource)
113
+ @tokens[resource] = token
114
+ end
115
+ end
116
+ end
117
+
118
+ # Record a per-resource error, clearing any prior token for the resource.
119
+ #
120
+ # @return [void]
121
+ def set_resource_error(resource, error)
122
+ @mutex.synchronize do
123
+ @tokens.delete(resource)
124
+ @resource_errors[resource] = error
125
+ end
126
+ end
127
+
128
+ # Record a global (context-wide) error.
129
+ #
130
+ # @return [void]
131
+ def set_error(error)
132
+ @mutex.synchronize { @error = error }
133
+ end
134
+
135
+ # Merge another context's tokens and errors into this one.
136
+ #
137
+ # @param other [AccessContext]
138
+ # @return [void]
139
+ def merge(other)
140
+ snapshot = other.errors
141
+ unless snapshot[:error]
142
+ set_bulk_tokens(other.successful_resources.to_h { |resource| [resource, other.access(resource)] })
143
+ end
144
+ snapshot[:resources].each { |resource, error| set_resource_error(resource, error) }
145
+ set_error(snapshot[:error]) if snapshot[:error]
146
+ end
147
+
148
+ private
149
+
150
+ def raise_access_error(error_type, resource, details: nil, available: [])
151
+ message = {
152
+ "global_error" => "a context-wide error is set",
153
+ "resource_error" => "the exchange for #{resource} failed",
154
+ "missing_token" => "no token was granted for #{resource}"
155
+ }.fetch(error_type)
156
+ raise ResourceAccessError.new(message, resource: resource, error_type: error_type,
157
+ error_details: details, available_resources: available)
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "socket"
6
+ require "uri"
7
+
8
+ module Keycardai
9
+ # The high-level authorization-code convenience: open the browser, receive
10
+ # the redirect on a loopback server (RFC 8252), exchange the code.
11
+ module OAuth
12
+ DEFAULT_CALLBACK_PORT = 8765
13
+ DEFAULT_CALLBACK_TIMEOUT = 300
14
+
15
+ # Run the full authorization-code + PKCE login flow: generate the PKCE
16
+ # pair and a CSRF state, build the authorize URL, open the user's
17
+ # browser, receive the redirect on a local loopback server, validate the
18
+ # state, and exchange the code for a token.
19
+ #
20
+ # @param issuer [String] the zone's issuer URL
21
+ # @param client_id [String]
22
+ # @param scope [String, nil] space-separated scopes
23
+ # @param resource [String, nil] RFC 8707 resource indicator
24
+ # @param port [Integer] loopback port; 0 binds an ephemeral port
25
+ # @param callback_timeout [Numeric] seconds to wait for the redirect
26
+ # @param client_secret [String, nil] confidential clients only
27
+ # @param verifier_length [Integer] PKCE verifier length, 43 to 128
28
+ # @param http_client [#get, #post_form] pluggable transport
29
+ # @param browser_opener [#call, nil] receives the authorize URL; defaults
30
+ # to the platform opener (open / xdg-open / cmd start), shell-free
31
+ # @param timeout [Numeric, nil] HTTP timeout for discovery and exchange
32
+ # @return [TokenResponse]
33
+ # @raise [InteractionTimeoutError] the user did not complete the redirect
34
+ # @raise [OAuthError] the authorization server denied the request
35
+ # @raise [ProtocolError] the redirect's state did not match (code state_mismatch)
36
+ def self.authenticate(issuer:, client_id:, scope: nil, resource: nil, port: DEFAULT_CALLBACK_PORT,
37
+ callback_timeout: DEFAULT_CALLBACK_TIMEOUT, client_secret: nil,
38
+ verifier_length: PKCE::DEFAULT_VERIFIER_LENGTH,
39
+ http_client: HTTP::NetHTTPClient.new, browser_opener: nil, timeout: nil)
40
+ authorization_endpoint = authorization_endpoint_for(issuer, http_client, timeout)
41
+ pair = PKCE.generate_pair(length: verifier_length)
42
+ state = SecureRandom.urlsafe_base64(24)
43
+
44
+ Loopback::CallbackServer.open(port: port) do |server|
45
+ open_authorize_page(server, authorization_endpoint, pair, state,
46
+ client_id: client_id, scope: scope, resource: resource,
47
+ browser_opener: browser_opener)
48
+ code = server.wait_for_code(state: state, timeout: callback_timeout)
49
+ exchange_authorization_code(
50
+ issuer,
51
+ code: code, code_verifier: pair.code_verifier, redirect_uri: server.redirect_uri,
52
+ client_id: client_id, client_secret: client_secret, resource: resource,
53
+ http_client: http_client, timeout: timeout
54
+ )
55
+ end
56
+ end
57
+
58
+ def self.authorization_endpoint_for(issuer, http_client, timeout)
59
+ metadata = fetch_authorization_server_metadata(issuer, http_client: http_client, timeout: timeout)
60
+ metadata.authorization_endpoint ||
61
+ raise(ProtocolError.new("metadata for #{issuer} has no authorization_endpoint", code: "invalid_metadata"))
62
+ end
63
+ private_class_method :authorization_endpoint_for
64
+
65
+ def self.open_authorize_page(server, endpoint, pair, state, client_id:, scope:, resource:, browser_opener:)
66
+ url = build_authorize_url(
67
+ endpoint,
68
+ client_id: client_id, redirect_uri: server.redirect_uri, code_challenge: pair.code_challenge,
69
+ code_challenge_method: pair.code_challenge_method, scope: scope, state: state, resource: resource
70
+ )
71
+ (browser_opener || Loopback.method(:open_browser)).call(url)
72
+ end
73
+ private_class_method :open_authorize_page
74
+
75
+ # Resolve the issuer from a resource's WWW-Authenticate challenge
76
+ # (RFC 9728): fetch the challenge's resource_metadata document and return
77
+ # its first authorization server.
78
+ #
79
+ # @param www_authenticate [String] the WWW-Authenticate header value
80
+ # @param http_client [#get] pluggable transport
81
+ # @param timeout [Numeric, nil]
82
+ # @return [String] the issuer URL
83
+ # @raise [ProtocolError] no resource_metadata parameter, or a metadata
84
+ # document without authorization_servers
85
+ def self.resolve_issuer_from_challenge(www_authenticate, http_client: HTTP::NetHTTPClient.new, timeout: nil)
86
+ metadata_url = www_authenticate.to_s[/resource_metadata="([^"]+)"/, 1]
87
+ unless metadata_url
88
+ raise ProtocolError.new("challenge carries no resource_metadata parameter", code: "invalid_metadata")
89
+ end
90
+
91
+ document = Loopback.fetch_resource_metadata(metadata_url, http_client, timeout)
92
+ issuer = Array(document["authorization_servers"]).first
93
+ issuer || raise(ProtocolError.new("resource metadata lists no authorization_servers", code: "invalid_metadata"))
94
+ end
95
+
96
+ # Challenge-driven entry to the login flow: resolve the issuer from a
97
+ # WWW-Authenticate challenge, then run authenticate against it.
98
+ #
99
+ # @param www_authenticate [String] the WWW-Authenticate header value
100
+ # @param options [Hash] forwarded to authenticate
101
+ # @return [TokenResponse]
102
+ def self.authenticate_from_challenge(www_authenticate, http_client: HTTP::NetHTTPClient.new, **options)
103
+ issuer = resolve_issuer_from_challenge(www_authenticate, http_client: http_client,
104
+ timeout: options[:timeout])
105
+ authenticate(issuer: issuer, http_client: http_client, **options)
106
+ end
107
+
108
+ # Internals of the loopback flow. Not public API.
109
+ module Loopback
110
+ SUCCESS_PAGE = "<html><body><p>Authentication complete. You can close this window.</p></body></html>"
111
+
112
+ module_function
113
+
114
+ def fetch_resource_metadata(url, http_client, timeout)
115
+ response = http_client.get(url, headers: { "Accept" => "application/json" }, timeout: timeout)
116
+ unless response.success?
117
+ raise HTTPError.new("resource metadata fetch returned HTTP #{response.status}",
118
+ status: response.status, body: response.body)
119
+ end
120
+
121
+ JSON.parse(response.body)
122
+ rescue JSON::ParserError
123
+ raise ProtocolError.new("resource metadata is not valid JSON", code: "invalid_metadata")
124
+ end
125
+
126
+ # Open the system browser without a shell.
127
+ def open_browser(url)
128
+ command = case RUBY_PLATFORM
129
+ when /darwin/ then ["open", url]
130
+ when /mswin|mingw/ then ["cmd", "/c", "start", "", url]
131
+ else ["xdg-open", url]
132
+ end
133
+ Process.spawn(*command, %i[out err] => File::NULL)
134
+ end
135
+
136
+ # A single-shot loopback HTTP server receiving the OAuth redirect.
137
+ class CallbackServer
138
+ PATH = "/callback"
139
+
140
+ # @return [String] the redirect URI registered for this flow
141
+ attr_reader :redirect_uri
142
+
143
+ def self.open(port:)
144
+ server = new(port: port)
145
+ begin
146
+ yield server
147
+ ensure
148
+ server.close
149
+ end
150
+ end
151
+
152
+ def initialize(port:)
153
+ @server = TCPServer.new("127.0.0.1", port)
154
+ @redirect_uri = "http://127.0.0.1:#{@server.addr[1]}#{PATH}"
155
+ end
156
+
157
+ def close
158
+ @server.close unless @server.closed?
159
+ end
160
+
161
+ # Wait for the redirect, validate its state, and return the code.
162
+ #
163
+ # @raise [InteractionTimeoutError] no redirect within the timeout
164
+ # @raise [ProtocolError] state mismatch (code state_mismatch)
165
+ # @raise [OAuthError] the redirect carried an OAuth error
166
+ def wait_for_code(state:, timeout:)
167
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
168
+ loop do
169
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ raise InteractionTimeoutError, "no redirect received within #{timeout}s" if remaining <= 0
171
+ next unless @server.wait_readable(remaining)
172
+
173
+ params = accept_redirect
174
+ next unless params
175
+
176
+ return validate(params, state)
177
+ end
178
+ end
179
+
180
+ private
181
+
182
+ def accept_redirect
183
+ client = @server.accept
184
+ request_line = client.gets.to_s
185
+ drain_headers(client)
186
+ client.write("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n" \
187
+ "Content-Length: #{SUCCESS_PAGE.bytesize}\r\nConnection: close\r\n\r\n#{SUCCESS_PAGE}")
188
+ client.close
189
+ parse_target(request_line)
190
+ end
191
+
192
+ def drain_headers(client)
193
+ loop do
194
+ line = client.gets
195
+ break if line.nil? || line.strip.empty?
196
+ end
197
+ end
198
+
199
+ def parse_target(request_line)
200
+ target = request_line.split[1].to_s
201
+ uri = URI("http://127.0.0.1#{target}")
202
+ return nil unless uri.path == PATH
203
+
204
+ URI.decode_www_form(uri.query.to_s).to_h
205
+ end
206
+
207
+ def validate(params, state)
208
+ if params["error"]
209
+ raise OAuthError.new("authorization was denied: #{params["error"]}",
210
+ error: params["error"], error_description: params["error_description"],
211
+ status: 400)
212
+ end
213
+ unless params["state"] == state
214
+ raise ProtocolError.new("redirect state does not match the authorization request",
215
+ code: "state_mismatch")
216
+ end
217
+
218
+ params["code"] || raise(ProtocolError.new("redirect carries no code", code: "invalid_response"))
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Keycardai
6
+ # Authorization-code grant building blocks (RFC 6749 §4.1 + RFC 7636): the
7
+ # authorize-URL builder and the back-channel code exchange.
8
+ module OAuth
9
+ # Build the authorization request URL.
10
+ #
11
+ # @param authorization_endpoint [String] from discovery
12
+ # @param client_id [String]
13
+ # @param redirect_uri [String]
14
+ # @param code_challenge [String] the PKCE challenge
15
+ # @param code_challenge_method [String] the method the challenge was derived with
16
+ # @param scope [String, nil] space-separated scopes
17
+ # @param state [String, nil] CSRF state value
18
+ # @param resource [String, nil] RFC 8707 resource indicator
19
+ # @return [String] the authorize URL
20
+ def self.build_authorize_url(authorization_endpoint, client_id:, redirect_uri:, code_challenge:,
21
+ code_challenge_method: "S256", scope: nil, state: nil, resource: nil)
22
+ params = {
23
+ "response_type" => "code",
24
+ "client_id" => client_id,
25
+ "redirect_uri" => redirect_uri,
26
+ "code_challenge" => code_challenge,
27
+ "code_challenge_method" => code_challenge_method,
28
+ "scope" => scope,
29
+ "state" => state,
30
+ "resource" => resource
31
+ }.compact
32
+
33
+ uri = URI(authorization_endpoint)
34
+ query = URI.encode_www_form(params)
35
+ uri.query = uri.query.nil? || uri.query.empty? ? query : "#{uri.query}&#{query}"
36
+ uri.to_s
37
+ end
38
+
39
+ # Exchange an authorization code for a token (RFC 6749 §4.1.3). A public
40
+ # client sends its client_id in the body; a confidential client
41
+ # authenticates with HTTP Basic and omits client_id from the body.
42
+ #
43
+ # @param issuer [String] the zone's issuer URL; supplies the token endpoint
44
+ # @param code [String] the authorization code from the redirect
45
+ # @param code_verifier [String] the PKCE verifier matching the challenge
46
+ # @param redirect_uri [String] must match the authorization request
47
+ # @param client_id [String, nil] public-client identifier
48
+ # @param client_secret [String, nil] confidential-client secret; requires client_id
49
+ # @param resource [String, nil] RFC 8707 resource indicator
50
+ # @param http_client [#get, #post_form] pluggable transport
51
+ # @param timeout [Numeric, nil]
52
+ # @return [TokenResponse]
53
+ # @raise [OAuthError] an RFC 6749 §5.2 error response (invalid_grant, ...)
54
+ # @raise [HTTPError, ProtocolError, NetworkError, ConfigurationError]
55
+ def self.exchange_authorization_code(issuer, code:, code_verifier:, redirect_uri:, client_id: nil,
56
+ client_secret: nil, resource: nil,
57
+ http_client: HTTP::NetHTTPClient.new, timeout: nil)
58
+ raise ConfigurationError, "client_secret requires client_id" if client_secret && client_id.nil?
59
+
60
+ metadata = fetch_authorization_server_metadata(issuer, http_client: http_client, timeout: timeout)
61
+ endpoint = metadata.token_endpoint ||
62
+ raise(ProtocolError.new("metadata for #{issuer} has no token_endpoint", code: "invalid_metadata"))
63
+
64
+ # A confidential client authenticates with Basic and omits client_id
65
+ # from the body; a public client carries client_id in the body.
66
+ params = {
67
+ "grant_type" => GrantType::AUTHORIZATION_CODE,
68
+ "code" => code,
69
+ "code_verifier" => code_verifier,
70
+ "redirect_uri" => redirect_uri,
71
+ "client_id" => client_secret ? nil : client_id,
72
+ "resource" => resource
73
+ }.compact
74
+ headers = { "Accept" => "application/json" }
75
+ headers["Authorization"] = HTTP.basic_authorization(client_id, client_secret) if client_secret
76
+
77
+ TokenRequests.parse_response(http_client.post_form(endpoint, params, headers: headers, timeout: timeout))
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keycardai
4
+ module OAuth
5
+ # OAuth 2.0 client_credentials grant (RFC 6749 §4.4): autonomous workload
6
+ # authentication. The client requests a token for itself, with no user in
7
+ # the loop, and authenticates with a shared secret (HTTP Basic) or a
8
+ # client assertion carried on the request.
9
+ #
10
+ # The token endpoint is discovered from the issuer on first use and
11
+ # cached. Requests do not retry transparently.
12
+ class ClientCredentialsClient
13
+ include TokenRequests
14
+
15
+ # @param issuer [String] the zone's issuer URL
16
+ # @param credential [Object, nil] a Basic-auth credential (ClientSecret);
17
+ # exclusive with client_id/client_secret. Assertion-based credentials
18
+ # supply their assertion through the request fields instead.
19
+ # @param client_id [String, nil] shared-secret client id (HTTP Basic)
20
+ # @param client_secret [String, nil] shared-secret client secret;
21
+ # provide both or neither
22
+ # @param http_client [#get, #post_form] pluggable transport
23
+ # @param timeout [Numeric, nil] request timeout in seconds
24
+ # @raise [ConfigurationError] when only one of client_id/client_secret is
25
+ # given, or a credential is combined with a raw pair
26
+ def initialize(issuer:, credential: nil, client_id: nil, client_secret: nil,
27
+ http_client: HTTP::NetHTTPClient.new, timeout: nil)
28
+ initialize_token_client(issuer: issuer, credential: credential, client_id: client_id,
29
+ client_secret: client_secret, http_client: http_client, timeout: timeout)
30
+ end
31
+
32
+ # Request a token for the client itself.
33
+ #
34
+ # @param scope [String, nil] space-separated scopes for the token
35
+ # @param resource [String, nil] RFC 8707 resource indicator
36
+ # @param client_assertion [String, nil] client-authentication assertion,
37
+ # form-encoded in the body
38
+ # @param client_assertion_type [String, nil]
39
+ # @return [TokenResponse]
40
+ # @raise [OAuthError] an RFC 6749 §5.2 error response (invalid_client,
41
+ # invalid_scope, ...)
42
+ # @raise [HTTPError, ProtocolError, NetworkError]
43
+ def request_token(scope: nil, resource: nil, client_assertion: nil, client_assertion_type: nil,
44
+ issuer: nil)
45
+ post_token_request(
46
+ {
47
+ "grant_type" => GrantType::CLIENT_CREDENTIALS,
48
+ "scope" => scope,
49
+ "resource" => resource,
50
+ "client_assertion" => client_assertion,
51
+ "client_assertion_type" => client_assertion_type
52
+ },
53
+ issuer: issuer || @issuer
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end