ruby-mcp-client 2.1.0 → 3.0.0

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/OAUTH.md +555 -0
  3. data/README.md +825 -48
  4. data/lib/mcp_client/audio_content.rb +1 -1
  5. data/lib/mcp_client/auth/browser_oauth.rb +131 -21
  6. data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
  7. data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
  8. data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
  9. data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
  10. data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
  11. data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
  12. data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
  13. data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
  14. data/lib/mcp_client/auth/peer_text.rb +174 -0
  15. data/lib/mcp_client/auth.rb +298 -32
  16. data/lib/mcp_client/cached_result.rb +145 -0
  17. data/lib/mcp_client/called_tool_definition.rb +138 -0
  18. data/lib/mcp_client/client/cache_slices.rb +195 -0
  19. data/lib/mcp_client/client/list_aggregation.rb +243 -0
  20. data/lib/mcp_client/client/notification_routing.rb +155 -0
  21. data/lib/mcp_client/client/sampling_validation.rb +200 -0
  22. data/lib/mcp_client/client/task_api.rb +531 -0
  23. data/lib/mcp_client/client/task_lifetimes.rb +269 -0
  24. data/lib/mcp_client/client/task_registry.rb +254 -0
  25. data/lib/mcp_client/client/task_shape.rb +102 -0
  26. data/lib/mcp_client/client/task_support.rb +1166 -0
  27. data/lib/mcp_client/client/task_updates.rb +457 -0
  28. data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
  29. data/lib/mcp_client/client/task_workers.rb +63 -0
  30. data/lib/mcp_client/client.rb +796 -518
  31. data/lib/mcp_client/deep_copy.rb +49 -0
  32. data/lib/mcp_client/deprecation_notices.rb +94 -0
  33. data/lib/mcp_client/deprecations.rb +419 -0
  34. data/lib/mcp_client/errors.rb +474 -7
  35. data/lib/mcp_client/header_params.rb +320 -0
  36. data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
  37. data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
  38. data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
  39. data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
  40. data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
  41. data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
  42. data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
  43. data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
  44. data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
  45. data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
  46. data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
  47. data/lib/mcp_client/http_transport_base.rb +666 -120
  48. data/lib/mcp_client/input_round_trips.rb +128 -0
  49. data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
  50. data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
  51. data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
  52. data/lib/mcp_client/json_rpc_common.rb +900 -13
  53. data/lib/mcp_client/oauth_client.rb +14 -5
  54. data/lib/mcp_client/prompt.rb +4 -0
  55. data/lib/mcp_client/request_authorization.rb +128 -0
  56. data/lib/mcp_client/request_meta_scope.rb +77 -0
  57. data/lib/mcp_client/request_metadata.rb +287 -0
  58. data/lib/mcp_client/resource.rb +4 -0
  59. data/lib/mcp_client/resource_content.rb +20 -0
  60. data/lib/mcp_client/resource_template.rb +4 -0
  61. data/lib/mcp_client/result_caching.rb +999 -0
  62. data/lib/mcp_client/result_completeness.rb +34 -0
  63. data/lib/mcp_client/root.rb +6 -0
  64. data/lib/mcp_client/round_trip_marker.rb +28 -0
  65. data/lib/mcp_client/schema_validator/annotations.rb +82 -0
  66. data/lib/mcp_client/schema_validator/composition.rb +86 -0
  67. data/lib/mcp_client/schema_validator/dialects.rb +66 -0
  68. data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
  69. data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
  70. data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
  71. data/lib/mcp_client/schema_validator/instances.rb +449 -0
  72. data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
  73. data/lib/mcp_client/schema_validator/normalization.rb +104 -0
  74. data/lib/mcp_client/schema_validator/references.rb +610 -0
  75. data/lib/mcp_client/schema_validator/scalars.rb +126 -0
  76. data/lib/mcp_client/schema_validator/shapes.rb +319 -0
  77. data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
  78. data/lib/mcp_client/schema_validator.rb +882 -208
  79. data/lib/mcp_client/server_base.rb +233 -5
  80. data/lib/mcp_client/server_factory.rb +9 -3
  81. data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
  82. data/lib/mcp_client/server_http.rb +307 -90
  83. data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
  84. data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
  85. data/lib/mcp_client/server_sse.rb +227 -62
  86. data/lib/mcp_client/server_stdio/child_session.rb +98 -0
  87. data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
  88. data/lib/mcp_client/server_stdio.rb +772 -183
  89. data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
  90. data/lib/mcp_client/server_streamable_http.rb +302 -115
  91. data/lib/mcp_client/session_pin.rb +119 -0
  92. data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
  93. data/lib/mcp_client/subscription.rb +852 -0
  94. data/lib/mcp_client/subscription_support.rb +715 -0
  95. data/lib/mcp_client/task.rb +286 -14
  96. data/lib/mcp_client/tool.rb +31 -3
  97. data/lib/mcp_client/version.rb +21 -6
  98. data/lib/mcp_client.rb +108 -19
  99. metadata +68 -2
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ module Auth
5
+ class OAuthProvider
6
+ # Where the OAuth tokens of one MCP server live, and which token
7
+ # answers for the authorization server in use.
8
+ #
9
+ # MCP 2026-07-28 makes registration state per authorization server, and
10
+ # names what that state is: "client credentials, tokens". A token is
11
+ # issued by one authorization server, for one resource, and means
12
+ # nothing at another server — so it is stored twice, exactly as a
13
+ # client registration is: under the resource URL, which is the token
14
+ # currently in use and where every backend (and every record written by
15
+ # an earlier version) already keeps it, and under a key of its own
16
+ # authorization server ({RegistrationStore#client_registration_key}).
17
+ #
18
+ # One MCP server can be served by more than one authorization server
19
+ # over its lifetime. With the token kept only under the resource URL, a
20
+ # switch away from a server threw its token away, and coming back meant
21
+ # sending the user through consent again for a grant that was never
22
+ # revoked. The per-authorization-server copy keeps it instead — while a
23
+ # token this client RETIRED stays retired wherever it is kept, and a
24
+ # token is still never presented to an authorization server other than
25
+ # the one that issued it.
26
+ #
27
+ # Mixed into OAuthProvider; every method relies on its state.
28
+ module TokenStore
29
+ private
30
+
31
+ # Forget the stored token (the authorization server it came from is no
32
+ # longer the one in use). Storage backends may implement the optional
33
+ # delete_token(server_url); otherwise set_token(server_url, nil) is
34
+ # attempted, and a backend that accepts neither is reported.
35
+ # @return [void]
36
+ # @param bind_to [String, nil] the issuer the token belonged to (or Token::RETIRED_ISSUER): a token
37
+ # that records no issuer is first re-stored bound to it, so a backend that cannot delete still
38
+ # keeps it away from another authorization server after a restart
39
+ def delete_token(bind_to: nil)
40
+ # Whatever the backend manages, this token is never presented again.
41
+ current = stored_token
42
+ if current.respond_to?(:access_token) && current.access_token
43
+ # Opaque tokens are unique only within an issuer: the marker names
44
+ # the issuer the bytes were retired for, so another provider
45
+ # sharing the storage may store the same bytes for a new server.
46
+ (@retired_tokens ||= {})[retirement_key(current, bind_to)] = true
47
+ # A token retired outright is retired wherever it is kept: the copy
48
+ # under its own authorization server's key must not hand it back.
49
+ forget_token_of_issuer(current.issuer) if bind_to == Token::RETIRED_ISSUER
50
+ end
51
+ if bind_to && current.respond_to?(:with_issuer) && (current.issuer.nil? || bind_to == Token::RETIRED_ISSUER)
52
+ begin
53
+ storage.set_token(server_url, current.with_issuer(bind_to))
54
+ rescue StandardError => e
55
+ logger.debug("Could not bind the retired token to its issuer in storage: #{e.class}")
56
+ end
57
+ end
58
+ remove_token_in_use
59
+ rescue StandardError => e
60
+ logger.warn('The OAuth token for the previous authorization server could not be removed from storage ' \
61
+ "(#{e.class}); implement delete_token(server_url) on the storage backend. The token is " \
62
+ 'ignored while the authorization server differs from its issuer.')
63
+ end
64
+
65
+ # Empty the slot the token in use is kept in.
66
+ # @return [void]
67
+ def remove_token_in_use
68
+ if storage.respond_to?(:delete_token)
69
+ storage.delete_token(server_url)
70
+ else
71
+ storage.set_token(server_url, nil)
72
+ end
73
+ end
74
+
75
+ # The authorization server in use changed, so the token of the previous
76
+ # one is no longer the token in use — but it is still that server's
77
+ # token. MCP 2026-07-28 keeps registration state, "client credentials,
78
+ # tokens", per authorization server, so a token that says which server
79
+ # issued it is set aside under that server's own key instead of being
80
+ # thrown away: a resource served by two authorization servers over its
81
+ # lifetime finds the token again when it comes back to the first,
82
+ # rather than sending the user through consent for a grant that is
83
+ # still valid. Anything else — an unbound token, one already retired —
84
+ # is retired exactly as before: a token that cannot say where it came
85
+ # from cannot be filed under an authorization server either.
86
+ # @param issuer [String, nil] the authorization server that is no longer in use
87
+ # @return [void]
88
+ def withdraw_token(issuer)
89
+ token = stored_token_or_nil
90
+ unless issuer.is_a?(String) && record_bound_to?(token, issuer) && !retired_token?(token)
91
+ delete_token(bind_to: issuer)
92
+ return
93
+ end
94
+
95
+ preserve_token(token)
96
+ begin
97
+ remove_token_in_use
98
+ rescue StandardError => e
99
+ logger.warn('The OAuth token for the previous authorization server could not be removed from storage ' \
100
+ "(#{e.class}); implement delete_token(server_url) on the storage backend. The token is " \
101
+ 'ignored while the authorization server differs from its issuer.')
102
+ end
103
+ end
104
+
105
+ # The token of the authorization server in use, wherever it is kept:
106
+ # the slot in use, else the copy set aside under that server's own key
107
+ # when it stopped being the server in use (MCP 2026-07-28 keeps tokens
108
+ # per authorization server). A token from another authorization server
109
+ # is never presented, and retired bytes are refused before any binding
110
+ # could attribute them anew.
111
+ # @return [Token, nil]
112
+ def token_in_use
113
+ token = stored_token
114
+ if token && !retired_token?(token)
115
+ token = bind_token_issuer(token)
116
+ return token if token && token_for_current_issuer?(token)
117
+ end
118
+
119
+ adopt_token_kept_for_issuer_in_use
120
+ end
121
+
122
+ # Make the token kept for the authorization server in use the token in
123
+ # use again.
124
+ #
125
+ # The reads above are not the state the write may trust: a switch of
126
+ # authorization server another provider validated in between made ITS
127
+ # token the one in use, and this one stale. So the re-validation and
128
+ # the write are one step, under the same lock the switch, the code
129
+ # exchange and the refresh response take — and a switch that got in
130
+ # first keeps its token, which is the one this caller is handed.
131
+ # @return [Token, nil]
132
+ def adopt_token_kept_for_issuer_in_use
133
+ issuer = current_issuer_for_tokens
134
+ kept = issuer && token_kept_for_issuer(issuer)
135
+ return nil unless kept
136
+
137
+ with_authorization_state_lock do
138
+ in_use = token_in_use_after_switch
139
+ next in_use if in_use
140
+ # The authorization server itself may have moved while this
141
+ # adoption waited; a token kept for the server that is gone is not
142
+ # made the token in use.
143
+ next nil unless current_issuer_for_tokens == issuer
144
+
145
+ adopt_kept_token(kept)
146
+ end
147
+ end
148
+
149
+ # The token the slot holds now, when it is one this resource may
150
+ # present: what a switch validated meanwhile stored there.
151
+ # @return [Token, nil]
152
+ def token_in_use_after_switch
153
+ token = stored_token
154
+ return nil unless token && !retired_token?(token)
155
+
156
+ bound = bind_token_issuer(token)
157
+ bound if bound && token_for_current_issuer?(bound)
158
+ end
159
+
160
+ # @param kept [Token] the token kept for the authorization server in use
161
+ # @return [Token] the token the caller may present
162
+ def adopt_kept_token(kept)
163
+ logger.debug('Using the OAuth token kept for the authorization server this resource now uses again')
164
+ begin
165
+ storage.set_token(server_url, kept)
166
+ rescue StandardError => e
167
+ logger.debug("The kept OAuth token could not be made the token in use (#{e.class})")
168
+ end
169
+ kept
170
+ end
171
+
172
+ # Keep a token under its own authorization server's key, so a later
173
+ # return to that server finds it. Best-effort, exactly like the
174
+ # per-authorization-server copy of a client registration: the slot in
175
+ # use is the one every read depends on.
176
+ # @param token [Token] a token bound to an authorization server
177
+ # @return [void]
178
+ def preserve_token(token)
179
+ return unless token.respond_to?(:issuer)
180
+
181
+ key = client_registration_key(token.issuer)
182
+ return if key == server_url
183
+
184
+ begin
185
+ storage.set_token(key, token)
186
+ rescue StandardError => e
187
+ logger.debug("The OAuth token could not be stored under #{key.inspect} (#{e.class})")
188
+ end
189
+ end
190
+
191
+ # The token kept for one authorization server, made the token in use
192
+ # again. Only a record that says it belongs to that very server is
193
+ # adopted, and one this client retired stays retired.
194
+ # @param issuer [String, nil] the authorization server in use
195
+ # @return [Token, nil]
196
+ def token_kept_for_issuer(issuer)
197
+ key = client_registration_key(issuer)
198
+ return nil if key == server_url
199
+
200
+ kept = begin
201
+ normalize_record(storage.get_token(key), Token)
202
+ rescue StandardError => e
203
+ logger.debug("The OAuth token under #{key.inspect} could not be read (#{e.class})")
204
+ nil
205
+ end
206
+ return nil unless token_bytes?(kept) && record_bound_to?(kept, issuer) && !retired_token?(kept)
207
+
208
+ kept
209
+ end
210
+
211
+ # Forget the token kept for one authorization server.
212
+ # @param issuer [String, nil]
213
+ # @return [void]
214
+ def forget_token_of_issuer(issuer)
215
+ key = client_registration_key(issuer)
216
+ return if key == server_url
217
+
218
+ begin
219
+ storage.respond_to?(:delete_token) ? storage.delete_token(key) : storage.set_token(key, nil)
220
+ rescue StandardError => e
221
+ logger.debug("The retired OAuth token could not be removed from #{key.inspect} (#{e.class})")
222
+ mark_kept_token_retired(key)
223
+ end
224
+ end
225
+
226
+ # A copy the backend refuses to delete is re-stored as retired, exactly
227
+ # as the slot in use is: a provider built after a restart holds no
228
+ # in-process retirement marker, so every copy in storage has to say
229
+ # for itself that it was retired, or it would be adopted as live.
230
+ # @param key [String] the per-authorization-server key of the copy
231
+ # @return [void]
232
+ def mark_kept_token_retired(key)
233
+ kept = normalize_record(storage.get_token(key), Token)
234
+ return unless kept.respond_to?(:with_issuer) && !kept.retired?
235
+
236
+ storage.set_token(key, kept.with_issuer(Token::RETIRED_ISSUER))
237
+ rescue StandardError => e
238
+ logger.debug("The retired OAuth token under #{key.inspect} could not be marked retired (#{e.class})")
239
+ end
240
+
241
+ # @return [Token, nil]
242
+ def stored_token
243
+ token = normalize_record(storage.get_token(server_url), Token)
244
+ # A backend without delete_token is asked to store nil, and one that
245
+ # persists plain hashes writes `nil.to_h` — `{}`. Read back that is a
246
+ # record without token bytes, whose header would be a bare "Bearer "
247
+ # attributed to whatever authorization server is current now. It is
248
+ # not a token: it is the absence storage meant to express. The same
249
+ # backend can read back any other JSON type, or bytes no header can
250
+ # carry, in EVERY field the token presents: a token_type that is not a
251
+ # string crashes `capitalize`, and one carrying CR/LF makes the
252
+ # `Authorization` value two header lines. A record that cannot be
253
+ # presented is not a token either, so the read path is as strict as
254
+ # the wire path.
255
+ return nil if token.respond_to?(:access_token) && !token_bytes?(token)
256
+
257
+ token
258
+ end
259
+
260
+ # Persist a token the authorization server just issued. Opaque tokens
261
+ # are unique only within an issuer, so a new server may legitimately
262
+ # issue the same bytes as a token retired at the previous one: the
263
+ # fresh, issuer-bound token is never mistaken for the retired one.
264
+ # @param token [Token]
265
+ # @return [void]
266
+ def store_token(token)
267
+ storage.set_token(server_url, token)
268
+ # Only a persisted replacement lifts the marker: if the write failed,
269
+ # the stale record still in storage stays retired.
270
+ @retired_tokens&.delete(retirement_key(token, nil)) if token.respond_to?(:access_token)
271
+ # Kept under its own authorization server's key too, so a later
272
+ # return to that server finds it (MCP 2026-07-28 keeps tokens per
273
+ # authorization server).
274
+ preserve_token(token)
275
+ end
276
+
277
+ # @param refreshed [Token, nil] the token a refresh was made with
278
+ # @return [Boolean] whether it is still the token in use (a refresh made
279
+ # with no token to compare, as from a direct call, is not judged)
280
+ def refreshed_token_in_use?(refreshed)
281
+ return true unless refreshed.respond_to?(:access_token)
282
+
283
+ current = stored_token_or_nil
284
+ return false unless current.respond_to?(:access_token) && !retired_token?(current)
285
+
286
+ same_token?(current, refreshed)
287
+ end
288
+
289
+ # @return [Boolean] whether two records carry the same token
290
+ def same_token?(one, other)
291
+ one.access_token == other.access_token && one.refresh_token == other.refresh_token
292
+ end
293
+
294
+ # @param token [Token, nil] the token in use
295
+ # @return [Token, nil] the token when it can be presented now
296
+ def presentable_token(token)
297
+ return nil unless token && !token.expired? && token_for_current_issuer?(token)
298
+
299
+ token
300
+ end
301
+
302
+ # Whether a token was retired in this process: a bound token when its
303
+ # bytes were retired for its issuer, an unbound one when its bytes
304
+ # were retired for any issuer (it cannot say which one it came from).
305
+ # @param token [Token]
306
+ # @return [Boolean]
307
+ def retired_token?(token)
308
+ return true if token.respond_to?(:retired?) && token.retired?
309
+ return false unless token.respond_to?(:access_token) && @retired_tokens
310
+
311
+ issuer = token.respond_to?(:issuer) ? token.issuer : nil
312
+ return @retired_tokens.key?([issuer, token.access_token]) if issuer
313
+
314
+ @retired_tokens.keys.any? { |_issuer, bytes| bytes == token.access_token }
315
+ end
316
+
317
+ # The in-process retirement marker of a token: its bytes together with
318
+ # the issuer they were retired for (the recorded issuer, else the
319
+ # issuer the token was bound to at retirement).
320
+ # @param token [Token]
321
+ # @param bind_to [String, nil]
322
+ # @return [Array(String, String)]
323
+ def retirement_key(token, bind_to)
324
+ issuer = token.respond_to?(:issuer) ? token.issuer : nil
325
+ [issuer || bind_to || Token::RETIRED_ISSUER, token.access_token]
326
+ end
327
+
328
+ # Whether a stored token belongs to the authorization server currently
329
+ # known for this resource. While that server is unknown (no cached
330
+ # metadata) nothing is presented: the next challenge discovers it.
331
+ # @param token [Token]
332
+ # @return [Boolean]
333
+ def token_for_current_issuer?(token)
334
+ return false if retired_token?(token)
335
+ return true unless token.respond_to?(:issuer)
336
+
337
+ current = current_issuer_for_tokens
338
+ !current.nil? && current == token.issuer
339
+ end
340
+
341
+ # A 2025-11-25 record that names no issuer is bound to the authorization
342
+ # server in use the first time it is read ({TokenStore#bind_token_issuer});
343
+ # what it says was granted counts before that read happens too, as long
344
+ # as there is a server in use to bind it to and it was not retired.
345
+ # @param token [Token] the stored token
346
+ # @return [Boolean]
347
+ def bindable_to_current_issuer?(token)
348
+ return false unless token.respond_to?(:issuer) && token.issuer.nil?
349
+ return false if retired_token?(token)
350
+
351
+ !current_issuer_for_tokens.nil?
352
+ end
353
+
354
+ # The authorization server tokens are judged against: a validated
355
+ # challenge received since the metadata was cached is authoritative
356
+ # (discovery treats it so), else the cached metadata's issuer.
357
+ # @return [String, nil]
358
+ def current_issuer_for_tokens
359
+ # A challenge we REFUSED said the cached authorization server is no
360
+ # longer the right one, and discovery fails closed on that latch. The
361
+ # request path must fail closed too: presenting the cached bearer would
362
+ # undo the rejection exactly as falling back to the cache would.
363
+ return nil if @challenge_error
364
+
365
+ advertised = Array(@challenge_resource_metadata&.authorization_servers).first
366
+ return advertised if advertised.is_a?(String)
367
+ # An unresolved challenge URL means the current server is unknown.
368
+ return nil if @challenge_metadata_url
369
+
370
+ stored_server_metadata&.issuer
371
+ end
372
+
373
+ # A token persisted before issuers were recorded was obtained from the
374
+ # authorization server cached alongside it (a server change always
375
+ # retires or binds the token first), so it is bound to that server on
376
+ # first use; while no server is known it is not presented.
377
+ # @param token [Token]
378
+ # @return [Token, nil] the bound token, or nil when it cannot be bound yet
379
+ def bind_token_issuer(token)
380
+ return token unless token.respond_to?(:issuer) && token.issuer.nil? && token.respond_to?(:with_issuer)
381
+ # A refused challenge says the cached server is no longer current, so
382
+ # it cannot attribute an unbound token either.
383
+ return nil if @challenge_error
384
+
385
+ # Read, attribute, write: one step, for the reason adoption takes the
386
+ # same lock. The record this was read from need not be the one in the
387
+ # slot by now — a switch validated meanwhile stored its own token
388
+ # there — and binding these bytes over it would hand one
389
+ # authorization server's token to another.
390
+ with_authorization_state_lock do
391
+ next nil unless slot_still_holds_unbound?(token)
392
+
393
+ current = stored_server_metadata&.issuer
394
+ next nil unless current
395
+
396
+ bound = token.with_issuer(current)
397
+ begin
398
+ storage.set_token(server_url, bound)
399
+ rescue StandardError => e
400
+ logger.debug("The stored OAuth token could not be re-stored with its issuer (#{e.class})")
401
+ end
402
+ bound
403
+ end
404
+ end
405
+
406
+ # Whether the slot still holds the very issuer-less record that is
407
+ # about to be attributed to the authorization server in use.
408
+ # @param token [Token] the record read from the slot
409
+ # @return [Boolean]
410
+ def slot_still_holds_unbound?(token)
411
+ current = stored_token
412
+ return false unless current.respond_to?(:issuer) && current.issuer.nil?
413
+
414
+ current.respond_to?(:access_token) && current.access_token == token.access_token
415
+ end
416
+ end
417
+ end
418
+ end
419
+ end