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
@@ -15,72 +15,116 @@ module AuthProto
15
15
  class SessionManager
16
16
 
17
17
  # Creates a session manager and its encrypted session serializer.
18
+ #
19
+ # @param supported_signing_keys [Hash{String => String}] key IDs mapped to RFC 7638 SHA-256 thumbprints
20
+ # @raise [ArgumentError] if key IDs or thumbprints are not nonempty strings
18
21
  # @raise [ArgumentError] if `ATPROTO_MASTER_KEY` is not valid Base64
19
22
  #
20
- def initialize
23
+ def initialize(supported_signing_keys: {})
24
+ keys = supported_signing_keys
25
+
26
+ unless keys.is_a?(Hash) && keys.all? { |id, thumb| [id, thumb].all? { |x| x.is_a?(String) && !x.empty? }}
27
+ raise ArgumentError, "supported_signing_keys must map nonempty key IDs to nonempty thumbprints"
28
+ end
29
+
30
+ @supported_signing_keys = keys.to_h { |id, thumb| [id.dup.freeze, thumb.dup.freeze] }.freeze
21
31
  @serializer = Serialization::Session.new
22
32
  end
23
33
 
24
34
  # Creates and stores a session and its state-token lookup under a storage lock.
35
+ # The signing key thumbprint is taken from the configured key mapping.
25
36
  #
26
37
  # @param client_id [String] OAuth client ID
27
38
  # @param scope [String] requested space-separated scopes
28
39
  # @param auth_server [Metadata::AuthorizationServer, nil] pre-resolved authorization server
29
40
  # @param did [String, nil] pre-resolved account DID
41
+ # @param signing_key_id [String, nil] client-authentication key ID selected before PAR
30
42
  #
31
43
  # @return [Session] newly created session
44
+ # @raise [ArgumentError] if the signing key ID is not configured
45
+ # @raise [DPoP::KeySet::KeyError] if session key generation fails
32
46
  # @raise [PKCE::Error] if session PKCE generation fails
33
47
  # @raise [Storage::StorageError] if writing or locking fails
34
48
  # @raise [Serialization::ValidationError] if the session cannot be serialized
35
49
  # @raise [Encryption::EncryptionError] if sensitive fields cannot be encrypted
36
50
  #
37
- def create_session(client_id:, scope:, auth_server: nil, did: nil)
51
+ def create_session(client_id:, scope:, auth_server: nil, did: nil, signing_key_id: nil)
52
+ if signing_key_id && !@supported_signing_keys.key?(signing_key_id)
53
+ raise ArgumentError, "Unsupported signing_key_id: #{signing_key_id}"
54
+ end
55
+
38
56
  session = Session.new(
39
57
  client_id: client_id,
40
58
  scope: scope,
41
59
  auth_server: auth_server,
42
- did: did
60
+ did: did,
61
+ signing_key_id: signing_key_id,
62
+ signing_key_thumbprint: @supported_signing_keys[signing_key_id],
63
+ authorization_expires_at: Time.now + AuthProto.configuration.pending_session_ttl
43
64
  )
44
65
 
45
- # Store both session and state mapping atomically
66
+ # Store the session and state mapping with the same pending authorization lifetime.
46
67
  session_key = Storage::KeyBuilder.session_key(session.session_id)
47
68
  state_key = Storage::KeyBuilder.state_key(session.state_token)
48
69
 
49
70
  AuthProto.storage.with_lock(session_key, ttl: 30) do
71
+ ttl = session_ttl(session)
50
72
  serialized = @serializer.serialize(session)
51
- AuthProto.storage.set(session_key, serialized)
52
- AuthProto.storage.set(state_key, session.session_id)
73
+ AuthProto.storage.set(session_key, serialized, ttl: ttl)
74
+ AuthProto.storage.set(state_key, session.session_id, ttl: ttl)
53
75
  end
54
76
 
55
77
  session
56
78
  end
57
79
 
58
- # Persists a session and updates its state-token lookup under a storage lock.
80
+ # Persists a session under a storage lock.
81
+ #
82
+ # Pending sessions retain their original deadline. Non-renewable authenticated sessions
83
+ # expire with their access token; renewable sessions have no storage expiry.
59
84
  #
60
85
  # @param session [Session] session to persist
61
86
  # @return [Session] the supplied session
62
87
  # @raise [Storage::StorageError] if writing or locking fails
63
88
  # @raise [Serialization::ValidationError] if the session cannot be serialized
64
89
  # @raise [Encryption::EncryptionError] if sensitive fields cannot be encrypted
90
+ # @raise [SessionError] if the pending deadline is missing or the session has expired
65
91
  #
66
92
  def update_session(session)
67
93
  session_key = Storage::KeyBuilder.session_key(session.session_id)
68
94
 
69
95
  AuthProto.storage.with_lock(session_key, ttl: 30) do
96
+ ttl = session_ttl(session)
70
97
  serialized = @serializer.serialize(session)
71
- AuthProto.storage.set(session_key, serialized)
72
-
73
- state_key = Storage::KeyBuilder.state_key(session.state_token)
74
- AuthProto.storage.set(state_key, session.session_id)
98
+ AuthProto.storage.set(session_key, serialized, ttl: ttl)
75
99
  end
76
100
 
77
101
  session
78
102
  end
79
103
 
104
+ # Persists successful authorization without its state token and removes the callback lookup.
105
+ #
106
+ # @param session [Session] session with successfully validated and assigned tokens
107
+ # @return [Session] the supplied session, with its state token cleared
108
+ # @raise [Storage::StorageError] if writing, deletion or locking fails
109
+ # @raise [Serialization::ValidationError] if the session cannot be serialized
110
+ # @raise [Encryption::EncryptionError] if sensitive fields cannot be encrypted
111
+ #
112
+ def complete_authorization(session)
113
+ state_token = session.state_token
114
+ session.clear_state_token
115
+ update_session(session)
116
+
117
+ AuthProto.storage.delete(Storage::KeyBuilder.state_key(state_token)) if state_token
118
+
119
+ session
120
+ end
121
+
80
122
  # Loads and decrypts a session by ID.
81
123
  #
82
124
  # Returns `nil` for missing, unreadable or malformed sessions and for expired
83
- # sessions that have no refresh token. Read/deserialization errors are logged.
125
+ # sessions that have no refresh token. Sessions with an unsupported signing key ID
126
+ # or a missing/mismatching key thumbprint are unavailable but remain in storage.
127
+ # Public and legacy sessions without an ID are accepted. Read/deserialization errors are logged.
84
128
  #
85
129
  # @param session_id [String] session identifier
86
130
  # @return [Session, nil] restored session, or `nil` if unavailable
@@ -98,7 +142,18 @@ module AuthProto
98
142
 
99
143
  begin
100
144
  session = @serializer.deserialize(serialized)
101
- return nil if !session.renewable? && session.tokens&.expired?
145
+
146
+ if session.signing_key_id
147
+ thumbprint = @supported_signing_keys[session.signing_key_id]
148
+ return nil unless thumbprint && thumbprint == session.signing_key_thumbprint
149
+ end
150
+
151
+ if session.pending?
152
+ deadline = session.authorization_expires_at
153
+ return nil if !deadline || deadline <= Time.now
154
+ else
155
+ return nil if !session.tokens_valid? && !session.renewable?
156
+ end
102
157
 
103
158
  session
104
159
  rescue StandardError => e
@@ -111,13 +166,13 @@ module AuthProto
111
166
  #
112
167
  # Storage read errors are logged and treated as a missing session.
113
168
  #
114
- # @param state [String, nil] callback state token
169
+ # @param state_token [String, nil] callback state token
115
170
  # @return [Session, nil] restored session, or `nil` if unavailable
116
171
  #
117
- def get_session_by_state(state)
118
- return nil unless state
172
+ def get_session_by_state_token(state_token)
173
+ return nil unless state_token
119
174
 
120
- state_key = Storage::KeyBuilder.state_key(state)
175
+ state_key = Storage::KeyBuilder.state_key(state_token)
121
176
 
122
177
  begin
123
178
  session_id = AuthProto.storage.get(state_key)
@@ -127,7 +182,9 @@ module AuthProto
127
182
  return nil
128
183
  end
129
184
 
130
- get_session(session_id)
185
+ session = get_session(session_id)
186
+
187
+ session if !session.nil? && session.state_token == state_token
131
188
  end
132
189
 
133
190
  # Deletes a session and its state mapping if it can be retrieved.
@@ -141,11 +198,9 @@ module AuthProto
141
198
  return unless session
142
199
 
143
200
  session_key = Storage::KeyBuilder.session_key(session_id)
144
- state_key = Storage::KeyBuilder.state_key(session.state_token)
145
-
146
201
  AuthProto.storage.with_lock(session_key, ttl: 30) do
147
202
  AuthProto.storage.delete(session_key)
148
- AuthProto.storage.delete(state_key)
203
+ AuthProto.storage.delete(Storage::KeyBuilder.state_key(session.state_token)) if session.state_token
149
204
  end
150
205
  end
151
206
 
@@ -157,6 +212,25 @@ module AuthProto
157
212
  def cleanup_expired
158
213
  # No-op - expiry handled by storage TTL and retrieval validation
159
214
  end
215
+
216
+
217
+ private
218
+
219
+ def session_ttl(session)
220
+ return nil if session.renewable?
221
+
222
+ if session.pending?
223
+ expires_at = session.authorization_expires_at
224
+ raise SessionError, "Pending session has no authorization deadline" unless expires_at
225
+ else
226
+ expires_at = session.tokens.expires_at
227
+ end
228
+
229
+ remaining = expires_at - Time.now
230
+ raise SessionError, "Session has expired" unless remaining.positive?
231
+
232
+ remaining.ceil
233
+ end
160
234
  end
161
235
  end
162
236
  end
@@ -17,10 +17,6 @@ module AuthProto
17
17
  # @return [String, nil]
18
18
  attr_reader :refresh_token
19
19
 
20
- # Token scheme; always `DPoP`.
21
- # @return [String]
22
- attr_reader :token_type
23
-
24
20
  # Space-separated granted scopes.
25
21
  # @return [String]
26
22
  attr_reader :scope
@@ -36,35 +32,37 @@ module AuthProto
36
32
  # Creates a token set and calculates its expiry from the current time.
37
33
  #
38
34
  # @param access_token [String] nonempty DPoP-bound access token
39
- # @param token_type [String] must be `DPoP`
40
- # @param expires_in [Integer] lifetime in seconds; negative values represent expired tokens
35
+ # @param token_type [String] must be `DPoP` (case-insensitive)
36
+ # @param expires_in [Integer] lifetime of the access token in seconds; negative value means it's already expired
41
37
  # @param scope [String] nonempty space-separated granted scopes
42
38
  # @param sub [String] nonempty account DID
43
39
  # @param refresh_token [String, nil] refresh token, if issued
44
40
  # @raise [ArgumentError] if required strings are missing, the token type is wrong, or expiry is not an integer
45
41
  #
46
- def initialize(
47
- access_token:,
48
- token_type:,
49
- expires_in:,
50
- scope:,
51
- sub:,
52
- refresh_token: nil
53
- )
42
+ def initialize(access_token:, token_type:, expires_in:, scope:, sub:, refresh_token: nil)
54
43
  validate_token_type(token_type)
44
+
55
45
  validate_required('access_token', access_token)
56
46
  validate_required('scope', scope)
57
47
  validate_required('sub', sub)
48
+
58
49
  validate_expires_in(expires_in)
59
50
 
60
51
  @access_token = access_token
61
52
  @refresh_token = refresh_token
62
- @token_type = token_type
63
53
  @scope = scope
64
54
  @sub = sub
65
55
  @expires_at = Time.now + expires_in
66
56
  end
67
57
 
58
+ # Returns the token scheme, which is always `DPoP`.
59
+ #
60
+ # @return [String] `DPoP`
61
+ #
62
+ def token_type
63
+ 'DPoP'
64
+ end
65
+
68
66
  # Whether this token set includes a refresh token
69
67
  # @return [Boolean]
70
68
  def renewable?
@@ -84,7 +82,9 @@ module AuthProto
84
82
  private
85
83
 
86
84
  def validate_token_type(type)
87
- raise ArgumentError, "token_type must be DPoP" unless type == 'DPoP'
85
+ unless type.is_a?(String) && type.casecmp?('DPoP')
86
+ raise ArgumentError, "token_type must be DPoP"
87
+ end
88
88
  end
89
89
 
90
90
  def validate_required(name, value)
@@ -45,7 +45,7 @@ module AuthProto
45
45
  #
46
46
  # # Lookup session later
47
47
  # session = manager.get_session(session.session_id)
48
- # if session.authorized?
48
+ # if session.tokens_valid?
49
49
  # puts "Access token: #{session.tokens.access_token}"
50
50
  # end
51
51
  #
@@ -39,20 +39,12 @@ module AuthProto
39
39
 
40
40
  # Builds a namespaced storage key for a DPoP nonce.
41
41
  #
42
+ # @param session_id [String] OAuth session identifier
42
43
  # @param server_url [String] server origin URL
43
44
  # @return [String] colon-separated key beginning with `atproto:`
44
45
  #
45
- def nonce_key(server_url)
46
- build_key("nonce", server_url)
47
- end
48
-
49
- # Builds a namespaced storage key for a DPoP key.
50
- #
51
- # @param client_id [String] OAuth client ID
52
- # @return [String] colon-separated key beginning with `atproto:`
53
- #
54
- def dpop_key(client_id)
55
- build_key("dpop", client_id)
46
+ def nonce_key(session_id, server_url)
47
+ build_key("nonce", session_id, server_url)
56
48
  end
57
49
 
58
50
  # Builds a namespaced storage key for a lock.
@@ -32,7 +32,7 @@ module AuthProto
32
32
  #
33
33
  # @param key [String] nonempty key beginning with `atproto:`
34
34
  # @param value [Object] value retained by reference
35
- # @param ttl [Integer, nil] positive expiry in seconds, or `nil` for no new expiry
35
+ # @param ttl [Integer, nil] positive expiry in seconds, or `nil` to clear any expiry
36
36
  # @return [true] if the write succeeds
37
37
  # @raise [StorageError] if key or TTL validation fails
38
38
  #
@@ -42,7 +42,13 @@ module AuthProto
42
42
 
43
43
  @monitor.synchronize do
44
44
  @data[key] = value
45
- set_expiration(key, ttl) if ttl
45
+
46
+ if ttl
47
+ set_expiration(key, ttl)
48
+ else
49
+ @expirations.delete(key)
50
+ end
51
+
46
52
  true
47
53
  end
48
54
  end
@@ -7,6 +7,7 @@ require 'monitor'
7
7
  require_relative '../configuration'
8
8
  require_relative '../errors'
9
9
  require_relative '../http_client'
10
+ require_relative '../http_helpers'
10
11
  require_relative '../par'
11
12
  require_relative '../par/client_assertion'
12
13
  require_relative '../state/token_set'
@@ -116,6 +117,7 @@ module AuthProto
116
117
  #
117
118
 
118
119
  class Refresh
120
+ include HTTPHelpers
119
121
  include MonitorMixin
120
122
 
121
123
  # Maximum attempts for one refresh operation.
@@ -152,9 +154,10 @@ module AuthProto
152
154
  # @param dpop_client [DPoP::Client] proof signer and nonce tracker
153
155
  # @param auth_server [Metadata::AuthorizationServer] server with a token endpoint
154
156
  # @param client_metadata [Metadata::ClientMetadata] client ID and authentication key configuration
155
- # @param signing_key [Hash, JOSE::JWK, nil] private ES256 client-authentication key
157
+ # @param client_assertion [PAR::ClientAssertion, nil] reusable validated signer; required for confidential clients
158
+ # @raise [ArgumentError] if confidential-client metadata is supplied without an assertion signer
156
159
  #
157
- def initialize(session:, dpop_client:, auth_server:, client_metadata:, signing_key: nil)
160
+ def initialize(session:, dpop_client:, auth_server:, client_metadata:, client_assertion: nil)
158
161
  super() # Initialize MonitorMixin
159
162
 
160
163
  @session = session
@@ -162,17 +165,23 @@ module AuthProto
162
165
  @auth_server = auth_server
163
166
  @attempt_count = 0
164
167
  @client_metadata = client_metadata
165
- @signing_key = signing_key
168
+ @client_assertion = client_assertion
169
+
170
+ if client_metadata.confidential? && !client_assertion.is_a?(PAR::ClientAssertion)
171
+ raise ArgumentError, "Confidential clients require a client_assertion signer"
172
+ end
166
173
  end
167
174
 
168
175
  # Exchanges a refresh token for a replacement token set.
169
176
  #
170
- # Retries eligible failures up to {MAX_RETRIES} times with exponential backoff and
171
- # jitter, and handles a DPoP nonce challenge during token exchange. Does not assign
177
+ # Makes up to {MAX_RETRIES} attempts for eligible failures, with exponential backoff
178
+ # and jitter. Each attempt can retry one DPoP nonce challenge. Does not assign
172
179
  # the returned tokens to the session or persist them; the caller must do so.
180
+ # HTTP 400/401 OAuth errors preserve the server's error code and description in the exception message.
173
181
  #
174
182
  # @return [State::TokenSet] validated replacement tokens
175
183
  # @raise [RefreshError] if the session is not renewable, refresh fails, or retries are exhausted
184
+ # @raise [DPoP::Client::Error] if any response lacks a valid nonce or nonce persistence fails
176
185
  #
177
186
  def perform_refresh
178
187
  synchronize do
@@ -219,24 +228,15 @@ module AuthProto
219
228
  end
220
229
 
221
230
  def request_token_refresh
222
- # Initial token request without nonce
223
- response = make_token_request(session)
224
-
225
- # Handle DPoP nonce requirement
226
- if requires_dpop_nonce?(response)
227
- # Extract and store nonce from error response
228
- extract_dpop_nonce(response)
229
- dpop_client.process_response(response[:headers], auth_server.issuer)
230
-
231
- # Retry request with nonce
232
- response = make_token_request(session)
231
+ response = dpop_client.with_nonce_handling(session, auth_server.token_endpoint) do
232
+ make_token_request(session)
233
233
  end
234
234
 
235
235
  handle_refresh_response(response)
236
236
  end
237
237
 
238
238
  def make_token_request(session)
239
- dpop_proof = dpop_client.generate_proof(method: 'POST', uri: auth_server.token_endpoint)
239
+ dpop_proof = dpop_client.generate_proof(session: session, method: 'POST', uri: auth_server.token_endpoint)
240
240
 
241
241
  body = {
242
242
  grant_type: 'refresh_token',
@@ -257,35 +257,10 @@ module AuthProto
257
257
  )
258
258
  end
259
259
 
260
- def requires_dpop_nonce?(response)
261
- return false unless response[:status] == 400
262
- return false if response[:body].nil?
263
-
264
- error_data = JSON.parse(response[:body])
265
- error_data.is_a?(Hash) && error_data['error'] == DPoP::ERROR_USE_NONCE
266
- rescue JSON::ParserError
267
- false
268
- end
269
-
270
- def extract_dpop_nonce(response)
271
- nonce = (response[:headers] || {}).detect { |k, v| k.downcase == 'dpop-nonce' }&.last
272
-
273
- unless nonce.is_a?(String) && !nonce.empty?
274
- raise RefreshError.new("No DPoP nonce provided in error response", retry_possible: false)
275
- end
276
-
277
- nonce
278
- end
279
-
280
260
  def add_client_authentication(body)
281
- client_assertion = PAR::ClientAssertion.new(
282
- client_id: client_metadata.client_id,
283
- signing_key: @signing_key
284
- )
285
-
286
261
  body.merge!(
287
262
  client_assertion_type: PAR::CLIENT_ASSERTION_TYPE,
288
- client_assertion: client_assertion.generate_jwt(audience: auth_server.issuer)
263
+ client_assertion: @client_assertion.generate_jwt(audience: auth_server.issuer)
289
264
  )
290
265
  end
291
266
 
@@ -293,10 +268,8 @@ module AuthProto
293
268
  case response[:status]
294
269
  when 200
295
270
  process_successful_response(response)
296
- when 400
297
- handle_400_response(response)
298
- when 401
299
- raise RefreshError.new("Refresh token is invalid", retry_possible: false)
271
+ when 400, 401
272
+ handle_error_response(response)
300
273
  when 429
301
274
  handle_rate_limit_response(response)
302
275
  else
@@ -322,7 +295,7 @@ module AuthProto
322
295
  raise RefreshError.new("Invalid response format: #{e.message}", retry_possible: false)
323
296
  end
324
297
 
325
- def handle_400_response(response)
298
+ def handle_error_response(response)
326
299
  raise RefreshError.new("Missing error response body", retry_possible: false) if response[:body].nil?
327
300
 
328
301
  error_data = JSON.parse(response[:body])
@@ -331,13 +304,9 @@ module AuthProto
331
304
  raise RefreshError.new("Invalid error response format", retry_possible: false)
332
305
  end
333
306
 
334
- error_description = error_data['error_description'] || error_data['error']
307
+ error_description = [error_data['error'], error_data['error_description']].compact.join(': ')
335
308
 
336
309
  case error_data['error']
337
- when DPoP::ERROR_USE_NONCE
338
- extract_dpop_nonce(response)
339
- dpop_client.process_response(response[:headers], auth_server.issuer)
340
- raise RefreshError.new("Retry with DPoP nonce", retry_possible: true)
341
310
  when "invalid_grant"
342
311
  # The refresh token has been invalidated or already used
343
312
  raise RefreshError.new(
@@ -356,7 +325,8 @@ module AuthProto
356
325
 
357
326
  def handle_rate_limit_response(response)
358
327
  # Extract retry-after if available
359
- retry_after = response[:headers]["Retry-After"]&.to_i || calculate_delay
328
+ retry_after = response_header(response, 'Retry-After')
329
+ retry_after = retry_after&.to_i || calculate_delay
360
330
  raise RefreshError, "Rate limited - retry after #{retry_after} seconds"
361
331
  end
362
332
 
@@ -365,7 +335,6 @@ module AuthProto
365
335
  raise RefreshError.new("Token response must be a JSON object", retry_possible: false)
366
336
  end
367
337
 
368
- # Required fields
369
338
  %w[access_token token_type expires_in scope sub].each do |field|
370
339
  raise RefreshError.new("Missing #{field} in response", retry_possible: false) unless data[field]
371
340
  end
@@ -384,19 +353,17 @@ module AuthProto
384
353
  raise RefreshError.new("Invalid refresh_token in response", retry_possible: false)
385
354
  end
386
355
 
387
- # Token type must be DPoP
388
- unless data['token_type'] == 'DPoP'
356
+ unless data['token_type'].casecmp?('DPoP')
389
357
  raise RefreshError.new("Invalid token_type: #{data['token_type']}", retry_possible: false)
390
358
  end
391
359
 
392
- # Scope must include original scopes
393
360
  original_scopes = session.scope.split
394
361
  response_scopes = data['scope'].split
362
+
395
363
  unless (original_scopes - response_scopes).empty?
396
364
  raise RefreshError.new("Invalid scope in response", retry_possible: false)
397
365
  end
398
366
 
399
- # Subject must match
400
367
  return if data['sub'] == session.tokens.sub
401
368
 
402
369
  raise RefreshError.new("Subject mismatch in response", retry_possible: false)
@@ -18,11 +18,22 @@ module AuthProto
18
18
 
19
19
  attr_reader :port, :userinfo, :path
20
20
 
21
+ # @!attribute [r] port
22
+ # Explicit port text from the input URL, or `nil` when omitted.
23
+ # @return [String, nil]
24
+ # @!attribute [r] userinfo
25
+ # User information from the input URL, including an explicitly empty component.
26
+ # @return [String, nil]
27
+ # @!attribute [r] path
28
+ # URL path, treating the root path `/` as an empty string.
29
+ # @return [String]
21
30
  def_delegators :@uri, :scheme, :host, :hostname, :query, :fragment,
22
31
  :user, :password, :registry, :opaque, :default_port,
23
32
  :request_uri, :origin, :absolute?, :relative?, :hierarchical?,
24
33
  :to_s, :to_str
25
34
 
35
+ # Parses an absolute HTTP(S) URL and preserves explicitly supplied components.
36
+ #
26
37
  # @param url [String, URI::Generic] URL to parse; URI inputs use their serialized form
27
38
  # @raise [URI::InvalidURIError] if the URL is malformed, not HTTP(S), or has no host
28
39
  #
@@ -42,14 +53,26 @@ module AuthProto
42
53
  @path = (@uri.path == '/') ? '' : @uri.path
43
54
  end
44
55
 
56
+ # Returns the explicitly supplied port, without substituting a scheme default.
57
+ #
58
+ # @return [String, nil] port text, or `nil` if omitted
59
+ #
45
60
  def specified_port
46
61
  port
47
62
  end
48
63
 
64
+ # Returns the parsed port, using the scheme default when no port was supplied.
65
+ #
66
+ # @return [Integer] effective HTTP(S) port
67
+ #
49
68
  def effective_port
50
69
  @uri.port
51
70
  end
52
71
 
72
+ # Returns the underlying parsed Ruby URI object.
73
+ #
74
+ # @return [URI::HTTP, URI::HTTPS] parsed URI; this is the original object, not a copy
75
+ #
53
76
  def to_uri
54
77
  @uri
55
78
  end
@@ -4,5 +4,5 @@ module AuthProto
4
4
 
5
5
  # Current gem version.
6
6
  # @return [String]
7
- VERSION = "0.3.0.beta2"
7
+ VERSION = "0.3.0.beta3"
8
8
  end
data/lib/authproto.rb CHANGED
@@ -5,7 +5,6 @@ require_relative 'authproto/configuration'
5
5
  require_relative 'authproto/http_client'
6
6
  require_relative 'authproto/insecure_http_client'
7
7
  require_relative 'authproto/localhost_server'
8
- require_relative 'authproto/serialization/dpop_key'
9
8
  require_relative 'authproto/validated_uri'
10
9
  require_relative 'authproto/version'
11
10
 
@@ -15,9 +15,9 @@ module AuthProto
15
15
  attr_reader session_manager: State::SessionManager
16
16
  attr_reader dpop_client: DPoP::Client
17
17
 
18
- def initialize: (client_id: String, redirect_uri: String, ?metadata: json_object?, ?dpop_key: (json_object | JOSE::Map)?, ?signing_key: (json_object | JOSE::JWK)?) -> void
18
+ def initialize: (client_id: String, redirect_uri: String, ?metadata: json_object?, ?signing_key: (json_object | JOSE::JWK)?) -> void
19
19
  def authorize: (?handle: String?, ?pds: String?, ?scope: String) -> authorization_result
20
- def handle_callback: (code: String, state: String, iss: String) -> callback_result
20
+ def handle_callback: (code: String, state: String, iss: String, expected_session_id: String) -> callback_result
21
21
  def get_tokens: (String session_id) -> token_info?
22
22
  def refresh_token: (String session_id) -> refreshed_tokens
23
23
  def authorized?: (String session_id) -> bool
@@ -28,15 +28,13 @@ module AuthProto
28
28
  private
29
29
  def load_client_metadata: (json_object? metadata) -> Metadata::ClientMetadata
30
30
  def validate_redirect_uri: () -> void
31
- def initialize_signing_key: ((json_object | JOSE::JWK)? key) -> JOSE::JWK?
32
- def initialize_dpop: ((json_object | JOSE::Map)? key) -> DPoP::Client
31
+ def initialize_client_assertion: ((json_object | JOSE::JWK)? key) -> String?
32
+ def metadata_key_matches_public_key?: (json_object candidate, json_object public_key) -> bool
33
33
  def validate_auth_params: (String? handle, String? pds, String scope) -> void
34
- def resolve_auth_server: (String pds) -> Metadata::AuthorizationServer
34
+ def resolve_auth_server_info: (String pds) -> Metadata::AuthorizationServer
35
35
  def submit_authorization_par: (Metadata::AuthorizationServer authorization_server, State::Session session, ?login_hint: String?) -> String
36
36
  def exchange_code: (code: String, session: State::Session) -> json_object
37
37
  def make_token_request: (String code, State::Session session) -> http_response
38
- def requires_dpop_nonce?: (http_response response) -> bool
39
- def extract_dpop_nonce: (http_response response) -> String
40
38
  def validate_token_response: (json_object response, State::Session session) -> void
41
39
  end
42
40
  end
@@ -15,5 +15,7 @@ module JOSE
15
15
  def self.generate_key: (untyped params) -> JWK
16
16
  def to_map: (?untyped key, ?untyped jwe) -> Map
17
17
  def to_public: () -> JWK
18
+ def to_public_map: () -> Map
19
+ def thumbprint: (?String? digest_type) -> String
18
20
  end
19
21
  end
@@ -1,16 +1,23 @@
1
1
  module AuthProto
2
2
  module DPoP
3
3
  class Client
4
+ include HTTPHelpers
4
5
  class Error < AuthProto::Error
5
6
  end
6
7
 
7
- attr_reader key_manager: KeyManager
8
8
  attr_reader proof_generator: ProofGenerator
9
9
  attr_reader nonce_manager: NonceManager
10
10
 
11
- def initialize: (?key_manager: KeyManager?, ?nonce_ttl: Integer?) -> void
12
- def generate_proof: (method: String, uri: String, ?access_token: String?, ?nonce: String?) -> JOSE::SignedBinary
13
- def process_response: (Hash[String, String]? response_headers, String server_url) -> void
11
+ def initialize: (?nonce_ttl: Integer?) -> void
12
+ def generate_proof: (session: State::Session, method: String, uri: String, ?access_token: String?) -> JOSE::SignedBinary
13
+ def has_nonce_error?: (http_response response) -> bool
14
+ def process_response: (http_response response, server_url: String, session_id: String) -> void
15
+
16
+ def with_nonce_handling: (State::Session session, String url) { () -> http_response } -> http_response
17
+
18
+ private
19
+
20
+ def nonce_challenge?: (String? challenge) -> bool
14
21
  end
15
22
  end
16
23
  end