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.
- checksums.yaml +4 -4
- data/OAUTH.md +555 -0
- data/README.md +825 -48
- data/lib/mcp_client/audio_content.rb +1 -1
- data/lib/mcp_client/auth/browser_oauth.rb +131 -21
- data/lib/mcp_client/auth/oauth_provider/challenge_handling.rb +532 -0
- data/lib/mcp_client/auth/oauth_provider/client_authentication.rb +121 -0
- data/lib/mcp_client/auth/oauth_provider/pending_requests.rb +51 -0
- data/lib/mcp_client/auth/oauth_provider/registration_store.rb +486 -0
- data/lib/mcp_client/auth/oauth_provider/response_validation.rb +441 -0
- data/lib/mcp_client/auth/oauth_provider/scope_selection.rb +134 -0
- data/lib/mcp_client/auth/oauth_provider/token_store.rb +419 -0
- data/lib/mcp_client/auth/oauth_provider.rb +1354 -386
- data/lib/mcp_client/auth/peer_text.rb +174 -0
- data/lib/mcp_client/auth.rb +298 -32
- data/lib/mcp_client/cached_result.rb +145 -0
- data/lib/mcp_client/called_tool_definition.rb +138 -0
- data/lib/mcp_client/client/cache_slices.rb +195 -0
- data/lib/mcp_client/client/list_aggregation.rb +243 -0
- data/lib/mcp_client/client/notification_routing.rb +155 -0
- data/lib/mcp_client/client/sampling_validation.rb +200 -0
- data/lib/mcp_client/client/task_api.rb +531 -0
- data/lib/mcp_client/client/task_lifetimes.rb +269 -0
- data/lib/mcp_client/client/task_registry.rb +254 -0
- data/lib/mcp_client/client/task_shape.rb +102 -0
- data/lib/mcp_client/client/task_support.rb +1166 -0
- data/lib/mcp_client/client/task_updates.rb +457 -0
- data/lib/mcp_client/client/task_wait_boundaries.rb +198 -0
- data/lib/mcp_client/client/task_workers.rb +63 -0
- data/lib/mcp_client/client.rb +796 -518
- data/lib/mcp_client/deep_copy.rb +49 -0
- data/lib/mcp_client/deprecation_notices.rb +94 -0
- data/lib/mcp_client/deprecations.rb +419 -0
- data/lib/mcp_client/errors.rb +474 -7
- data/lib/mcp_client/header_params.rb +320 -0
- data/lib/mcp_client/http_transport_base/bounded_inflate.rb +41 -0
- data/lib/mcp_client/http_transport_base/cache_support.rb +694 -0
- data/lib/mcp_client/http_transport_base/era_detection.rb +134 -0
- data/lib/mcp_client/http_transport_base/listen_stream.rb +763 -0
- data/lib/mcp_client/http_transport_base/param_headers.rb +35 -0
- data/lib/mcp_client/http_transport_base/request_recovery.rb +156 -0
- data/lib/mcp_client/http_transport_base/session_recovery.rb +113 -0
- data/lib/mcp_client/http_transport_base/sse_event_scanner.rb +145 -0
- data/lib/mcp_client/http_transport_base/stream_capture.rb +160 -0
- data/lib/mcp_client/http_transport_base/stream_recovery.rb +318 -0
- data/lib/mcp_client/http_transport_base/tool_listing.rb +277 -0
- data/lib/mcp_client/http_transport_base.rb +666 -120
- data/lib/mcp_client/input_round_trips.rb +128 -0
- data/lib/mcp_client/json_rpc_common/envelopes.rb +32 -0
- data/lib/mcp_client/json_rpc_common/error_bodies.rb +105 -0
- data/lib/mcp_client/json_rpc_common/input_waits.rb +167 -0
- data/lib/mcp_client/json_rpc_common.rb +900 -13
- data/lib/mcp_client/oauth_client.rb +14 -5
- data/lib/mcp_client/prompt.rb +4 -0
- data/lib/mcp_client/request_authorization.rb +128 -0
- data/lib/mcp_client/request_meta_scope.rb +77 -0
- data/lib/mcp_client/request_metadata.rb +287 -0
- data/lib/mcp_client/resource.rb +4 -0
- data/lib/mcp_client/resource_content.rb +20 -0
- data/lib/mcp_client/resource_template.rb +4 -0
- data/lib/mcp_client/result_caching.rb +999 -0
- data/lib/mcp_client/result_completeness.rb +34 -0
- data/lib/mcp_client/root.rb +6 -0
- data/lib/mcp_client/round_trip_marker.rb +28 -0
- data/lib/mcp_client/schema_validator/annotations.rb +82 -0
- data/lib/mcp_client/schema_validator/composition.rb +86 -0
- data/lib/mcp_client/schema_validator/dialects.rb +66 -0
- data/lib/mcp_client/schema_validator/ecma_patterns.rb +567 -0
- data/lib/mcp_client/schema_validator/evaluation.rb +517 -0
- data/lib/mcp_client/schema_validator/input_requirements.rb +84 -0
- data/lib/mcp_client/schema_validator/instances.rb +449 -0
- data/lib/mcp_client/schema_validator/keyword_scan.rb +121 -0
- data/lib/mcp_client/schema_validator/normalization.rb +104 -0
- data/lib/mcp_client/schema_validator/references.rb +610 -0
- data/lib/mcp_client/schema_validator/scalars.rb +126 -0
- data/lib/mcp_client/schema_validator/shapes.rb +319 -0
- data/lib/mcp_client/schema_validator/uri_references.rb +153 -0
- data/lib/mcp_client/schema_validator.rb +882 -208
- data/lib/mcp_client/server_base.rb +233 -5
- data/lib/mcp_client/server_factory.rb +9 -3
- data/lib/mcp_client/server_http/json_rpc_transport.rb +219 -4
- data/lib/mcp_client/server_http.rb +307 -90
- data/lib/mcp_client/server_sse/json_rpc_transport.rb +113 -25
- data/lib/mcp_client/server_sse/sse_parser.rb +39 -6
- data/lib/mcp_client/server_sse.rb +227 -62
- data/lib/mcp_client/server_stdio/child_session.rb +98 -0
- data/lib/mcp_client/server_stdio/json_rpc_transport.rb +1003 -28
- data/lib/mcp_client/server_stdio.rb +772 -183
- data/lib/mcp_client/server_streamable_http/json_rpc_transport.rb +189 -25
- data/lib/mcp_client/server_streamable_http.rb +302 -115
- data/lib/mcp_client/session_pin.rb +119 -0
- data/lib/mcp_client/subscription/notification_dispatcher.rb +354 -0
- data/lib/mcp_client/subscription.rb +852 -0
- data/lib/mcp_client/subscription_support.rb +715 -0
- data/lib/mcp_client/task.rb +286 -14
- data/lib/mcp_client/tool.rb +31 -3
- data/lib/mcp_client/version.rb +21 -6
- data/lib/mcp_client.rb +108 -19
- 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
|