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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6555377e7e8580de6fa660f16a5a9b764a61b5206b992f20c5c65dc3791d3af
|
|
4
|
+
data.tar.gz: af2cbf8b640fb487fd53f630f15fd6f7905490eab697c27b4432160233ed95fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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)
|