atproto_auth 0.0.1

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 (67) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +16 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +179 -0
  6. data/Rakefile +16 -0
  7. data/examples/confidential_client/Gemfile +12 -0
  8. data/examples/confidential_client/Gemfile.lock +84 -0
  9. data/examples/confidential_client/README.md +110 -0
  10. data/examples/confidential_client/app.rb +136 -0
  11. data/examples/confidential_client/config/client-metadata.json +25 -0
  12. data/examples/confidential_client/config.ru +4 -0
  13. data/examples/confidential_client/public/client-metadata.json +24 -0
  14. data/examples/confidential_client/public/styles.css +70 -0
  15. data/examples/confidential_client/scripts/generate_keys.rb +15 -0
  16. data/examples/confidential_client/views/authorized.erb +29 -0
  17. data/examples/confidential_client/views/index.erb +44 -0
  18. data/examples/confidential_client/views/layout.erb +11 -0
  19. data/lib/atproto_auth/client.rb +410 -0
  20. data/lib/atproto_auth/client_metadata.rb +264 -0
  21. data/lib/atproto_auth/configuration.rb +17 -0
  22. data/lib/atproto_auth/dpop/client.rb +122 -0
  23. data/lib/atproto_auth/dpop/key_manager.rb +235 -0
  24. data/lib/atproto_auth/dpop/nonce_manager.rb +138 -0
  25. data/lib/atproto_auth/dpop/proof_generator.rb +112 -0
  26. data/lib/atproto_auth/errors.rb +47 -0
  27. data/lib/atproto_auth/http_client.rb +227 -0
  28. data/lib/atproto_auth/identity/document.rb +104 -0
  29. data/lib/atproto_auth/identity/resolver.rb +221 -0
  30. data/lib/atproto_auth/identity.rb +24 -0
  31. data/lib/atproto_auth/par/client.rb +203 -0
  32. data/lib/atproto_auth/par/client_assertion.rb +50 -0
  33. data/lib/atproto_auth/par/request.rb +140 -0
  34. data/lib/atproto_auth/par/response.rb +23 -0
  35. data/lib/atproto_auth/par.rb +40 -0
  36. data/lib/atproto_auth/pkce.rb +105 -0
  37. data/lib/atproto_auth/server_metadata/authorization_server.rb +175 -0
  38. data/lib/atproto_auth/server_metadata/origin_url.rb +51 -0
  39. data/lib/atproto_auth/server_metadata/resource_server.rb +71 -0
  40. data/lib/atproto_auth/server_metadata.rb +24 -0
  41. data/lib/atproto_auth/state/session.rb +117 -0
  42. data/lib/atproto_auth/state/session_manager.rb +75 -0
  43. data/lib/atproto_auth/state/token_set.rb +68 -0
  44. data/lib/atproto_auth/state.rb +54 -0
  45. data/lib/atproto_auth/version.rb +5 -0
  46. data/lib/atproto_auth.rb +56 -0
  47. data/sig/atproto_auth/client_metadata.rbs +95 -0
  48. data/sig/atproto_auth/dpop/client.rbs +38 -0
  49. data/sig/atproto_auth/dpop/key_manager.rbs +33 -0
  50. data/sig/atproto_auth/dpop/nonce_manager.rbs +48 -0
  51. data/sig/atproto_auth/dpop/proof_generator.rbs +42 -0
  52. data/sig/atproto_auth/http_client.rbs +58 -0
  53. data/sig/atproto_auth/identity/document.rbs +31 -0
  54. data/sig/atproto_auth/identity/resolver.rbs +41 -0
  55. data/sig/atproto_auth/par/client.rbs +31 -0
  56. data/sig/atproto_auth/par/request.rbs +73 -0
  57. data/sig/atproto_auth/par/response.rbs +17 -0
  58. data/sig/atproto_auth/pkce.rbs +24 -0
  59. data/sig/atproto_auth/server_metadata/authorization_server.rbs +69 -0
  60. data/sig/atproto_auth/server_metadata/origin_url.rbs +21 -0
  61. data/sig/atproto_auth/server_metadata/resource_server.rbs +27 -0
  62. data/sig/atproto_auth/state/session.rbs +50 -0
  63. data/sig/atproto_auth/state/session_manager.rbs +26 -0
  64. data/sig/atproto_auth/state/token_set.rbs +40 -0
  65. data/sig/atproto_auth/version.rbs +3 -0
  66. data/sig/atproto_auth.rbs +39 -0
  67. metadata +142 -0
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+
6
+ module AtprotoAuth
7
+ module ApplicationType
8
+ ALL = [
9
+ WEB = "web",
10
+ NATIVE = "native"
11
+ ].freeze
12
+ end
13
+
14
+ # Handles validation and management of AT Protocol OAuth client metadata according to
15
+ # the specification. This includes required fields like client_id and redirect URIs,
16
+ # optional metadata like client name and logo, and authentication configuration for
17
+ # confidential clients. Validates that all fields conform to the protocol's requirements,
18
+ # including:
19
+ # - Application type (web/native) validation and redirect URI rules
20
+ # - Required scopes and grant types
21
+ # - JWKS configuration for confidential clients
22
+ # - DPoP binding requirements
23
+ # - URI scheme and format validation
24
+ class ClientMetadata
25
+ # Required fields
26
+ attr_reader :application_type, :client_id, :grant_types, :response_types, :redirect_uris, :scope
27
+ # Optional fields
28
+ attr_reader :client_name, :client_uri, :logo_uri, :tos_uri, :policy_uri
29
+ # Authentication and key-related fields
30
+ attr_reader :token_endpoint_auth_method, :jwks, :jwks_uri
31
+
32
+ # Initializes a new ClientMetadata instance from metadata hash.
33
+ # @param metadata [Hash] Client metadata.
34
+ # @raise [InvalidClientMetadata] if metadata is invalid.
35
+ def initialize(metadata)
36
+ validate_and_set_metadata!(metadata)
37
+ end
38
+
39
+ # Fetches client metadata from a URL and creates a new instance.
40
+ # @param url [String] URL to fetch metadata from.
41
+ # @return [ClientMetadata] new instance with fetched metadata.
42
+ # @raise [InvalidClientMetadata] if metadata is invalid or cannot be fetched.
43
+ def self.from_url(url)
44
+ validate_url!(url)
45
+ response = fetch_metadata(url)
46
+ metadata = parse_metadata(response[:body])
47
+ validate_client_id!(metadata["client_id"], url)
48
+ new(metadata)
49
+ end
50
+
51
+ # Determines if the client is confidential (has authentication keys).
52
+ # @return [Boolean] true if client is confidential.
53
+ def confidential?
54
+ token_endpoint_auth_method == "private_key_jwt"
55
+ end
56
+
57
+ private
58
+
59
+ def validate_and_set_metadata!(metadata) # rubocop:disable Metrics/AbcSize
60
+ # Required fields
61
+ @application_type = validate_application_type(metadata["application_type"])
62
+ @client_id = validate_client_id!(metadata["client_id"])
63
+ @grant_types = validate_grant_types!(metadata["grant_types"])
64
+ @response_types = validate_response_types!(metadata["response_types"])
65
+ @redirect_uris = validate_redirect_uris!(metadata["redirect_uris"])
66
+ @scope = validate_scope!(metadata["scope"])
67
+
68
+ validate_dpop!(metadata)
69
+
70
+ # Optional fields
71
+ @client_name = metadata["client_name"]
72
+ @client_uri = validate_client_uri(metadata["client_uri"])
73
+ @logo_uri = validate_https_uri(metadata["logo_uri"])
74
+ @tos_uri = validate_https_uri(metadata["tos_uri"])
75
+ @policy_uri = validate_https_uri(metadata["policy_uri"])
76
+
77
+ # Authentication methods
78
+ validate_auth_methods!(metadata)
79
+ end
80
+
81
+ def validate_client_id!(client_id)
82
+ raise InvalidClientMetadata, "client_id is required" unless client_id
83
+
84
+ uri = URI(client_id)
85
+ unless uri.scheme == "https" || (uri.scheme == "http" && uri.host == "localhost")
86
+ raise InvalidClientMetadata, "client_id must be HTTPS or localhost HTTP URL"
87
+ end
88
+
89
+ client_id
90
+ end
91
+
92
+ def validate_grant_types!(grant_types)
93
+ raise InvalidClientMetadata, "grant_types is required" unless grant_types
94
+
95
+ valid_types = %w[authorization_code refresh_token]
96
+ unless grant_types.include?("authorization_code") && (grant_types - valid_types).empty?
97
+ raise InvalidClientMetadata, "grant_types must include authorization_code and optionally refresh_token"
98
+ end
99
+
100
+ grant_types
101
+ end
102
+
103
+ def validate_response_types!(response_types)
104
+ raise InvalidClientMetadata, "response_types is required" unless response_types
105
+ raise InvalidClientMetadata, "response_types must include 'code'" unless response_types.include?("code")
106
+
107
+ response_types
108
+ end
109
+
110
+ def validate_redirect_uris!(uris)
111
+ raise InvalidClientMetadata, "redirect_uris is required" if uris.nil? || uris.none?
112
+
113
+ uris.each { |uri| validate_redirect_uri!(URI(uri)) }
114
+ uris
115
+ end
116
+
117
+ def validate_redirect_uri!(uri)
118
+ case application_type
119
+ when ApplicationType::WEB
120
+ if uri.host != "127.0.0.1" && uri.scheme != "https"
121
+ raise InvalidClientMetadata, "web clients must use HTTPS redirect URIs #{uri}"
122
+ end
123
+
124
+ validate_redirect_uri_origin!(uri)
125
+ when ApplicationType::NATIVE
126
+ validate_native_redirect_uri!(uri)
127
+ end
128
+ end
129
+
130
+ def validate_redirect_uri_origin!(uri)
131
+ client_origin = URI(@client_id).host
132
+ valid = client_origin == "localhost" ? true : uri.host == client_origin
133
+ raise InvalidClientMetadata, "redirect URI must match client_id origin" unless valid
134
+ end
135
+
136
+ def validate_native_redirect_uri!(uri)
137
+ if uri.scheme == "http"
138
+ unless ["127.0.0.1", "[::1]"].include?(uri.host)
139
+ raise InvalidClientMetadata, "HTTP redirect URIs for native clients must use loopback IP"
140
+ end
141
+ else
142
+ validate_custom_scheme!(uri)
143
+ end
144
+ end
145
+
146
+ def validate_custom_scheme!(uri)
147
+ reversed_host = URI(@client_id).host.split(".").reverse
148
+ scheme_parts = uri.scheme.split(".")
149
+ unless scheme_parts == reversed_host
150
+ raise InvalidClientMetadata, "custom scheme must match reversed client_id domain"
151
+ end
152
+ raise InvalidClientMetadata, "custom scheme URI must have single path component" unless uri.path == "/"
153
+ end
154
+
155
+ def validate_scope!(scope)
156
+ raise InvalidClientMetadata, "scope is required" unless scope
157
+
158
+ scope_values = scope.split
159
+ raise InvalidClientMetadata, "atproto scope is required" unless scope_values.include?("atproto")
160
+
161
+ # validate_offline_access_scope!(scope_values)
162
+ scope
163
+ end
164
+
165
+ def validate_offline_access_scope!(scope_values)
166
+ has_refresh = @grant_types&.include?("refresh_token")
167
+ has_offline = scope_values.include?("offline_access")
168
+ return unless has_refresh != has_offline
169
+
170
+ raise InvalidClientMetadata, "offline_access scope must match refresh_token grant type"
171
+ end
172
+
173
+ def validate_application_type(type)
174
+ type ||= ApplicationType::WEB # Default to web
175
+ unless ApplicationType::ALL.include?(type)
176
+ raise InvalidClientMetadata,
177
+ "application_type must be 'web' or 'native'"
178
+ end
179
+
180
+ type
181
+ end
182
+
183
+ def validate_client_uri(uri)
184
+ return unless uri
185
+ raise InvalidClientMetadata, "client_uri must match client_id origin" unless URI(uri).host == URI(@client_id).host
186
+
187
+ uri
188
+ end
189
+
190
+ def validate_https_uri(uri)
191
+ return unless uri
192
+ raise InvalidClientMetadata, "URI must use HTTPS" unless URI(uri).scheme == "https"
193
+
194
+ uri
195
+ end
196
+
197
+ def validate_jwks!(jwks)
198
+ raise InvalidClientMetadata, "jwks must have keys array" unless jwks["keys"].is_a?(Array)
199
+
200
+ jwks["keys"].each_with_index do |key, index|
201
+ has_key_use_sig = key["use"] == "sig"
202
+ has_key_ops_sign = key["key_ops"]&.include?("sign")
203
+ if !has_key_use_sig && !has_key_ops_sign
204
+ raise InvalidClientMetadata, "jwks.keys.#{index} must have use='sig' or key_ops including 'sign'"
205
+ end
206
+
207
+ raise InvalidClientMetadata, "jwks.keys.#{index} must have kid" unless key["kid"]
208
+ end
209
+ end
210
+
211
+ def validate_auth_methods!(metadata) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
212
+ @token_endpoint_auth_method = metadata["token_endpoint_auth_method"]
213
+ return unless @token_endpoint_auth_method == "private_key_jwt"
214
+
215
+ # Validate auth signing algorithm
216
+ @token_endpoint_auth_signing_alg = metadata["token_endpoint_auth_signing_alg"]
217
+ unless @token_endpoint_auth_signing_alg == "ES256"
218
+ raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be ES256"
219
+ end
220
+
221
+ @jwks = metadata["jwks"]
222
+ @jwks_uri = metadata["jwks_uri"]
223
+ raise InvalidClientMetadata, "cannot use both jwks and jwks_uri" if @jwks && @jwks_uri
224
+ raise InvalidClientMetadata, "confidential clients must provide jwks or jwks_uri" unless @jwks || @jwks_uri
225
+
226
+ validate_jwks!(@jwks) if @jwks
227
+ validate_https_uri(@jwks_uri) if @jwks_uri
228
+ end
229
+
230
+ def validate_dpop!(metadata)
231
+ return if metadata["dpop_bound_access_tokens"] == true
232
+
233
+ raise InvalidClientMetadata, "dpop_bound_access_tokens must be true"
234
+ end
235
+
236
+ class << self
237
+ private
238
+
239
+ def validate_url!(url)
240
+ uri = URI(url)
241
+ return if uri.scheme == "https" || uri.host == "localhost"
242
+
243
+ raise InvalidClientMetadata, "client_id must use HTTPS except for localhost"
244
+ end
245
+
246
+ def fetch_metadata(url)
247
+ AtprotoAuth.configuration.http_client&.get(url) ||
248
+ raise(InvalidClientMetadata, "HTTP client not configured")
249
+ end
250
+
251
+ def parse_metadata(body)
252
+ JSON.parse(body)
253
+ rescue JSON::ParserError => e
254
+ raise InvalidClientMetadata, "Invalid JSON in client metadata: #{e.message}"
255
+ end
256
+
257
+ def validate_client_id!(metadata_client_id, url)
258
+ return if metadata_client_id == url
259
+
260
+ raise InvalidClientMetadata, "client_id mismatch: #{metadata_client_id} != #{url}"
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module AtprotoAuth
6
+ # Configuration class for global AtprotoAuth settings
7
+ class Configuration
8
+ attr_accessor :default_token_lifetime, :dpop_nonce_lifetime, :http_client, :logger
9
+
10
+ def initialize
11
+ @default_token_lifetime = 300 # 5 minutes in seconds
12
+ @dpop_nonce_lifetime = 300 # 5 minutes in seconds
13
+ @http_client = nil
14
+ @logger = Logger.new($stdout)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module DPoP
5
+ # High-level client for managing DPoP operations. Integrates key management,
6
+ # proof generation, and nonce tracking to provide a complete DPoP client
7
+ # implementation according to RFC 9449.
8
+ #
9
+ # This client handles:
10
+ # - Key management for signing proofs
11
+ # - Proof generation for HTTP requests
12
+ # - Nonce tracking across servers
13
+ # - Header construction for requests
14
+ # - Response processing for nonce updates
15
+ class Client
16
+ # Error raised for DPoP client operations
17
+ class Error < AtprotoAuth::Error; end
18
+
19
+ # @return [KeyManager] DPoP key manager instance
20
+ attr_reader :key_manager
21
+ # @return [ProofGenerator] DPoP proof generator instance
22
+ attr_reader :proof_generator
23
+ # @return [NonceManager] DPoP nonce manager instance
24
+ attr_reader :nonce_manager
25
+
26
+ # Creates a new DPoP client
27
+ # @param key_manager [KeyManager, nil] Optional existing key manager
28
+ # @param nonce_ttl [Integer, nil] Optional TTL for nonces in seconds
29
+ def initialize(key_manager: nil, nonce_ttl: nil)
30
+ @key_manager = key_manager || KeyManager.new
31
+ @nonce_manager = NonceManager.new(ttl: nonce_ttl)
32
+ @proof_generator = ProofGenerator.new(@key_manager)
33
+ end
34
+
35
+ # Generates a DPoP proof for an HTTP request
36
+ # @param http_method [String] HTTP method (e.g., "POST")
37
+ # @param http_uri [String] Full request URI
38
+ # @param access_token [String, nil] Optional access token to bind to proof
39
+ # @return [String] The DPoP proof JWT
40
+ # @raise [Error] if proof generation fails
41
+ def generate_proof(http_method:, http_uri:, access_token: nil, nonce: nil)
42
+ uri = URI(http_uri)
43
+ server_url = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
44
+
45
+ # Use provided nonce or get one from the manager
46
+ nonce ||= @nonce_manager.get(server_url)
47
+
48
+ @proof_generator.generate(
49
+ http_method: http_method,
50
+ http_uri: http_uri,
51
+ nonce: nonce,
52
+ access_token: access_token
53
+ )
54
+ rescue StandardError => e
55
+ raise Error, "Failed to generate proof: #{e.message}"
56
+ end
57
+
58
+ # Updates stored nonce from server response
59
+ # @param response_headers [Hash] Response headers
60
+ # @param server_url [String] Server's base URL
61
+ # @return [void]
62
+ # @raise [Error] if nonce update fails
63
+ def process_response(response_headers, server_url)
64
+ return unless response_headers
65
+
66
+ # Look for DPoP-Nonce header (case insensitive)
67
+ nonce = response_headers.find { |k, _| k.downcase == "dpop-nonce" }&.last
68
+ return unless nonce
69
+
70
+ # Store new nonce for future requests
71
+ @nonce_manager.update(nonce: nonce, server_url: server_url)
72
+ rescue StandardError => e
73
+ raise Error, "Failed to process response: #{e.message}"
74
+ end
75
+
76
+ # Constructs DPoP header value for a request
77
+ # @param proof [String] The DPoP proof JWT
78
+ # @return [Hash] Headers to add to request
79
+ def request_headers(proof)
80
+ {
81
+ "DPoP" => proof
82
+ }
83
+ end
84
+
85
+ # Gets the current public key in JWK format
86
+ # @return [Hash] JWK representation of public key
87
+ def public_key
88
+ @key_manager.public_jwk
89
+ end
90
+
91
+ # Exports the current keypair as JWK
92
+ # @param include_private [Boolean] Whether to include private key
93
+ # @return [Hash] JWK representation of keypair
94
+ def export_key(include_private: false)
95
+ @key_manager.to_jwk(include_private: include_private)
96
+ end
97
+
98
+ private
99
+
100
+ def extract_nonce(headers)
101
+ # Headers can be hash with string or symbol keys, or http headers object
102
+ headers = headers.to_h if headers.respond_to?(:to_h)
103
+
104
+ # Try different common header key formats
105
+ nonce = headers["DPoP-Nonce"] ||
106
+ headers["dpop-nonce"] ||
107
+ headers[:dpop_nonce]
108
+
109
+ nonce&.strip
110
+ end
111
+
112
+ def origin_for_uri(uri)
113
+ port = uri.port
114
+ port = nil if (uri.scheme == "https" && port == 443) || (uri.scheme == "http" && port == 80)
115
+
116
+ origin = "#{uri.scheme}://#{uri.host}"
117
+ origin = "#{origin}:#{port}" if port
118
+ origin
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtprotoAuth
4
+ module DPoP
5
+ # Manages ES256 keypair generation and storage for DPoP proofs.
6
+ # Provides functionality to generate new keys and store them securely.
7
+ # Uses JOSE for cryptographic operations and key format handling.
8
+ class KeyManager
9
+ # Error raised when key operations fail
10
+ class KeyError < AtprotoAuth::Error; end
11
+
12
+ # Default curve for ES256 key generation
13
+ CURVE = "P-256"
14
+ # Default algorithm for key usage
15
+ ALGORITHM = "ES256"
16
+
17
+ # @return [JOSE::JWK] The current DPoP keypair
18
+ attr_reader :keypair
19
+
20
+ # Creates a new KeyManager instance with an optional existing keypair
21
+ # @param keypair [JOSE::JWK, nil] Optional existing keypair to use
22
+ # @raise [KeyError] if the provided keypair is invalid
23
+ def initialize(keypair = nil)
24
+ @keypair = keypair || generate_keypair
25
+ validate_keypair!
26
+ end
27
+
28
+ # Generates a new ES256 keypair for DPoP usage
29
+ # @return [JOSE::JWK] The newly generated keypair
30
+ # @raise [KeyError] if key generation fails
31
+ def generate_keypair
32
+ # Generate base keypair
33
+ base_key = JOSE::JWK.generate_key([:ec, CURVE])
34
+ base_map = base_key.to_map
35
+
36
+ # Create new map with all required properties
37
+ key_map = {
38
+ "kty" => base_map["kty"],
39
+ "crv" => base_map["crv"],
40
+ "x" => base_map["x"],
41
+ "y" => base_map["y"],
42
+ "d" => base_map["d"],
43
+ "use" => "sig",
44
+ "kid" => generate_kid(base_map)
45
+ }
46
+
47
+ # Create new JWK with all properties
48
+ JOSE::JWK.from_map(key_map)
49
+ rescue StandardError => e
50
+ raise KeyError, "Failed to generate keypair: #{e.message}"
51
+ end
52
+
53
+ # Returns the public key in JWK format
54
+ # @return [Hash] JWK representation of the public key
55
+ def public_jwk
56
+ jwk = @keypair.to_public.to_map.to_h
57
+ # If somehow the properties aren't set, add them
58
+ jwk["use"] ||= "sig"
59
+ jwk["kid"] ||= generate_kid(jwk)
60
+ jwk
61
+ rescue StandardError => e
62
+ raise KeyError, "Failed to export public key: #{e.message}"
63
+ end
64
+
65
+ # Signs data using the private key
66
+ # @param data [String] Data to sign
67
+ # @return [String] The signature
68
+ # @raise [KeyError] if signing fails
69
+ def sign(data)
70
+ @keypair.sign(data).compact
71
+ rescue StandardError => e
72
+ raise KeyError, "Failed to sign data: #{e.message}"
73
+ end
74
+
75
+ def sign_segments(header, payload)
76
+ # Deep transform all keys to strings to avoid symbol comparison issues
77
+ header = deep_stringify_keys(header)
78
+ payload = deep_stringify_keys(payload)
79
+
80
+ # Configure JOSE to use ES256 for signing
81
+ signing_config = { "alg" => "ES256" }
82
+
83
+ # Merge our header with JOSE's required fields
84
+ full_header = header.merge(signing_config)
85
+
86
+ # Convert payload to JSON string before signing
87
+ payload_json = JSON.generate(payload)
88
+
89
+ # Create the JWS with our header and payload
90
+ jws = @keypair.sign(payload_json, full_header)
91
+
92
+ # Get the compact serialization
93
+ jws.compact
94
+ rescue StandardError => e
95
+ raise KeyError, "Failed to sign segments: #{e.message}"
96
+ end
97
+
98
+ def deep_stringify_keys(obj)
99
+ case obj
100
+ when Hash
101
+ obj.each_with_object({}) do |(k, v), hash|
102
+ hash[k.to_s] = deep_stringify_keys(v)
103
+ end
104
+ when Array
105
+ obj.map { |v| deep_stringify_keys(v) }
106
+ else
107
+ obj
108
+ end
109
+ end
110
+
111
+ # Verifies a signed JWS
112
+ # @param signed_jws [String] The complete signed JWS to verify
113
+ # @return [Boolean] True if signature is valid
114
+ # @raise [KeyError] if verification fails
115
+ def verify(signed_jws)
116
+ verified, _payload, = @keypair.verify(signed_jws)
117
+ verified
118
+ rescue StandardError => e
119
+ raise KeyError, "Failed to verify signature: #{e.message}"
120
+ end
121
+
122
+ # Exports the keypair in JWK format
123
+ # @param include_private [Boolean] Whether to include private key
124
+ # @return [Hash] JWK representation of the keypair
125
+ # @raise [KeyError] if export fails
126
+ def to_jwk(include_private: false)
127
+ key = include_private ? @keypair : @keypair.to_public
128
+ key.to_map
129
+ rescue StandardError => e
130
+ raise KeyError, "Failed to export key: #{e.message}"
131
+ end
132
+
133
+ # Creates a KeyManager instance from a JWK
134
+ # @param jwk [Hash] JWK representation of a keypair
135
+ # @return [KeyManager] New KeyManager instance
136
+ # @raise [KeyError] if import fails
137
+ def self.from_jwk(jwk)
138
+ keypair = JOSE::JWK.from_map(jwk)
139
+ new(keypair)
140
+ rescue StandardError => e
141
+ raise KeyError, "Failed to import key: #{e.message}"
142
+ end
143
+
144
+ private
145
+
146
+ def generate_kid(jwk)
147
+ # Generate a key ID based on the key's components
148
+ components = [
149
+ jwk["kty"],
150
+ jwk["crv"],
151
+ jwk["x"],
152
+ jwk["y"]
153
+ ].join(":")
154
+
155
+ # Create a SHA-256 hash and take first 8 bytes
156
+ digest = OpenSSL::Digest::SHA256.digest(components)
157
+ Base64.urlsafe_encode64(digest[0..7], padding: false)
158
+ end
159
+
160
+ # Validates that the keypair meets DPoP requirements
161
+ # @raise [KeyError] if validation fails
162
+ def validate_keypair!
163
+ # Check that we have a valid EC key
164
+ raise KeyError, "Invalid key type: #{@keypair.kty}, must be :ec" unless @keypair.kty.is_a?(JOSE::JWK::KTY_EC)
165
+
166
+ # Verify the curve
167
+ curve = @keypair.to_map["crv"]
168
+ raise KeyError, "Invalid curve: #{curve}, must be #{CURVE}" unless curve == CURVE
169
+
170
+ # Verify we can perform basic operations
171
+ test_data = "test"
172
+ signed = sign(test_data)
173
+ raise KeyError, "Key validation failed: signature verification error" unless verify(signed)
174
+ rescue StandardError => e
175
+ raise KeyError, "Key validation failed: #{e.message}"
176
+ end
177
+
178
+ def sign_message(message)
179
+ # Create SHA-256 digest of message
180
+ digest = OpenSSL::Digest::SHA256.digest(message)
181
+
182
+ # Get EC key from JOSE JWK
183
+ ec_key = extract_ec_key
184
+
185
+ # Sign using ECDSA
186
+ signature = ec_key.sign(OpenSSL::Digest.new("SHA256"), digest)
187
+
188
+ # Convert to raw r|s format required for JWTs
189
+ asn1_to_raw(signature)
190
+ end
191
+
192
+ def extract_ec_key # rubocop:disable Metrics/AbcSize
193
+ # Extract the raw EC key from JOSE JWK
194
+ key_data = @keypair.to_map
195
+ raise KeyError, "Private key required for signing" unless key_data["d"] # Private key component
196
+
197
+ group = OpenSSL::PKey::EC::Group.new("prime256v1")
198
+ key = OpenSSL::PKey::EC.new(group)
199
+
200
+ # Convert base64url to hex string for BN
201
+ d = bin_to_hex(Base64.urlsafe_decode64(key_data["d"]))
202
+ x = bin_to_hex(Base64.urlsafe_decode64(key_data["x"]))
203
+ y = bin_to_hex(Base64.urlsafe_decode64(key_data["y"]))
204
+
205
+ # Create BNs from hex strings
206
+ key.private_key = OpenSSL::BN.new(d, 16)
207
+
208
+ # Set public key point
209
+ point = OpenSSL::PKey::EC::Point.new(group)
210
+ point.set_to_keypair(x, y)
211
+ key.public_key = point
212
+
213
+ key
214
+ end
215
+
216
+ def bin_to_hex(binary)
217
+ binary.unpack1("H*")
218
+ end
219
+
220
+ def asn1_to_raw(signature)
221
+ # Parse ASN.1 signature
222
+ asn1 = OpenSSL::ASN1.decode(signature)
223
+ r = asn1.value[0].value.to_s(2)
224
+ s = asn1.value[1].value.to_s(2)
225
+
226
+ # Pad r and s to 32 bytes each
227
+ r = r.rjust(32, "\x00")
228
+ s = s.rjust(32, "\x00")
229
+
230
+ # Concatenate r|s
231
+ r + s
232
+ end
233
+ end
234
+ end
235
+ end