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.
@@ -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. Any object with `get(scheme)`, `set(scheme, token)` and
18
- # `delete(scheme)` can replace it (ClientOptions `token_store`).
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
- # A fresh PKCE pair: pass `challenge` to authorization_url and keep
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
- # The URL to send the user to: Ok with the URL, or Err.
66
- def authorization_url(redirect_uri: nil, scopes: nil, state: nil, code_challenge: nil, extra: {})
67
- @core.send(:authorization_url, @scheme, redirect_uri: redirect_uri, scopes: scopes, state: state,
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
- # Trade the code from the redirect for tokens, stored for later calls.
72
- def exchange_code(code:, redirect_uri: nil, code_verifier: nil)
73
- @core.send(:exchange_code, @scheme, code: code, redirect_uri: redirect_uri, code_verifier: code_verifier)
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 = stored_token_inner(scheme, force)
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 stored_token_inner(scheme, force)
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 = refresh_stored(scheme, current, force)
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 = read_token(scheme)
162
- return nil unless stored
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 refresh_stored(scheme, stored, force)
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(scheme, url, client, fields, previous)
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
- out = Transport.attempt(url: url.to_s, method: "POST", headers: headers, body: body, timeout_ms: token_timeout_ms)
204
- unless out.kind == :response && out.body
205
- return [nil, TokenFailure.new("The OAuth2 token endpoint for #{scheme} could not be reached; check network access and the token URL.", false)]
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
- named = code.is_a?(String) && code.match?(/\A[a-z_]{1,40}\z/) ? " (#{code})" : ""
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 OAuth2 token endpoint for #{scheme} answered HTTP #{out.status}#{named}; check the code or refresh token, client credentials and redirect URI.",
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 OAuth2 token endpoint for #{scheme} returned no access_token.", false)] if access.empty?
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, refresh_token: refresh.empty? ? nil : refresh, expires_at: expires,
226
- token_type: text.call("token_type"), scope: text.call("scope"))
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 authorization_url(scheme, redirect_uri:, scopes:, state:, code_challenge:, extra:)
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 exchange_code(scheme, code:, redirect_uri:, code_verifier:)
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 = stored_token_inner(scheme, true)
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