authproto 0.3.0.beta1 → 0.3.0.beta2

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/.yardopts +1 -0
  3. data/AGENTS.md +80 -0
  4. data/CHANGELOG.md +48 -7
  5. data/README.md +20 -0
  6. data/examples/confidential_client/.gitignore +2 -1
  7. data/examples/confidential_client/Gemfile.lock +5 -1
  8. data/examples/confidential_client/README.md +2 -2
  9. data/examples/confidential_client/app.rb +17 -17
  10. data/examples/confidential_client/config/{client-metadata.example.json → oauth-client-metadata.example.json} +0 -1
  11. data/examples/confidential_client/scripts/generate_keys.rb +15 -9
  12. data/examples/minisky_console/Gemfile.lock +2 -2
  13. data/examples/minisky_console/localhost.rb +8 -25
  14. data/examples/minisky_console/production.rb +1 -1
  15. data/lib/authproto/client.rb +150 -201
  16. data/lib/authproto/dpop/client.rb +12 -60
  17. data/lib/authproto/dpop/key_manager.rb +17 -17
  18. data/lib/authproto/dpop/nonce_manager.rb +5 -19
  19. data/lib/authproto/dpop/proof_generator.rb +27 -23
  20. data/lib/authproto/errors.rb +1 -1
  21. data/lib/authproto/http_client.rb +17 -31
  22. data/lib/authproto/localhost_server.rb +15 -6
  23. data/lib/authproto/metadata/authorization_server.rb +358 -0
  24. data/lib/authproto/metadata/client_metadata.rb +574 -0
  25. data/lib/authproto/metadata/resource_server.rb +137 -0
  26. data/lib/authproto/{server_metadata.rb → metadata.rb} +7 -7
  27. data/lib/authproto/minisky.rb +8 -10
  28. data/lib/authproto/par/client.rb +51 -55
  29. data/lib/authproto/par/client_assertion.rb +58 -15
  30. data/lib/authproto/par/request.rb +58 -140
  31. data/lib/authproto/par/response.rb +9 -10
  32. data/lib/authproto/par.rb +3 -5
  33. data/lib/authproto/pkce.rb +29 -106
  34. data/lib/authproto/serialization/session.rb +11 -11
  35. data/lib/authproto/serialization/token_set.rb +6 -6
  36. data/lib/authproto/state/session.rb +27 -31
  37. data/lib/authproto/state/session_manager.rb +2 -2
  38. data/lib/authproto/state/token_set.rb +4 -4
  39. data/lib/authproto/state.rb +3 -3
  40. data/lib/authproto/token/refresh.rb +35 -43
  41. data/lib/authproto/validated_uri.rb +57 -0
  42. data/lib/authproto/version.rb +1 -1
  43. data/lib/authproto.rb +1 -0
  44. data/misc/yard_attributes.rb +34 -0
  45. data/sig/authproto/client.rbs +9 -10
  46. data/sig/authproto/dpop/client.rbs +1 -8
  47. data/sig/authproto/dpop/nonce_manager.rbs +0 -1
  48. data/sig/authproto/dpop/proof_generator.rbs +5 -5
  49. data/sig/authproto/http_client.rbs +2 -2
  50. data/sig/authproto/{server_metadata → metadata}/authorization_server.rbs +18 -12
  51. data/sig/authproto/metadata/client_metadata.rbs +118 -0
  52. data/sig/authproto/metadata/resource_server.rbs +27 -0
  53. data/sig/authproto/minisky_oauth.rbs +5 -3
  54. data/sig/authproto/par/client.rbs +4 -6
  55. data/sig/authproto/par/client_assertion.rbs +2 -1
  56. data/sig/authproto/par/request.rbs +5 -19
  57. data/sig/authproto/par/response.rbs +1 -5
  58. data/sig/authproto/pkce.rbs +6 -16
  59. data/sig/authproto/serialization/session.rbs +2 -2
  60. data/sig/authproto/state/session.rbs +4 -7
  61. data/sig/authproto/state/session_manager.rbs +2 -2
  62. data/sig/authproto/token/refresh.rbs +4 -4
  63. data/sig/authproto/validated_uri.rbs +36 -0
  64. data/sig/authproto.rbs +5 -3
  65. metadata +13 -11
  66. data/lib/authproto/client_metadata.rb +0 -478
  67. data/lib/authproto/server_metadata/authorization_server.rb +0 -267
  68. data/lib/authproto/server_metadata/origin_url.rb +0 -71
  69. data/lib/authproto/server_metadata/resource_server.rb +0 -98
  70. data/sig/authproto/client_metadata.rbs +0 -113
  71. data/sig/authproto/server_metadata/origin_url.rbs +0 -21
  72. data/sig/authproto/server_metadata/resource_server.rbs +0 -27
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ require_relative '../configuration'
7
+ require_relative '../errors'
8
+ require_relative '../http_client'
9
+ require_relative '../metadata'
10
+ require_relative '../validated_uri'
11
+
12
+ module AuthProto
13
+ module Metadata
14
+
15
+ #
16
+ # This class represents a Resource Server (PDS) and is responsible for
17
+ # validating and managing its metadata. It ensures that the authorization
18
+ # server URLs provided are valid and compliant with expected standards.
19
+ # The class also includes functionality to fetch and parse metadata from a
20
+ # remote URL, raising specific errors for invalid or malformed metadata.
21
+ #
22
+
23
+ class ResourceServer
24
+
25
+ # The single authorization server origin advertised by the resource server.
26
+ # @return [String]
27
+ attr_reader :authorization_server
28
+
29
+ # Validates resource server metadata and its single authorization server origin.
30
+ #
31
+ # @param metadata [Hash{String => Object}] discovery document containing `authorization_servers`
32
+ # @raise [InvalidAuthorizationServer] if the list is missing or does not contain one valid origin
33
+ # @raise [URI::InvalidURIError] if the authorization server URI cannot be parsed
34
+ #
35
+ def initialize(metadata)
36
+ @authorization_server = validate_authorization_servers(metadata["authorization_servers"])
37
+ end
38
+
39
+ # Fetches `/.well-known/oauth-protected-resource` using the configured transport.
40
+ # Requires HTTP 200 without redirects, a JSON object served as application/json,
41
+ # and a resource identifier that exactly matches the requested URL.
42
+ # URL scheme restrictions are enforced by the configured transport.
43
+ # The PDS URL must have no userinfo, path prefix, query or fragment.
44
+ #
45
+ # @param url [String] resource server/PDS base URL
46
+ # @return [ResourceServer] validated resource server metadata
47
+ # @raise [InvalidAuthorizationServer] if HTTP transport, JSON parsing or metadata validation fails
48
+ # @raise [HTTPClient::SSRFError] if the transport rejects the initial destination
49
+ # @raise [URI::InvalidURIError] if the PDS or authorization server URL cannot be parsed
50
+ #
51
+ def self.from_url(url)
52
+ validate_source_url(url)
53
+
54
+ response = fetch_metadata(url)
55
+ validate_response(response)
56
+
57
+ metadata = parse_metadata(response[:body])
58
+ validate_resource(metadata["resource"], url)
59
+
60
+ new(metadata)
61
+ end
62
+
63
+
64
+ private
65
+
66
+ def validate_authorization_servers(servers)
67
+ raise InvalidAuthorizationServer, "authorization_servers missing" if servers.nil?
68
+ raise InvalidAuthorizationServer, "authorization_servers is not an array" unless servers.is_a?(Array)
69
+ raise InvalidAuthorizationServer, "must have exactly one authorization server" unless servers.length == 1
70
+
71
+ uri = ValidatedURI.new(servers.first)
72
+
73
+ valid = (
74
+ uri.scheme == 'https' && (!uri.specified_port || uri.effective_port != 443) &&
75
+ uri.path.empty? && !uri.query && !uri.fragment && !uri.userinfo
76
+ )
77
+
78
+ raise InvalidAuthorizationServer, "invalid authorization server URL format for #{servers.first}" unless valid
79
+
80
+ servers.first
81
+ end
82
+
83
+ class << self
84
+ private
85
+
86
+ def validate_source_url(url)
87
+ uri = ValidatedURI.new(url)
88
+
89
+ unless uri.path.empty? && !uri.userinfo && !uri.query && !uri.fragment
90
+ raise InvalidAuthorizationServer,
91
+ "Resource server URL must be an HTTP(S) origin without userinfo, a path prefix, query or fragment"
92
+ end
93
+ rescue URI::InvalidURIError => e
94
+ raise InvalidAuthorizationServer, "Invalid resource server URL: #{e.message}"
95
+ end
96
+
97
+ def fetch_metadata(url)
98
+ metadata_url = URI.join(url, "/.well-known/oauth-protected-resource")
99
+
100
+ AuthProto.configuration.http_client.get(metadata_url.to_s)
101
+ rescue HTTPClient::HTTPError => e
102
+ raise InvalidAuthorizationServer, "Failed to fetch resource server metadata: #{e.message}"
103
+ end
104
+
105
+ def validate_response(response)
106
+ unless response[:status] == 200
107
+ raise InvalidAuthorizationServer, "Resource server metadata must return HTTP 200"
108
+ end
109
+
110
+ content_type = response[:headers]["content-type"].to_s.split(";", 2).first.to_s.strip
111
+
112
+ unless content_type.casecmp?("application/json")
113
+ raise InvalidAuthorizationServer, "Resource server metadata must use application/json"
114
+ end
115
+ end
116
+
117
+ def parse_metadata(body)
118
+ metadata = JSON.parse(body.to_s)
119
+
120
+ unless metadata.is_a?(Hash)
121
+ raise InvalidAuthorizationServer, "Resource server metadata must be a JSON object"
122
+ end
123
+
124
+ metadata
125
+ rescue JSON::ParserError => e
126
+ raise InvalidAuthorizationServer, "Invalid JSON in resource server metadata: #{e.message}"
127
+ end
128
+
129
+ def validate_resource(resource, url)
130
+ unless resource.is_a?(String) && resource == url
131
+ raise InvalidAuthorizationServer, "Resource server metadata resource must match the requested resource URL"
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -3,8 +3,8 @@
3
3
  module AuthProto
4
4
 
5
5
  #
6
- # Provides functionality for fetching and validating AT Protocol OAuth server metadata
7
- # from both Resource Servers (PDS instances) and Authorization Servers (PDS/entryway).
6
+ # Provides functionality for fetching and validating AT Protocol OAuth metadata
7
+ # for clients, Resource Servers (PDS instances), and Authorization Servers (PDS/entryway).
8
8
  #
9
9
  # The flow for resolving an account's authorization server is:
10
10
  # 1. Start with PDS URL
@@ -13,16 +13,16 @@ module AuthProto
13
13
  # 4. Fetch Authorization Server metadata from /.well-known/oauth-authorization-server
14
14
  #
15
15
  # @example Resolving authorization server from PDS URL
16
- # resource_server = AuthProto::ServerMetadata::ResourceServer.from_url("https://pds.example.com")
17
- # auth_server_url = resource_server.authorization_servers.first
18
- # auth_server = AuthProto::ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
16
+ # resource_server = AuthProto::Metadata::ResourceServer.from_url("https://pds.example.com")
17
+ # auth_server_url = resource_server.authorization_server
18
+ # auth_server = AuthProto::Metadata::AuthorizationServer.from_url(auth_server_url)
19
19
  #
20
20
  # The module includes three main classes:
21
21
  # - {ResourceServer} - Handles PDS metadata validation and authorization server discovery
22
22
  # - {AuthorizationServer} - Handles authorization server metadata validation
23
- # - {OriginURL} - Validates URLs conform to AT Protocol's "simple origin URL" requirements
23
+ # - {ClientMetadata} - Handles client metadata and registration requirements
24
24
  #
25
25
 
26
- module ServerMetadata
26
+ module Metadata
27
27
  end
28
28
  end
@@ -40,6 +40,7 @@ class Minisky::OAuthClient
40
40
  # @param client_id [String] OAuth client metadata URL or localhost client ID
41
41
  # @param redirect_uri [String] registered callback URI
42
42
  # @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch it
43
+ # @param signing_key [Hash, JOSE::JWK, nil] private ES256 key for confidential-client authentication
43
44
  # @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
44
45
  #
45
46
  # @raise [AuthProto::InvalidClientMetadata] if metadata is invalid or unavailable
@@ -47,7 +48,7 @@ class Minisky::OAuthClient
47
48
  # @raise [AuthProto::HTTPClient::HTTPError, AuthProto::HTTPClient::SSRFError] if fetching metadata fails
48
49
  # @see AuthProto::Client#initialize
49
50
  #
50
- def initialize(host, client_id:, redirect_uri:, metadata: nil, dpop_key: nil, session_id: nil)
51
+ def initialize(host, client_id:, redirect_uri:, metadata: nil, dpop_key: nil, signing_key: nil, session_id: nil)
51
52
  @host = host
52
53
  @session_id = session_id
53
54
 
@@ -58,7 +59,8 @@ class Minisky::OAuthClient
58
59
  client_id: client_id,
59
60
  redirect_uri: redirect_uri,
60
61
  metadata: metadata,
61
- dpop_key: dpop_key
62
+ dpop_key: dpop_key,
63
+ signing_key: signing_key
62
64
  )
63
65
  end
64
66
 
@@ -72,8 +74,8 @@ class Minisky::OAuthClient
72
74
  #
73
75
  # @param options [Hash{Symbol => Object}] options forwarded to {AuthProto::Client#authorize}
74
76
  # @option options [String] :handle account handle, optionally prefixed with `@`
75
- # @option options [String] :pds_url PDS URL, as an alternative to `handle`
76
- # @option options [String] :scope ("atproto") requested scopes
77
+ # @option options [String] :pds PDS URL, as an alternative to `handle`
78
+ # @option options [String] :scope ('atproto') requested scopes
77
79
  #
78
80
  # @return [Hash{Symbol => String}] `:url` to open and pending `:session_id`
79
81
  # @raise [AuthProto::Error] if authorization, discovery, PAR or persistence fails
@@ -226,15 +228,11 @@ class Minisky::OAuthClient
226
228
  nonce_retries = 0
227
229
  refreshed = false
228
230
 
229
- proof_url = url.dup
230
- proof_url.query = nil
231
- proof_url.fragment = nil
232
-
233
231
  loop do
234
232
  # Fresh proof for every attempt; callers cannot override OAuth auth headers.
235
233
  request_headers = headers.reject { |k, v| %w[authorization dpop].include?(k.to_s.downcase) }
236
234
  request_headers.merge!(oauth_client.auth_headers(
237
- session_id: session_id, method: request_class::METHOD, url: proof_url.to_s
235
+ session_id: session_id, method: request_class::METHOD, url: url.to_s
238
236
  ))
239
237
 
240
238
  request = request_class.new(url, request_headers)
@@ -245,7 +243,7 @@ class Minisky::OAuthClient
245
243
  oauth_client.dpop_client.process_response(response.each_header.to_h, url.to_s) if nonce && !nonce.empty?
246
244
  error = authentication_error(response)
247
245
 
248
- if response.code.to_i == 401 && error == "use_dpop_nonce"
246
+ if response.code.to_i == 401 && error == AuthProto::DPoP::ERROR_USE_NONCE
249
247
  unless nonce && !nonce.empty?
250
248
  raise AuthProto::DPoP::Client::Error, "DPoP nonce challenge (HTTP 401) is missing DPoP-Nonce"
251
249
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'cgi'
4
3
  require 'json'
5
4
  require 'uri'
6
5
 
@@ -25,7 +24,7 @@ module AuthProto
25
24
  #
26
25
  # @example Basic PAR flow
27
26
  # client = AuthProto::PAR::Client.new(
28
- # endpoint: "https://auth.example.com/par",
27
+ # authorization_server: authorization_server,
29
28
  # dpop_client: AuthProto::DPoP::Client.new
30
29
  # )
31
30
  #
@@ -34,16 +33,14 @@ module AuthProto
34
33
  # config.client_id = "https://app.example.com/oauth-client-metadata.json"
35
34
  # config.redirect_uri = "https://app.example.com/callback"
36
35
  # config.code_challenge = "abc123..."
37
- # config.code_challenge_method = "S256"
38
36
  # config.state = "xyz789..."
39
- # config.scope = "atproto"
37
+ # config.scope = 'atproto'
40
38
  # end
41
39
  #
42
40
  # response = client.submit(request)
43
41
  #
44
42
  # # Build authorization URL using response
45
- # auth_url = client.authorization_url(
46
- # authorize_endpoint: "https://auth.example.com/authorize",
43
+ # auth_url = client.build_authorization_url(
47
44
  # request_uri: response.request_uri,
48
45
  # client_id: request.client_id
49
46
  # )
@@ -51,8 +48,7 @@ module AuthProto
51
48
  # @example With client authentication (confidential clients)
52
49
  # request = AuthProto::PAR::Request.build do |config|
53
50
  # # ... basic parameters ...
54
- # config.client_assertion_type = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
55
- # config.client_assertion = jwt_token
51
+ # config.client_assertion = AuthProto::PAR::ClientAssertion.new(client_id: client_id, signing_key: signing_key)
56
52
  # end
57
53
  #
58
54
  # All requests use HTTPS and include a fresh DPoP proof from the supplied
@@ -62,29 +58,28 @@ module AuthProto
62
58
 
63
59
  class Client
64
60
 
61
+ # Authorization server supplying endpoints and the client assertion audience.
62
+ # @return [Metadata::AuthorizationServer]
63
+ attr_reader :authorization_server
64
+
65
65
  # HTTPS pushed authorization request endpoint.
66
66
  # @return [String]
67
- attr_reader :endpoint
67
+ attr_reader :par_endpoint
68
68
 
69
69
  # DPoP client used to sign requests.
70
70
  # @return [DPoP::Client]
71
71
  attr_reader :dpop_client
72
72
 
73
- # Nonce manager belonging to the DPoP client.
74
- # @return [DPoP::NonceManager]
75
- attr_reader :nonce_manager
76
-
77
73
  # Creates a PAR transport using the global HTTP client and supplied DPoP client.
78
74
  #
79
- # @param endpoint [String] HTTPS PAR endpoint
75
+ # @param authorization_server [Metadata::AuthorizationServer] discovered authorization server metadata
80
76
  # @param dpop_client [DPoP::Client] proof generator and nonce tracker
81
- # @raise [Error] if the endpoint is malformed or does not use HTTPS
82
77
  #
83
- def initialize(endpoint:, dpop_client:)
84
- @endpoint = endpoint
78
+ def initialize(authorization_server:, dpop_client:)
79
+ @authorization_server = authorization_server
80
+ @par_endpoint = authorization_server.pushed_authorization_request_endpoint
85
81
  @dpop_client = dpop_client
86
82
  @nonce_manager = dpop_client.nonce_manager
87
- validate_endpoint
88
83
  end
89
84
 
90
85
  # Submits a PAR request, handling DPoP nonce requirements
@@ -94,6 +89,8 @@ module AuthProto
94
89
  # @raise [Error] if request fails
95
90
  #
96
91
  def submit(request)
92
+ raise Error, "Expected a PAR::Request" unless request.is_a?(PAR::Request)
93
+
97
94
  # Try the initial request
98
95
  response = make_request(request)
99
96
 
@@ -105,7 +102,7 @@ module AuthProto
105
102
  store_nonce(nonce)
106
103
 
107
104
  # Get stored nonce to verify
108
- nonce_manager.get(server_origin)
105
+ @nonce_manager.get(server_origin)
109
106
 
110
107
  # Generate new proof with nonce and retry
111
108
  response = make_request(request)
@@ -124,12 +121,7 @@ module AuthProto
124
121
  # @raise [Error] if no supported nonce header is present
125
122
  #
126
123
  def extract_nonce(response)
127
- # Try all possible header key formats
128
- headers = response[:headers]
129
- nonce = headers["DPoP-Nonce"] ||
130
- headers["dpop-nonce"] ||
131
- headers["Dpop-Nonce"]
132
-
124
+ nonce = response[:headers].find { |k, v| k.downcase == 'dpop-nonce' }&.last
133
125
  raise Error, "No DPoP nonce provided in response" unless nonce
134
126
 
135
127
  nonce
@@ -137,38 +129,38 @@ module AuthProto
137
129
 
138
130
  # Builds the browser URL using the client ID and returned PAR request URI.
139
131
  #
140
- # The endpoint's existing query is replaced by these two parameters.
132
+ # Retains existing query parameters.
141
133
  #
142
- # @param authorize_endpoint [String] authorization endpoint URL
143
134
  # @param request_uri [String] request URI from {Response#request_uri}
144
135
  # @param client_id [String] OAuth client ID
145
136
  # @return [String] browser authorization URL
146
137
  # @raise [URI::InvalidURIError] if the authorization endpoint cannot be parsed
147
138
  #
148
- def authorization_url(authorize_endpoint:, request_uri:, client_id:)
149
- uri = URI(authorize_endpoint)
150
- uri.query = encode_params(
151
- "request_uri" => request_uri,
152
- "client_id" => client_id
153
- )
139
+ def build_authorization_url(request_uri:, client_id:)
140
+ uri = URI(@authorization_server.authorization_endpoint)
141
+
142
+ params = {
143
+ request_uri: request_uri,
144
+ client_id: client_id
145
+ }
146
+
147
+ existing_query = uri.query || ''
148
+ params_query = URI.encode_www_form(params).gsub('*', '%2A') # unescaped '*' may break links in the terminal
149
+
150
+ uri.query = existing_query + (existing_query.empty? ? '' : '&') + params_query
151
+
154
152
  uri.to_s
155
153
  end
156
154
 
157
- private
158
155
 
159
- def validate_endpoint
160
- uri = URI(@endpoint)
161
- raise Error, "endpoint must be HTTPS" unless uri.scheme == "https"
162
- rescue URI::InvalidURIError => e
163
- raise Error, "invalid endpoint URL: #{e.message}"
164
- end
156
+ private
165
157
 
166
158
  def make_request(request)
167
159
  # Generate DPoP proof for this request
168
160
  proof = dpop_client.generate_proof(
169
- http_method: "POST",
170
- http_uri: endpoint,
171
- nonce: nonce_manager.get(server_origin)
161
+ method: 'POST',
162
+ uri: @par_endpoint,
163
+ nonce: @nonce_manager.get(server_origin)
172
164
  )
173
165
 
174
166
  # Build headers including DPoP proof
@@ -176,8 +168,8 @@ module AuthProto
176
168
 
177
169
  # Make the request
178
170
  AuthProto.configuration.http_client.post(
179
- endpoint,
180
- body: request.to_form,
171
+ @par_endpoint,
172
+ body: request.to_form(audience: @authorization_server.issuer),
181
173
  headers: headers
182
174
  )
183
175
  end
@@ -191,24 +183,24 @@ module AuthProto
191
183
 
192
184
  def requires_nonce?(response)
193
185
  body = JSON.parse(response[:body])
194
- body["error"] == "use_dpop_nonce"
186
+ body['error'] == DPoP::ERROR_USE_NONCE
195
187
  rescue JSON::ParserError
196
188
  false
197
189
  end
198
190
 
199
191
  def store_nonce(nonce)
200
- nonce_manager.update(nonce: nonce, server_url: server_origin)
192
+ @nonce_manager.update(nonce: nonce, server_url: server_origin)
201
193
  end
202
194
 
203
195
  def server_origin
204
- uri = URI(@endpoint)
196
+ uri = URI(@par_endpoint)
205
197
  "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
206
198
  end
207
199
 
208
200
  def handle_error_response(response)
209
201
  begin
210
202
  error_data = JSON.parse(response[:body])
211
- error_message = error_data["error_description"] || error_data["error"] || "Unknown error"
203
+ error_message = error_data['error_description'] || error_data['error'] || "Unknown error"
212
204
  rescue JSON::ParserError
213
205
  error_message = "Invalid response from server"
214
206
  end
@@ -219,11 +211,19 @@ module AuthProto
219
211
  def process_response(response)
220
212
  raise Error, "unexpected response status: #{response[:status]}" unless response[:status] == 201
221
213
 
214
+ content_type = (response[:headers] || {}).find { |key, _| key.downcase == 'content-type' }&.last
215
+
216
+ unless content_type.to_s.split(';', 2).first.to_s.strip.casecmp?('application/json')
217
+ raise Error, "PAR response must use application/json"
218
+ end
219
+
222
220
  begin
223
221
  data = JSON.parse(response[:body])
222
+ raise Error, "PAR response must be a JSON object" unless data.is_a?(Hash)
223
+
224
224
  Response.new(
225
- request_uri: data["request_uri"],
226
- expires_in: data["expires_in"]
225
+ request_uri: data['request_uri'],
226
+ expires_in: data['expires_in']
227
227
  )
228
228
  rescue JSON::ParserError => e
229
229
  raise Error, "invalid JSON response: #{e.message}"
@@ -231,10 +231,6 @@ module AuthProto
231
231
  raise Error, "failed to process response: #{e.message}"
232
232
  end
233
233
  end
234
-
235
- def encode_params(params)
236
- params.map { |k, v| "#{CGI.escape(k)}=#{CGI.escape(v.to_s)}" }.join("&")
237
- end
238
234
  end
239
235
  end
240
236
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'jwt'
4
+ require 'jose'
4
5
  require 'securerandom'
5
6
 
6
7
  require_relative '../errors'
@@ -19,14 +20,21 @@ module AuthProto
19
20
  # Raised when client assertion key import or JWT signing fails.
20
21
  class Error < AuthProto::Error; end
21
22
 
23
+ attr_reader :signing_key
24
+
22
25
  # Creates an ES256 client assertion signer.
23
26
  #
24
27
  # @param client_id [String] client ID used in `iss` and `sub`
25
- # @param signing_key [JOSE::JWK] private EC P-256 signing key, including its `kid`
28
+ # @param signing_key [Hash, JOSE::JWK] private EC P-256 signing key, including its `kid`
29
+ # @raise [Error] if the key cannot sign with ES256 or its usage is incompatible
26
30
  #
27
31
  def initialize(client_id:, signing_key:)
28
32
  @client_id = client_id
29
- @signing_key = signing_key
33
+ @signing_key = validate_signing_key(signing_key)
34
+ rescue ClientAssertion::Error
35
+ raise
36
+ rescue StandardError => e
37
+ raise Error, "Invalid signing_key: #{e.message}"
30
38
  end
31
39
 
32
40
  # Signs a short-lived client authentication JWT with ES256.
@@ -42,28 +50,63 @@ module AuthProto
42
50
  now = Time.now.to_i
43
51
 
44
52
  payload = {
45
- # Required claims
46
- iss: @client_id, # Issuer is client_id
47
- sub: @client_id, # Subject is client_id
48
- aud: audience, # Audience is the authorization server issuer
49
- jti: SecureRandom.uuid, # Unique identifier
50
- exp: now + lifetime, # Expiration time
51
- iat: now # Issued at time
53
+ iss: @client_id, # issuer
54
+ sub: @client_id, # subject
55
+ aud: audience, # audience is the authorization server issuer
56
+ jti: SecureRandom.uuid, # token identifier
57
+ exp: now + lifetime, # expiration time
58
+ iat: now # issued at time
52
59
  }
53
60
 
54
- # Header specifying ES256 algorithm for signing
55
61
  header = {
56
- alg: "ES256",
57
- typ: "JWT",
58
- kid: @signing_key.fields["kid"]
62
+ alg: 'ES256',
63
+ typ: 'JWT',
64
+ kid: @signing_key.fields['kid']
59
65
  }
60
66
 
61
- # Sign and return the JWT
62
67
  key = JWT::JWK.import(@signing_key.to_map.to_h)
63
- JWT.encode(payload, key, "ES256", header)
68
+ JWT.encode(payload, key, 'ES256', header)
64
69
  rescue JWT::Error, ArgumentError => e
65
70
  raise Error, "Failed to generate client assertion: #{e.message}"
66
71
  end
72
+
73
+
74
+ private
75
+
76
+ def validate_signing_key(signing_key)
77
+ data = signing_key.is_a?(JOSE::JWK) ? signing_key.to_map.to_h : signing_key
78
+
79
+ raise Error, "signing_key must be a hash or a JOSE::JWK" unless data.is_a?(Hash)
80
+ raise Error, "signing_key must be active" if data['revoked']
81
+
82
+ unless data['kty'] == 'EC' && data['crv'] == 'P-256'
83
+ raise Error, "signing_key must be a EC P-256 JWK"
84
+ end
85
+
86
+ unless data['d'].is_a?(String) && !data['d'].empty?
87
+ raise Error, "signing_key must be a private key ('d' field must not be empty)"
88
+ end
89
+
90
+ %w[kid x y].each do |field|
91
+ unless data[field].is_a?(String) && !data[field].empty?
92
+ raise Error, "signing_key '#{field}' field must not be empty"
93
+ end
94
+ end
95
+
96
+ if (data.key?('alg') && data['alg'] != 'ES256') ||
97
+ (data.key?('use') && data['use'] != 'sig') ||
98
+ (data.key?('key_ops') && (!data['key_ops'].is_a?(Array) || !data['key_ops'].include?('sign')))
99
+ raise Error, "signing_key must be active and permit ES256 signing"
100
+ end
101
+
102
+ signing_key = JOSE::JWK.from_map(data)
103
+
104
+ imported = JWT::JWK.import(data)
105
+ proof = JWT.encode({}, imported, 'ES256')
106
+ JWT.decode(proof, imported.public_key, true, algorithms: ['ES256'])
107
+
108
+ signing_key
109
+ end
67
110
  end
68
111
  end
69
112
  end