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,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ require_relative '../errors'
6
+ require_relative 'key_manager'
7
+ require_relative 'nonce_manager'
8
+ require_relative 'proof_generator'
9
+
10
+ module AuthProto
11
+
12
+ #
13
+ # Key management, per-request proofs and server-origin nonces for DPoP authentication.
14
+ #
15
+
16
+ module DPoP
17
+
18
+ # High-level client for managing DPoP operations. Integrates key management,
19
+ # proof generation, and nonce tracking to provide a complete DPoP client
20
+ # implementation according to RFC 9449.
21
+ #
22
+ # This client handles:
23
+ # - Key management for signing proofs
24
+ # - Proof generation for HTTP requests
25
+ # - Nonce tracking across servers
26
+ # - Header construction for requests
27
+ # - Response processing for nonce updates
28
+ #
29
+
30
+ class Client
31
+
32
+ # Error raised for DPoP client operations
33
+ class Error < AuthProto::Error; end
34
+
35
+ # Key manager used to sign proofs.
36
+ # @return [KeyManager]
37
+ attr_reader :key_manager
38
+
39
+ # Generator for per-request DPoP JWTs.
40
+ # @return [ProofGenerator]
41
+ attr_reader :proof_generator
42
+
43
+ # Persistent nonce store, indexed by server origin.
44
+ # @return [NonceManager]
45
+ attr_reader :nonce_manager
46
+
47
+ # Creates DPoP signing, proof and nonce managers.
48
+ #
49
+ # @param key_manager [KeyManager, nil] existing key manager, or `nil` to generate a keypair
50
+ # @param nonce_ttl [Integer, nil] stored nonce lifetime in seconds, or the nonce manager's default
51
+ # @raise [KeyManager::KeyError] if key generation fails
52
+ # @raise [ProofGenerator::ProofError] if the key manager is invalid
53
+ # @raise [ArgumentError] if the encryption master key is not valid Base64
54
+ #
55
+ def initialize(key_manager: nil, nonce_ttl: nil)
56
+ @key_manager = key_manager || KeyManager.new
57
+ @nonce_manager = NonceManager.new(ttl: nonce_ttl)
58
+ @proof_generator = ProofGenerator.new(@key_manager)
59
+ end
60
+
61
+ # Generates a fresh DPoP proof using a supplied or stored nonce for the origin.
62
+ #
63
+ # @param http_method [String] HTTP method, converted to uppercase
64
+ # @param http_uri [String] absolute HTTP(S) URL; callers should omit the query and fragment
65
+ # @param access_token [String, nil] token to bind with an `ath` claim
66
+ # @param nonce [String, nil] explicit nonce; `nil` looks up the origin's stored nonce
67
+ # @return [JOSE::SignedBinary] compact signed DPoP JWT
68
+ # @raise [Error] if URI parsing, nonce lookup or signing fails
69
+ #
70
+ def generate_proof(http_method:, http_uri:, access_token: nil, nonce: nil)
71
+ uri = URI(http_uri)
72
+ server_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
73
+
74
+ # Use provided nonce or get one from the manager
75
+ nonce ||= @nonce_manager.get(server_url)
76
+
77
+ @proof_generator.generate(
78
+ http_method: http_method,
79
+ http_uri: http_uri,
80
+ nonce: nonce,
81
+ access_token: access_token
82
+ )
83
+ rescue StandardError => e
84
+ raise Error, "Failed to generate proof: #{e.message}"
85
+ end
86
+
87
+ # Stores a `DPoP-Nonce` response header for future requests to the same origin.
88
+ # Header names are compared case-insensitively. Missing headers/nonces are ignored.
89
+ #
90
+ # @param response_headers [Hash{String => String}, nil] response headers, or `nil`
91
+ # @param server_url [String] request URL whose origin owns the nonce
92
+ # @return [void]
93
+ # @raise [Error] if nonce validation or persistence fails
94
+ #
95
+ def process_response(response_headers, server_url)
96
+ return unless response_headers
97
+
98
+ # Look for DPoP-Nonce header (case insensitive)
99
+ nonce = response_headers.find { |k, _| k.downcase == "dpop-nonce" }&.last
100
+ return unless nonce
101
+
102
+ # Store new nonce for future requests
103
+ @nonce_manager.update(nonce: nonce, server_url: server_url)
104
+ rescue StandardError => e
105
+ raise Error, "Failed to process response: #{e.message}"
106
+ end
107
+
108
+ # Wraps a proof in an HTTP header hash.
109
+ #
110
+ # @param proof [String] signed DPoP JWT
111
+ # @return [Hash{String => String}] a single `DPoP` header
112
+ #
113
+ def request_headers(proof)
114
+ {
115
+ "DPoP" => proof
116
+ }
117
+ end
118
+
119
+ # Exports the current public signing key.
120
+ #
121
+ # @return [Hash{String => Object}] public JWK
122
+ # @raise [KeyManager::KeyError] if public-key export fails
123
+ #
124
+ def public_key
125
+ @key_manager.public_jwk
126
+ end
127
+
128
+ # Exports the current DPoP key for persistence or inspection.
129
+ #
130
+ # @param include_private [Boolean] include the private key needed to restore the same identity
131
+ # @return [JOSE::Map] JWK fields; call `to_h` for a Ruby hash
132
+ # @raise [KeyManager::KeyError] if key export fails
133
+ #
134
+ def export_key(include_private: false)
135
+ @key_manager.to_jwk(include_private: include_private)
136
+ end
137
+
138
+
139
+ private
140
+
141
+ def extract_nonce(headers)
142
+ # Headers can be hash with string or symbol keys, or http headers object
143
+ headers = headers.to_h if headers.respond_to?(:to_h)
144
+
145
+ # Try different common header key formats
146
+ nonce = headers["DPoP-Nonce"] || headers["dpop-nonce"] || headers[:dpop_nonce]
147
+
148
+ nonce&.strip
149
+ end
150
+
151
+ def origin_for_uri(uri)
152
+ port = uri.port
153
+ port = nil if (uri.scheme == "https" && port == 443) || (uri.scheme == "http" && port == 80)
154
+
155
+ origin = "#{uri.scheme}://#{uri.host}"
156
+ origin = "#{origin}:#{port}" if port
157
+ origin
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'jose'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ require_relative '../errors'
9
+
10
+ module AuthProto
11
+ module DPoP
12
+
13
+ #
14
+ # Manages ES256 keypair generation and storage for DPoP proofs.
15
+ # Provides functionality to generate new keys and store them securely.
16
+ # Uses JOSE for cryptographic operations and key format handling.
17
+ #
18
+
19
+ class KeyManager
20
+
21
+ # Error raised when key operations fail
22
+ class KeyError < AuthProto::Error; end
23
+
24
+ # Elliptic curve used for DPoP signing keys.
25
+ # @return [String]
26
+ CURVE = "P-256"
27
+
28
+ # JWS signing algorithm used for DPoP proofs.
29
+ # @return [String]
30
+ ALGORITHM = "ES256"
31
+
32
+ # Private EC P-256 keypair used for signing.
33
+ # @return [JOSE::JWK]
34
+ attr_reader :keypair
35
+
36
+ # Creates a new KeyManager instance with an optional existing keypair
37
+ #
38
+ # @param keypair [JOSE::JWK, nil] Optional existing keypair to use
39
+ # @raise [KeyError] if the provided keypair is invalid
40
+ #
41
+ def initialize(keypair = nil)
42
+ @keypair = keypair || generate_keypair
43
+ validate_keypair
44
+ end
45
+
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.
74
+ #
75
+ # @return [Hash{String => Object}] public JWK without private key material
76
+ # @raise [KeyError] if public-key export fails
77
+ #
78
+ 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
84
+ rescue StandardError => e
85
+ raise KeyError, "Failed to export public key: #{e.message}"
86
+ end
87
+
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
+ # Signs a JSON payload with the supplied protected JWS header and ES256.
101
+ # Hash keys are recursively converted to strings. The algorithm is forced to `ES256`.
102
+ #
103
+ # @param header [Hash{String, Symbol => Object}] protected JOSE header fields
104
+ # @param payload [Hash{String, Symbol => Object}] JWT claims to JSON-encode
105
+ # @return [JOSE::SignedBinary] compact JWS (a string subclass)
106
+ # @raise [KeyError] if encoding or signing fails
107
+ #
108
+ def sign_segments(header, payload)
109
+ # Deep transform all keys to strings to avoid symbol comparison issues
110
+ header = deep_stringify_keys(header)
111
+ payload = deep_stringify_keys(payload)
112
+
113
+ # Configure JOSE to use ES256 for signing
114
+ signing_config = { "alg" => "ES256" }
115
+
116
+ # Merge our header with JOSE's required fields
117
+ full_header = header.merge(signing_config)
118
+
119
+ # Convert payload to JSON string before signing
120
+ payload_json = JSON.generate(payload)
121
+
122
+ # Create the JWS with our header and payload
123
+ jws = @keypair.sign(payload_json, full_header)
124
+
125
+ # Get the compact serialization
126
+ jws.compact
127
+ rescue StandardError => e
128
+ raise KeyError, "Failed to sign segments: #{e.message}"
129
+ end
130
+
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.
163
+ #
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
166
+ # @raise [KeyError] if export fails
167
+ #
168
+ def to_jwk(include_private: false)
169
+ key = include_private ? @keypair : @keypair.to_public
170
+ key.to_map
171
+ rescue StandardError => e
172
+ raise KeyError, "Failed to export key: #{e.message}"
173
+ end
174
+
175
+ # Imports an EC P-256 JWK and validates that it can sign and verify.
176
+ #
177
+ # @param jwk [Hash{String => Object}, JOSE::Map] JWK including private key material
178
+ # @return [KeyManager] imported key manager
179
+ # @raise [KeyError] if import or key validation fails
180
+ #
181
+ def self.from_jwk(jwk)
182
+ keypair = JOSE::JWK.from_map(jwk)
183
+ new(keypair)
184
+ rescue StandardError => e
185
+ raise KeyError, "Failed to import key: #{e.message}"
186
+ end
187
+
188
+ private
189
+
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)
202
+ end
203
+
204
+ # Validates that the keypair meets DPoP requirements
205
+ # @raise [KeyError] if validation fails
206
+ 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)
209
+
210
+ # Verify the curve
211
+ curve = @keypair.to_map["crv"]
212
+ raise KeyError, "Invalid curve: #{curve}, must be #{CURVE}" unless curve == CURVE
213
+
214
+ # Verify we can perform basic operations
215
+ test_data = "test"
216
+ signed = sign(test_data)
217
+ raise KeyError, "Key validation failed: signature verification error" unless verify(signed)
218
+ rescue StandardError => e
219
+ raise KeyError, "Key validation failed: #{e.message}"
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ require_relative '../configuration'
6
+ require_relative '../errors'
7
+ require_relative '../serialization/stored_nonce'
8
+ require_relative '../storage/key_builder'
9
+ require_relative 'stored_nonce'
10
+
11
+ module AuthProto
12
+ module DPoP
13
+
14
+ #
15
+ # Manages DPoP nonces provided by servers during the OAuth flow.
16
+ # Tracks separate nonces for each server using persistent storage.
17
+ # Thread-safe to handle concurrent requests.
18
+ #
19
+
20
+ class NonceManager
21
+
22
+ # Error for nonce-related issues
23
+ class NonceError < AuthProto::Error; end
24
+
25
+ # Default stored nonce lifetime in seconds.
26
+ # @return [Integer]
27
+ DEFAULT_TTL = 300 # 5 minutes
28
+
29
+ # Creates a nonce manager using the global storage backend.
30
+ #
31
+ # @param ttl [Integer, nil] nonce lifetime in seconds; `nil` uses {DEFAULT_TTL}
32
+ # @raise [ArgumentError] if the master encryption key is not valid Base64
33
+ #
34
+ def initialize(ttl: nil)
35
+ @ttl = ttl || DEFAULT_TTL
36
+ @serializer = Serialization::StoredNonce.new
37
+ end
38
+
39
+ # Stores a nonce for the URL's origin, replacing any previous value.
40
+ #
41
+ # @param nonce [String] nonempty server-provided nonce
42
+ # @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
43
+ # @return [void]
44
+ # @raise [NonceError] if inputs are invalid or storage reports failure
45
+ # @raise [Storage::StorageError] if backend validation or storage fails
46
+ #
47
+ def update(nonce:, server_url:)
48
+ validate_inputs(nonce, server_url)
49
+ origin = normalize_server_url(server_url)
50
+
51
+ stored_nonce = StoredNonce.new(nonce, origin)
52
+ serialized = @serializer.serialize(stored_nonce)
53
+
54
+ key = Storage::KeyBuilder.nonce_key(origin)
55
+ return if AuthProto.storage.set(key, serialized, ttl: @ttl)
56
+
57
+ raise NonceError, "Failed to store nonce"
58
+ end
59
+
60
+ # Retrieves the current nonce for a URL's origin.
61
+ #
62
+ # @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
63
+ # @return [String, nil] stored nonce, or `nil` when missing/expired according to storage TTL
64
+ # @raise [NonceError] if the URL is invalid or serialized data cannot be read
65
+ # @raise [Storage::StorageError] if storage retrieval fails
66
+ #
67
+ def get(server_url)
68
+ validate_server_url(server_url)
69
+ origin = normalize_server_url(server_url)
70
+ key = Storage::KeyBuilder.nonce_key(origin)
71
+
72
+ stored = AuthProto.storage.get(key)
73
+ return nil unless stored
74
+
75
+ begin
76
+ stored_nonce = @serializer.deserialize(stored)
77
+ stored_nonce.value
78
+ rescue Serialization::Error => e
79
+ raise NonceError, "Failed to deserialize nonce: #{e.message}"
80
+ end
81
+ end
82
+
83
+ # Removes the stored nonce for a URL's origin.
84
+ #
85
+ # @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
86
+ # @return [Boolean] backend deletion status
87
+ # @raise [NonceError] if the URL is invalid
88
+ # @raise [Storage::StorageError] if deletion fails
89
+ #
90
+ def clear(server_url)
91
+ validate_server_url(server_url)
92
+ origin = normalize_server_url(server_url)
93
+ key = Storage::KeyBuilder.nonce_key(origin)
94
+ AuthProto.storage.delete(key)
95
+ end
96
+
97
+ # Checks for an unexpired nonce record in storage for a URL's origin.
98
+ #
99
+ # @param server_url [String] HTTPS URL, or HTTP URL with host `localhost`
100
+ # @return [Boolean] whether the backend reports a nonce record present
101
+ # @raise [NonceError] if the URL is invalid
102
+ # @raise [Storage::StorageError] if the existence check fails
103
+ #
104
+ def valid_nonce?(server_url)
105
+ validate_server_url(server_url)
106
+ origin = normalize_server_url(server_url)
107
+ key = Storage::KeyBuilder.nonce_key(origin)
108
+ AuthProto.storage.exists?(key)
109
+ end
110
+
111
+ private
112
+
113
+ def normalize_server_url(url)
114
+ uri = URI(url)
115
+ port = uri.port
116
+ port = nil if (uri.scheme == "https" && port == 443) ||
117
+ (uri.scheme == "http" && port == 80)
118
+
119
+ origin = "#{uri.scheme}://#{uri.host}"
120
+ origin = "#{origin}:#{port}" if port
121
+ origin
122
+ end
123
+
124
+ def validate_inputs(nonce, server_url)
125
+ raise NonceError, "nonce is required" if nonce.nil? || nonce.empty?
126
+
127
+ validate_server_url(server_url)
128
+ end
129
+
130
+ def validate_server_url(server_url)
131
+ raise NonceError, "server_url is required" if server_url.nil? || server_url.empty?
132
+
133
+ uri = URI(server_url)
134
+ raise NonceError, "server_url must be HTTP(S)" unless uri.is_a?(URI::HTTP)
135
+
136
+ # Allow HTTP for localhost only
137
+ if uri.host != "localhost" && uri.scheme != "https"
138
+ raise NonceError, "server_url must be HTTPS (except for localhost)"
139
+ end
140
+ rescue URI::InvalidURIError => e
141
+ raise NonceError, "invalid server_url: #{e.message}"
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'openssl'
5
+ require 'securerandom'
6
+ require 'uri'
7
+
8
+ require_relative '../errors'
9
+ require_relative 'key_manager'
10
+
11
+ module AuthProto
12
+ module DPoP
13
+
14
+ #
15
+ # Creates and manages DPoP proof JWTs according to RFC 9449.
16
+ # DPoP proofs are used to prove possession of a key when making
17
+ # HTTP requests. Each proof is a JWT that includes details about
18
+ # the request and is signed by the DPoP key.
19
+ #
20
+
21
+ class ProofGenerator
22
+
23
+ # Error raised for proof generation/validation issues
24
+ class ProofError < AuthProto::Error; end
25
+
26
+ # Key manager used to sign proofs.
27
+ # @return [KeyManager]
28
+ attr_reader :key_manager
29
+
30
+ # Creates a new ProofGenerator instance
31
+ #
32
+ # @param key_manager [KeyManager] Key manager to use for signing proofs
33
+ # @raise [ProofError] if key_manager is invalid
34
+ #
35
+ def initialize(key_manager)
36
+ raise ProofError, "key_manager is required" unless key_manager
37
+ raise ProofError, "invalid key_manager type" unless key_manager.is_a?(KeyManager)
38
+
39
+ @key_manager = key_manager
40
+ end
41
+
42
+ # Builds and signs a fresh DPoP JWT with `jti`, `htm`, `htu` and `iat` claims.
43
+ #
44
+ # A nonce and access-token hash are added when requested. The URI normalizer removes
45
+ # fragments and default ports; callers must remove query strings before calling.
46
+ #
47
+ # @param http_method [String] nonempty HTTP method, converted to uppercase
48
+ # @param http_uri [String] absolute HTTP(S) request URL without a query string
49
+ # @param nonce [String, nil] server-provided nonce to include
50
+ # @param access_token [String, nil] access token to bind to the proof
51
+ # @param ath [Boolean, nil] include an access-token hash; `nil` enables it when a token is supplied
52
+ # @return [JOSE::SignedBinary] compact signed DPoP JWT
53
+ # @raise [ProofError] if input validation or proof generation fails
54
+ #
55
+ def generate(http_method:, http_uri:, nonce: nil, access_token: nil, ath: nil)
56
+ validate_inputs(http_method, http_uri)
57
+ ath = !access_token.nil? if ath.nil?
58
+
59
+ header = build_header
60
+ payload = build_payload(
61
+ http_method: http_method,
62
+ http_uri: http_uri,
63
+ nonce: nonce,
64
+ access_token: access_token,
65
+ include_ath: ath
66
+ )
67
+
68
+ key_manager.sign_segments(header, payload)
69
+ rescue StandardError => e
70
+ raise ProofError, "Failed to generate proof: #{e.message}"
71
+ end
72
+
73
+ private
74
+
75
+ def validate_inputs(http_method, http_uri)
76
+ raise ProofError, "http_method is required" if http_method.nil? || http_method.empty?
77
+ raise ProofError, "http_uri is required" if http_uri.nil? || http_uri.empty?
78
+
79
+ uri = URI(http_uri)
80
+ raise ProofError, "invalid http_uri" unless uri.is_a?(URI::HTTP)
81
+ rescue URI::InvalidURIError => e
82
+ raise ProofError, "invalid http_uri: #{e.message}"
83
+ end
84
+
85
+ def build_header
86
+ {
87
+ typ: "dpop+jwt",
88
+ alg: "ES256",
89
+ jwk: key_manager.public_jwk.to_h
90
+ }
91
+ end
92
+
93
+ def build_payload(http_method:, http_uri:, nonce: nil, access_token: nil, include_ath: nil)
94
+ payload = {
95
+ "jti" => SecureRandom.uuid,
96
+ "htm" => http_method.upcase,
97
+ "htu" => normalize_uri(http_uri),
98
+ "iat" => Time.now.to_i
99
+ }
100
+
101
+ # Add the nonce if provided
102
+ payload["nonce"] = nonce if nonce
103
+
104
+ # Add access token hash if needed
105
+ payload["ath"] = generate_access_token_hash(access_token) if access_token && include_ath
106
+
107
+ payload
108
+ end
109
+
110
+ def normalize_uri(uri)
111
+ uri = URI(uri)
112
+ # Remove default ports
113
+ uri.port = nil if (uri.scheme == "https" && uri.port == 443) || (uri.scheme == "http" && uri.port == 80)
114
+ uri.fragment = nil
115
+ uri.to_s
116
+ end
117
+
118
+ def generate_access_token_hash(access_token)
119
+ digest = OpenSSL::Digest::SHA256.digest(access_token)
120
+ Base64.urlsafe_encode64(digest, padding: false)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AuthProto
4
+ module DPoP
5
+
6
+ #
7
+ # Represents a stored nonce with its server URL
8
+ #
9
+
10
+ class StoredNonce
11
+
12
+ # Nonce value received from the server.
13
+ # @return [String]
14
+ attr_reader :value
15
+
16
+ # Server origin associated with the nonce.
17
+ # @return [String]
18
+ attr_reader :server_url
19
+
20
+ # Creation timestamp; defaults to integer Unix seconds.
21
+ # @return [Integer, Time]
22
+ attr_reader :timestamp
23
+
24
+ # Creates a nonce record without validation.
25
+ #
26
+ # @param value [String] server-provided nonce
27
+ # @param server_url [String] server origin URL
28
+ # @param timestamp [Integer, Time, nil] timestamp to retain, or `nil` for current Unix seconds
29
+ #
30
+ def initialize(value, server_url, timestamp: nil)
31
+ @value = value
32
+ @server_url = server_url
33
+ @timestamp = timestamp || Time.now.to_i
34
+ end
35
+ end
36
+ end
37
+ end