From cd1620fba2aa1bfffba8b90dc6b3f67625fef2b0 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 15:35:49 +0300 Subject: [PATCH 1/7] feat: add the authentication layer under Dexpace::Auth (AUTH-1..38) Phase 6c, cut from main at f1fe848 with nothing of phase 6a present, so the steps take their own logger: keyword and AUTH-31 calls phase 3b's Body#replayable? directly. Twenty-five new lib files: auth.rb, the flat AuthResolutionError under error/, and twenty-three under auth/ -- the closed five-member Scheme set with ALL and .of, Requirement, Descriptor and the pure three-tier Resolver (AUTH-1..7); BearerToken, KeyCredential, NamedKeyCredential and PasswordCredential, each redacting in #to_s, #inspect and, for the two Data types, #pretty_print, because pp walks a Data's members and never calls an #inspect override (AUTH-8..10); the never-raising RFC 7235 list parser Challenges.parse over Challenge, the one fold point (AUTH-12, AUTH-13); BasicHandler over pack("m0") and never Base64 (AUTH-14); the challenge-only DigestHandler with MD5, MD5-sess, SHA-256 and SHA-256-sess, qop=auth or legacy, a per-nonce counter in its own BoundedMap and a typed failure for a credential ISO-8859-1 cannot carry (AUTH-15..22, AUTH-24); ChallengeHandlerChain with its proxy-aware header name and the hook adapter (AUTH-23, AUTH-25); the stateless KeyStamper (AUTH-26); BearerStamper with a lock-free hot path and XCUT-12's sanctioned lock-across-fetch (AUTH-34..36); AsyncBearerStamper with AUTH-37's three zones and one single-flight slot; BearerProvider.fetch_async as AUTH-11's never-raising default; and the AUTH pillar Step and AsyncStep at Stages::AUTH, built through .build(stamper:, challenge_hook:, logger:), forking for every drive, reading the cross-origin marker off the cursor and never a header, guarding HTTPS before any fetch or write, replaying a 401 at most once behind the replayability gate and closing the superseded response (AUTH-27..33, AUTH-38). Three earlier files widen in place: BoundedMap gains #update(key), the read-yield-write the nonce counter needs; Instrumentation::Events gains AUTH_REFRESH, the ninth event; lib/dexpace.rb gains the twenty-five-line Phase 6c block. Every file has a sig/ mirror; the strict Steep target is green with no relaxation and no Digest, SecureRandom or Random in any signature. Three existing tests change as pins the code invalidated: the smoke suite's layer table and its preloaded stdlib features, the seam surface's require pin (digest joins the four), and 5b's keys_test.rb (eight events become nine). The surface manifest is regenerated once, 956 to 1059 lines, all 103 rows read against the object model. --- gems/dexpace-core/lib/dexpace.rb | 36 +++ gems/dexpace-core/lib/dexpace/auth.rb | 26 ++ .../lib/dexpace/auth/async_bearer_stamper.rb | 201 ++++++++++++ .../lib/dexpace/auth/async_step.rb | 207 ++++++++++++ .../lib/dexpace/auth/basic_handler.rb | 74 +++++ .../lib/dexpace/auth/bearer_provider.rb | 71 +++++ .../lib/dexpace/auth/bearer_stamper.rb | 114 +++++++ .../lib/dexpace/auth/bearer_token.rb | 72 +++++ .../lib/dexpace/auth/challenge.rb | 60 ++++ .../dexpace/auth/challenge_handler_chain.rb | 78 +++++ .../lib/dexpace/auth/challenges.rb | 214 +++++++++++++ .../lib/dexpace/auth/descriptor.rb | 51 +++ .../lib/dexpace/auth/digest_handler.rb | 297 ++++++++++++++++++ .../lib/dexpace/auth/https_required_error.rb | 32 ++ .../lib/dexpace/auth/key_credential.rb | 54 ++++ .../lib/dexpace/auth/key_stamper.rb | 52 +++ .../lib/dexpace/auth/named_key_credential.rb | 57 ++++ .../lib/dexpace/auth/password_credential.rb | 65 ++++ .../lib/dexpace/auth/provider_error.rb | 18 ++ .../lib/dexpace/auth/requirement.rb | 52 +++ .../dexpace-core/lib/dexpace/auth/resolver.rb | 67 ++++ gems/dexpace-core/lib/dexpace/auth/scheme.rb | 88 ++++++ gems/dexpace-core/lib/dexpace/auth/step.rb | 202 ++++++++++++ .../auth/unencodable_credential_error.rb | 38 +++ .../lib/dexpace/auth/validation.rb | 40 +++ gems/dexpace-core/lib/dexpace/bounded_map.rb | 26 ++ .../dexpace/error/auth_resolution_error.rb | 38 +++ .../lib/dexpace/instrumentation/keys.rb | 15 +- gems/dexpace-core/sig/dexpace/auth.rbs | 6 + .../sig/dexpace/auth/async_bearer_stamper.rbs | 33 ++ .../sig/dexpace/auth/async_step.rbs | 40 +++ .../sig/dexpace/auth/basic_handler.rbs | 15 + .../sig/dexpace/auth/bearer_provider.rbs | 24 ++ .../sig/dexpace/auth/bearer_stamper.rbs | 25 ++ .../sig/dexpace/auth/bearer_token.rbs | 21 ++ .../sig/dexpace/auth/challenge.rbs | 24 ++ .../dexpace/auth/challenge_handler_chain.rbs | 20 ++ .../sig/dexpace/auth/challenges.rbs | 40 +++ .../sig/dexpace/auth/descriptor.rbs | 17 + .../sig/dexpace/auth/digest_handler.rbs | 73 +++++ .../sig/dexpace/auth/https_required_error.rbs | 13 + .../sig/dexpace/auth/key_credential.rbs | 17 + .../sig/dexpace/auth/key_stamper.rbs | 20 ++ .../sig/dexpace/auth/named_key_credential.rbs | 18 ++ .../sig/dexpace/auth/password_credential.rbs | 20 ++ .../sig/dexpace/auth/provider_error.rbs | 8 + .../sig/dexpace/auth/requirement.rbs | 20 ++ .../sig/dexpace/auth/resolver.rbs | 14 + gems/dexpace-core/sig/dexpace/auth/scheme.rbs | 30 ++ gems/dexpace-core/sig/dexpace/auth/step.rbs | 49 +++ .../auth/unencodable_credential_error.rbs | 13 + .../sig/dexpace/auth/validation.rbs | 12 + gems/dexpace-core/sig/dexpace/bounded_map.rbs | 5 +- .../dexpace/error/auth_resolution_error.rbs | 15 + .../sig/dexpace/instrumentation/keys.rbs | 1 + .../test/dexpace/instrumentation/keys_test.rb | 4 +- .../test/dexpace/seam_surface_test.rb | 6 +- gems/dexpace-core/test/dexpace_test.rb | 40 ++- test/fixtures/surface/dexpace-core.txt | 103 ++++++ 59 files changed, 3076 insertions(+), 15 deletions(-) create mode 100644 gems/dexpace-core/lib/dexpace/auth.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/async_step.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/basic_handler.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/bearer_provider.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/bearer_token.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/challenge.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/challenge_handler_chain.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/challenges.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/descriptor.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/digest_handler.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/https_required_error.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/key_credential.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/key_stamper.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/named_key_credential.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/password_credential.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/provider_error.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/requirement.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/resolver.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/scheme.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/step.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb create mode 100644 gems/dexpace-core/lib/dexpace/auth/validation.rb create mode 100644 gems/dexpace-core/lib/dexpace/error/auth_resolution_error.rb create mode 100644 gems/dexpace-core/sig/dexpace/auth.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/async_step.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/bearer_provider.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/bearer_stamper.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/bearer_token.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/challenge.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/challenge_handler_chain.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/challenges.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/descriptor.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/https_required_error.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/key_credential.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/key_stamper.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/named_key_credential.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/password_credential.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/provider_error.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/requirement.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/resolver.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/scheme.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/step.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs create mode 100644 gems/dexpace-core/sig/dexpace/auth/validation.rbs create mode 100644 gems/dexpace-core/sig/dexpace/error/auth_resolution_error.rbs diff --git a/gems/dexpace-core/lib/dexpace.rb b/gems/dexpace-core/lib/dexpace.rb index 2a36e94..171b4fb 100644 --- a/gems/dexpace-core/lib/dexpace.rb +++ b/gems/dexpace-core/lib/dexpace.rb @@ -210,6 +210,42 @@ require_relative "dexpace/resilience/async_retry_step" require_relative "dexpace/resilience/recovery_retry" +# Phase 6c: the authentication layer, in dependency order -- the namespace and the flat +# resolution error, the private non-blank helper, the closed scheme set, the requirement and +# the descriptor over it, the resolver, the four credential types, the challenge and its +# parser, the two challenge handlers and the chain over them, the key stamper, the bearer +# provider function, the three namespaced errors (filed under auth/, where their constants +# live), the two bearer stampers, then the two pillar steps, the async one over the sync one. +# bounded_map.rb, which the Digest handler's nonce store reaches by a bare name, is phase 4a's +# line above and gains #update in place. `digest` and `securerandom` are required by +# digest_handler.rb and `strscan` by challenges.rb, each in the file that uses it; all three +# were on the allowlist before this phase. +require_relative "dexpace/auth" +require_relative "dexpace/error/auth_resolution_error" +require_relative "dexpace/auth/validation" +require_relative "dexpace/auth/scheme" +require_relative "dexpace/auth/requirement" +require_relative "dexpace/auth/descriptor" +require_relative "dexpace/auth/resolver" +require_relative "dexpace/auth/bearer_token" +require_relative "dexpace/auth/key_credential" +require_relative "dexpace/auth/named_key_credential" +require_relative "dexpace/auth/password_credential" +require_relative "dexpace/auth/challenge" +require_relative "dexpace/auth/challenges" +require_relative "dexpace/auth/basic_handler" +require_relative "dexpace/auth/unencodable_credential_error" +require_relative "dexpace/auth/digest_handler" +require_relative "dexpace/auth/challenge_handler_chain" +require_relative "dexpace/auth/key_stamper" +require_relative "dexpace/auth/provider_error" +require_relative "dexpace/auth/bearer_provider" +require_relative "dexpace/auth/bearer_stamper" +require_relative "dexpace/auth/async_bearer_stamper" +require_relative "dexpace/auth/https_required_error" +require_relative "dexpace/auth/step" +require_relative "dexpace/auth/async_step" + # The dexpace Ruby SDK: an HTTP-client toolkit, not an HTTP client. # # This file issues explicit `require_relative`s for the whole tree rather than using an diff --git a/gems/dexpace-core/lib/dexpace/auth.rb b/gems/dexpace-core/lib/dexpace/auth.rb new file mode 100644 index 0000000..0244a47 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + # The authentication layer (product spec §11, AUTH-1–AUTH-38; design §6.3): the + # descriptor/resolver model, the four credential types, the RFC 7235 challenge parser, the + # Basic and Digest handlers, the composing chain, the key and bearer stampers, and the AUTH + # pillar step on both runtimes. + # + # Everything here is a value, a pure function or an object a caller constructs and installs; + # nothing is registered process-wide and nothing reads Dexpace.configuration (R11: the one + # tunable, the Digest nonce store's cap, is a constructor keyword because the handler is + # explicitly constructed and was never ambient). The layer depends on phases 0–5 only: it + # reads the cross-origin marker phase 4c's cursor carries and never a header (AUTH-29, design + # §10.15), gates every replay on phase 3b's Body#replayable? directly (AUTH-31), and takes the + # per-nonce counter from phase 4a's BoundedMap by a bare name from a full-nesting body + # (AUTH-19). + module Auth + # AUTH-8: what every credential's #to_s, #inspect and pretty-print show in place of its + # secret. One marker for the four types, so a log line reads the same whichever credential + # produced it. Distinct from the instrumentation redactor's `***`: that marks a redacted + # header VALUE on the way into a log record; this marks a field the object itself refuses + # to render, whatever asked. + REDACTED = "[REDACTED]" + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb b/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb new file mode 100644 index 0000000..bdf725e --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../http/headers" +require_relative "../clock" +require_relative "../async/completer" +require_relative "../async/future" +require_relative "../instrumentation/keys" +require_relative "../instrumentation/logger" +require_relative "../instrumentation/contain" +require_relative "bearer_token" +require_relative "bearer_provider" +require_relative "bearer_stamper" +require_relative "provider_error" + +module Dexpace + module Auth + # AUTH-37, AUTH-36's async half, AUTH-11: the bearer stamper for the async runtime and + # 6c's R12 as code. It never calls #value or #wait on any future -- the calling fiber + # returns as soon as it has something to hand back -- and it spawns no thread: "kick off an + # off-thread background refresh" means calling the provider's async fetch and attaching + # #on_settle, and whatever runs the fetch is the provider's own affair (6c's P6-5). + # + # Three zones, read off one lock-free token reference (XCUT-12): FRESH (not expired with + # the margin) stamps and makes no provider call; EXPIRING-BUT-VALID (expired with the + # margin, not without it) stamps the still-valid token at once and kicks off a refresh it + # does not await; EXPIRED-OR-MISSING derives the stamped request from the in-flight fetch + # through Future#then. Every refresh goes through ONE single-flight slot: the first caller + # registers a Completer under @lock and starts the fetch; every later caller, from either + # zone, coalesces onto that future. A failed fetch settles the waiters with the error and + # caches nothing; a failed BACKGROUND refresh is reported through `logger:` as an + # `http.auth.refresh` diagnostic and fails nothing, since a valid token was already stamped. + # + # The fetch is started OUTSIDE the lock, and that is not a style choice. AUTH-11's default + # wrapper mirrors a sync-only provider's #fetch into an ALREADY-SETTLED future, on which + # phase 2's #on_settle runs the block inline on the calling fiber; the settle block takes + # @lock to publish the token, and Thread::Mutex is not reentrant, so starting the fetch + # inside `synchronize` raises `ThreadError: deadlock; recursive locking` for the commonest + # provider shape there is (verified on 3.2.11, 3.4.10 and 4.0.6). Register, release, fetch. + # + # #stamp_fresh is the post-eviction path AUTH-37's last clause names: it bypasses the + # three-zone read and settles on a coalesced fetch, so the retry can never re-send the + # token the server just rejected. AsyncStep calls it after a successful eviction, and + # #stamp after a failed one, where AUTH-36 says the refreshed token is reused. + class AsyncBearerStamper + # @param provider [Object] anything answering #fetch, and optionally #fetch_async + # @param clock [_Clock] the time seam; Clock::SYSTEM by default + # @param refresh_margin [Numeric] seconds before expiry at which a token is refreshed + # @param logger [Instrumentation::Logger] where a failed background refresh is reported; + # Logger::NULL reports nothing + def initialize(provider:, clock: Clock::SYSTEM, + refresh_margin: BearerStamper::DEFAULT_REFRESH_MARGIN, + logger: Instrumentation::Logger::NULL) + unless BearerProvider.conforms?(provider) + raise InvalidArgumentError, "provider must answer #fetch (AUTH-11)" + end + unless refresh_margin.is_a?(::Numeric) && !refresh_margin.negative? + raise InvalidArgumentError, "refresh_margin must be a non-negative number of seconds" + end + + @provider = provider + @clock = clock + @refresh_margin = refresh_margin + @logger = Model.required!("logger", logger) + @lock = ::Thread::Mutex.new + @token = nil #: BearerToken? + @in_flight = nil #: Dexpace::Async::Future? + end + + # The three-zone policy. Returns a future of the stamped request. + # + # @param request [Dexpace::Request] + # @return [Dexpace::Async::Future] + def stamp(request) + token = @token # the hot path: no lock (XCUT-12) + return awaiting(request) if token.nil? + + case zone(token) + when :fresh + settled(stamp_with(request, token)) # no provider call + when :expiring + background_refresh # stamp now, refresh without awaiting + settled(stamp_with(request, token)) + else + awaiting(request) # expired + end + end + + # AUTH-37's post-eviction clause: always a coalesced fetch, never the cache. + # + # @param request [Dexpace::Request] + # @return [Dexpace::Async::Future] + def stamp_fresh(request) + awaiting(request) + end + + # AUTH-36's cache half, identical to BearerStamper#evict_if_matches. + # + # @param rejected_header [String] the Authorization value the 401 rejected + # @return [Boolean] whether the cached token was the rejected one and was evicted + def evict_if_matches(rejected_header) + @lock.synchronize do + token = @token + next false if token.nil? || header(token) != rejected_header + + @token = nil + true + end + end + + private + + # AUTH-37's three zones of a cached token, from one clock reading: :fresh (not expired + # with the margin), :expiring (expired with the margin, valid without it), :expired. + def zone(token) + now = @clock.now + return :fresh unless token.expired?(now: now, margin: @refresh_margin) + + token.expired?(now: now, margin: 0) ? :expired : :expiring + end + + # The expired-or-missing zone's return: the stamped request derived from the coalesced + # fetch through Future#then, which never blocks. + def awaiting(request) + refresh_future.then { |fresh| stamp_with(request, fresh) } + end + + def header(token) = "Bearer #{token.token}" + + def stamp_with(request, token) + request.with(headers: request.headers.new_builder.set("Authorization", header(token)).build) + end + + # An already-settled future over a value: the fresh and expiring zones' return. + def settled(request) + completer = Dexpace::Async::Completer.new + completer.fulfil(request) + completer.future + end + + # The single-flight slot. Under the lock: reuse the in-flight future or register a new + # Completer. Outside it: start the fetch. See the class comment for why that order is + # load-bearing. + def refresh_future + completer = Dexpace::Async::Completer.new # discarded when a fetch is already in flight + existing = @lock.synchronize do + in_flight = @in_flight + @in_flight = completer.future if in_flight.nil? + in_flight + end + return existing unless existing.nil? + + start_fetch(completer) + completer.future + end + + # The fetch, and the one place the token is published. BearerProvider.fetch_async never + # raises, so the only way out of here is the settle block, which clears the slot and + # writes the cache under the lock and then settles the waiters outside it. A settle + # block that raised would propagate into whoever settled the provider's future, so + # nothing in it can raise: Completer#fulfil and #fail report rather than raise. + def start_fetch(completer) + BearerProvider.fetch_async(@provider).on_settle do |settlement| + token = settlement.success? ? settlement.response : nil + error = settlement.error || invalid(token) + @lock.synchronize do + @in_flight = nil + @token = token if error.nil? + end + error.nil? ? completer.fulfil(token) : completer.fail(error) + end + end + + # AUTH-35's rejections as an error value, or nil for a usable token. + def invalid(token) + return ProviderError.new("the provider returned no token (AUTH-35)") if token.nil? + unless token.is_a?(BearerToken) + return ProviderError.new("the provider returned a #{token.class}, not a BearerToken") + end + return nil unless token.expired?(now: @clock.now, margin: 0) + + ProviderError.new("the provider returned a token already expired at fetch time") + end + + # The expiring zone's refresh: coalesced like any other, observed only to log a failure. + # AUTH-37: "a failed/unusable BACKGROUND refresh MUST NOT fail the in-flight request + # (log-and-continue)" -- there is no in-flight request left to fail. + def background_refresh + refresh_future.on_settle do |settlement| + next if settlement.success? + + Instrumentation.diagnostic(@logger, event: Instrumentation::Events::AUTH_REFRESH, + cause: settlement.error,) + end + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/async_step.rb b/gems/dexpace-core/lib/dexpace/auth/async_step.rb new file mode 100644 index 0000000..9cfec51 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/async_step.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "step" +require_relative "../registry" +require_relative "../async/completer" +require_relative "../async/future" +require_relative "../error/cancelled_error" + +module Dexpace + module Auth + # AUTH-27–AUTH-38 on the async runtime: the same step over 4c's _AsyncStep, sharing Step's + # private helpers -- the cross-origin read, the HTTPS guard, the 401 tests and AUTH-31's + # one replayability predicate -- so the two paths cannot drift (spec-forced boundary 13). + # Subclassing inherits .build, so both steps take the same keywords; only the stamper shape + # widens, to anything answering `#stamp(request) -> Future` (AsyncBearerStamper) beside + # `#call(request) -> Request` (every other stamper, adapted into a settled future here). + # + # The whole body runs inside one Completer-backed frame (6c's R12): the HTTPS guard, the + # stamp, the drive, the bearer branch and the challenge hook all settle the ONE returned + # future, and every raise inside the frame -- on the calling fiber or in a settlement + # callback on whatever thread settles a future -- fails it rather than propagating. That is + # what makes AUTH-38's SHOULD unconditional here: not a per-error-type special case and not + # a scheduler-presence branch, since nothing in this class waits, delays or calls #value. + # AUTH-32's three clauses are all real on this path: a hook that raises synchronously, one + # whose returned future fails, and one that returns something that is not a request all + # leave the open 401 closed behind them, the close failure on the error's trail. + # + # AUTH-36 with AUTH-37's last clause: after a successful eviction the retry is stamped by + # #stamp_fresh, which awaits a genuinely fresh fetch; after a failed one (another request + # already refreshed the token) by #stamp, which reuses it. Cancelling the returned future + # cancels whichever inner future is in flight (SEAM-18), and an inner cancellation is + # forwarded as a cancellation, never as a plain failure. + class AsyncStep < Step # rubocop:disable Metrics/ClassLength -- the sync step's one #call, written as the continuations one Completer frame needs; see the class comment + # One 401 exchange's four references, carried through the callbacks as one argument. + class Exchange < ::Data.define(:stamped, :response, :cursor, :completer) + end + private_constant :Exchange + + # The stamper shapes this runtime drives: `#stamp -> Future` or `#call -> Request`. + def self.stamper!(stamper) + return if Registry.callable?(stamper, arity: 1) + return if stamper.respond_to?(:stamp) + + raise InvalidArgumentError, "stamper must answer #stamp(request) or #call(request)" + end + private_class_method :stamper! + + # @param request [Dexpace::Request] + # @param cursor [Dexpace::Pipeline::Cursor] + # @return [Dexpace::Async::Future] settling with the response, or failing + def call(request, cursor) + completer = Dexpace::Async::Completer.new + guarded(completer) do + if cross_origin?(cursor) # AUTH-29: no guard, no stamp, still a fork (P4-39) + chain_into(cursor.fork.call(request), completer) + else + enforce_https!(request) # AUTH-28, AUTH-38: a raise here fails the future + observe(stamp_async(request), completer) do |settlement| + drive(settlement, cursor, completer) + end + end + end + completer.future + end + + private + + # AUTH-38's frame: every StandardError inside settles the future as a failure. + def guarded(completer) + yield + rescue ::StandardError => error + completer.fail(error) + end + + # A stamper that answers #stamp is async already; every other is adapted, and a raise + # from it lands in the caller's guarded frame. + def stamp_async(request) + return @stamper.stamp(request) if @stamper.respond_to?(:stamp) + + settled(@stamper.call(request)) + end + + def settled(value) + completer = Dexpace::Async::Completer.new + completer.fulfil(value) + completer.future + end + + # Watch one inner future from the frame: its settlement is handled inside the guarded + # frame, and cancelling the frame's future cancels it (SEAM-18, both ways). + def observe(future, completer) + completer.on_cancel { |reason| future.cancel(reason) } + future.on_settle { |settlement| guarded(completer) { yield settlement } } + end + + # Forward one future's settlement into the frame's completer as it is. + def chain_into(future, completer) + observe(future, completer) do |settlement| + if settlement.success? + completer.fulfil(settlement.response) + else + forward_failure(settlement, completer) + end + end + end + + # A cancellation stays a cancellation one link down; a failure is the same object. + def forward_failure(settlement, completer) + error = settlement.error + return if error.nil? # a failed settlement always carries one (Settlement's own rule) + + if settlement.cancelled && error.is_a?(Dexpace::CancelledError) + completer.request_cancel(error.reason) + else + completer.fail(error) + end + end + + # The stamped request drives a fresh fork; its settlement is handled below. + def drive(settlement, cursor, completer) + return forward_failure(settlement, completer) unless settlement.success? + + stamped = settlement.response + observe(cursor.fork.call(stamped), completer) do |driven| + if driven.success? + handle(Exchange.new(stamped: stamped, response: driven.response, cursor: cursor, + completer: completer,)) + else + forward_failure(driven, completer) + end + end + end + + # The sync step's post-drive logic over futures: pass-through, AUTH-33, AUTH-36, AUTH-30. + def handle(exchange) + response = exchange.response + return exchange.completer.fulfil(response) unless unauthorized?(response) + + challenge = challenge_header(response) + return exchange.completer.fulfil(response) if challenge.nil? + return bearer_retry_async(exchange) if bearer_retry?(challenge, exchange.stamped) + + replay_async(challenge, exchange) + end + + # AUTH-36 and AUTH-37's post-eviction clause: evicted → #stamp_fresh; preserved → #stamp. + def bearer_retry_async(exchange) + evicted = @stamper.evict_if_matches(rejected_header(exchange.stamped).to_s) + Dexpace.close_quietly(exchange.response, logger: @logger) + drive_replacement(restamp(exchange.stamped, evicted), exchange) + end + + # Once the re-stamp settles, drive the retry through a fresh fork; forward a failure. + def drive_replacement(restamped, exchange) + completer = exchange.completer + observe(restamped, completer) do |settlement| + if settlement.success? + chain_into(exchange.cursor.fork.call(settlement.response), completer) + else + forward_failure(settlement, completer) + end + end + end + + def restamp(stamped, evicted) + return @stamper.stamp_fresh(stamped) if evicted && @stamper.respond_to?(:stamp_fresh) + + stamp_async(stamped) + end + + # AUTH-30 over futures: the hook may answer a request, nil, or a future of either + # (AUTH-32's "its async future completes exceptionally"). + def replay_async(challenge, exchange) + result = consult(challenge, exchange.stamped, exchange.response) + return replace(result, exchange) unless result.is_a?(Dexpace::Async::Future) + + observe(result, exchange.completer) do |settlement| + if settlement.success? + replace(replacement!(settlement.response), exchange) + else + Dexpace.close_quietly(exchange.response, onto: settlement.error) # AUTH-32 + forward_failure(settlement, exchange.completer) + end + end + end + + # The hook may hand back a future; the sync check is deferred to its settlement. + def replacement!(replacement) + return replacement if replacement.is_a?(Dexpace::Async::Future) + + super + end + + # AUTH-30, AUTH-31: nil or a non-replayable replacement surfaces the 401 (unclosed); + # otherwise the 401 is closed and the replacement driven through a fresh fork once. + def replace(replacement, exchange) + if replacement.nil? || !replayable?(replacement) + return exchange.completer.fulfil(exchange.response) + end + + Dexpace.close_quietly(exchange.response, logger: @logger) + chain_into(exchange.cursor.fork.call(replacement), exchange.completer) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/basic_handler.rb b/gems/dexpace-core/lib/dexpace/auth/basic_handler.rb new file mode 100644 index 0000000..634ba7b --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/basic_handler.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../http/headers" +require_relative "password_credential" +require_relative "challenge" + +module Dexpace + module Auth + # AUTH-14: RFC 7617 Basic. One class, two roles, one precomputed value: #call is preemptive + # stamping -- the path OpenAPI's `http`/`basic` security scheme takes, where a generated SDK + # sends the credential on the FIRST request and never waits for a 401 -- and + # #authorization_for is challenge answering, the path ChallengeHandlerChain drives. A second + # class would compute the same value twice, and AUTH-14 says "computed once and reused". + # + # The value is `Basic ` + `["u:p"].pack("m0")`, never Base64: `base64` is a bundled gem from + # Ruby 3.4 and core may not require it (CLAUDE.md's hard rule; design §6.3). pack("m0") + # base64-encodes the UTF-8 bytes of the joined string and returns a US-ASCII String, which + # is the header-safe form AUTH-14 asks for (verified on 3.2.11, 3.4.10 and 4.0.6). A + # credential in another encoding is transcoded to UTF-8 first, so the bytes packed are the + # bytes the requirement names. + # + # The non-EMPTY check is AUTH-14's own laxer rule -- "permitting whitespace-only values, per + # RFC 7617" -- and not AUTH-9's non-blank one (6c's P6-3): a password of three spaces is a + # legal Basic password. Nothing here re-validates the header at the wire; that is the + # transport adapter's re-validation pass (phase 8). + class BasicHandler + # @param credential [PasswordCredential] + # @raise [Dexpace::InvalidArgumentError] on an empty username or password (AUTH-14) + def initialize(credential) + unless credential.is_a?(PasswordCredential) + raise InvalidArgumentError, "a Dexpace::Auth::PasswordCredential is required" + end + if credential.username.empty? || credential.password.empty? + raise InvalidArgumentError, "username and password must be non-empty (AUTH-14)" + end + if credential.username.include?(":") + raise InvalidArgumentError, "a Basic username must not contain a colon (RFC 7617 §2)" + end + + pair = "#{credential.username}:#{credential.password}".encode(::Encoding::UTF_8) + @value = "Basic #{[pair].pack("m0")}".freeze + freeze + end + + # Preemptive stamping: the stamper duck type Step takes. Sets rather than adds, so + # re-stamping a request that already carries the header replaces it. + # + # @param request [Dexpace::Request] + # @return [Dexpace::Request] with `Authorization` set to the precomputed value + def call(request) + request.with(headers: request.headers.new_builder.set("Authorization", @value).build) + end + + # Challenge answering: the same precomputed value, returned only when a Basic challenge + # was actually offered, accepted case-insensitively (the parser folds the scheme once at + # construction, so this is an equality test). The header NAME is the chain's to choose + # from `proxy:` (AUTH-25); this returns the VALUE. + # + # @param challenges [Array] + # @param _request [Dexpace::Request] unused: Basic does not depend on the request + # @param proxy [Boolean] unused here; the chain selects the header name from it + # @return [String, nil] the value, or nil when no Basic challenge was offered (AUTH-25) + def authorization_for(challenges, _request, proxy: false) # rubocop:disable Lint/UnusedMethodArgument -- the handler protocol's signature, which the chain calls uniformly + return nil unless challenges.any? { |challenge| challenge.scheme == "basic" } + + @value + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/bearer_provider.rb b/gems/dexpace-core/lib/dexpace/auth/bearer_provider.rb new file mode 100644 index 0000000..3afba5a --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/bearer_provider.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../error/invalid_argument_error" +require_relative "../async/completer" +require_relative "../async/future" +require_relative "bearer_token" +require_relative "provider_error" + +module Dexpace + module Auth + # AUTH-11: the bearer token provider is a duck type, not a class -- an object answering + # `#fetch -> BearerToken` and, optionally, `#fetch_async -> Dexpace::Async::Future`. This + # module names the two shapes for the RBS scan (`_BearerProvider`, `_AsyncBearerProvider`) + # and ships the one function AUTH-11 fixes: the default async fetch, which "mirrors the + # blocking fetch's outcome into an already-failed future" for a provider that implements + # #fetch alone, and normalises "a synchronous throw from a misbehaving async override into + # a failed future" for one that implements #fetch_async and raises out of it. + # + # So `fetch_async` NEVER raises, whatever the provider does: a raise from #fetch, a raise + # from #fetch_async, a nil token and a non-Future return all become a failed future, and + # the async stamper reads every provider through this one function. Providers MAY block + # inside #fetch and SHOULD cache internally; the stamper caches on top regardless. + module BearerProvider + extend self + + # @param provider [Object] anything answering #fetch, and optionally #fetch_async + # @return [Dexpace::Async::Future] settling with the token, or failing with the + # provider's own error or a ProviderError + def fetch_async(provider) + return mirror(provider) unless provider.respond_to?(:fetch_async) + + future = provider.fetch_async + return future if future.is_a?(Dexpace::Async::Future) + + failed(ProviderError.new("#fetch_async returned a #{future.class}, not a " \ + "Dexpace::Async::Future (AUTH-11)")) + rescue ::StandardError => error + failed(error) + end + + # Whether an object can serve as a provider at all: #fetch is the one required method. + # + # @param provider [Object] + # @return [Boolean] + def conforms?(provider) + provider.respond_to?(:fetch) + end + + private + + # The blocking fetch, mirrored into an already-settled future; a raise from #fetch lands + # in fetch_async's rescue, as a raise from #fetch_async does. + def mirror(provider) + token = provider.fetch + return failed(ProviderError.new("the provider returned no token (AUTH-35)")) if token.nil? + + completer = Dexpace::Async::Completer.new + completer.fulfil(token) + completer.future + end + + def failed(error) + completer = Dexpace::Async::Completer.new + completer.fail(error) + completer.future + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb b/gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb new file mode 100644 index 0000000..a48e7e5 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../http/headers" +require_relative "../clock" +require_relative "bearer_token" +require_relative "bearer_provider" +require_relative "provider_error" + +module Dexpace + module Auth + # AUTH-11 (sync half), AUTH-34, AUTH-35, AUTH-36's cache half: the synchronous bearer + # stamper -- one cached token per credential, refreshed through the provider a configurable + # margin before its expiry, with single-flight coordination so concurrent requests racing + # on a missing or expiring token cost at most one fetch. + # + # The hot path takes no lock (XCUT-12): #call reads @token, one frozen BearerToken published + # by a write under @lock, and stamps it when it is fresh. Safe by publication rather than by + # the GVL -- the reference is written once, under the mutex, and the object it points to is + # immutable -- so it holds on every Ruby. The slow path acquires @lock, re-checks (the + # double-check), and calls the provider WHILE HOLDING IT: the one sanctioned exception to + # "never hold a mutex across a suspension point" (XCUT-12's own text), because serialising + # the fetch is exactly what single-flight means, and the lock is this credential's own, so + # it can serialise nothing else. + # + # AUTH-35's rejections -- a nil token, a token already expired at fetch time with NO margin, + # a non-BearerToken -- raise ProviderError from inside the lock with @token untouched, and a + # provider that raises propagates its own error the same way; nothing is cached on any of + # those paths, so a later request retries (AUTH-11). + class BearerStamper + # AUTH-34's default refresh margin, in seconds. + DEFAULT_REFRESH_MARGIN = 30 + + # @param provider [Object] anything answering #fetch -> BearerToken + # @param clock [_Clock] the time seam; Clock::SYSTEM by default + # @param refresh_margin [Numeric] seconds before expiry at which a token is refreshed + def initialize(provider:, clock: Clock::SYSTEM, refresh_margin: DEFAULT_REFRESH_MARGIN) + unless BearerProvider.conforms?(provider) + raise InvalidArgumentError, "provider must answer #fetch (AUTH-11)" + end + unless refresh_margin.is_a?(::Numeric) && !refresh_margin.negative? + raise InvalidArgumentError, "refresh_margin must be a non-negative number of seconds" + end + + @provider = provider + @clock = clock + @refresh_margin = refresh_margin + @lock = ::Thread::Mutex.new + @token = nil #: BearerToken? + end + + # The stamper duck type Step takes: `Authorization: Bearer `, set rather than + # added so a re-stamp replaces. + # + # @param request [Dexpace::Request] + # @return [Dexpace::Request] + # @raise [ProviderError] on a misbehaving provider result (AUTH-35) + def call(request) + token = @token # the hot path: no lock (XCUT-12) + token = refresh! if token.nil? || token.expired?(now: @clock.now, margin: @refresh_margin) + request.with(headers: request.headers.new_builder.set("Authorization", header(token)).build) + end + + # AUTH-36's cache half: clear the cached token iff its stamped header value is exactly + # the rejected one -- compare-and-clear under the lock, on the HEADER VALUE and never on + # credential equality. A token another request already refreshed no longer matches, and + # survives; the Boolean says which happened, so the step can re-stamp from the cache in + # that case and fetch afresh in the other. + # + # @param rejected_header [String] the Authorization value the 401 rejected + # @return [Boolean] whether the cached token was the rejected one and was evicted + def evict_if_matches(rejected_header) + @lock.synchronize do + token = @token + next false if token.nil? || header(token) != rejected_header + + @token = nil + true + end + end + + private + + def header(token) = "Bearer #{token.token}" + + # The slow path, and XCUT-12's sanctioned lock-across-fetch. + def refresh! + @lock.synchronize do + token = @token + return token if !token.nil? && !token.expired?(now: @clock.now, margin: @refresh_margin) + + fetched = validate(@provider.fetch) + @token = fetched # written only on success: a raise above leaves the cache untouched + end + end + + # AUTH-35: non-nil, a BearerToken, and not already expired with NO margin. + def validate(fetched) + raise ProviderError, "the provider returned no token (AUTH-35)" if fetched.nil? + unless fetched.is_a?(BearerToken) + raise ProviderError, "the provider returned a #{fetched.class}, not a BearerToken" + end + if fetched.expired?(now: @clock.now, margin: 0) + raise ProviderError, "the provider returned a token already expired at fetch time" + end + + fetched + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/bearer_token.rb b/gems/dexpace-core/lib/dexpace/auth/bearer_token.rb new file mode 100644 index 0000000..d0b2740 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/bearer_token.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "validation" + +module Dexpace + module Auth + # AUTH-8, AUTH-9, AUTH-10: a bearer token and its optional expiry. Value equality over the + # REAL token and expiry is Data's own and is AUTH-8's text -- the override lives only in the + # three renderings, never in ==, eql? or hash, and the real fields are never touched to + # achieve it. + # + # Three renderings, not two. Ruby interpolation calls #to_s and a debugger #inspect; but + # `pp` does not call #inspect on a Data -- pp.rb gives Data its own #pretty_print, which + # walks the members directly (verified on 3.2.11, 3.4.10 and 4.0.6: a Data with #inspect + # overridden still pretty-prints as `#`). So #pretty_print is the + # third override, and it is the one a reader will not think to write. + class BearerToken < ::Data.define(:token, :expiry) + include Model + + private_class_method :new + + # The validating factory; #with routes through it. + # + # @param token [String] non-blank (AUTH-9) + # @param expiry [Time, nil] nil means the token never locally expires (AUTH-10) + # @return [BearerToken] + def self.build(token:, expiry: nil) + new(token: token, expiry: expiry) + end + + def initialize(token:, expiry:) + text = Validation.non_blank!("token", token) + unless expiry.nil? || expiry.is_a?(::Time) + raise InvalidArgumentError, "expiry must be a Time or nil" + end + + super(token: Model.frozen_string(text), expiry: expiry) + end + + # AUTH-10: expired at `now` with margin `margin` iff the expiry is set and (now + margin) + # is strictly after it. A non-expiring token is never expired, whatever the margin. + # + # @param now [Time] the reference instant, a Clock#now reading + # @param margin [Numeric] seconds of grace, added to `now` + # @return [Boolean] + def expired?(now:, margin: 0) + limit = expiry + return false if limit.nil? + + (now + margin) > limit + end + + # @return [String] the token redacted, the expiry visible + def to_s = "BearerToken(token=#{REDACTED}, expiry=#{expiry.inspect})" + + # @return [String] the token redacted, the expiry visible + def inspect = "#" + + # The rendering `pp` uses; see the class comment. + # + # @param printer [PP] + # @return [void] + def pretty_print(printer) + printer.text(inspect) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/challenge.rb b/gems/dexpace-core/lib/dexpace/auth/challenge.rb new file mode 100644 index 0000000..a498a8a --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/challenge.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" + +module Dexpace + module Auth + # AUTH-12: one parsed RFC 7235 challenge -- a lower-cased scheme and a frozen Hash of + # lower-cased parameter names to verbatim (unquoted, unescaped) String values. A token68 + # value sits under the synthetic key TOKEN68. The folding happens HERE, at construction, + # rather than in the parser alone, so a challenge a test or a caller builds by hand meets a + # handler in the same shape a parsed one does (`Challenge.build(scheme: "BASIC")` has the + # scheme "basic"); the fold is the bare, locale-independent downcase (HTTP-13). + class Challenge < ::Data.define(:scheme, :params) + include Model + + private_class_method :new + + # The synthetic parameter key a token68 value is recorded under (AUTH-12). + TOKEN68 = "token68" + + # The validating factory; #with routes through it. + # + # @param scheme [String] the auth-scheme token, in any case + # @param params [Hash{String => String}] parameter names in any case, values verbatim + # @return [Challenge] + def self.build(scheme:, params: {}) + new(scheme: scheme, params: params) + end + + def initialize(scheme:, params:) + name = Model.required!("scheme", scheme) + unless name.is_a?(::String) && !name.strip.empty? + raise InvalidArgumentError, "scheme must be a non-empty String" + end + raise InvalidArgumentError, "params must be a Hash" unless params.is_a?(::Hash) + + super(scheme: name.downcase.freeze, params: Model.own(fold(params))) + end + + # @return [String, nil] the token68 value, when the challenge carried one + def token68 = params[TOKEN68] + + private + + # Every key a String folded once; every value a String, verbatim. + def fold(params) + params.to_h do |key, value| + unless key.is_a?(::String) && value.is_a?(::String) + raise InvalidArgumentError, "challenge params are String names to String values" + end + + [key.downcase, value] + end + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/challenge_handler_chain.rb b/gems/dexpace-core/lib/dexpace/auth/challenge_handler_chain.rb new file mode 100644 index 0000000..292f595 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/challenge_handler_chain.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../http/headers" +require_relative "challenges" + +module Dexpace + module Auth + # AUTH-23, AUTH-25: the composing challenge handler. Parses the header value once and tries + # each handler in declaration order, returning the first non-nil header VALUE; nil when + # none can satisfy any offered challenge -- no header, never an empty one. The handler + # protocol is one method, `#authorization_for(challenges, request, proxy:) -> String | nil` + # (6c's P6-2): behaviourally identical to a can-handle query plus a build call, at half + # the public surface. Callers order stronger schemes first (Digest before Basic); the + # chain reorders nothing. + # + # #as_challenge_hook is the adapter that makes the chain reachable from the pillar step, + # and it is where AUTH-25's header NAME -- Authorization for a WWW-Authenticate challenge, + # Proxy-Authorization for a Proxy-Authenticate one, chosen by the explicit `proxy:` flag + # and never by inspecting the response -- is written onto a request. It is never installed + # by default: AUTH-30's "the default hook MUST yield no replacement" is Step::NO_REPLACEMENT, + # and a caller opts in by passing this. + class ChallengeHandlerChain + # @param handlers [Array<#authorization_for>] tried in this order; copied at construction + # so later caller mutation cannot reorder it (AUTH-23) + def initialize(handlers) + list = Model.required!("handlers", handlers) + raise InvalidArgumentError, "handlers must be an Array" unless list.is_a?(::Array) + unless list.all? { |handler| handler.respond_to?(:authorization_for) } + raise InvalidArgumentError, "every handler must answer #authorization_for" + end + + @handlers = list.dup.freeze + freeze + end + + # @param header_value [String, nil] the WWW-Authenticate or Proxy-Authenticate value + # @param request [Dexpace::Request] the request being answered + # @param proxy [Boolean] whether the challenge was a proxy's + # @return [String, nil] the first handler's header value, or nil (AUTH-25) + def authorization_for(header_value, request, proxy: false) + challenges = Challenges.parse(header_value) + @handlers.each do |handler| + value = handler.authorization_for(challenges, request, proxy: proxy) + return value unless value.nil? + end + nil + end + + # AUTH-25: the header name, from the flag alone. + # + # @param proxy [Boolean] + # @return [String] + def header_name(proxy:) + proxy ? "Proxy-Authorization" : "Authorization" + end + + # AUTH-30's hook contract is "a replacement request or nil"; a handler returns a header + # value; this is where the two meet. The replacement SETS the header, so a preemptive + # stamp on the rejected request is replaced rather than joined by a second value. + # + # @param proxy [Boolean] which header the hook writes + # @return [Proc] a three-argument hook: (header value, request, response) -> Request | nil + def as_challenge_hook(proxy: false) + lambda do |header_value, request, _response| + value = authorization_for(header_value, request, proxy: proxy) + next nil if value.nil? + + headers = request.headers.new_builder.set(header_name(proxy: proxy), value).build + request.with(headers: headers) + end + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/challenges.rb b/gems/dexpace-core/lib/dexpace/auth/challenges.rb new file mode 100644 index 0000000..2142d64 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/challenges.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "strscan" + +require_relative "../auth" +require_relative "challenge" + +module Dexpace + module Auth + # AUTH-12, AUTH-13: the RFC 7235 challenge-list parser, as a StringScanner-driven state + # machine and never a regexp over the grammar -- the grammar is not regular (a quoted-string + # may hold the list's own delimiters), and a hostile WWW-Authenticate must not be able to + # drive a backtracking engine (design §6.3). The eight patterns it does use are fixed + # character classes with no alternation inside a repetition, compiled with the tree's + # per-pattern timeout; every loop iteration consumes at least one byte, so the parse is + # linear in the input and the suite measures it rather than trusting the claim. + # + # Public, like Dexpace::HTTPDate: a caller writing a challenge handler for a scheme this SDK + # does not implement needs the same lenient parser, and it carries no credential-shaped + # state. + # + # The grammar's one real ambiguity is settled here once. `1#challenge` is a comma-separated + # list whose elements are `auth-scheme [ 1*SP ( token68 / #auth-param ) ]`, so a comma may + # separate two PARAMETERS of one challenge or two CHALLENGES, and the only thing that tells + # them apart is what follows the next token: `name=` continues the current challenge, + # a bare token opens a new one. Empty list elements (`,,`) are skipped, as RFC 7230 §7 + # requires of any recipient. A token68 is taken only when it runs to a list boundary (the + # end of input, or optional whitespace and a comma), because `realm=` is also a token68 + # prefix and `Digest realm="r"` must not lose its realm to that reading. + # + # Leniency (AUTH-13): the parser never raises. Malformed input -- a value that is neither a + # token nor a quoted-string, a parameter before any scheme, a bare token where a parameter + # was expected, a character no token starts with -- is skipped to the next TOP-LEVEL comma, + # walking quoted strings so a comma inside one is not mistaken for the boundary; parameters + # parsed before the malformed tail stay on the emitted challenge; an unterminated + # quoted-string takes everything to the end of input as its value. + module Challenges + extend self + + # RFC 7230's tchar set: the auth-scheme and every parameter name and token value. + TOKEN = Regexp.new("[!#$%&'*+\\-.^_`|~0-9A-Za-z]+", timeout: 1.0) + # RFC 7235's token68: TOKEN's letters and digits plus `-._~+/`, then base64 padding, + # which TOKEN excludes -- so `Bearer dGhl…==` is not readable as a parameter. + TOKEN68 = Regexp.new("[A-Za-z0-9\\-._~+/]+=*", timeout: 1.0) + # One or more list separators with their whitespace: what sits between two elements. + SEPARATORS = Regexp.new("[ \\t]*,[ \\t,]*", timeout: 1.0) + # Whitespace, then a list boundary: a comma or the end of input. Checked, never consumed. + BOUNDARY = Regexp.new("[ \\t]*(?:,|\\z)", timeout: 1.0) + # A parameter's `=` with the bad whitespace RFC 7235 tolerates on either side. + EQUALS = Regexp.new("[ \\t]*=[ \\t]*", timeout: 1.0) + # The whitespace between the scheme and what follows it. + SPACES = Regexp.new("[ \\t]+", timeout: 1.0) + # Optional whitespace at the start of an element, which recovery leaves behind. + OWS = Regexp.new("[ \\t]*", timeout: 1.0) + # The opening quote of a quoted-string. + QUOTE = Regexp.new("\"", timeout: 1.0) + private_constant :TOKEN, :TOKEN68, :SEPARATORS, :BOUNDARY, :EQUALS, :SPACES, :OWS, :QUOTE + + # The parse itself: `nil`, blank input and an input of nothing but separators all yield + # an empty list. The state is one open challenge (`current`) and whether the scanner + # stands just past a list separator (`boundary`), which is what decides whether a bare + # token opens a challenge or is a malformed tail. + # + # @param header_value [String, nil] a WWW-Authenticate or Proxy-Authenticate value; the + # values of a repeated header are joined with ", " before they reach here (RFC 7235 §4.1) + # @return [Array] in wire order, frozen + def parse(header_value) + none = [] #: Array[Challenge] + return none.freeze if header_value.nil? + + # A value whose bytes are invalid under its own tag -- a Latin-1 realm a transport tagged + # UTF-8 -- makes StringScanner#scan and String#downcase raise ArgumentError, and AUTH-13 + # says this never raises: such a value is scanned as bytes (verified on 3.2.11, 3.4.10 + # and 4.0.6). A valid one keeps its tag, so its values come back as the caller's Strings. + text = header_value.valid_encoding? ? header_value : header_value.b + return none.freeze if text.strip.empty? + + parser = Parser.new(text) + parser.run + parser.challenges.freeze + end + + # The state machine, one instance per parse so the module stays stateless. A private + # class rather than a set of module functions threading three arguments. + class Parser + attr_reader :challenges + + def initialize(input) + @scanner = ::StringScanner.new(input) + @challenges = [] + @scheme = nil #: String? + @params = {} #: Hash[String, String] + @boundary = true + end + + # Every iteration consumes at least one byte: each branch scans, skips or recovers. + def run + until @scanner.eos? + @scanner.skip(OWS) + @boundary = true if @scanner.skip(SEPARATORS) + break if @scanner.eos? + + step + end + emit + end + + private + + # One list element, or one malformed tail: a parameter, a scheme at a list boundary, or + # -- a character no token starts with, a bare token where a parameter was expected -- + # a recovery. + def step + name = @scanner.scan(TOKEN) + if !name.nil? && @scanner.skip(EQUALS) + parameter(name) + elsif !name.nil? && @boundary + open_challenge(name) + else + recover + end + end + + # `name=value`: a parameter of the open challenge. Before any scheme it is malformed. + def parameter(name) + return recover if @scheme.nil? + + value = scan_value + return recover if value.nil? + + @params[name] = value # folded once, at Challenge.build + @boundary = false + # A value must be followed by a boundary; anything else is the malformed tail. + recover unless @scanner.match?(BOUNDARY) + end + + # A scheme opens a challenge, closing the previous one. What follows the whitespace is + # a token68 only when it runs to a boundary; otherwise the parameters (or the malformed + # tail) are read by the next iterations. + def open_challenge(name) + emit + @scheme = name # folded once, at Challenge.build + @params = {} + @boundary = false + @scanner.skip(SPACES) + position = @scanner.pos + bare = @scanner.scan(TOKEN68) + if bare && @scanner.match?(BOUNDARY) + @params[Challenge::TOKEN68] = bare + else + @scanner.pos = position + end + end + + # A token, or an unquoted, unescaped quoted-string; nil when the input is neither. + def scan_value + return @scanner.scan(TOKEN) unless @scanner.skip(QUOTE) + + value = +"" + until @scanner.eos? + char = @scanner.getch + if char == "\\" && !@scanner.eos? + value << @scanner.getch.to_s + elsif char == '"' + return value.freeze + else + value << char.to_s + end + end + value.freeze # unterminated: the value runs to the end of input (AUTH-13) + end + + # AUTH-13: skip to the next TOP-LEVEL comma, walking any quoted-string on the way so a + # comma inside one is not read as the boundary. Consumes at least one byte, or ends + # the input. + def recover + until @scanner.eos? + char = @scanner.getch + if char == '"' + skip_quoted + elsif char == "," + break + end + end + @boundary = true + end + + # Inside a quoted-string during recovery: to the closing quote, honouring escapes. + def skip_quoted + until @scanner.eos? + char = @scanner.getch + if char == "\\" + @scanner.getch + elsif char == '"' + return + end + end + end + + # Close the open challenge, if any, onto the list. + def emit + scheme = @scheme + return if scheme.nil? + + @challenges << Challenge.build(scheme: scheme, params: @params) + @scheme = nil + @params = {} + end + end + private_constant :Parser + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/descriptor.rb b/gems/dexpace-core/lib/dexpace/auth/descriptor.rb new file mode 100644 index 0000000..beafc95 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/descriptor.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "scheme" +require_relative "requirement" + +module Dexpace + module Auth + # AUTH-3: a non-empty ordered list of requirements in caller preference order. Immutable in + # and immutable out: the list is copied and frozen once at construction and #requirements + # returns that same frozen reference at every call (HTTP-5's pattern, applied for the same + # reason it is applied to every other model collection). A Requirement is itself deeply + # frozen, so Model.own keeps each element's identity and freezes only the new list. + class Descriptor < ::Data.define(:requirements) + include Model + + private_class_method :new + + # The validating factory; keyword-shaped so Model#with can route a derivation through it. + # + # @param requirements [Array] at least one + # @return [Descriptor] + # @raise [Dexpace::InvalidArgumentError] on an empty list, or on an element that is not a + # Requirement + def self.build(requirements:) + new(requirements: requirements) + end + + def initialize(requirements:) + list = Model.required!("requirements", requirements) + raise InvalidArgumentError, "requirements must be an Array" unless list.is_a?(::Array) + raise InvalidArgumentError, "requirements must be non-empty (AUTH-3)" if list.empty? + unless list.all?(Requirement) + raise InvalidArgumentError, "every requirement must be a Dexpace::Auth::Requirement" + end + + super(requirements: Model.own(list)) + end + + # AUTH-3: true iff any requirement's scheme is the NO_AUTH sentinel. + # + # @return [Boolean] + def allows_anonymous? + requirements.any? { |requirement| requirement.scheme == Scheme::NO_AUTH } + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb b/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb new file mode 100644 index 0000000..d59766a --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "digest" +require "securerandom" + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../http/header_syntax" +require_relative "../http/percent_encoding" +require_relative "../bounded_map" +require_relative "password_credential" +require_relative "challenge" +require_relative "unencodable_credential_error" + +module Dexpace + module Auth + # AUTH-15–AUTH-24: RFC 7616 Digest, challenge-driven by construction -- there is no + # preemptive #call, because a Digest response needs the server's nonce. Reached from the + # pillar step only through ChallengeHandlerChain#as_challenge_hook. + # + # The hashes are ::Digest::MD5 and ::Digest::SHA256, never OpenSSL::Digest (design §6.3); + # the cnonce is sixteen SecureRandom bytes, hex-encoded, never Random (AUTH-20, XCUT-21); + # every hash input is BINARY before it reaches a hasher (HTTP-13's outbound rule). + # + # The per-nonce counter store is this instance's own BoundedMap (6c's R11, P6-4): one per + # handler, constructed here, never shared, its cap an ordinary keyword defaulting to + # AUTH-19's 1024 and read from no configuration chain -- the handler is explicitly + # constructed by whoever assembles the pipeline, so the knob was never ambient. The + # reference is BARE and unqualified, resolved from this full-nesting `module Dexpace; + # module Auth; class DigestHandler` body: BoundedMap is a private_constant of Dexpace, so + # `Dexpace::BoundedMap` raises NameError even from inside Dexpace and the compact `module + # Dexpace::Auth::…` form cannot see it at all (execution-context/b58728da; verified on + # 3.2.11, 3.4.10 and 4.0.6). The increment is BoundedMap#update, one read-modify-write under + # the map's own mutex, which is what makes AUTH-24's non-duplicated counts true. + # + # Two things the requirements leave to the port, decided here and recorded as 6c's + # as-built rows. First, a non-ASCII username goes on the wire as RFC 7616 §3.4's + # `username*=UTF-8''…` (RFC 8187), because HTTP-18's outbound grammar refuses a byte above + # 0x7F in a header value and the quoted form cannot carry it; the hash still uses the raw + # username. Second, a challenge whose realm, nonce or opaque cannot be echoed under that + # grammar is UNSATISFIABLE (AUTH-16, AUTH-25): RFC 7616 defines no encoded form for those + # three, so the handler declines rather than raise from the header write. + class DigestHandler # rubocop:disable Metrics/ClassLength -- one algorithm family, one class: selection, the hash chain, the counter and the rendering are one RFC and split by nothing but method + # AUTH-15's closed algorithm set, in the RFC's canonical spelling (AUTH-22). + ALGORITHMS = %w[MD5 MD5-sess SHA-256 SHA-256-sess].freeze + # AUTH-19's default cap on distinct nonces tracked. + DEFAULT_CAP = 1024 + + # The base algorithm of each supported name to its hasher. + HASHES = { "MD5" => ::Digest::MD5, "SHA-256" => ::Digest::SHA256 }.freeze + # The challenge parameters echoed verbatim into the response, which must therefore pass + # the outbound header grammar. + ECHOED = %w[realm nonce opaque].freeze + private_constant :HASHES, :ECHOED + + # The values one response is rendered from; private, so #render takes one argument. + class Computed < ::Data.define(:challenge, :algorithm, :uri, :cnonce, :nc, :qop, :response) + end + private_constant :Computed + + # @param credential [PasswordCredential] non-empty username and password (AUTH-14's rule) + # @param preference [Array] the algorithms to prefer, most preferred first; a + # subset of ALGORITHMS + # @param cap [Integer] AUTH-19's bound on distinct nonces tracked + # @param cnonce_source [#hex] the random source; SecureRandom, and never Random + # @raise [Dexpace::InvalidArgumentError] on an empty credential field, an unsupported + # algorithm in the preference, or a source with no #hex + def initialize(credential, preference: ALGORITHMS, cap: DEFAULT_CAP, + cnonce_source: ::SecureRandom) + @credential = credential!(credential) + @preference = preference!(preference) + unless cnonce_source.respond_to?(:hex) + raise InvalidArgumentError, "cnonce_source must answer #hex(bytes)" + end + + @cnonce_source = cnonce_source + @nonces = BoundedMap.new(cap: cap) # per handler, never shared (R11) + freeze + end + + # AUTH-23's one-method handler protocol (6c's P6-2): the header VALUE for the first + # satisfiable challenge by the configured preference, or nil when none is (AUTH-25). + # + # @param challenges [Array] + # @param request [Dexpace::Request] its method and request-target enter the hash + # @param proxy [Boolean] unused here; the chain selects the header name from it + # @return [String, nil] + # @raise [UnencodableCredentialError] when the username or password cannot be encoded + # under the challenge's encoding (AUTH-21, R10) + def authorization_for(challenges, request, proxy: false) # rubocop:disable Lint/UnusedMethodArgument -- the handler protocol's signature, which the chain calls uniformly + challenge = select(challenges) + return nil if challenge.nil? + + render(compute(challenge, request)) + end + + private + + def credential!(credential) + unless credential.is_a?(PasswordCredential) + raise InvalidArgumentError, "a Dexpace::Auth::PasswordCredential is required" + end + if credential.username.empty? || credential.password.empty? + raise InvalidArgumentError, "username and password must be non-empty (AUTH-14)" + end + + credential + end + + def preference!(preference) + list = Model.required!("preference", preference) + unless list.is_a?(::Array) && !list.empty? && list.all? { |name| ALGORITHMS.include?(name) } + raise InvalidArgumentError, + "preference must be a non-empty subset of #{ALGORITHMS.join(", ")} (AUTH-15)" + end + + Model.own(list) + end + + # AUTH-16: filter to the satisfiable challenges, then walk the PREFERENCE list rather + # than the challenge list, which is what makes "independent of the order challenges + # arrived in" literal rather than incidental. + def select(challenges) + satisfiable = challenges.select { |challenge| satisfiable?(challenge) } + @preference.each do |algorithm| + found = satisfiable.find { |challenge| algorithm_of(challenge) == algorithm } + return found unless found.nil? + end + nil + end + + # AUTH-16's four conditions, plus the echo condition the class comment states. + def satisfiable?(challenge) + params = challenge.params + challenge.scheme == "digest" && params.key?("realm") && params.key?("nonce") && + (params["qop"].nil? || qop_auth?(challenge)) && !algorithm_of(challenge).nil? && + echoable?(params) + end + + # The three echoed values must pass the outbound header grammar, byte for byte. + def echoable?(params) + ECHOED.all? do |key| + value = params[key] + value.nil? || HeaderSyntax.valid_outbound_value?(value) + end + end + + # The challenge's algorithm in the canonical spelling; nil when unsupported. Absent + # defaults to MD5 (AUTH-16); the token is matched with a bare, ASCII-only fold, never + # casecmp? (Dexpace/NoLocaleCaseFold). + def algorithm_of(challenge) + token = challenge.params["algorithm"] + return "MD5" if token.nil? + + wanted = token.b.downcase + ALGORITHMS.find { |name| name.downcase == wanted } + end + + # AUTH-15, AUTH-16: qop is a comma-separated TOKEN LIST and the comparison is + # token-exact -- `"auth-int".include?("auth")` is true, so a substring test would accept + # exactly the auth-int-only challenge AUTH-15 requires be declined. + def qop_auth?(challenge) + challenge.params["qop"].to_s.b.split(",").any? { |token| token.strip.downcase == "auth" } + end + + # AUTH-17: the values one response is rendered from, for one challenge and one request. + def compute(challenge, request) + algorithm = algorithm_of(challenge).to_s + cnonce = @cnonce_source.hex(16) # AUTH-20: 128 bits from a CSPRNG + nonce = challenge.params.fetch("nonce") + computed = Computed.new(challenge: challenge, algorithm: algorithm, cnonce: cnonce, + uri: request_target(request), nc: next_count(nonce), + qop: qop_auth?(challenge) ? "auth" : nil, response: nil,) + computed.with(response: response_for(computed, request.method.to_s)) + end + + # AUTH-17: HA1, HA2 over the method and the request-target, then the response. + def response_for(computed, method) + hasher = HASHES.fetch(computed.algorithm.delete_suffix("-sess")) + ha1 = ha1_for(computed, hasher) + ha2 = hasher.hexdigest(join(method, computed.uri)) + response_digest(hasher, computed, ha1, ha2) + end + + # The qop=auth response, or the legacy RFC 2069 no-qop one. + def response_digest(hasher, computed, ha1, ha2) + nonce = computed.challenge.params.fetch("nonce") + return hasher.hexdigest(join(ha1, nonce, ha2)) if computed.qop.nil? + + hasher.hexdigest(join(ha1, nonce, computed.nc, computed.cnonce, computed.qop, ha2)) + end + + # H(username:realm:password), each component materialised under its own field name so + # the typed failure can say which one could not be encoded (R10), then session-keyed + # with the nonce and cnonce for a -sess algorithm. The charset token is compared with a + # bare, ASCII-only fold. + def ha1_for(computed, hasher) + params = computed.challenge.params + ha1 = hasher.hexdigest(join(*credential_bytes(params))) + return ha1 unless computed.algorithm.end_with?("-sess") + + hasher.hexdigest(join(ha1, params.fetch("nonce"), computed.cnonce)) + end + + # The three HA1 components as BINARY, under AUTH-21's encoding for this challenge. + def credential_bytes(params) + utf8 = params["charset"].to_s.b.downcase == "utf-8" + [materialize(@credential.username, :username, utf8), + materialize(params.fetch("realm"), :realm, utf8), + materialize(@credential.password, :password, utf8),] + end + + # AUTH-21: UTF-8 when the challenge advertises charset=UTF-8, ISO-8859-1 otherwise -- + # and the Latin-1 branch RAISES the typed failure, never `:replace` (R10, P6-1). + def materialize(text, field, utf8) + return text.encode(::Encoding::UTF_8).b if utf8 + + text.encode(::Encoding::ISO_8859_1).b + rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError => error + raise UnencodableCredentialError.new(field: field, encoding: "ISO-8859-1"), cause: error + end + + # Every hash input is BINARY, so the joiner is too. + def join(*parts) + parts.map(&:b).join(":".b) + end + + # AUTH-18, AUTH-19, AUTH-24: one read-modify-write under the map's own mutex; a nonce + # the map has not seen (including one the drain evicted) starts at 1, rendered as exactly + # 8 lower-case hex digits from the low 32 bits. + def next_count(nonce) + count = @nonces.update(nonce) { |current| (current || 0) + 1 } + format("%08x", count & 0xFFFFFFFF) + end + + # AUTH-22: the request-target form -- the raw path, "/" when empty, then "?" and the raw + # query when there is one. + def request_target(request) + path = request.url.path + path = "/" if path.nil? || path.empty? + query = request.url.query + query.nil? ? path : "#{path}?#{query}" + end + + # AUTH-22: username, realm, nonce, uri, response, cnonce and opaque quoted with + # backslash-escaping; qop, nc and algorithm bare, the algorithm in its full RFC spelling; + # cnonce, nc and qop only when qop was negotiated; opaque only when the challenge sent it. + def render(computed) + parts = [username_field, *echoed(computed), *negotiated(computed), + quoted("response", computed.response.to_s), *opaque(computed),] + "Digest #{parts.join(", ")}" + end + + # The realm and nonce echoed from the challenge, the uri and the algorithm. + def echoed(computed) + params = computed.challenge.params + [quoted("realm", params.fetch("realm")), quoted("uri", computed.uri), + "algorithm=#{computed.algorithm}", quoted("nonce", params.fetch("nonce")),] + end + + # The three fields that exist only when qop was negotiated. + def negotiated(computed) + return [] if computed.qop.nil? + + ["nc=#{computed.nc}", quoted("cnonce", computed.cnonce), "qop=#{computed.qop}"] + end + + # Echoed only when the challenge sent one. + def opaque(computed) + value = computed.challenge.params["opaque"] + value.nil? ? [] : [quoted("opaque", value)] + end + + def quoted(name, value) = %(#{name}="#{quote(value)}") + + # RFC 7616 §3.4: `username` as a quoted-string when the outbound grammar can carry it, + # `username*` in RFC 8187's UTF-8 form otherwise. PercentEncoding's RFC 3986 unreserved + # set is a subset of RFC 8187's attr-char, so its output is valid there. + def username_field + name = @credential.username + if HeaderSyntax.valid_outbound_value?(name) + quoted("username", name) + else + "username*=UTF-8''#{PercentEncoding.encode_component(name.encode(::Encoding::UTF_8))}" + end + end + + # The two characters a quoted-string escapes, without a regexp; the block form, because a + # replacement String has its own backslash grammar. + def quote(value) + value.gsub("\\") { "\\\\" }.gsub('"') { '\\"' } + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/https_required_error.rb b/gems/dexpace-core/lib/dexpace/auth/https_required_error.rb new file mode 100644 index 0000000..c415cbc --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/https_required_error.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../error" + +module Dexpace + module Auth + # AUTH-28: the AUTH step refused to attach a credential to a request whose URL scheme is + # not `https`. Raised BEFORE any token fetch or header write, and only on a path where a + # credential would be attached -- a cross-origin redirect re-issue skips the guard (AUTH-29) + # because nothing is attached there. The message names the concrete step and the offending + # scheme, as the requirement asks; both are members too. + class HTTPSRequiredError < ::StandardError + include Dexpace::Error + + # @return [String] the request URL's scheme, as parsed + attr_reader :scheme + # @return [String] the concrete step's class name + attr_reader :step + + # @param scheme [String] + # @param step [String] + def initialize(scheme:, step:) + @scheme = scheme + @step = step + super("#{step} refuses to attach a credential to a #{scheme.inspect} request: " \ + "credentials are stamped over HTTPS only (AUTH-28)") + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/key_credential.rb b/gems/dexpace-core/lib/dexpace/auth/key_credential.rb new file mode 100644 index 0000000..2804d04 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/key_credential.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../http/header_name" +require_relative "validation" + +module Dexpace + module Auth + # AUTH-8, AUTH-9, AUTH-26: a static API key and the header it is stamped into. A plain + # class, not a Data, and deliberately so: AUTH-8 gives the two key credentials reference + # identity -- "two instances with identical fields are NOT equal" -- so no ==, eql? or hash + # is defined and Ruby's identity default is what a caller gets (design §6.3). With no + # derivation and no value equality there is nothing for Model to add, so .new stays public + # and validates in place; the instance freezes itself at the end of construction. + # + # #prefix and #key_value are the pair KeyStamper is written against, for both key types. + class KeyCredential + # @return [String] the header the key is stamped into; "Authorization" by default + attr_reader :header_name + # @return [String, nil] what precedes the key, separated by one space (AUTH-26) + attr_reader :prefix + + # @param api_key [String] non-blank (AUTH-9) + # @param header_name [String] a valid header name (HTTP-17) + # @param prefix [String, nil] a non-blank prefix, or nil for none + def initialize(api_key:, header_name: "Authorization", prefix: nil) + @api_key = Model.frozen_string(Validation.non_blank!("api_key", api_key)) + @header_name = Model.frozen_string(HeaderName.of(header_name).original) + @prefix = prefix.nil? ? nil : Model.frozen_string(Validation.non_blank!("prefix", prefix)) + freeze + end + + # The secret, for the stamper. Never rendered by #to_s, #inspect or pretty-print. + # + # @return [String] + def key_value = @api_key + + # @return [String] the key redacted, the header name and prefix visible + def to_s + "KeyCredential(api_key=#{REDACTED}, header_name=#{@header_name.inspect}, " \ + "prefix=#{@prefix.inspect})" + end + + # @return [String] the key redacted, the header name and prefix visible + def inspect + "#" + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/key_stamper.rb b/gems/dexpace-core/lib/dexpace/auth/key_stamper.rb new file mode 100644 index 0000000..7acd589 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/key_stamper.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../error/invalid_argument_error" +require_relative "../http/headers" +require_relative "../http/header_syntax" + +module Dexpace + module Auth + # AUTH-26: static key-credential stamping. Constructed against a KeyCredential or a + # NamedKeyCredential -- anything answering #header_name, #prefix and #key_value -- it + # computes the header value once and is stateless after construction: #call reads two frozen + # Strings and writes one header. The prefix, when there is one, precedes the key with + # exactly one space (`SharedAccessKey `). + # + # The write is a SET, not an add, through the phase-1 idiom for a derived request + # (`request.with(headers: request.headers.new_builder.set(…).build)`): re-stamping a request + # that already carries the header replaces the value, where Request::Builder#header would + # append a second one. The value is checked against the outbound header grammar here, so a + # key that could never be sent fails at construction rather than at every request. + class KeyStamper + # @param credential [KeyCredential, NamedKeyCredential] + # @raise [Dexpace::InvalidArgumentError] when the credential lacks the three readers, or + # its rendered value cannot be carried by a header (HTTP-18) + def initialize(credential) + unless %i[header_name prefix key_value].all? { |reader| credential.respond_to?(reader) } + raise InvalidArgumentError, + "a key credential answering #header_name, #prefix and #key_value is required" + end + + prefix = credential.prefix + value = prefix.nil? ? credential.key_value : "#{prefix} #{credential.key_value}" + unless HeaderSyntax.valid_outbound_value?(value) + raise InvalidArgumentError, + "the key for header #{credential.header_name} contains a byte no outbound " \ + "header value may carry (HTTP-18)" + end + + @header_name = credential.header_name + @value = value.frozen? ? value : value.dup.freeze + freeze + end + + # @param request [Dexpace::Request] + # @return [Dexpace::Request] with the credential's header set to the key value + def call(request) + request.with(headers: request.headers.new_builder.set(@header_name, @value).build) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/named_key_credential.rb b/gems/dexpace-core/lib/dexpace/auth/named_key_credential.rb new file mode 100644 index 0000000..1dbda47 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/named_key_credential.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../http/header_name" +require_relative "validation" + +module Dexpace + module Auth + # AUTH-8, AUTH-9, AUTH-26: a named key -- a key NAME that identifies which key is in use + # (a shared-access-key name, an access-key id) beside the secret key itself. Reference + # identity, a public validating .new and self-freezing, for the reasons KeyCredential gives. + # + # The name stays visible in the renderings: AUTH-8 lists "key name" among the non-secret + # fields that MAY remain visible, and it is the half a caller needs to see to tell two + # credentials apart. The key is redacted everywhere. + class NamedKeyCredential + # @return [String] the key's name, non-secret (AUTH-8) + attr_reader :name + # @return [String] the header the key is stamped into; "Authorization" by default + attr_reader :header_name + # @return [String, nil] what precedes the key, separated by one space (AUTH-26) + attr_reader :prefix + + # @param name [String] non-blank (AUTH-9) + # @param key [String] non-blank (AUTH-9) + # @param header_name [String] a valid header name (HTTP-17) + # @param prefix [String, nil] a non-blank prefix, or nil for none + def initialize(name:, key:, header_name: "Authorization", prefix: nil) + @name = Model.frozen_string(Validation.non_blank!("name", name)) + @key = Model.frozen_string(Validation.non_blank!("key", key)) + @header_name = Model.frozen_string(HeaderName.of(header_name).original) + @prefix = prefix.nil? ? nil : Model.frozen_string(Validation.non_blank!("prefix", prefix)) + freeze + end + + # The secret, for the stamper. Never rendered. + # + # @return [String] + def key_value = @key + + # @return [String] the key redacted; the name, header name and prefix visible + def to_s + "NamedKeyCredential(name=#{@name.inspect}, key=#{REDACTED}, " \ + "header_name=#{@header_name.inspect}, prefix=#{@prefix.inspect})" + end + + # @return [String] the key redacted; the name, header name and prefix visible + def inspect + "#" + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/password_credential.rb b/gems/dexpace-core/lib/dexpace/auth/password_credential.rb new file mode 100644 index 0000000..9ca5c14 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/password_credential.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" + +module Dexpace + module Auth + # AUTH-8, AUTH-14: the username/password pair the Basic and Digest handlers consume. A Data, + # so two credentials with equal fields are == -- AUTH-8 names no equality rule for this type + # and Data's generated value equality is the harmless default. + # + # No blank check at construction (6c's P6-3): AUTH-9 enumerates exactly three types and this + # is not one of them, and AUTH-14 fixes a LAXER non-empty rule for Basic that "permits + # whitespace-only values". So the two fields are only required to be present and Strings + # here -- HTTP-4's missing-field rule, not AUTH-9's -- and each handler applies AUTH-14's + # rule at the point it uses the credential, so a blank is refused exactly once, by the rule + # that governs it. + # + # Both fields are redacted in every rendering, the username included. AUTH-8 does not list + # the username among the non-secret fields it lets remain visible, a Basic username is half + # of the value that goes on the wire, and phase 5a's review masked the proxy username for + # the same pair (its checklist, item 24). #pretty_print is overridden for the reason + # BearerToken states. + class PasswordCredential < ::Data.define(:username, :password) + include Model + + private_class_method :new + + # The validating factory; #with routes through it. + # + # @param username [String] + # @param password [String] + # @return [PasswordCredential] + def self.build(username:, password:) + new(username: username, password: password) + end + + def initialize(username:, password:) + user = Model.required!("username", username) + raise InvalidArgumentError, "username must be a String" unless user.is_a?(::String) + + pass = Model.required!("password", password) + raise InvalidArgumentError, "password must be a String" unless pass.is_a?(::String) + + super(username: Model.frozen_string(user), password: Model.frozen_string(pass)) + end + + # @return [String] both fields redacted + def to_s = "PasswordCredential(username=#{REDACTED}, password=#{REDACTED})" + + # @return [String] both fields redacted + def inspect = "#" + + # The rendering `pp` uses; see BearerToken. + # + # @param printer [PP] + # @return [void] + def pretty_print(printer) + printer.text(inspect) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/provider_error.rb b/gems/dexpace-core/lib/dexpace/auth/provider_error.rb new file mode 100644 index 0000000..7b3865b --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/provider_error.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../error" + +module Dexpace + module Auth + # AUTH-35, AUTH-11: a bearer token provider misbehaved -- it returned nil, a token already + # expired at fetch time (evaluated with no margin), something that is not a BearerToken, or + # (on the async path) something that is not a Future from #fetch_async. A provider that + # RAISES is not wrapped: its own error propagates, as AUTH-35 requires. Never cached: the + # stamper leaves its cache untouched on this error, so a later request retries the fetch. + class ProviderError < ::StandardError + include Dexpace::Error + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/requirement.rb b/gems/dexpace-core/lib/dexpace/auth/requirement.rb new file mode 100644 index 0000000..be1acbd --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/requirement.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "scheme" + +module Dexpace + module Auth + # AUTH-2: one scheme bound to its own OAuth scopes and params. The two collections are + # meaningful only for OAUTH2 -- resolution never inspects them for any scheme (AUTH-5) -- + # and are retained for every scheme, because the requirement says "still preserved for + # caller inspection". Value equality over all three members is Data's own and is AUTH-2's + # text. + # + # Ownership is taken through Model.own, phase 1's deep copy-and-freeze, and NOT through + # `dup.freeze`: dup is shallow, and AUTH-2's "retained input collections mutated by the + # caller after construction MUST NOT affect the stored value" covers a caller mutating a + # String INSIDE the array. Verified on 3.2.11, 3.4.10 and 4.0.6: with dup.freeze, a caller's + # `scopes[0] << ":write"` after construction turns the stored ["read"] into ["read:write"]; + # with Model.own the stored value is untouched. + class Requirement < ::Data.define(:scheme, :scopes, :params) + include Model + + private_class_method :new + + # The validating factory every construction path goes through; #with routes here. + # + # @param scheme [Scheme, String, Symbol] resolved through Scheme.of, as Request resolves + # its method through Method.of + # @param scopes [Array] OAuth scopes; copied and deep-frozen + # @param params [Hash] OAuth params; copied and deep-frozen + # @return [Requirement] + def self.build(scheme:, scopes: [], params: {}) + new(scheme: scheme, scopes: scopes, params: params) + end + + def initialize(scheme:, scopes:, params:) + resolved = Scheme.of(scheme) + unless Model.required!("scopes", scopes).is_a?(::Array) + raise InvalidArgumentError, "scopes must be an Array" + end + unless Model.required!("params", params).is_a?(::Hash) + raise InvalidArgumentError, "params must be a Hash" + end + + super(scheme: resolved, scopes: Model.own(scopes), params: Model.own(params)) + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/resolver.rb b/gems/dexpace-core/lib/dexpace/auth/resolver.rb new file mode 100644 index 0000000..671984f --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/resolver.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../error/invalid_argument_error" +require_relative "../error/auth_resolution_error" +require_relative "scheme" +require_relative "descriptor" + +module Dexpace + module Auth + # AUTH-4–AUTH-7: tier resolution as a pure function. A module with `extend self` and no + # instance -- the shape §6.1 gives the resilience policy and 5a gave Dexpace::Retryability -- + # because AUTH-7 requires the resolver to be "stateless and safe for concurrent use" with "a + # single shared instance" as "a valid entry point": a module IS the single shared entry point, + # with nothing to instantiate and nothing to race on. + module Resolver + extend self + + # Tier selection is strict (AUTH-4): the first PRESENT tier is the only one consulted, and + # a present tier that cannot be satisfied fails rather than falling through -- which the + # `||` chain gets right by construction, since once `per_call` is non-nil it is used + # exclusively whether or not its search finds anything. Within the selected descriptor the + # first requirement in declared order whose scheme is NO_AUTH or is in `available_schemes` + # wins (AUTH-5). No concrete credential is ever received, so none can be inspected. + # + # @param per_call [Descriptor, nil] the per-call override + # @param operation [Descriptor, nil] the operation's descriptor + # @param client [Descriptor, nil] the client's descriptor + # @param available_schemes [Enumerable] the schemes the caller can + # supply a credential for; each is resolved through Scheme.of + # @return [Requirement] the selected requirement + # @raise [Dexpace::InvalidArgumentError] when all three tiers are absent (AUTH-6) + # @raise [Dexpace::AuthResolutionError] when the selected descriptor lists no satisfiable + # scheme (AUTH-6) + def resolve(per_call:, operation:, client:, available_schemes:) + descriptor = selected!(per_call || operation || client) + available = available_schemes.map { |scheme| Scheme.of(scheme) } + requirement = first_satisfiable(descriptor, available) + return requirement unless requirement.nil? + + raise AuthResolutionError.new(required: descriptor.requirements.map(&:scheme), + available: available,) + end + + private + + # AUTH-5: declared order, NO_AUTH always satisfiable, membership otherwise. + def first_satisfiable(descriptor, available) + descriptor.requirements.find do |candidate| + candidate.scheme == Scheme::NO_AUTH || available.include?(candidate.scheme) + end + end + + # AUTH-6's first failure, and the type of the tier that was selected. + def selected!(descriptor) + if descriptor.nil? + raise InvalidArgumentError, + "an auth descriptor is required at the per-call, operation or client tier (AUTH-6)" + end + return descriptor if descriptor.is_a?(Descriptor) + + raise InvalidArgumentError, "a tier must hold a Dexpace::Auth::Descriptor" + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/scheme.rb b/gems/dexpace-core/lib/dexpace/auth/scheme.rb new file mode 100644 index 0000000..b3e29b3 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/scheme.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" + +module Dexpace + module Auth + # AUTH-1: the closed set the descriptor/resolver layer recognizes -- exactly OAUTH2, API_KEY, + # BASIC, DIGEST and NO_AUTH -- as a frozen Data over a frozen table with .of as its only + # lookup, never a Symbol and never an enum library (type-system/545949a5). The set is closed + # BY the requirement, so it is closed structurally in phase 4c's Stage shape (P4-32, P4-56): + # both generated constructors are private, there is no .build, and #with refuses, because + # Data#with would otherwise mint a sixth member .of cannot find. The five constants are + # built through the private .new exactly as phase 1's Method builds its own -- never + # `allocate` plus `instance_variable_set`, which leaves every member nil on a Data (verified + # on 3.2.11, 3.4.10 and 4.0.6: a Data's members are not instance variables). + # + # NO_AUTH is a sentinel meaning "this operation may run anonymously", never a wire scheme: + # the resolver treats it as always satisfiable (AUTH-5) and the step's stamper for it is + # Step::NO_STAMP. + class Scheme < ::Data.define(:name) + include Model + + private_class_method :new, :[] + + NAMES = %w[OAUTH2 API_KEY BASIC DIGEST NO_AUTH].freeze + private_constant :NAMES + + # @param name [String] one of the five names, exactly + def initialize(name:) + text = Model.required!("scheme", name) + unless NAMES.include?(text) + raise InvalidArgumentError, + "unknown auth scheme #{name.inspect}; one of #{NAMES.join(", ")} (AUTH-1)" + end + + super(name: Model.frozen_string(text)) + end + + # OAuth 2.0 / OpenID Connect bearer credentials. + OAUTH2 = new(name: "OAUTH2") + # A static API key carried in a header (AUTH-26). + API_KEY = new(name: "API_KEY") + # RFC 7617 Basic. + BASIC = new(name: "BASIC") + # RFC 7616 Digest. + DIGEST = new(name: "DIGEST") + # The anonymous sentinel: no credential is stamped. + NO_AUTH = new(name: "NO_AUTH") + + # The whole population, in AUTH-1's order. Public, unlike Proxy::Type's table, because the + # requirement is stated as a set and a caller building an `available_schemes` list has to + # be able to say "every scheme I can supply" without spelling five constants. + ALL = [OAUTH2, API_KEY, BASIC, DIGEST, NO_AUTH].freeze + + # The one lookup: a token in any case, trimmed, a Symbol, or a Scheme (which resolves to + # its constant, so a dup or a Marshal copy is canonicalised). + # + # @param token [String, Symbol, Scheme] + # @return [Scheme] the shared instance + # @raise [Dexpace::InvalidArgumentError] on an unknown, blank or absent token + def self.of(token) + text = Model.required!("scheme", token) + text = text.name if text.is_a?(Scheme) + # upcase with no argument (Dexpace/NoLocaleCaseFold): the fold is locale-independent. + wanted = text.to_s.strip.upcase + ALL.find { |scheme| scheme.name == wanted } || + raise(InvalidArgumentError, + "unknown auth scheme #{token.inspect}; one of #{NAMES.join(", ")} (AUTH-1)",) + end + + # The closed set has no derivation (P4-56): there is no Scheme.build for Model#with to + # route through, and Data#with would mint a member .of cannot find. + # + # @raise [Dexpace::InvalidArgumentError] always + def with(_changes = nil) + raise InvalidArgumentError, + "the auth scheme set is closed at #{NAMES.join(", ")} and a Scheme cannot be " \ + "derived; use the constants on Dexpace::Auth::Scheme" + end + + # The name, so a scheme interpolates as `OAUTH2` rather than as a Data dump. + def to_s = name + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/step.rb b/gems/dexpace-core/lib/dexpace/auth/step.rb new file mode 100644 index 0000000..11689ba --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/step.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" +require_relative "../registry" +require_relative "../closeable" +require_relative "../http/request" +require_relative "../http/response" +require_relative "../pipeline/stages" +require_relative "../instrumentation/logger" +require_relative "challenges" +require_relative "https_required_error" + +module Dexpace + module Auth + # AUTH-27–AUTH-36 on the sync runtime: the AUTH pillar step at Stages::AUTH (order 800), + # nested inside the redirect and retry loops by PIPE-2's stage order, which phase 4c fixed + # -- AUTH-27's "redirect wraps retry wraps auth" is that table and not a call this class + # makes. It forks for EVERY drive, the first included, and never calls Cursor#call (P4-39, + # spec-forced boundary 1): AUTH-30's replay is the case a reader writes as call-then-fork, + # and #fork after #call raises PipelineError. + # + # #call's order is the contract, and AUTH-29 fixes it. The cross-origin check comes FIRST: + # on a cross-origin redirect re-issue -- read as `cursor.state(Stages::REDIRECT)` carrying a + # truthy :cross_origin, the marker the redirect step forks into its own slot (design §10.15) + # -- the step stamps nothing, guards nothing and drives the request as it is. Nothing is + # stripped, because nothing was ever added to the request: the marker is cursor state and + # never a header, so AUTH-29's stripping clause is satisfied by construction. The read is + # keyed by (stage, key), so a RETRY step between REDIRECT and AUTH cannot write the value + # this step reads, and a request header cannot either: the mechanism can only SUPPRESS a + # stamp, never cause one. Then the HTTPS guard (AUTH-28), before any fetch or header write; + # then the stamper; then the drive; then the 401 handling -- the bearer branch (AUTH-36) + # before the challenge hook (AUTH-30), each gated on AUTH-31's replayability through one + # private predicate both runtimes inherit (6c's P6-7; spec-forced boundary 13). + # + # Step does not dispatch on a credential class and has no #stamp of its own: `stamper:` + # decides at construction -- KeyStamper, BasicHandler (preemptive Basic), BearerStamper, or + # NO_STAMP for the NO_AUTH sentinel. Challenge-driven schemes are reached only through + # `challenge_hook:`, whose default yields no replacement (AUTH-30); Digest arrives when a + # caller passes ChallengeHandlerChain#as_challenge_hook. The step's one logger use is + # §3.7's second disposal route for a superseded 401 that fails to close. + # + # Built through .build with .new private, the shape phase 5b's step took; frozen, holding + # three references and no per-request state (PIPE-11). + class Step + private_class_method :new + + # AUTH-30's default challenge hook: no replacement, no retry. + NO_REPLACEMENT = ->(_challenge, _request, _response) {} + # AUTH-1's NO_AUTH sentinel as a stamper: the request unchanged. The HTTPS guard still + # runs, because Step cannot know the stamper attaches nothing. + NO_STAMP = ->(request) { request } + + # @param stamper [#call] `(Request) -> Request`, decided at construction + # @param challenge_hook [#call] `(String, Request, Response) -> Request | nil` (AUTH-30) + # @param logger [Instrumentation::Logger] for a superseded response's close failure + # @return [Step] frozen + # @raise [Dexpace::InvalidArgumentError] for a stamper or hook of the wrong arity + def self.build(stamper:, challenge_hook: NO_REPLACEMENT, logger: Instrumentation::Logger::NULL) + stamper!(stamper) + unless Registry.callable?(challenge_hook, arity: 3) + raise InvalidArgumentError, + "challenge_hook must be callable with (challenge, request, response)" + end + + new(stamper: stamper, challenge_hook: challenge_hook, + logger: Model.required!("logger", logger),).freeze + end + + # The stamper shape this runtime drives: `#call(request) -> Request`. + def self.stamper!(stamper) + return if Registry.callable?(stamper, arity: 1) + + raise InvalidArgumentError, "stamper must be callable with (request)" + end + private_class_method :stamper! + + def initialize(stamper:, challenge_hook:, logger:) + @stamper = stamper + @challenge_hook = challenge_hook + @logger = logger + end + + # 4c's declaration, read once at install. + # + # @return [Dexpace::Pipeline::Stage] + def stage + Pipeline::Stages::AUTH + end + + # @param request [Dexpace::Request] + # @param cursor [Dexpace::Pipeline::Cursor] + # @return [Dexpace::Response] + # @raise [HTTPSRequiredError] on a non-HTTPS URL where a credential would be attached + def call(request, cursor) + return cursor.fork.call(request) if cross_origin?(cursor) # AUTH-29: no guard, no stamp + + enforce_https!(request) # AUTH-28: before any fetch or stamp + stamped = @stamper.call(request) + response = cursor.fork.call(stamped) + return response unless unauthorized?(response) + + challenge = challenge_header(response) + return response if challenge.nil? # AUTH-33: the hook is never consulted + + retried = bearer_retry(challenge, stamped, response, cursor) # AUTH-36 + return retried unless retried.nil? + + replay(challenge, stamped, response, cursor) # AUTH-30, AUTH-31, AUTH-32 + end + + private + + # AUTH-29's read: the redirect step's own slot, a shared frozen empty Hash when no + # redirect step forked (the same-origin answer), truthy meaning suppress. + def cross_origin?(cursor) + cursor.state(Pipeline::Stages::REDIRECT)[:cross_origin] ? true : false + end + + # AUTH-28: the scheme compared case-insensitively with a bare downcase. + def enforce_https!(request) + scheme = request.url.scheme.to_s + return if scheme.downcase == "https" + + raise HTTPSRequiredError.new(scheme: scheme, step: self.class.name.to_s) + end + + def unauthorized?(response) = response.status.code == 401 + + # The WWW-Authenticate value the hook receives: a repeated header's values joined with + # ", ", which RFC 7235 §4.1 makes one challenge list; nil when the header is absent. + def challenge_header(response) + values = response.headers["WWW-Authenticate"] + return nil if values.nil? || values.empty? + + values.join(", ") + end + + # AUTH-31's gate, one implementation for both runtimes: a request with no body is + # replayable; otherwise phase 3b's own predicate decides. + def replayable?(request) + body = request.body + body.nil? || body.replayable? + end + + def bearer_offered?(challenge) + Challenges.parse(challenge).any? { |parsed| parsed.scheme == "bearer" } + end + + # AUTH-36's three surface-unchanged conditions plus P6-7's gate, in that order. + def bearer_retry?(challenge, stamped) + @stamper.respond_to?(:evict_if_matches) && + !rejected_header(stamped).nil? && bearer_offered?(challenge) && replayable?(stamped) + end + + def rejected_header(stamped) + stamped.headers["Authorization"]&.first + end + + # AUTH-36: evict only the exact token that produced this 401, close the superseded + # response, and re-stamp ONE retry -- from the cache when another request already + # refreshed it, from a fresh fetch otherwise. Regardless of HTTP method. + def bearer_retry(challenge, stamped, response, cursor) + return nil unless bearer_retry?(challenge, stamped) + + @stamper.evict_if_matches(rejected_header(stamped).to_s) + Dexpace.close_quietly(response, logger: @logger) + cursor.fork.call(@stamper.call(stamped)) + end + + # AUTH-30: consult the hook; on a replacement, close the 401 and drive the replacement + # through a fresh fork exactly once, with no further challenge handling. AUTH-31: a + # non-replayable replacement surfaces the 401 unchanged and UNCLOSED. + def replay(challenge, stamped, response, cursor) + replacement = consult(challenge, stamped, response) + return response if replacement.nil? || !replayable?(replacement) + + Dexpace.close_quietly(response, logger: @logger) + cursor.fork.call(replacement) + end + + # AUTH-32: a hook that raises, or returns something that is not a request, leaves the + # open 401 closed behind it -- the close failure, if any, on the error's suppressed trail. + def consult(challenge, stamped, response) + replacement = @challenge_hook.call(challenge, stamped, response) + replacement!(replacement) + rescue ::StandardError => error + Dexpace.close_quietly(response, onto: error) + raise + end + + def replacement!(replacement) + return replacement if replacement.nil? || replacement.is_a?(Request) + + raise InvalidArgumentError, + "the challenge hook must return a Dexpace::Request or nil, got #{replacement.class}" + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb b/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb new file mode 100644 index 0000000..9b9dea4 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../error" + +module Dexpace + module Auth + # AUTH-21's ISO-8859-1 branch is a raising path, and this is the typed failure it raises + # (6c's R10, P6-1): a challenge that does not advertise `charset=UTF-8` fixes Latin-1 as the + # hash-input encoding, and a username, realm or password with a character Latin-1 cannot + # represent has no Digest response at all -- not a wrong one. `:replace` would produce a + # well-formed header the server rejects with a 401 that cannot be told from a wrong + # password; a bare Encoding::UndefinedConversionError gives the caller no Dexpace:: type, + # no field and no encoding to act on. The message names the FIELD and the encoding, never + # the value (AUTH-8). #cause is the rescued conversion error. + # + # Filed under lib/dexpace/auth/ because the constant is namespaced under Auth, as phase + # 2's Serde errors are under lib/dexpace/serde/: the file path follows the constant path. + class UnencodableCredentialError < ::StandardError + include Dexpace::Error + + # @return [Symbol] :username, :realm or :password + attr_reader :field + # @return [String] the target encoding's name, "ISO-8859-1" + attr_reader :encoding + + # @param field [Symbol] + # @param encoding [String] + def initialize(field:, encoding:) + @field = field + @encoding = encoding + super("the #{field} cannot be encoded as #{encoding}: the Digest challenge did not " \ + "advertise charset=UTF-8, so RFC 7616's default encoding applies (AUTH-21)") + end + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/auth/validation.rb b/gems/dexpace-core/lib/dexpace/auth/validation.rb new file mode 100644 index 0000000..42cec99 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/validation.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../auth" +require_relative "../model" +require_relative "../error/invalid_argument_error" + +module Dexpace + module Auth + # AUTH-9's non-blank check, which phase 1 ships no helper for. Dexpace::Model.required! + # raises " is required" only when the value is nil -- that is SEAM-29's one message + # form for a MISSING field and HTTP-4's rule, and it is deliberately not a blank check. So a + # nil field still goes through Model.required! and still reads " is required", while + # a present-but-blank field reads " must not be blank": the two forms name two + # different mistakes and do not overlap (6c's P6-6). AUTH-14's laxer non-EMPTY rule for a + # Basic credential is a third check and lives at BasicHandler/DigestHandler, never here. + # + # The argument order is Model.required!'s, (name, value), so the two helpers read the same + # way at every call site; the plan's fence had them the other way round. + # + # Not public API: a private_constant reachable by its bare name from any `module Dexpace; + # module Auth` body, and from nowhere else. + module Validation + extend self + + # @param name [String] the field, for the message + # @param value [Object] the candidate + # @return [String] the value, unchanged + # @raise [Dexpace::InvalidArgumentError] when nil, not a String, or blank after strip + def non_blank!(name, value) + text = Model.required!(name, value) + raise InvalidArgumentError, "#{name} must be a String" unless text.is_a?(::String) + raise InvalidArgumentError, "#{name} must not be blank" if text.strip.empty? + + text + end + end + private_constant :Validation + end +end diff --git a/gems/dexpace-core/lib/dexpace/bounded_map.rb b/gems/dexpace-core/lib/dexpace/bounded_map.rb index 8a79c6e..efdb118 100644 --- a/gems/dexpace-core/lib/dexpace/bounded_map.rb +++ b/gems/dexpace-core/lib/dexpace/bounded_map.rb @@ -102,6 +102,32 @@ def delete_if_identical(key, object) end end + # Read-modify-write in ONE critical section: yields the slot's current occupant (nil when + # absent or evicted) under this map's own mutex, stores what the block returns, drains back + # under the cap in the same section as #set does, and returns the stored value. Added by + # phase 6 for AUTH-19's per-nonce counter, whose increment is `update(nonce) { |n| (n || 0) + # + 1 }` -- the read and the write under one lock is what makes AUTH-24's "concurrent reuse + # of one nonce still yields correct, non-duplicated counts" true, and a read through #[] + # followed by #set would not be (the class comment anticipated it). + # + # The block runs while the lock is held and MUST touch only in-memory state: no I/O, no + # other lock, no call back into this map (a non-reentrant Thread::Mutex would raise), and + # never a suspension point (concurrency-and-async/f414b864). A block that raises leaves the + # slot as it was. + # + # @param key [Object] the slot + # @yieldparam current [Object, nil] the slot's occupant, or nil + # @yieldreturn [Object] the new occupant + # @return [Object] the value stored + def update(key) + @mutex.synchronize do + value = yield(@h[key]) + @h[key] = value + drain + value + end + end + # @return [Integer] the number of live entries, at most the cap once inserts quiesce def size @mutex.synchronize { @h.size } diff --git a/gems/dexpace-core/lib/dexpace/error/auth_resolution_error.rb b/gems/dexpace-core/lib/dexpace/error/auth_resolution_error.rb new file mode 100644 index 0000000..3440787 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/error/auth_resolution_error.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../error" + +module Dexpace + # AUTH-6's second failure: the selected descriptor lists no scheme the caller can supply a + # credential for. Distinct from Dexpace::InvalidArgumentError, which is AUTH-6's FIRST failure + # (no descriptor at any tier): a caller who passed a descriptor passed nothing invalid, and one + # who cannot tell "you gave me nothing" from "you gave me something I cannot satisfy" cannot + # act on either. Carries the required schemes in preference order and the available ones as + # members, so a caller reads them rather than parsing the message. Flat under Dexpace, in + # phase 2's shape (`< ::StandardError` with the Dexpace::Error marker included), because the + # condition is a general resolution failure and not one only the Auth subsystem can raise. + class AuthResolutionError < ::StandardError + include Dexpace::Error + + # @return [Array] the descriptor's schemes, in preference order + attr_reader :required + # @return [Array] the schemes the caller said it could supply + attr_reader :available + + # @param required [Array] + # @param available [Array] + def initialize(required:, available:) + @required = required.dup.freeze + @available = available.dup.freeze + super("no satisfiable auth scheme: required #{names(@required)} in preference order, " \ + "available #{names(@available)} (AUTH-6)") + end + + private + + def names(schemes) + schemes.empty? ? "(none)" : schemes.map(&:name).join(", ") + end + end +end diff --git a/gems/dexpace-core/lib/dexpace/instrumentation/keys.rb b/gems/dexpace-core/lib/dexpace/instrumentation/keys.rb index 9db79dc..a2527d0 100644 --- a/gems/dexpace-core/lib/dexpace/instrumentation/keys.rb +++ b/gems/dexpace-core/lib/dexpace/instrumentation/keys.rb @@ -53,10 +53,11 @@ module Keys INSTRUMENT_REQUEST_DURATION = "http.client.request.duration" end - # OBS-39 and OBS-20: the event names the logging half emits, as frozen String constants - # covered by the surface manifest for the reason Keys gives. Two are the request cycle's; - # the five diagnostics share OBS-20's `http.instrumentation.` prefix, derived from one - # constant so a test can assert every diagnostic starts with it. + # OBS-39 and OBS-20: the event names core emits, as frozen String constants covered by the + # surface manifest for the reason Keys gives. Two are the request cycle's; the five + # instrumentation diagnostics share OBS-20's `http.instrumentation.` prefix, derived from + # one constant so a test can assert every one of them starts with it; the ninth is phase + # 6c's auth-layer diagnostic. module Events # The request event (OBS-39). HTTP_REQUEST = "http.request" @@ -79,6 +80,12 @@ module Events INSTRUMENTATION_SHUTDOWN = "#{INSTRUMENTATION_PREFIX}shutdown".freeze # CFG-24/CFG-25's proxy-configuration warning, emitted BESIDE 5a's Kernel#warn (P5-8). INSTRUMENTATION_CONFIG = "#{INSTRUMENTATION_PREFIX}config".freeze + # AUTH-37's log-and-continue: a BACKGROUND bearer-token refresh failed or returned an + # unusable token, and the in-flight request -- already stamped with the still-valid + # cached token -- was not failed by it. Phase 6c's, and the first event outside the + # request cycle and the instrumentation family: an auth-layer diagnostic, named for the + # layer that emits it. + AUTH_REFRESH = "http.auth.refresh" end end end diff --git a/gems/dexpace-core/sig/dexpace/auth.rbs b/gems/dexpace-core/sig/dexpace/auth.rbs new file mode 100644 index 0000000..bfa3cc8 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth.rbs @@ -0,0 +1,6 @@ +module Dexpace + # Phase 6c: the authentication layer's namespace and its one shared redaction marker. + module Auth + REDACTED: String + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs b/gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs new file mode 100644 index 0000000..d4b057e --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs @@ -0,0 +1,33 @@ +module Dexpace + module Auth + # AUTH-37, AUTH-36 (async half), AUTH-11: the three-zone async bearer stamper. + class AsyncBearerStamper + @provider: untyped + @clock: _Clock + @refresh_margin: Numeric + @logger: Instrumentation::Logger + @lock: Thread::Mutex + @token: BearerToken? + @in_flight: Dexpace::Async::Future? + + def initialize: (provider: untyped, ?clock: _Clock, ?refresh_margin: Numeric, + ?logger: Instrumentation::Logger) -> void + + def stamp: (Dexpace::Request request) -> Dexpace::Async::Future + def stamp_fresh: (Dexpace::Request request) -> Dexpace::Async::Future + def evict_if_matches: (String rejected_header) -> bool + + private + + def zone: (BearerToken token) -> Symbol + def awaiting: (Dexpace::Request request) -> Dexpace::Async::Future + def header: (BearerToken token) -> String + def stamp_with: (Dexpace::Request request, BearerToken token) -> Dexpace::Request + def settled: (Dexpace::Request request) -> Dexpace::Async::Future + def refresh_future: () -> Dexpace::Async::Future + def start_fetch: (Dexpace::Async::Completer completer) -> void + def invalid: (untyped token) -> Exception? + def background_refresh: () -> void + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/async_step.rbs b/gems/dexpace-core/sig/dexpace/auth/async_step.rbs new file mode 100644 index 0000000..649ccdd --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/async_step.rbs @@ -0,0 +1,40 @@ +module Dexpace + module Auth + # AUTH-27 through AUTH-38 on the async runtime: Step over 4c's _AsyncStep. Exchange is a + # private value carried through the callbacks; declared for the strict target. + class AsyncStep < Step + class Exchange < Data + attr_reader stamped: Dexpace::Request + attr_reader response: Dexpace::Response + attr_reader cursor: Pipeline::Cursor + attr_reader completer: Dexpace::Async::Completer + + def self.new: (stamped: Dexpace::Request, response: Dexpace::Response, + cursor: Pipeline::Cursor, completer: Dexpace::Async::Completer) -> instance + end + + def self.build: (stamper: untyped, ?challenge_hook: untyped, + ?logger: Instrumentation::Logger) -> AsyncStep + + def call: (Dexpace::Request request, Pipeline::Cursor cursor) -> Dexpace::Async::Future + + private + + def self.stamper!: (untyped stamper) -> void + def guarded: (Dexpace::Async::Completer completer) { () -> untyped } -> void + def stamp_async: (Dexpace::Request request) -> Dexpace::Async::Future + def settled: (untyped value) -> Dexpace::Async::Future + def observe: (Dexpace::Async::Future future, Dexpace::Async::Completer completer) { (Dexpace::Async::Settlement) -> untyped } -> void + def chain_into: (Dexpace::Async::Future future, Dexpace::Async::Completer completer) -> void + def forward_failure: (Dexpace::Async::Settlement settlement, Dexpace::Async::Completer completer) -> void + def drive: (Dexpace::Async::Settlement settlement, Pipeline::Cursor cursor, Dexpace::Async::Completer completer) -> void + def handle: (Exchange exchange) -> void + def bearer_retry_async: (Exchange exchange) -> void + def drive_replacement: (Dexpace::Async::Future restamped, Exchange exchange) -> void + def restamp: (Dexpace::Request stamped, bool evicted) -> Dexpace::Async::Future + def replay_async: (String challenge, Exchange exchange) -> void + def replacement!: (untyped replacement) -> untyped + def replace: (untyped replacement, Exchange exchange) -> void + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs b/gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs new file mode 100644 index 0000000..3a1c327 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs @@ -0,0 +1,15 @@ +module Dexpace + module Auth + # AUTH-14: Basic, preemptive through #call and challenge-answered through + # #authorization_for, one precomputed value. + class BasicHandler + @value: String + + def initialize: (PasswordCredential credential) -> void + + def call: (Dexpace::Request request) -> Dexpace::Request + def authorization_for: (Array[Challenge] challenges, Dexpace::Request _request, + ?proxy: bool) -> String? + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/bearer_provider.rbs b/gems/dexpace-core/sig/dexpace/auth/bearer_provider.rbs new file mode 100644 index 0000000..6a8064e --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/bearer_provider.rbs @@ -0,0 +1,24 @@ +module Dexpace + module Auth + # AUTH-11: the provider duck type -- #fetch, and optionally #fetch_async. + interface _BearerProvider + def fetch: () -> BearerToken? + end + + interface _AsyncBearerProvider + def fetch: () -> BearerToken? + def fetch_async: () -> Dexpace::Async::Future + end + + # AUTH-11: the default async fetch over either shape, which never raises. + module BearerProvider + def self?.fetch_async: (untyped provider) -> Dexpace::Async::Future + def self?.conforms?: (untyped provider) -> bool + + private + + def self?.mirror: (untyped provider) -> Dexpace::Async::Future + def self?.failed: (Exception error) -> Dexpace::Async::Future + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/bearer_stamper.rbs b/gems/dexpace-core/sig/dexpace/auth/bearer_stamper.rbs new file mode 100644 index 0000000..8ae8486 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/bearer_stamper.rbs @@ -0,0 +1,25 @@ +module Dexpace + module Auth + # AUTH-11, AUTH-34, AUTH-35, AUTH-36: the sync bearer stamper. + class BearerStamper + DEFAULT_REFRESH_MARGIN: Integer + + @provider: untyped + @clock: _Clock + @refresh_margin: Numeric + @lock: Thread::Mutex + @token: BearerToken? + + def initialize: (provider: untyped, ?clock: _Clock, ?refresh_margin: Numeric) -> void + + def call: (Dexpace::Request request) -> Dexpace::Request + def evict_if_matches: (String rejected_header) -> bool + + private + + def header: (BearerToken token) -> String + def refresh!: () -> BearerToken + def validate: (untyped fetched) -> BearerToken + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/bearer_token.rbs b/gems/dexpace-core/sig/dexpace/auth/bearer_token.rbs new file mode 100644 index 0000000..32a23f8 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/bearer_token.rbs @@ -0,0 +1,21 @@ +module Dexpace + module Auth + # AUTH-8, AUTH-9, AUTH-10: the bearer token, redacted in every rendering. + class BearerToken < Data + include Model + + attr_reader token: String + attr_reader expiry: ::Time? + + private def self.new: (token: String, expiry: ::Time?) -> instance + def initialize: (token: untyped, expiry: ::Time?) -> void + + def self.build: (token: untyped, ?expiry: ::Time?) -> BearerToken + + def expired?: (now: ::Time, ?margin: Numeric) -> bool + def to_s: () -> String + def inspect: () -> String + def pretty_print: (untyped printer) -> void + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/challenge.rbs b/gems/dexpace-core/sig/dexpace/auth/challenge.rbs new file mode 100644 index 0000000..c542ae0 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/challenge.rbs @@ -0,0 +1,24 @@ +module Dexpace + module Auth + # AUTH-12: one parsed challenge, folded once at construction. + class Challenge < Data + include Model + + TOKEN68: String + + attr_reader scheme: String + attr_reader params: Hash[String, String] + + private def self.new: (scheme: String, params: Hash[String, String]) -> instance + def initialize: (scheme: untyped, params: untyped) -> void + + def self.build: (scheme: untyped, ?params: Hash[String, String]) -> Challenge + + def token68: () -> String? + + private + + def fold: (Hash[untyped, untyped] params) -> Hash[String, String] + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/challenge_handler_chain.rbs b/gems/dexpace-core/sig/dexpace/auth/challenge_handler_chain.rbs new file mode 100644 index 0000000..14297c5 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/challenge_handler_chain.rbs @@ -0,0 +1,20 @@ +module Dexpace + module Auth + # AUTH-23's one-method handler protocol (6c's P6-2). + interface _ChallengeHandler + def authorization_for: (Array[Challenge] challenges, Dexpace::Request request, + proxy: bool) -> String? + end + + # AUTH-23, AUTH-25: the composing handler and the hook adapter. + class ChallengeHandlerChain + @handlers: Array[_ChallengeHandler] + + def initialize: (Array[_ChallengeHandler] handlers) -> void + + def authorization_for: (String? header_value, Dexpace::Request request, ?proxy: bool) -> String? + def header_name: (proxy: bool) -> String + def as_challenge_hook: (?proxy: bool) -> ^(String, Dexpace::Request, Dexpace::Response) -> Dexpace::Request? + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/challenges.rbs b/gems/dexpace-core/sig/dexpace/auth/challenges.rbs new file mode 100644 index 0000000..d7d958e --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/challenges.rbs @@ -0,0 +1,40 @@ +module Dexpace + module Auth + # AUTH-12, AUTH-13: the RFC 7235 challenge-list parser. Its patterns and its Parser class are + # private_constants, declared because the strict `core` Steep target types their uses. + module Challenges + TOKEN: Regexp + TOKEN68: Regexp + SEPARATORS: Regexp + BOUNDARY: Regexp + EQUALS: Regexp + SPACES: Regexp + OWS: Regexp + QUOTE: Regexp + + def self?.parse: (String? header_value) -> Array[Challenge] + + class Parser + @scanner: StringScanner + @scheme: String? + @params: Hash[String, String] + @boundary: bool + + attr_reader challenges: Array[Challenge] + + def initialize: (String input) -> void + def run: () -> void + + private + + def step: () -> void + def parameter: (String name) -> void + def open_challenge: (String name) -> void + def scan_value: () -> String? + def recover: () -> void + def skip_quoted: () -> void + def emit: () -> void + end + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/descriptor.rbs b/gems/dexpace-core/sig/dexpace/auth/descriptor.rbs new file mode 100644 index 0000000..47d2537 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/descriptor.rbs @@ -0,0 +1,17 @@ +module Dexpace + module Auth + # AUTH-3: a non-empty ordered requirement list, immutable in and out. + class Descriptor < Data + include Model + + attr_reader requirements: Array[Requirement] + + private def self.new: (requirements: Array[Requirement]) -> instance + def initialize: (requirements: Array[Requirement]) -> void + + def self.build: (requirements: Array[Requirement]) -> Descriptor + + def allows_anonymous?: () -> bool + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs b/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs new file mode 100644 index 0000000..58d8ea6 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs @@ -0,0 +1,73 @@ +module Dexpace + module Auth + # A hash function the handler drives: what ::Digest::MD5 and ::Digest::SHA256 answer. + # An interface rather than `singleton(Digest::Base)`, because NFR-11's scan admits no + # stdlib constant but the fixed allow-list in a public signature. + interface _Hasher + def hexdigest: (String input) -> String + end + + # The cnonce source: SecureRandom's #hex(bytes), which a fixed test source shares. + interface _CnonceSource + def hex: (Integer bytes) -> String + end + + # AUTH-15 through AUTH-24: RFC 7616 Digest. HASHES, ECHOED and Computed are private. + class DigestHandler + ALGORITHMS: Array[String] + DEFAULT_CAP: Integer + HASHES: Hash[String, _Hasher] + ECHOED: Array[String] + + class Computed < Data + attr_reader challenge: Challenge + attr_reader algorithm: String + attr_reader uri: String + attr_reader cnonce: String + attr_reader nc: String + attr_reader qop: String? + attr_reader response: String? + + def self.new: (challenge: Challenge, algorithm: String, uri: String, cnonce: String, + nc: String, qop: String?, response: String?) -> instance + end + + @credential: PasswordCredential + @preference: Array[String] + @cnonce_source: _CnonceSource & Object + @nonces: BoundedMap + + def initialize: (PasswordCredential credential, ?preference: Array[String], ?cap: Integer, + ?cnonce_source: _CnonceSource & Object) -> void + + def authorization_for: (Array[Challenge] challenges, Dexpace::Request request, + ?proxy: bool) -> String? + + private + + def credential!: (untyped credential) -> PasswordCredential + def preference!: (untyped preference) -> Array[String] + def select: (Array[Challenge] challenges) -> Challenge? + def satisfiable?: (Challenge challenge) -> bool + def echoable?: (Hash[String, String] params) -> bool + def algorithm_of: (Challenge challenge) -> String? + def qop_auth?: (Challenge challenge) -> bool + def compute: (Challenge challenge, Dexpace::Request request) -> Computed + def response_for: (Computed computed, String method) -> String + def response_digest: (_Hasher hasher, Computed computed, String ha1, String ha2) -> String + def ha1_for: (Computed computed, _Hasher hasher) -> String + def credential_bytes: (Hash[String, String] params) -> Array[String] + def materialize: (String text, Symbol field, bool utf8) -> String + def join: (*String parts) -> String + def next_count: (String nonce) -> String + def request_target: (Dexpace::Request request) -> String + def render: (Computed computed) -> String + def echoed: (Computed computed) -> Array[String] + def negotiated: (Computed computed) -> Array[String] + def opaque: (Computed computed) -> Array[String] + def quoted: (String name, String value) -> String + def username_field: () -> String + def quote: (String value) -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/https_required_error.rbs b/gems/dexpace-core/sig/dexpace/auth/https_required_error.rbs new file mode 100644 index 0000000..0672b97 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/https_required_error.rbs @@ -0,0 +1,13 @@ +module Dexpace + module Auth + # AUTH-28: the HTTPS guard's refusal, naming the step and the scheme. + class HTTPSRequiredError < ::StandardError + include Dexpace::Error + + attr_reader scheme: String + attr_reader step: String + + def initialize: (scheme: String, step: String) -> void + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/key_credential.rbs b/gems/dexpace-core/sig/dexpace/auth/key_credential.rbs new file mode 100644 index 0000000..1e07c02 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/key_credential.rbs @@ -0,0 +1,17 @@ +module Dexpace + module Auth + # AUTH-8, AUTH-9, AUTH-26: an API key with reference identity. + class KeyCredential + @api_key: String + + attr_reader header_name: String + attr_reader prefix: String? + + def initialize: (api_key: untyped, ?header_name: String, ?prefix: String?) -> void + + def key_value: () -> String + def to_s: () -> String + def inspect: () -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/key_stamper.rbs b/gems/dexpace-core/sig/dexpace/auth/key_stamper.rbs new file mode 100644 index 0000000..d14cfa1 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/key_stamper.rbs @@ -0,0 +1,20 @@ +module Dexpace + module Auth + # What KeyStamper is written against: KeyCredential and NamedKeyCredential both answer it. + interface _KeyCredential + def header_name: () -> String + def prefix: () -> String? + def key_value: () -> String + end + + # AUTH-26: static key-credential stamping, stateless after construction. + class KeyStamper + @header_name: String + @value: String + + def initialize: (_KeyCredential & Object credential) -> void + + def call: (Dexpace::Request request) -> Dexpace::Request + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/named_key_credential.rbs b/gems/dexpace-core/sig/dexpace/auth/named_key_credential.rbs new file mode 100644 index 0000000..e65e0d9 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/named_key_credential.rbs @@ -0,0 +1,18 @@ +module Dexpace + module Auth + # AUTH-8, AUTH-9, AUTH-26: a named key with reference identity. + class NamedKeyCredential + @key: String + + attr_reader name: String + attr_reader header_name: String + attr_reader prefix: String? + + def initialize: (name: untyped, key: untyped, ?header_name: String, ?prefix: String?) -> void + + def key_value: () -> String + def to_s: () -> String + def inspect: () -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/password_credential.rbs b/gems/dexpace-core/sig/dexpace/auth/password_credential.rbs new file mode 100644 index 0000000..5ac3872 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/password_credential.rbs @@ -0,0 +1,20 @@ +module Dexpace + module Auth + # AUTH-8, AUTH-14: the username/password pair, both fields redacted. + class PasswordCredential < Data + include Model + + attr_reader username: String + attr_reader password: String + + private def self.new: (username: String, password: String) -> instance + def initialize: (username: untyped, password: untyped) -> void + + def self.build: (username: untyped, password: untyped) -> PasswordCredential + + def to_s: () -> String + def inspect: () -> String + def pretty_print: (untyped printer) -> void + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/provider_error.rbs b/gems/dexpace-core/sig/dexpace/auth/provider_error.rbs new file mode 100644 index 0000000..5d1e4b5 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/provider_error.rbs @@ -0,0 +1,8 @@ +module Dexpace + module Auth + # AUTH-35, AUTH-11: a misbehaving bearer provider result. + class ProviderError < ::StandardError + include Dexpace::Error + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/requirement.rbs b/gems/dexpace-core/sig/dexpace/auth/requirement.rbs new file mode 100644 index 0000000..11731b9 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/requirement.rbs @@ -0,0 +1,20 @@ +module Dexpace + module Auth + # AUTH-2: one scheme bound to its own scopes and params, deep-frozen once. + class Requirement < Data + include Model + + attr_reader scheme: Scheme + attr_reader scopes: Array[String] + attr_reader params: Hash[untyped, untyped] + + private def self.new: (scheme: String | Symbol | Scheme, scopes: Array[String], + params: Hash[untyped, untyped]) -> instance + def initialize: (scheme: String | Symbol | Scheme, scopes: Array[String], + params: Hash[untyped, untyped]) -> void + + def self.build: (scheme: String | Symbol | Scheme, ?scopes: Array[String], + ?params: Hash[untyped, untyped]) -> Requirement + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/resolver.rbs b/gems/dexpace-core/sig/dexpace/auth/resolver.rbs new file mode 100644 index 0000000..a6a5665 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/resolver.rbs @@ -0,0 +1,14 @@ +module Dexpace + module Auth + # AUTH-4 through AUTH-7: tier resolution as a pure module function. + module Resolver + def self?.resolve: (per_call: Descriptor?, operation: Descriptor?, client: Descriptor?, + available_schemes: Enumerable[String | Symbol | Scheme]) -> Requirement + + private + + def self?.selected!: (untyped descriptor) -> Descriptor + def self?.first_satisfiable: (Descriptor descriptor, Array[Scheme] available) -> Requirement? + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/scheme.rbs b/gems/dexpace-core/sig/dexpace/auth/scheme.rbs new file mode 100644 index 0000000..c7796ee --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/scheme.rbs @@ -0,0 +1,30 @@ +module Dexpace + module Auth + # AUTH-1: the closed five-member set, in Pipeline::Stage's shape (P4-32, P4-56): both + # generated constructors private, no .build, #with refusing. + class Scheme < Data + include Model + + attr_reader name: String + + # A private_constant; declared because the strict `core` Steep target types its uses. + NAMES: Array[String] + + OAUTH2: Scheme + API_KEY: Scheme + BASIC: Scheme + DIGEST: Scheme + NO_AUTH: Scheme + ALL: Array[Scheme] + + private def self.new: (name: String) -> instance + def initialize: (name: String) -> void + + def self.of: (String | Symbol | Scheme token) -> Scheme + + # Always raises: the closed set has no derivation. + def with: (?untyped _changes) -> bot + def to_s: () -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/step.rbs b/gems/dexpace-core/sig/dexpace/auth/step.rbs new file mode 100644 index 0000000..abeadc7 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/step.rbs @@ -0,0 +1,49 @@ +module Dexpace + module Auth + # A synchronous stamper: KeyStamper, BasicHandler, BearerStamper, or Step::NO_STAMP. + interface _Stamper + def call: (Dexpace::Request request) -> Dexpace::Request + end + + # AUTH-27 through AUTH-36 on the sync runtime: the AUTH pillar step. The stamper and the hook + # are `untyped` because a lambda is a valid value for either (Registry.callable? validates + # the arity), and the async subclass widens the stamper to an object answering #stamp. + class Step + NO_REPLACEMENT: ^(untyped, untyped, untyped) -> nil + NO_STAMP: ^(Dexpace::Request) -> Dexpace::Request + + @stamper: untyped + @challenge_hook: untyped + @logger: Instrumentation::Logger + + private def self.new: (stamper: untyped, challenge_hook: untyped, + logger: Instrumentation::Logger) -> instance + def initialize: (stamper: untyped, challenge_hook: untyped, + logger: Instrumentation::Logger) -> void + + def self.build: (stamper: untyped, ?challenge_hook: untyped, + ?logger: Instrumentation::Logger) -> Step + + def stage: () -> Pipeline::Stage + def call: (Dexpace::Request request, Pipeline::Cursor cursor) -> Dexpace::Response + + private + + def self.stamper!: (untyped stamper) -> void + def cross_origin?: (Pipeline::Cursor cursor) -> bool + def enforce_https!: (Dexpace::Request request) -> void + def unauthorized?: (Dexpace::Response response) -> bool + def challenge_header: (Dexpace::Response response) -> String? + def replayable?: (Dexpace::Request request) -> bool + def bearer_offered?: (String challenge) -> bool + def bearer_retry?: (String challenge, Dexpace::Request stamped) -> bool + def rejected_header: (Dexpace::Request stamped) -> String? + def bearer_retry: (String challenge, Dexpace::Request stamped, Dexpace::Response response, + Pipeline::Cursor cursor) -> Dexpace::Response? + def replay: (String challenge, Dexpace::Request stamped, Dexpace::Response response, + Pipeline::Cursor cursor) -> Dexpace::Response + def consult: (String challenge, Dexpace::Request stamped, Dexpace::Response response) -> untyped + def replacement!: (untyped replacement) -> untyped + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs b/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs new file mode 100644 index 0000000..6bfc40a --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs @@ -0,0 +1,13 @@ +module Dexpace + module Auth + # AUTH-21 (R10): the typed failure of the ISO-8859-1 branch. + class UnencodableCredentialError < ::StandardError + include Dexpace::Error + + attr_reader field: Symbol + attr_reader encoding: String + + def initialize: (field: Symbol, encoding: String) -> void + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/auth/validation.rbs b/gems/dexpace-core/sig/dexpace/auth/validation.rbs new file mode 100644 index 0000000..8407a9d --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/validation.rbs @@ -0,0 +1,12 @@ +# Dexpace::Auth::Validation is a private_constant and not public API: this declaration exists +# because the strict `core` Steep target checks every file under lib/ and needs the module and +# its one method declared to type the credential constructors, exactly as hooks.rbs does for +# Dexpace::Hooks. RBS has no visibility for a constant, so the privacy lives in +# lib/dexpace/auth/validation.rb alone, and the module takes no test/ mirror. +module Dexpace + module Auth + module Validation + def self?.non_blank!: (String name, untyped value) -> String + end + end +end diff --git a/gems/dexpace-core/sig/dexpace/bounded_map.rbs b/gems/dexpace-core/sig/dexpace/bounded_map.rbs index 1dee4ed..4e67bab 100644 --- a/gems/dexpace-core/sig/dexpace/bounded_map.rbs +++ b/gems/dexpace-core/sig/dexpace/bounded_map.rbs @@ -2,7 +2,9 @@ # exists because the strict `core` Steep target checks every file under lib/ and needs the class # declared to type ContextStore's call sites, exactly as hooks.rbs does for Dexpace::Hooks. RBS # has no visibility for a constant, so the privacy lives in lib/dexpace/bounded_map.rb alone, and -# the class takes no surface-manifest row (Module#constants excludes it) and no test/ mirror. +# the class takes no surface-manifest row (Module#constants excludes it). Phase 6c gave it a +# test/ mirror when it added #update, the first method with no consumer of its own to assert +# it through. module Dexpace class BoundedMap @cap: Integer @@ -14,6 +16,7 @@ module Dexpace def put: (untyped key, untyped value) -> bool def []: (untyped key) -> untyped def delete_if_identical: (untyped key, untyped object) -> bool + def update: (untyped key) { (untyped) -> untyped } -> untyped def size: () -> Integer private def drain: () -> void diff --git a/gems/dexpace-core/sig/dexpace/error/auth_resolution_error.rbs b/gems/dexpace-core/sig/dexpace/error/auth_resolution_error.rbs new file mode 100644 index 0000000..470076f --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/error/auth_resolution_error.rbs @@ -0,0 +1,15 @@ +module Dexpace + # AUTH-6's resolution failure, flat under Dexpace in phase 2's error shape. + class AuthResolutionError < ::StandardError + include Dexpace::Error + + attr_reader required: Array[Auth::Scheme] + attr_reader available: Array[Auth::Scheme] + + def initialize: (required: Array[Auth::Scheme], available: Array[Auth::Scheme]) -> void + + private + + def names: (Array[Auth::Scheme] schemes) -> String + end +end diff --git a/gems/dexpace-core/sig/dexpace/instrumentation/keys.rbs b/gems/dexpace-core/sig/dexpace/instrumentation/keys.rbs index d17ff4c..19e71b0 100644 --- a/gems/dexpace-core/sig/dexpace/instrumentation/keys.rbs +++ b/gems/dexpace-core/sig/dexpace/instrumentation/keys.rbs @@ -30,6 +30,7 @@ module Dexpace INSTRUMENTATION_HOOK: String INSTRUMENTATION_SHUTDOWN: String INSTRUMENTATION_CONFIG: String + AUTH_REFRESH: String end end end diff --git a/gems/dexpace-core/test/dexpace/instrumentation/keys_test.rb b/gems/dexpace-core/test/dexpace/instrumentation/keys_test.rb index 828c634..21e74f0 100644 --- a/gems/dexpace-core/test/dexpace/instrumentation/keys_test.rb +++ b/gems/dexpace-core/test/dexpace/instrumentation/keys_test.rb @@ -41,6 +41,8 @@ class DexpaceInstrumentationKeysTest < DexpaceTestCase INSTRUMENTATION_HOOK: "http.instrumentation.hook", INSTRUMENTATION_SHUTDOWN: "http.instrumentation.shutdown", INSTRUMENTATION_CONFIG: "http.instrumentation.config", + # Phase 6c's, AUTH-37's log-and-continue: an auth-layer diagnostic, outside the prefix. + AUTH_REFRESH: "http.auth.refresh", }.freeze # Sixteen: OBS-39's named minimum plus the reserved `event` key (OBS-4), the `cause` the @@ -57,7 +59,7 @@ class DexpaceInstrumentationKeysTest < DexpaceTestCase end end - test "OBS-39, OBS-20: Events holds exactly these eight, six under the instrumentation prefix" do + test "OBS-39, OBS-20: Events holds exactly these nine, six under the instrumentation prefix" do assert_equal(EXPECTED_EVENTS.keys.sort, Events.constants.sort) EXPECTED_EVENTS.each do |name, value| constant = Events.const_get(name) diff --git a/gems/dexpace-core/test/dexpace/seam_surface_test.rb b/gems/dexpace-core/test/dexpace/seam_surface_test.rb index 05bb632..521ea0f 100644 --- a/gems/dexpace-core/test/dexpace/seam_surface_test.rb +++ b/gems/dexpace-core/test/dexpace/seam_surface_test.rb @@ -48,7 +48,7 @@ class DexpaceSeamSurfaceTest < DexpaceTestCase # 3b's one (securerandom, for HTTP-51's boundary) and phase 5a's one (time, for CFG-29's # Time#httpdate; its proxy resolver reuses phase 1's uri), all on the allowlist; phases 2, 3a # and 4 added none. - test "core requires nothing outside its own tree beyond the four stdlib features it names" do + test "core requires nothing outside its own tree beyond the five stdlib features it names" do requires = Dir.glob(File.expand_path("../../lib/**/*.rb", __dir__)) .flat_map { |path| File.readlines(path) } .grep(/^\s*require\s+["']/) @@ -56,9 +56,9 @@ class DexpaceSeamSurfaceTest < DexpaceTestCase .uniq .sort - assert_equal(%w[securerandom strscan time uri], requires, + assert_equal(%w[digest securerandom strscan time uri], requires, "SEAM-1: the only non-relative requires in core are phase 1's two, phase " \ - "3b's securerandom and phase 5a's time, all on the allowlist",) + "3b's securerandom, phase 5a's time and phase 6c's digest, all on the allowlist",) end test "the seam modules expose no instance side to be included by accident" do diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb index 43ff031..9a8a04f 100644 --- a/gems/dexpace-core/test/dexpace_test.rb +++ b/gems/dexpace-core/test/dexpace_test.rb @@ -8,15 +8,16 @@ class DexpaceTest < DexpaceTestCase # The top-level namespace is snapshotted around the require, so "defines nothing outside # Dexpace" holds whether this file loads alone or after the other five gems in one - # `rake test:gems` process, where Dexpace already exists. The four stdlib features core + # `rake test:gems` process, where Dexpace already exists. The five stdlib features core # requires (all on the require allowlist) are loaded first: the constants they define -- - # URI, StringScanner and strscan's ScanError alias, phase 3b's SecureRandom, and phase 5a's - # `time`, which pulls in Date and DateTime for Time#httpdate -- are theirs, not the entry - # file's. + # URI, StringScanner and strscan's ScanError alias, phase 3b's SecureRandom, phase 5a's + # `time`, which pulls in Date and DateTime for Time#httpdate, and phase 6c's Digest -- are + # theirs, not the entry file's. require "uri" require "strscan" require "securerandom" require "time" + require "digest" TOP_LEVEL_BEFORE = Object.constants NAMESPACE_BEFORE = defined?(Dexpace) ? Dexpace.constants(false) : [] require "dexpace" @@ -44,7 +45,8 @@ class DexpaceTest < DexpaceTestCase # 6a's Resilience::PacingParsers and Resilience::RetryStepHelpers are private_constants and # appear in no constants(false) list. Phase 5c's tracing and metrics layer and phase 5b's # logging layer add no flat constant: everything either ships is under - # Dexpace::Instrumentation, which the Layers case below pins. + # Dexpace::Instrumentation, which the Layers case below pins. Phase 6c adds two flat names, + # its namespace and AUTH-6's general resolution error. DOMAIN_MODEL = %i[ Error InvalidArgumentError Model Builder HeaderSyntax HeaderName Headers Status Method Protocol MediaType PercentEncoding Query URL RequestOptions Request Response @@ -68,9 +70,12 @@ class DexpaceTest < DexpaceTestCase BuildInfo UUID Retryability HTTPDate Clock Configuration Proxy ].freeze RESILIENCE_LAYER = %i[RetryPredicateError Resilience].freeze + # Phase 6c: the namespace and the one flat error AUTH-6 scopes generally; everything else the + # layer ships is under Dexpace::Auth, which the Layers case below pins. + AUTH_LAYER = %i[Auth AuthResolutionError].freeze LAYERS = [ DOMAIN_MODEL, SEAM_LAYER, IO_LAYER, BODY_LAYER, CONTEXT_LAYER, RECOVERY_LAYER, PIPELINE_LAYER, - CONFIGURATION_LAYER, RESILIENCE_LAYER, + CONFIGURATION_LAYER, RESILIENCE_LAYER, AUTH_LAYER, ].flatten.freeze test "defines nothing outside the Dexpace namespace" do @@ -158,6 +163,29 @@ class Layers < DexpaceTestCase assert_raises(::NameError) { Dexpace::Instrumentation::AsyncStep::Pending } end + # A consumer requires "dexpace" and nothing else: the authentication layer resolves too + # (phase 6c), under Dexpace::Auth, its one private_constant and its parser's private class as + # unreachable as Dexpace::Hooks. + test "requiring dexpace alone makes the whole authentication layer resolve" do + auth = Dexpace::Auth + + assert_equal( + %i[ + AsyncBearerStamper AsyncStep BasicHandler BearerProvider BearerStamper BearerToken + Challenge ChallengeHandlerChain Challenges Descriptor DigestHandler HTTPSRequiredError + KeyCredential KeyStamper NamedKeyCredential PasswordCredential ProviderError REDACTED + Requirement Resolver Scheme Step UnencodableCredentialError + ], + auth.constants(false).sort, + ) + assert_equal(Dexpace::AuthResolutionError, Dexpace.const_get(:AuthResolutionError)) + assert_same(Dexpace::Pipeline::Stages::AUTH, auth::Step.build(stamper: auth::Step::NO_STAMP).stage) + assert_raises(::NameError) { Dexpace::Auth::Validation } + assert_raises(::NameError) { Dexpace::Auth::Challenges::Parser } + assert_raises(::NameError) { Dexpace::Auth::DigestHandler::HASHES } + assert_raises(::NameError) { Dexpace::Auth::AsyncStep::Exchange } + end + # A consumer requires "dexpace" and nothing else: the execution context resolves too (phase # 4a), the instrumentation subsystem keeps its namespace (design §8.1), and the two private # constants are as unreachable as Dexpace::Hooks. diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index a5d8b0f..95d6384 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -50,6 +50,108 @@ Dexpace::AsyncTransport.registered_keys Dexpace::AsyncTransport.resolve Dexpace::AsyncTransport.swap Dexpace::AsyncTransport.sync_over +Dexpace::Auth +Dexpace::Auth::AsyncBearerStamper +Dexpace::Auth::AsyncBearerStamper#evict_if_matches +Dexpace::Auth::AsyncBearerStamper#stamp +Dexpace::Auth::AsyncBearerStamper#stamp_fresh +Dexpace::Auth::AsyncStep +Dexpace::Auth::AsyncStep#call +Dexpace::Auth::BasicHandler +Dexpace::Auth::BasicHandler#authorization_for +Dexpace::Auth::BasicHandler#call +Dexpace::Auth::BearerProvider +Dexpace::Auth::BearerProvider#conforms? +Dexpace::Auth::BearerProvider#fetch_async +Dexpace::Auth::BearerStamper +Dexpace::Auth::BearerStamper#call +Dexpace::Auth::BearerStamper#evict_if_matches +Dexpace::Auth::BearerStamper::DEFAULT_REFRESH_MARGIN : Integer +Dexpace::Auth::BearerToken +Dexpace::Auth::BearerToken#expired? +Dexpace::Auth::BearerToken#expiry +Dexpace::Auth::BearerToken#inspect +Dexpace::Auth::BearerToken#pretty_print +Dexpace::Auth::BearerToken#to_s +Dexpace::Auth::BearerToken#token +Dexpace::Auth::BearerToken.build +Dexpace::Auth::Challenge +Dexpace::Auth::Challenge#params +Dexpace::Auth::Challenge#scheme +Dexpace::Auth::Challenge#token68 +Dexpace::Auth::Challenge.build +Dexpace::Auth::Challenge::TOKEN68 : String +Dexpace::Auth::ChallengeHandlerChain +Dexpace::Auth::ChallengeHandlerChain#as_challenge_hook +Dexpace::Auth::ChallengeHandlerChain#authorization_for +Dexpace::Auth::ChallengeHandlerChain#header_name +Dexpace::Auth::Challenges +Dexpace::Auth::Challenges#parse +Dexpace::Auth::Descriptor +Dexpace::Auth::Descriptor#allows_anonymous? +Dexpace::Auth::Descriptor#requirements +Dexpace::Auth::Descriptor.build +Dexpace::Auth::DigestHandler +Dexpace::Auth::DigestHandler#authorization_for +Dexpace::Auth::DigestHandler::ALGORITHMS : Array +Dexpace::Auth::DigestHandler::DEFAULT_CAP : Integer +Dexpace::Auth::HTTPSRequiredError +Dexpace::Auth::HTTPSRequiredError#scheme +Dexpace::Auth::HTTPSRequiredError#step +Dexpace::Auth::KeyCredential +Dexpace::Auth::KeyCredential#header_name +Dexpace::Auth::KeyCredential#inspect +Dexpace::Auth::KeyCredential#key_value +Dexpace::Auth::KeyCredential#prefix +Dexpace::Auth::KeyCredential#to_s +Dexpace::Auth::KeyStamper +Dexpace::Auth::KeyStamper#call +Dexpace::Auth::NamedKeyCredential +Dexpace::Auth::NamedKeyCredential#header_name +Dexpace::Auth::NamedKeyCredential#inspect +Dexpace::Auth::NamedKeyCredential#key_value +Dexpace::Auth::NamedKeyCredential#name +Dexpace::Auth::NamedKeyCredential#prefix +Dexpace::Auth::NamedKeyCredential#to_s +Dexpace::Auth::PasswordCredential +Dexpace::Auth::PasswordCredential#inspect +Dexpace::Auth::PasswordCredential#password +Dexpace::Auth::PasswordCredential#pretty_print +Dexpace::Auth::PasswordCredential#to_s +Dexpace::Auth::PasswordCredential#username +Dexpace::Auth::PasswordCredential.build +Dexpace::Auth::ProviderError +Dexpace::Auth::REDACTED : String +Dexpace::Auth::Requirement +Dexpace::Auth::Requirement#params +Dexpace::Auth::Requirement#scheme +Dexpace::Auth::Requirement#scopes +Dexpace::Auth::Requirement.build +Dexpace::Auth::Resolver +Dexpace::Auth::Resolver#resolve +Dexpace::Auth::Scheme +Dexpace::Auth::Scheme#name +Dexpace::Auth::Scheme#to_s +Dexpace::Auth::Scheme#with +Dexpace::Auth::Scheme.of +Dexpace::Auth::Scheme::ALL : Array +Dexpace::Auth::Scheme::API_KEY : Dexpace::Auth::Scheme +Dexpace::Auth::Scheme::BASIC : Dexpace::Auth::Scheme +Dexpace::Auth::Scheme::DIGEST : Dexpace::Auth::Scheme +Dexpace::Auth::Scheme::NO_AUTH : Dexpace::Auth::Scheme +Dexpace::Auth::Scheme::OAUTH2 : Dexpace::Auth::Scheme +Dexpace::Auth::Step +Dexpace::Auth::Step#call +Dexpace::Auth::Step#stage +Dexpace::Auth::Step.build +Dexpace::Auth::Step::NO_REPLACEMENT : Proc +Dexpace::Auth::Step::NO_STAMP : Proc +Dexpace::Auth::UnencodableCredentialError +Dexpace::Auth::UnencodableCredentialError#encoding +Dexpace::Auth::UnencodableCredentialError#field +Dexpace::AuthResolutionError +Dexpace::AuthResolutionError#available +Dexpace::AuthResolutionError#required Dexpace::Body Dexpace::Body#== Dexpace::Body#close @@ -369,6 +471,7 @@ Dexpace::Instrumentation::Event#event Dexpace::Instrumentation::Event#field Dexpace::Instrumentation::Event::INERT : Dexpace::Instrumentation::Event::Inert Dexpace::Instrumentation::Events +Dexpace::Instrumentation::Events::AUTH_REFRESH : String Dexpace::Instrumentation::Events::HTTP_REQUEST : String Dexpace::Instrumentation::Events::HTTP_RESPONSE : String Dexpace::Instrumentation::Events::INSTRUMENTATION_CLOSE : String From c8dbc4bf26257a5018073430eee9b50aaff5e636 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 16:58:57 +0300 Subject: [PATCH 2/7] fix: name the UTF-8 branch's target and count nc after materialising Review round 0's R0-3 and R0-4, both in DigestHandler. UnencodableCredentialError always named ISO-8859-1, and its message blamed the challenge for not advertising charset=UTF-8, even when the UTF-8 branch was the one that raised -- a BINARY-tagged credential against a charset=UTF-8 challenge fails "\xE4 from ASCII-8BIT to UTF-8" and was reported as a Latin-1 failure. #materialize now takes the branch's target Encoding and the error names it; the message's reason is keyed by the target (a private REASONS table) so each branch says why its encoding applied. The same branch also refuses a UTF-8-tagged credential carrying an invalid sequence: `encode` to the same encoding passes bytes through unvalidated, so such a value would have been hashed as it was, which is the silently wrong response R10 rejects. #compute took the nonce count before hashing, so a refused attempt consumed an nc and the next response on that nonce went out one higher than the server had seen. The credential is now materialised first -- the one step that can raise -- and the count taken after it, which is the order the design's own authorization_for fence has. The RBS mirrors follow: REASONS declared, the three private signatures that carry the materialised parts and the target updated. --- .../lib/dexpace/auth/digest_handler.rb | 54 +++++++++++-------- .../auth/unencodable_credential_error.rb | 25 +++++++-- .../sig/dexpace/auth/digest_handler.rbs | 6 +-- .../auth/unencodable_credential_error.rbs | 4 +- 4 files changed, 58 insertions(+), 31 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb b/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb index d59766a..fa7d6c6 100644 --- a/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb +++ b/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb @@ -166,20 +166,24 @@ def qop_auth?(challenge) end # AUTH-17: the values one response is rendered from, for one challenge and one request. + # The credential is materialised FIRST -- the one step that can raise (AUTH-21) -- and the + # nonce count taken after it, so a refused attempt consumes no count and the next response + # on that nonce is not one higher than the server has seen (AUTH-18; the design's own order). def compute(challenge, request) + ha1_parts = credential_bytes(challenge.params) algorithm = algorithm_of(challenge).to_s cnonce = @cnonce_source.hex(16) # AUTH-20: 128 bits from a CSPRNG nonce = challenge.params.fetch("nonce") computed = Computed.new(challenge: challenge, algorithm: algorithm, cnonce: cnonce, uri: request_target(request), nc: next_count(nonce), qop: qop_auth?(challenge) ? "auth" : nil, response: nil,) - computed.with(response: response_for(computed, request.method.to_s)) + computed.with(response: response_for(computed, request.method.to_s, ha1_parts)) end # AUTH-17: HA1, HA2 over the method and the request-target, then the response. - def response_for(computed, method) + def response_for(computed, method, ha1_parts) hasher = HASHES.fetch(computed.algorithm.delete_suffix("-sess")) - ha1 = ha1_for(computed, hasher) + ha1 = ha1_for(computed, hasher, ha1_parts) ha2 = hasher.hexdigest(join(method, computed.uri)) response_digest(hasher, computed, ha1, ha2) end @@ -192,34 +196,40 @@ def response_digest(hasher, computed, ha1, ha2) hasher.hexdigest(join(ha1, nonce, computed.nc, computed.cnonce, computed.qop, ha2)) end - # H(username:realm:password), each component materialised under its own field name so - # the typed failure can say which one could not be encoded (R10), then session-keyed - # with the nonce and cnonce for a -sess algorithm. The charset token is compared with a - # bare, ASCII-only fold. - def ha1_for(computed, hasher) - params = computed.challenge.params - ha1 = hasher.hexdigest(join(*credential_bytes(params))) + # H(username:realm:password) over the materialised components, then session-keyed with + # the nonce and cnonce for a -sess algorithm. + def ha1_for(computed, hasher, ha1_parts) + ha1 = hasher.hexdigest(join(*ha1_parts)) return ha1 unless computed.algorithm.end_with?("-sess") - hasher.hexdigest(join(ha1, params.fetch("nonce"), computed.cnonce)) + hasher.hexdigest(join(ha1, computed.challenge.params.fetch("nonce"), computed.cnonce)) end - # The three HA1 components as BINARY, under AUTH-21's encoding for this challenge. + # The three HA1 components as BINARY, under AUTH-21's encoding for this challenge, each + # materialised under its own field name so the typed failure can say which one could not + # be encoded (R10). The charset token is compared with a bare, ASCII-only fold. def credential_bytes(params) utf8 = params["charset"].to_s.b.downcase == "utf-8" - [materialize(@credential.username, :username, utf8), - materialize(params.fetch("realm"), :realm, utf8), - materialize(@credential.password, :password, utf8),] + target = utf8 ? ::Encoding::UTF_8 : ::Encoding::ISO_8859_1 + [materialize(@credential.username, :username, target), + materialize(params.fetch("realm"), :realm, target), + materialize(@credential.password, :password, target),] end - # AUTH-21: UTF-8 when the challenge advertises charset=UTF-8, ISO-8859-1 otherwise -- - # and the Latin-1 branch RAISES the typed failure, never `:replace` (R10, P6-1). - def materialize(text, field, utf8) - return text.encode(::Encoding::UTF_8).b if utf8 - - text.encode(::Encoding::ISO_8859_1).b + # AUTH-21: UTF-8 when the challenge advertises charset=UTF-8, ISO-8859-1 otherwise -- and + # either branch RAISES the typed failure naming ITS target, never `:replace` (R10, P6-1). + # The Latin-1 branch is the one RFC 7616's default makes ordinary (a character Latin-1 has + # no code for). The UTF-8 branch fires only for a value that is not text under its own tag + # -- a BINARY-tagged one, whose high bytes have no UTF-8 meaning, or a UTF-8-tagged one + # with an invalid sequence, which `encode` to the same encoding passes through unvalidated + # and would otherwise be hashed as it is (verified on 3.2.11, 3.4.10 and 4.0.6). + def materialize(text, field, target) + encoded = text.encode(target) + return encoded.b if encoded.valid_encoding? + + raise UnencodableCredentialError.new(field: field, encoding: target.name) rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError => error - raise UnencodableCredentialError.new(field: field, encoding: "ISO-8859-1"), cause: error + raise UnencodableCredentialError.new(field: field, encoding: target.name), cause: error end # Every hash input is BINARY, so the joiner is too. diff --git a/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb b/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb index 9b9dea4..0b4f5c9 100644 --- a/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb +++ b/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb @@ -12,17 +12,32 @@ module Auth # represent has no Digest response at all -- not a wrong one. `:replace` would produce a # well-formed header the server rejects with a 401 that cannot be told from a wrong # password; a bare Encoding::UndefinedConversionError gives the caller no Dexpace:: type, - # no field and no encoding to act on. The message names the FIELD and the encoding, never - # the value (AUTH-8). #cause is the rescued conversion error. + # no field and no encoding to act on. The UTF-8 branch raises the same failure, naming + # UTF-8, for a value that is not text under its own tag -- a BINARY-tagged credential, or a + # UTF-8-tagged one carrying an invalid sequence -- so the message says why THIS encoding + # applied and never blames the challenge for a byte the caller supplied (6c's P6-84). The + # message names the FIELD and the encoding, never the value (AUTH-8). #cause is the rescued + # conversion error, or nil when the value was refused for its own invalid bytes. # # Filed under lib/dexpace/auth/ because the constant is namespaced under Auth, as phase # 2's Serde errors are under lib/dexpace/serde/: the file path follows the constant path. class UnencodableCredentialError < ::StandardError include Dexpace::Error + # Why each target encoding applied, keyed by its name: the half of the message that + # differs between the two branches. + REASONS = { + "UTF-8" => "the Digest challenge advertised charset=UTF-8 and the value cannot be " \ + "transcoded to it from its own encoding", + "ISO-8859-1" => "the Digest challenge did not advertise charset=UTF-8, so RFC 7616's " \ + "default encoding applies", + }.freeze + private_constant :REASONS + # @return [Symbol] :username, :realm or :password attr_reader :field - # @return [String] the target encoding's name, "ISO-8859-1" + # @return [String] the target encoding's name: "ISO-8859-1" under RFC 7616's default, + # "UTF-8" when the challenge advertised it and the value could not be transcoded to it attr_reader :encoding # @param field [Symbol] @@ -30,8 +45,8 @@ class UnencodableCredentialError < ::StandardError def initialize(field:, encoding:) @field = field @encoding = encoding - super("the #{field} cannot be encoded as #{encoding}: the Digest challenge did not " \ - "advertise charset=UTF-8, so RFC 7616's default encoding applies (AUTH-21)") + reason = REASONS.fetch(encoding, "no Digest hash input can be materialised under it") + super("the #{field} cannot be encoded as #{encoding}: #{reason} (AUTH-21)") end end end diff --git a/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs b/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs index 58d8ea6..fd5c116 100644 --- a/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs +++ b/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs @@ -53,11 +53,11 @@ module Dexpace def algorithm_of: (Challenge challenge) -> String? def qop_auth?: (Challenge challenge) -> bool def compute: (Challenge challenge, Dexpace::Request request) -> Computed - def response_for: (Computed computed, String method) -> String + def response_for: (Computed computed, String method, Array[String] ha1_parts) -> String def response_digest: (_Hasher hasher, Computed computed, String ha1, String ha2) -> String - def ha1_for: (Computed computed, _Hasher hasher) -> String + def ha1_for: (Computed computed, _Hasher hasher, Array[String] ha1_parts) -> String def credential_bytes: (Hash[String, String] params) -> Array[String] - def materialize: (String text, Symbol field, bool utf8) -> String + def materialize: (String text, Symbol field, Encoding target) -> String def join: (*String parts) -> String def next_count: (String nonce) -> String def request_target: (Dexpace::Request request) -> String diff --git a/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs b/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs index 6bfc40a..499db69 100644 --- a/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs +++ b/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs @@ -1,9 +1,11 @@ module Dexpace module Auth - # AUTH-21 (R10): the typed failure of the ISO-8859-1 branch. + # AUTH-21 (R10): the typed failure of either encoding branch, naming its target. class UnencodableCredentialError < ::StandardError include Dexpace::Error + REASONS: Hash[String, String] + attr_reader field: Symbol attr_reader encoding: String From 7bd8aa438534f49461a75116bebf6d630d245c69 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 17:00:56 +0300 Subject: [PATCH 3/7] fix: freeze the challenge parser's eight scanner patterns Review round 0's R0-2. Regexp.new, unlike a Regexp literal, returns an unfrozen object, so the parser's eight timeout-compiled patterns were the one set of core patterns not frozen at load: phase 5a's HTTPDate::GRAMMAR freezes and its suite pins the property. The eight now freeze the same way, so the tests branch can pin "a private, frozen Regexp with a per-pattern timeout" on each of them and a removed `timeout:` runs red instead of surviving. Private constants; no surface change. --- .../lib/dexpace/auth/challenges.rb | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/auth/challenges.rb b/gems/dexpace-core/lib/dexpace/auth/challenges.rb index 2142d64..a92f2fa 100644 --- a/gems/dexpace-core/lib/dexpace/auth/challenges.rb +++ b/gems/dexpace-core/lib/dexpace/auth/challenges.rb @@ -13,8 +13,9 @@ module Auth # may hold the list's own delimiters), and a hostile WWW-Authenticate must not be able to # drive a backtracking engine (design §6.3). The eight patterns it does use are fixed # character classes with no alternation inside a repetition, compiled with the tree's - # per-pattern timeout; every loop iteration consumes at least one byte, so the parse is - # linear in the input and the suite measures it rather than trusting the claim. + # per-pattern timeout and frozen, as HTTPDate's grammar is (Regexp.new, unlike a literal, + # returns an unfrozen object); every loop iteration consumes at least one byte, so the + # parse is linear in the input and the suite measures it rather than trusting the claim. # # Public, like Dexpace::HTTPDate: a caller writing a challenge handler for a scheme this SDK # does not implement needs the same lenient parser, and it carries no credential-shaped @@ -39,22 +40,22 @@ module Challenges extend self # RFC 7230's tchar set: the auth-scheme and every parameter name and token value. - TOKEN = Regexp.new("[!#$%&'*+\\-.^_`|~0-9A-Za-z]+", timeout: 1.0) + TOKEN = Regexp.new("[!#$%&'*+\\-.^_`|~0-9A-Za-z]+", timeout: 1.0).freeze # RFC 7235's token68: TOKEN's letters and digits plus `-._~+/`, then base64 padding, # which TOKEN excludes -- so `Bearer dGhl…==` is not readable as a parameter. - TOKEN68 = Regexp.new("[A-Za-z0-9\\-._~+/]+=*", timeout: 1.0) + TOKEN68 = Regexp.new("[A-Za-z0-9\\-._~+/]+=*", timeout: 1.0).freeze # One or more list separators with their whitespace: what sits between two elements. - SEPARATORS = Regexp.new("[ \\t]*,[ \\t,]*", timeout: 1.0) + SEPARATORS = Regexp.new("[ \\t]*,[ \\t,]*", timeout: 1.0).freeze # Whitespace, then a list boundary: a comma or the end of input. Checked, never consumed. - BOUNDARY = Regexp.new("[ \\t]*(?:,|\\z)", timeout: 1.0) + BOUNDARY = Regexp.new("[ \\t]*(?:,|\\z)", timeout: 1.0).freeze # A parameter's `=` with the bad whitespace RFC 7235 tolerates on either side. - EQUALS = Regexp.new("[ \\t]*=[ \\t]*", timeout: 1.0) + EQUALS = Regexp.new("[ \\t]*=[ \\t]*", timeout: 1.0).freeze # The whitespace between the scheme and what follows it. - SPACES = Regexp.new("[ \\t]+", timeout: 1.0) + SPACES = Regexp.new("[ \\t]+", timeout: 1.0).freeze # Optional whitespace at the start of an element, which recovery leaves behind. - OWS = Regexp.new("[ \\t]*", timeout: 1.0) + OWS = Regexp.new("[ \\t]*", timeout: 1.0).freeze # The opening quote of a quoted-string. - QUOTE = Regexp.new("\"", timeout: 1.0) + QUOTE = Regexp.new("\"", timeout: 1.0).freeze private_constant :TOKEN, :TOKEN68, :SEPARATORS, :BOUNDARY, :EQUALS, :SPACES, :OWS, :QUOTE # The parse itself: `nil`, blank input and an input of nothing but separators all yield From 7d5ecf9c3023a4a3b8a2d6f558a1be00b3f92e1a Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 18:22:12 +0300 Subject: [PATCH 4/7] fix: close the 401 on a settled non-request and drop the leaking cause Review round 1's R1-1 and R1-3. AsyncStep validated a challenge hook's future-settled value outside the frame that closes the 401: a hook future fulfilling with a non-request failed the step's future with the 401 body left open, contradicting the class comment and AUTH-32. Step#consult's rescue becomes one closing_on_error(response) frame both runtimes use; AsyncStep overrides consult to pass a future through and checks the settled value inside the same frame, so a String, or a future of a future, closes the 401 before the frame fails the future. replacement! is strict everywhere. UnencodableCredentialError carried the rescued conversion error as its cause, whose message names the offending character (U+65E5) or byte ("\xE4") of the secret, and full_message renders a cause on every supported Ruby (AUTH-8). Both raises in DigestHandler#materialize now spell cause: nil, and the error carries the value's own encoding as #source_encoding and in its message instead, so the diagnostic loses only the character. BasicHandler raised the bare Encoding error for a BINARY-tagged or invalid UTF-8-tagged field; each field is now transcoded under its own name and refused as an InvalidArgumentError naming the field and the two encodings, cause nil. RBS mirrors follow; the surface manifest gains the one new reader. --- .../lib/dexpace/auth/async_step.rb | 35 +++++++---- .../lib/dexpace/auth/basic_handler.rb | 58 +++++++++++++++---- .../lib/dexpace/auth/digest_handler.rb | 16 +++-- gems/dexpace-core/lib/dexpace/auth/step.rb | 15 ++++- .../auth/unencodable_credential_error.rb | 24 ++++++-- .../sig/dexpace/auth/async_step.rbs | 4 +- .../sig/dexpace/auth/basic_handler.rbs | 6 ++ .../sig/dexpace/auth/digest_handler.rbs | 1 + gems/dexpace-core/sig/dexpace/auth/step.rbs | 1 + .../auth/unencodable_credential_error.rbs | 6 +- test/fixtures/surface/dexpace-core.txt | 1 + 11 files changed, 129 insertions(+), 38 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/auth/async_step.rb b/gems/dexpace-core/lib/dexpace/auth/async_step.rb index 9cfec51..ecd8f77 100644 --- a/gems/dexpace-core/lib/dexpace/auth/async_step.rb +++ b/gems/dexpace-core/lib/dexpace/auth/async_step.rb @@ -175,21 +175,34 @@ def replay_async(challenge, exchange) result = consult(challenge, exchange.stamped, exchange.response) return replace(result, exchange) unless result.is_a?(Dexpace::Async::Future) - observe(result, exchange.completer) do |settlement| - if settlement.success? - replace(replacement!(settlement.response), exchange) - else - Dexpace.close_quietly(exchange.response, onto: settlement.error) # AUTH-32 - forward_failure(settlement, exchange.completer) - end + observe(result, exchange.completer) { |settlement| settle_replay(settlement, exchange) } + end + + # The hook's future settled: its value replayed through the same check and gate as a + # direct answer, or its failure forwarded with the 401 closed first (AUTH-32). + def settle_replay(settlement, exchange) + if settlement.success? + replace(settled_replacement!(settlement.response, exchange.response), exchange) + else + Dexpace.close_quietly(exchange.response, onto: settlement.error) + forward_failure(settlement, exchange.completer) end end - # The hook may hand back a future; the sync check is deferred to its settlement. - def replacement!(replacement) - return replacement if replacement.is_a?(Dexpace::Async::Future) + # The hook may hand back a future (P6-78), passed through here and checked once it + # settles; a direct answer meets the sync check, and a raise closes the 401 (AUTH-32). + def consult(challenge, stamped, response) + closing_on_error(response) do + result = @challenge_hook.call(challenge, stamped, response) + result.is_a?(Dexpace::Async::Future) ? result : replacement!(result) + end + end - super + # AUTH-32's third clause once the hook's future settles: a value that is not a request or + # nil -- a future of a future included -- closes the open 401 before the frame fails the + # step's future, exactly as the sync #consult does for a direct answer (review round 1). + def settled_replacement!(replacement, response) + closing_on_error(response) { replacement!(replacement) } end # AUTH-30, AUTH-31: nil or a non-replayable replacement surfaces the 401 (unclosed); diff --git a/gems/dexpace-core/lib/dexpace/auth/basic_handler.rb b/gems/dexpace-core/lib/dexpace/auth/basic_handler.rb index 634ba7b..2af28ad 100644 --- a/gems/dexpace-core/lib/dexpace/auth/basic_handler.rb +++ b/gems/dexpace-core/lib/dexpace/auth/basic_handler.rb @@ -27,21 +27,21 @@ module Auth # RFC 7617" -- and not AUTH-9's non-blank one (6c's P6-3): a password of three spaces is a # legal Basic password. Nothing here re-validates the header at the wire; that is the # transport adapter's re-validation pass (phase 8). + # + # A field that cannot be transcoded to UTF-8 -- a BINARY-tagged one with a high byte, or a + # UTF-8-tagged one with an invalid sequence, which `encode` to the same encoding passes + # through unvalidated -- is refused at construction as an InvalidArgumentError naming the + # FIELD and the two encodings, never the value, and carrying no cause: Ruby's conversion + # error names the offending byte of the secret, and #full_message renders a cause (AUTH-8; + # 6c's P6-85, review round 1). InvalidArgumentError's usual "the original left as the + # cause" rule yields to that, for a credential. class BasicHandler # @param credential [PasswordCredential] - # @raise [Dexpace::InvalidArgumentError] on an empty username or password (AUTH-14) + # @raise [Dexpace::InvalidArgumentError] on an empty username or password (AUTH-14), a + # colon in the username, or a field that is not text UTF-8 can carry def initialize(credential) - unless credential.is_a?(PasswordCredential) - raise InvalidArgumentError, "a Dexpace::Auth::PasswordCredential is required" - end - if credential.username.empty? || credential.password.empty? - raise InvalidArgumentError, "username and password must be non-empty (AUTH-14)" - end - if credential.username.include?(":") - raise InvalidArgumentError, "a Basic username must not contain a colon (RFC 7617 §2)" - end - - pair = "#{credential.username}:#{credential.password}".encode(::Encoding::UTF_8) + credential!(credential) + pair = "#{utf8!(credential.username, :username)}:#{utf8!(credential.password, :password)}" @value = "Basic #{[pair].pack("m0")}".freeze freeze end @@ -69,6 +69,40 @@ def authorization_for(challenges, _request, proxy: false) # rubocop:disable Lint @value end + + private + + def credential!(credential) + unless credential.is_a?(PasswordCredential) + raise InvalidArgumentError, "a Dexpace::Auth::PasswordCredential is required" + end + if credential.username.empty? || credential.password.empty? + raise InvalidArgumentError, "username and password must be non-empty (AUTH-14)" + end + return unless credential.username.include?(":") + + raise InvalidArgumentError, "a Basic username must not contain a colon (RFC 7617 §2)" + end + + # AUTH-14 names the UTF-8 bytes of the pair, so each field is transcoded under its own + # name and must be valid text once it is; the failure is typed, names no value or byte, + # and carries no cause (see the class comment). + def utf8!(text, field) + encoded = text.encode(::Encoding::UTF_8) + return encoded if encoded.valid_encoding? + + raise not_utf8(text, field), cause: nil + rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError + raise not_utf8(text, field), cause: nil + end + + def not_utf8(text, field) + InvalidArgumentError.new( + "the #{field} cannot be encoded as UTF-8 from #{text.encoding.name}: a Basic " \ + "credential is the UTF-8 bytes of username:password, and the value is not text " \ + "UTF-8 can carry (AUTH-14)", + ) + end end end end diff --git a/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb b/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb index fa7d6c6..37669ff 100644 --- a/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb +++ b/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb @@ -222,14 +222,22 @@ def credential_bytes(params) # no code for). The UTF-8 branch fires only for a value that is not text under its own tag # -- a BINARY-tagged one, whose high bytes have no UTF-8 meaning, or a UTF-8-tagged one # with an invalid sequence, which `encode` to the same encoding passes through unvalidated - # and would otherwise be hashed as it is (verified on 3.2.11, 3.4.10 and 4.0.6). + # and would otherwise be hashed as it is (verified on 3.2.11, 3.4.10 and 4.0.6). The + # rescued conversion error is NOT the cause: its message names the offending character of + # the secret, and #full_message renders a cause (AUTH-8; 6c's P6-85). `cause: nil` on both + # raises, so neither picks up a caller's in-flight `$!` either. def materialize(text, field, target) encoded = text.encode(target) return encoded.b if encoded.valid_encoding? - raise UnencodableCredentialError.new(field: field, encoding: target.name) - rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError => error - raise UnencodableCredentialError.new(field: field, encoding: target.name), cause: error + raise unencodable(text, field, target), cause: nil + rescue ::Encoding::UndefinedConversionError, ::Encoding::InvalidByteSequenceError + raise unencodable(text, field, target), cause: nil + end + + def unencodable(text, field, target) + UnencodableCredentialError.new(field: field, encoding: target.name, + source_encoding: text.encoding.name,) end # Every hash input is BINARY, so the joiner is too. diff --git a/gems/dexpace-core/lib/dexpace/auth/step.rb b/gems/dexpace-core/lib/dexpace/auth/step.rb index 11689ba..de7f371 100644 --- a/gems/dexpace-core/lib/dexpace/auth/step.rb +++ b/gems/dexpace-core/lib/dexpace/auth/step.rb @@ -182,10 +182,19 @@ def replay(challenge, stamped, response, cursor) end # AUTH-32: a hook that raises, or returns something that is not a request, leaves the - # open 401 closed behind it -- the close failure, if any, on the error's suppressed trail. + # open 401 closed behind it. def consult(challenge, stamped, response) - replacement = @challenge_hook.call(challenge, stamped, response) - replacement!(replacement) + closing_on_error(response) do + replacement!(@challenge_hook.call(challenge, stamped, response)) + end + end + + # AUTH-32's one closing frame, for both runtimes: a raise inside the block closes the open + # 401 before propagating -- the close failure, if any, on the error's suppressed trail. The + # async step wraps its hook's SETTLED value in it too, so a future that fulfils with a + # non-request closes the 401 exactly as a synchronous non-request does (review round 1). + def closing_on_error(response) + yield rescue ::StandardError => error Dexpace.close_quietly(response, onto: error) raise diff --git a/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb b/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb index 0b4f5c9..0b8f6dc 100644 --- a/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb +++ b/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb @@ -16,8 +16,16 @@ module Auth # UTF-8, for a value that is not text under its own tag -- a BINARY-tagged credential, or a # UTF-8-tagged one carrying an invalid sequence -- so the message says why THIS encoding # applied and never blames the challenge for a byte the caller supplied (6c's P6-84). The - # message names the FIELD and the encoding, never the value (AUTH-8). #cause is the rescued - # conversion error, or nil when the value was refused for its own invalid bytes. + # message names the FIELD, the target encoding and the value's own encoding, never the value + # (AUTH-8). + # + # #cause is ALWAYS nil, and the source encoding is a member instead (6c's P6-85): Ruby's + # conversion error names the offending character (`U+65E5 from UTF-8 to ISO-8859-1`) or byte + # (`"\xE4" from ASCII-8BIT to UTF-8`), which is a character of the secret, and #full_message + # renders a cause on every supported Ruby -- so R10's "the rescued conversion error as #cause" + # was the one diagnostic rendering through which a credential could leak, and it is dropped. + # Review round 1 (2026-09-18) found it. The design's `raise …, cause:` discipline is kept: + # the value is nil, explicitly, so a raise inside a caller's rescue picks up no `$!` either. # # Filed under lib/dexpace/auth/ because the constant is namespaced under Auth, as phase # 2's Serde errors are under lib/dexpace/serde/: the file path follows the constant path. @@ -28,7 +36,7 @@ class UnencodableCredentialError < ::StandardError # differs between the two branches. REASONS = { "UTF-8" => "the Digest challenge advertised charset=UTF-8 and the value cannot be " \ - "transcoded to it from its own encoding", + "transcoded to it from its own encoding, or is not valid text under its own tag", "ISO-8859-1" => "the Digest challenge did not advertise charset=UTF-8, so RFC 7616's " \ "default encoding applies", }.freeze @@ -39,14 +47,20 @@ class UnencodableCredentialError < ::StandardError # @return [String] the target encoding's name: "ISO-8859-1" under RFC 7616's default, # "UTF-8" when the challenge advertised it and the value could not be transcoded to it attr_reader :encoding + # @return [String] the value's own encoding name ("UTF-8", "ASCII-8BIT", …): the part of + # the dropped conversion error's message that was NOT a character of the secret + attr_reader :source_encoding # @param field [Symbol] # @param encoding [String] - def initialize(field:, encoding:) + # @param source_encoding [String] + def initialize(field:, encoding:, source_encoding:) @field = field @encoding = encoding + @source_encoding = source_encoding reason = REASONS.fetch(encoding, "no Digest hash input can be materialised under it") - super("the #{field} cannot be encoded as #{encoding}: #{reason} (AUTH-21)") + super("the #{field} cannot be encoded as #{encoding} from #{source_encoding}: #{reason} " \ + "(AUTH-21)") end end end diff --git a/gems/dexpace-core/sig/dexpace/auth/async_step.rbs b/gems/dexpace-core/sig/dexpace/auth/async_step.rbs index 649ccdd..4dfd529 100644 --- a/gems/dexpace-core/sig/dexpace/auth/async_step.rbs +++ b/gems/dexpace-core/sig/dexpace/auth/async_step.rbs @@ -33,7 +33,9 @@ module Dexpace def drive_replacement: (Dexpace::Async::Future restamped, Exchange exchange) -> void def restamp: (Dexpace::Request stamped, bool evicted) -> Dexpace::Async::Future def replay_async: (String challenge, Exchange exchange) -> void - def replacement!: (untyped replacement) -> untyped + def settle_replay: (Dexpace::Async::Settlement settlement, Exchange exchange) -> void + def consult: (String challenge, Dexpace::Request stamped, Dexpace::Response response) -> untyped + def settled_replacement!: (untyped replacement, Dexpace::Response response) -> untyped def replace: (untyped replacement, Exchange exchange) -> void end end diff --git a/gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs b/gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs index 3a1c327..2bdf721 100644 --- a/gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs +++ b/gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs @@ -10,6 +10,12 @@ module Dexpace def call: (Dexpace::Request request) -> Dexpace::Request def authorization_for: (Array[Challenge] challenges, Dexpace::Request _request, ?proxy: bool) -> String? + + private + + def credential!: (PasswordCredential credential) -> void + def utf8!: (String text, Symbol field) -> String + def not_utf8: (String text, Symbol field) -> InvalidArgumentError end end end diff --git a/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs b/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs index fd5c116..7f9224c 100644 --- a/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs +++ b/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs @@ -58,6 +58,7 @@ module Dexpace def ha1_for: (Computed computed, _Hasher hasher, Array[String] ha1_parts) -> String def credential_bytes: (Hash[String, String] params) -> Array[String] def materialize: (String text, Symbol field, Encoding target) -> String + def unencodable: (String text, Symbol field, Encoding target) -> UnencodableCredentialError def join: (*String parts) -> String def next_count: (String nonce) -> String def request_target: (Dexpace::Request request) -> String diff --git a/gems/dexpace-core/sig/dexpace/auth/step.rbs b/gems/dexpace-core/sig/dexpace/auth/step.rbs index abeadc7..24bd214 100644 --- a/gems/dexpace-core/sig/dexpace/auth/step.rbs +++ b/gems/dexpace-core/sig/dexpace/auth/step.rbs @@ -43,6 +43,7 @@ module Dexpace def replay: (String challenge, Dexpace::Request stamped, Dexpace::Response response, Pipeline::Cursor cursor) -> Dexpace::Response def consult: (String challenge, Dexpace::Request stamped, Dexpace::Response response) -> untyped + def closing_on_error: [T] (Dexpace::Response response) { () -> T } -> T def replacement!: (untyped replacement) -> untyped end end diff --git a/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs b/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs index 499db69..e255bdb 100644 --- a/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs +++ b/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs @@ -1,6 +1,7 @@ module Dexpace module Auth - # AUTH-21 (R10): the typed failure of either encoding branch, naming its target. + # AUTH-21 (R10): the typed failure of either encoding branch, naming its target and the + # value's own encoding; #cause is always nil (P6-85). class UnencodableCredentialError < ::StandardError include Dexpace::Error @@ -8,8 +9,9 @@ module Dexpace attr_reader field: Symbol attr_reader encoding: String + attr_reader source_encoding: String - def initialize: (field: Symbol, encoding: String) -> void + def initialize: (field: Symbol, encoding: String, source_encoding: String) -> void end end end diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index 95d6384..03b277c 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -149,6 +149,7 @@ Dexpace::Auth::Step::NO_STAMP : Proc Dexpace::Auth::UnencodableCredentialError Dexpace::Auth::UnencodableCredentialError#encoding Dexpace::Auth::UnencodableCredentialError#field +Dexpace::Auth::UnencodableCredentialError#source_encoding Dexpace::AuthResolutionError Dexpace::AuthResolutionError#available Dexpace::AuthResolutionError#required From c8d26fe22f28ac3c4db004683065132e67f2b8ef Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 19:41:25 +0300 Subject: [PATCH 5/7] fix: settle each bearer waiter from the shared fetch, never through it Review round 2's R2-1 and R2-4, both in AsyncBearerStamper. The expired-or-missing zone derived the caller's future from the single-flight slot through Future#then, and #then wires the derived future's cancellation back to its source. The source here is the ONE slot every coalesced caller shares, and every arrival until the provider settles, so cancelling one request's future -- which AsyncStep#observe forwards from the step's future -- cancelled every other waiter, and every new request coalesced onto an already-cancelled future until the provider settled (AUTH-37, SEAM-18). R12 prescribes a second #on_settle and a second Completer; the stamper now builds each waiter's future that way, settled FROM the slot's settlement and never wired back to it, so cancelling a waiter detaches that waiter alone and the fetch, the cache and the other waiters are untouched. One private #settle classifies both completers by Future#then's three rules: a cancellation stays a cancellation (a provider that cancels its own fetch cancels the slot and every waiter with its reason), a failure is the same object, a success settles with the block's value; a stamp the outbound header grammar refuses fails that waiter only. The class comment's line beginning `@lock` read to YARD as an unknown tag; reworded. RBS mirror follows; no public surface changes. --- .../lib/dexpace/auth/async_bearer_stamper.rb | 64 +++++++++++++++---- .../sig/dexpace/auth/async_bearer_stamper.rbs | 4 ++ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb b/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb index bdf725e..5f156b3 100644 --- a/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb +++ b/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb @@ -8,6 +8,7 @@ require_relative "../clock" require_relative "../async/completer" require_relative "../async/future" +require_relative "../error/cancelled_error" require_relative "../instrumentation/keys" require_relative "../instrumentation/logger" require_relative "../instrumentation/contain" @@ -27,17 +28,27 @@ module Auth # Three zones, read off one lock-free token reference (XCUT-12): FRESH (not expired with # the margin) stamps and makes no provider call; EXPIRING-BUT-VALID (expired with the # margin, not without it) stamps the still-valid token at once and kicks off a refresh it - # does not await; EXPIRED-OR-MISSING derives the stamped request from the in-flight fetch - # through Future#then. Every refresh goes through ONE single-flight slot: the first caller - # registers a Completer under @lock and starts the fetch; every later caller, from either - # zone, coalesces onto that future. A failed fetch settles the waiters with the error and - # caches nothing; a failed BACKGROUND refresh is reported through `logger:` as an - # `http.auth.refresh` diagnostic and fails nothing, since a valid token was already stamped. + # does not await; EXPIRED-OR-MISSING settles a Completer of its own from the in-flight + # fetch's settlement -- R12's "second #on_settle and a second Completer". Every refresh goes + # through ONE single-flight slot: the first caller registers a Completer under @lock and + # starts the fetch; every later caller, from either zone, coalesces onto that future. A + # failed fetch settles the waiters with the error and caches nothing; a failed BACKGROUND + # refresh is reported through `logger:` as an `http.auth.refresh` diagnostic and fails + # nothing, since a valid token was already stamped. + # + # The fetch is shared; a cancellation is not. The waiter's future is settled FROM the slot's + # and never wired back to it: Future#then would register the derived future's cancellation + # against its source, which here is the one slot every coalesced caller and every arrival + # until the provider settles share, so cancelling one request's future would cancel them + # all (review round 2). Cancelling a waiter detaches that waiter alone -- the fetch runs on, + # the token is cached, the other waiters stamp it. Only the provider's own settlement + # settles the slot, and a cancellation there is forwarded as a cancellation, not as a plain + # failure, so `#cancelled?` stays true one link down (SEAM-18, 4c's rule). # # The fetch is started OUTSIDE the lock, and that is not a style choice. AUTH-11's default # wrapper mirrors a sync-only provider's #fetch into an ALREADY-SETTLED future, on which # phase 2's #on_settle runs the block inline on the calling fiber; the settle block takes - # @lock to publish the token, and Thread::Mutex is not reentrant, so starting the fetch + # the lock to publish the token, and Thread::Mutex is not reentrant, so starting the fetch # inside `synchronize` raises `ThreadError: deadlock; recursive locking` for the commonest # provider shape there is (verified on 3.2.11, 3.4.10 and 4.0.6). Register, release, fetch. # @@ -45,7 +56,7 @@ module Auth # three-zone read and settles on a coalesced fetch, so the retry can never re-send the # token the server just rejected. AsyncStep calls it after a successful eviction, and # #stamp after a failed one, where AUTH-36 says the refreshed token is reused. - class AsyncBearerStamper + class AsyncBearerStamper # rubocop:disable Metrics/ClassLength -- R12's two Completers written out: the slot, the waiter settled from it and never wired back, and the one SEAM-18 classification both settle by; see the class comment # @param provider [Object] anything answering #fetch, and optionally #fetch_async # @param clock [_Clock] the time seam; Clock::SYSTEM by default # @param refresh_margin [Numeric] seconds before expiry at which a token is refreshed @@ -122,10 +133,22 @@ def zone(token) token.expired?(now: now, margin: 0) ? :expired : :expiring end - # The expired-or-missing zone's return: the stamped request derived from the coalesced - # fetch through Future#then, which never blocks. + # The expired-or-missing zone's return: this request's own Completer, settled from the + # coalesced fetch's settlement and never blocking. Not Future#then -- see the class + # comment for why the waiter must not be wired back to the shared slot. def awaiting(request) - refresh_future.then { |fresh| stamp_with(request, fresh) } + own = Dexpace::Async::Completer.new + refresh_future.on_settle { |settlement| deliver(settlement, request, own) } + own.future + end + + # The waiter's settlement from the slot's. The rescue keeps a raising stamp (a token the + # outbound header grammar refuses) on this side of the settling thread, as a failure of + # this waiter alone. + def deliver(settlement, request, own) + settle(own, settlement, settlement.error) { |token| stamp_with(request, token) } + rescue ::StandardError => error + own.fail(error) end def header(token) = "Bearer #{token.token}" @@ -161,7 +184,9 @@ def refresh_future # raises, so the only way out of here is the settle block, which clears the slot and # writes the cache under the lock and then settles the waiters outside it. A settle # block that raised would propagate into whoever settled the provider's future, so - # nothing in it can raise: Completer#fulfil and #fail report rather than raise. + # nothing in it can raise: Completer#fulfil, #fail and #request_cancel report rather + # than raise. A provider that cancels its own fetch cancels the slot, and through it every + # waiter, as a cancellation. def start_fetch(completer) BearerProvider.fetch_async(@provider).on_settle do |settlement| token = settlement.success? ? settlement.response : nil @@ -170,7 +195,20 @@ def start_fetch(completer) @in_flight = nil @token = token if error.nil? end - error.nil? ? completer.fulfil(token) : completer.fail(error) + settle(completer, settlement, error) { token } + end + end + + # The one classification both completers settle by, Future#then's three rules: a + # cancellation of `settlement` stays a cancellation (SEAM-18), any other `error` is the + # same object, and a success settles with what the block makes of the response. + def settle(target, settlement, error) + if error.nil? + target.fulfil(yield(settlement.response)) + elsif settlement.cancelled && error.is_a?(Dexpace::CancelledError) + target.request_cancel(error.reason) + else + target.fail(error) end end diff --git a/gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs b/gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs index d4b057e..1131a75 100644 --- a/gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs +++ b/gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs @@ -21,11 +21,15 @@ module Dexpace def zone: (BearerToken token) -> Symbol def awaiting: (Dexpace::Request request) -> Dexpace::Async::Future + def deliver: (Dexpace::Async::Settlement settlement, Dexpace::Request request, + Dexpace::Async::Completer own) -> void def header: (BearerToken token) -> String def stamp_with: (Dexpace::Request request, BearerToken token) -> Dexpace::Request def settled: (Dexpace::Request request) -> Dexpace::Async::Future def refresh_future: () -> Dexpace::Async::Future def start_fetch: (Dexpace::Async::Completer completer) -> void + def settle: (Dexpace::Async::Completer target, Dexpace::Async::Settlement settlement, + Exception? error) { (untyped) -> untyped } -> void def invalid: (untyped token) -> Exception? def background_refresh: () -> void end From 1fbc41d8b36af7d3fb8cb31fb5f2fb806219edd9 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 21:31:48 +0300 Subject: [PATCH 6/7] fix: refuse a bearer token the header grammar cannot carry, uncached Review round 3's R3-1, in both bearer stampers. AUTH-35's validation checked a fetched token for nil, class and expiry and nothing else, so a token whose `Bearer ` wire form the outbound header grammar refuses (HTTP-18: a trailing newline read off a file, a CR) was written into the cache. It can never be sent, so no 401 can ever arrive to evict it (AUTH-36), and it stays until it expires -- never, for a token with no expiry: the sync stamper raised HTTP-18's InvalidArgumentError on every later call with the provider never asked again, and the async stamper's fresh zone raised it synchronously out of a method that returns a Future, failing every later request through an AsyncStep with the transport never reached. AUTH-35 wants a misbehaving provider result uncached so a later request retries. The grammar check is now the fourth rejection in BearerStamper#validate and AsyncBearerStamper#invalid, a ProviderError whose message never names the token: nothing is cached, the sync call raises from inside the lock with @token untouched, the async waiters fail with it, the slot clears, and the next call fetches again. Checked where the token arrives, as KeyStamper checks its key where IT arrives (construction), not in BearerToken.build, whose contract is AUTH-9's non-blank rule. A cached token therefore always stamps, so #stamp's fresh and expiring zones cannot raise; #deliver's rescue stays for a request whose own derivation refuses. Comments on both stampers and ProviderError follow; no public surface changes, the RBS mirrors are unchanged. --- .../lib/dexpace/auth/async_bearer_stamper.rb | 32 +++++++++++++------ .../lib/dexpace/auth/bearer_stamper.rb | 21 +++++++++--- .../lib/dexpace/auth/provider_error.rb | 10 +++--- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb b/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb index 5f156b3..b00ac1d 100644 --- a/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb +++ b/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb @@ -5,6 +5,7 @@ require_relative "../model" require_relative "../error/invalid_argument_error" require_relative "../http/headers" +require_relative "../http/header_syntax" require_relative "../clock" require_relative "../async/completer" require_relative "../async/future" @@ -32,9 +33,13 @@ module Auth # fetch's settlement -- R12's "second #on_settle and a second Completer". Every refresh goes # through ONE single-flight slot: the first caller registers a Completer under @lock and # starts the fetch; every later caller, from either zone, coalesces onto that future. A - # failed fetch settles the waiters with the error and caches nothing; a failed BACKGROUND - # refresh is reported through `logger:` as an `http.auth.refresh` diagnostic and fails - # nothing, since a valid token was already stamped. + # failed fetch settles the waiters with the error and caches nothing -- and a fetch that + # lands one of AUTH-35's rejections (nil, a non-BearerToken, already expired, or a token + # whose wire form the outbound header grammar refuses, BearerStamper's same four) is a + # failed fetch: nothing is cached, the waiters fail with a ProviderError, the slot clears + # and the next call fetches again. A failed BACKGROUND refresh is reported through + # `logger:` as an `http.auth.refresh` diagnostic and fails nothing, since a valid token was + # already stamped. # # The fetch is shared; a cancellation is not. The waiter's future is settled FROM the slot's # and never wired back to it: Future#then would register the derived future's cancellation @@ -142,9 +147,11 @@ def awaiting(request) own.future end - # The waiter's settlement from the slot's. The rescue keeps a raising stamp (a token the - # outbound header grammar refuses) on this side of the settling thread, as a failure of - # this waiter alone. + # The waiter's settlement from the slot's. The rescue keeps a raising stamp on this side of + # the settling thread, as a failure of this waiter alone: a cached token has passed the + # grammar check, so what is left to raise here is the request's own derivation (a forged + # or duck-typed request whose #with refuses), and it must not land on whoever settled the + # provider's future. def deliver(settlement, request, own) settle(own, settlement, settlement.error) { |token| stamp_with(request, token) } rescue ::StandardError => error @@ -212,15 +219,22 @@ def settle(target, settlement, error) end end - # AUTH-35's rejections as an error value, or nil for a usable token. + # AUTH-35's rejections as an error value, or nil for a usable token: BearerStamper#validate's + # four, the fourth being a token no outbound header value may carry (HTTP-18) -- cached, it + # would fail every later #stamp until it expired, and raise from the fresh zone rather than + # settle. The message never carries the token (HTTP-20, AUTH-8). def invalid(token) return ProviderError.new("the provider returned no token (AUTH-35)") if token.nil? unless token.is_a?(BearerToken) return ProviderError.new("the provider returned a #{token.class}, not a BearerToken") end - return nil unless token.expired?(now: @clock.now, margin: 0) + if token.expired?(now: @clock.now, margin: 0) + return ProviderError.new("the provider returned a token already expired at fetch time") + end + return nil if HeaderSyntax.valid_outbound_value?(header(token)) - ProviderError.new("the provider returned a token already expired at fetch time") + ProviderError.new("the provider returned a token no outbound header value may carry " \ + "(HTTP-18)") end # The expiring zone's refresh: coalesced like any other, observed only to log a failure. diff --git a/gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb b/gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb index a48e7e5..40d8a0c 100644 --- a/gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb +++ b/gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb @@ -5,6 +5,7 @@ require_relative "../model" require_relative "../error/invalid_argument_error" require_relative "../http/headers" +require_relative "../http/header_syntax" require_relative "../clock" require_relative "bearer_token" require_relative "bearer_provider" @@ -27,9 +28,15 @@ module Auth # it can serialise nothing else. # # AUTH-35's rejections -- a nil token, a token already expired at fetch time with NO margin, - # a non-BearerToken -- raise ProviderError from inside the lock with @token untouched, and a - # provider that raises propagates its own error the same way; nothing is cached on any of - # those paths, so a later request retries (AUTH-11). + # a non-BearerToken, and a token whose `Bearer ` wire form the outbound header grammar + # refuses (HTTP-18: a trailing newline read off a file, a CR) -- raise ProviderError from + # inside the lock with @token untouched, and a provider that raises propagates its own error + # the same way; nothing is cached on any of those paths, so a later request retries + # (AUTH-11). The fourth rejection is the port's: a token the grammar refuses can never be + # sent, so no 401 can ever arrive to evict it (AUTH-36), and caching it would fail every + # request until it expired -- forever, for a token with no expiry. Checked here, where the + # token arrives, as KeyStamper checks its key where IT arrives (construction), rather than in + # BearerToken.build, whose contract is AUTH-9's non-blank rule and nothing more. class BearerStamper # AUTH-34's default refresh margin, in seconds. DEFAULT_REFRESH_MARGIN = 30 @@ -97,7 +104,9 @@ def refresh! end end - # AUTH-35: non-nil, a BearerToken, and not already expired with NO margin. + # AUTH-35: non-nil, a BearerToken, not already expired with NO margin, and -- the port's + # fourth rejection -- carriable by an outbound header (HTTP-18). The message never carries + # the token (HTTP-20, AUTH-8). def validate(fetched) raise ProviderError, "the provider returned no token (AUTH-35)" if fetched.nil? unless fetched.is_a?(BearerToken) @@ -106,6 +115,10 @@ def validate(fetched) if fetched.expired?(now: @clock.now, margin: 0) raise ProviderError, "the provider returned a token already expired at fetch time" end + unless HeaderSyntax.valid_outbound_value?(header(fetched)) + raise ProviderError, "the provider returned a token no outbound header value may " \ + "carry (HTTP-18)" + end fetched end diff --git a/gems/dexpace-core/lib/dexpace/auth/provider_error.rb b/gems/dexpace-core/lib/dexpace/auth/provider_error.rb index 7b3865b..51c6697 100644 --- a/gems/dexpace-core/lib/dexpace/auth/provider_error.rb +++ b/gems/dexpace-core/lib/dexpace/auth/provider_error.rb @@ -7,10 +7,12 @@ module Dexpace module Auth # AUTH-35, AUTH-11: a bearer token provider misbehaved -- it returned nil, a token already - # expired at fetch time (evaluated with no margin), something that is not a BearerToken, or - # (on the async path) something that is not a Future from #fetch_async. A provider that - # RAISES is not wrapped: its own error propagates, as AUTH-35 requires. Never cached: the - # stamper leaves its cache untouched on this error, so a later request retries the fetch. + # expired at fetch time (evaluated with no margin), something that is not a BearerToken, a + # token whose `Bearer ` wire form no outbound header value may carry (HTTP-18; the + # message never names the token), or (on the async path) something that is not a Future + # from #fetch_async. A provider that RAISES is not wrapped: its own error propagates, as + # AUTH-35 requires. Never cached: the stamper leaves its cache untouched on this error, so a + # later request retries the fetch. class ProviderError < ::StandardError include Dexpace::Error end From 5a74e170fb3602d9dc8ede568d4cee7040995b36 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sat, 19 Sep 2026 12:37:25 +0300 Subject: [PATCH 7/7] fix: split the smoke suite's phase-6 layer pins into a nested class The reconciled Layers class carried both phase 6a's retry pin and phase 6c's authentication pin beside the phase-3b through phase-5 cases and reached 104 lines, over Metrics/ClassLength's 100, which the honest RuboCop run sees and the nested-worktree rake gate does not. The two phase-6 cases move to a sibling PhaseSixLayers class, the shape phase 4a's reconciliation used for the same collision. --- gems/dexpace-core/test/dexpace_test.rb | 89 ++++++++++++++------------ 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/gems/dexpace-core/test/dexpace_test.rb b/gems/dexpace-core/test/dexpace_test.rb index 9a8a04f..0b9aaf7 100644 --- a/gems/dexpace-core/test/dexpace_test.rb +++ b/gems/dexpace-core/test/dexpace_test.rb @@ -163,29 +163,6 @@ class Layers < DexpaceTestCase assert_raises(::NameError) { Dexpace::Instrumentation::AsyncStep::Pending } end - # A consumer requires "dexpace" and nothing else: the authentication layer resolves too - # (phase 6c), under Dexpace::Auth, its one private_constant and its parser's private class as - # unreachable as Dexpace::Hooks. - test "requiring dexpace alone makes the whole authentication layer resolve" do - auth = Dexpace::Auth - - assert_equal( - %i[ - AsyncBearerStamper AsyncStep BasicHandler BearerProvider BearerStamper BearerToken - Challenge ChallengeHandlerChain Challenges Descriptor DigestHandler HTTPSRequiredError - KeyCredential KeyStamper NamedKeyCredential PasswordCredential ProviderError REDACTED - Requirement Resolver Scheme Step UnencodableCredentialError - ], - auth.constants(false).sort, - ) - assert_equal(Dexpace::AuthResolutionError, Dexpace.const_get(:AuthResolutionError)) - assert_same(Dexpace::Pipeline::Stages::AUTH, auth::Step.build(stamper: auth::Step::NO_STAMP).stage) - assert_raises(::NameError) { Dexpace::Auth::Validation } - assert_raises(::NameError) { Dexpace::Auth::Challenges::Parser } - assert_raises(::NameError) { Dexpace::Auth::DigestHandler::HASHES } - assert_raises(::NameError) { Dexpace::Auth::AsyncStep::Exchange } - end - # A consumer requires "dexpace" and nothing else: the execution context resolves too (phase # 4a), the instrumentation subsystem keeps its namespace (design §8.1), and the two private # constants are as unreachable as Dexpace::Hooks. @@ -200,25 +177,6 @@ class Layers < DexpaceTestCase assert_raises(::NameError) { Dexpace::CallKey } end - # A consumer requires "dexpace" and nothing else: the retry layer resolves too (phase 6a) -- - # the five public Resilience constants, the flat error, and the two private helpers and the - # two private per-call classes as unreachable as Dexpace::Hooks. Phase 6b and 6c add their - # own constants under Resilience beside these. - test "requiring dexpace alone makes the whole retry layer resolve, its helpers private" do - resilience = Dexpace::Resilience - - assert_equal(%i[AsyncRetryStep Policy RecoveryRetry Resend RetrySettings RetryStep], - resilience.constants(false).sort,) - assert_equal(Dexpace::RetryPredicateError, Dexpace.const_get(:RetryPredicateError)) - assert_equal(2, resilience::Policy::DEFAULT_MAX_RETRIES) - assert_raises(::NameError) { Dexpace::Resilience::PacingParsers } - assert_raises(::NameError) { Dexpace::Resilience::RetryStepHelpers } - assert_raises(::NameError) { Dexpace::Resilience::AsyncRetryStep::Pump } - assert_raises(::NameError) { Dexpace::Resilience::RetryStep::Run } - assert_raises(::NameError) { Dexpace::Resilience::RecoveryRetry::Run } - assert_raises(::NameError) { Dexpace::Resilience::RetrySettings::UNSET } - end - # A consumer requires "dexpace" and nothing else: the seam layer resolves too (phase 2). test "requiring dexpace alone makes the whole seam layer resolve" do assert_equal(Dexpace::Transport, Dexpace.const_get(:Transport)) @@ -245,6 +203,53 @@ class Layers < DexpaceTestCase end end + # The two phase-6 layers, in a second nested class: the retry and authentication pins were built + # in parallel lanes and landed beside one another, which pushed `Layers` past Metrics/ClassLength + # the way the three phase-4 lanes once did. + class PhaseSixLayers < DexpaceTestCase + # A consumer requires "dexpace" and nothing else: the retry layer resolves too (phase 6a) -- + # the five public Resilience constants, the flat error, and the two private helpers and the + # two private per-call classes as unreachable as Dexpace::Hooks. Phase 6b and 6c add their + # own constants under Resilience beside these. + test "requiring dexpace alone makes the whole retry layer resolve, its helpers private" do + resilience = Dexpace::Resilience + + assert_equal(%i[AsyncRetryStep Policy RecoveryRetry Resend RetrySettings RetryStep], + resilience.constants(false).sort,) + assert_equal(Dexpace::RetryPredicateError, Dexpace.const_get(:RetryPredicateError)) + assert_equal(2, resilience::Policy::DEFAULT_MAX_RETRIES) + assert_raises(::NameError) { Dexpace::Resilience::PacingParsers } + assert_raises(::NameError) { Dexpace::Resilience::RetryStepHelpers } + assert_raises(::NameError) { Dexpace::Resilience::AsyncRetryStep::Pump } + assert_raises(::NameError) { Dexpace::Resilience::RetryStep::Run } + assert_raises(::NameError) { Dexpace::Resilience::RecoveryRetry::Run } + assert_raises(::NameError) { Dexpace::Resilience::RetrySettings::UNSET } + end + + # A consumer requires "dexpace" and nothing else: the authentication layer resolves too + # (phase 6c), under Dexpace::Auth, its one private_constant and its parser's private class as + # unreachable as Dexpace::Hooks. + test "requiring dexpace alone makes the whole authentication layer resolve" do + auth = Dexpace::Auth + + assert_equal( + %i[ + AsyncBearerStamper AsyncStep BasicHandler BearerProvider BearerStamper BearerToken + Challenge ChallengeHandlerChain Challenges Descriptor DigestHandler HTTPSRequiredError + KeyCredential KeyStamper NamedKeyCredential PasswordCredential ProviderError REDACTED + Requirement Resolver Scheme Step UnencodableCredentialError + ], + auth.constants(false).sort, + ) + assert_equal(Dexpace::AuthResolutionError, Dexpace.const_get(:AuthResolutionError)) + assert_same(Dexpace::Pipeline::Stages::AUTH, auth::Step.build(stamper: auth::Step::NO_STAMP).stage) + assert_raises(::NameError) { Dexpace::Auth::Validation } + assert_raises(::NameError) { Dexpace::Auth::Challenges::Parser } + assert_raises(::NameError) { Dexpace::Auth::DigestHandler::HASHES } + assert_raises(::NameError) { Dexpace::Auth::AsyncStep::Exchange } + end + end + # The shadowing names this SDK never defines: each would make a bare `rescue ArgumentError`, # `rescue IOError` or `rescue EOFError` inside `module Dexpace` stop catching Ruby's own # (deviation P1-3; phase 3a's design for the two I/O names).