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