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
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ require_relative '../configuration'
7
+ require_relative '../errors'
8
+ require_relative '../http_client'
9
+ require_relative '../metadata'
10
+ require_relative '../validated_uri'
11
+
12
+ module AuthProto
13
+ module Metadata
14
+
15
+ #
16
+ # Handles fetching and validation of AT Protocol OAuth Authorization Server metadata.
17
+ # An Authorization Server in atproto can be either a PDS instance or a separate "entryway" server
18
+ # that handles authentication for multiple PDS instances.
19
+ #
20
+ # The Authorization Server metadata is fetched from the well-known endpoint
21
+ # /.well-known/oauth-authorization-server and must conform to RFC 8414 plus additional
22
+ # requirements specific to the AT Protocol OAuth profile.
23
+ #
24
+ # @example Fetching and validating Authorization Server metadata
25
+ # begin
26
+ # auth_server = AuthProto::Metadata::AuthorizationServer.from_url("https://auth.example.com")
27
+ # puts "Authorization endpoint: #{auth_server.authorization_endpoint}"
28
+ # puts "Supported scopes: #{auth_server.scopes_supported}"
29
+ # rescue AuthProto::InvalidAuthorizationServer => e
30
+ # puts "Failed to validate authorization server: #{e.message}"
31
+ # end
32
+ #
33
+ # @see https://atproto.com/specs/oauth#authorization-servers Documentation of Authorization Server requirements
34
+ #
35
+
36
+ class AuthorizationServer
37
+
38
+ # Metadata keys that must be present before capability validation.
39
+ # @return [Array<String>]
40
+ REQUIRED_FIELDS = %w[
41
+ issuer
42
+ authorization_endpoint
43
+ token_endpoint
44
+ response_types_supported
45
+ grant_types_supported
46
+ code_challenge_methods_supported
47
+ token_endpoint_auth_methods_supported
48
+ token_endpoint_auth_signing_alg_values_supported
49
+ scopes_supported
50
+ authorization_response_iss_parameter_supported
51
+ require_pushed_authorization_requests
52
+ pushed_authorization_request_endpoint
53
+ dpop_signing_alg_values_supported
54
+ client_id_metadata_document_supported
55
+ ].freeze
56
+
57
+ # Authorization server issuer origin.
58
+ # @return [String]
59
+ attr_reader :issuer
60
+
61
+ # HTTPS browser authorization endpoint.
62
+ # @return [String]
63
+ attr_reader :authorization_endpoint
64
+
65
+ # HTTPS token exchange and refresh endpoint.
66
+ # @return [String]
67
+ attr_reader :token_endpoint
68
+
69
+ # HTTPS PAR endpoint.
70
+ # @return [String]
71
+ attr_reader :pushed_authorization_request_endpoint
72
+
73
+ # Supported response types, including `code`.
74
+ # @return [Array<String>]
75
+ attr_reader :response_types_supported
76
+
77
+ # Supported grant types, including authorization code and refresh token.
78
+ # @return [Array<String>]
79
+ attr_reader :grant_types_supported
80
+
81
+ # Supported PKCE methods, including `S256`.
82
+ # @return [Array<String>]
83
+ attr_reader :code_challenge_methods_supported
84
+
85
+ # Supported client authentication methods.
86
+ # @return [Array<String>]
87
+ attr_reader :token_endpoint_auth_methods_supported
88
+
89
+ # Supported client assertion algorithms.
90
+ # @return [Array<String>]
91
+ attr_reader :token_endpoint_auth_signing_alg_values_supported
92
+
93
+ # Supported scopes, including `atproto`.
94
+ # @return [Array<String>]
95
+ attr_reader :scopes_supported
96
+
97
+ # Supported DPoP algorithms, including `ES256`.
98
+ # @return [Array<String>]
99
+ attr_reader :dpop_signing_alg_values_supported
100
+
101
+ # Validates an authorization server's OAuth metadata.
102
+ #
103
+ # @param metadata [Hash{String => Object}] parsed discovery document with the fields in {REQUIRED_FIELDS} and required Boolean capabilities
104
+ # @raise [InvalidAuthorizationServer] if required capabilities, issuer or endpoint schemes are invalid
105
+ # @raise [URI::InvalidURIError] if a metadata URL cannot be parsed
106
+ #
107
+ def initialize(metadata)
108
+ validate_and_set_metadata(metadata)
109
+ end
110
+
111
+ # Fetches `/.well-known/oauth-authorization-server` using the configured transport.
112
+ # Requires HTTP 200 without redirects and a JSON object served as application/json.
113
+ #
114
+ # The document's issuer must exactly match the requested url.
115
+ #
116
+ # @param url [String] authorization server issuer origin
117
+ # @return [AuthorizationServer] validated discovery metadata
118
+ # @raise [InvalidAuthorizationServer] if HTTP transport fails, JSON is invalid, or metadata/issuer validation fails
119
+ # @raise [HTTPClient::SSRFError] if the transport rejects the initial destination
120
+ # @raise [URI::InvalidURIError] if the issuer or a metadata URI cannot be parsed
121
+ #
122
+ def self.from_url(url)
123
+ validate_source_url(url)
124
+
125
+ response = fetch_metadata(url)
126
+ validate_response(response)
127
+
128
+ metadata = parse_metadata(response[:body])
129
+ validate_issuer_matches(metadata['issuer'], url)
130
+
131
+ new(metadata)
132
+ end
133
+
134
+ # Exports metadata for serialization using symbol keys.
135
+ #
136
+ # Includes issuer/endpoints (String), capability lists (Array<String>), and the
137
+ # three required Boolean flags set to `true`. Use JSON serialization to obtain
138
+ # string keys suitable for passing back to {#initialize}.
139
+ #
140
+ # @return [Hash{Symbol => String, Array<String>, Boolean}] authorization server metadata
141
+ #
142
+ def to_h
143
+ {
144
+ issuer: issuer,
145
+ authorization_endpoint: authorization_endpoint,
146
+ token_endpoint: token_endpoint,
147
+ pushed_authorization_request_endpoint: pushed_authorization_request_endpoint,
148
+ response_types_supported: response_types_supported,
149
+ grant_types_supported: grant_types_supported,
150
+ code_challenge_methods_supported: code_challenge_methods_supported,
151
+ token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
152
+ token_endpoint_auth_signing_alg_values_supported: token_endpoint_auth_signing_alg_values_supported,
153
+ scopes_supported: scopes_supported,
154
+ dpop_signing_alg_values_supported: dpop_signing_alg_values_supported,
155
+ authorization_response_iss_parameter_supported: true,
156
+ require_pushed_authorization_requests: true,
157
+ client_id_metadata_document_supported: true
158
+ }
159
+ end
160
+
161
+
162
+ private
163
+
164
+ def validate_and_set_metadata(data)
165
+ REQUIRED_FIELDS.each do |field|
166
+ raise InvalidAuthorizationServer, "#{field} is required" unless data[field]
167
+ end
168
+
169
+ @issuer = validate_issuer(data['issuer'])
170
+
171
+ # Endpoint strings
172
+ @authorization_endpoint = validate_https_url(data['authorization_endpoint'])
173
+ @token_endpoint = validate_https_url(data['token_endpoint'])
174
+ @pushed_authorization_request_endpoint = validate_https_url(data['pushed_authorization_request_endpoint'])
175
+
176
+ # Capability lists
177
+ @response_types_supported = data['response_types_supported']
178
+ @grant_types_supported = data['grant_types_supported']
179
+ @code_challenge_methods_supported = data['code_challenge_methods_supported']
180
+ @token_endpoint_auth_methods_supported = data['token_endpoint_auth_methods_supported']
181
+ @token_endpoint_auth_signing_alg_values_supported = data['token_endpoint_auth_signing_alg_values_supported']
182
+ @scopes_supported = data['scopes_supported']
183
+ @dpop_signing_alg_values_supported = data['dpop_signing_alg_values_supported']
184
+
185
+ validate_response_types
186
+ validate_grant_types
187
+ validate_code_challenge_methods
188
+ validate_token_endpoint_auth_methods
189
+ validate_token_endpoint_auth_signing_algs
190
+ validate_dpop_signing_algs
191
+ validate_scopes
192
+
193
+ # Required boolean fields
194
+ validate_boolean_field(data, 'authorization_response_iss_parameter_supported', true)
195
+ validate_boolean_field(data, 'require_pushed_authorization_requests', true)
196
+ validate_boolean_field(data, 'client_id_metadata_document_supported', true)
197
+
198
+ if data.key?('require_request_uri_registration')
199
+ validate_boolean_field(data, 'require_request_uri_registration', true)
200
+ end
201
+ end
202
+
203
+ def validate_issuer(issuer)
204
+ uri = ValidatedURI.new(issuer)
205
+
206
+ is_valid = (
207
+ uri.scheme == 'https' && (!uri.specified_port || uri.effective_port != 443) &&
208
+ uri.path.empty? && !uri.query && !uri.fragment && !uri.userinfo
209
+ )
210
+
211
+ raise InvalidAuthorizationServer, "invalid issuer URL format" unless is_valid
212
+
213
+ issuer
214
+ end
215
+
216
+ def validate_https_url(url)
217
+ uri = ValidatedURI.new(url)
218
+ raise InvalidAuthorizationServer, "URL must use HTTPS" unless uri.scheme == 'https'
219
+ raise InvalidAuthorizationServer, "Endpoint URL must not contain a fragment" if uri.fragment
220
+
221
+ url
222
+ end
223
+
224
+ def validate_response_types
225
+ validate_string_array(@response_types_supported, 'response_types_supported')
226
+
227
+ unless @response_types_supported.include?('code')
228
+ raise InvalidAuthorizationServer, "response_types_supported must include 'code'"
229
+ end
230
+ end
231
+
232
+ def validate_grant_types
233
+ validate_string_array(@grant_types_supported, 'grant_types_supported')
234
+
235
+ unless %w[authorization_code refresh_token] - @grant_types_supported == []
236
+ raise InvalidAuthorizationServer, "grant types should include 'authorization_code' and 'refresh_token'"
237
+ end
238
+ end
239
+
240
+ def validate_code_challenge_methods
241
+ validate_string_array(@code_challenge_methods_supported, 'code_challenge_methods_supported')
242
+
243
+ unless @code_challenge_methods_supported.include?(PKCE::CHALLENGE_METHOD_S256)
244
+ raise InvalidAuthorizationServer, "code_challenge_methods_supported must include S256"
245
+ end
246
+ end
247
+
248
+ def validate_token_endpoint_auth_methods
249
+ validate_string_array(@token_endpoint_auth_methods_supported, 'token_endpoint_auth_methods_supported')
250
+
251
+ unless %w[private_key_jwt none] - @token_endpoint_auth_methods_supported == []
252
+ raise InvalidAuthorizationServer, "auth methods must include 'private_key_jwt' and 'none'"
253
+ end
254
+ end
255
+
256
+ def validate_token_endpoint_auth_signing_algs
257
+ validate_string_array(
258
+ @token_endpoint_auth_signing_alg_values_supported, 'token_endpoint_auth_signing_alg_values_supported'
259
+ )
260
+
261
+ unless @token_endpoint_auth_signing_alg_values_supported.include?('ES256')
262
+ raise InvalidAuthorizationServer, "token_endpoint_auth_signing_alg_values_supported must include ES256"
263
+ end
264
+
265
+ if @token_endpoint_auth_signing_alg_values_supported.include?('none')
266
+ raise InvalidAuthorizationServer, "token_endpoint_auth_signing_alg_values_supported must not include 'none'"
267
+ end
268
+ end
269
+
270
+ def validate_dpop_signing_algs
271
+ validate_string_array(@dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported')
272
+
273
+ unless @dpop_signing_alg_values_supported.include?('ES256')
274
+ raise InvalidAuthorizationServer, "dpop_signing_alg_values_supported must include ES256"
275
+ end
276
+ end
277
+
278
+ def validate_scopes
279
+ validate_string_array(@scopes_supported, 'scopes_supported')
280
+
281
+ unless @scopes_supported.include?('atproto')
282
+ raise InvalidAuthorizationServer, "scopes_supported must include 'atproto'"
283
+ end
284
+ end
285
+
286
+ def validate_string_array(values, field)
287
+ unless values.is_a?(Array)
288
+ raise InvalidAuthorizationServer, "#{field} must be an array"
289
+ end
290
+
291
+ unless values.all? { |value| value.is_a?(String) }
292
+ raise InvalidAuthorizationServer, "#{field} must contain only strings"
293
+ end
294
+ end
295
+
296
+ def validate_boolean_field(metadata, field, required_value)
297
+ unless metadata[field] == required_value
298
+ raise InvalidAuthorizationServer, "#{field} must be #{required_value}"
299
+ end
300
+ end
301
+
302
+ class << self
303
+ private
304
+
305
+ def validate_source_url(url)
306
+ uri = ValidatedURI.new(url)
307
+
308
+ valid = (
309
+ uri.scheme == 'https' && (!uri.specified_port || uri.effective_port != 443) &&
310
+ uri.path.empty? && !uri.userinfo && !uri.query && !uri.fragment
311
+ )
312
+
313
+ raise InvalidAuthorizationServer, "Invalid authorization server URL" unless valid
314
+ rescue URI::InvalidURIError => e
315
+ raise InvalidAuthorizationServer, "Invalid authorization server URL: #{e.message}"
316
+ end
317
+
318
+ def fetch_metadata(url)
319
+ metadata_url = URI.join(url, "/.well-known/oauth-authorization-server")
320
+
321
+ AuthProto.configuration.http_client.get(metadata_url.to_s)
322
+ rescue HTTPClient::HTTPError => e
323
+ raise InvalidAuthorizationServer, "Failed to fetch authorization server metadata: #{e.message}"
324
+ end
325
+
326
+ def validate_response(response)
327
+ unless response[:status] == 200
328
+ raise InvalidAuthorizationServer, "Authorization server metadata must return HTTP 200"
329
+ end
330
+
331
+ content_type = response[:headers]["content-type"].to_s.split(";", 2).first.to_s.strip
332
+
333
+ unless content_type.casecmp?('application/json')
334
+ raise InvalidAuthorizationServer, "Authorization server metadata must use application/json"
335
+ end
336
+ end
337
+
338
+ def parse_metadata(body)
339
+ metadata = JSON.parse(body.to_s)
340
+
341
+ unless metadata.is_a?(Hash)
342
+ raise InvalidAuthorizationServer, "Authorization server metadata must be a JSON object"
343
+ end
344
+
345
+ metadata
346
+ rescue JSON::ParserError => e
347
+ raise InvalidAuthorizationServer, "Invalid JSON in authorization server metadata: #{e.message}"
348
+ end
349
+
350
+ def validate_issuer_matches(metadata_issuer, source_url)
351
+ return if metadata_issuer == source_url
352
+
353
+ raise InvalidAuthorizationServer, "issuer mismatch: #{metadata_issuer} != #{source_url}"
354
+ end
355
+ end
356
+ end
357
+ end
358
+ end