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
@@ -1,478 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'uri'
5
-
6
- require_relative 'configuration'
7
- require_relative 'errors'
8
-
9
- module AuthProto
10
-
11
- #
12
- # Supported OAuth application types used by {ClientMetadata}.
13
- #
14
-
15
- module ApplicationType
16
-
17
- # Application type for browser-based OAuth client applications
18
- WEB = "web"
19
-
20
- # Application type for native desktop or mobile OAuth client applications
21
- NATIVE = "native"
22
-
23
- # Supported application type values.
24
- # @return [Array<String>]
25
- ALL = [WEB, NATIVE].freeze
26
- end
27
-
28
- #
29
- # Handles validation and management of AT Protocol OAuth client metadata according to
30
- # the specification. This includes required fields like client_id and redirect URIs,
31
- # optional metadata like client name and logo, and authentication configuration for
32
- # confidential clients. Validates that all fields conform to the protocol's requirements,
33
- # including:
34
- #
35
- # - Application type (web/native) validation and redirect URI rules
36
- # - Required scopes and grant types
37
- # - JWKS configuration for confidential clients
38
- # - DPoP binding requirements
39
- # - URI scheme and format validation
40
- #
41
-
42
- class ClientMetadata
43
-
44
- # Client authentication method for confidential clients
45
- AUTH_METHOD_PRIVATE_KEY_JWT = "private_key_jwt"
46
-
47
- # Client authentication method value for public clients
48
- AUTH_METHOD_NONE = "none"
49
-
50
- # RFC 6749 scope-token characters, separated by single ASCII spaces.
51
- SCOPE_PATTERN = /\A[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*\z/
52
-
53
-
54
- # -- required fields --
55
-
56
- # Client metadata URL or localhost client identifier. **Required**.
57
- # @return [String]
58
- attr_reader :client_id
59
-
60
- # List of allowed grant types. ATProto requires `authorization_code` and supports `refresh_token`. **Required**.
61
- # @return [Array<String>]
62
- attr_reader :grant_types
63
-
64
- # List of supported response types. In ATProto, it must include `code`. **Required**.
65
- # @return [Array<String>]
66
- attr_reader :response_types
67
-
68
- # List of registered OAuth callback URIs. **Required**.
69
- # @return [Array<String>]
70
- attr_reader :redirect_uris
71
-
72
- # Space-separated list of supported OAuth scopes. Must include at least `atproto`. **Required**.
73
- # @return [String]
74
- attr_reader :scope
75
-
76
- # Client authentication method: `none` for public clients, or `private_key_jwt` for confidential ones.
77
- # Not technically required, but the reference PDS sets it to `client_secret_basic` if not set,
78
- # which we don't support.
79
- # @return [String]
80
- attr_reader :token_endpoint_auth_method
81
-
82
-
83
- # -- optional fields --
84
-
85
- # Application type: `web` (default) or `native`.
86
- # @return [String]
87
- attr_reader :application_type
88
-
89
- # Public name of the client application that is presented to the user, if supplied.
90
- # @return [String, nil]
91
- attr_reader :client_name
92
-
93
- # URL of the client application's home page, if supplied.
94
- # @return [String, nil]
95
- attr_reader :client_uri
96
-
97
- # URL to the client application's logo image, if supplied.
98
- # @return [String, nil]
99
- attr_reader :logo_uri
100
-
101
- # URL to human-readable terms of service of the client, if supplied.
102
- # @return [String, nil]
103
- attr_reader :tos_uri
104
-
105
- # URL to human-readable privacy policy of the client, if supplied.
106
- # @return [String, nil]
107
- attr_reader :policy_uri
108
-
109
- # Inline set of JWK keys for confidential clients, if supplied.
110
- # @return [Hash{String => Object}, nil]
111
- attr_reader :jwks
112
-
113
- # HTTPS URL to a set of JWK keys for confidential clients, if supplied.
114
- # @return [String, nil]
115
- attr_reader :jwks_uri
116
-
117
-
118
- # Validates string-keyed client metadata and exposes its fields.
119
- #
120
- # Requires `client_id`, `grant_types`, `response_types`, `redirect_uris`, `scope`,
121
- # `token_endpoint_auth_method`, and `dpop_bound_access_tokens: true`.
122
- # `application_type` defaults to `web`.
123
- # Confidential clients using `private_key_jwt` require `ES256` and a JWK set or URI.
124
- #
125
- # @param metadata [Hash{String => Object}] parsed OAuth client metadata
126
- # @raise [InvalidClientMetadata] if a required field or supported value is invalid
127
- # @raise [URI::InvalidURIError] if a metadata URI cannot be parsed
128
- #
129
- def initialize(metadata)
130
- validate_and_set_metadata(metadata)
131
- end
132
-
133
- # Fetches and validates a client metadata document using the configured transport.
134
- #
135
- # Requires HTTP 200 and application/json, without following redirects.
136
- # The document's `client_id` must equal the requested URL.
137
- #
138
- # @param url [String] HTTPS metadata URL, or localhost URL for development
139
- # @return [ClientMetadata] validated metadata
140
- # @raise [InvalidClientMetadata] if metadata is missing, invalid/mismatched, not JSON, or transport isn't configured
141
- # @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if the transport rejects/fails the request
142
- # @raise [URI::InvalidURIError] if a URL cannot be parsed
143
- #
144
- def self.from_url(url)
145
- uri = URI(url)
146
-
147
- unless uri.scheme == 'https' || (uri.scheme == 'http' && uri.host == 'localhost')
148
- raise InvalidClientMetadata, "client_id must use HTTPS except for localhost"
149
- end
150
-
151
- raise InvalidClientMetadata, "HTTP client not configured" unless AuthProto.configuration.http_client
152
-
153
- response = AuthProto.configuration.http_client.get(url, follow_redirects: false)
154
-
155
- unless response[:status] == 200
156
- raise InvalidClientMetadata, "expected HTTP 200 response for client metadata request, got #{response[:status]}"
157
- end
158
-
159
- content_type = (response[:headers] || {}).find { |name, _| name.downcase == 'content-type' }&.last
160
-
161
- unless content_type.to_s.split(';', 2).first.to_s.strip.downcase == 'application/json'
162
- raise InvalidClientMetadata, "client metadata Content-Type must be application/json"
163
- end
164
-
165
- raise InvalidClientMetadata, "client metadata request returned response with no body" if response[:body].nil?
166
-
167
- metadata = JSON.parse(response[:body])
168
- raise InvalidClientMetadata, "metadata must be a hash" unless metadata.is_a?(Hash)
169
-
170
- if metadata['client_id'] != url
171
- raise InvalidClientMetadata, "client_id mismatch: #{metadata['client_id']} != #{url}"
172
- end
173
-
174
- self.new(metadata)
175
- rescue JSON::ParserError => e
176
- raise InvalidClientMetadata, "Invalid JSON in client metadata: #{e.message}"
177
- end
178
-
179
- # Determines if the client is confidential (has authentication keys).
180
- # @return [Boolean] true if client is confidential.
181
- #
182
- def confidential?
183
- token_endpoint_auth_method == AUTH_METHOD_PRIVATE_KEY_JWT
184
- end
185
-
186
-
187
- private
188
-
189
- def validate_and_set_metadata(metadata)
190
- raise InvalidClientMetadata, "metadata must be a hash" unless metadata.is_a?(Hash)
191
-
192
- # Optional (defaults to "web"), but must be set before validating redirect URIs
193
- @application_type = validate_application_type(metadata["application_type"])
194
-
195
- # Required fields
196
- @client_id = validate_client_id(metadata["client_id"])
197
- @grant_types = validate_grant_types(metadata["grant_types"])
198
- @response_types = validate_response_types(metadata["response_types"])
199
- @redirect_uris = validate_redirect_uris(metadata["redirect_uris"])
200
- @scope = validate_scope(metadata["scope"])
201
-
202
- # Check dpop_bound_access_tokens
203
- validate_dpop(metadata)
204
-
205
- # Optional fields
206
- @client_name = validate_client_name(metadata["client_name"])
207
- @client_uri = validate_client_uri(metadata["client_uri"])
208
- @logo_uri = validate_https_uri(metadata["logo_uri"])
209
- @tos_uri = validate_https_uri(metadata["tos_uri"])
210
- @policy_uri = validate_https_uri(metadata["policy_uri"])
211
-
212
- # Check token_endpoint_auth_method and jwks
213
- validate_auth_methods(metadata)
214
- end
215
-
216
-
217
- # -- Required field validations --
218
-
219
- def validate_client_id(client_id)
220
- raise InvalidClientMetadata, "client_id is required" unless client_id
221
- raise InvalidClientMetadata, "client_id must be a string" unless client_id.is_a?(String)
222
-
223
- unless valid_https_client_id?(client_id) || valid_localhost_client_id?(client_id)
224
- raise InvalidClientMetadata, "client_id must be HTTPS or localhost HTTP URL"
225
- end
226
-
227
- client_id
228
- end
229
-
230
- def valid_https_client_id?(client_id)
231
- uri = URI(client_id)
232
- userinfo, port = URI.split(client_id).values_at(1, 3)
233
-
234
- uri.scheme == 'https' && uri.host.to_s != '' && port.nil? && userinfo.nil? && !uri.fragment
235
- rescue URI::InvalidURIError
236
- false
237
- end
238
-
239
- def valid_localhost_client_id?(client_id)
240
- uri = URI(client_id)
241
- userinfo, port = URI.split(client_id).values_at(1, 3)
242
-
243
- uri.scheme == 'http' && uri.host == 'localhost' && port.nil? && userinfo.nil? && !uri.fragment &&
244
- uri.path.to_s.chomp('/').empty?
245
- rescue URI::InvalidURIError
246
- false
247
- end
248
-
249
- def validate_grant_types(grant_types)
250
- raise InvalidClientMetadata, "grant_types is required" unless grant_types
251
- raise InvalidClientMetadata, "grant_types must be an array" unless grant_types.is_a?(Array)
252
-
253
- unless grant_types.include?('authorization_code')
254
- raise InvalidClientMetadata, "grant_types must include authorization_code"
255
- end
256
-
257
- valid_types = %w[authorization_code refresh_token]
258
-
259
- grant_types.each do |grant|
260
- raise InvalidClientMetadata, "unsupported grant type" unless valid_types.include?(grant)
261
- end
262
-
263
- grant_types
264
- end
265
-
266
- def validate_response_types(response_types)
267
- raise InvalidClientMetadata, "response_types is required" unless response_types
268
- raise InvalidClientMetadata, "response_types must be an array" unless response_types.is_a?(Array)
269
- raise InvalidClientMetadata, "response_types must include 'code'" unless response_types.include?("code")
270
-
271
- unless response_types.all? { |type| type.is_a?(String) }
272
- raise InvalidClientMetadata, "response_types must contain only strings"
273
- end
274
-
275
- response_types
276
- end
277
-
278
- def validate_redirect_uris(uris)
279
- raise InvalidClientMetadata, "redirect_uris is required" unless uris
280
- raise InvalidClientMetadata, "redirect_uris must be an array" unless uris.is_a?(Array)
281
- raise InvalidClientMetadata, "redirect_uris must not be empty" if uris.empty?
282
-
283
- uris.each { |uri| validate_redirect_uri(uri) }
284
- uris
285
- end
286
-
287
- def validate_redirect_uri(value)
288
- raise InvalidClientMetadata, "redirect_uris entries must be strings" unless value.is_a?(String)
289
-
290
- uri = URI(value)
291
- userinfo, port = URI.split(value).values_at(1, 3)
292
-
293
- raise InvalidClientMetadata, "redirect URI must be absolute" unless uri.absolute?
294
- raise InvalidClientMetadata, "redirect URI must not contain credentials" unless userinfo.nil?
295
- raise InvalidClientMetadata, "redirect URI must not contain a fragment" unless uri.fragment.nil?
296
-
297
- case application_type
298
- when ApplicationType::WEB
299
- validate_https_redirect_uri(uri, port)
300
- when ApplicationType::NATIVE
301
- validate_native_redirect_uri(uri, port)
302
- end
303
- rescue URI::InvalidURIError => e
304
- raise InvalidClientMetadata, "Invalid redirect URI: #{e.message}"
305
- end
306
-
307
- def validate_https_redirect_uri(uri, port)
308
- unless uri.scheme == 'https' && !uri.host.to_s.empty?
309
- raise InvalidClientMetadata, "redirect URI must use HTTPS with a hostname"
310
- end
311
-
312
- if port && uri.port == 443
313
- raise InvalidClientMetadata, "HTTPS redirect URI must not specify the default port"
314
- end
315
- end
316
-
317
- def validate_redirect_uri_origin(uri)
318
- client_uri = URI(@client_id)
319
-
320
- unless uri.origin.downcase == client_uri.origin.downcase
321
- raise InvalidClientMetadata, "redirect URI must match client_id origin"
322
- end
323
- end
324
-
325
- def validate_native_redirect_uri(uri, port)
326
- case uri.scheme
327
- when 'https'
328
- validate_https_redirect_uri(uri, port)
329
- validate_redirect_uri_origin(uri)
330
- when 'http'
331
- unless ["127.0.0.1", "[::1]"].include?(uri.host)
332
- raise InvalidClientMetadata, "HTTP redirect URIs for native clients must use loopback IP"
333
- end
334
- else
335
- validate_custom_scheme(uri)
336
- end
337
- end
338
-
339
- def validate_custom_scheme(uri)
340
- reversed_host = URI(@client_id).host.downcase.split('.').reverse.join('.')
341
-
342
- unless uri.scheme.include?('.') && uri.scheme == reversed_host
343
- raise InvalidClientMetadata, "custom scheme must match reversed client_id domain"
344
- end
345
-
346
- unless uri.host.nil? && uri.path && uri.path.start_with?('/')
347
- raise InvalidClientMetadata, "custom scheme redirect URI must use a single slash after the scheme"
348
- end
349
- end
350
-
351
- def validate_scope(scope)
352
- raise InvalidClientMetadata, "scope is required" unless scope
353
- raise InvalidClientMetadata, "scope must be a string" unless scope.is_a?(String)
354
- raise InvalidClientMetadata, "scope must contain valid tokens separated by single spaces" unless SCOPE_PATTERN.match?(scope)
355
-
356
- scope_values = scope.split(' ')
357
- raise InvalidClientMetadata, "atproto scope is required" unless scope_values.include?("atproto")
358
-
359
- scope
360
- end
361
-
362
- def validate_dpop(metadata)
363
- unless metadata["dpop_bound_access_tokens"] == true
364
- raise InvalidClientMetadata, "dpop_bound_access_tokens must be true"
365
- end
366
- end
367
-
368
-
369
- # -- Optional field validations --
370
-
371
- def validate_application_type(type)
372
- if ApplicationType::ALL.include?(type)
373
- type
374
- elsif type.nil?
375
- ApplicationType::WEB
376
- else
377
- raise InvalidClientMetadata, "application_type must be 'web' or 'native'"
378
- end
379
- end
380
-
381
- def validate_client_name(name)
382
- return if name.nil?
383
- raise InvalidClientMetadata, "client_name must be a string, if provided" unless name.is_a?(String)
384
-
385
- name
386
- end
387
-
388
- def validate_client_uri(uri)
389
- return if uri.nil?
390
- raise InvalidClientMetadata, "client_uri must be a string" unless uri.is_a?(String)
391
-
392
- parsed = URI(uri)
393
-
394
- unless %w[http https].include?(parsed.scheme)
395
- raise InvalidClientMetadata, "client_uri must use HTTP or HTTPS"
396
- end
397
-
398
- unless URI(uri).origin == URI(@client_id).origin
399
- raise InvalidClientMetadata, "client_uri must have the same hostname as client_id"
400
- end
401
-
402
- uri
403
- end
404
-
405
- def validate_https_uri(uri)
406
- return if uri.nil?
407
-
408
- raise InvalidClientMetadata, "URI must be a string" unless uri.is_a?(String)
409
-
410
- parsed = URI(uri)
411
- raise InvalidClientMetadata, "URI must use HTTPS" unless parsed.scheme == "https" && !parsed.host.to_s.empty?
412
-
413
- uri
414
- end
415
-
416
- def validate_jwks(jwks)
417
- raise InvalidClientMetadata, "jwks must be a hash" unless jwks.is_a?(Hash)
418
- raise InvalidClientMetadata, "jwks must have a 'keys' array" unless jwks['keys'].is_a?(Array)
419
-
420
- jwks['keys'].each_with_index do |key, index|
421
- raise InvalidClientMetadata, "jwks.keys.#{index} must be a hash" unless key.is_a?(Hash)
422
-
423
- if key.key?('key_ops') && !key['key_ops'].is_a?(Array)
424
- raise InvalidClientMetadata, "jwks.keys.#{index}.key_ops must be an array"
425
- end
426
-
427
- has_key_use_sig = (key['use'] == 'sig')
428
- has_key_ops_sign = (key['key_ops']&.include?('sign'))
429
-
430
- if !has_key_use_sig && !has_key_ops_sign
431
- raise InvalidClientMetadata, "jwks.keys.#{index} must have use='sig' or key_ops including 'sign'"
432
- end
433
-
434
- raise InvalidClientMetadata, "jwks.keys.#{index} must have a 'kid' field" unless key['kid']
435
- end
436
- end
437
-
438
-
439
- # -- Auth fields validations --
440
-
441
- def validate_auth_methods(metadata)
442
- @token_endpoint_auth_method = metadata["token_endpoint_auth_method"]
443
- @token_endpoint_auth_signing_alg = metadata["token_endpoint_auth_signing_alg"]
444
-
445
- raise InvalidClientMetadata, "token_endpoint_auth_method is required" if @token_endpoint_auth_method.nil?
446
-
447
- case @token_endpoint_auth_method
448
- when AUTH_METHOD_NONE
449
- validate_auth_public(metadata)
450
- when AUTH_METHOD_PRIVATE_KEY_JWT
451
- validate_auth_confidential(metadata)
452
- else
453
- raise InvalidClientMetadata, "unsupported token_endpoint_auth_method"
454
- end
455
- end
456
-
457
- def validate_auth_public(metadata)
458
- unless @token_endpoint_auth_signing_alg.nil?
459
- raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be unset for public clients"
460
- end
461
- end
462
-
463
- def validate_auth_confidential(metadata)
464
- unless @token_endpoint_auth_signing_alg == "ES256"
465
- raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be ES256"
466
- end
467
-
468
- @jwks = metadata["jwks"]
469
- @jwks_uri = metadata["jwks_uri"]
470
-
471
- raise InvalidClientMetadata, "cannot use both jwks and jwks_uri" if @jwks && @jwks_uri
472
- raise InvalidClientMetadata, "confidential clients must provide jwks or jwks_uri" unless @jwks || @jwks_uri
473
-
474
- validate_jwks(@jwks) if @jwks
475
- validate_https_uri(@jwks_uri) if @jwks_uri
476
- end
477
- end
478
- end