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
@@ -1,84 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require 'uri'
4
5
 
5
6
  require_relative '../errors'
6
- require_relative 'key_manager'
7
+ require_relative '../http_helpers'
7
8
  require_relative 'nonce_manager'
8
9
  require_relative 'proof_generator'
9
10
 
10
11
  module AuthProto
11
12
 
12
13
  #
13
- # Key management, per-request proofs and server-origin nonces for DPoP authentication.
14
+ # Shared proof generation and server-origin nonces for DPoP authentication.
14
15
  #
15
16
 
16
17
  module DPoP
17
18
 
18
- ERROR_USE_NONCE = "use_dpop_nonce"
19
-
20
- # High-level client for managing DPoP operations. Integrates key management,
21
- # proof generation, and nonce tracking to provide a complete DPoP client
22
- # implementation according to RFC 9449.
23
19
  #
24
- # This client handles:
25
- # - Key management for signing proofs
26
- # - Proof generation for HTTP requests
27
- # - Nonce tracking across servers
28
- # - Header construction for requests
29
- # - Response processing for nonce updates
20
+ # Shared DPoP client using session-owned signing keys and nonces scoped to
21
+ # each session and server origin. Generates RFC 9449 proofs, processes nonce
22
+ # updates, and detects nonce challenges. Response processing requires a nonce
23
+ # on every DPoP response, as required by the AT Protocol OAuth profile.
30
24
  #
31
25
 
32
26
  class Client
27
+ include HTTPHelpers
33
28
 
34
29
  # Error raised for DPoP client operations
35
30
  class Error < AuthProto::Error; end
36
31
 
37
- # Key manager used to sign proofs.
38
- # @return [KeyManager]
39
- attr_reader :key_manager
40
-
41
32
  # Generator for per-request DPoP JWTs.
42
33
  # @return [ProofGenerator]
43
34
  attr_reader :proof_generator
44
35
 
45
- # Persistent nonce store, indexed by server origin.
36
+ # Persistent nonce store, indexed by session and server origin.
46
37
  # @return [NonceManager]
47
38
  attr_reader :nonce_manager
48
39
 
49
- # Creates DPoP signing, proof and nonce managers.
40
+ # Creates a shared proof generator and nonce storage scoped to sessions and server origins.
50
41
  #
51
- # @param key_manager [KeyManager, nil] existing key manager, or `nil` to generate a keypair
52
- # @param nonce_ttl [Integer, nil] stored nonce lifetime in seconds, or the nonce manager's default
53
- # @raise [KeyManager::KeyError] if key generation fails
54
- # @raise [ProofGenerator::ProofError] if the key manager is invalid
42
+ # @param nonce_ttl [Integer, nil] stored nonce lifetime in seconds; `nil` uses {Configuration#dpop_nonce_lifetime}
55
43
  # @raise [ArgumentError] if the encryption master key is not valid Base64
56
44
  #
57
- def initialize(key_manager: nil, nonce_ttl: nil)
58
- @key_manager = key_manager || KeyManager.new
45
+ def initialize(nonce_ttl: nil)
59
46
  @nonce_manager = NonceManager.new(ttl: nonce_ttl)
60
- @proof_generator = ProofGenerator.new(@key_manager)
47
+ @proof_generator = ProofGenerator.new
61
48
  end
62
49
 
63
- # Generates a fresh DPoP proof using a supplied or stored nonce for the origin.
50
+ # Generates a fresh DPoP proof using the stored nonce for the session and origin, if available.
64
51
  #
52
+ # @param session [State::Session] session whose DPoP key signs the request
65
53
  # @param method [String] HTTP method, converted to uppercase
66
- # @param uri [String] absolute HTTP(S) URL; callers should omit the query and fragment
54
+ # @param uri [String] absolute HTTP(S) request URL; query and fragment are omitted from the signed `htu`
67
55
  # @param access_token [String, nil] token to bind with an `ath` claim
68
- # @param nonce [String, nil] explicit nonce; `nil` looks up the origin's stored nonce
69
56
  # @return [JOSE::SignedBinary] compact signed DPoP JWT
70
57
  # @raise [Error] if URI parsing, nonce lookup or signing fails
71
58
  #
72
- def generate_proof(method:, uri:, access_token: nil, nonce: nil)
59
+ def generate_proof(session:, method:, uri:, access_token: nil)
73
60
  parsed_uri = URI(uri)
74
61
 
75
62
  server_url = "#{parsed_uri.scheme}://#{parsed_uri.host}"
76
63
  server_url += ":#{parsed_uri.port}" if parsed_uri.port != parsed_uri.default_port
77
64
 
78
- # Use provided nonce or get one from the manager
79
- nonce ||= @nonce_manager.get(server_url)
65
+ nonce = @nonce_manager.get(server_url: server_url, session_id: session.session_id)
80
66
 
81
67
  @proof_generator.generate(
68
+ key_set: session.dpop_key,
82
69
  method: method,
83
70
  uri: uri,
84
71
  nonce: nonce,
@@ -89,25 +76,103 @@ module AuthProto
89
76
  end
90
77
 
91
78
  # Stores a `DPoP-Nonce` response header for future requests to the same origin.
92
- # Header names are compared case-insensitively. Missing headers/nonces are ignored.
79
+ # Call after every DPoP HTTP attempt, before accepting a result or deciding to retry.
80
+ # Header names are case-insensitive; missing, malformed and duplicate nonces are rejected.
93
81
  #
94
- # @param response_headers [Hash{String => String}, nil] response headers, or `nil`
82
+ # @param session_id [String] session that made the request
83
+ # @param response [Hash{Symbol => Object}] HTTP response with `:status`, `:headers`, and `:body`
95
84
  # @param server_url [String] request URL whose origin owns the nonce
96
85
  # @return [void]
97
86
  # @raise [Error] if nonce validation or persistence fails
98
87
  #
99
- def process_response(response_headers, server_url)
100
- return unless response_headers
88
+ def process_response(response, server_url:, session_id:)
89
+ raise Error, "Expected a response hash" unless response.is_a?(Hash)
90
+
91
+ response_headers = response[:headers]
92
+ raise Error, "Missing or invalid response headers" unless response_headers.is_a?(Hash)
101
93
 
102
- # Look for DPoP-Nonce header (case insensitive)
103
- nonce = response_headers.find { |k, v| k.downcase == 'dpop-nonce' }&.last
104
- return unless nonce
94
+ if response_headers.keys.count { |name| name.casecmp?('DPoP-Nonce') } > 1
95
+ raise Error, "Multiple DPoP-Nonce headers"
96
+ end
97
+
98
+ nonce = response_header(response, 'DPoP-Nonce')
99
+
100
+ if nonce.nil? || nonce == ''
101
+ raise Error, "Missing DPoP-Nonce header"
102
+ end
105
103
 
106
104
  # Store new nonce for future requests
107
- @nonce_manager.update(nonce: nonce, server_url: server_url)
105
+ @nonce_manager.update(nonce: nonce, server_url: server_url, session_id: session_id)
108
106
  rescue StandardError => e
109
107
  raise Error, "Failed to process response: #{e.message}"
110
108
  end
109
+
110
+ # Detects a nonce challenge without treating other errors as nonce challenges.
111
+ #
112
+ # Recognizes HTTP 400 JSON errors and an HTTP 401 DPoP authentication
113
+ # challenge. Header/parameter names and the scheme are case-insensitive;
114
+ # error values are case-sensitive. Multiple DPoP challenges are treated as ambiguous.
115
+ #
116
+ # @param response [Hash{Symbol => Object}] HTTP response with status, headers and body
117
+ # @return [Boolean] whether the response requests a retry with a server nonce
118
+ # @raise [Error] if the response or its headers have an invalid type
119
+ #
120
+ def has_nonce_error?(response)
121
+ raise Error, "Expected a response hash" unless response.is_a?(Hash)
122
+
123
+ headers = response[:headers]
124
+ raise Error, "Missing or invalid response headers" unless headers.is_a?(Hash)
125
+
126
+ case response[:status]
127
+ when 400
128
+ return false unless response[:body].is_a?(String)
129
+
130
+ begin
131
+ data = JSON.parse(response[:body])
132
+ data.is_a?(Hash) && data['error'] == DPoP::ERROR_USE_NONCE
133
+ rescue JSON::ParserError
134
+ false
135
+ end
136
+ when 401
137
+ challenge = response_header(response, 'WWW-Authenticate')
138
+ nonce_challenge?(challenge)
139
+ else
140
+ false
141
+ end
142
+ end
143
+
144
+ # Runs a DPoP request and retries once if the server requests a new nonce.
145
+ # Processes every response before returning it or invoking the block again.
146
+ # The block must build a fresh proof and send the request on each invocation;
147
+ # the initial proof may already include a cached nonce.
148
+ #
149
+ # @param session [State::Session] session making the request
150
+ # @param url [String] request URL whose origin owns the nonce
151
+ # @yield sends one HTTP request using a fresh DPoP proof
152
+ # @yieldreturn [Hash{Symbol => Object}] HTTP response with status, headers and body
153
+ # @return [Hash{Symbol => Object}] final response, including any remaining error
154
+ # @raise [Error] if a response lacks a valid nonce or nonce persistence fails
155
+ #
156
+ def with_nonce_handling(session, url)
157
+ response = yield
158
+
159
+ process_response(response, server_url: url, session_id: session.session_id)
160
+
161
+ if has_nonce_error?(response)
162
+ response = yield
163
+
164
+ process_response(response, server_url: url, session_id: session.session_id)
165
+ end
166
+
167
+ response
168
+ end
169
+
170
+
171
+ private
172
+
173
+ def nonce_challenge?(challenge)
174
+ authentication_challenge(challenge, scheme: 'DPoP')&.fetch('error', nil) == DPoP::ERROR_USE_NONCE
175
+ end
111
176
  end
112
177
  end
113
178
  end
@@ -1,22 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'base64'
4
3
  require 'jose'
5
4
  require 'json'
6
- require 'openssl'
7
5
 
6
+ require_relative '../dpop'
8
7
  require_relative '../errors'
9
8
 
10
9
  module AuthProto
11
10
  module DPoP
12
11
 
13
12
  #
14
- # Manages ES256 keypair generation and storage for DPoP proofs.
15
- # Provides functionality to generate new keys and store them securely.
13
+ # Holds an ES256 signing keypair and imports/exports JWKs for DPoP proofs.
14
+ # Keys are held in memory; the session serializer handles encrypted persistence.
16
15
  # Uses JOSE for cryptographic operations and key format handling.
17
16
  #
18
17
 
19
- class KeyManager
18
+ class KeySet
20
19
 
21
20
  # Error raised when key operations fail
22
21
  class KeyError < AuthProto::Error; end
@@ -25,78 +24,31 @@ module AuthProto
25
24
  # @return [String]
26
25
  CURVE = "P-256"
27
26
 
28
- # JWS signing algorithm used for DPoP proofs.
29
- # @return [String]
30
- ALGORITHM = "ES256"
31
-
32
27
  # Private EC P-256 keypair used for signing.
33
28
  # @return [JOSE::JWK]
34
- attr_reader :keypair
29
+ attr_reader :key
35
30
 
36
- # Creates a new KeyManager instance with an optional existing keypair
31
+ # Creates a new KeySet instance with an optional existing keypair
37
32
  #
38
- # @param keypair [JOSE::JWK, nil] Optional existing keypair to use
39
- # @raise [KeyError] if the provided keypair is invalid
33
+ # @param key [JOSE::JWK, nil] Optional existing keypair to use
34
+ # @raise [KeyError] if the provided key is invalid
40
35
  #
41
- def initialize(keypair = nil)
42
- @keypair = keypair || generate_keypair
36
+ def initialize(key = nil)
37
+ @key = key || generate_keypair
43
38
  validate_keypair
44
39
  end
45
40
 
46
- # Generates a new ES256 keypair for DPoP usage
47
- #
48
- # @return [JOSE::JWK] The newly generated keypair
49
- # @raise [KeyError] if key generation fails
50
- #
51
- def generate_keypair
52
- # Generate base keypair
53
- base_key = JOSE::JWK.generate_key([:ec, CURVE])
54
- base_map = base_key.to_map
55
-
56
- # Create new map with all required properties
57
- key_map = {
58
- 'kty' => base_map['kty'],
59
- 'crv' => base_map['crv'],
60
- 'x' => base_map['x'],
61
- 'y' => base_map['y'],
62
- 'd' => base_map['d'],
63
- 'use' => 'sig',
64
- 'kid' => generate_kid(base_map)
65
- }
66
-
67
- # Create new JWK with all properties
68
- JOSE::JWK.from_map(key_map)
69
- rescue StandardError => e
70
- raise KeyError, "Failed to generate keypair: #{e.message}"
71
- end
72
-
73
- # Exports the public key with `use: sig` and a key ID.
41
+ # Exports the public key for embedding in a DPoP proof.
74
42
  #
75
43
  # @return [Hash{String => Object}] public JWK without private key material
76
44
  # @raise [KeyError] if public-key export fails
77
45
  #
78
46
  def public_jwk
79
- jwk = @keypair.to_public.to_map.to_h
80
- # If somehow the properties aren't set, add them
81
- jwk['use'] ||= 'sig'
82
- jwk['kid'] ||= generate_kid(jwk)
83
- jwk
47
+ @key.to_public_map.to_h
84
48
  rescue StandardError => e
85
49
  raise KeyError, "Failed to export public key: #{e.message}"
86
50
  end
87
51
 
88
- # Signs bytes with this keypair and returns a compact JWS.
89
- #
90
- # @param data [String] payload bytes to sign
91
- # @return [JOSE::SignedBinary] compact JWS (a string subclass)
92
- # @raise [KeyError] if signing fails
93
- #
94
- def sign(data)
95
- @keypair.sign(data).compact
96
- rescue StandardError => e
97
- raise KeyError, "Failed to sign data: #{e.message}"
98
- end
99
-
100
52
  # Signs a JSON payload with the supplied protected JWS header and ES256.
101
53
  # Hash keys are recursively converted to strings. The algorithm is forced to `ES256`.
102
54
  #
@@ -111,7 +63,7 @@ module AuthProto
111
63
  payload = deep_stringify_keys(payload)
112
64
 
113
65
  # Configure JOSE to use ES256 for signing
114
- signing_config = { 'alg' => 'ES256' }
66
+ signing_config = { 'alg' => ALGORITHM }
115
67
 
116
68
  # Merge our header with JOSE's required fields
117
69
  full_header = header.merge(signing_config)
@@ -120,7 +72,7 @@ module AuthProto
120
72
  payload_json = JSON.generate(payload)
121
73
 
122
74
  # Create the JWS with our header and payload
123
- jws = @keypair.sign(payload_json, full_header)
75
+ jws = @key.sign(payload_json, full_header)
124
76
 
125
77
  # Get the compact serialization
126
78
  jws.compact
@@ -128,46 +80,13 @@ module AuthProto
128
80
  raise KeyError, "Failed to sign segments: #{e.message}"
129
81
  end
130
82
 
131
- # Copies hashes and arrays recursively, converting hash keys to strings.
132
- #
133
- # @param obj [Object] input structure or scalar value
134
- # @return [Object] transformed hash/array, or the original scalar
135
- #
136
- def deep_stringify_keys(obj)
137
- case obj
138
- when Hash
139
- obj.each_with_object({}) do |(k, v), hash|
140
- hash[k.to_s] = deep_stringify_keys(v)
141
- end
142
- when Array
143
- obj.map { |v| deep_stringify_keys(v) }
144
- else
145
- obj
146
- end
147
- end
148
-
149
- # Verifies a compact JWS using this keypair.
150
- #
151
- # @param signed_jws [String, JOSE::SignedBinary] complete signed JWS
152
- # @return [Boolean] whether the signature is valid
153
- # @raise [KeyError] if decoding or verification raises an error
154
- #
155
- def verify(signed_jws)
156
- verified, payload, = @keypair.verify(signed_jws)
157
- verified
158
- rescue StandardError => e
159
- raise KeyError, "Failed to verify signature: #{e.message}"
160
- end
161
-
162
- # Exports this keypair as a JOSE map.
83
+ # Exports this keypair including its private signing material.
163
84
  #
164
- # @param include_private [Boolean] include secret `d` key material when `true`
165
- # @return [JOSE::Map] JWK fields; call `to_h` for a Ruby hash
85
+ # @return [Hash{String => Object}] private JWK including the secret `d` component
166
86
  # @raise [KeyError] if export fails
167
87
  #
168
- def to_jwk(include_private: false)
169
- key = include_private ? @keypair : @keypair.to_public
170
- key.to_map
88
+ def private_jwk
89
+ @key.to_map.to_h
171
90
  rescue StandardError => e
172
91
  raise KeyError, "Failed to export key: #{e.message}"
173
92
  end
@@ -175,40 +94,55 @@ module AuthProto
175
94
  # Imports an EC P-256 JWK and validates that it can sign and verify.
176
95
  #
177
96
  # @param jwk [Hash{String => Object}, JOSE::Map] JWK including private key material
178
- # @return [KeyManager] imported key manager
97
+ # @return [KeySet] imported and validated keypair wrapper
179
98
  # @raise [KeyError] if import or key validation fails
180
99
  #
181
100
  def self.from_jwk(jwk)
182
- keypair = JOSE::JWK.from_map(jwk)
183
- new(keypair)
101
+ key = JOSE::JWK.from_map(jwk)
102
+ new(key)
184
103
  rescue StandardError => e
185
104
  raise KeyError, "Failed to import key: #{e.message}"
186
105
  end
187
106
 
107
+
188
108
  private
189
109
 
190
- def generate_kid(jwk)
191
- # Generate a key ID based on the key's components
192
- components = [
193
- jwk['kty'],
194
- jwk['crv'],
195
- jwk['x'],
196
- jwk['y']
197
- ].join(':')
198
-
199
- # Create a SHA-256 hash and take first 8 bytes
200
- digest = OpenSSL::Digest::SHA256.digest(components)
201
- Base64.urlsafe_encode64(digest[0..7], padding: false)
110
+ def generate_keypair
111
+ JOSE::JWK.generate_key([:ec, CURVE])
112
+ rescue StandardError => e
113
+ raise KeyError, "Failed to generate keypair: #{e.message}"
114
+ end
115
+
116
+ def sign(data)
117
+ @key.sign(data, { 'alg' => ALGORITHM }).compact
118
+ rescue StandardError => e
119
+ raise KeyError, "Failed to sign data: #{e.message}"
120
+ end
121
+
122
+ def deep_stringify_keys(obj)
123
+ case obj
124
+ when Hash
125
+ obj.each_with_object({}) do |(k, v), hash|
126
+ hash[k.to_s] = deep_stringify_keys(v)
127
+ end
128
+ when Array
129
+ obj.map { |v| deep_stringify_keys(v) }
130
+ else
131
+ obj
132
+ end
133
+ end
134
+
135
+ def verify(signed_jws)
136
+ verified, payload, = @key.verify(signed_jws)
137
+ verified
138
+ rescue StandardError => e
139
+ raise KeyError, "Failed to verify signature: #{e.message}"
202
140
  end
203
141
 
204
- # Validates that the keypair meets DPoP requirements
205
- # @raise [KeyError] if validation fails
206
142
  def validate_keypair
207
- # Check that we have a valid EC key
208
- raise KeyError, "Invalid key type: #{@keypair.kty}, must be :ec" unless @keypair.kty.is_a?(JOSE::JWK::KTY_EC)
143
+ raise KeyError, "Invalid key type: #{@key.kty}, must be :ec" unless @key.kty.is_a?(JOSE::JWK::KTY_EC)
209
144
 
210
- # Verify the curve
211
- curve = @keypair.to_map['crv']
145
+ curve = @key.to_map['crv']
212
146
  raise KeyError, "Invalid curve: #{curve}, must be #{CURVE}" unless curve == CURVE
213
147
 
214
148
  # Verify we can perform basic operations
@@ -14,61 +14,64 @@ module AuthProto
14
14
 
15
15
  #
16
16
  # Manages DPoP nonces provided by servers during the OAuth flow.
17
- # Tracks separate nonces for each server using persistent storage.
17
+ # Tracks separate nonces for each session and server using persistent storage.
18
18
  # Thread-safe to handle concurrent requests.
19
19
  #
20
20
 
21
21
  class NonceManager
22
22
 
23
+ # Regexp for checking the nonce value for invalid characters
24
+ NONCE_REGEXP = /\A[\x21\x23-\x5B\x5D-\x7E]+\z/
25
+
23
26
  # Error for nonce-related issues
24
27
  class NonceError < AuthProto::Error; end
25
28
 
26
- # Default stored nonce lifetime in seconds.
27
- # @return [Integer]
28
- DEFAULT_TTL = 300 # 5 minutes
29
-
30
29
  # Creates a nonce manager using the global storage backend.
31
30
  #
32
- # @param ttl [Integer, nil] nonce lifetime in seconds; `nil` uses {DEFAULT_TTL}
31
+ # @param ttl [Integer, nil] nonce lifetime in seconds; `nil` uses {Configuration#dpop_nonce_lifetime}
33
32
  # @raise [ArgumentError] if the master encryption key is not valid Base64
34
33
  #
35
34
  def initialize(ttl: nil)
36
- @ttl = ttl || DEFAULT_TTL
35
+ @ttl = ttl || AuthProto.configuration.dpop_nonce_lifetime
37
36
  @serializer = Serialization::StoredNonce.new
38
37
  end
39
38
 
40
- # Stores a nonce for the URL's origin, replacing any previous value.
39
+ # Stores a nonce for the session and URL's origin, replacing any previous value.
41
40
  #
42
- # @param nonce [String] nonempty server-provided nonce
41
+ # @param nonce [String] server-provided nonce using RFC 9449's `1*NQCHAR` syntax
42
+ # @param session_id [String] nonempty OAuth session identifier
43
43
  # @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
44
44
  # @return [void]
45
45
  # @raise [NonceError] if inputs are invalid or storage reports failure
46
46
  # @raise [Storage::StorageError] if backend validation or storage fails
47
47
  #
48
- def update(nonce:, server_url:)
48
+ def update(nonce:, server_url:, session_id:)
49
+ validate_session_id(session_id)
49
50
  validate_inputs(nonce, server_url)
50
51
  origin = normalize_server_url(server_url)
51
52
 
52
53
  stored_nonce = StoredNonce.new(nonce, origin)
53
54
  serialized = @serializer.serialize(stored_nonce)
54
55
 
55
- key = Storage::KeyBuilder.nonce_key(origin)
56
+ key = Storage::KeyBuilder.nonce_key(session_id, origin)
56
57
  return if AuthProto.storage.set(key, serialized, ttl: @ttl)
57
58
 
58
59
  raise NonceError, "Failed to store nonce"
59
60
  end
60
61
 
61
- # Retrieves the current nonce for a URL's origin.
62
+ # Retrieves the current nonce for a session and URL's origin.
62
63
  #
64
+ # @param session_id [String] nonempty OAuth session identifier
63
65
  # @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
64
66
  # @return [String, nil] stored nonce, or `nil` when missing/expired according to storage TTL
65
- # @raise [NonceError] if the URL is invalid or serialized data cannot be read
67
+ # @raise [NonceError] if the session ID or URL is invalid, or serialized data cannot be read
66
68
  # @raise [Storage::StorageError] if storage retrieval fails
67
69
  #
68
- def get(server_url)
70
+ def get(server_url:, session_id:)
71
+ validate_session_id(session_id)
69
72
  validate_server_url(server_url)
70
73
  origin = normalize_server_url(server_url)
71
- key = Storage::KeyBuilder.nonce_key(origin)
74
+ key = Storage::KeyBuilder.nonce_key(session_id, origin)
72
75
 
73
76
  stored = AuthProto.storage.get(key)
74
77
  return nil unless stored
@@ -81,22 +84,30 @@ module AuthProto
81
84
  end
82
85
  end
83
86
 
84
- # Removes the stored nonce for a URL's origin.
87
+ # Removes the stored nonce for a session and URL's origin.
85
88
  #
89
+ # @param session_id [String] nonempty OAuth session identifier
86
90
  # @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
87
91
  # @return [Boolean] backend deletion status
88
- # @raise [NonceError] if the URL is invalid
92
+ # @raise [NonceError] if the session ID or URL is invalid
89
93
  # @raise [Storage::StorageError] if deletion fails
90
94
  #
91
- def clear(server_url)
95
+ def clear(server_url:, session_id:)
96
+ validate_session_id(session_id)
92
97
  validate_server_url(server_url)
93
98
  origin = normalize_server_url(server_url)
94
- key = Storage::KeyBuilder.nonce_key(origin)
99
+ key = Storage::KeyBuilder.nonce_key(session_id, origin)
95
100
  AuthProto.storage.delete(key)
96
101
  end
97
102
 
98
103
  private
99
104
 
105
+ def validate_session_id(session_id)
106
+ return if session_id.is_a?(String) && !session_id.empty?
107
+
108
+ raise NonceError, "session_id must be a nonempty string"
109
+ end
110
+
100
111
  def normalize_server_url(url)
101
112
  uri = URI(url)
102
113
  port = uri.port
@@ -109,7 +120,9 @@ module AuthProto
109
120
  end
110
121
 
111
122
  def validate_inputs(nonce, server_url)
112
- raise NonceError, "nonce is required" if nonce.nil? || nonce.empty?
123
+ raise NonceError, "nonce must not be empty" if nonce.to_s == ''
124
+ raise NonceError, "Invalid DPoP nonce" unless nonce.is_a?(String)
125
+ raise NonceError, "Invalid characters in the DPoP nonce" unless nonce.ascii_only? && nonce =~ NONCE_REGEXP
113
126
 
114
127
  validate_server_url(server_url)
115
128
  end