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
@@ -5,17 +5,17 @@ require 'jose'
5
5
  require 'json'
6
6
  require 'uri'
7
7
 
8
- require_relative 'client_metadata'
9
8
  require_relative 'configuration'
10
9
  require_relative 'dpop/client'
11
10
  require_relative 'dpop/key_manager'
12
11
  require_relative 'errors'
12
+ require_relative 'metadata/authorization_server'
13
+ require_relative 'metadata/client_metadata'
14
+ require_relative 'metadata/resource_server'
13
15
  require_relative 'par'
14
16
  require_relative 'par/client'
15
17
  require_relative 'par/client_assertion'
16
18
  require_relative 'par/request'
17
- require_relative 'server_metadata/authorization_server'
18
- require_relative 'server_metadata/resource_server'
19
19
  require_relative 'state/session_manager'
20
20
  require_relative 'state/token_set'
21
21
  require_relative 'storage/key_builder'
@@ -66,7 +66,7 @@ module AuthProto
66
66
  attr_reader :redirect_uri
67
67
 
68
68
  # Validated client metadata.
69
- # @return [ClientMetadata]
69
+ # @return [Metadata::ClientMetadata]
70
70
  attr_reader :client_metadata
71
71
 
72
72
  # Manager for stored OAuth sessions.
@@ -85,6 +85,7 @@ module AuthProto
85
85
  # @param client_id [String] OAuth client ID URL
86
86
  # @param redirect_uri [String] callback URI listed in `redirect_uris`
87
87
  # @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch `client_id`
88
+ # @param signing_key [Hash, JOSE::JWK, nil] private ES256 client-authentication key, separate from public metadata
88
89
  # @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
89
90
  #
90
91
  # @raise [InvalidClientMetadata] if client metadata is invalid or unavailable
@@ -92,13 +93,15 @@ module AuthProto
92
93
  # @raise [DPoP::KeyManager::KeyError] if the DPoP key cannot be imported or generated
93
94
  # @raise [HTTPClient::HTTPError, HTTPClient::SSRFError] if fetching client metadata fails
94
95
  #
95
- def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil)
96
+ def initialize(client_id:, redirect_uri:, metadata: nil, dpop_key: nil, signing_key: nil)
96
97
  @client_id = client_id
97
98
  @redirect_uri = redirect_uri
98
99
 
99
- # Initialize core dependencies
100
+ # Client metadata is fetched from .well-known if not provided directly
100
101
  @client_metadata = load_client_metadata(metadata)
102
+
101
103
  validate_redirect_uri
104
+ @signing_key = initialize_signing_key(signing_key)
102
105
 
103
106
  @session_manager = State::SessionManager.new
104
107
  @dpop_client = initialize_dpop(dpop_key)
@@ -106,12 +109,12 @@ module AuthProto
106
109
 
107
110
  # Starts authorization, stores a pending session, and submits a PAR request.
108
111
  #
109
- # Supply exactly one of `handle` or `pds_url`. Open the returned `:url` in a browser
112
+ # Supply exactly one of `handle` or `pds`. Open the returned `:url` in a browser
110
113
  # and pass the eventual callback parameters to {#handle_callback}.
111
114
  #
112
115
  # @param handle [String, nil] account handle (optionally prefixed with `@`) or DID
113
- # @param pds_url [String, nil] PDS URL when no handle is supplied
114
- # @param scope [String] space-separated scopes, including `atproto`
116
+ # @param pds [String, nil] PDS URL when no handle is supplied
117
+ # @param scope [String] space-separated scopes, which must all be declared in client metadata, including `atproto`
115
118
  #
116
119
  # @return [Hash{Symbol => String}] `:url` is the browser URL; `:session_id` identifies the pending session
117
120
  # @raise [Error] if identity arguments or scope are invalid, the handle cannot be verified, or no HTTPS PDS is found
@@ -124,33 +127,25 @@ module AuthProto
124
127
  # @raise [Storage::StorageError] if session storage or locking fails
125
128
  # @raise [Encryption::EncryptionError] if session serialization cannot encrypt secrets
126
129
  #
127
- def authorize(handle: nil, pds_url: nil, scope: "atproto")
128
- validate_auth_params(handle, pds_url, scope)
129
-
130
- # Create new session
131
- session = session_manager.create_session(
132
- client_id: client_id,
133
- scope: scope
134
- )
130
+ def authorize(handle: nil, pds: nil, scope: 'atproto')
131
+ validate_auth_params(handle, pds, scope)
135
132
 
136
- # Store session with storage backend
137
- session_manager.update_session(session)
133
+ if !pds
134
+ did = DIDKit::DID.resolve_handle(handle)
135
+ raise Error, "Could not resolve or verify identity #{handle}" unless did
138
136
 
139
- # Resolve identity and authorization server if handle provided
140
- if handle
141
- auth_info = resolve_from_handle(handle, session)
142
- elsif pds_url
143
- auth_info = resolve_from_pds(pds_url, session)
144
- else
145
- raise Error, "Either handle or pds_url must be provided"
137
+ pds = did.document.pds_endpoint
138
+ raise Error, "No HTTPS PDS location found for #{did}" unless pds && URI(pds).is_a?(URI::HTTPS)
146
139
  end
147
140
 
148
- # Generate authorization URL
149
- auth_url = generate_authorization_url(
150
- auth_info[:server],
151
- session,
152
- login_hint: handle
153
- )
141
+ authorization_server = resolve_auth_server(pds)
142
+
143
+ session = session_manager.create_session(client_id: @client_id, scope: scope)
144
+ session.authorization_server = authorization_server
145
+ session.did = did&.to_s
146
+ session_manager.update_session(session)
147
+
148
+ auth_url = submit_authorization_par(authorization_server, session, login_hint: handle)
154
149
 
155
150
  {
156
151
  url: auth_url,
@@ -180,46 +175,28 @@ module AuthProto
180
175
  # @raise [Encryption::EncryptionError] if tokens cannot be encrypted for storage
181
176
  #
182
177
  def handle_callback(code:, state:, iss:)
183
- # Find and validate session
184
178
  session = session_manager.get_session_by_state(state)
185
- raise CallbackError, "Invalid state parameter" unless session
186
179
 
187
- # Verify issuer matches session
180
+ raise CallbackError, "Invalid state parameter" unless session
188
181
  raise CallbackError, "Issuer mismatch" unless session.auth_server && session.auth_server.issuer == iss
189
182
 
190
183
  AuthProto.storage.with_lock(Storage::KeyBuilder.lock_key("session", session.session_id), ttl: 30) do
191
- # Exchange code for tokens
192
- token_response = exchange_code(
193
- code: code,
194
- session: session
195
- )
196
-
197
- # Validate token response
198
- validate_token_response(token_response, session)
184
+ response = exchange_code(code: code, session: session)
185
+ validate_token_response(response, session)
199
186
 
200
- # Create token set and store in session
201
187
  token_set = State::TokenSet.new(
202
- access_token: token_response["access_token"],
203
- token_type: token_response["token_type"],
204
- expires_in: token_response["expires_in"],
205
- refresh_token: token_response["refresh_token"],
206
- scope: token_response["scope"],
207
- sub: token_response["sub"]
188
+ access_token: response['access_token'],
189
+ token_type: response['token_type'],
190
+ expires_in: response['expires_in'],
191
+ refresh_token: response['refresh_token'],
192
+ scope: response['scope'],
193
+ sub: response['sub']
208
194
  )
209
- session.tokens = token_set
210
195
 
211
- # Update stored session
196
+ session.tokens = token_set
212
197
  session_manager.update_session(session)
213
198
 
214
- {
215
- access_token: token_set.access_token,
216
- token_type: token_set.token_type,
217
- expires_in: (token_set.expires_at - Time.now).to_i,
218
- refresh_token: token_set.refresh_token,
219
- scope: token_set.scope,
220
- session_id: session.session_id,
221
- did: session.did
222
- }
199
+ session.session_data
223
200
  end
224
201
  end
225
202
 
@@ -228,6 +205,7 @@ module AuthProto
228
205
  # The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
229
206
  # `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
230
207
  # and `:scope` (String).
208
+ # It also contains `:session_id` (String) and `:did` (String).
231
209
  # This method does not refresh expired tokens.
232
210
  #
233
211
  # @param session_id [String] session identifier
@@ -237,13 +215,7 @@ module AuthProto
237
215
  session = session_manager.get_session(session_id)
238
216
  return nil unless session&.authorized?
239
217
 
240
- {
241
- access_token: session.tokens.access_token,
242
- token_type: session.tokens.token_type,
243
- expires_in: (session.tokens.expires_at - Time.now).to_i,
244
- refresh_token: session.tokens.refresh_token,
245
- scope: session.tokens.scope
246
- }
218
+ session.session_data
247
219
  end
248
220
 
249
221
  # Refreshes a renewable session and persists the replacement tokens.
@@ -251,11 +223,11 @@ module AuthProto
251
223
  # The result contains `:access_token` (String), `:token_type` (String, `DPoP`),
252
224
  # `:expires_in` (Integer seconds remaining), `:refresh_token` (String or nil),
253
225
  # and `:scope` (String).
254
- # The result also contains `:session_id` (String). An expired access token can
226
+ # The result also contains `:session_id` (String) and `:did` (String). An expired access token can
255
227
  # still be refreshed when the stored session has a refresh token.
256
228
  #
257
229
  # @param session_id [String] session to refresh
258
- # @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session ID
230
+ # @return [Hash{Symbol => String, Integer, nil}] replacement tokens and session identifiers
259
231
  # @raise [TokenError] if the session is missing or has no refresh token
260
232
  # @raise [Token::RefreshError] if token refresh fails or exhausts retries
261
233
  # @raise [State::SessionError] if replacement tokens have a different subject
@@ -264,6 +236,7 @@ module AuthProto
264
236
  #
265
237
  def refresh_token(session_id)
266
238
  session = session_manager.get_session(session_id)
239
+
267
240
  raise TokenError, "Invalid session" unless session
268
241
  raise TokenError, "Session not authorized" unless session.renewable?
269
242
 
@@ -272,23 +245,16 @@ module AuthProto
272
245
  session: session,
273
246
  dpop_client: @dpop_client,
274
247
  auth_server: session.auth_server,
275
- client_metadata: client_metadata
248
+ client_metadata: @client_metadata,
249
+ signing_key: @signing_key
276
250
  )
277
251
 
278
- new_tokens = refresher.perform
279
- session.tokens = new_tokens
252
+ new_tokens = refresher.perform_refresh
280
253
 
281
- # Update stored session
254
+ session.tokens = new_tokens
282
255
  session_manager.update_session(session)
283
256
 
284
- {
285
- access_token: new_tokens.access_token,
286
- token_type: new_tokens.token_type,
287
- expires_in: (new_tokens.expires_at - Time.now).to_i,
288
- refresh_token: new_tokens.refresh_token,
289
- scope: new_tokens.scope,
290
- session_id: session.session_id
291
- }
257
+ session.session_data
292
258
  end
293
259
  end
294
260
 
@@ -318,19 +284,19 @@ module AuthProto
318
284
  #
319
285
  def auth_headers(session_id:, method:, url:)
320
286
  session = session_manager.get_session(session_id)
287
+
321
288
  raise TokenError, "Invalid session" unless session
322
289
  raise TokenError, "Session not authorized" unless session.authorized?
323
290
 
324
- # Generate DPoP proof
325
- proof = dpop_client.generate_proof(
326
- http_method: method,
327
- http_uri: url,
291
+ dpop_proof = dpop_client.generate_proof(
292
+ method: method,
293
+ uri: url,
328
294
  access_token: session.tokens.access_token
329
295
  )
330
296
 
331
297
  {
332
298
  "Authorization" => "DPoP #{session.tokens.access_token}",
333
- "DPoP" => proof
299
+ "DPoP" => dpop_proof
334
300
  }
335
301
  end
336
302
 
@@ -340,10 +306,11 @@ module AuthProto
340
306
  # @return [void]
341
307
  # @raise [Storage::StorageError] if deletion or locking fails
342
308
  #
343
- def remove_session(session_id)
309
+ def delete_session(session_id)
344
310
  key = Storage::KeyBuilder.session_key(session_id)
311
+
345
312
  AuthProto.storage.delete(key)
346
- session_manager.remove_session(session_id)
313
+ session_manager.delete_session(session_id)
347
314
  end
348
315
 
349
316
  # Invokes the session manager's expiry cleanup hook.
@@ -356,119 +323,116 @@ module AuthProto
356
323
  session_manager.cleanup_expired
357
324
  end
358
325
 
326
+
359
327
  private
360
328
 
361
329
  def load_client_metadata(metadata)
362
330
  if metadata
363
- ClientMetadata.new(metadata)
331
+ Metadata::ClientMetadata.new(metadata)
364
332
  else
365
- ClientMetadata.from_url(@client_id)
333
+ Metadata::ClientMetadata.from_url(@client_id)
366
334
  end
367
335
  end
368
336
 
369
337
  def validate_redirect_uri
370
- valid = @client_metadata.redirect_uris.include?(@redirect_uri)
371
- raise Error, "redirect_uri not found in client metadata" unless valid
338
+ unless @client_metadata.redirect_uris.include?(@redirect_uri)
339
+ raise Error, "redirect_uri not found in client metadata"
340
+ end
372
341
  end
373
342
 
374
- def initialize_dpop(key)
375
- key_manager = if key
376
- DPoP::KeyManager.from_jwk(key)
377
- else
378
- DPoP::KeyManager.new
379
- end
343
+ def initialize_signing_key(key)
344
+ if @client_metadata.confidential?
345
+ raise InvalidClientMetadata, "Confidential clients require a signing_key" if key.nil?
346
+ else
347
+ raise InvalidClientMetadata, "Public clients must not supply a signing_key" unless key.nil?
348
+ return nil
349
+ end
380
350
 
381
- DPoP::Client.new(key_manager: key_manager)
382
- end
351
+ assertion = PAR::ClientAssertion.new(client_id: @client_id, signing_key: key)
352
+ private_key = assertion.signing_key
383
353
 
384
- def validate_auth_params(handle, pds_url, scope)
385
- raise Error, "scope must include 'atproto'" unless scope.split.include?("atproto")
386
- raise Error, "handle or pds_url must be provided" if handle.nil? && pds_url.nil?
387
- raise Error, "cannot provide both handle and pds_url" if handle && pds_url
388
- end
354
+ if @client_metadata.jwks
355
+ public_key = private_key.to_public.to_map.to_h
389
356
 
390
- def resolve_from_handle(handle, session)
391
- did = DIDKit::DID.resolve_handle(handle)
392
- raise Error, "Could not resolve or verify identity #{handle}" unless did
357
+ key_in_jwks = @client_metadata.jwks['keys'].any? { |candidate|
358
+ metadata_key_matches_public_key?(candidate, public_key)
359
+ }
360
+
361
+ raise InvalidClientMetadata, "signing_key must match an active public key advertised in jwks" if !key_in_jwks
362
+ end
393
363
 
394
- pds_url = did.document.pds_endpoint
395
- raise Error, "No HTTPS PDS location found for #{did}" unless pds_url && URI(pds_url).is_a?(URI::HTTPS)
364
+ private_key
365
+ end
396
366
 
397
- session.did = did.to_s
367
+ def metadata_key_matches_public_key?(candidate, public_key)
368
+ return false if candidate['revoked']
369
+ return false if candidate.key?('alg') && candidate['alg'] != 'ES256'
370
+ return false if candidate.key?('use') && candidate['use'] != 'sig'
371
+ return false if candidate.key?('key_ops') && !candidate['key_ops'].include?('verify')
398
372
 
399
- # Get authorization server from PDS
400
- server = resolve_auth_server(pds_url)
401
- session.authorization_server = server
373
+ %w[kid kty crv x y].all? { |field| candidate[field] == public_key[field] }
374
+ end
402
375
 
403
- # Update stored session
404
- session_manager.update_session(session)
376
+ def initialize_dpop(key)
377
+ key_manager = key ? DPoP::KeyManager.from_jwk(key) : DPoP::KeyManager.new
405
378
 
406
- { server: server, pds: pds_url }
379
+ DPoP::Client.new(key_manager: key_manager)
407
380
  end
408
381
 
409
- def resolve_from_pds(pds_url, session)
410
- # Get authorization server from PDS
411
- server = resolve_auth_server(pds_url)
412
- session.authorization_server = server
382
+ def validate_auth_params(handle, pds, scope)
383
+ unless scope.is_a?(String) && scope.valid_encoding? && Metadata::ClientMetadata::SCOPE_PATTERN.match?(scope)
384
+ raise Error, "scope must contain valid tokens separated by single spaces"
385
+ end
413
386
 
414
- # Update stored session
415
- session_manager.update_session(session)
387
+ requested_scopes = scope.split(' ')
388
+ raise Error, "scope must include 'atproto'" unless requested_scopes.include?('atproto')
389
+
390
+ undeclared_scopes = requested_scopes - @client_metadata.scope.split(' ')
391
+
392
+ unless undeclared_scopes.empty?
393
+ raise Error, "scope contains values not declared in client metadata: #{undeclared_scopes.join(' ')}"
394
+ end
416
395
 
417
- { server: server, pds: pds_url }
396
+ raise Error, "handle or pds must be provided" if handle.nil? && pds.nil?
397
+ raise Error, "cannot provide both handle and pds" if handle && pds
418
398
  end
419
399
 
420
- def resolve_auth_server(pds_url)
421
- # Get resource server metadata
422
- resource_server = ServerMetadata::ResourceServer.from_url(pds_url)
423
- auth_server_url = resource_server.authorization_servers.first
400
+ def resolve_auth_server(pds)
401
+ resource_server = Metadata::ResourceServer.from_url(pds)
402
+ auth_server_url = resource_server.authorization_server
424
403
 
425
- # Get and validate authorization server metadata
426
- ServerMetadata::AuthorizationServer.from_issuer(auth_server_url)
404
+ Metadata::AuthorizationServer.from_url(auth_server_url)
427
405
  end
428
406
 
429
- def generate_authorization_url(auth_server, session, login_hint: nil)
430
- # Create PAR client
407
+ def submit_authorization_par(authorization_server, session, login_hint: nil)
431
408
  par_client = PAR::Client.new(
432
- endpoint: auth_server.pushed_authorization_request_endpoint,
409
+ authorization_server: authorization_server,
433
410
  dpop_client: @dpop_client
434
411
  )
435
412
 
436
- # Build PAR request
437
- par_request = PAR::Request.build do |config|
438
- config.client_id = client_id
439
- config.redirect_uri = redirect_uri
440
- config.state = session.state_token
441
- config.scope = session.scope
442
- config.login_hint = login_hint if login_hint
443
-
444
- # Add PKCE parameters
445
- config.code_challenge = session.pkce_challenge
446
- config.code_challenge_method = "S256"
447
-
448
- # Public clients use PKCE and DPoP without client authentication.
449
- if client_metadata.confidential?
450
- signing_key = JOSE::JWK.from_map(client_metadata.jwks.fetch("keys").first)
451
- client_assertion = PAR::ClientAssertion.new(client_id: client_id, signing_key: signing_key)
452
- config.client_assertion = client_assertion.generate_jwt(audience: auth_server.issuer)
453
- config.client_assertion_type = PAR::CLIENT_ASSERTION_TYPE
413
+ par_request = PAR::Request.build do |req|
414
+ req.client_id = @client_id
415
+ req.redirect_uri = @redirect_uri
416
+ req.state = session.state_token
417
+ req.scope = session.scope
418
+ req.login_hint = login_hint if login_hint
419
+ req.code_challenge = session.pkce_challenge
420
+
421
+ if @client_metadata.confidential?
422
+ req.client_assertion = PAR::ClientAssertion.new(client_id: @client_id, signing_key: @signing_key)
454
423
  end
455
424
 
456
- # Add DPoP proof
457
- proof = @dpop_client.generate_proof(
458
- http_method: "POST",
459
- http_uri: auth_server.pushed_authorization_request_endpoint
425
+ req.dpop_proof = @dpop_client.generate_proof(
426
+ method: 'POST',
427
+ uri: authorization_server.pushed_authorization_request_endpoint
460
428
  )
461
- config.dpop_proof = proof
462
429
  end
463
430
 
464
- # Submit PAR request
465
- response = par_client.submit(par_request)
431
+ par_response = par_client.submit(par_request)
466
432
 
467
- # Build final authorization URL
468
- par_client.authorization_url(
469
- authorize_endpoint: auth_server.authorization_endpoint,
470
- request_uri: response.request_uri,
471
- client_id: client_id
433
+ par_client.build_authorization_url(
434
+ request_uri: par_response.request_uri,
435
+ client_id: @client_id
472
436
  )
473
437
  end
474
438
 
@@ -496,33 +460,28 @@ module AuthProto
496
460
  end
497
461
 
498
462
  def make_token_request(code, session)
499
- # Generate proof
500
- proof = dpop_client.generate_proof(
501
- http_method: "POST",
502
- http_uri: session.auth_server.token_endpoint
463
+ dpop_proof = dpop_client.generate_proof(
464
+ method: 'POST',
465
+ uri: session.auth_server.token_endpoint
503
466
  )
504
467
 
505
468
  body = {
506
- grant_type: "authorization_code",
469
+ grant_type: 'authorization_code',
507
470
  code: code,
508
- redirect_uri: redirect_uri,
509
- client_id: client_id,
471
+ redirect_uri: @redirect_uri,
472
+ client_id: @client_id,
510
473
  code_verifier: session.pkce_verifier
511
474
  }
512
475
 
513
- # Add client authentication
514
- if client_metadata.confidential?
515
- signing_key = JOSE::JWK.from_map(client_metadata.jwks["keys"].first)
516
- client_assertion = PAR::ClientAssertion.new(
517
- client_id: client_id,
518
- signing_key: signing_key
476
+ if @client_metadata.confidential?
477
+ assertion = PAR::ClientAssertion.new(
478
+ client_id: @client_id,
479
+ signing_key: @signing_key
519
480
  )
520
481
 
521
482
  body.merge!(
522
483
  client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
523
- client_assertion: client_assertion.generate_jwt(
524
- audience: session.auth_server.issuer
525
- )
484
+ client_assertion: assertion.generate_jwt(audience: session.auth_server.issuer)
526
485
  )
527
486
  end
528
487
 
@@ -531,7 +490,7 @@ module AuthProto
531
490
  body: body,
532
491
  headers: {
533
492
  "Content-Type" => "application/x-www-form-urlencoded",
534
- "DPoP" => proof
493
+ "DPoP" => dpop_proof
535
494
  }
536
495
  )
537
496
  end
@@ -540,16 +499,14 @@ module AuthProto
540
499
  return false unless response[:status] == 400
541
500
 
542
501
  error_data = JSON.parse(response[:body])
543
- error_data["error"] == "use_dpop_nonce"
502
+ error_data['error'] == DPoP::ERROR_USE_NONCE
544
503
  rescue JSON::ParserError
545
504
  false
546
505
  end
547
506
 
548
507
  def extract_dpop_nonce(response)
549
508
  headers = response[:headers]
550
- nonce = headers["DPoP-Nonce"] ||
551
- headers["dpop-nonce"] ||
552
- headers["Dpop-Nonce"]
509
+ nonce = headers.find { |k, v| k.downcase == 'dpop-nonce' }&.last
553
510
 
554
511
  raise TokenError, "No DPoP nonce provided in error response" unless nonce
555
512
 
@@ -557,27 +514,19 @@ module AuthProto
557
514
  end
558
515
 
559
516
  def validate_token_response(response, session)
560
- # Required fields
561
- %w[access_token token_type expires_in scope sub].each do |field|
517
+ ['access_token', 'token_type', 'expires_in', 'scope', 'sub'].each do |field|
562
518
  raise TokenError, "Missing #{field} in token response" unless response[field]
563
519
  end
564
520
 
565
- # Token type must be DPoP
566
- raise TokenError, "Invalid token_type: #{response["token_type"]}" unless response["token_type"] == "DPoP"
567
-
568
- # Scope must include atproto
569
- raise TokenError, "Missing atproto scope in token response" unless response["scope"].split.include?("atproto")
570
-
571
- # If we have a pre-resolved DID, verify it matches
572
- raise TokenError, "Subject mismatch in token response" if session.did && session.did != response["sub"]
521
+ raise TokenError, "Invalid token_type: #{response['token_type']}" unless response['token_type'] == 'DPoP'
522
+ raise TokenError, "Missing atproto scope in token response" unless response['scope'].split.include?('atproto')
573
523
 
574
- # Process DPoP-Nonce from response headers if present
575
- return unless response[:headers] && response[:headers]["DPoP-Nonce"]
524
+ # If we have a pre-resolved DID, verify that it matches
525
+ raise TokenError, "Subject mismatch in token response" if session.did && session.did != response['sub']
576
526
 
577
- dpop_client.process_response(
578
- response[:headers],
579
- session.auth_server.issuer
580
- )
527
+ if response[:headers] && response[:headers]["DPoP-Nonce"]
528
+ dpop_client.process_response(response[:headers], session.auth_server.issuer)
529
+ end
581
530
  end
582
531
  end
583
532
  end