authproto 0.3.0.beta2 → 0.3.0.beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/README.md +108 -5
  4. data/examples/confidential_client/README.md +10 -0
  5. data/examples/confidential_client/app.rb +8 -7
  6. data/examples/confidential_client/scripts/generate_keys.rb +1 -1
  7. data/examples/minisky_console/Gemfile.lock +2 -2
  8. data/examples/minisky_console/README.md +20 -1
  9. data/examples/minisky_console/localhost.rb +5 -3
  10. data/examples/minisky_console/production.rb +9 -3
  11. data/lib/authproto/client.rb +73 -90
  12. data/lib/authproto/configuration.rb +37 -16
  13. data/lib/authproto/dpop/client.rb +105 -40
  14. data/lib/authproto/dpop/{key_manager.rb → key_set.rb} +54 -120
  15. data/lib/authproto/dpop/nonce_manager.rb +33 -20
  16. data/lib/authproto/dpop/proof_generator.rb +17 -32
  17. data/lib/authproto/dpop.rb +19 -0
  18. data/lib/authproto/http_helpers.rb +85 -0
  19. data/lib/authproto/metadata/authorization_server.rb +12 -6
  20. data/lib/authproto/metadata/client_metadata.rb +18 -5
  21. data/lib/authproto/metadata/resource_server.rb +4 -2
  22. data/lib/authproto/minisky.rb +36 -34
  23. data/lib/authproto/par/client.rb +22 -73
  24. data/lib/authproto/par/client_assertion.rb +47 -16
  25. data/lib/authproto/par/request.rb +4 -8
  26. data/lib/authproto/par.rb +2 -1
  27. data/lib/authproto/serialization/session.rb +31 -13
  28. data/lib/authproto/serialization/token_set.rb +1 -2
  29. data/lib/authproto/state/session.rb +66 -6
  30. data/lib/authproto/state/session_manager.rb +95 -21
  31. data/lib/authproto/state/token_set.rb +16 -16
  32. data/lib/authproto/state.rb +1 -1
  33. data/lib/authproto/storage/key_builder.rb +3 -11
  34. data/lib/authproto/storage/memory.rb +8 -2
  35. data/lib/authproto/token/refresh.rb +26 -59
  36. data/lib/authproto/validated_uri.rb +23 -0
  37. data/lib/authproto/version.rb +1 -1
  38. data/lib/authproto.rb +0 -1
  39. data/sig/authproto/client.rbs +5 -7
  40. data/sig/authproto/dependencies.rbs +2 -0
  41. data/sig/authproto/dpop/client.rbs +11 -4
  42. data/sig/authproto/dpop/{key_manager.rbs → key_set.rbs} +9 -11
  43. data/sig/authproto/dpop/nonce_manager.rbs +4 -4
  44. data/sig/authproto/dpop/proof_generator.rbs +5 -10
  45. data/sig/authproto/dpop.rbs +6 -0
  46. data/sig/authproto/http_helpers.rbs +7 -0
  47. data/sig/authproto/localhost_server.rbs +1 -1
  48. data/sig/authproto/metadata/authorization_server.rbs +1 -0
  49. data/sig/authproto/metadata/client_metadata.rbs +2 -1
  50. data/sig/authproto/metadata/resource_server.rbs +1 -0
  51. data/sig/authproto/minisky_oauth.rbs +5 -2
  52. data/sig/authproto/par/client.rbs +4 -8
  53. data/sig/authproto/par/client_assertion.rbs +4 -0
  54. data/sig/authproto/par/request.rbs +0 -9
  55. data/sig/authproto/state/session.rbs +10 -3
  56. data/sig/authproto/state/session_manager.rbs +8 -3
  57. data/sig/authproto/state/token_set.rbs +1 -2
  58. data/sig/authproto/storage/key_builder.rbs +1 -2
  59. data/sig/authproto/token/refresh.rbs +3 -4
  60. data/sig/authproto.rbs +5 -2
  61. metadata +14 -19
  62. data/.rspec +0 -1
  63. data/.rubocop.yml +0 -31
  64. data/.tm_properties +0 -1
  65. data/.yardopts +0 -5
  66. data/AGENTS.md +0 -80
  67. data/Rakefile +0 -8
  68. data/examples/confidential_client/.gitignore +0 -6
  69. data/examples/minisky_console/.gitignore +0 -2
  70. data/lib/authproto/serialization/dpop_key.rb +0 -38
  71. data/misc/yard_attributes.rb +0 -34
  72. data/sig/authproto/serialization/dpop_key.rbs +0 -14
@@ -5,9 +5,10 @@ require 'openssl'
5
5
  require 'securerandom'
6
6
  require 'uri'
7
7
 
8
+ require_relative '../dpop'
8
9
  require_relative '../errors'
9
10
  require_relative '../validated_uri'
10
- require_relative 'key_manager'
11
+ require_relative 'key_set'
11
12
 
12
13
  module AuthProto
13
14
  module DPoP
@@ -24,49 +25,33 @@ module AuthProto
24
25
  # Error raised for proof generation/validation issues
25
26
  class ProofError < AuthProto::Error; end
26
27
 
27
- # Key manager used to sign proofs.
28
- # @return [KeyManager]
29
- attr_reader :key_manager
30
-
31
- # Creates a new ProofGenerator instance
32
- #
33
- # @param key_manager [KeyManager] Key manager to use for signing proofs
34
- # @raise [ProofError] if key_manager is invalid
35
- #
36
- def initialize(key_manager)
37
- raise ProofError, "key_manager is required" unless key_manager
38
- raise ProofError, "invalid key_manager type" unless key_manager.is_a?(KeyManager)
39
-
40
- @key_manager = key_manager
41
- end
42
-
43
28
  # Builds and signs a fresh DPoP JWT with `jti`, `htm`, `htu` and `iat` claims.
44
29
  #
45
- # A nonce and access-token hash are added when requested. The URI normalizer removes
46
- # query strings, fragments and default ports.
30
+ # Includes a nonce when provided and an access-token hash whenever an access token
31
+ # is supplied. The URI normalizer removes query strings, fragments and default ports.
47
32
  #
33
+ # @param key_set [KeySet] key belonging to the session making this request
48
34
  # @param method [String] nonempty HTTP method, converted to uppercase
49
35
  # @param uri [String] absolute HTTP(S) request URL
50
36
  # @param nonce [String, nil] server-provided nonce to include
51
37
  # @param access_token [String, nil] access token to bind to the proof
52
- # @param ath [Boolean, nil] include an access-token hash; `nil` enables it when a token is supplied
53
38
  # @return [JOSE::SignedBinary] compact signed DPoP JWT
54
39
  # @raise [ProofError] if input validation or proof generation fails
55
40
  #
56
- def generate(method:, uri:, nonce: nil, access_token: nil, ath: nil)
41
+ def generate(key_set:, method:, uri:, nonce: nil, access_token: nil)
42
+ raise ProofError, "invalid key_set type" unless key_set.is_a?(KeySet)
43
+
57
44
  validate_inputs(method, uri)
58
- ath = !access_token.nil? if ath.nil?
59
45
 
60
- header = build_header
46
+ header = build_header(key_set)
61
47
  payload = build_payload(
62
48
  method: method,
63
49
  uri: uri,
64
50
  nonce: nonce,
65
- access_token: access_token,
66
- include_ath: ath
51
+ access_token: access_token
67
52
  )
68
53
 
69
- key_manager.sign_segments(header, payload)
54
+ key_set.sign_segments(header, payload)
70
55
  rescue StandardError => e
71
56
  raise ProofError, "Failed to generate proof: #{e.message}"
72
57
  end
@@ -82,15 +67,15 @@ module AuthProto
82
67
  raise ProofError, "invalid uri: #{e.message}"
83
68
  end
84
69
 
85
- def build_header
70
+ def build_header(key_set)
86
71
  {
87
72
  typ: 'dpop+jwt',
88
- alg: 'ES256',
89
- jwk: key_manager.public_jwk.to_h
73
+ alg: ALGORITHM,
74
+ jwk: key_set.public_jwk
90
75
  }
91
76
  end
92
77
 
93
- def build_payload(method:, uri:, nonce: nil, access_token: nil, include_ath: nil)
78
+ def build_payload(method:, uri:, nonce: nil, access_token: nil)
94
79
  payload = {
95
80
  'jti' => SecureRandom.uuid,
96
81
  'htm' => method.upcase,
@@ -101,8 +86,8 @@ module AuthProto
101
86
  # Add the nonce if provided
102
87
  payload['nonce'] = nonce if nonce
103
88
 
104
- # Add access token hash if needed
105
- payload['ath'] = generate_access_token_hash(access_token) if access_token && include_ath
89
+ # Bind the proof to the access token whenever one is supplied
90
+ payload['ath'] = generate_access_token_hash(access_token) unless access_token.nil?
106
91
 
107
92
  payload
108
93
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AuthProto
4
+
5
+ #
6
+ # Demonstrating Proof of Possession (DPoP) signing and request authentication.
7
+ #
8
+
9
+ module DPoP
10
+
11
+ # JWS signing algorithm used for DPoP proofs.
12
+ # @return [String]
13
+ ALGORITHM = "ES256"
14
+
15
+ # OAuth error value requesting a retry with a server-provided DPoP nonce.
16
+ # @return [String]
17
+ ERROR_USE_NONCE = "use_dpop_nonce"
18
+ end
19
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'strscan'
4
+
5
+ module AuthProto
6
+
7
+ #
8
+ # Shared helpers for HTTP response hashes returned by the configured transport.
9
+ #
10
+
11
+ module HTTPHelpers
12
+
13
+ private
14
+
15
+ # Looks up a response header without regard to the case of its name.
16
+ #
17
+ # @param response [Hash{Symbol => Object}] HTTP response with an optional `:headers` hash
18
+ # @param name [String] header name, in any case
19
+ # @param strip_parameters [Boolean] return only the trimmed value before the first semicolon
20
+ # @return [String, nil] header value, or `nil` if the header or headers hash is absent
21
+ #
22
+ def response_header(response, name, strip_parameters: false)
23
+ value = (response[:headers] || {}).find { |header, value| header.casecmp?(name) }&.last
24
+ return value unless value && strip_parameters
25
+
26
+ value.split(';', 2).first.to_s.strip
27
+ end
28
+
29
+ # Finds an authentication scheme in a WWW-Authenticate challenge list.
30
+ # Quoted commas and escapes are preserved while parsing. Scheme and parameter
31
+ # names are case-insensitive; parameter values retain their original case.
32
+ #
33
+ # @param header [String, nil] combined WWW-Authenticate header value
34
+ # @param scheme [String] authentication scheme to select
35
+ # @return [Hash{String => String}, nil] parameters, or `nil` for absent, malformed or multiple matching challenges
36
+ #
37
+ def authentication_challenge(header, scheme:)
38
+ return nil unless header.is_a?(String)
39
+
40
+ scanner = StringScanner.new(header)
41
+ matches = []
42
+ scanner.skip(/[ \t]*/)
43
+
44
+ loop do
45
+ name = scanner.scan(/[!#$%&'*+\-.^_`|~0-9A-Za-z]+/)
46
+ return nil unless name
47
+
48
+ whitespace = scanner.skip(/[ \t]*/)
49
+ params = {}
50
+
51
+ unless scanner.eos? || scanner.check(/,/)
52
+ return nil if whitespace.zero?
53
+
54
+ # Other schemes can carry token68 credentials rather than parameters.
55
+ unless scanner.scan(/[A-Za-z0-9\-._~+\/]+=*(?=[ \t]*(?:,|\z))/)
56
+ loop do
57
+ parameter = scanner.scan(/[!#$%&'*+\-.^_`|~0-9A-Za-z]+/)&.downcase
58
+ return nil unless parameter && !params.key?(parameter)
59
+
60
+ scanner.skip(/[ \t]*/)
61
+ return nil unless scanner.scan(/=/)
62
+
63
+ scanner.skip(/[ \t]*/)
64
+ value = scanner.scan(/"(?:\\.|[^"\\])*"/) || scanner.scan(/[!#$%&'*+\-.^_`|~0-9A-Za-z]+/)
65
+ return nil unless value
66
+
67
+ params[parameter] = value.start_with?('"') ? value[1...-1].gsub(/\\(.)/, '\\1') : value
68
+ scanner.skip(/[ \t]*/)
69
+ break unless scanner.check(/,[ \t]*[!#$%&'*+\-.^_`|~0-9A-Za-z]+[ \t]*=/)
70
+
71
+ scanner.scan(/,[ \t]*/)
72
+ end
73
+ end
74
+ end
75
+
76
+ matches << params if name.casecmp?(scheme)
77
+ scanner.skip(/[ \t]*/)
78
+ break if scanner.eos?
79
+ return nil unless scanner.scan(/,[ \t]*/)
80
+ end
81
+
82
+ matches.one? ? matches.first : nil
83
+ end
84
+ end
85
+ end
@@ -4,9 +4,12 @@ require 'json'
4
4
  require 'uri'
5
5
 
6
6
  require_relative '../configuration'
7
+ require_relative '../dpop'
7
8
  require_relative '../errors'
8
9
  require_relative '../http_client'
10
+ require_relative '../http_helpers'
9
11
  require_relative '../metadata'
12
+ require_relative '../par/client_assertion'
10
13
  require_relative '../validated_uri'
11
14
 
12
15
  module AuthProto
@@ -34,6 +37,7 @@ module AuthProto
34
37
  #
35
38
 
36
39
  class AuthorizationServer
40
+ extend HTTPHelpers
37
41
 
38
42
  # Metadata keys that must be present before capability validation.
39
43
  # @return [Array<String>]
@@ -258,8 +262,10 @@ module AuthProto
258
262
  @token_endpoint_auth_signing_alg_values_supported, 'token_endpoint_auth_signing_alg_values_supported'
259
263
  )
260
264
 
261
- unless @token_endpoint_auth_signing_alg_values_supported.include?('ES256')
262
- raise InvalidAuthorizationServer, "token_endpoint_auth_signing_alg_values_supported must include ES256"
265
+ algorithm = PAR::ClientAssertion::ALGORITHM
266
+
267
+ unless @token_endpoint_auth_signing_alg_values_supported.include?(algorithm)
268
+ raise InvalidAuthorizationServer, "token_endpoint_auth_signing_alg_values_supported must include #{algorithm}"
263
269
  end
264
270
 
265
271
  if @token_endpoint_auth_signing_alg_values_supported.include?('none')
@@ -270,8 +276,8 @@ module AuthProto
270
276
  def validate_dpop_signing_algs
271
277
  validate_string_array(@dpop_signing_alg_values_supported, 'dpop_signing_alg_values_supported')
272
278
 
273
- unless @dpop_signing_alg_values_supported.include?('ES256')
274
- raise InvalidAuthorizationServer, "dpop_signing_alg_values_supported must include ES256"
279
+ unless @dpop_signing_alg_values_supported.include?(DPoP::ALGORITHM)
280
+ raise InvalidAuthorizationServer, "dpop_signing_alg_values_supported must include #{DPoP::ALGORITHM}"
275
281
  end
276
282
  end
277
283
 
@@ -328,9 +334,9 @@ module AuthProto
328
334
  raise InvalidAuthorizationServer, "Authorization server metadata must return HTTP 200"
329
335
  end
330
336
 
331
- content_type = response[:headers]["content-type"].to_s.split(";", 2).first.to_s.strip
337
+ content_type = response_header(response, 'Content-Type', strip_parameters: true)
332
338
 
333
- unless content_type.casecmp?('application/json')
339
+ unless content_type.to_s.casecmp?('application/json')
334
340
  raise InvalidAuthorizationServer, "Authorization server metadata must use application/json"
335
341
  end
336
342
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'json'
4
3
  require 'jose'
4
+ require 'json'
5
5
  require 'uri'
6
6
 
7
7
  require_relative '../configuration'
8
8
  require_relative '../errors'
9
+ require_relative '../http_helpers'
9
10
  require_relative '../metadata'
11
+ require_relative '../par/client_assertion'
10
12
  require_relative '../validated_uri'
11
13
 
12
14
  module AuthProto
@@ -45,6 +47,7 @@ module AuthProto
45
47
  #
46
48
 
47
49
  class ClientMetadata
50
+ extend HTTPHelpers
48
51
 
49
52
  # Client authentication method for confidential clients
50
53
  AUTH_METHOD_PRIVATE_KEY_JWT = "private_key_jwt"
@@ -166,9 +169,9 @@ module AuthProto
166
169
  "expected HTTP 200 response for client metadata request, got #{response[:status]}"
167
170
  end
168
171
 
169
- content_type = (response[:headers] || {}).find { |name, _| name.downcase == 'content-type' }&.last
172
+ content_type = response_header(response, 'Content-Type', strip_parameters: true)
170
173
 
171
- unless content_type.to_s.split(';', 2).first.to_s.strip.downcase == 'application/json'
174
+ unless content_type.to_s.downcase == 'application/json'
172
175
  raise InvalidClientMetadata, "client metadata Content-Type must be application/json"
173
176
  end
174
177
 
@@ -472,6 +475,8 @@ module AuthProto
472
475
  raise InvalidClientMetadata, "jwks must have a 'keys' array" unless jwks['keys'].is_a?(Array)
473
476
  raise InvalidClientMetadata, "jwks must contain at least one public key" if jwks['keys'].empty?
474
477
 
478
+ key_ids = Set.new
479
+
475
480
  jwks['keys'].each_with_index do |key, index|
476
481
  path = "jwks.keys.#{index}"
477
482
 
@@ -481,6 +486,12 @@ module AuthProto
481
486
  raise InvalidClientMetadata, "#{path} must have a nonempty string kid"
482
487
  end
483
488
 
489
+ if key_ids.include?(key['kid'])
490
+ raise InvalidClientMetadata, "#{path} has a duplicate kid: #{key['kid']}"
491
+ end
492
+
493
+ key_ids << key['kid']
494
+
484
495
  if %w[d p q dp dq qi oth k].any? { |field| key.key?(field) }
485
496
  raise InvalidClientMetadata, "#{path} must contain only public key material"
486
497
  end
@@ -561,8 +572,10 @@ module AuthProto
561
572
  end
562
573
 
563
574
  def validate_auth_confidential(metadata)
564
- unless @token_endpoint_auth_signing_alg == 'ES256'
565
- raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be ES256"
575
+ algorithm = PAR::ClientAssertion::ALGORITHM
576
+
577
+ unless @token_endpoint_auth_signing_alg == algorithm
578
+ raise InvalidClientMetadata, "token_endpoint_auth_signing_alg must be #{algorithm}"
566
579
  end
567
580
 
568
581
  if @jwks.nil? && @jwks_uri.nil?
@@ -6,6 +6,7 @@ require 'uri'
6
6
  require_relative '../configuration'
7
7
  require_relative '../errors'
8
8
  require_relative '../http_client'
9
+ require_relative '../http_helpers'
9
10
  require_relative '../metadata'
10
11
  require_relative '../validated_uri'
11
12
 
@@ -21,6 +22,7 @@ module AuthProto
21
22
  #
22
23
 
23
24
  class ResourceServer
25
+ extend HTTPHelpers
24
26
 
25
27
  # The single authorization server origin advertised by the resource server.
26
28
  # @return [String]
@@ -107,9 +109,9 @@ module AuthProto
107
109
  raise InvalidAuthorizationServer, "Resource server metadata must return HTTP 200"
108
110
  end
109
111
 
110
- content_type = response[:headers]["content-type"].to_s.split(";", 2).first.to_s.strip
112
+ content_type = response_header(response, 'Content-Type', strip_parameters: true)
111
113
 
112
- unless content_type.casecmp?("application/json")
114
+ unless content_type.to_s.casecmp?("application/json")
113
115
  raise InvalidAuthorizationServer, "Resource server metadata must use application/json"
114
116
  end
115
117
  end
@@ -6,6 +6,7 @@ require 'net/http'
6
6
  require 'uri'
7
7
 
8
8
  require_relative 'client'
9
+ require_relative 'http_helpers'
9
10
 
10
11
  #
11
12
  # Experimental Minisky client backed by an AuthProto session.
@@ -13,6 +14,7 @@ require_relative 'client'
13
14
  #
14
15
 
15
16
  class Minisky::OAuthClient
17
+ include AuthProto::HTTPHelpers
16
18
  include Minisky::Requests
17
19
 
18
20
  # Maximum authenticated XRPC retries after nonce challenges.
@@ -41,14 +43,14 @@ class Minisky::OAuthClient
41
43
  # @param redirect_uri [String] registered callback URI
42
44
  # @param metadata [Hash{String => Object}, nil] preloaded metadata, or `nil` to fetch it
43
45
  # @param signing_key [Hash, JOSE::JWK, nil] private ES256 key for confidential-client authentication
44
- # @param dpop_key [Hash{String => Object}, nil] existing private DPoP JWK, or `nil` to generate one
46
+ # @param session_id [String, nil] stored session ID to resume; `nil` starts without an attached session
45
47
  #
46
48
  # @raise [AuthProto::InvalidClientMetadata] if metadata is invalid or unavailable
47
49
  # @raise [AuthProto::Error] if OAuth client or DPoP initialization fails
48
50
  # @raise [AuthProto::HTTPClient::HTTPError, AuthProto::HTTPClient::SSRFError] if fetching metadata fails
49
51
  # @see AuthProto::Client#initialize
50
52
  #
51
- def initialize(host, client_id:, redirect_uri:, metadata: nil, dpop_key: nil, signing_key: nil, session_id: nil)
53
+ def initialize(host, client_id:, redirect_uri:, metadata: nil, signing_key: nil, session_id: nil)
52
54
  @host = host
53
55
  @session_id = session_id
54
56
 
@@ -59,7 +61,6 @@ class Minisky::OAuthClient
59
61
  client_id: client_id,
60
62
  redirect_uri: redirect_uri,
61
63
  metadata: metadata,
62
- dpop_key: dpop_key,
63
64
  signing_key: signing_key
64
65
  )
65
66
  end
@@ -87,19 +88,22 @@ class Minisky::OAuthClient
87
88
 
88
89
  # Completes OAuth authorization and remembers the authenticated session ID.
89
90
  #
91
+ # @param expected_session_id [String] pending session ID retained in the initiating browser session or native context
90
92
  # @param options [Hash{Symbol => String}] callback parameters forwarded to {AuthProto::Client#handle_callback}
91
93
  # @option options [String] :code authorization code
92
94
  # @option options [String] :state callback state token
93
95
  # @option options [String] :iss authorization server issuer
94
96
  #
95
97
  # @return [Hash{Symbol => String, Integer, nil}] token and session details from the OAuth client
96
- # @raise [AuthProto::Client::CallbackError] if state or issuer validation fails
98
+ # @raise [AuthProto::Client::CallbackError] if expected session, state or issuer validation fails
97
99
  # @raise [AuthProto::Client::TokenError] if token exchange or validation fails
98
100
  # @raise [AuthProto::Error] if proofs, storage or serialization fail
99
101
  # @see AuthProto::Client#handle_callback
100
102
  #
101
- def handle_callback(**options)
102
- oauth_client.handle_callback(**options).tap { |result| @session_id = result.fetch(:session_id) }
103
+ def handle_callback(expected_session_id:, **options)
104
+ oauth_client.handle_callback(**options, expected_session_id: expected_session_id).tap do |result|
105
+ @session_id = result.fetch(:session_id)
106
+ end
103
107
  end
104
108
 
105
109
  # Sends an XRPC GET through Minisky, adding OAuth handling when authenticated.
@@ -117,7 +121,7 @@ class Minisky::OAuthClient
117
121
  # @raise [ArgumentError] if the method is invalid or `auth` is a truthy non-Boolean value
118
122
  # @raise [Minisky::AuthError] if no completed OAuth login or refresh capability is available
119
123
  # @raise [Minisky::BadResponse] if the final response has an error or redirect status
120
- # @raise [AuthProto::DPoP::Client::Error] if proof handling fails or a nonce challenge lacks its nonce
124
+ # @raise [AuthProto::DPoP::Client::Error] if proof handling fails or any DPoP response lacks a valid nonce
121
125
  # @raise [AuthProto::Error] if OAuth refresh or token persistence fails
122
126
  # @raise [JSON::ParserError] if a JSON response cannot be parsed
123
127
  # @raise [Net::OpenTimeout, Net::ReadTimeout, IOError, SystemCallError] if HTTP transport fails
@@ -152,7 +156,7 @@ class Minisky::OAuthClient
152
156
  # @raise [ArgumentError] if the method is invalid or `auth` is a truthy non-Boolean value
153
157
  # @raise [Minisky::AuthError] if no completed OAuth login or refresh capability is available
154
158
  # @raise [Minisky::BadResponse] if the final response has an error or redirect status
155
- # @raise [AuthProto::DPoP::Client::Error] if proof handling fails or a nonce challenge lacks its nonce
159
+ # @raise [AuthProto::DPoP::Client::Error] if proof handling fails or any DPoP response lacks a valid nonce
156
160
  # @raise [AuthProto::Error] if OAuth refresh or token persistence fails
157
161
  # @raise [JSON::ParserError] if a JSON response cannot be parsed
158
162
  # @raise [Net::OpenTimeout, Net::ReadTimeout, IOError, SystemCallError] if HTTP transport fails
@@ -196,6 +200,7 @@ class Minisky::OAuthClient
196
200
  # @raise [AuthProto::Client::TokenError] if the session cannot be refreshed
197
201
  # @raise [AuthProto::Token::RefreshError] if refresh fails
198
202
  # @raise [AuthProto::Storage::StorageError] if token persistence fails
203
+ # @raise [AuthProto::DPoP::Client::Error] if a refresh response has a missing or invalid nonce
199
204
  #
200
205
  def perform_token_refresh
201
206
  oauth_client.refresh_token(session_id)
@@ -209,14 +214,17 @@ class Minisky::OAuthClient
209
214
  #
210
215
  def check_access
211
216
  session = oauth_client.session_manager.get_session(session_id) if session_id
212
- raise Minisky::AuthError, "Complete OAuth login before making authenticated requests" unless session&.tokens
213
-
214
- return :ok unless session.tokens.expired?
215
-
216
- raise Minisky::AuthError, "OAuth session cannot be refreshed; log in again" unless session.renewable?
217
217
 
218
- perform_token_refresh
219
- :refreshed
218
+ if !session || session.pending?
219
+ raise Minisky::AuthError, "Complete OAuth login before making authenticated requests"
220
+ elsif session.tokens_valid?
221
+ :ok
222
+ elsif session.renewable?
223
+ perform_token_refresh
224
+ :refreshed
225
+ else
226
+ raise Minisky::AuthError, "OAuth session cannot be refreshed; log in again"
227
+ end
220
228
  end
221
229
 
222
230
 
@@ -237,35 +245,29 @@ class Minisky::OAuthClient
237
245
 
238
246
  request = request_class.new(url, request_headers)
239
247
  request.body = body unless body.nil?
248
+
240
249
  response = make_request(request)
250
+ response_hash = { status: response.code.to_i, headers: response.each_header.to_h, body: response.body }
241
251
 
242
- nonce = response["DPoP-Nonce"]
243
- oauth_client.dpop_client.process_response(response.each_header.to_h, url.to_s) if nonce && !nonce.empty?
244
- error = authentication_error(response)
252
+ oauth_client.dpop_client.process_response(response_hash, server_url: url.to_s, session_id: session_id)
245
253
 
246
- if response.code.to_i == 401 && error == AuthProto::DPoP::ERROR_USE_NONCE
247
- unless nonce && !nonce.empty?
248
- raise AuthProto::DPoP::Client::Error, "DPoP nonce challenge (HTTP 401) is missing DPoP-Nonce"
249
- end
254
+ if oauth_client.dpop_client.has_nonce_error?(response_hash)
250
255
  return response if nonce_retries >= MAX_NONCE_RETRIES
251
-
252
256
  nonce_retries += 1
253
- elsif response.code.to_i == 401 && error == "invalid_token" && !refreshed
254
- perform_token_refresh
255
- refreshed = true
256
257
  else
257
- return response
258
+ error = authentication_error(response)
259
+
260
+ if response.code.to_i == 401 && error == "invalid_token" && !refreshed
261
+ perform_token_refresh
262
+ refreshed = true
263
+ else
264
+ return response
265
+ end
258
266
  end
259
267
  end
260
268
  end
261
269
 
262
270
  def authentication_error(response)
263
- # Only interpret a single DPoP challenge. Leave other auth schemes and
264
- # ambiguous multi-challenge headers to Minisky's normal error handling.
265
- challenge = response["WWW-Authenticate"].to_s
266
- return unless challenge.match?(/\ADPoP\s/i)
267
- return if challenge.match?(/,\s*(?:Bearer|Basic|DPoP)\b/i)
268
-
269
- challenge[/\berror\s*=\s*"(use_dpop_nonce|invalid_token)"/i, 1]
271
+ authentication_challenge(response['WWW-Authenticate'], scheme: 'DPoP')&.fetch('error', nil)
270
272
  end
271
273
  end