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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df110fa13841d1574e906b26ac0c64eef7102be30ccbfce2789dd462c165b657
4
- data.tar.gz: 20106e7943dfa350c6818fb6cc5f9187139fe98b592c33ccd5cf217e73dee1c0
3
+ metadata.gz: b6555377e7e8580de6fa660f16a5a9b764a61b5206b992f20c5c65dc3791d3af
4
+ data.tar.gz: af2cbf8b640fb487fd53f630f15fd6f7905490eab697c27b4432160233ed95fa
5
5
  SHA512:
6
- metadata.gz: a930ac5449418b2597860bd6b925ff3930a293f8649efa0c4a2c8ad1d7b0aa2a3afb87fffc18d61d958a607be36c6bd59ab65bdf74fa7c8eb55ebde92ebb4adb
7
- data.tar.gz: 6a834eb1ecce40a9cf7c570efa61dc8d4d68814cc23b1a66f2039c300f94510f4c81c75be77d1c0bedf3173b586cef8d7105fb1cb936d58c7a030762b9a7a39f
6
+ metadata.gz: 841805313c667ee7c2d258ce86424918a14840f75c5eff15790f63a1686ad7c7dcf1e17ca1fb78117efc1139abbc46cc4cda8412251ee6a9ccceb0410b9cfb79
7
+ data.tar.gz: 06134745fdcd13043ac3318059f33605489ec999d88da4333e67b2d2c3d38964b9c6ad7ccfd09ca7379eafe6c62528be491c66ae595d7ecfddc3f2535bfb3778
data/README.md CHANGED
@@ -35,7 +35,7 @@ responsible for authorization and server-side idempotency.
35
35
  Select `FileIdempotencyStore` through the client's idempotency-store option to
36
36
  reuse keys after a restart and coordinate automatic key allocation across
37
37
  processes. The version-1 JSON file and `<path>.lock` owner lease interoperate
38
- with the TypeScript, Python, Go, Rust, Java, C# and Ruby stores.
38
+ with the TypeScript, Python, Go, Rust, Java, C#, Ruby and Dart stores.
39
39
 
40
40
  ```ruby
41
41
  options = Tungsten::Runtime::ClientOptions.new(
@@ -59,16 +59,198 @@ token immediately before commit and removes only its own lease. Use a filesystem
59
59
  that supports exclusive creation and atomic rename; this is not a distributed
60
60
  transaction service. Losing this file can cause a new key for a previous intent.
61
61
 
62
+ ## Credential transport
63
+
64
+ Credentials require HTTPS by default, including OAuth client-credentials, code
65
+ exchange and refresh endpoints. Non-loopback HTTP returns `AUTH_FAILED` before
66
+ sending credentials. Migrate authenticated HTTP services to HTTPS, or explicitly
67
+ opt in with `allow_insecure_http: true` in client options for intentional insecure transport.
68
+ This permits cleartext credentials; enable it only for a trusted environment.
69
+
70
+ The local-development exception covers `localhost` (also with a trailing dot),
71
+ literal IPv4 `127/8`, and IPv6 `::1`. Subdomains, unspecified addresses and
72
+ IPv4-mapped IPv6 addresses require the explicit override. DNS resolution does not
73
+ grant an exception. Anonymous HTTP requests remain supported.
74
+
75
+ ## Managed OAuth authorization
76
+
77
+ In unreleased 0.2.0, `client.oauth(scheme).authorization_url` begins a managed
78
+ authorization attempt. Configure the scheme with `flow: "authorizationCode"`,
79
+ `client_id`, `redirect_uri`, and a `client_secret` only when the provider requires
80
+ one. Qualify the provider's S256 support and issuer response before enabling them:
81
+
82
+ ```ruby
83
+ flow = client.oauth("browser") # Use the generated API's scheme name.
84
+ binding = SecureRandom.hex(32) # Keep with the owning session or callback listener.
85
+ started = flow.authorization_url(
86
+ session_binding: binding,
87
+ s256_supported: true,
88
+ issuer_defense: Tungsten::Runtime::IssuerDefense.issuer_response("https://issuer.example")
89
+ )
90
+ raise "Authorization could not begin" unless started.ok?
91
+ attempt = started.value
92
+ # Open attempt.url in the browser. Retain attempt.attempt_handle with binding.
93
+ # callback_url is the full URL received by that same owning listener.
94
+ completed = flow.exchange_code(
95
+ attempt_handle: attempt.attempt_handle,
96
+ callback_url: callback_url,
97
+ session_binding: binding
98
+ )
99
+ raise "Authorization did not complete" unless completed.ok?
100
+ # Authenticated generated calls can now use the selected token.
101
+ ```
102
+
103
+ Each attempt owns fresh cryptographic state and an S256 verifier. It captures
104
+ immutable copies of the issuer defense, endpoints, client, redirect, scopes,
105
+ resource, audience, account context and extra parameters. Managed completion
106
+ requires the handle, owning binding and full callback URL; it rejects duplicate
107
+ fields, malformed escapes, redirect or issuer mismatches, and replay before a
108
+ token request. Registered redirect text must match exactly, including any
109
+ registered query values. HTTP(S) redirects need a host; qualified absolute app
110
+ schemes are supported. Endpoint and extra parameters cannot override protected
111
+ OAuth fields. Descriptor or credential changes invalidate captured work.
112
+
113
+ `IssuerDefense.distinct_redirect(qualification)` is an explicit alternative for
114
+ an application that has qualified a redirect unique to that issuer/client.
115
+ The qualification is an application assertion; the runtime cannot establish
116
+ provider support or discover issuer metadata. An invalid callback or binding
117
+ leaves an otherwise valid attempt available; a validated denial consumes it.
118
+ Cancel with `cancel_authorization(attempt_handle: ..., session_binding: ...)`.
119
+ Attempts expire after ten minutes of monotonic time. One owned expiry thread per
120
+ active client releases idle attempts and exits when there are no live deadlines.
121
+ Cancel attempts and call `logout` when their owning session ends.
122
+
123
+ One client admits at most 32 attempts and 32 active token/read/refresh/cleanup
124
+ owners, including waiting refresh followers. An in-flight attempt retains its
125
+ slot through synchronous transport/store settlement after cancellation or expiry.
126
+ There are at most 32 captured authorization-code scheme names; changing the set
127
+ requires a new client. Captured configuration is limited to 32 KiB of UTF-8 text,
128
+ with at most 64 scopes and 64 extra fields. A callback is limited to 16 KiB and
129
+ 64 fields. Limits are checked before collection copies and URL decoding. Ruby
130
+ objects, URL/form encoding and parser copies add overhead beyond those budgets.
131
+
132
+ Code and refresh responses default to 64 KiB. Set the positive safe integer
133
+ `max_oauth_token_response_bytes` client option for a provider needing a different
134
+ finite cap. Each stored token string and metadata string is bounded separately
135
+ by that cap, so retaining a previous refresh token does not reduce the new access
136
+ token allowance. Token bodies, HTTP decoding and JSON parsing can allocate more
137
+ than the accepted byte cap. The monotonic token job deadline covers admitted
138
+ reads, dispatch and final store predicates. A custom synchronous hook that
139
+ ignores its deadline may block its owner until it settles; it cannot authorize
140
+ a late token send or final mutation after a known expired deadline.
141
+
142
+ `refresh` and automatic refresh share fenced work. `logout` fences the local
143
+ generation before cleanup, invalidates pending attempts, and deselects stored
144
+ credentials even if cleanup fails or has no admission slot. A separate mutation
145
+ epoch prevents older refresh success or rejection from replacing or deleting a
146
+ newer login, even when token strings are identical. Independent login attempts
147
+ remain usable; the last successfully committed completion selects credentials.
148
+ A cancel/logout winning the final pre-dispatch gate sends nothing. After that
149
+ gate a request may reach the provider; persistence remains fenced. Logout does
150
+ not revoke provider tokens or promise physical zeroization of Ruby strings.
151
+
152
+ The default `MemoryTokenStore` supplies atomic `set_if_current(scheme, token)`
153
+ and `delete_if_current(scheme)` methods. A custom managed store must implement
154
+ both, yield its current `StoredToken` to the supplied predicate **exactly once**
155
+ at the final serialized mutation, and commit without an intervening wait. The
156
+ predicate may advance a session epoch: speculative checks, retries, or calling
157
+ it before an asynchronous operation are invalid. Return `true` only after the
158
+ mutation commits. If a write fails after the predicate, preserve the old token;
159
+ older refresh work may be conservatively invalidated. No store or application
160
+ callbacks run under the manager lock. A store committing then holding its return
161
+ can still cause the caller to report failure after cancellation or a deadline;
162
+ that does not roll back a completed commit. Shared multi-account adapters need
163
+ their own partitioning and coordination; per-client memory behavior does not
164
+ qualify them.
165
+
166
+ Migration: primitive URL/code/verifier calls are now explicitly named
167
+ `low_level_authorization_url`, `low_level_exchange_code` and `low_level_refresh`.
168
+ Those escapes leave state, PKCE, issuer, callback validation, replay and store
169
+ coordination to the caller. `pkce` remains a primitive utility; managed begin
170
+ generates its own pair. Direct store `set`/`delete` and low-level methods bypass
171
+ managed epochs and must not be mixed into managed session guarantees. Helpers,
172
+ options and token holders redact `inspect`/`to_s`; URLs, handles, callback URLs
173
+ and bindings are still secrets when explicitly accessed and must not be logged.
174
+
175
+ ## Opening authorization URLs safely
176
+
177
+ OAuth authorization helpers return URL data; they do not open a browser. Treat
178
+ provider endpoints and returned URLs as untrusted input. Apply your application's
179
+ URL policy and use a browser or native URL API that treats the complete target as
180
+ literal data. Never interpolate the URL into PowerShell, CMD, POSIX shell or other
181
+ command source, and do not pass it to an opener that does so internally.
182
+
183
+ Scheme validation alone does not prevent command injection: an ordinary HTTPS
184
+ URL can contain shell expansion characters. Managed OAuth endpoint checks are
185
+ protocol validation, not shell escaping. Explicit low-level compatibility helpers
186
+ retain application-owned URL policy, including custom schemes; their results
187
+ need the same safe integration boundary. Do not silently route a rejected URL or
188
+ failed browser launch through a shell fallback.
189
+
190
+ These boundaries do not qualify third-party openers, custom integrations or real
191
+ provider/listener behavior. Qualify the application's complete launch path on
192
+ each supported platform, including Windows and WSL.
193
+
62
194
  ## Limits
63
195
 
64
- Set `ClientOptions.new(max_response_bytes: ...)` to opt into an ordinary-response byte cap;
65
- a call option with the same name overrides it. The value must be a positive
66
- safe integer (at most 9007199254740991); unset keeps the existing unbounded
67
- behavior. Content-Length is checked before reading, and actual bytes are
68
- counted even without that header. Overflow closes the body and returns
69
- `UNEXPECTED_RESPONSE` with code `RESPONSE_TOO_LARGE` and `retryable: never`.
70
- A mutation may have taken effect: inspect API state before retrying. The cap
71
- does not limit a successful SSE stream; use its event and collection limits.
196
+ Multipart bodies: a field whose OpenAPI `encoding` entry declares a
197
+ `contentType` is sent with that media type (the first entry without
198
+ wildcards); a file part's own media type wins. The entry's `headers` are not
199
+ sent (the TypeScript, Python and Rust runtimes send those with a fixed
200
+ value), and neither is positional or nested multipart encoding. Unreleased
201
+ 0.2.0 only.
202
+
203
+ The OAuth 2.0 device authorization flow (OpenAPI 3.2 `deviceAuthorization`) is
204
+ not run by the client: configure the scheme with an access token obtained
205
+ through it.
206
+
207
+ A tagged union with an OpenAPI 3.2 discriminator `defaultMapping` decodes a
208
+ hash whose tag is missing, not a string or claimed by no other variant as the
209
+ default variant's model. Schema validation of the response checks it against
210
+ that variant's own field types. Without `defaultMapping` such a hash is
211
+ returned as it is.
212
+
213
+ In unreleased 0.2.0, `deadline_ms` (on `ClientOptions` or the call options) sets a total deadline
214
+ for one logical call, separate from the per-attempt `timeout_ms`. It is measured on a monotonic
215
+ clock and covers authentication, idempotency-key reservation, every attempt (each one's
216
+ `timeout_ms` is cut to the time left), retry waits, response bodies and verification; for `pages`,
217
+ `poll` and `run_macro` it covers the whole traversal, poll or macro, and nested work cannot restart
218
+ it. No attempt starts after it expires: the call fails with `code` `DEADLINE_EXCEEDED`, and a
219
+ mutation that was already sent reports `OUTCOME_UNKNOWN`, never a blind replay. A retry wait or
220
+ poll interval that would end after the deadline is not waited for. For `stream` it bounds
221
+ connecting and each reconnect, never an open stream (use `idle_timeout_ms`). With a deadline,
222
+ authentication, a shared OAuth token refresh and idempotency-key reservation run on a helper
223
+ thread that is waited for only until the deadline and never killed, so other callers still get a
224
+ shared refresh's result; it stays bounded by its own timeout, and a custom store that blocks keeps
225
+ its thread busy. Default: no total deadline.
226
+
227
+ In unreleased 0.2.0, ordinary buffered responses default to **4 MiB (4,194,304 bytes) per response**.
228
+ Set `ClientOptions.new(max_response_bytes: ...)` to choose a finite client cap; the call option with the same
229
+ native name overrides it. Omitted/null values inherit the default or client cap,
230
+ not an unlimited mode. Values must be positive safe integers (at most
231
+ 9007199254740991), validated before authentication, storage and preview effects.
232
+ This covers ordinary success/error bodies, server previews, each page and each
233
+ verification read. A page cap does not bound collecting many pages.
234
+
235
+ Content-Length can reject an oversized representation early; actual delivered
236
+ bytes are counted even without it. HEAD/204/205/304 metadata alone does not
237
+ consume the budget, but any body bytes a transport delivers are still counted.
238
+ Overflow closes the owned body and returns `UNEXPECTED_RESPONSE`, code
239
+ `RESPONSE_TOO_LARGE`, with `retryable: never`. A mutation may have taken effect:
240
+ inspect API state before retrying. The cap is separate from OAuth token-response
241
+ and successful SSE event/collection budgets.
242
+
243
+ The default HTTP stack supplies decoded bytes when it decompresses a response.
244
+ The cap limits accepted body bytes; parsers, copies and HTTP decoders can allocate
245
+ more. Custom transports must stream nonempty bodies and honor cancellation;
246
+ their own buffering, decoding and settlement behavior need separate qualification.
247
+
248
+ Schema `min_length`/`max_length` count Unicode code points (a surrogate pair is one, a lone
249
+ surrogate is one), as JSON Schema does. A schema `pattern` is an ECMAScript expression run on
250
+ Onigmo. One Onigmo cannot compile is accepted and reported through `on_diagnostic` as
251
+ `PATTERN_UNCHECKED` (path, pattern and reason, never the value; once per path and pattern per
252
+ call). Ruby matching has no time or length limit. Calling `Schema#validate` directly is silent unless
253
+ you pass a notes array.
72
254
 
73
255
  The runtime is synchronous and opens a new HTTP connection for each
74
256
  attempt; it does not pool persistent connections.
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
 
4
+ require "ipaddr"
5
+
4
6
  module Tungsten
5
7
  module Runtime
6
8
  # Auth resolution: an operation's security is an OR of AND-sets of
@@ -9,6 +11,36 @@ module Tungsten
9
11
  # `satisfies` list when every part it needs for this request is
10
12
  # configured.
11
13
  class ClientCore
14
+ INSECURE_HTTP_AUTH = "Refusing to send credentials over non-loopback HTTP. Use HTTPS, or set ClientOptions.allow_insecure_http = true for intentional insecure transport. Nothing was sent."
15
+
16
+ def credential_header?(name, preserved_idempotency = false)
17
+ (!preserved_idempotency && Util.looks_sensitive?(name)) ||
18
+ %w[authorization proxy-authorization cookie].include?(name.downcase) ||
19
+ Array(@api["auth"]).any? do |scheme|
20
+ (scheme["kind"] == "api_key" && scheme["in"] == "header" && scheme["wire"].to_s.casecmp?(name)) ||
21
+ (scheme["kind"] == "composite" && Array(scheme["parts"]).any? do |part|
22
+ part["kind"] == "header" && part["from_config"].to_s.empty? && part["name"].to_s.casecmp?(name)
23
+ end)
24
+ end
25
+ end
26
+
27
+ def insecure_http?(url)
28
+ return false if @allow_insecure_http
29
+
30
+ parsed = URI.parse(url.to_s)
31
+ host = parsed.hostname.to_s.downcase
32
+ return false if parsed.scheme&.downcase != "http" || %w[localhost localhost.].include?(host)
33
+
34
+ begin
35
+ ip = IPAddr.new(host)
36
+ loopback = ip.ipv4? ? (ip.to_i >> 24) == 127 : ip.to_i == 1
37
+ !loopback
38
+ rescue IPAddr::InvalidAddressError
39
+ true
40
+ end
41
+ rescue URI::InvalidURIError
42
+ false
43
+ end
12
44
  # The headers, cookies and query parameters credentials add.
13
45
  AuthPlan = Struct.new(:headers, :cookies, :query) do
14
46
  def self.empty = new([], [], [])
@@ -23,7 +55,7 @@ module Tungsten
23
55
 
24
56
  private
25
57
 
26
- def safe_method?(method) = %w[GET HEAD OPTIONS TRACE].include?(method)
58
+ def safe_method?(method) = %w[GET HEAD OPTIONS TRACE QUERY].include?(method)
27
59
 
28
60
  def composite_key(part)
29
61
  case part["kind"]
@@ -212,7 +244,7 @@ module Tungsten
212
244
  end
213
245
 
214
246
  # The credentials for an operation.
215
- def resolve_auth(op, method)
247
+ def resolve_auth(op, method, credential_error = nil)
216
248
  empty = AuthResult.new(AuthPlan.empty, Util::Secrets.new, nil, nil)
217
249
  return empty if op.security.empty?
218
250
 
@@ -232,6 +264,7 @@ module Tungsten
232
264
  existing ? existing[1] = sat : sats << [key, sat]
233
265
  end
234
266
  if missing.empty?
267
+ return AuthResult.new(nil, nil, nil, credential_error) if !sats.empty? && credential_error
235
268
  out = AuthResult.new(AuthPlan.empty, Util::Secrets.new, nil, nil)
236
269
  sats.each do |_, sat|
237
270
  problem = apply(sat, out)