authproto 0.3.0.beta2 → 0.3.0.beta3

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/CHANGELOG.md +19 -0
  3. data/README.md +108 -5
  4. data/examples/confidential_client/README.md +10 -0
  5. data/examples/confidential_client/app.rb +8 -7
  6. data/examples/confidential_client/scripts/generate_keys.rb +1 -1
  7. data/examples/minisky_console/Gemfile.lock +2 -2
  8. data/examples/minisky_console/README.md +20 -1
  9. data/examples/minisky_console/localhost.rb +5 -3
  10. data/examples/minisky_console/production.rb +9 -3
  11. data/lib/authproto/client.rb +73 -90
  12. data/lib/authproto/configuration.rb +37 -16
  13. data/lib/authproto/dpop/client.rb +105 -40
  14. data/lib/authproto/dpop/{key_manager.rb → key_set.rb} +54 -120
  15. data/lib/authproto/dpop/nonce_manager.rb +33 -20
  16. data/lib/authproto/dpop/proof_generator.rb +17 -32
  17. data/lib/authproto/dpop.rb +19 -0
  18. data/lib/authproto/http_helpers.rb +85 -0
  19. data/lib/authproto/metadata/authorization_server.rb +12 -6
  20. data/lib/authproto/metadata/client_metadata.rb +18 -5
  21. data/lib/authproto/metadata/resource_server.rb +4 -2
  22. data/lib/authproto/minisky.rb +36 -34
  23. data/lib/authproto/par/client.rb +22 -73
  24. data/lib/authproto/par/client_assertion.rb +47 -16
  25. data/lib/authproto/par/request.rb +4 -8
  26. data/lib/authproto/par.rb +2 -1
  27. data/lib/authproto/serialization/session.rb +31 -13
  28. data/lib/authproto/serialization/token_set.rb +1 -2
  29. data/lib/authproto/state/session.rb +66 -6
  30. data/lib/authproto/state/session_manager.rb +95 -21
  31. data/lib/authproto/state/token_set.rb +16 -16
  32. data/lib/authproto/state.rb +1 -1
  33. data/lib/authproto/storage/key_builder.rb +3 -11
  34. data/lib/authproto/storage/memory.rb +8 -2
  35. data/lib/authproto/token/refresh.rb +26 -59
  36. data/lib/authproto/validated_uri.rb +23 -0
  37. data/lib/authproto/version.rb +1 -1
  38. data/lib/authproto.rb +0 -1
  39. data/sig/authproto/client.rbs +5 -7
  40. data/sig/authproto/dependencies.rbs +2 -0
  41. data/sig/authproto/dpop/client.rbs +11 -4
  42. data/sig/authproto/dpop/{key_manager.rbs → key_set.rbs} +9 -11
  43. data/sig/authproto/dpop/nonce_manager.rbs +4 -4
  44. data/sig/authproto/dpop/proof_generator.rbs +5 -10
  45. data/sig/authproto/dpop.rbs +6 -0
  46. data/sig/authproto/http_helpers.rbs +7 -0
  47. data/sig/authproto/localhost_server.rbs +1 -1
  48. data/sig/authproto/metadata/authorization_server.rbs +1 -0
  49. data/sig/authproto/metadata/client_metadata.rbs +2 -1
  50. data/sig/authproto/metadata/resource_server.rbs +1 -0
  51. data/sig/authproto/minisky_oauth.rbs +5 -2
  52. data/sig/authproto/par/client.rbs +4 -8
  53. data/sig/authproto/par/client_assertion.rbs +4 -0
  54. data/sig/authproto/par/request.rbs +0 -9
  55. data/sig/authproto/state/session.rbs +10 -3
  56. data/sig/authproto/state/session_manager.rbs +8 -3
  57. data/sig/authproto/state/token_set.rbs +1 -2
  58. data/sig/authproto/storage/key_builder.rbs +1 -2
  59. data/sig/authproto/token/refresh.rbs +3 -4
  60. data/sig/authproto.rbs +5 -2
  61. metadata +14 -19
  62. data/.rspec +0 -1
  63. data/.rubocop.yml +0 -31
  64. data/.tm_properties +0 -1
  65. data/.yardopts +0 -5
  66. data/AGENTS.md +0 -80
  67. data/Rakefile +0 -8
  68. data/examples/confidential_client/.gitignore +0 -6
  69. data/examples/minisky_console/.gitignore +0 -2
  70. data/lib/authproto/serialization/dpop_key.rb +0 -38
  71. data/misc/yard_attributes.rb +0 -34
  72. data/sig/authproto/serialization/dpop_key.rbs +0 -14
@@ -7,7 +7,6 @@ require 'uri'
7
7
 
8
8
  require_relative 'configuration'
9
9
  require_relative 'dpop/client'
10
- require_relative 'dpop/key_manager'
11
10
  require_relative 'errors'
12
11
  require_relative 'metadata/authorization_server'
13
12
  require_relative 'metadata/client_metadata'
@@ -73,44 +72,51 @@ module AuthProto
73
72
  # @return [State::SessionManager]
74
73
  attr_reader :session_manager
75
74
 
76
- # DPoP key, proof and nonce manager.
75
+ # Shared DPoP proof generator and per-server nonce manager.
77
76
  # @return [DPoP::Client]
78
77
  attr_reader :dpop_client
79
78
 
80
- # Creates an OAuth client with its own DPoP key and session manager.
79
+ # Creates an OAuth client with shared DPoP helpers and a session manager.
81
80
  #
82
81
  # Configure {AuthProto.configure} with an HTTP transport before fetching metadata
83
- # or starting authorization. Reuse `dpop_key` when restoring tokens bound to that key.
82
+ # or starting authorization. Session storage restores the DPoP key bound to each session.
84
83
  #
85
84
  # @param client_id [String] OAuth client ID URL
86
85
  # @param redirect_uri [String] callback URI listed in `redirect_uris`
87
86
  # @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch `client_id`
88
87
  # @param signing_key [Hash, JOSE::JWK, nil] private ES256 client-authentication key, separate from public metadata
89
- # @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
90
88
  #
91
- # @raise [InvalidClientMetadata] if client metadata is invalid or unavailable
89
+ # @raise [InvalidClientMetadata] if metadata is invalid, unavailable, has a different client ID,
90
+ # or the supplied signing key is missing, unexpected or does not match inline public metadata
91
+ # @raise [PAR::ClientAssertion::Error] if the private assertion key is invalid or unusable
92
92
  # @raise [Error] if the redirect URI is not registered
93
- # @raise [DPoP::KeyManager::KeyError] if the DPoP key cannot be imported or generated
94
93
  # @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if fetching client metadata fails
95
94
  #
96
- def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil, signing_key: nil)
95
+ def initialize(client_id:, redirect_uri:, metadata: nil, signing_key: nil)
97
96
  @client_id = client_id
98
97
  @redirect_uri = redirect_uri
99
98
 
100
- # Client metadata is fetched from .well-known if not provided directly
99
+ if metadata && metadata['client_id'] != @client_id
100
+ raise InvalidClientMetadata, "client_id mismatch: #{metadata['client_id']} != #{@client_id}"
101
+ end
102
+
103
+ # Client metadata is fetched from the client ID URL if not provided directly
101
104
  @client_metadata = load_client_metadata(metadata)
102
105
 
103
106
  validate_redirect_uri
104
- @signing_key = initialize_signing_key(signing_key)
107
+ initialize_client_assertion(signing_key)
105
108
 
106
- @session_manager = State::SessionManager.new
107
- @dpop_client = initialize_dpop(dpop_key)
109
+ signing_keys = @client_assertion ? { @signing_key_kid => @signing_key_thumbprint } : {}
110
+ @session_manager = State::SessionManager.new(supported_signing_keys: signing_keys)
111
+
112
+ @dpop_client = DPoP::Client.new
108
113
  end
109
114
 
110
115
  # Starts authorization, stores a pending session, and submits a PAR request.
111
116
  #
112
117
  # Supply exactly one of `handle` or `pds`. Open the returned `:url` in a browser
113
- # and pass the eventual callback parameters to {#handle_callback}.
118
+ # and pass the eventual callback parameters to {#handle_callback}. Retain `:session_id`
119
+ # in the initiating browser session or native login context for callback verification.
114
120
  #
115
121
  # @param handle [String, nil] account handle (optionally prefixed with `@`) or DID
116
122
  # @param pds [String, nil] PDS URL when no handle is supplied
@@ -124,6 +130,7 @@ module AuthProto
124
130
  # @raise [PAR::ClientAssertion::Error] if confidential-client signing fails
125
131
  # @raise [DPoP::Client::Error] if generating the PAR proof fails
126
132
  # @raise [HTTPClient::SSRFError] if server discovery rejects a destination
133
+ # @raise [DPoP::KeySet::KeyError] if generating the session key fails
127
134
  # @raise [Storage::StorageError] if session storage or locking fails
128
135
  # @raise [Encryption::EncryptionError] if session serialization cannot encrypt secrets
129
136
  #
@@ -138,12 +145,15 @@ module AuthProto
138
145
  raise Error, "No HTTPS PDS location found for #{did}" unless pds && URI(pds).is_a?(URI::HTTPS)
139
146
  end
140
147
 
141
- authorization_server = resolve_auth_server(pds)
148
+ authorization_server = resolve_auth_server_info(pds)
142
149
 
143
- session = session_manager.create_session(client_id: @client_id, scope: scope)
144
- session.authorization_server = authorization_server
145
- session.did = did&.to_s
146
- session_manager.update_session(session)
150
+ session = session_manager.create_session(
151
+ client_id: @client_id,
152
+ scope: scope,
153
+ auth_server: authorization_server,
154
+ did: did&.to_s,
155
+ signing_key_id: @signing_key_kid
156
+ )
147
157
 
148
158
  auth_url = submit_authorization_par(authorization_server, session, login_hint: handle)
149
159
 
@@ -160,12 +170,18 @@ module AuthProto
160
170
  # and `:scope` (String).
161
171
  # It also contains `:session_id` (String) and `:did` (String).
162
172
  #
173
+ # Supply the session ID retained when starting authorization as `expected_session_id`.
174
+ # For web apps, read it from the initiating browser's session, never from callback parameters
175
+ # or the state-token lookup. It is checked before exchanging the authorization code.
176
+ # Remove the browser's pending-session association after this method succeeds.
177
+ #
163
178
  # @param code [String] authorization code from the callback
164
179
  # @param state [String] state token from the callback
165
180
  # @param iss [String] issuer URL from the callback
181
+ # @param expected_session_id [String] pending session ID retained by the caller when authorization started
166
182
  #
167
183
  # @return [Hash{Symbol => String, Integer, nil}] tokens and authenticated session details
168
- # @raise [CallbackError] if state is unknown or the issuer does not match the session
184
+ # @raise [CallbackError] if the expected session ID is invalid, state is unknown, or session/issuer validation fails
169
185
  # @raise [TokenError] if token exchange or response validation fails
170
186
  # @raise [DPoP::Client::Error, PAR::ClientAssertion::Error] if proof/assertion generation fails
171
187
  # @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if token transport fails
@@ -174,15 +190,19 @@ module AuthProto
174
190
  # @raise [Storage::StorageError] if session storage or locking fails
175
191
  # @raise [Encryption::EncryptionError] if tokens cannot be encrypted for storage
176
192
  #
177
- def handle_callback(code:, state:, iss:)
178
- session = session_manager.get_session_by_state(state)
193
+ def handle_callback(code:, state:, iss:, expected_session_id:)
194
+ unless expected_session_id.is_a?(String) && !expected_session_id.empty?
195
+ raise CallbackError, "Expected session ID is required"
196
+ end
197
+
198
+ session = session_manager.get_session_by_state_token(state)
179
199
 
180
200
  raise CallbackError, "Invalid state parameter" unless session
201
+ raise CallbackError, "Session ID mismatch" unless session.session_id == expected_session_id
181
202
  raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
182
203
 
183
204
  AuthProto.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
184
205
  response = exchange_code(code: code, session: session)
185
- validate_token_response(response, session)
186
206
 
187
207
  token_set = State::TokenSet.new(
188
208
  access_token: response['access_token'],
@@ -194,7 +214,7 @@ module AuthProto
194
214
  )
195
215
 
196
216
  session.tokens = token_set
197
- session_manager.update_session(session)
217
+ session_manager.complete_authorization(session)
198
218
 
199
219
  session.session_data
200
220
  end
@@ -213,7 +233,7 @@ module AuthProto
213
233
  #
214
234
  def get_tokens(session_id)
215
235
  session = session_manager.get_session(session_id)
216
- return nil unless session&.authorized?
236
+ return nil unless session && session.tokens_valid?
217
237
 
218
238
  session.session_data
219
239
  end
@@ -230,6 +250,7 @@ module AuthProto
230
250
  # @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session identifiers
231
251
  # @raise [TokenError] if the session is missing or has no refresh token
232
252
  # @raise [Token::RefreshError] if token refresh fails or exhausts retries
253
+ # @raise [DPoP::Client::Error] if a refresh response lacks a valid nonce
233
254
  # @raise [State::SessionError] if replacement tokens have a different subject
234
255
  # @raise [Storage::StorageError] if session storage or locking fails
235
256
  # @raise [Encryption::EncryptionError] if replacement tokens cannot be stored
@@ -246,7 +267,7 @@ module AuthProto
246
267
  dpop_client: @dpop_client,
247
268
  auth_server: session.auth_server,
248
269
  client_metadata: @client_metadata,
249
- signing_key: @signing_key
270
+ client_assertion: @client_assertion
250
271
  )
251
272
 
252
273
  new_tokens = refresher.perform_refresh
@@ -265,13 +286,13 @@ module AuthProto
265
286
  #
266
287
  def authorized?(session_id)
267
288
  session = session_manager.get_session(session_id)
268
- session&.authorized? || false
289
+ session ? session.tokens_valid? : false
269
290
  end
270
291
 
271
292
  # Builds authorization headers and a fresh DPoP proof for one HTTP request.
272
293
  #
273
294
  # The caller sends the request. This method does not refresh tokens, send HTTP,
274
- # or retry nonce challenges. Feed response headers to {DPoP::Client#process_response}
295
+ # or retry nonce challenges. Feed each full HTTP response to {DPoP::Client#process_response}
275
296
  # and call {#refresh_token} explicitly when needed. Generate new headers for each retry.
276
297
  #
277
298
  # @param session_id [String] authorized session ID
@@ -286,9 +307,11 @@ module AuthProto
286
307
  session = session_manager.get_session(session_id)
287
308
 
288
309
  raise TokenError, "Invalid session" unless session
289
- raise TokenError, "Session not authorized" unless session.authorized?
310
+ raise TokenError, "Session not authorized" if session.pending?
311
+ raise TokenError, "Session has expired" unless session.tokens_valid?
290
312
 
291
313
  dpop_proof = dpop_client.generate_proof(
314
+ session: session,
292
315
  method: method,
293
316
  uri: url,
294
317
  access_token: session.tokens.access_token
@@ -307,9 +330,6 @@ module AuthProto
307
330
  # @raise [Storage::StorageError] if deletion or locking fails
308
331
  #
309
332
  def delete_session(session_id)
310
- key = Storage::KeyBuilder.session_key(session_id)
311
-
312
- AuthProto.storage.delete(key)
313
333
  session_manager.delete_session(session_id)
314
334
  end
315
335
 
@@ -340,7 +360,7 @@ module AuthProto
340
360
  end
341
361
  end
342
362
 
343
- def initialize_signing_key(key)
363
+ def initialize_client_assertion(key)
344
364
  if @client_metadata.confidential?
345
365
  raise InvalidClientMetadata, "Confidential clients require a signing_key" if key.nil?
346
366
  else
@@ -352,7 +372,7 @@ module AuthProto
352
372
  private_key = assertion.signing_key
353
373
 
354
374
  if @client_metadata.jwks
355
- public_key = private_key.to_public.to_map.to_h
375
+ public_key = private_key.to_public_map.to_h
356
376
 
357
377
  key_in_jwks = @client_metadata.jwks['keys'].any? { |candidate|
358
378
  metadata_key_matches_public_key?(candidate, public_key)
@@ -361,24 +381,20 @@ module AuthProto
361
381
  raise InvalidClientMetadata, "signing_key must match an active public key advertised in jwks" if !key_in_jwks
362
382
  end
363
383
 
364
- private_key
384
+ @client_assertion = assertion
385
+ @signing_key_kid = private_key.fields.fetch('kid')
386
+ @signing_key_thumbprint = private_key.thumbprint('SHA256')
365
387
  end
366
388
 
367
389
  def metadata_key_matches_public_key?(candidate, public_key)
368
390
  return false if candidate['revoked']
369
- return false if candidate.key?('alg') && candidate['alg'] != 'ES256'
391
+ return false if candidate.key?('alg') && candidate['alg'] != PAR::ClientAssertion::ALGORITHM
370
392
  return false if candidate.key?('use') && candidate['use'] != 'sig'
371
393
  return false if candidate.key?('key_ops') && !candidate['key_ops'].include?('verify')
372
394
 
373
395
  %w[kid kty crv x y].all? { |field| candidate[field] == public_key[field] }
374
396
  end
375
397
 
376
- def initialize_dpop(key)
377
- key_manager = key ? DPoP::KeyManager.from_jwk(key) : DPoP::KeyManager.new
378
-
379
- DPoP::Client.new(key_manager: key_manager)
380
- end
381
-
382
398
  def validate_auth_params(handle, pds, scope)
383
399
  unless scope.is_a?(String) && scope.valid_encoding? && Metadata::ClientMetadata::SCOPE_PATTERN.match?(scope)
384
400
  raise Error, "scope must contain valid tokens separated by single spaces"
@@ -397,7 +413,7 @@ module AuthProto
397
413
  raise Error, "cannot provide both handle and pds" if handle && pds
398
414
  end
399
415
 
400
- def resolve_auth_server(pds)
416
+ def resolve_auth_server_info(pds)
401
417
  resource_server = Metadata::ResourceServer.from_url(pds)
402
418
  auth_server_url = resource_server.authorization_server
403
419
 
@@ -419,16 +435,11 @@ module AuthProto
419
435
  req.code_challenge = session.pkce_challenge
420
436
 
421
437
  if @client_metadata.confidential?
422
- req.client_assertion = PAR::ClientAssertion.new(client_id: @client_id, signing_key: @signing_key)
438
+ req.client_assertion = @client_assertion
423
439
  end
424
-
425
- req.dpop_proof = @dpop_client.generate_proof(
426
- method: 'POST',
427
- uri: authorization_server.pushed_authorization_request_endpoint
428
- )
429
440
  end
430
441
 
431
- par_response = par_client.submit(par_request)
442
+ par_response = par_client.submit(par_request, session: session)
432
443
 
433
444
  par_client.build_authorization_url(
434
445
  request_uri: par_response.request_uri,
@@ -437,30 +448,26 @@ module AuthProto
437
448
  end
438
449
 
439
450
  def exchange_code(code:, session:)
440
- # Initial token request without nonce
441
- response = make_token_request(code, session)
442
-
443
- # Handle DPoP nonce requirement
444
- if requires_dpop_nonce?(response)
445
- # Extract and store nonce from error response
446
- extract_dpop_nonce(response)
447
- dpop_client.process_response(response[:headers], session.auth_server.issuer)
448
-
449
- # Retry request with nonce
450
- response = make_token_request(code, session)
451
+ response = dpop_client.with_nonce_handling(session, session.auth_server.token_endpoint) do
452
+ make_token_request(code, session)
451
453
  end
452
454
 
453
455
  raise TokenError, "Token request failed: #{response[:status]}" unless response[:status] == 200
454
456
 
455
457
  begin
456
- JSON.parse(response[:body])
458
+ data = JSON.parse(response[:body])
457
459
  rescue JSON::ParserError => e
458
460
  raise TokenError, "Invalid token response: #{e.message}"
459
461
  end
462
+
463
+ validate_token_response(data, session)
464
+
465
+ data
460
466
  end
461
467
 
462
468
  def make_token_request(code, session)
463
469
  dpop_proof = dpop_client.generate_proof(
470
+ session: session,
464
471
  method: 'POST',
465
472
  uri: session.auth_server.token_endpoint
466
473
  )
@@ -474,14 +481,9 @@ module AuthProto
474
481
  }
475
482
 
476
483
  if @client_metadata.confidential?
477
- assertion = PAR::ClientAssertion.new(
478
- client_id: @client_id,
479
- signing_key: @signing_key
480
- )
481
-
482
484
  body.merge!(
483
485
  client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
484
- client_assertion: assertion.generate_jwt(audience: session.auth_server.issuer)
486
+ client_assertion: @client_assertion.generate_jwt(audience: session.auth_server.issuer)
485
487
  )
486
488
  end
487
489
 
@@ -495,38 +497,19 @@ module AuthProto
495
497
  )
496
498
  end
497
499
 
498
- def requires_dpop_nonce?(response)
499
- return false unless response[:status] == 400
500
-
501
- error_data = JSON.parse(response[:body])
502
- error_data['error'] == DPoP::ERROR_USE_NONCE
503
- rescue JSON::ParserError
504
- false
505
- end
506
-
507
- def extract_dpop_nonce(response)
508
- headers = response[:headers]
509
- nonce = headers.find { |k, v| k.downcase == 'dpop-nonce' }&.last
510
-
511
- raise TokenError, "No DPoP nonce provided in error response" unless nonce
512
-
513
- nonce
514
- end
515
-
516
500
  def validate_token_response(response, session)
517
501
  ['access_token', 'token_type', 'expires_in', 'scope', 'sub'].each do |field|
518
502
  raise TokenError, "Missing #{field} in token response" unless response[field]
519
503
  end
520
504
 
521
- raise TokenError, "Invalid token_type: #{response['token_type']}" unless response['token_type'] == 'DPoP'
505
+ unless response['token_type'].is_a?(String) && response['token_type'].casecmp?('DPoP')
506
+ raise TokenError, "Invalid token_type: #{response['token_type']}"
507
+ end
508
+
522
509
  raise TokenError, "Missing atproto scope in token response" unless response['scope'].split.include?('atproto')
523
510
 
524
511
  # If we have a pre-resolved DID, verify that it matches
525
512
  raise TokenError, "Subject mismatch in token response" if session.did && session.did != response['sub']
526
-
527
- if response[:headers] && response[:headers]["DPoP-Nonce"]
528
- dpop_client.process_response(response[:headers], session.auth_server.issuer)
529
- end
530
513
  end
531
514
  end
532
515
  end
@@ -34,7 +34,7 @@ module AuthProto
34
34
  # @yieldparam config [Configuration] mutable global settings
35
35
  # @yieldreturn [Object] ignored
36
36
  # @return [Configuration] validated global settings
37
- # @raise [ConfigurationError] if the storage or HTTP client is invalid
37
+ # @raise [ConfigurationError] if storage, HTTP transport or lifetime settings are invalid
38
38
  # @example Configure the default transport
39
39
  # AuthProto.configure do |config|
40
40
  # config.http_client = AuthProto::HTTPClient.new
@@ -62,7 +62,7 @@ module AuthProto
62
62
  end
63
63
  end
64
64
 
65
- # Raised when global storage or HTTP transport settings are invalid.
65
+ # Raised when global configuration settings are invalid.
66
66
  class ConfigurationError < Error; end
67
67
 
68
68
  #
@@ -71,18 +71,14 @@ module AuthProto
71
71
 
72
72
  class Configuration
73
73
 
74
- # Configured default token lifetime in seconds (300); currently unused by token exchange.
75
- # @return [Integer]
76
- attr_accessor :default_token_lifetime
74
+ # Default client assertion validity in seconds, read when generating each assertion.
75
+ # @return [Integer] positive lifetime in seconds; defaults to 300
76
+ attr_accessor :client_assertion_lifetime
77
77
 
78
- # Configured default nonce lifetime in seconds (300); nonce managers currently use their own TTL.
79
- # @return [Integer]
78
+ # Default stored nonce lifetime in seconds; used when creating nonce managers without an explicit TTL.
79
+ # @return [Integer] positive lifetime in seconds; defaults to 300
80
80
  attr_accessor :dpop_nonce_lifetime
81
81
 
82
- # Reserved encryption setting; serializers currently create their own service.
83
- # @return [Object, nil]
84
- attr_accessor :encryption
85
-
86
82
  # Transport used for discovery, PAR and token requests; initially `nil`.
87
83
  # @return [#get, #post, nil]
88
84
  attr_accessor :http_client
@@ -91,6 +87,10 @@ module AuthProto
91
87
  # @return [Logger]
92
88
  attr_accessor :logger
93
89
 
90
+ # Lifetime of pending authorization attempts in seconds; defaults to 900 (15 minutes).
91
+ # @return [Integer] positive lifetime in seconds
92
+ attr_accessor :pending_session_ttl
93
+
94
94
  # Storage backend; defaults to a new {Storage::Memory}.
95
95
  # @return [Storage::Interface]
96
96
  attr_accessor :storage
@@ -98,30 +98,51 @@ module AuthProto
98
98
  # Creates default settings with in-memory storage and no HTTP transport.
99
99
  #
100
100
  def initialize
101
- @default_token_lifetime = 300 # 5 minutes in seconds
102
- @dpop_nonce_lifetime = 300 # 5 minutes in seconds
103
- @encryption = nil
101
+ @client_assertion_lifetime = 300 # 5 minutes
102
+ @dpop_nonce_lifetime = 300 # 5 minutes
103
+ @pending_session_ttl = 900 # 15 minutes
104
104
  @http_client = nil
105
105
  @logger = Logger.new($stdout)
106
106
  @storage = AuthProto::Storage::Memory.new
107
107
  end
108
108
 
109
- # Checks that storage implements the interface and the transport supports GET/POST.
109
+ # Checks storage and transport interfaces and positive assertion, nonce and pending session lifetimes.
110
110
  #
111
111
  # A `nil` HTTP client is accepted, but cannot perform OAuth or discovery requests.
112
112
  #
113
113
  # @return [true] if validation succeeds
114
- # @raise [ConfigurationError] if storage is absent/invalid or transport methods are missing
114
+ # @raise [ConfigurationError] if storage, HTTP transport or lifetime settings are invalid
115
115
  #
116
116
  def validate
117
117
  validate_storage
118
118
  validate_http_client
119
+ validate_client_assertion_lifetime
120
+ validate_dpop_nonce_lifetime
121
+ validate_pending_session_ttl
119
122
  true
120
123
  end
121
124
 
122
125
 
123
126
  private
124
127
 
128
+ def validate_client_assertion_lifetime
129
+ return if @client_assertion_lifetime.is_a?(Integer) && @client_assertion_lifetime.positive?
130
+
131
+ raise ConfigurationError, "Client assertion lifetime must be a positive integer"
132
+ end
133
+
134
+ def validate_dpop_nonce_lifetime
135
+ return if @dpop_nonce_lifetime.is_a?(Integer) && @dpop_nonce_lifetime.positive?
136
+
137
+ raise ConfigurationError, "DPoP nonce lifetime must be a positive integer"
138
+ end
139
+
140
+ def validate_pending_session_ttl
141
+ return if @pending_session_ttl.is_a?(Integer) && @pending_session_ttl.positive?
142
+
143
+ raise ConfigurationError, "Pending session TTL must be a positive integer"
144
+ end
145
+
125
146
  def validate_storage
126
147
  raise ConfigurationError, "Storage must be configured" if @storage.nil?
127
148
  return if @storage.is_a?(AuthProto::Storage::Interface)