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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE +9 -0
- data/README.md +100 -0
- data/lib/keycardai/oauth/access_context.rb +161 -0
- data/lib/keycardai/oauth/authenticate.rb +223 -0
- data/lib/keycardai/oauth/authorization_code.rb +80 -0
- data/lib/keycardai/oauth/client_credentials_client.rb +58 -0
- data/lib/keycardai/oauth/client_secret.rb +104 -0
- data/lib/keycardai/oauth/discovery.rb +109 -0
- data/lib/keycardai/oauth/errors.rb +140 -0
- data/lib/keycardai/oauth/exchange_tokens.rb +53 -0
- data/lib/keycardai/oauth/http.rb +89 -0
- data/lib/keycardai/oauth/jwks_keyring.rb +169 -0
- data/lib/keycardai/oauth/jwt_signer.rb +45 -0
- data/lib/keycardai/oauth/jwt_verifier.rb +146 -0
- data/lib/keycardai/oauth/pkce.rb +62 -0
- data/lib/keycardai/oauth/private_key.rb +110 -0
- data/lib/keycardai/oauth/registration.rb +121 -0
- data/lib/keycardai/oauth/substitute_user.rb +29 -0
- data/lib/keycardai/oauth/token_exchange_client.rb +117 -0
- data/lib/keycardai/oauth/token_requests.rb +99 -0
- data/lib/keycardai/oauth/token_sources.rb +169 -0
- data/lib/keycardai/oauth/token_types.rb +51 -0
- data/lib/keycardai/oauth/token_verifier.rb +104 -0
- data/lib/keycardai/oauth/version.rb +7 -0
- data/lib/keycardai/oauth/web_identity.rb +106 -0
- data/lib/keycardai/oauth/workload_identity.rb +104 -0
- data/lib/keycardai/oauth.rb +38 -0
- metadata +87 -0
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
|