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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -0
  3. data/AGENTS.md +80 -0
  4. data/CHANGELOG.md +48 -7
  5. data/README.md +20 -0
  6. data/examples/confidential_client/.gitignore +2 -1
  7. data/examples/confidential_client/Gemfile.lock +5 -1
  8. data/examples/confidential_client/README.md +2 -2
  9. data/examples/confidential_client/app.rb +17 -17
  10. data/examples/confidential_client/config/{client-metadata.example.json → oauth-client-metadata.example.json} +0 -1
  11. data/examples/confidential_client/scripts/generate_keys.rb +15 -9
  12. data/examples/minisky_console/Gemfile.lock +2 -2
  13. data/examples/minisky_console/localhost.rb +8 -25
  14. data/examples/minisky_console/production.rb +1 -1
  15. data/lib/authproto/client.rb +150 -201
  16. data/lib/authproto/dpop/client.rb +12 -60
  17. data/lib/authproto/dpop/key_manager.rb +17 -17
  18. data/lib/authproto/dpop/nonce_manager.rb +5 -19
  19. data/lib/authproto/dpop/proof_generator.rb +27 -23
  20. data/lib/authproto/errors.rb +1 -1
  21. data/lib/authproto/http_client.rb +17 -31
  22. data/lib/authproto/localhost_server.rb +15 -6
  23. data/lib/authproto/metadata/authorization_server.rb +358 -0
  24. data/lib/authproto/metadata/client_metadata.rb +574 -0
  25. data/lib/authproto/metadata/resource_server.rb +137 -0
  26. data/lib/authproto/{server_metadata.rb → metadata.rb} +7 -7
  27. data/lib/authproto/minisky.rb +8 -10
  28. data/lib/authproto/par/client.rb +51 -55
  29. data/lib/authproto/par/client_assertion.rb +58 -15
  30. data/lib/authproto/par/request.rb +58 -140
  31. data/lib/authproto/par/response.rb +9 -10
  32. data/lib/authproto/par.rb +3 -5
  33. data/lib/authproto/pkce.rb +29 -106
  34. data/lib/authproto/serialization/session.rb +11 -11
  35. data/lib/authproto/serialization/token_set.rb +6 -6
  36. data/lib/authproto/state/session.rb +27 -31
  37. data/lib/authproto/state/session_manager.rb +2 -2
  38. data/lib/authproto/state/token_set.rb +4 -4
  39. data/lib/authproto/state.rb +3 -3
  40. data/lib/authproto/token/refresh.rb +35 -43
  41. data/lib/authproto/validated_uri.rb +57 -0
  42. data/lib/authproto/version.rb +1 -1
  43. data/lib/authproto.rb +1 -0
  44. data/misc/yard_attributes.rb +34 -0
  45. data/sig/authproto/client.rbs +9 -10
  46. data/sig/authproto/dpop/client.rbs +1 -8
  47. data/sig/authproto/dpop/nonce_manager.rbs +0 -1
  48. data/sig/authproto/dpop/proof_generator.rbs +5 -5
  49. data/sig/authproto/http_client.rbs +2 -2
  50. data/sig/authproto/{server_metadata → metadata}/authorization_server.rbs +18 -12
  51. data/sig/authproto/metadata/client_metadata.rbs +118 -0
  52. data/sig/authproto/metadata/resource_server.rbs +27 -0
  53. data/sig/authproto/minisky_oauth.rbs +5 -3
  54. data/sig/authproto/par/client.rbs +4 -6
  55. data/sig/authproto/par/client_assertion.rbs +2 -1
  56. data/sig/authproto/par/request.rbs +5 -19
  57. data/sig/authproto/par/response.rbs +1 -5
  58. data/sig/authproto/pkce.rbs +6 -16
  59. data/sig/authproto/serialization/session.rbs +2 -2
  60. data/sig/authproto/state/session.rbs +4 -7
  61. data/sig/authproto/state/session_manager.rbs +2 -2
  62. data/sig/authproto/token/refresh.rbs +4 -4
  63. data/sig/authproto/validated_uri.rbs +36 -0
  64. data/sig/authproto.rbs +5 -3
  65. metadata +13 -11
  66. data/lib/authproto/client_metadata.rb +0 -478
  67. data/lib/authproto/server_metadata/authorization_server.rb +0 -267
  68. data/lib/authproto/server_metadata/origin_url.rb +0 -71
  69. data/lib/authproto/server_metadata/resource_server.rb +0 -98
  70. data/sig/authproto/client_metadata.rbs +0 -113
  71. data/sig/authproto/server_metadata/origin_url.rbs +0 -21
  72. 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 http_method [String] HTTP method, converted to uppercase
64
- # @param http_uri [String] absolute HTTP(S) URL; callers should omit the query and fragment
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(http_method:, http_uri:, access_token: nil, nonce: nil)
71
- uri = URI(http_uri)
72
- server_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
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
- http_method: http_method,
79
- http_uri: http_uri,
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, _| k.downcase == "dpop-nonce" }&.last
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
- "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)
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["use"] ||= "sig"
82
- jwk["kid"] ||= generate_kid(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 = { "alg" => "ES256" }
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["kty"],
194
- jwk["crv"],
195
- jwk["x"],
196
- jwk["y"]
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["crv"]
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 = "test"
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 == "https" && port == 443) ||
117
- (uri.scheme == "http" && port == 80)
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 = URI(server_url)
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 != "localhost" && uri.scheme != "https"
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; callers must remove query strings before calling.
46
+ # query strings, fragments and default ports.
46
47
  #
47
- # @param http_method [String] nonempty HTTP method, converted to uppercase
48
- # @param http_uri [String] absolute HTTP(S) request URL without a query string
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(http_method:, http_uri:, nonce: nil, access_token: nil, ath: nil)
56
- validate_inputs(http_method, http_uri)
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
- http_method: http_method,
62
- http_uri: http_uri,
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(http_method, http_uri)
76
- raise ProofError, "http_method is required" if http_method.nil? || http_method.empty?
77
- raise ProofError, "http_uri is required" if http_uri.nil? || http_uri.empty?
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 = URI(http_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 http_uri: #{e.message}"
82
+ raise ProofError, "invalid uri: #{e.message}"
83
83
  end
84
84
 
85
85
  def build_header
86
86
  {
87
- typ: "dpop+jwt",
88
- alg: "ES256",
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(http_method:, http_uri:, nonce: nil, access_token: nil, include_ath: nil)
93
+ def build_payload(method:, uri:, nonce: nil, access_token: nil, include_ath: nil)
94
94
  payload = {
95
- "jti" => SecureRandom.uuid,
96
- "htm" => http_method.upcase,
97
- "htu" => normalize_uri(http_uri),
98
- "iat" => Time.now.to_i
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["nonce"] = nonce if nonce
102
+ payload['nonce'] = nonce if nonce
103
103
 
104
104
  # Add access token hash if needed
105
- payload["ath"] = generate_access_token_hash(access_token) if access_token && include_ath
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 == "https" && uri.port == 443) || (uri.scheme == "http" && uri.port == 80)
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
 
@@ -58,7 +58,7 @@ module AuthProto
58
58
  #
59
59
  # @example Handling authorization server errors
60
60
  # begin
61
- # server = AuthProto::ServerMetadata::AuthorizationServer.from_issuer("https://auth.example.com")
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. Follows at most {MAX_REDIRECTS} redirects and checks size
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, or return the initial response when false
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: true)
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 and redirect handling.
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. Follows at most {MAX_REDIRECTS} redirects and checks size
130
- # against {MAX_RESPONSE_SIZE} using `Content-Length`, or body bytes when absent.
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 = URI(url)
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: true)
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 == "https")
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 = {}, redirect_count = 0)
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 == "https")
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
- response = http.request(request)
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
- if kwargs[:method] == :post
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}] metadata containing a nonempty `redirect_uris` array of strings
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 = metadata['redirect_uris'] || metadata[:redirect_uris]
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
- @uri = URI(redirects.first)
110
+ uri = ValidatedURI.new(redirects.first)
111
+ @uri = uri.to_uri
103
112
 
104
- unless @uri.is_a?(URI::HTTP) && @uri.scheme == "http" && loopback_host?(@uri.hostname)
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" unless !@uri.userinfo
110
- raise ArgumentError, "The redirect URI should not include a URL fragment" unless !@uri.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