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,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jose'
4
+ require 'json'
5
+ require 'monitor'
6
+
7
+ require_relative '../configuration'
8
+ require_relative '../errors'
9
+ require_relative '../http_client'
10
+ require_relative '../par'
11
+ require_relative '../par/client_assertion'
12
+ require_relative '../state/token_set'
13
+
14
+ module AuthProto
15
+
16
+ #
17
+ # Token error types and synchronized refresh-token exchange operations.
18
+ #
19
+
20
+ module Token
21
+
22
+ #
23
+ # Base error class for token-related errors
24
+ #
25
+
26
+ class Error < AuthProto::Error
27
+
28
+ # Kind of token associated with the failure.
29
+ # @return [String]
30
+ attr_reader :token_type
31
+
32
+ # Machine-readable token error code.
33
+ # @return [String]
34
+ attr_reader :error_code
35
+
36
+ # Whether retrying the operation may succeed.
37
+ # @return [Boolean]
38
+ attr_reader :retry_possible
39
+
40
+ # Creates a token error with structured retry information.
41
+ #
42
+ # @param message [String] human-readable failure description
43
+ # @param token_type [String] kind of token associated with the failure
44
+ # @param error_code [String] machine-readable failure code
45
+ # @param retry_possible [Boolean] whether retrying may succeed
46
+ #
47
+ def initialize(message, token_type:, error_code:, retry_possible: false)
48
+ @token_type = token_type
49
+ @error_code = error_code
50
+ @retry_possible = retry_possible
51
+ super(message)
52
+ end
53
+ end
54
+
55
+ #
56
+ # Raised for token expiry; its retry metadata permits a refresh/retry.
57
+ #
58
+
59
+ class ExpiredTokenError < Error
60
+
61
+ # Creates an error describing token expiry.
62
+ # @param token_type [String] kind of token that failed
63
+ #
64
+ def initialize(token_type:)
65
+ super(
66
+ "Token has expired",
67
+ token_type: token_type,
68
+ error_code: "token_expired",
69
+ retry_possible: true
70
+ )
71
+ end
72
+ end
73
+
74
+ #
75
+ # Raised when a token is structurally valid but has been invalidated or revoked
76
+ #
77
+
78
+ class InvalidTokenError < Error
79
+
80
+ # Creates an error describing token invalidation/revocation.
81
+ # @param token_type [String] kind of token that failed
82
+ #
83
+ def initialize(token_type:)
84
+ super(
85
+ "Token is invalid",
86
+ token_type: token_type,
87
+ error_code: "token_invalid",
88
+ retry_possible: false
89
+ )
90
+ end
91
+ end
92
+
93
+ #
94
+ # Raised during token refresh operations, includes retry information and server responses
95
+ #
96
+
97
+ class RefreshError < Error
98
+
99
+ # Creates a refresh error with retry metadata.
100
+ #
101
+ # @param message [String] failure description
102
+ # @param retry_possible [Boolean] whether a later retry may succeed
103
+ #
104
+ def initialize(message, retry_possible: true)
105
+ super(
106
+ message,
107
+ token_type: "refresh",
108
+ error_code: "refresh_failed",
109
+ retry_possible: retry_possible
110
+ )
111
+ end
112
+ end
113
+
114
+ #
115
+ # Handles token refresh operations with retry logic
116
+ #
117
+
118
+ class Refresh
119
+ include MonitorMixin
120
+
121
+ # Maximum attempts for one refresh operation.
122
+ # @return [Integer]
123
+ MAX_RETRIES = 3
124
+
125
+ # Initial retry delay in seconds before jitter.
126
+ # @return [Integer]
127
+ BASE_DELAY = 1
128
+
129
+ # Maximum retry delay in seconds before jitter.
130
+ # @return [Integer]
131
+ MAX_DELAY = 8
132
+
133
+ # Session containing the tokens to refresh.
134
+ # @return [State::Session]
135
+ attr_reader :session
136
+
137
+ # DPoP client used to sign refresh requests.
138
+ # @return [DPoP::Client]
139
+ attr_reader :dpop_client
140
+
141
+ # Server providing the token endpoint.
142
+ # @return [ServerMetadata::AuthorizationServer]
143
+ attr_reader :auth_server
144
+
145
+ # Client metadata used for optional confidential-client assertions.
146
+ # @return [ClientMetadata]
147
+ attr_reader :client_metadata
148
+
149
+ # Creates a synchronized refresh operation for an existing OAuth session.
150
+ #
151
+ # @param session [State::Session] session with an access and refresh token
152
+ # @param dpop_client [DPoP::Client] proof signer and nonce tracker
153
+ # @param auth_server [ServerMetadata::AuthorizationServer] server with a token endpoint
154
+ # @param client_metadata [ClientMetadata] client ID and authentication key configuration
155
+ #
156
+ def initialize(session:, dpop_client:, auth_server:, client_metadata:)
157
+ super() # Initialize MonitorMixin
158
+ @session = session
159
+ @dpop_client = dpop_client
160
+ @auth_server = auth_server
161
+ @attempt_count = 0
162
+ @client_metadata = client_metadata
163
+ end
164
+
165
+ # Exchanges a refresh token for a replacement token set.
166
+ #
167
+ # Retries eligible failures up to {MAX_RETRIES} times with exponential backoff and
168
+ # jitter, and handles a DPoP nonce challenge during token exchange. Does not assign
169
+ # the returned tokens to the session or persist them; the caller must do so.
170
+ #
171
+ # @return [State::TokenSet] validated replacement tokens
172
+ # @raise [RefreshError] if the session is not renewable, refresh fails, or retries are exhausted
173
+ #
174
+ def perform
175
+ synchronize do
176
+ raise RefreshError.new("No refresh token available", retry_possible: false) unless session.renewable?
177
+ raise RefreshError.new("No access token to refresh", retry_possible: false) if session.tokens.nil?
178
+
179
+ with_retries do
180
+ request_token_refresh
181
+ end
182
+ end
183
+ end
184
+
185
+
186
+ private
187
+
188
+ def with_retries
189
+ @attempt_count = 0
190
+ last_error = nil
191
+
192
+ while @attempt_count < MAX_RETRIES
193
+ begin
194
+ return yield
195
+ rescue RefreshError, HTTPClient::HTTPError => e
196
+ last_error = e
197
+ @attempt_count += 1
198
+
199
+ # Don't retry if error indicates retry not possible
200
+ raise e if e.respond_to?(:retry_possible) && !e.retry_possible
201
+
202
+ sleep calculate_delay if @attempt_count < MAX_RETRIES
203
+ end
204
+ end
205
+
206
+ # Reached max retries
207
+ raise RefreshError.new(
208
+ "Token refresh failed after #{MAX_RETRIES} attempts: #{last_error.message}",
209
+ retry_possible: false
210
+ )
211
+ end
212
+
213
+ def calculate_delay
214
+ # Exponential backoff with jitter
215
+ delay = [BASE_DELAY * (2**(@attempt_count - 1)), MAX_DELAY].min
216
+ delay + (rand * 0.5 * delay) # Add up to 50% jitter
217
+ end
218
+
219
+ def request_token_refresh
220
+ # Initial token request without nonce
221
+ response = make_token_request(session)
222
+
223
+ # Handle DPoP nonce requirement
224
+ if requires_dpop_nonce?(response)
225
+ # Extract and store nonce from error response
226
+ extract_dpop_nonce(response)
227
+ dpop_client.process_response(response[:headers], auth_server.issuer)
228
+
229
+ # Retry request with nonce
230
+ response = make_token_request(session)
231
+ end
232
+
233
+ handle_refresh_response(response)
234
+ end
235
+
236
+ def make_token_request(session)
237
+ # Generate proof
238
+ proof = dpop_client.generate_proof(
239
+ http_method: "POST",
240
+ http_uri: auth_server.token_endpoint
241
+ )
242
+
243
+ body = {
244
+ grant_type: "refresh_token",
245
+ refresh_token: session.tokens.refresh_token,
246
+ scope: session.scope,
247
+ client_id: client_metadata.client_id
248
+ }
249
+
250
+ # Add client authentication if available
251
+ add_client_authentication(body) if client_metadata.confidential?
252
+
253
+ AuthProto.configuration.http_client.post(
254
+ auth_server.token_endpoint,
255
+ body: body,
256
+ headers: {
257
+ "Content-Type" => "application/x-www-form-urlencoded",
258
+ "DPoP" => proof
259
+ }
260
+ )
261
+ end
262
+
263
+ def requires_dpop_nonce?(response)
264
+ return false unless response[:status] == 400
265
+ return false if response[:body].nil?
266
+
267
+ error_data = JSON.parse(response[:body])
268
+ error_data.is_a?(Hash) && error_data["error"] == "use_dpop_nonce"
269
+ rescue JSON::ParserError
270
+ false
271
+ end
272
+
273
+ def extract_dpop_nonce(response)
274
+ nonce = (response[:headers] || {}).detect { |k, v| k.downcase == "dpop-nonce" }&.last
275
+
276
+ unless nonce.is_a?(String) && !nonce.empty?
277
+ raise RefreshError.new("No DPoP nonce provided in error response", retry_possible: false)
278
+ end
279
+
280
+ nonce
281
+ end
282
+
283
+ def add_client_authentication(body)
284
+ return unless client_metadata.jwks && !client_metadata.jwks["keys"].empty?
285
+
286
+ signing_key = JOSE::JWK.from_map(client_metadata.jwks["keys"].first)
287
+ client_assertion = PAR::ClientAssertion.new(
288
+ client_id: client_metadata.client_id,
289
+ signing_key: signing_key
290
+ )
291
+
292
+ body.merge!(
293
+ client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
294
+ client_assertion: client_assertion.generate_jwt(
295
+ audience: auth_server.issuer
296
+ )
297
+ )
298
+ end
299
+
300
+ def handle_refresh_response(response)
301
+ case response[:status]
302
+ when 200
303
+ process_successful_response(response)
304
+ when 400
305
+ handle_400_response(response)
306
+ when 401
307
+ raise RefreshError.new("Refresh token is invalid", retry_possible: false)
308
+ when 429
309
+ handle_rate_limit_response(response)
310
+ else
311
+ raise RefreshError, "Unexpected response: #{response[:status]}"
312
+ end
313
+ end
314
+
315
+ def process_successful_response(response)
316
+ raise RefreshError.new("Missing response body", retry_possible: false) if response[:body].nil?
317
+
318
+ data = JSON.parse(response[:body])
319
+ validate_refresh_response(data)
320
+
321
+ AuthProto::State::TokenSet.new(
322
+ access_token: data["access_token"],
323
+ token_type: data["token_type"],
324
+ expires_in: data["expires_in"],
325
+ refresh_token: data["refresh_token"],
326
+ scope: data["scope"],
327
+ sub: data["sub"]
328
+ )
329
+ rescue JSON::ParserError => e
330
+ raise RefreshError.new("Invalid response format: #{e.message}", retry_possible: false)
331
+ end
332
+
333
+ def handle_400_response(response)
334
+ raise RefreshError.new("Missing error response body", retry_possible: false) if response[:body].nil?
335
+
336
+ error_data = JSON.parse(response[:body])
337
+
338
+ unless error_data.is_a?(Hash)
339
+ raise RefreshError.new("Invalid error response format", retry_possible: false)
340
+ end
341
+
342
+ error_description = error_data["error_description"] || error_data["error"]
343
+
344
+ case error_data["error"]
345
+ when "use_dpop_nonce"
346
+ extract_dpop_nonce(response)
347
+ dpop_client.process_response(response[:headers], auth_server.issuer)
348
+ raise RefreshError.new("Retry with DPoP nonce", retry_possible: true)
349
+ when "invalid_grant"
350
+ # The refresh token has been invalidated or already used
351
+ raise RefreshError.new(
352
+ "Refresh token has been invalidated: #{error_description}",
353
+ retry_possible: false
354
+ )
355
+ else
356
+ raise RefreshError.new(
357
+ "Refresh request failed: #{error_description}",
358
+ retry_possible: false
359
+ )
360
+ end
361
+ rescue JSON::ParserError
362
+ raise RefreshError.new("Invalid error response format", retry_possible: false)
363
+ end
364
+
365
+ def handle_rate_limit_response(response)
366
+ # Extract retry-after if available
367
+ retry_after = response[:headers]["Retry-After"]&.to_i || calculate_delay
368
+ raise RefreshError, "Rate limited - retry after #{retry_after} seconds"
369
+ end
370
+
371
+ def validate_refresh_response(data)
372
+ unless data.is_a?(Hash)
373
+ raise RefreshError.new("Token response must be a JSON object", retry_possible: false)
374
+ end
375
+
376
+ # Required fields
377
+ %w[access_token token_type expires_in scope sub].each do |field|
378
+ raise RefreshError.new("Missing #{field} in response", retry_possible: false) unless data[field]
379
+ end
380
+
381
+ %w[access_token token_type scope sub].each do |field|
382
+ unless data[field].is_a?(String) && !data[field].empty?
383
+ raise RefreshError.new("Invalid #{field} in response", retry_possible: false)
384
+ end
385
+ end
386
+
387
+ unless data["expires_in"].is_a?(Integer)
388
+ raise RefreshError.new("Invalid expires_in in response", retry_possible: false)
389
+ end
390
+
391
+ unless data["refresh_token"].nil? || data["refresh_token"].is_a?(String)
392
+ raise RefreshError.new("Invalid refresh_token in response", retry_possible: false)
393
+ end
394
+
395
+ # Token type must be DPoP
396
+ unless data["token_type"] == "DPoP"
397
+ raise RefreshError.new("Invalid token_type: #{data["token_type"]}", retry_possible: false)
398
+ end
399
+
400
+ # Scope must include original scopes
401
+ original_scopes = session.scope.split
402
+ response_scopes = data["scope"].split
403
+ unless (original_scopes - response_scopes).empty?
404
+ raise RefreshError.new("Invalid scope in response", retry_possible: false)
405
+ end
406
+
407
+ # Subject must match
408
+ return if data["sub"] == session.tokens.sub
409
+
410
+ raise RefreshError.new("Subject mismatch in response", retry_possible: false)
411
+ end
412
+ end
413
+ end
414
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AuthProto
4
+
5
+ # Current gem version.
6
+ # @return [String]
7
+ VERSION = "0.3.0.beta1"
8
+ end
data/lib/authproto.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'authproto/client'
4
+ require_relative 'authproto/configuration'
5
+ require_relative 'authproto/http_client'
6
+ require_relative 'authproto/insecure_http_client'
7
+ require_relative 'authproto/localhost_server'
8
+ require_relative 'authproto/serialization/dpop_key'
9
+ require_relative 'authproto/version'
10
+
11
+ #
12
+ # AuthProto is a Ruby library implementing the AT Protocol OAuth specification.
13
+ # It provides OAuth clients with DPoP, PAR, identity discovery, client metadata
14
+ # validation, and storage for sessions and tokens.
15
+ #
16
+
17
+ module AuthProto
18
+ end
@@ -0,0 +1,43 @@
1
+ module AuthProto
2
+ class Client
3
+ class CallbackError < AuthProto::Error
4
+ end
5
+ class TokenError < AuthProto::Error
6
+ end
7
+ class SessionError < AuthProto::Error
8
+ attr_reader session_id: String?
9
+ def initialize: (String message, ?session_id: String?) -> void
10
+ end
11
+
12
+ attr_reader client_id: String
13
+ attr_reader redirect_uri: String
14
+ attr_reader client_metadata: ClientMetadata
15
+ attr_reader session_manager: State::SessionManager
16
+ attr_reader dpop_client: DPoP::Client
17
+
18
+ def initialize: (client_id: String, redirect_uri: String, ?metadata: json_object?, ?dpop_key: (json_object | JOSE::Map)?) -> void
19
+ def authorize: (?handle: String?, ?pds_url: String?, ?scope: String) -> authorization_result
20
+ def handle_callback: (code: String, state: String, iss: String) -> callback_result
21
+ def get_tokens: (String session_id) -> token_info?
22
+ def refresh_token: (String session_id) -> refreshed_tokens
23
+ def authorized?: (String session_id) -> bool
24
+ def auth_headers: (session_id: String, method: String, url: String) -> Hash[String, String]
25
+ def remove_session: (String session_id) -> void
26
+ def cleanup_expired_sessions: () -> void
27
+
28
+ private
29
+ def load_client_metadata: (json_object? metadata) -> ClientMetadata
30
+ def validate_redirect_uri: () -> void
31
+ def initialize_dpop: ((json_object | JOSE::Map)? key) -> DPoP::Client
32
+ def validate_auth_params!: (String? handle, String? pds_url, String scope) -> void
33
+ def resolve_from_handle: (String handle, State::Session session) -> { server: ServerMetadata::AuthorizationServer, pds: String }
34
+ def resolve_from_pds: (String pds_url, State::Session session) -> { server: ServerMetadata::AuthorizationServer, pds: String }
35
+ def resolve_auth_server: (String pds_url) -> ServerMetadata::AuthorizationServer
36
+ def generate_authorization_url: (ServerMetadata::AuthorizationServer auth_server, State::Session session, ?login_hint: String?) -> String
37
+ def exchange_code: (code: String, session: State::Session) -> json_object
38
+ def make_token_request: (String code, State::Session session) -> http_response
39
+ def requires_dpop_nonce?: (http_response response) -> bool
40
+ def extract_dpop_nonce: (http_response response) -> String
41
+ def validate_token_response: (json_object response, State::Session session) -> void
42
+ end
43
+ end
@@ -0,0 +1,113 @@
1
+ module AuthProto
2
+ # Handles and validates AT Protocol OAuth client metadata.
3
+ class ClientMetadata
4
+ AUTH_PRIVATE_KEY_JWT: String
5
+ SCOPE_PATTERN: Regexp
6
+
7
+ @client_id: String
8
+
9
+ @grant_types: Array[String]
10
+
11
+ @response_types: Array[String]
12
+
13
+ @redirect_uris: Array[String]
14
+
15
+ @scope: String
16
+
17
+ @client_name: String?
18
+
19
+ @application_type: String
20
+
21
+ @client_uri: String?
22
+
23
+ @logo_uri: String?
24
+
25
+ @tos_uri: String?
26
+
27
+ @policy_uri: String?
28
+
29
+ @token_endpoint_auth_method: String
30
+
31
+ @token_endpoint_auth_signing_alg: String?
32
+
33
+ @jwks: Hash[String, untyped]?
34
+
35
+ @jwks_uri: String?
36
+
37
+ attr_reader client_id: String
38
+ attr_reader grant_types: Array[String]
39
+ attr_reader response_types: Array[String]
40
+ attr_reader redirect_uris: Array[String]
41
+ attr_reader scope: String
42
+ attr_reader client_name: String?
43
+ attr_reader client_uri: String?
44
+ attr_reader logo_uri: String?
45
+ attr_reader tos_uri: String?
46
+ attr_reader policy_uri: String?
47
+ attr_reader token_endpoint_auth_method: String
48
+ attr_reader jwks: Hash[String, untyped]?
49
+ attr_reader jwks_uri: String?
50
+ attr_reader application_type: String
51
+
52
+ def initialize: (Hash[String, untyped] metadata) -> void
53
+
54
+ def self.from_url: (String url) -> ClientMetadata
55
+
56
+ def confidential?: () -> bool
57
+
58
+ private
59
+
60
+ def validate_and_set_metadata: (Hash[String, untyped] metadata) -> void
61
+
62
+ def validate_client_id: (String client_id) -> String
63
+
64
+ def valid_https_client_id?: (String client_id) -> bool
65
+
66
+ def valid_localhost_client_id?: (String client_id) -> bool
67
+
68
+ def validate_grant_types: (Array[String] grant_types) -> Array[String]
69
+
70
+ def validate_response_types: (Array[String] response_types) -> Array[String]
71
+
72
+ def validate_redirect_uris: (Array[String] uris) -> Array[String]
73
+
74
+ def validate_redirect_uri: (String value) -> void
75
+
76
+ def validate_https_redirect_uri: (URI::Generic uri, String? port) -> void
77
+
78
+ def validate_redirect_uri_origin: (URI::Generic uri) -> void
79
+
80
+ def validate_native_redirect_uri: (URI::Generic uri, String? port) -> void
81
+
82
+ def validate_custom_scheme: (URI::Generic uri) -> void
83
+
84
+ def validate_scope: (String scope) -> String
85
+
86
+ def validate_application_type: (String? type) -> String
87
+
88
+ def validate_client_name: (String? name) -> String?
89
+
90
+ def validate_client_uri: (String? uri) -> String?
91
+
92
+ def validate_https_uri: (String? uri) -> String?
93
+
94
+ def validate_jwks: (Hash[String, untyped] jwks) -> void
95
+
96
+ def validate_auth_methods: (Hash[String, untyped] metadata) -> void
97
+
98
+ def validate_auth_public: (Hash[String, untyped] metadata) -> void
99
+
100
+ def validate_auth_confidential: (Hash[String, untyped] metadata) -> void
101
+
102
+ def validate_dpop: (Hash[String, untyped] metadata) -> void
103
+
104
+ end
105
+ end
106
+
107
+ module AuthProto
108
+ module ApplicationType
109
+ WEB: String
110
+ NATIVE: String
111
+ ALL: Array[String]
112
+ end
113
+ end
@@ -0,0 +1,19 @@
1
+ # Minimal nominal types for the JOSE objects exposed by AuthProto's API.
2
+ # JOSE currently ships no RBS; this is not a signature for its complete API.
3
+ module JOSE
4
+ class Map
5
+ def []: (String key) -> untyped
6
+ def to_h: () -> Hash[String, untyped]
7
+ end
8
+
9
+ class SignedBinary < String
10
+ end
11
+
12
+ class JWK
13
+ attr_accessor fields: Map
14
+ def self.from_map: (Hash[String, untyped] | Map jwk, ?untyped modules, ?untyped key) -> JWK
15
+ def self.generate_key: (untyped params) -> JWK
16
+ def to_map: (?untyped key, ?untyped jwe) -> Map
17
+ def to_public: () -> JWK
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ module AuthProto
2
+ module DPoP
3
+ class Client
4
+ class Error < AuthProto::Error
5
+ end
6
+
7
+ attr_reader key_manager: KeyManager
8
+ attr_reader proof_generator: ProofGenerator
9
+ attr_reader nonce_manager: NonceManager
10
+
11
+ def initialize: (?key_manager: KeyManager?, ?nonce_ttl: Integer?) -> void
12
+ def generate_proof: (http_method: String, http_uri: String, ?access_token: String?, ?nonce: String?) -> JOSE::SignedBinary
13
+ def process_response: (Hash[String, String]? response_headers, String server_url) -> void
14
+ def request_headers: (String proof) -> { "DPoP" => String }
15
+ def public_key: () -> json_object
16
+ def export_key: (?include_private: bool) -> JOSE::Map
17
+
18
+ private
19
+ def extract_nonce: (Hash[String | Symbol, String] headers) -> String?
20
+ def origin_for_uri: (URI::HTTP uri) -> String
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ module AuthProto
2
+ module DPoP
3
+ class KeyManager
4
+ class KeyError < AuthProto::Error
5
+ end
6
+
7
+ CURVE: String
8
+ ALGORITHM: String
9
+ attr_reader keypair: JOSE::JWK
10
+
11
+ def initialize: (?JOSE::JWK? keypair) -> void
12
+ def generate_keypair: () -> JOSE::JWK
13
+ def public_jwk: () -> json_object
14
+ def sign: (String data) -> JOSE::SignedBinary
15
+ def sign_segments: (Hash[String | Symbol, untyped] header, Hash[String | Symbol, untyped] payload) -> JOSE::SignedBinary
16
+ def deep_stringify_keys: (untyped obj) -> untyped
17
+ def verify: (String signed_jws) -> bool
18
+ def to_jwk: (?include_private: bool) -> JOSE::Map
19
+ def self.from_jwk: (json_object | JOSE::Map jwk) -> KeyManager
20
+
21
+ private
22
+ def generate_kid: (json_object | JOSE::Map jwk) -> String
23
+ def validate_keypair: () -> void
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,23 @@
1
+ module AuthProto
2
+ module DPoP
3
+ class NonceManager
4
+ @ttl: Integer
5
+ @serializer: Serialization::StoredNonce
6
+
7
+ class NonceError < AuthProto::Error
8
+ end
9
+
10
+ DEFAULT_TTL: Integer
11
+ def initialize: (?ttl: Integer?) -> void
12
+ def update: (nonce: String, server_url: String) -> void
13
+ def get: (String server_url) -> String?
14
+ def clear: (String server_url) -> bool
15
+ def valid_nonce?: (String server_url) -> bool
16
+
17
+ private
18
+ def normalize_server_url: (String url) -> String
19
+ def validate_inputs: (String nonce, String server_url) -> void
20
+ def validate_server_url: (String server_url) -> void
21
+ end
22
+ end
23
+ end