authproto 0.3.0.beta1

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 (115) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +31 -0
  4. data/.tm_properties +1 -0
  5. data/.yardopts +4 -0
  6. data/CHANGELOG.md +52 -0
  7. data/MIT-LICENSE.txt +21 -0
  8. data/README.md +304 -0
  9. data/Rakefile +8 -0
  10. data/SECURITY.md +16 -0
  11. data/examples/confidential_client/.gitignore +5 -0
  12. data/examples/confidential_client/Gemfile +14 -0
  13. data/examples/confidential_client/Gemfile.lock +88 -0
  14. data/examples/confidential_client/README.md +226 -0
  15. data/examples/confidential_client/app.rb +290 -0
  16. data/examples/confidential_client/config/client-metadata.example.json +25 -0
  17. data/examples/confidential_client/config.ru +4 -0
  18. data/examples/confidential_client/public/styles.css +70 -0
  19. data/examples/confidential_client/screenshots/screenshot-1-sign-in.png +0 -0
  20. data/examples/confidential_client/screenshots/screenshot-2-success.png +0 -0
  21. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  22. data/examples/confidential_client/views/authorized.erb +46 -0
  23. data/examples/confidential_client/views/index.erb +44 -0
  24. data/examples/confidential_client/views/layout.erb +11 -0
  25. data/examples/minisky_console/.gitignore +2 -0
  26. data/examples/minisky_console/Gemfile +12 -0
  27. data/examples/minisky_console/Gemfile.lock +131 -0
  28. data/examples/minisky_console/README.md +112 -0
  29. data/examples/minisky_console/app.rb +50 -0
  30. data/examples/minisky_console/bin/console +6 -0
  31. data/examples/minisky_console/config.ru +5 -0
  32. data/examples/minisky_console/localhost.rb +53 -0
  33. data/examples/minisky_console/production.rb +32 -0
  34. data/lib/authproto/client.rb +583 -0
  35. data/lib/authproto/client_metadata.rb +478 -0
  36. data/lib/authproto/configuration.rb +139 -0
  37. data/lib/authproto/dpop/client.rb +161 -0
  38. data/lib/authproto/dpop/key_manager.rb +223 -0
  39. data/lib/authproto/dpop/nonce_manager.rb +145 -0
  40. data/lib/authproto/dpop/proof_generator.rb +124 -0
  41. data/lib/authproto/dpop/stored_nonce.rb +37 -0
  42. data/lib/authproto/encryption.rb +250 -0
  43. data/lib/authproto/errors.rb +75 -0
  44. data/lib/authproto/http_client.rb +320 -0
  45. data/lib/authproto/insecure_http_client.rb +24 -0
  46. data/lib/authproto/localhost_server.rb +274 -0
  47. data/lib/authproto/minisky.rb +273 -0
  48. data/lib/authproto/par/client.rb +240 -0
  49. data/lib/authproto/par/client_assertion.rb +69 -0
  50. data/lib/authproto/par/request.rb +243 -0
  51. data/lib/authproto/par/response.rb +43 -0
  52. data/lib/authproto/par.rb +48 -0
  53. data/lib/authproto/pkce.rb +125 -0
  54. data/lib/authproto/serialization/base.rb +258 -0
  55. data/lib/authproto/serialization/dpop_key.rb +38 -0
  56. data/lib/authproto/serialization/session.rb +88 -0
  57. data/lib/authproto/serialization/stored_nonce.rb +46 -0
  58. data/lib/authproto/serialization/token_set.rb +54 -0
  59. data/lib/authproto/server_metadata/authorization_server.rb +267 -0
  60. data/lib/authproto/server_metadata/origin_url.rb +71 -0
  61. data/lib/authproto/server_metadata/resource_server.rb +98 -0
  62. data/lib/authproto/server_metadata.rb +28 -0
  63. data/lib/authproto/state/session.rb +168 -0
  64. data/lib/authproto/state/session_manager.rb +162 -0
  65. data/lib/authproto/state/token_set.rb +101 -0
  66. data/lib/authproto/state.rb +61 -0
  67. data/lib/authproto/storage/interface.rb +121 -0
  68. data/lib/authproto/storage/key_builder.rb +76 -0
  69. data/lib/authproto/storage/memory.rb +222 -0
  70. data/lib/authproto/storage/redis.rb +156 -0
  71. data/lib/authproto/storage/sqlite.rb +281 -0
  72. data/lib/authproto/token/refresh.rb +414 -0
  73. data/lib/authproto/version.rb +8 -0
  74. data/lib/authproto.rb +18 -0
  75. data/sig/authproto/client.rbs +43 -0
  76. data/sig/authproto/client_metadata.rbs +113 -0
  77. data/sig/authproto/dependencies.rbs +19 -0
  78. data/sig/authproto/dpop/client.rbs +23 -0
  79. data/sig/authproto/dpop/key_manager.rbs +26 -0
  80. data/sig/authproto/dpop/nonce_manager.rbs +23 -0
  81. data/sig/authproto/dpop/proof_generator.rbs +40 -0
  82. data/sig/authproto/dpop/stored_nonce.rbs +10 -0
  83. data/sig/authproto/encryption.rbs +42 -0
  84. data/sig/authproto/http_client.rbs +45 -0
  85. data/sig/authproto/insecure_http_client.rbs +8 -0
  86. data/sig/authproto/localhost_server.rbs +30 -0
  87. data/sig/authproto/minisky_oauth.rbs +22 -0
  88. data/sig/authproto/par/client.rbs +25 -0
  89. data/sig/authproto/par/client_assertion.rbs +10 -0
  90. data/sig/authproto/par/request.rbs +73 -0
  91. data/sig/authproto/par/response.rbs +17 -0
  92. data/sig/authproto/par.rbs +7 -0
  93. data/sig/authproto/pkce.rbs +24 -0
  94. data/sig/authproto/serialization/base.rbs +36 -0
  95. data/sig/authproto/serialization/dpop_key.rbs +14 -0
  96. data/sig/authproto/serialization/session.rbs +19 -0
  97. data/sig/authproto/serialization/stored_nonce.rbs +14 -0
  98. data/sig/authproto/serialization/token_set.rbs +14 -0
  99. data/sig/authproto/server_metadata/authorization_server.rbs +71 -0
  100. data/sig/authproto/server_metadata/origin_url.rbs +21 -0
  101. data/sig/authproto/server_metadata/resource_server.rbs +27 -0
  102. data/sig/authproto/state/session.rbs +28 -0
  103. data/sig/authproto/state/session_manager.rbs +15 -0
  104. data/sig/authproto/state/token_set.rbs +40 -0
  105. data/sig/authproto/state.rbs +6 -0
  106. data/sig/authproto/storage/interface.rbs +20 -0
  107. data/sig/authproto/storage/key_builder.rbs +16 -0
  108. data/sig/authproto/storage/memory.rbs +22 -0
  109. data/sig/authproto/storage/redis.rbs +19 -0
  110. data/sig/authproto/storage/redis_client.rbs +11 -0
  111. data/sig/authproto/storage/sqlite.rbs +26 -0
  112. data/sig/authproto/token/refresh.rbs +47 -0
  113. data/sig/authproto/version.rbs +3 -0
  114. data/sig/authproto.rbs +54 -0
  115. metadata +200 -0
@@ -0,0 +1,478 @@
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
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ require_relative 'errors'
6
+ require_relative 'storage/interface'
7
+
8
+ module AuthProto
9
+ class << self
10
+
11
+ # Global settings object, replaced without validation.
12
+ #
13
+ # @param value [Configuration] replacement global settings
14
+ # @return [Configuration]
15
+ #
16
+ attr_writer :configuration
17
+
18
+ # Returns the process-wide configuration, creating defaults on first access.
19
+ #
20
+ # The default storage is in memory; `http_client` is initially `nil` and must be
21
+ # configured before discovery or OAuth requests.
22
+ #
23
+ # @return [Configuration] current global settings
24
+ #
25
+ def configuration
26
+ @configuration ||= Configuration.new
27
+ end
28
+
29
+ # Yields the global configuration, validates it, and returns it.
30
+ #
31
+ # Changes remain on the global object even if validation fails.
32
+ #
33
+ # @yield [config] block that changes settings
34
+ # @yieldparam config [Configuration] mutable global settings
35
+ # @yieldreturn [Object] ignored
36
+ # @return [Configuration] validated global settings
37
+ # @raise [ConfigurationError] if the storage or HTTP client is invalid
38
+ # @example Configure the default transport
39
+ # AuthProto.configure do |config|
40
+ # config.http_client = AuthProto::HTTPClient.new
41
+ # end
42
+ #
43
+ def configure
44
+ yield(configuration)
45
+ configuration.validate
46
+ configuration
47
+ end
48
+
49
+ # Gets the configured storage backend
50
+ # @return [Storage::Interface] The configured storage implementation
51
+ def storage
52
+ configuration.storage
53
+ end
54
+
55
+ # Replaces the global configuration with a fresh set of defaults.
56
+ # Existing client instances and their dependencies are not rebuilt.
57
+ #
58
+ # @return [Configuration] new global settings
59
+ #
60
+ def reset_configuration
61
+ @configuration = Configuration.new
62
+ end
63
+ end
64
+
65
+ # Raised when global storage or HTTP transport settings are invalid.
66
+ class ConfigurationError < Error; end
67
+
68
+ #
69
+ # Configuration class for global AuthProto settings
70
+ #
71
+
72
+ class Configuration
73
+
74
+ # Configured default token lifetime in seconds (300); currently unused by token exchange.
75
+ # @return [Integer]
76
+ attr_accessor :default_token_lifetime
77
+
78
+ # Configured default nonce lifetime in seconds (300); nonce managers currently use their own TTL.
79
+ # @return [Integer]
80
+ attr_accessor :dpop_nonce_lifetime
81
+
82
+ # Reserved encryption setting; serializers currently create their own service.
83
+ # @return [Object, nil]
84
+ attr_accessor :encryption
85
+
86
+ # Transport used for discovery, PAR and token requests; initially `nil`.
87
+ # @return [#get, #post, nil]
88
+ attr_accessor :http_client
89
+
90
+ # Logger used for diagnostics; defaults to standard output.
91
+ # @return [Logger]
92
+ attr_accessor :logger
93
+
94
+ # Storage backend; defaults to a new {Storage::Memory}.
95
+ # @return [Storage::Interface]
96
+ attr_accessor :storage
97
+
98
+ # Creates default settings with in-memory storage and no HTTP transport.
99
+ #
100
+ def initialize
101
+ @default_token_lifetime = 300 # 5 minutes in seconds
102
+ @dpop_nonce_lifetime = 300 # 5 minutes in seconds
103
+ @encryption = nil
104
+ @http_client = nil
105
+ @logger = Logger.new($stdout)
106
+ @storage = AuthProto::Storage::Memory.new
107
+ end
108
+
109
+ # Checks that storage implements the interface and the transport supports GET/POST.
110
+ #
111
+ # A `nil` HTTP client is accepted, but cannot perform OAuth or discovery requests.
112
+ #
113
+ # @return [true] if validation succeeds
114
+ # @raise [ConfigurationError] if storage is absent/invalid or transport methods are missing
115
+ #
116
+ def validate
117
+ validate_storage
118
+ validate_http_client
119
+ true
120
+ end
121
+
122
+
123
+ private
124
+
125
+ def validate_storage
126
+ raise ConfigurationError, "Storage must be configured" if @storage.nil?
127
+ return if @storage.is_a?(AuthProto::Storage::Interface)
128
+
129
+ raise ConfigurationError, "Storage must implement Storage::Interface"
130
+ end
131
+
132
+ def validate_http_client
133
+ return if @http_client.nil? # Allow nil for testing
134
+ return if @http_client.respond_to?(:get) && @http_client.respond_to?(:post)
135
+
136
+ raise ConfigurationError, "HTTP client must implement get and post methods"
137
+ end
138
+ end
139
+ end