tungsten-runtime 0.1.4 → 0.1.6
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/README.md +191 -9
- data/lib/tungsten/runtime/auth.rb +35 -2
- data/lib/tungsten/runtime/client_core.rb +195 -20
- data/lib/tungsten/runtime/descriptors.rb +12 -3
- data/lib/tungsten/runtime/json.rb +10 -0
- data/lib/tungsten/runtime/macros.rb +1 -1
- data/lib/tungsten/runtime/oauth.rb +133 -36
- data/lib/tungsten/runtime/oauth_inputs.rb +136 -0
- data/lib/tungsten/runtime/oauth_sessions.rb +521 -0
- data/lib/tungsten/runtime/options.rb +35 -11
- data/lib/tungsten/runtime/pages.rb +3 -1
- data/lib/tungsten/runtime/schema.rb +49 -19
- data/lib/tungsten/runtime/serialize.rb +43 -8
- data/lib/tungsten/runtime/stream.rb +3 -3
- data/lib/tungsten/runtime/version.rb +1 -1
- data/lib/tungsten/runtime.rb +2 -0
- metadata +3 -1
|
@@ -14,8 +14,12 @@ module Tungsten
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# The default token store of the authorization-code schemes: in memory,
|
|
17
|
-
# per client.
|
|
18
|
-
#
|
|
17
|
+
# per client. Managed adapters must also implement set_if_current and
|
|
18
|
+
# delete_if_current. Invoke the predicate exactly once with the current
|
|
19
|
+
# token, at the final serialized mutation, with no speculative calls,
|
|
20
|
+
# retries or wait gap. It advances a session epoch. Return true only after
|
|
21
|
+
# committing; failed writes retain the old token and may conservatively
|
|
22
|
+
# invalidate older refresh work. Direct set/delete bypass managed fences.
|
|
19
23
|
class MemoryTokenStore
|
|
20
24
|
def initialize
|
|
21
25
|
@tokens = {}
|
|
@@ -25,6 +29,36 @@ module Tungsten
|
|
|
25
29
|
def get(scheme) = @lock.synchronize { @tokens[scheme] }
|
|
26
30
|
def set(scheme, token) = @lock.synchronize { @tokens[scheme] = token }
|
|
27
31
|
def delete(scheme) = @lock.synchronize { @tokens.delete(scheme) }
|
|
32
|
+
|
|
33
|
+
def set_if_current(scheme, token)
|
|
34
|
+
@lock.synchronize do
|
|
35
|
+
return false unless yield(@tokens[scheme])
|
|
36
|
+
@tokens[scheme] = token
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delete_if_current(scheme)
|
|
42
|
+
@lock.synchronize do
|
|
43
|
+
return false unless yield(@tokens[scheme])
|
|
44
|
+
@tokens.delete(scheme)
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Explicit, application-qualified issuer defense, pinned for one attempt.
|
|
51
|
+
IssuerDefense = Data.define(:mode, :value) do
|
|
52
|
+
def self.issuer_response(issuer) = new(mode: :response, value: issuer)
|
|
53
|
+
def self.distinct_redirect(qualification) = new(mode: :distinct, value: qualification)
|
|
54
|
+
def inspect = "#<Tungsten::Runtime::IssuerDefense #{REDACTED}>"
|
|
55
|
+
alias_method :to_s, :inspect
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The handle and URL are secrets of the owning listener.
|
|
59
|
+
AuthorizationAttempt = Data.define(:attempt_handle, :url, :expires_in_ms) do
|
|
60
|
+
def inspect = "#<Tungsten::Runtime::AuthorizationAttempt expires_in_ms=#{expires_in_ms}>"
|
|
61
|
+
alias_method :to_s, :inspect
|
|
28
62
|
end
|
|
29
63
|
|
|
30
64
|
# A PKCE pair (RFC 7636, S256); the verifier is a secret until the
|
|
@@ -53,29 +87,63 @@ module Tungsten
|
|
|
53
87
|
|
|
54
88
|
def initialize(core, scheme)
|
|
55
89
|
@core = core
|
|
56
|
-
@scheme = scheme
|
|
90
|
+
@scheme = OAuthInputs.text(scheme, OAuthInputs::INPUT_BYTES)
|
|
91
|
+
rescue StandardError
|
|
92
|
+
@scheme = nil
|
|
57
93
|
end
|
|
58
94
|
|
|
95
|
+
def inspect = "#<Tungsten::Runtime::OAuthFlow #{REDACTED}>"
|
|
96
|
+
alias_method :to_s, :inspect
|
|
97
|
+
|
|
59
98
|
def token_store = @core.token_store
|
|
60
99
|
|
|
61
|
-
#
|
|
62
|
-
# `verifier` for exchange_code.
|
|
100
|
+
# Explicit primitive for low-level callers. Managed begin owns its pair.
|
|
63
101
|
def pkce = Pkce.generate
|
|
64
102
|
|
|
65
|
-
#
|
|
66
|
-
def authorization_url(
|
|
67
|
-
|
|
103
|
+
# Begin a managed attempt; retain its handle with the owning listener.
|
|
104
|
+
def authorization_url(session_binding: nil, s256_supported: false, issuer_defense: nil, redirect_uri: nil,
|
|
105
|
+
scopes: nil, resource: nil, audience: nil, account_context: nil, extra: {}, **unknown)
|
|
106
|
+
unless unknown.empty?
|
|
107
|
+
return @core.oauth_sessions.error(@core.oauth_sessions.operation_scheme(@scheme), "authorizationUrl", OAuthSessions::BEGIN_REFUSAL)
|
|
108
|
+
end
|
|
109
|
+
@core.oauth_sessions.begin_authorization(@scheme, {
|
|
110
|
+
session_binding: session_binding, s256_supported: s256_supported, issuer_defense: issuer_defense,
|
|
111
|
+
redirect_uri: redirect_uri, scopes: scopes, resource: resource, audience: audience,
|
|
112
|
+
account_context: account_context, extra: extra
|
|
113
|
+
})
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Validate the whole callback and atomically claim its owning attempt.
|
|
117
|
+
def exchange_code(attempt_handle: nil, callback_url: nil, session_binding: nil, **unknown)
|
|
118
|
+
unless unknown.empty?
|
|
119
|
+
return @core.oauth_sessions.error(@core.oauth_sessions.operation_scheme(@scheme), "exchangeCode", OAuthSessions::CALLBACK_REFUSAL)
|
|
120
|
+
end
|
|
121
|
+
@core.oauth_sessions.complete(@scheme, attempt_handle, callback_url, session_binding)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def cancel_authorization(attempt_handle: nil, session_binding: nil)
|
|
125
|
+
@core.oauth_sessions.cancel(@scheme, attempt_handle, session_binding)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Local fencing/removal only; provider revocation is separate.
|
|
129
|
+
def logout = @core.oauth_sessions.logout(@scheme)
|
|
130
|
+
|
|
131
|
+
# Deliberate escape: caller owns PKCE/state/issuer/callback/replay fences.
|
|
132
|
+
def low_level_authorization_url(redirect_uri: nil, scopes: nil, state: nil, code_challenge: nil, extra: {})
|
|
133
|
+
@core.send(:low_level_authorization_url, @scheme, redirect_uri: redirect_uri, scopes: scopes, state: state,
|
|
68
134
|
code_challenge: code_challenge, extra: extra)
|
|
69
135
|
end
|
|
70
136
|
|
|
71
|
-
#
|
|
72
|
-
def
|
|
73
|
-
@core.send(:
|
|
137
|
+
# Deliberate primitive exchange; bypasses managed attempts and store epochs.
|
|
138
|
+
def low_level_exchange_code(code:, redirect_uri: nil, code_verifier: nil)
|
|
139
|
+
@core.send(:low_level_exchange_code, @scheme, code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
|
|
74
140
|
end
|
|
75
141
|
|
|
76
142
|
# Refresh the stored token now (calls refresh on their own when it
|
|
77
143
|
# expires).
|
|
78
144
|
def refresh = @core.send(:refresh_now, @scheme)
|
|
145
|
+
|
|
146
|
+
def low_level_refresh = @core.send(:low_level_refresh_now, @scheme)
|
|
79
147
|
end
|
|
80
148
|
|
|
81
149
|
class ClientCore
|
|
@@ -84,10 +152,14 @@ module Tungsten
|
|
|
84
152
|
|
|
85
153
|
# The store of authorization-code tokens.
|
|
86
154
|
attr_reader :token_store
|
|
155
|
+
attr_reader :oauth_sessions, :max_oauth_token_response_bytes
|
|
87
156
|
|
|
88
157
|
private
|
|
89
158
|
|
|
90
|
-
TokenFailure = Struct.new(:message, :rejected)
|
|
159
|
+
TokenFailure = Struct.new(:message, :rejected) do
|
|
160
|
+
def inspect = "#<Tungsten::Runtime::TokenFailure>"
|
|
161
|
+
alias_method :to_s, :inspect
|
|
162
|
+
end
|
|
91
163
|
|
|
92
164
|
def form_body(fields) = fields.map { |k, v| "#{Serialize.percent_encode(k)}=#{Serialize.percent_encode(v)}" }.join("&")
|
|
93
165
|
|
|
@@ -122,11 +194,11 @@ module Tungsten
|
|
|
122
194
|
|
|
123
195
|
# [token, nil] or [nil, remediation].
|
|
124
196
|
def stored_token(scheme, force)
|
|
125
|
-
token, failure =
|
|
126
|
-
failure ? [nil, failure.message] : [token, nil]
|
|
197
|
+
token, failure = @oauth_sessions.token(scheme, force)
|
|
198
|
+
failure ? [nil, failure.message] : [token&.access_token, nil]
|
|
127
199
|
end
|
|
128
200
|
|
|
129
|
-
def
|
|
201
|
+
def low_level_stored_token_inner(scheme, force)
|
|
130
202
|
missing = TokenFailure.new(
|
|
131
203
|
"No OAuth2 token is stored for #{scheme}; send the user to authorization_url(), then call exchange_code() " \
|
|
132
204
|
"with the code, or put a token in ClientOptions.token_store.", false
|
|
@@ -147,7 +219,7 @@ module Tungsten
|
|
|
147
219
|
current = read_token(scheme)
|
|
148
220
|
return [nil, missing] unless current
|
|
149
221
|
|
|
150
|
-
token, failure =
|
|
222
|
+
token, failure = low_level_refresh_stored(scheme, current, force)
|
|
151
223
|
return [nil, failure] if failure
|
|
152
224
|
|
|
153
225
|
@lock.synchronize { gate[:generation] += 1 }
|
|
@@ -158,16 +230,11 @@ module Tungsten
|
|
|
158
230
|
# The refreshed token after a 401 when the stored token has a refresh
|
|
159
231
|
# token, else nil.
|
|
160
232
|
def refresh_after_rejection(scheme, rejected)
|
|
161
|
-
stored =
|
|
162
|
-
|
|
163
|
-
return stored.access_token if stored.access_token != rejected
|
|
164
|
-
return nil if stored.refresh_token.to_s.empty?
|
|
165
|
-
|
|
166
|
-
token, problem = stored_token(scheme, true)
|
|
167
|
-
problem ? nil : token
|
|
233
|
+
stored, problem = @oauth_sessions.token(scheme, true, rejected: rejected)
|
|
234
|
+
problem ? nil : stored&.access_token
|
|
168
235
|
end
|
|
169
236
|
|
|
170
|
-
def
|
|
237
|
+
def low_level_refresh_stored(scheme, stored, force)
|
|
171
238
|
flow, reason = code_flow(scheme)
|
|
172
239
|
return [nil, TokenFailure.new("Cannot refresh the OAuth2 token for #{scheme}: #{reason}.", false)] unless flow
|
|
173
240
|
|
|
@@ -190,7 +257,15 @@ module Tungsten
|
|
|
190
257
|
|
|
191
258
|
def token_timeout_ms = [@timeout_ms, 1].max
|
|
192
259
|
|
|
193
|
-
def token_request(
|
|
260
|
+
def token_request(_scheme, url, client, fields, previous, deadline: nil)
|
|
261
|
+
limit = @max_oauth_token_response_bytes
|
|
262
|
+
unless OAuthInputs.safe_integer?(limit) && OAuthInputs.safe_integer?(token_timeout_ms)
|
|
263
|
+
return [nil, TokenFailure.new("OAuth token byte and time limits must be positive safe integers; nothing was sent.", false)]
|
|
264
|
+
end
|
|
265
|
+
managed = !deadline.nil?
|
|
266
|
+
deadline ||= Transport.monotonic_ms + token_timeout_ms
|
|
267
|
+
OAuthInputs.endpoint(url) if managed
|
|
268
|
+
return [nil, TokenFailure.new(INSECURE_HTTP_AUTH, false)] if insecure_http?(url)
|
|
194
269
|
headers = [["Content-Type", "application/x-www-form-urlencoded"], ["Accept", "application/json"]]
|
|
195
270
|
all = fields.dup
|
|
196
271
|
if client[:secret].empty?
|
|
@@ -200,36 +275,52 @@ module Tungsten
|
|
|
200
275
|
headers << ["Authorization", "Basic #{basic}"]
|
|
201
276
|
end
|
|
202
277
|
body = Serialize::Encoded.new(empty: false, bytes: form_body(all).b, parts: nil, content_type: nil, display: nil, hashed: nil)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
278
|
+
remaining = deadline - Transport.monotonic_ms
|
|
279
|
+
return [nil, TokenFailure.new(OAuthSessions::SESSION_REFUSAL, false)] unless remaining.positive?
|
|
280
|
+
out = Transport.attempt(url: url.to_s, method: "POST", headers: headers, body: body,
|
|
281
|
+
timeout_ms: [token_timeout_ms, remaining].min, max_response_bytes: limit)
|
|
282
|
+
if Transport.monotonic_ms >= deadline
|
|
283
|
+
return [nil, TokenFailure.new(OAuthSessions::SESSION_REFUSAL, false)]
|
|
284
|
+
end
|
|
285
|
+
unless out.kind == :response && out.body.is_a?(String) && out.body.bytesize <= limit
|
|
286
|
+
return [nil, TokenFailure.new("The OAuth token response was unavailable, incomplete or exceeded its byte limit; begin a new attempt if a code may have been sent.", false)]
|
|
206
287
|
end
|
|
207
288
|
|
|
208
289
|
decoded = Classify.decode_body(out.body, out.headers, "application/json")
|
|
290
|
+
if Transport.monotonic_ms >= deadline
|
|
291
|
+
return [nil, TokenFailure.new(OAuthSessions::SESSION_REFUSAL, false)]
|
|
292
|
+
end
|
|
209
293
|
unless out.status.between?(200, 299)
|
|
210
294
|
code = Util.get_path_str(decoded.value, "error")
|
|
211
|
-
|
|
295
|
+
known = %w[invalid_request invalid_client invalid_grant unauthorized_client unsupported_grant_type invalid_scope
|
|
296
|
+
access_denied temporarily_unavailable server_error]
|
|
297
|
+
named = known.include?(code) ? " (#{code})" : ""
|
|
212
298
|
return [nil, TokenFailure.new(
|
|
213
|
-
"The
|
|
299
|
+
"The OAuth token endpoint refused the request#{named}; check client configuration and begin a new authorization if needed.",
|
|
214
300
|
out.status.between?(400, 499)
|
|
215
301
|
)]
|
|
216
302
|
end
|
|
217
303
|
text = ->(path) { Util.get_path_str(decoded.value, path).then { |v| v.is_a?(String) ? v : "" } }
|
|
218
304
|
access = text.call("access_token")
|
|
219
|
-
return [nil, TokenFailure.new("The
|
|
305
|
+
return [nil, TokenFailure.new("The OAuth token endpoint returned no access_token.", false)] if access.empty?
|
|
220
306
|
|
|
221
307
|
refresh = text.call("refresh_token")
|
|
222
308
|
refresh = previous if refresh.empty?
|
|
223
309
|
expires_in = Util.get_path_str(decoded.value, "expires_in")
|
|
224
310
|
expires = expires_in.is_a?(Numeric) && expires_in.finite? ? now + ([expires_in, 0].max * 1000).to_i : nil
|
|
225
|
-
token = StoredToken.new(access_token: access,
|
|
226
|
-
|
|
311
|
+
token = StoredToken.new(access_token: OAuthInputs.text(access, limit),
|
|
312
|
+
refresh_token: refresh.empty? ? nil : OAuthInputs.text(refresh, limit), expires_at: expires,
|
|
313
|
+
token_type: OAuthInputs.text(text.call("token_type"), limit), scope: OAuthInputs.text(text.call("scope"), limit))
|
|
314
|
+
return [nil, TokenFailure.new(OAuthSessions::SESSION_REFUSAL, false)] if Transport.monotonic_ms >= deadline
|
|
227
315
|
[token, nil]
|
|
316
|
+
rescue StandardError
|
|
317
|
+
[nil, TokenFailure.new("The OAuth token request could not complete; check local configuration and begin a new authorization if needed.", false)]
|
|
228
318
|
end
|
|
229
319
|
|
|
230
320
|
# An OAuth2 client-credentials token, fetched once per scheme and
|
|
231
321
|
# cached until 30 s before it expires (one request at a time).
|
|
232
322
|
def client_credentials_token(sat)
|
|
323
|
+
return [nil, INSECURE_HTTP_AUTH] if insecure_http?(sat.token_url)
|
|
233
324
|
@cc_lock.synchronize do
|
|
234
325
|
real_now = (Time.now.to_f * 1000).to_i
|
|
235
326
|
cached = @cc_tokens[sat.name]
|
|
@@ -262,7 +353,7 @@ module Tungsten
|
|
|
262
353
|
|
|
263
354
|
def oauth_operation(scheme, helper) = "oauth.#{scheme}.#{helper}"
|
|
264
355
|
|
|
265
|
-
def
|
|
356
|
+
def low_level_authorization_url(scheme, redirect_uri:, scopes:, state:, code_challenge:, extra:)
|
|
266
357
|
operation = oauth_operation(scheme, "authorizationUrl")
|
|
267
358
|
flow, reason = code_flow(scheme)
|
|
268
359
|
return oauth_error(operation, "Cannot build an authorization URL: #{reason}.") unless flow
|
|
@@ -287,7 +378,7 @@ module Tungsten
|
|
|
287
378
|
Ok.new(value: base + sep + form_body(fields), meta: ResponseMeta.local)
|
|
288
379
|
end
|
|
289
380
|
|
|
290
|
-
def
|
|
381
|
+
def low_level_exchange_code(scheme, code:, redirect_uri:, code_verifier:)
|
|
291
382
|
operation = oauth_operation(scheme, "exchangeCode")
|
|
292
383
|
flow, reason = code_flow(scheme)
|
|
293
384
|
return oauth_error(operation, "Cannot exchange the code: #{reason}.") unless flow
|
|
@@ -311,8 +402,14 @@ module Tungsten
|
|
|
311
402
|
end
|
|
312
403
|
|
|
313
404
|
def refresh_now(scheme)
|
|
405
|
+
stored, failure = @oauth_sessions.token(scheme, true)
|
|
406
|
+
return @oauth_sessions.error(@oauth_sessions.operation_scheme(scheme), "refresh", failure.message) if failure
|
|
407
|
+
Ok.new(value: token_info(scheme, stored), meta: ResponseMeta.local)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def low_level_refresh_now(scheme)
|
|
314
411
|
operation = oauth_operation(scheme, "refresh")
|
|
315
|
-
_, failure =
|
|
412
|
+
_, failure = low_level_stored_token_inner(scheme, true)
|
|
316
413
|
return oauth_error(operation, failure.message) if failure
|
|
317
414
|
|
|
318
415
|
stored = read_token(scheme)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
module Tungsten
|
|
5
|
+
module Runtime
|
|
6
|
+
# Bounded parsing and immutable captures used only by managed OAuth.
|
|
7
|
+
module OAuthInputs
|
|
8
|
+
CAPACITY = 32
|
|
9
|
+
INPUT_BYTES = 32 * 1024
|
|
10
|
+
CALLBACK_BYTES = 16 * 1024
|
|
11
|
+
FIELDS = 64
|
|
12
|
+
LIFETIME_MS = 600_000
|
|
13
|
+
SAFE_INTEGER = 9_007_199_254_740_991
|
|
14
|
+
PROTECTED = %w[state code code_verifier code_challenge code_challenge_method client_id client_secret
|
|
15
|
+
redirect_uri grant_type response_type response_mode request request_uri scope resource audience
|
|
16
|
+
iss error error_description error_uri].freeze
|
|
17
|
+
class Invalid < StandardError; end
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def require_valid(value)
|
|
22
|
+
raise Invalid unless value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def safe_integer?(value) = value.is_a?(Integer) && value.positive? && value <= SAFE_INTEGER
|
|
26
|
+
|
|
27
|
+
# Check original bytes before copying. Managed text is UTF-8, including
|
|
28
|
+
# binary/ASCII strings whose bytes form valid UTF-8; no unbounded transcode.
|
|
29
|
+
def text(value, remaining)
|
|
30
|
+
require_valid(value.is_a?(String) && value.bytesize <= remaining)
|
|
31
|
+
require_valid([Encoding::UTF_8, Encoding::US_ASCII, Encoding::BINARY].include?(value.encoding))
|
|
32
|
+
# Bound the copy even if another application thread grows the string
|
|
33
|
+
# after the size check; normalize String subclasses to an owned String.
|
|
34
|
+
copied = String.new(value.byteslice(0, remaining + 1), encoding: Encoding::UTF_8)
|
|
35
|
+
require_valid(copied.bytesize <= remaining)
|
|
36
|
+
require_valid(copied.valid_encoding?)
|
|
37
|
+
copied.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Budget
|
|
41
|
+
def initialize = @remaining = INPUT_BYTES
|
|
42
|
+
|
|
43
|
+
def text(value)
|
|
44
|
+
copied = OAuthInputs.text(value, @remaining)
|
|
45
|
+
@remaining -= copied.bytesize
|
|
46
|
+
copied
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def optional(value) = value.nil? ? nil : text(value)
|
|
50
|
+
|
|
51
|
+
def scopes(value)
|
|
52
|
+
OAuthInputs.require_valid(value.is_a?(Array) && value.length <= FIELDS)
|
|
53
|
+
Array.new(value.length) { |index| text(value[index]) }.freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extra(value)
|
|
57
|
+
OAuthInputs.require_valid(value.is_a?(Hash) && value.length <= FIELDS)
|
|
58
|
+
result = {}
|
|
59
|
+
count = 0
|
|
60
|
+
value.each do |key, item|
|
|
61
|
+
count += 1
|
|
62
|
+
OAuthInputs.require_valid(count <= FIELDS)
|
|
63
|
+
name = text(key)
|
|
64
|
+
copied = text(item)
|
|
65
|
+
OAuthInputs.require_valid(!name.empty? && !PROTECTED.include?(name.downcase) && !result.key?(name))
|
|
66
|
+
result[name] = copied
|
|
67
|
+
end
|
|
68
|
+
result.freeze
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def decode(value)
|
|
73
|
+
require_valid(!value.match?(/%(?![0-9a-fA-F]{2})/))
|
|
74
|
+
bytes = value.b.tr("+", " ").gsub(/%([0-9a-fA-F]{2})/) { [$1.to_i(16)].pack("C") }
|
|
75
|
+
decoded = bytes.force_encoding(Encoding::UTF_8)
|
|
76
|
+
require_valid(decoded.valid_encoding?)
|
|
77
|
+
decoded.freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Count before split; charge every decoded pair before duplicate refusal.
|
|
81
|
+
def query(raw, limit)
|
|
82
|
+
return {}.freeze if raw.nil? || raw.empty?
|
|
83
|
+
require_valid(raw.bytesize <= limit && raw.count("&") < FIELDS)
|
|
84
|
+
result = {}
|
|
85
|
+
remaining = limit
|
|
86
|
+
raw.split("&", -1).each do |field|
|
|
87
|
+
key, value = field.split("=", 2)
|
|
88
|
+
require_valid(!key.empty?)
|
|
89
|
+
name, item = decode(key), decode(value || "")
|
|
90
|
+
remaining -= name.bytesize + item.bytesize
|
|
91
|
+
require_valid(remaining >= 0 && !result.key?(name))
|
|
92
|
+
result[name] = item
|
|
93
|
+
end
|
|
94
|
+
result.freeze
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def uri(value, redirect: false)
|
|
98
|
+
require_valid(!value.empty? && !value.match?(/[\x00-\x20\x7f]/))
|
|
99
|
+
parsed = URI.parse(value)
|
|
100
|
+
require_valid(parsed.absolute? && parsed.userinfo.nil? && parsed.fragment.nil?)
|
|
101
|
+
if %w[http https].include?(parsed.scheme&.downcase)
|
|
102
|
+
require_valid(parsed.host && !parsed.host.empty? && parsed.port&.positive?)
|
|
103
|
+
else
|
|
104
|
+
require_valid(redirect)
|
|
105
|
+
end
|
|
106
|
+
parsed
|
|
107
|
+
rescue URI::Error, ArgumentError
|
|
108
|
+
raise Invalid
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def endpoint(value, redirect: false)
|
|
112
|
+
parsed = uri(value, redirect: redirect)
|
|
113
|
+
query(parsed.query, INPUT_BYTES).each_key { |key| require_valid(!PROTECTED.include?(key.downcase)) }
|
|
114
|
+
value
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def callback(raw, capture)
|
|
118
|
+
value = text(raw, CALLBACK_BYTES)
|
|
119
|
+
parsed = uri(value, redirect: true)
|
|
120
|
+
require_valid(value.split("?", 2).first == capture[:redirect].split("?", 2).first)
|
|
121
|
+
fields = query(parsed.query, CALLBACK_BYTES)
|
|
122
|
+
registered = query(URI.parse(capture[:redirect]).query, CALLBACK_BYTES)
|
|
123
|
+
registered.each { |key, item| require_valid(fields[key] == item) }
|
|
124
|
+
require_valid(fields["state"] == capture[:state])
|
|
125
|
+
require_valid(fields["iss"] == capture[:issuer]) if capture[:issuer_mode] == :response
|
|
126
|
+
if fields.key?("code")
|
|
127
|
+
require_valid(!fields["code"].empty? && %w[error error_description error_uri].none? { |key| fields.key?(key) })
|
|
128
|
+
[:code, fields["code"]]
|
|
129
|
+
else
|
|
130
|
+
require_valid(fields["error"].is_a?(String) && !fields["error"].empty?)
|
|
131
|
+
[:denied, nil]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|