authproto 0.3.0.beta1 → 0.3.0.beta2
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/.yardopts +1 -0
- data/AGENTS.md +80 -0
- data/CHANGELOG.md +48 -7
- data/README.md +20 -0
- data/examples/confidential_client/.gitignore +2 -1
- data/examples/confidential_client/Gemfile.lock +5 -1
- data/examples/confidential_client/README.md +2 -2
- data/examples/confidential_client/app.rb +17 -17
- data/examples/confidential_client/config/{client-metadata.example.json → oauth-client-metadata.example.json} +0 -1
- data/examples/confidential_client/scripts/generate_keys.rb +15 -9
- data/examples/minisky_console/Gemfile.lock +2 -2
- data/examples/minisky_console/localhost.rb +8 -25
- data/examples/minisky_console/production.rb +1 -1
- data/lib/authproto/client.rb +150 -201
- data/lib/authproto/dpop/client.rb +12 -60
- data/lib/authproto/dpop/key_manager.rb +17 -17
- data/lib/authproto/dpop/nonce_manager.rb +5 -19
- data/lib/authproto/dpop/proof_generator.rb +27 -23
- data/lib/authproto/errors.rb +1 -1
- data/lib/authproto/http_client.rb +17 -31
- data/lib/authproto/localhost_server.rb +15 -6
- data/lib/authproto/metadata/authorization_server.rb +358 -0
- data/lib/authproto/metadata/client_metadata.rb +574 -0
- data/lib/authproto/metadata/resource_server.rb +137 -0
- data/lib/authproto/{server_metadata.rb → metadata.rb} +7 -7
- data/lib/authproto/minisky.rb +8 -10
- data/lib/authproto/par/client.rb +51 -55
- data/lib/authproto/par/client_assertion.rb +58 -15
- data/lib/authproto/par/request.rb +58 -140
- data/lib/authproto/par/response.rb +9 -10
- data/lib/authproto/par.rb +3 -5
- data/lib/authproto/pkce.rb +29 -106
- data/lib/authproto/serialization/session.rb +11 -11
- data/lib/authproto/serialization/token_set.rb +6 -6
- data/lib/authproto/state/session.rb +27 -31
- data/lib/authproto/state/session_manager.rb +2 -2
- data/lib/authproto/state/token_set.rb +4 -4
- data/lib/authproto/state.rb +3 -3
- data/lib/authproto/token/refresh.rb +35 -43
- data/lib/authproto/validated_uri.rb +57 -0
- data/lib/authproto/version.rb +1 -1
- data/lib/authproto.rb +1 -0
- data/misc/yard_attributes.rb +34 -0
- data/sig/authproto/client.rbs +9 -10
- data/sig/authproto/dpop/client.rbs +1 -8
- data/sig/authproto/dpop/nonce_manager.rbs +0 -1
- data/sig/authproto/dpop/proof_generator.rbs +5 -5
- data/sig/authproto/http_client.rbs +2 -2
- data/sig/authproto/{server_metadata → metadata}/authorization_server.rbs +18 -12
- data/sig/authproto/metadata/client_metadata.rbs +118 -0
- data/sig/authproto/metadata/resource_server.rbs +27 -0
- data/sig/authproto/minisky_oauth.rbs +5 -3
- data/sig/authproto/par/client.rbs +4 -6
- data/sig/authproto/par/client_assertion.rbs +2 -1
- data/sig/authproto/par/request.rbs +5 -19
- data/sig/authproto/par/response.rbs +1 -5
- data/sig/authproto/pkce.rbs +6 -16
- data/sig/authproto/serialization/session.rbs +2 -2
- data/sig/authproto/state/session.rbs +4 -7
- data/sig/authproto/state/session_manager.rbs +2 -2
- data/sig/authproto/token/refresh.rbs +4 -4
- data/sig/authproto/validated_uri.rbs +36 -0
- data/sig/authproto.rbs +5 -3
- metadata +13 -11
- data/lib/authproto/client_metadata.rb +0 -478
- data/lib/authproto/server_metadata/authorization_server.rb +0 -267
- data/lib/authproto/server_metadata/origin_url.rb +0 -71
- data/lib/authproto/server_metadata/resource_server.rb +0 -98
- data/sig/authproto/client_metadata.rbs +0 -113
- data/sig/authproto/server_metadata/origin_url.rbs +0 -21
- data/sig/authproto/server_metadata/resource_server.rbs +0 -27
|
@@ -15,6 +15,8 @@ module AuthProto
|
|
|
15
15
|
|
|
16
16
|
module DPoP
|
|
17
17
|
|
|
18
|
+
ERROR_USE_NONCE = "use_dpop_nonce"
|
|
19
|
+
|
|
18
20
|
# High-level client for managing DPoP operations. Integrates key management,
|
|
19
21
|
# proof generation, and nonce tracking to provide a complete DPoP client
|
|
20
22
|
# implementation according to RFC 9449.
|
|
@@ -60,23 +62,25 @@ module AuthProto
|
|
|
60
62
|
|
|
61
63
|
# Generates a fresh DPoP proof using a supplied or stored nonce for the origin.
|
|
62
64
|
#
|
|
63
|
-
# @param
|
|
64
|
-
# @param
|
|
65
|
+
# @param method [String] HTTP method, converted to uppercase
|
|
66
|
+
# @param uri [String] absolute HTTP(S) URL; callers should omit the query and fragment
|
|
65
67
|
# @param access_token [String, nil] token to bind with an `ath` claim
|
|
66
68
|
# @param nonce [String, nil] explicit nonce; `nil` looks up the origin's stored nonce
|
|
67
69
|
# @return [JOSE::SignedBinary] compact signed DPoP JWT
|
|
68
70
|
# @raise [Error] if URI parsing, nonce lookup or signing fails
|
|
69
71
|
#
|
|
70
|
-
def generate_proof(
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
def generate_proof(method:, uri:, access_token: nil, nonce: nil)
|
|
73
|
+
parsed_uri = URI(uri)
|
|
74
|
+
|
|
75
|
+
server_url = "#{parsed_uri.scheme}://#{parsed_uri.host}"
|
|
76
|
+
server_url += ":#{parsed_uri.port}" if parsed_uri.port != parsed_uri.default_port
|
|
73
77
|
|
|
74
78
|
# Use provided nonce or get one from the manager
|
|
75
79
|
nonce ||= @nonce_manager.get(server_url)
|
|
76
80
|
|
|
77
81
|
@proof_generator.generate(
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
method: method,
|
|
83
|
+
uri: uri,
|
|
80
84
|
nonce: nonce,
|
|
81
85
|
access_token: access_token
|
|
82
86
|
)
|
|
@@ -96,7 +100,7 @@ module AuthProto
|
|
|
96
100
|
return unless response_headers
|
|
97
101
|
|
|
98
102
|
# Look for DPoP-Nonce header (case insensitive)
|
|
99
|
-
nonce = response_headers.find { |k,
|
|
103
|
+
nonce = response_headers.find { |k, v| k.downcase == 'dpop-nonce' }&.last
|
|
100
104
|
return unless nonce
|
|
101
105
|
|
|
102
106
|
# Store new nonce for future requests
|
|
@@ -104,58 +108,6 @@ module AuthProto
|
|
|
104
108
|
rescue StandardError => e
|
|
105
109
|
raise Error, "Failed to process response: #{e.message}"
|
|
106
110
|
end
|
|
107
|
-
|
|
108
|
-
# Wraps a proof in an HTTP header hash.
|
|
109
|
-
#
|
|
110
|
-
# @param proof [String] signed DPoP JWT
|
|
111
|
-
# @return [Hash{String => String}] a single `DPoP` header
|
|
112
|
-
#
|
|
113
|
-
def request_headers(proof)
|
|
114
|
-
{
|
|
115
|
-
"DPoP" => proof
|
|
116
|
-
}
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
# Exports the current public signing key.
|
|
120
|
-
#
|
|
121
|
-
# @return [Hash{String => Object}] public JWK
|
|
122
|
-
# @raise [KeyManager::KeyError] if public-key export fails
|
|
123
|
-
#
|
|
124
|
-
def public_key
|
|
125
|
-
@key_manager.public_jwk
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# Exports the current DPoP key for persistence or inspection.
|
|
129
|
-
#
|
|
130
|
-
# @param include_private [Boolean] include the private key needed to restore the same identity
|
|
131
|
-
# @return [JOSE::Map] JWK fields; call `to_h` for a Ruby hash
|
|
132
|
-
# @raise [KeyManager::KeyError] if key export fails
|
|
133
|
-
#
|
|
134
|
-
def export_key(include_private: false)
|
|
135
|
-
@key_manager.to_jwk(include_private: include_private)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
private
|
|
140
|
-
|
|
141
|
-
def extract_nonce(headers)
|
|
142
|
-
# Headers can be hash with string or symbol keys, or http headers object
|
|
143
|
-
headers = headers.to_h if headers.respond_to?(:to_h)
|
|
144
|
-
|
|
145
|
-
# Try different common header key formats
|
|
146
|
-
nonce = headers["DPoP-Nonce"] || headers["dpop-nonce"] || headers[:dpop_nonce]
|
|
147
|
-
|
|
148
|
-
nonce&.strip
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def origin_for_uri(uri)
|
|
152
|
-
port = uri.port
|
|
153
|
-
port = nil if (uri.scheme == "https" && port == 443) || (uri.scheme == "http" && port == 80)
|
|
154
|
-
|
|
155
|
-
origin = "#{uri.scheme}://#{uri.host}"
|
|
156
|
-
origin = "#{origin}:#{port}" if port
|
|
157
|
-
origin
|
|
158
|
-
end
|
|
159
111
|
end
|
|
160
112
|
end
|
|
161
113
|
end
|
|
@@ -55,13 +55,13 @@ module AuthProto
|
|
|
55
55
|
|
|
56
56
|
# Create new map with all required properties
|
|
57
57
|
key_map = {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
'kty' => base_map['kty'],
|
|
59
|
+
'crv' => base_map['crv'],
|
|
60
|
+
'x' => base_map['x'],
|
|
61
|
+
'y' => base_map['y'],
|
|
62
|
+
'd' => base_map['d'],
|
|
63
|
+
'use' => 'sig',
|
|
64
|
+
'kid' => generate_kid(base_map)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
# Create new JWK with all properties
|
|
@@ -78,8 +78,8 @@ module AuthProto
|
|
|
78
78
|
def public_jwk
|
|
79
79
|
jwk = @keypair.to_public.to_map.to_h
|
|
80
80
|
# If somehow the properties aren't set, add them
|
|
81
|
-
jwk[
|
|
82
|
-
jwk[
|
|
81
|
+
jwk['use'] ||= 'sig'
|
|
82
|
+
jwk['kid'] ||= generate_kid(jwk)
|
|
83
83
|
jwk
|
|
84
84
|
rescue StandardError => e
|
|
85
85
|
raise KeyError, "Failed to export public key: #{e.message}"
|
|
@@ -111,7 +111,7 @@ module AuthProto
|
|
|
111
111
|
payload = deep_stringify_keys(payload)
|
|
112
112
|
|
|
113
113
|
# Configure JOSE to use ES256 for signing
|
|
114
|
-
signing_config = {
|
|
114
|
+
signing_config = { 'alg' => 'ES256' }
|
|
115
115
|
|
|
116
116
|
# Merge our header with JOSE's required fields
|
|
117
117
|
full_header = header.merge(signing_config)
|
|
@@ -190,11 +190,11 @@ module AuthProto
|
|
|
190
190
|
def generate_kid(jwk)
|
|
191
191
|
# Generate a key ID based on the key's components
|
|
192
192
|
components = [
|
|
193
|
-
jwk[
|
|
194
|
-
jwk[
|
|
195
|
-
jwk[
|
|
196
|
-
jwk[
|
|
197
|
-
].join(
|
|
193
|
+
jwk['kty'],
|
|
194
|
+
jwk['crv'],
|
|
195
|
+
jwk['x'],
|
|
196
|
+
jwk['y']
|
|
197
|
+
].join(':')
|
|
198
198
|
|
|
199
199
|
# Create a SHA-256 hash and take first 8 bytes
|
|
200
200
|
digest = OpenSSL::Digest::SHA256.digest(components)
|
|
@@ -208,11 +208,11 @@ module AuthProto
|
|
|
208
208
|
raise KeyError, "Invalid key type: #{@keypair.kty}, must be :ec" unless @keypair.kty.is_a?(JOSE::JWK::KTY_EC)
|
|
209
209
|
|
|
210
210
|
# Verify the curve
|
|
211
|
-
curve = @keypair.to_map[
|
|
211
|
+
curve = @keypair.to_map['crv']
|
|
212
212
|
raise KeyError, "Invalid curve: #{curve}, must be #{CURVE}" unless curve == CURVE
|
|
213
213
|
|
|
214
214
|
# Verify we can perform basic operations
|
|
215
|
-
test_data =
|
|
215
|
+
test_data = 'test'
|
|
216
216
|
signed = sign(test_data)
|
|
217
217
|
raise KeyError, "Key validation failed: signature verification error" unless verify(signed)
|
|
218
218
|
rescue StandardError => e
|
|
@@ -6,6 +6,7 @@ require_relative '../configuration'
|
|
|
6
6
|
require_relative '../errors'
|
|
7
7
|
require_relative '../serialization/stored_nonce'
|
|
8
8
|
require_relative '../storage/key_builder'
|
|
9
|
+
require_relative '../validated_uri'
|
|
9
10
|
require_relative 'stored_nonce'
|
|
10
11
|
|
|
11
12
|
module AuthProto
|
|
@@ -94,27 +95,13 @@ module AuthProto
|
|
|
94
95
|
AuthProto.storage.delete(key)
|
|
95
96
|
end
|
|
96
97
|
|
|
97
|
-
# Checks for an unexpired nonce record in storage for a URL's origin.
|
|
98
|
-
#
|
|
99
|
-
# @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
|
|
100
|
-
# @return [Boolean] whether the backend reports a nonce record present
|
|
101
|
-
# @raise [NonceError] if the URL is invalid
|
|
102
|
-
# @raise [Storage::StorageError] if the existence check fails
|
|
103
|
-
#
|
|
104
|
-
def valid_nonce?(server_url)
|
|
105
|
-
validate_server_url(server_url)
|
|
106
|
-
origin = normalize_server_url(server_url)
|
|
107
|
-
key = Storage::KeyBuilder.nonce_key(origin)
|
|
108
|
-
AuthProto.storage.exists?(key)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
98
|
private
|
|
112
99
|
|
|
113
100
|
def normalize_server_url(url)
|
|
114
101
|
uri = URI(url)
|
|
115
102
|
port = uri.port
|
|
116
|
-
port = nil if (uri.scheme ==
|
|
117
|
-
(uri.scheme ==
|
|
103
|
+
port = nil if (uri.scheme == 'https' && port == 443) ||
|
|
104
|
+
(uri.scheme == 'http' && port == 80)
|
|
118
105
|
|
|
119
106
|
origin = "#{uri.scheme}://#{uri.host}"
|
|
120
107
|
origin = "#{origin}:#{port}" if port
|
|
@@ -130,11 +117,10 @@ module AuthProto
|
|
|
130
117
|
def validate_server_url(server_url)
|
|
131
118
|
raise NonceError, "server_url is required" if server_url.nil? || server_url.empty?
|
|
132
119
|
|
|
133
|
-
uri =
|
|
134
|
-
raise NonceError, "server_url must be HTTP(S)" unless uri.is_a?(URI::HTTP)
|
|
120
|
+
uri = ValidatedURI.new(server_url)
|
|
135
121
|
|
|
136
122
|
# Allow HTTP for localhost only
|
|
137
|
-
if uri.host !=
|
|
123
|
+
if uri.host != 'localhost' && uri.scheme != 'https'
|
|
138
124
|
raise NonceError, "server_url must be HTTPS (except for localhost)"
|
|
139
125
|
end
|
|
140
126
|
rescue URI::InvalidURIError => e
|
|
@@ -6,6 +6,7 @@ require 'securerandom'
|
|
|
6
6
|
require 'uri'
|
|
7
7
|
|
|
8
8
|
require_relative '../errors'
|
|
9
|
+
require_relative '../validated_uri'
|
|
9
10
|
require_relative 'key_manager'
|
|
10
11
|
|
|
11
12
|
module AuthProto
|
|
@@ -42,24 +43,24 @@ module AuthProto
|
|
|
42
43
|
# Builds and signs a fresh DPoP JWT with `jti`, `htm`, `htu` and `iat` claims.
|
|
43
44
|
#
|
|
44
45
|
# A nonce and access-token hash are added when requested. The URI normalizer removes
|
|
45
|
-
# fragments and default ports
|
|
46
|
+
# query strings, fragments and default ports.
|
|
46
47
|
#
|
|
47
|
-
# @param
|
|
48
|
-
# @param
|
|
48
|
+
# @param method [String] nonempty HTTP method, converted to uppercase
|
|
49
|
+
# @param uri [String] absolute HTTP(S) request URL
|
|
49
50
|
# @param nonce [String, nil] server-provided nonce to include
|
|
50
51
|
# @param access_token [String, nil] access token to bind to the proof
|
|
51
52
|
# @param ath [Boolean, nil] include an access-token hash; `nil` enables it when a token is supplied
|
|
52
53
|
# @return [JOSE::SignedBinary] compact signed DPoP JWT
|
|
53
54
|
# @raise [ProofError] if input validation or proof generation fails
|
|
54
55
|
#
|
|
55
|
-
def generate(
|
|
56
|
-
validate_inputs(
|
|
56
|
+
def generate(method:, uri:, nonce: nil, access_token: nil, ath: nil)
|
|
57
|
+
validate_inputs(method, uri)
|
|
57
58
|
ath = !access_token.nil? if ath.nil?
|
|
58
59
|
|
|
59
60
|
header = build_header
|
|
60
61
|
payload = build_payload(
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
method: method,
|
|
63
|
+
uri: uri,
|
|
63
64
|
nonce: nonce,
|
|
64
65
|
access_token: access_token,
|
|
65
66
|
include_ath: ath
|
|
@@ -72,46 +73,49 @@ module AuthProto
|
|
|
72
73
|
|
|
73
74
|
private
|
|
74
75
|
|
|
75
|
-
def validate_inputs(
|
|
76
|
-
raise ProofError, "
|
|
77
|
-
raise ProofError, "
|
|
76
|
+
def validate_inputs(method, uri)
|
|
77
|
+
raise ProofError, "method is required" if method.nil? || method.empty?
|
|
78
|
+
raise ProofError, "uri is required" if uri.nil? || uri.empty?
|
|
78
79
|
|
|
79
|
-
uri
|
|
80
|
-
raise ProofError, "invalid http_uri" unless uri.is_a?(URI::HTTP)
|
|
80
|
+
ValidatedURI.new(uri)
|
|
81
81
|
rescue URI::InvalidURIError => e
|
|
82
|
-
raise ProofError, "invalid
|
|
82
|
+
raise ProofError, "invalid uri: #{e.message}"
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
def build_header
|
|
86
86
|
{
|
|
87
|
-
typ:
|
|
88
|
-
alg:
|
|
87
|
+
typ: 'dpop+jwt',
|
|
88
|
+
alg: 'ES256',
|
|
89
89
|
jwk: key_manager.public_jwk.to_h
|
|
90
90
|
}
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
def build_payload(
|
|
93
|
+
def build_payload(method:, uri:, nonce: nil, access_token: nil, include_ath: nil)
|
|
94
94
|
payload = {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
'jti' => SecureRandom.uuid,
|
|
96
|
+
'htm' => method.upcase,
|
|
97
|
+
'htu' => normalize_uri(uri),
|
|
98
|
+
'iat' => Time.now.to_i
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
# Add the nonce if provided
|
|
102
|
-
payload[
|
|
102
|
+
payload['nonce'] = nonce if nonce
|
|
103
103
|
|
|
104
104
|
# Add access token hash if needed
|
|
105
|
-
payload[
|
|
105
|
+
payload['ath'] = generate_access_token_hash(access_token) if access_token && include_ath
|
|
106
106
|
|
|
107
107
|
payload
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def normalize_uri(uri)
|
|
111
111
|
uri = URI(uri)
|
|
112
|
+
|
|
112
113
|
# Remove default ports
|
|
113
|
-
uri.port = nil if (uri.scheme ==
|
|
114
|
+
uri.port = nil if (uri.scheme == 'https' && uri.port == 443) || (uri.scheme == 'http' && uri.port == 80)
|
|
115
|
+
|
|
116
|
+
uri.query = nil
|
|
114
117
|
uri.fragment = nil
|
|
118
|
+
|
|
115
119
|
uri.to_s
|
|
116
120
|
end
|
|
117
121
|
|
data/lib/authproto/errors.rb
CHANGED
|
@@ -58,7 +58,7 @@ module AuthProto
|
|
|
58
58
|
#
|
|
59
59
|
# @example Handling authorization server errors
|
|
60
60
|
# begin
|
|
61
|
-
# server = AuthProto::
|
|
61
|
+
# server = AuthProto::Metadata::AuthorizationServer.from_url("https://auth.example.com")
|
|
62
62
|
# rescue AuthProto::InvalidAuthorizationServer => e
|
|
63
63
|
# puts "Failed to validate authorization server: #{e.message}"
|
|
64
64
|
# end
|
|
@@ -7,6 +7,7 @@ require 'resolv'
|
|
|
7
7
|
require 'uri'
|
|
8
8
|
|
|
9
9
|
require_relative 'errors'
|
|
10
|
+
require_relative 'validated_uri'
|
|
10
11
|
|
|
11
12
|
module AuthProto
|
|
12
13
|
|
|
@@ -96,20 +97,20 @@ module AuthProto
|
|
|
96
97
|
# Sends an HTTP GET request with destination validation and redirect handling.
|
|
97
98
|
#
|
|
98
99
|
# Uses HTTPS and rejects private/loopback destinations by default. Subclasses may
|
|
99
|
-
# relax these rules.
|
|
100
|
+
# relax these rules. When enabled, follows at most {MAX_REDIRECTS} redirects and checks size
|
|
100
101
|
# against {MAX_RESPONSE_SIZE} using `Content-Length`, or body bytes when absent.
|
|
101
102
|
# Non-2xx status codes are returned for the caller to interpret; they do not
|
|
102
103
|
# themselves raise an exception.
|
|
103
104
|
#
|
|
104
105
|
# @param url [String] absolute HTTPS request URL without a fragment
|
|
105
106
|
# @param headers [Hash{String => String}] additional request headers; can override the default `Cache-Control` value
|
|
106
|
-
# @param follow_redirects [Boolean] follow redirects
|
|
107
|
+
# @param follow_redirects [Boolean] follow redirects; defaults to false
|
|
107
108
|
# @return [Hash{Symbol => Integer, Hash{String => String}, String, nil}] `:status` (Integer), lowercase `:headers` (Hash), and `:body` (String or nil)
|
|
108
109
|
# @raise [SSRFError] if initial scheme, host, fragment or resolved address is rejected
|
|
109
110
|
# @raise [HTTPError] if transport, TLS, redirect validation or response size checks fail
|
|
110
111
|
# @raise [URI::InvalidURIError] if the initial URL cannot be parsed
|
|
111
112
|
#
|
|
112
|
-
def get(url, headers = {}, follow_redirects:
|
|
113
|
+
def get(url, headers = {}, follow_redirects: false)
|
|
113
114
|
uri = validate_uri(url)
|
|
114
115
|
validate_ip(uri)
|
|
115
116
|
|
|
@@ -123,11 +124,11 @@ module AuthProto
|
|
|
123
124
|
}
|
|
124
125
|
end
|
|
125
126
|
|
|
126
|
-
# Sends an HTTP POST request with destination validation
|
|
127
|
+
# Sends an HTTP POST request with destination validation, without following redirects.
|
|
127
128
|
#
|
|
128
129
|
# Uses HTTPS and rejects private/loopback destinations by default. Subclasses may
|
|
129
|
-
# relax these rules.
|
|
130
|
-
#
|
|
130
|
+
# relax these rules. Checks size against {MAX_RESPONSE_SIZE} using `Content-Length`,
|
|
131
|
+
# or body bytes when absent.
|
|
131
132
|
# Non-2xx status codes are returned for the caller to interpret; they do not
|
|
132
133
|
# themselves raise an exception.
|
|
133
134
|
#
|
|
@@ -157,17 +158,16 @@ module AuthProto
|
|
|
157
158
|
private
|
|
158
159
|
|
|
159
160
|
def validate_uri(url)
|
|
160
|
-
uri =
|
|
161
|
+
uri = ValidatedURI.new(url)
|
|
161
162
|
unless self.class::ALLOWED_SCHEMES.include?(uri.scheme)
|
|
162
163
|
raise SSRFError, "URL scheme must be one of: #{self.class::ALLOWED_SCHEMES.join(", ")}"
|
|
163
164
|
end
|
|
164
165
|
|
|
165
|
-
# Extract and validate host before any network activity
|
|
166
|
-
host = uri.host.to_s.strip
|
|
167
|
-
raise SSRFError, "URL must include host" if host.empty?
|
|
168
166
|
raise SSRFError, "URL must not include fragment" if uri.fragment
|
|
169
167
|
|
|
170
|
-
uri
|
|
168
|
+
uri.to_uri
|
|
169
|
+
rescue URI::InvalidURIError => e
|
|
170
|
+
raise SSRFError, e.message
|
|
171
171
|
end
|
|
172
172
|
|
|
173
173
|
def validate_ip(uri)
|
|
@@ -207,9 +207,9 @@ module AuthProto
|
|
|
207
207
|
FORBIDDEN_IP_RANGES.any? { |range| range.include?(ip) }
|
|
208
208
|
end
|
|
209
209
|
|
|
210
|
-
def make_request(uri, headers = {}, redirect_count = 0, follow_redirects:
|
|
210
|
+
def make_request(uri, headers = {}, redirect_count = 0, follow_redirects: false)
|
|
211
211
|
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
212
|
-
configure_http_client(http, ssl: uri.scheme ==
|
|
212
|
+
configure_http_client(http, ssl: uri.scheme == 'https')
|
|
213
213
|
|
|
214
214
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
215
215
|
add_security_headers(request, headers)
|
|
@@ -228,23 +228,15 @@ module AuthProto
|
|
|
228
228
|
raise HTTPError.new("Request failed: #{e.message}", nil)
|
|
229
229
|
end
|
|
230
230
|
|
|
231
|
-
def make_post_request(uri, body, headers = {}
|
|
231
|
+
def make_post_request(uri, body, headers = {})
|
|
232
232
|
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
233
|
-
configure_http_client(http, ssl: uri.scheme ==
|
|
233
|
+
configure_http_client(http, ssl: uri.scheme == 'https')
|
|
234
234
|
|
|
235
235
|
request = Net::HTTP::Post.new(uri.request_uri)
|
|
236
236
|
add_security_headers(request, headers)
|
|
237
237
|
request.body = body.is_a?(Hash) ? URI.encode_www_form(body) : body if body
|
|
238
238
|
|
|
239
|
-
|
|
240
|
-
handle_redirect(
|
|
241
|
-
original_uri: uri,
|
|
242
|
-
body: body,
|
|
243
|
-
method: :post,
|
|
244
|
-
response: response,
|
|
245
|
-
headers: headers,
|
|
246
|
-
redirect_count: redirect_count
|
|
247
|
-
)
|
|
239
|
+
http.request(request)
|
|
248
240
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
249
241
|
raise HTTPError.new("Request timeout: #{e.message}", nil)
|
|
250
242
|
rescue StandardError => e
|
|
@@ -269,11 +261,9 @@ module AuthProto
|
|
|
269
261
|
# Handle HTTP redirects
|
|
270
262
|
# kwargs can include:
|
|
271
263
|
# - original_uri: URI of the original request
|
|
272
|
-
# - method: HTTP method of the original request (:get or :post)
|
|
273
264
|
# - response: Net::HTTPResponse object
|
|
274
265
|
# - headers: Hash of headers from the original request
|
|
275
266
|
# - redirect_count: Number of redirects so far
|
|
276
|
-
# - body: Request body for POST requests
|
|
277
267
|
#
|
|
278
268
|
def handle_redirect(**kwargs)
|
|
279
269
|
response = kwargs[:response]
|
|
@@ -292,11 +282,7 @@ module AuthProto
|
|
|
292
282
|
redirect_count += 1
|
|
293
283
|
|
|
294
284
|
# Recursive call to handle the next redirect
|
|
295
|
-
|
|
296
|
-
make_post_request(location, kwargs[:body], kwargs[:headers], redirect_count)
|
|
297
|
-
else
|
|
298
|
-
make_request(location, kwargs[:headers], redirect_count)
|
|
299
|
-
end
|
|
285
|
+
make_request(location, kwargs[:headers], redirect_count, follow_redirects: true)
|
|
300
286
|
end
|
|
301
287
|
|
|
302
288
|
def validate_response(response)
|
|
@@ -5,6 +5,9 @@ require 'socket'
|
|
|
5
5
|
require 'timeout'
|
|
6
6
|
require 'uri'
|
|
7
7
|
|
|
8
|
+
require_relative 'metadata/client_metadata'
|
|
9
|
+
require_relative 'validated_uri'
|
|
10
|
+
|
|
8
11
|
module AuthProto
|
|
9
12
|
|
|
10
13
|
#
|
|
@@ -89,25 +92,31 @@ module AuthProto
|
|
|
89
92
|
# Accepts `localhost`, IPv4 loopback, or IPv6 loopback. The URI must have a valid
|
|
90
93
|
# port and no user information or fragment. Its path is used as the callback path.
|
|
91
94
|
#
|
|
92
|
-
# @param metadata [Hash{String, Symbol => Object}]
|
|
95
|
+
# @param metadata [Hash{String, Symbol => Object}, Metadata::ClientMetadata]
|
|
96
|
+
# metadata containing a nonempty `redirect_uris` array of strings
|
|
93
97
|
# @raise [ArgumentError] if the first redirect URI is missing, malformed or not a supported HTTP loopback URL
|
|
94
98
|
#
|
|
95
99
|
def initialize(metadata)
|
|
96
|
-
redirects =
|
|
100
|
+
redirects = if metadata.is_a?(Metadata::ClientMetadata)
|
|
101
|
+
metadata.redirect_uris
|
|
102
|
+
else
|
|
103
|
+
metadata['redirect_uris'] || metadata[:redirect_uris]
|
|
104
|
+
end
|
|
97
105
|
|
|
98
106
|
unless redirects.is_a?(Array) && redirects.first.is_a?(String)
|
|
99
107
|
raise ArgumentError, "metadata must contain a non-empty redirect_uris array"
|
|
100
108
|
end
|
|
101
109
|
|
|
102
|
-
|
|
110
|
+
uri = ValidatedURI.new(redirects.first)
|
|
111
|
+
@uri = uri.to_uri
|
|
103
112
|
|
|
104
|
-
unless
|
|
113
|
+
unless uri.scheme == 'http' && loopback_host?(uri.hostname)
|
|
105
114
|
raise ArgumentError, "The first redirect URI must be an HTTP loopback URL"
|
|
106
115
|
end
|
|
107
116
|
|
|
108
117
|
raise ArgumentError, "The redirect URI should have a valid port" unless @uri.port.between?(1, 65_535)
|
|
109
|
-
raise ArgumentError, "The redirect URI should not include user info"
|
|
110
|
-
raise ArgumentError, "The redirect URI should not include a URL fragment"
|
|
118
|
+
raise ArgumentError, "The redirect URI should not include user info" if uri.userinfo
|
|
119
|
+
raise ArgumentError, "The redirect URI should not include a URL fragment" if uri.fragment
|
|
111
120
|
|
|
112
121
|
@path = (@uri.path.empty?) ? "/" : @uri.path
|
|
113
122
|
rescue URI::InvalidURIError
|