From cd1620fba2aa1bfffba8b90dc6b3f67625fef2b0 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 15:35:49 +0300 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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). From 5f34610e5c71c39b1feb1deaf9a7211bb01230eb Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 15:36:10 +0300 Subject: [PATCH 08/12] test: add the authentication suites and their doubles (AUTH-1..38) Twenty-nine suites, one per public auth file plus bounded_map_test.rb, the first true mirror of a private constant, plus five that carry no lib mirror and say so in their headers: step_bearer_challenge_test.rb (a second suite over step.rb), pillar_integration_test.rb (one example set over both runtimes), cross_origin_convergence_test.rb (Task 15, guarded on defined?(Dexpace::Redirect::Step) and skipping on this base with a reason naming 6b), matrix_facts_test.rb (Task 1's facts as a standing test, deriving the four Digest expectations rather than transcribing them) and error/auth_resolution_error_test.rb. Eight top-level test-support doubles, one class per file: ChallengeFixtures, FixedCnonce, SequencedTransport (named so as not to collide with the ScriptedTransport phase 6a is writing at the same time), SequencedAsyncTransport, ScriptedBearerProvider, ScriptedAsyncBearerProvider, SpyCursor and AuthFixtures. The AUTH-24 proof is deterministic: bounded_map_test.rb forces the interleaving between the read and the write through the block, and bearer_stamper_test.rb parks sixteen threads on a barrier so the single-flight fetch is counted, never timed. Every "never raises" claim is asserted on the value, identity claims use assert_same, the test helper is named dispatch because run is Minitest::Test#run, and every thread a test starts is joined. --- .../dexpace/auth/async_bearer_stamper_test.rb | 250 +++++++++++ .../test/dexpace/auth/async_step_test.rb | 366 +++++++++++++++ .../test/dexpace/auth/basic_handler_test.rb | 90 ++++ .../test/dexpace/auth/bearer_provider_test.rb | 77 ++++ .../test/dexpace/auth/bearer_stamper_test.rb | 151 +++++++ .../test/dexpace/auth/bearer_token_test.rb | 97 ++++ .../auth/challenge_handler_chain_test.rb | 98 ++++ .../test/dexpace/auth/challenge_test.rb | 56 +++ .../test/dexpace/auth/challenges_test.rb | 142 ++++++ .../auth/cross_origin_convergence_test.rb | 64 +++ .../test/dexpace/auth/descriptor_test.rb | 59 +++ .../test/dexpace/auth/digest_handler_test.rb | 425 ++++++++++++++++++ .../dexpace/auth/https_required_error_test.rb | 28 ++ .../test/dexpace/auth/key_credential_test.rb | 72 +++ .../test/dexpace/auth/key_stamper_test.rb | 77 ++++ .../test/dexpace/auth/matrix_facts_test.rb | 120 +++++ .../dexpace/auth/named_key_credential_test.rb | 52 +++ .../dexpace/auth/password_credential_test.rb | 60 +++ .../dexpace/auth/pillar_integration_test.rb | 289 ++++++++++++ .../test/dexpace/auth/provider_error_test.rb | 16 + .../test/dexpace/auth/requirement_test.rb | 96 ++++ .../test/dexpace/auth/resolver_test.rb | 96 ++++ .../test/dexpace/auth/scheme_test.rb | 70 +++ .../auth/step_bearer_challenge_test.rb | 181 ++++++++ .../test/dexpace/auth/step_test.rb | 379 ++++++++++++++++ .../auth/unencodable_credential_error_test.rb | 29 ++ gems/dexpace-core/test/dexpace/auth_test.rb | 18 + .../test/dexpace/bounded_map_test.rb | 128 ++++++ .../error/auth_resolution_error_test.rb | 40 ++ .../test/support/auth_fixtures.rb | 84 ++++ .../test/support/challenge_fixtures.rb | 23 + .../dexpace-core/test/support/fixed_cnonce.rb | 19 + .../support/scripted_async_bearer_provider.rb | 37 ++ .../test/support/scripted_bearer_provider.rb | 42 ++ .../test/support/sequenced_async_transport.rb | 43 ++ .../test/support/sequenced_transport.rb | 43 ++ gems/dexpace-core/test/support/spy_cursor.rb | 35 ++ 37 files changed, 3952 insertions(+) create mode 100644 gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/async_step_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/bearer_provider_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/bearer_token_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/challenge_handler_chain_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/challenge_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/challenges_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/cross_origin_convergence_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/descriptor_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/https_required_error_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/key_credential_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/key_stamper_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/matrix_facts_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/named_key_credential_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/password_credential_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/pillar_integration_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/provider_error_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/requirement_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/resolver_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/scheme_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/step_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb create mode 100644 gems/dexpace-core/test/dexpace/auth_test.rb create mode 100644 gems/dexpace-core/test/dexpace/bounded_map_test.rb create mode 100644 gems/dexpace-core/test/dexpace/error/auth_resolution_error_test.rb create mode 100644 gems/dexpace-core/test/support/auth_fixtures.rb create mode 100644 gems/dexpace-core/test/support/challenge_fixtures.rb create mode 100644 gems/dexpace-core/test/support/fixed_cnonce.rb create mode 100644 gems/dexpace-core/test/support/scripted_async_bearer_provider.rb create mode 100644 gems/dexpace-core/test/support/scripted_bearer_provider.rb create mode 100644 gems/dexpace-core/test/support/sequenced_async_transport.rb create mode 100644 gems/dexpace-core/test/support/sequenced_transport.rb create mode 100644 gems/dexpace-core/test/support/spy_cursor.rb diff --git a/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb new file mode 100644 index 0000000..e25610b --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/async_bearer_stamper" +require_relative "../../support/auth_fixtures" +require_relative "../../support/scripted_bearer_provider" +require_relative "../../support/scripted_async_bearer_provider" +require_relative "../../support/fake_clock" +require_relative "../../support/recording_sink" + +# Exercises: AUTH-37, AUTH-36 (async half), AUTH-11, AUTH-35 -- the three-zone async bearer +# policy over phase 2's pivot: no #value or #wait anywhere on the stamper's own path, the +# expiring zone stamping at once while a refresh it never awaits runs, the expired zone deriving +# from one coalesced fetch, a failed background refresh logged and not fatal, the re-entrancy +# trap an already-settled provider future sets, and #stamp_fresh after an eviction. +# +# Every wait in this file is on a future the test itself settles, or on one already settled; +# a hang here would be a finding, and FakeClock never advances by itself. Split under +# Metrics/ClassLength. +class DexpaceAuthAsyncBearerStamperTest < DexpaceTestCase + AsyncBearerStamper = Dexpace::Auth::AsyncBearerStamper + BearerToken = Dexpace::Auth::BearerToken + Completer = Dexpace::Async::Completer + LIB = File.expand_path("../../../lib/dexpace/auth/async_bearer_stamper.rb", __dir__) + + # A mutex that refuses to be taken: installed on the hot path to prove it takes no lock. + class RefusingMutex + def synchronize + raise "the hot path took the lock (XCUT-12)" + end + end + + # The stampers, tokens and futures the nested cases share. The clock reads 1000 and the + # margin is 30, so a token expiring at 1030 or later is fresh, one expiring in (1000, 1030] + # is expiring-but-valid, and one expiring at 1000 or earlier is expired. + module Fixtures + include AuthFixtures + + def clock = @clock ||= FakeClock.new(now: Time.at(1000)) + + def stamper(provider, margin: 30, logger: Dexpace::Instrumentation::Logger::NULL) + AsyncBearerStamper.new(provider: provider, clock: clock, refresh_margin: margin, + logger: logger,) + end + + # A stamper whose cache already holds `token`, without a fetch. + def seeded(provider, token) + stamper(provider).tap { |subject| subject.instance_variable_set(:@token, token) } + end + + def fresh_token(value = "fresh") = BearerToken.build(token: value, expiry: Time.at(2000)) + def expiring_token = BearerToken.build(token: "still-valid", expiry: Time.at(1010)) + def expired_token = BearerToken.build(token: "expired", expiry: Time.at(999)) + def no_fetch = ScriptedAsyncBearerProvider.new(-> { flunk "no fetch expected" }) + def settled_with(value) = Completer.new.tap { |c| c.fulfil(value) }.future + def authorization(request) = request.headers["Authorization"] + end + + # AUTH-37's three zones and the boundary between them. + class ZonesTest < DexpaceTestCase + include Fixtures + + test "AUTH-37 fresh: the cached token is stamped in a settled future with no provider call" do + provider = no_fetch + future = seeded(provider, fresh_token).stamp(https_request) + + assert_predicate(future, :settled?) + assert_equal(["Bearer fresh"], authorization(future.value)) + assert_equal(0, provider.fetches) + end + + test "AUTH-37, XCUT-12: the fresh zone takes no lock" do + subject = seeded(no_fetch, fresh_token) + subject.instance_variable_set(:@lock, RefusingMutex.new) + + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) + end + + test "AUTH-37 expiring-but-valid: stamps the cached token at once, never awaits the refresh" do + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future) + subject = seeded(provider, expiring_token) + future = subject.stamp(https_request) + + assert_predicate(future, :settled?) # returned before the refresh settled + assert_equal(["Bearer still-valid"], authorization(future.value)) + assert_equal(1, provider.fetches) + refute_predicate(completer, :settled?) + completer.fulfil(fresh_token) + + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) + end + + test "AUTH-37: the zone boundary is exactly the refresh margin" do + provider = no_fetch + at_margin = BearerToken.build(token: "t", expiry: Time.at(1030)) # 1000 + 30, not after + + assert_predicate(seeded(provider, at_margin).stamp(https_request), :settled?) + assert_equal(0, provider.fetches) + counting = ScriptedAsyncBearerProvider.new(Completer.new.future) + past_margin = BearerToken.build(token: "t", expiry: Time.at(1029)) + seeded(counting, past_margin).stamp(https_request) + + assert_equal(1, counting.fetches) + end + + test "AUTH-37 expired/missing: the stamped request awaits the fetch, derived, not blocked" do + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future) + subject = stamper(provider) + future = subject.stamp(https_request) + + refute_predicate(future, :settled?) + completer.fulfil(fresh_token) + + assert_predicate(future, :settled?) + assert_equal(["Bearer fresh"], authorization(future.value)) + again = seeded(provider, expired_token).stamp(https_request) # the settled future, reused + + assert_equal(["Bearer fresh"], authorization(again.value)) + end + + test "AUTH-37: concurrent expiring and missing requests coalesce onto ONE in-flight fetch" do + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future) + subject = stamper(provider) + futures = Array.new(8) { subject.stamp(https_request) } + subject.instance_variable_set(:@token, expiring_token) + expiring = Array.new(4) { subject.stamp(https_request) } + + assert_equal(1, provider.fetches) + expiring.each { |future| assert_equal(["Bearer still-valid"], authorization(future.value)) } + completer.fulfil(fresh_token) + + futures.each { |future| assert_equal(["Bearer fresh"], authorization(future.value)) } + end + end + + # The failure paths: logged, uncached, retried. + class FailureTest < DexpaceTestCase + include Fixtures + + test "AUTH-37: a failed BACKGROUND refresh is logged and does not fail the in-flight request" do + sink = RecordingSink.new + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future, settled_with(fresh_token)) + subject = stamper(provider, logger: Dexpace::Instrumentation::Logger.build(sink: sink)) + subject.instance_variable_set(:@token, expiring_token) + request = subject.stamp(https_request).value + + assert_equal(["Bearer still-valid"], authorization(request)) + completer.fail(RuntimeError.new("refresh failed")) + entry = sink.entries.find { |candidate| candidate.payload["event"] == "http.auth.refresh" } + + refute_nil(entry, sink.entries.inspect) + assert_equal(:warn, entry.severity) + assert_includes(entry.payload["cause"].to_s, "refresh failed") + # Nothing was cached: the next stamp in the expired zone fetches again and succeeds. + clock.advance(20) + + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) + assert_equal(2, provider.fetches) + end + + test "AUTH-37, AUTH-35: a failed fetch is not cached, and a later request retries" do + first = Completer.new + provider = ScriptedAsyncBearerProvider.new(first.future, settled_with(fresh_token)) + subject = stamper(provider) + waiting = subject.stamp(https_request) + first.fail(RuntimeError.new("boom")) + + assert_raises(RuntimeError) { waiting.value } + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) + assert_equal(2, provider.fetches) + end + + test "AUTH-35 on the async path: a nil, expired or non-token result fails the waiters" do + provider = ScriptedAsyncBearerProvider.new(settled_with(expired_token), + settled_with(Object.new), + settled_with(fresh_token),) + subject = stamper(provider) + + assert_raises(Dexpace::Auth::ProviderError) { subject.stamp(https_request).value } + assert_raises(Dexpace::Auth::ProviderError) { subject.stamp(https_request).value } + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) + nil_token = stamper(ScriptedBearerProvider.new(-> {})) + + assert_raises(Dexpace::Auth::ProviderError) { nil_token.stamp(https_request).value } + end + + # The regression R12 exists to prevent: AUTH-11's default wrapper mirrors a #fetch-only + # provider into an ALREADY-SETTLED future, whose #on_settle runs inline on the calling fiber; + # started under @lock, the settle block's own synchronize would raise + # `ThreadError: deadlock; recursive locking`. + test "AUTH-11, R12: a #fetch-only provider's already-settled future does not deadlock" do + subject = stamper(ScriptedBearerProvider.new("sync")) + future = subject.stamp(https_request) + + assert_predicate(future, :settled?) + assert_equal(["Bearer sync"], authorization(future.value)) + assert_equal(["Bearer sync"], authorization(subject.stamp_fresh(https_request).value)) + end + + test "AUTH-11: a #fetch_async override that raises synchronously fails the future, uncached" do + provider = ScriptedAsyncBearerProvider.new(ArgumentError.new("misbehaving"), + settled_with(fresh_token),) + subject = stamper(provider) + + assert_raises(ArgumentError) { subject.stamp(https_request).value } + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) + end + end + + # AUTH-36's async half, AUTH-37's post-eviction clause, and the construction checks. + class EvictionTest < DexpaceTestCase + include Fixtures + + test "AUTH-37's post-eviction clause: #stamp_fresh never stamps the cache, always a fetch" do + provider = ScriptedAsyncBearerProvider.new(-> { settled_with(fresh_token("fetched")) }) + subject = seeded(provider, fresh_token("cached")) + + assert_equal(["Bearer fetched"], authorization(subject.stamp_fresh(https_request).value)) + assert_equal(1, provider.fetches) + assert_equal(["Bearer fetched"], authorization(subject.stamp(https_request).value)) + assert_equal(1, provider.fetches) # now cached + end + + test "AUTH-36 async half: eviction on the exact header value, a refreshed token preserved" do + subject = seeded(no_fetch, fresh_token("cur")) + + refute(subject.evict_if_matches("Bearer stale")) + assert_equal(["Bearer cur"], authorization(subject.stamp(https_request).value)) + assert(subject.evict_if_matches("Bearer cur")) + refute(subject.evict_if_matches("Bearer cur")) + end + + test "R12 as code: the stamper's own path calls neither #value nor #wait" do + refute_match(/\.value\b|\.wait\b|Async\.delay/, File.read(LIB)) + end + + test "the provider must answer #fetch; the margin and logger are validated" do + provider = ScriptedBearerProvider.new("t") + + assert_raises(Dexpace::InvalidArgumentError) { stamper(Object.new) } + assert_raises(Dexpace::InvalidArgumentError) { stamper(provider, margin: -1) } + assert_raises(Dexpace::InvalidArgumentError) { stamper(provider, logger: nil) } + end + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb new file mode 100644 index 0000000..97b1dbd --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb @@ -0,0 +1,366 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/async_step" +require_relative "../../../lib/dexpace/auth/async_bearer_stamper" +require_relative "../../../lib/dexpace/auth/key_stamper" +require_relative "../../../lib/dexpace/auth/key_credential" +require_relative "../../support/auth_fixtures" +require_relative "../../support/scripted_bearer_provider" +require_relative "../../support/scripted_async_bearer_provider" +require_relative "../../support/spy_cursor" +require_relative "../../support/fake_clock" + +# Exercises: AUTH-38 and the async mirror of AUTH-27 through AUTH-37 -- the async AUTH pillar +# step through a real async pipeline: every failure a failed future and never a synchronous +# raise, the three-zone stamper driven through it, the bearer 401 branch awaiting a genuinely +# fresh fetch after an eviction and reusing a preserved token otherwise, AUTH-31's gate through +# the inherited predicate, AUTH-32's three clauses, and cancellation forwarded both ways. Every +# #value here is on a future the test settles or one already settled. Split under +# Metrics/ClassLength. +class DexpaceAuthAsyncStepTest < DexpaceTestCase + AsyncStep = Dexpace::Auth::AsyncStep + Step = Dexpace::Auth::Step + STAGES = Dexpace::Pipeline::Stages + Completer = Dexpace::Async::Completer + LIB = File.expand_path("../../../lib/dexpace/auth/async_step.rb", __dir__) + + # The steps, stampers and async pipelines the nested cases share. + module Fixtures + include AuthFixtures + + def key_stamper(key = "secret") + Dexpace::Auth::KeyStamper.new(Dexpace::Auth::KeyCredential.new(api_key: key)) + end + + def async_step(stamper: key_stamper, hook: Step::NO_REPLACEMENT) + AsyncStep.build(stamper: stamper, challenge_hook: hook) + end + + def clock = @clock ||= FakeClock.new(now: Time.at(1000)) + + def async_bearer(*tokens) + Dexpace::Auth::AsyncBearerStamper.new(provider: ScriptedBearerProvider.new(*tokens), + clock: clock,) + end + + def async_bearer_over(provider) + Dexpace::Auth::AsyncBearerStamper.new(provider: provider, clock: clock) + end + + def dispatch(step, transport, request = https_request, redirect_state: nil, stage: nil) + builder = Dexpace::Pipeline::Builder.new(transport: transport) + unless redirect_state.nil? + builder.append(ForkingProbe.new(times: 1, state_per_drive: [redirect_state]), + stage: STAGES::REDIRECT,) + end + stage.nil? ? builder.append(step) : builder.append(step, stage: stage) + builder.build_async.call(request) + end + + def settled(*script) = SequencedAsyncTransport.new(*script) + end + + # AUTH-38 and R12: the one Completer frame. + class FrameTest < DexpaceTestCase + include Fixtures + + test "P5-34's shape: an AsyncStep is a Step with the same stage, .build and private .new" do + step = async_step + + assert_kind_of(Step, step) + assert_same(STAGES::AUTH, step.stage) + assert_predicate(step, :frozen?) + refute_respond_to(AsyncStep, :new) + end + + test "the stamper may answer #stamp (async) or #call (adapted); anything else is refused" do + AsyncStep.build(stamper: async_bearer("t")) + AsyncStep.build(stamper: key_stamper) + + assert_raises(Dexpace::InvalidArgumentError) { AsyncStep.build(stamper: Object.new) } + assert_raises(Dexpace::InvalidArgumentError) { Step.build(stamper: async_bearer("t")) } + end + + test "AUTH-38: the HTTPS guard's failure is a failed future, never a synchronous raise" do + future = dispatch(async_step, settled(ok), http_request) + + assert_kind_of(Dexpace::Async::Future, future) + assert_predicate(future, :settled?) + error = assert_raises(Dexpace::Auth::HTTPSRequiredError) { future.value } + + assert_equal("Dexpace::Auth::AsyncStep", error.step) + end + + test "AUTH-38: a stamper that raises, or a provider that fails, is a failed future" do + raising = ->(_request) { raise "stamper blew up" } + + assert_raises(RuntimeError) { dispatch(async_step(stamper: raising), settled(ok)).value } + failing = async_bearer_over(ScriptedBearerProvider.new(RuntimeError.new("fetch failed"))) + future = dispatch(async_step(stamper: failing), settled(ok)) + + assert_predicate(future, :settled?) + assert_raises(RuntimeError) { future.value } + end + + # The driver normalises a synchronously raising step into a failed future (PIPE-30), so + # through a pipeline the frame cannot be told from the driver: this calls the step directly, + # on a root cursor, where nothing but the step's own frame stands between a raise and the + # caller. + test "AUTH-38: called directly, outside the driver, the guard's failure is a failed future" do + cursor = Dexpace::Pipeline::Cursor.build(drive: Object.new, request: http_request, + options: Dexpace::RequestOptions::EMPTY, + cancellation: Dexpace::Cancellation.none,) + future = async_step.call(http_request, cursor) + + assert_kind_of(Dexpace::Async::Future, future) + assert_raises(Dexpace::Auth::HTTPSRequiredError) { future.value } + raising = async_step(stamper: ->(_request) { raise "stamper blew up" }) + secure = Dexpace::Pipeline::Cursor.build(drive: Object.new, request: https_request, + options: Dexpace::RequestOptions::EMPTY, + cancellation: Dexpace::Cancellation.none,) + + assert_raises(RuntimeError) { raising.call(https_request, secure).value } + end + + test "R12: with no Fiber.scheduler the step still returns a future and never delays" do + refute(Fiber.scheduler) + refute_match(/\.value\b|\.wait\b|Async\.delay|Fiber\.scheduler/, File.read(LIB)) + assert_kind_of(Dexpace::Async::Future, dispatch(async_step, settled(ok))) + end + end + + # AUTH-27, AUTH-29, AUTH-37 and SEAM-18 on the async path. + class DriveTest < DexpaceTestCase + include Fixtures + + test "AUTH-27, P4-39: the stamped request drives a fresh fork; the handed cursor not called" do + spy = nil + step = async_step + wrapper = ->(request, cursor) { step.call(request, spy = SpyCursor.new(cursor)) } + transport = settled(ok) + response = dispatch(wrapper, transport, stage: STAGES::AUTH).value + + assert_equal(200, response.status.code) + assert_equal(["secret"], transport.authorization_headers) + assert_equal(1, spy.forks) + assert_equal(0, spy.calls) + end + + test "AUTH-29: cross-origin is neither guarded nor stamped; same-origin and none are stamped" do + transport = settled(ok) + no_stamp = async_step(stamper: ->(_r) { flunk "no stamp cross-origin" }) + response = dispatch(no_stamp, transport, http_request, + redirect_state: { cross_origin: true },).value + + assert_equal(200, response.status.code) + assert_equal([nil], transport.authorization_headers) + same = settled(ok) + dispatch(async_step, same, redirect_state: { cross_origin: false }).value + + assert_equal(["secret"], same.authorization_headers) + none = settled(ok) + dispatch(async_step, none).value + + assert_equal(["secret"], none.authorization_headers) + end + + test "AUTH-37 through the step: the expired zone awaits the fetch before the drive" do + completer = Completer.new + stamper = async_bearer_over(ScriptedAsyncBearerProvider.new(completer.future)) + transport = settled(ok) + future = dispatch(async_step(stamper: stamper), transport) + + refute_predicate(future, :settled?) + assert_empty(transport.calls) + completer.fulfil(Dexpace::Auth::BearerToken.build(token: "fresh")) + + assert_equal(200, future.value.status.code) + assert_equal(["Bearer fresh"], transport.authorization_headers) + end + + test "a transport failure on any drive fails the step's future with the same object" do + boom = RuntimeError.new("transport failed") + failed = dispatch(async_step, settled(boom)) + + assert_same(boom, assert_raises(RuntimeError) { failed.value }) + cross = dispatch(async_step, settled(boom), http_request, + redirect_state: { cross_origin: true },) + + assert_same(boom, assert_raises(RuntimeError) { cross.value }) + end + + test "a transport whose future settles later settles the step's future then, and not before" do + held = Completer.new + transport = settled(held.future) + future = dispatch(async_step, transport) + + refute_predicate(future, :settled?) + held.fulfil(ok) + + assert_equal(200, future.value.status.code) + end + + test "SEAM-18: cancelling the returned future cancels the in-flight drive, as a cancellation" do + held = Completer.new + future = dispatch(async_step, settled(held.future)) + future.cancel(:stop) + + assert_predicate(held.future, :cancelled?) + assert_predicate(future, :cancelled?) + inner = Completer.new + forwarded = dispatch(async_step, settled(inner.future)) + inner.request_cancel(:gone) + + assert_predicate(forwarded, :cancelled?) + assert_equal(:gone, assert_raises(Dexpace::CancelledError) { forwarded.value }.reason) + end + end + + # AUTH-30 through AUTH-33 on the async path, the hook's future form included. + class ChallengeTest < DexpaceTestCase + include Fixtures + + test "AUTH-30: a 401 with a challenge consults the hook and replays the replacement once" do + transport = settled(unauthorized("Basic realm=r"), ok) + response = dispatch(async_step(hook: ->(_c, request, _r) { request }), transport).value + + assert_equal(200, response.status.code) + assert_equal(2, transport.calls.size) + end + + test "AUTH-30: the default hook yields no replacement; AUTH-33: no challenge, no consulting" do + consulted = false + first = unauthorized(nil) + hook = lambda do |*| + consulted = true + nil + end + response = dispatch(async_step(hook: hook), settled(first, ok)).value + + assert_same(first, response) + refute(consulted) + default = dispatch(async_step, settled(unauthorized("Basic realm=r"), ok)).value + + assert_equal(401, default.status.code) + end + + test "AUTH-30, AUTH-32: a hook may answer a FUTURE of a replacement, awaited, not blocked on" do + pending = Completer.new + transport = settled(unauthorized("Basic realm=r"), ok) + future = dispatch(async_step(hook: ->(*) { pending.future }), transport) + + refute_predicate(future, :settled?) + pending.fulfil(https_request) + + assert_equal(200, future.value.status.code) + end + + test "AUTH-32: a hook that raises synchronously closes the 401 and fails the future" do + first = unauthorized("Basic realm=r") + future = dispatch(async_step(hook: ->(*) { raise "hook blew up" }), settled(first, ok)) + + assert_raises(RuntimeError) { future.value } + assert_equal(1, closes_of(first)) + end + + test "AUTH-32: a hook whose future completes exceptionally closes the 401, fails the future" do + first = unauthorized("Basic realm=r") + pending = Completer.new + future = dispatch(async_step(hook: ->(*) { pending.future }), settled(first, ok)) + pending.fail(RuntimeError.new("async hook failed")) + + assert_raises(RuntimeError) { future.value } + assert_equal(1, closes_of(first)) + end + + test "AUTH-32: a hook answering a non-request closes the 401 and fails the future" do + first = unauthorized("Basic realm=r") + future = dispatch(async_step(hook: ->(*) { "junk" }), settled(first, ok)) + + assert_raises(Dexpace::InvalidArgumentError) { future.value } + assert_equal(1, closes_of(first)) + end + + test "AUTH-31 on the async path: a non-replayable replacement surfaces the 401 unclosed" do + first = unauthorized("Basic realm=r") + transport = settled(first, ok) + hook = ->(*) { post_request(replayable: false) } + response = dispatch(async_step(hook: hook), transport, post_request).value + + assert_same(first, response) + assert_equal(0, closes_of(first)) + assert_equal(1, transport.calls.size) + end + end + + # AUTH-36 and AUTH-37's post-eviction clause on the async path. + class BearerTest < DexpaceTestCase + include Fixtures + + test "AUTH-36, AUTH-37: the bearer 401 branch awaits a fresh fetch, never re-sends the token" do + stamper = async_bearer("old", "new") + transport = settled(unauthorized_bearer, ok) + response = dispatch(async_step(stamper: stamper), transport).value + + assert_equal(200, response.status.code) + assert_equal(["Bearer old", "Bearer new"], transport.authorization_headers) + end + + test "AUTH-36: a token another request refreshed is preserved and reused, no fetch" do + provider = ScriptedBearerProvider.new("old", "never") + stamper = async_bearer_over(provider) + refreshed = Dexpace::Auth::BearerToken.build(token: "refreshed-elsewhere") + swap = lambda do |_request| + stamper.instance_variable_set(:@token, refreshed) + unauthorized_bearer + end + transport = settled(swap, ok) + dispatch(async_step(stamper: stamper), transport).value + + assert_equal(["Bearer old", "Bearer refreshed-elsewhere"], transport.authorization_headers) + assert_equal(1, provider.fetches) + end + + test "AUTH-36: cross-origin suppression and a non-Bearer challenge surface the 401 unchanged" do + first = unauthorized_bearer + response = dispatch(async_step(stamper: async_bearer("old")), settled(first, ok), + redirect_state: { cross_origin: true },).value + + assert_same(first, response) + basic = unauthorized("Basic realm=r") + + assert_same(basic, + dispatch(async_step(stamper: async_bearer("old")), settled(basic, ok)).value,) + end + + test "AUTH-31, P6-7: a non-replayable body skips the bearer retry on the async path too" do + first = unauthorized_bearer + transport = settled(first, ok) + response = dispatch(async_step(stamper: async_bearer("old", "new")), transport, + post_request(replayable: false),).value + + assert_same(first, response) + assert_equal(0, closes_of(first)) + end + + test "AUTH-35: a provider that fails on the post-eviction fetch fails the future, 401 closed" do + first = unauthorized_bearer + stamper = async_bearer("old", RuntimeError.new("refresh failed")) + future = dispatch(async_step(stamper: stamper), settled(first, ok)) + + assert_equal("refresh failed", assert_raises(RuntimeError) { future.value }.message) + assert_equal(1, closes_of(first)) + end + + test "a sync BearerStamper installed on the async step is adapted and still retries" do + stamper = Dexpace::Auth::BearerStamper.new(provider: ScriptedBearerProvider.new("old", "new"), + clock: clock,) + transport = settled(unauthorized_bearer, ok) + + assert_equal(200, dispatch(async_step(stamper: stamper), transport).value.status.code) + assert_equal(["Bearer old", "Bearer new"], transport.authorization_headers) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb b/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb new file mode 100644 index 0000000..8608f26 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/basic_handler" +require_relative "../../support/auth_fixtures" + +# Exercises: AUTH-14 -- Basic: `Basic ` + pack("m0") of the UTF-8 bytes, computed once and +# reused by both roles, the challenge accepted case-insensitively, non-empty (not non-blank) +# credentials, and never Base64. +class DexpaceAuthBasicHandlerTest < DexpaceTestCase + include AuthFixtures + + BasicHandler = Dexpace::Auth::BasicHandler + Challenge = Dexpace::Auth::Challenge + + def credential(username: "alice", password: "s3cr3t") + Dexpace::Auth::PasswordCredential.build(username: username, password: password) + end + + def challenge(scheme) = Challenge.build(scheme: scheme) + + test "AUTH-14: the value is Basic plus the base64 of username:password" do + handler = BasicHandler.new(credential) + + assert_equal("Basic YWxpY2U6czNjcjN0", + handler.authorization_for([challenge("basic")], https_request, proxy: false),) + end + + test "AUTH-14: the UTF-8 bytes of a non-ASCII credential are what is encoded, whatever its tag" do + handler = BasicHandler.new(credential(username: "ü", password: "pä")) + value = handler.authorization_for([challenge("basic")], https_request, proxy: false) + + assert_equal("Basic w7w6cMOk", value) + assert_predicate(value, :ascii_only?) + latin1 = credential(username: "ü".encode("ISO-8859-1"), password: "pä".encode("ISO-8859-1")) + stamped = BasicHandler.new(latin1).call(https_request) + + assert_equal("Basic w7w6cMOk", stamped.headers["Authorization"].first) + end + + test "AUTH-14: computed once -- both roles return the same frozen String object" do + handler = BasicHandler.new(credential) + answered = handler.authorization_for([challenge("basic")], https_request, proxy: false) + + assert_same(answered, + handler.authorization_for([challenge("basic")], https_request, proxy: true),) + assert_same(answered, handler.call(https_request).headers["Authorization"].first) + assert_predicate(answered, :frozen?) + assert_predicate(handler, :frozen?) + end + + test "AUTH-14: a Basic challenge is accepted case-insensitively, any other declined" do + handler = BasicHandler.new(credential) + + refute_nil(handler.authorization_for([challenge("BASIC")], https_request, proxy: false)) + refute_nil(handler.authorization_for([challenge("digest"), challenge("Basic")], https_request, + proxy: false,)) + assert_nil(handler.authorization_for([challenge("digest")], https_request, proxy: false)) + assert_nil(handler.authorization_for([], https_request, proxy: false)) + end + + test "AUTH-14 preemptively: #call stamps Authorization with no challenge, and SETS it" do + handler = BasicHandler.new(credential) + already = https_request(headers: Dexpace::Headers.builder.add("Authorization", "old").build) + + assert_equal(["Basic YWxpY2U6czNjcjN0"], handler.call(https_request).headers["Authorization"]) + assert_equal(["Basic YWxpY2U6czNjcjN0"], handler.call(already).headers["Authorization"]) + end + + test "AUTH-14's laxer rule: whitespace-only is permitted; empty is refused" do + BasicHandler.new(credential(password: " ")) + BasicHandler.new(credential(username: " ")) + + assert_raises(Dexpace::InvalidArgumentError) { BasicHandler.new(credential(username: "")) } + assert_raises(Dexpace::InvalidArgumentError) { BasicHandler.new(credential(password: "")) } + assert_raises(Dexpace::InvalidArgumentError) { BasicHandler.new("alice:s3cr3t") } + end + + test "RFC 7617 §2: a username carrying a colon cannot be encoded unambiguously and is refused" do + assert_raises(Dexpace::InvalidArgumentError) { BasicHandler.new(credential(username: "a:b")) } + end + + test "never Base64: the source spells pack(\"m0\") and requires no base64" do + source = File.read(File.expand_path("../../../lib/dexpace/auth/basic_handler.rb", __dir__)) + + assert_includes(source, 'pack("m0")') + refute_match(/Base64\.|require ["']base64/, source) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/bearer_provider_test.rb b/gems/dexpace-core/test/dexpace/auth/bearer_provider_test.rb new file mode 100644 index 0000000..608c27c --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/bearer_provider_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/bearer_provider" +require_relative "../../support/scripted_bearer_provider" +require_relative "../../support/scripted_async_bearer_provider" + +# Exercises: AUTH-11 -- the provider duck type and its default async fetch: a #fetch-only +# provider mirrored into an already-settled future (success and failure alike), a #fetch_async +# override's synchronous raise normalised into a failed future, a nil token and a non-Future +# return each a failed future, and a genuine future passed through untouched. +class DexpaceAuthBearerProviderTest < DexpaceTestCase + BearerProvider = Dexpace::Auth::BearerProvider + BearerToken = Dexpace::Auth::BearerToken + + test "AUTH-11: #fetch is the one required method" do + assert(BearerProvider.conforms?(ScriptedBearerProvider.new("t"))) + assert(BearerProvider.conforms?(ScriptedAsyncBearerProvider.new("t"))) + refute(BearerProvider.conforms?(Object.new)) + end + + test "AUTH-11: a #fetch-only provider's success is mirrored into an already-settled future" do + future = BearerProvider.fetch_async(ScriptedBearerProvider.new("tok")) + + assert_predicate(future, :settled?) + assert_equal("tok", future.value.token) + end + + test "AUTH-11: a #fetch-only provider's raise is mirrored into an already-FAILED future" do + future = BearerProvider.fetch_async(ScriptedBearerProvider.new(RuntimeError.new("boom"))) + + assert_predicate(future, :settled?) + error = assert_raises(RuntimeError) { future.value } + + assert_equal("boom", error.message) + end + + test "AUTH-35 through AUTH-11: a nil token from #fetch never reaches Completer#fulfil" do + future = BearerProvider.fetch_async(ScriptedBearerProvider.new(-> {})) + + assert_predicate(future, :settled?) + assert_raises(Dexpace::Auth::ProviderError) { future.value } + end + + test "AUTH-11: a genuine #fetch_async future is returned as it is, settled or not" do + completer = Dexpace::Async::Completer.new + future = BearerProvider.fetch_async(ScriptedAsyncBearerProvider.new(completer.future)) + + assert_same(completer.future, future) + refute_predicate(future, :settled?) + completer.fulfil(BearerToken.build(token: "t")) + + assert_equal("t", future.value.token) + end + + test "AUTH-11: a misbehaving #fetch_async that raises synchronously is a failed future" do + provider = ScriptedAsyncBearerProvider.new(ArgumentError.new("misbehaving")) + future = BearerProvider.fetch_async(provider) + + assert_predicate(future, :settled?) + assert_raises(ArgumentError) { future.value } + end + + test "AUTH-11: a #fetch_async that returns something other than a Future is a failed future" do + provider = ScriptedAsyncBearerProvider.new(BearerToken.build(token: "t")) + future = BearerProvider.fetch_async(provider) + + error = assert_raises(Dexpace::Auth::ProviderError) { future.value } + + assert_includes(error.message, "not a Dexpace::Async::Future") + end + + test "the module holds no state" do + assert_empty(BearerProvider.instance_variables) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb b/gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb new file mode 100644 index 0000000..05d6872 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/bearer_stamper" +require_relative "../../support/auth_fixtures" +require_relative "../../support/scripted_bearer_provider" +require_relative "../../support/fake_clock" + +# Exercises: AUTH-11 (sync half), AUTH-34, AUTH-35, AUTH-36 (the cache half) -- the sync bearer +# stamper: the cached token stamped until the refresh margin, a lock-free hot path, at most one +# fetch under sixteen racing threads, the three provider rejections uncached, and the +# compare-and-clear eviction on the stamped header value. +class DexpaceAuthBearerStamperTest < DexpaceTestCase + include AuthFixtures + + BearerStamper = Dexpace::Auth::BearerStamper + BearerToken = Dexpace::Auth::BearerToken + + # A mutex that refuses to be taken: installed on the hot path to prove it takes no lock. + class RefusingMutex + def synchronize + raise "the hot path took the lock (XCUT-12)" + end + end + + def stamper(provider, clock: FakeClock.new, margin: 30) + BearerStamper.new(provider: provider, clock: clock, refresh_margin: margin) + end + + def authorization(request) = request.headers["Authorization"] + + test "AUTH-34: stamps Authorization: Bearer , SET rather than added" do + already = https_request(headers: Dexpace::Headers.builder.add("Authorization", "old").build) + subject = stamper(ScriptedBearerProvider.new("t1")) + + assert_equal(["Bearer t1"], authorization(subject.call(https_request))) + assert_equal(["Bearer t1"], authorization(subject.call(already))) + end + + test "AUTH-34: the token is cached until the refresh margin before its expiry, 30 s by default" do + clock = FakeClock.new(now: Time.at(0)) + provider = ScriptedBearerProvider.new(BearerToken.build(token: "t1", expiry: Time.at(100)), + BearerToken.build(token: "t2", expiry: Time.at(300)),) + subject = stamper(provider, clock: clock) + subject.call(https_request) + clock.advance(69) # 69 + 30 = 99, not after 100: still cached + + assert_equal(["Bearer t1"], authorization(subject.call(https_request))) + assert_equal(1, provider.fetches) + clock.advance(2) # 71 + 30 = 101: refreshed + + assert_equal(["Bearer t2"], authorization(subject.call(https_request))) + assert_equal(2, provider.fetches) + assert_equal(30, BearerStamper::DEFAULT_REFRESH_MARGIN) + end + + test "AUTH-34, XCUT-12: the hot-path read of a valid cached token takes no lock" do + subject = stamper(ScriptedBearerProvider.new("t1")) + subject.call(https_request) + subject.instance_variable_set(:@lock, RefusingMutex.new) + + assert_equal(["Bearer t1"], authorization(subject.call(https_request))) + end + + # Deterministic: the one fetch parks until all sixteen threads have entered #call, so every + # other thread is racing on the missing token while it is in flight. + test "AUTH-34: sixteen threads racing on a missing token cause exactly one fetch" do + arrived = ::Thread::Queue.new + provider = ScriptedBearerProvider.new("t1").before_fetch do + Thread.pass until arrived.size == 16 + end + subject = stamper(provider) + threads = Array.new(16) do + Thread.new do + arrived << true + authorization(subject.call(https_request)) + end + end + + assert_equal([["Bearer t1"]] * 16, threads.map(&:value)) + assert_equal(1, provider.fetches) + end + + test "AUTH-35: a nil token surfaces as ProviderError and is not cached" do + provider = ScriptedBearerProvider.new(-> {}, "t2") + subject = stamper(provider) + + assert_raises(Dexpace::Auth::ProviderError) { subject.call(https_request) } + assert_equal(["Bearer t2"], authorization(subject.call(https_request))) + assert_equal(2, provider.fetches) + end + + test "AUTH-35: a token already expired at fetch time, evaluated with NO margin, is an error" do + clock = FakeClock.new(now: Time.at(100)) + provider = ScriptedBearerProvider.new(BearerToken.build(token: "old", expiry: Time.at(99)), + BearerToken.build(token: "edge", expiry: Time.at(100)),) + subject = stamper(provider, clock: clock) + + assert_raises(Dexpace::Auth::ProviderError) { subject.call(https_request) } + # expiry == now is NOT expired with no margin (strictly after), so it is accepted, then + # the margin makes it a refresh candidate on the next call. + assert_equal(["Bearer edge"], authorization(subject.call(https_request))) + end + + test "AUTH-35: something that is not a BearerToken is an error" do + assert_raises(Dexpace::Auth::ProviderError) do + stamper(ScriptedBearerProvider.new(-> { Object.new })).call(https_request) + end + end + + test "AUTH-35, AUTH-11: a raising provider propagates its own error, uncached; next retries" do + provider = ScriptedBearerProvider.new(RuntimeError.new("boom"), "t2") + subject = stamper(provider) + + error = assert_raises(RuntimeError) { subject.call(https_request) } + + assert_equal("boom", error.message) + assert_equal(["Bearer t2"], authorization(subject.call(https_request))) + end + + test "AUTH-36: eviction clears only the exact rejected header value, and the next call fetches" do + provider = ScriptedBearerProvider.new("old", "new") + subject = stamper(provider) + subject.call(https_request) + + assert(subject.evict_if_matches("Bearer old")) + assert_equal(["Bearer new"], authorization(subject.call(https_request))) + assert_equal(2, provider.fetches) + end + + test "AUTH-36: a token another request already refreshed does not match and is preserved" do + provider = ScriptedBearerProvider.new("current") + subject = stamper(provider) + subject.call(https_request) + + refute(subject.evict_if_matches("Bearer stale")) + refute(subject.evict_if_matches("Bearer current")) # matched on the exact header value + assert_equal(["Bearer current"], authorization(subject.call(https_request))) + assert_equal(1, provider.fetches) + refute(stamper(provider).evict_if_matches("Bearer current")) # nothing cached yet + end + + test "the provider must answer #fetch and the margin must be a non-negative number" do + assert_raises(Dexpace::InvalidArgumentError) { stamper(Object.new) } + provider = ScriptedBearerProvider.new("t") + + assert_raises(Dexpace::InvalidArgumentError) { stamper(provider, margin: -1) } + assert_raises(Dexpace::InvalidArgumentError) { stamper(provider, margin: "30") } + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/bearer_token_test.rb b/gems/dexpace-core/test/dexpace/auth/bearer_token_test.rb new file mode 100644 index 0000000..8987582 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/bearer_token_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "pp" +require "stringio" +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/bearer_token" + +# Exercises: AUTH-8, AUTH-9, AUTH-10 -- the bearer token: non-blank, optional expiry with an +# additive margin, value equality over the real fields, and the secret absent from every +# rendering Ruby has, pp included. +class DexpaceAuthBearerTokenTest < DexpaceTestCase + BearerToken = Dexpace::Auth::BearerToken + + def token(value = "SECRET-TOKEN", expiry: nil) = BearerToken.build(token: value, expiry: expiry) + + test "AUTH-9: the token must be non-blank: nil, empty and whitespace-only are all refused" do + assert_equal("token is required", + assert_raises(Dexpace::InvalidArgumentError) { token(nil) }.message,) + assert_equal("token must not be blank", + assert_raises(Dexpace::InvalidArgumentError) { token("") }.message,) + assert_raises(Dexpace::InvalidArgumentError) { token(" ") } + assert_raises(Dexpace::InvalidArgumentError) { token("\t\n") } + assert_raises(Dexpace::InvalidArgumentError) { token(:sym) } + end + + test "AUTH-10: a nil expiry never expires, whatever the margin" do + never = token(expiry: nil) + + refute(never.expired?(now: Time.at(10**12), margin: 0)) + refute(never.expired?(now: Time.at(10**12), margin: 10**9)) + assert_nil(never.expiry) + end + + test "AUTH-10: expired iff (now + margin) is STRICTLY after the expiry" do + expiring = token(expiry: Time.at(1000)) + + refute(expiring.expired?(now: Time.at(994), margin: 5)) # 999, not after 1000 + refute(expiring.expired?(now: Time.at(995), margin: 5)) # 1000, not strictly after + assert(expiring.expired?(now: Time.at(996), margin: 5)) # 1001 + refute(expiring.expired?(now: Time.at(1000))) # margin defaults to 0 + assert(expiring.expired?(now: Time.at(1001))) + end + + test "the expiry must be a Time or nil" do + assert_raises(Dexpace::InvalidArgumentError) { token(expiry: 1000) } + end + + test "AUTH-8: #to_s and #inspect redact the token and show the expiry" do + secret = token("super-secret-token", expiry: Time.at(1000).utc) + + refute_includes(secret.to_s, "super-secret") + refute_includes(secret.inspect, "super-secret") + refute_includes(secret.to_s, "super-secret") + assert_includes(secret.to_s, Dexpace::Auth::REDACTED) + assert_includes(secret.inspect, "1970-01-01 00:16:40 UTC") + assert_includes([secret].inspect, Dexpace::Auth::REDACTED) + end + + # pp does not call #inspect on a Data -- pp.rb gives Data its own #pretty_print that walks the + # members -- so this is the rendering a two-override credential leaks through. + test "AUTH-8: pp does not print the token either" do + output = StringIO.new + PP.pp(token("super-secret-token"), output) + + refute_includes(output.string, "super-secret") + assert_includes(output.string, Dexpace::Auth::REDACTED) + end + + test "AUTH-8: redaction corrupts nothing -- the real field is intact and read by the stamper" do + secret = token("super-secret-token") + secret.inspect + + assert_equal("super-secret-token", secret.token) + assert_equal({ token: "super-secret-token", expiry: nil }, secret.to_h) + end + + test "AUTH-8: value equality and hashing over the real token and expiry, not the redacted form" do + a = token("t", expiry: Time.at(1)) + b = token("t", expiry: Time.at(1)) + + assert_equal(a, b) + assert_equal(a.hash, b.hash) + refute_equal(a, token("u", expiry: Time.at(1))) + refute_equal(a, token("t", expiry: Time.at(2))) + assert_equal(token("t").inspect, token("u").inspect) # equal renderings, unequal tokens + end + + test "the construction pattern: .new private, frozen, #with re-validates through .build" do + refute_respond_to(BearerToken, :new) + secret = token("t") + + assert_predicate(secret, :frozen?) + assert_equal(Time.at(5), secret.with(expiry: Time.at(5)).expiry) + assert_raises(Dexpace::InvalidArgumentError) { secret.with(token: " ") } + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/challenge_handler_chain_test.rb b/gems/dexpace-core/test/dexpace/auth/challenge_handler_chain_test.rb new file mode 100644 index 0000000..38b4ce1 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/challenge_handler_chain_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/challenge_handler_chain" +require_relative "../../../lib/dexpace/auth/basic_handler" +require_relative "../../../lib/dexpace/auth/digest_handler" +require_relative "../../../lib/dexpace/auth/step" +require_relative "../../support/auth_fixtures" + +# Exercises: AUTH-23, AUTH-25, AUTH-30 -- the composing handler: first handler in declaration +# order, a defensive copy of the list, nil when nothing satisfies, the header NAME from the +# explicit proxy flag, and the hook adapter that is the only place a handler's VALUE becomes a +# header on a request. +class DexpaceAuthChallengeHandlerChainTest < DexpaceTestCase + include AuthFixtures + + Chain = Dexpace::Auth::ChallengeHandlerChain + + def credential = Dexpace::Auth::PasswordCredential.build(username: "a", password: "b") + def basic = Dexpace::Auth::BasicHandler.new(credential) + def digest = Dexpace::Auth::DigestHandler.new(credential) + + BOTH = 'Digest realm="r", nonce="n", Basic realm="r"' + + test "AUTH-23: delegates to the first handler in declaration order whose check passes" do + assert_match(/\ADigest /, Chain.new([digest, basic]).authorization_for(BOTH, https_request)) + assert_match(/\ABasic /, Chain.new([basic, digest]).authorization_for(BOTH, https_request)) + assert_match(/\ABasic /, + Chain.new([digest, basic]).authorization_for('Basic realm="r"', https_request),) + end + + test "AUTH-23: a defensive copy at construction -- later caller mutation cannot reorder it" do + handlers = [basic] + chain = Chain.new(handlers) + handlers.clear + handlers << digest + + refute_nil(chain.authorization_for('Basic realm="r"', https_request)) + assert_predicate(chain, :frozen?) + end + + test "AUTH-25: nil when no handler can satisfy any offered challenge -- never an empty header" do + assert_nil(Chain.new([]).authorization_for(BOTH, https_request)) + assert_nil(Chain.new([basic]).authorization_for('Digest realm="r", nonce="n"', https_request)) + assert_nil(Chain.new([digest]).authorization_for('Digest realm="r", qop="auth-int", nonce="n"', + https_request,)) + assert_nil(Chain.new([basic, digest]).authorization_for("NTLM", https_request)) + assert_nil(Chain.new([basic]).authorization_for(nil, https_request)) + end + + test "AUTH-25: the header name comes from the explicit proxy flag alone" do + chain = Chain.new([]) + + assert_equal("Authorization", chain.header_name(proxy: false)) + assert_equal("Proxy-Authorization", chain.header_name(proxy: true)) + end + + test "AUTH-25, AUTH-30: the hook yields a replacement carrying the selected header, SET" do + already = https_request(headers: Dexpace::Headers.builder.add("Authorization", "old").build) + replacement = Chain.new([basic]).as_challenge_hook.call('Basic realm="r"', already, + unauthorized,) + + assert_equal(["Basic YTpi"], replacement.headers["Authorization"]) + assert_nil(replacement.headers["Proxy-Authorization"]) + assert_equal(already.url, replacement.url) + end + + test "AUTH-25: with the proxy flag the hook writes Proxy-Authorization and not Authorization" do + replacement = Chain.new([basic]).as_challenge_hook(proxy: true) + .call('Basic realm="r"', https_request, unauthorized) + + assert_equal(["Basic YTpi"], replacement.headers["Proxy-Authorization"]) + assert_nil(replacement.headers["Authorization"]) + end + + test "AUTH-25: the hook yields nil, not an empty header, when no handler satisfies" do + assert_nil(Chain.new([]).as_challenge_hook.call('Digest realm="r", nonce="n"', https_request, + unauthorized,)) + end + + test "AUTH-30: the chain is never the default hook -- the default yields no replacement" do + assert_nil(Dexpace::Auth::Step::NO_REPLACEMENT.call('Basic realm="r"', https_request, + unauthorized,)) + end + + test "the hook is a three-argument callable the step accepts" do + assert(Dexpace::Registry.callable?(Chain.new([basic]).as_challenge_hook, arity: 3)) + Dexpace::Auth::Step.build(stamper: Dexpace::Auth::Step::NO_STAMP, + challenge_hook: Chain.new([digest]).as_challenge_hook,) + end + + test "the handlers must be an Array of objects answering #authorization_for" do + assert_raises(Dexpace::InvalidArgumentError) { Chain.new(basic) } + assert_raises(Dexpace::InvalidArgumentError) { Chain.new([Object.new]) } + assert_raises(Dexpace::InvalidArgumentError) { Chain.new(nil) } + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/challenge_test.rb b/gems/dexpace-core/test/dexpace/auth/challenge_test.rb new file mode 100644 index 0000000..79b5cea --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/challenge_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/challenge" + +# Exercises: AUTH-12 -- one parsed challenge: scheme and parameter names folded once at +# construction with a bare downcase, values verbatim, the token68 key, frozen throughout. +class DexpaceAuthChallengeTest < DexpaceTestCase + Challenge = Dexpace::Auth::Challenge + + test "AUTH-12: the scheme and the parameter names are lower-cased; values kept verbatim" do + challenge = Challenge.build(scheme: "DiGeSt", params: { "REALM" => "MiXeD", "Nonce" => "N" }) + + assert_equal("digest", challenge.scheme) + assert_equal({ "realm" => "MiXeD", "nonce" => "N" }, challenge.params) + end + + test "AUTH-12: the token68 value sits under the synthetic key" do + challenge = Challenge.build(scheme: "Bearer", params: { "token68" => "abc==" }) + + assert_equal("abc==", challenge.token68) + assert_equal("token68", Challenge::TOKEN68) + assert_nil(Challenge.build(scheme: "Basic").token68) + end + + test "the params default to empty, are copied and frozen, and must be String to String" do + params = { "realm" => "r" } + challenge = Challenge.build(scheme: "basic", params: params) + params["nonce"] = "n" + + assert_equal({ "realm" => "r" }, challenge.params) + assert_predicate(challenge.params, :frozen?) + assert_empty(Challenge.build(scheme: "basic").params) + assert_raises(Dexpace::InvalidArgumentError) do + Challenge.build(scheme: "b", params: { realm: "r" }) + end + assert_raises(Dexpace::InvalidArgumentError) do + Challenge.build(scheme: "b", params: { "r" => 1 }) + end + assert_raises(Dexpace::InvalidArgumentError) { Challenge.build(scheme: "b", params: nil) } + end + + test "the scheme must be a non-empty String" do + assert_raises(Dexpace::InvalidArgumentError) { Challenge.build(scheme: "") } + assert_raises(Dexpace::InvalidArgumentError) { Challenge.build(scheme: nil) } + assert_raises(Dexpace::InvalidArgumentError) { Challenge.build(scheme: :basic) } + end + + test "the construction pattern: .new private, value equality, #with through .build" do + refute_respond_to(Challenge, :new) + + assert_equal(Challenge.build(scheme: "basic"), Challenge.build(scheme: "BASIC")) + assert_equal("digest", Challenge.build(scheme: "basic").with(scheme: "Digest").scheme) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/challenges_test.rb b/gems/dexpace-core/test/dexpace/auth/challenges_test.rb new file mode 100644 index 0000000..458a41e --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/challenges_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/challenges" +require_relative "../../support/challenge_fixtures" + +# Exercises: AUTH-12, AUTH-13 -- the RFC 7235 challenge parser: every clause of the two +# requirements, the grammar's parameter-versus-challenge ambiguity, the recovery clauses on the +# deliberately malformed fixtures, and the bounded-time measurement that discharges the +# no-regexp house rule by measurement rather than by claim. +class DexpaceAuthChallengesTest < DexpaceTestCase + Challenges = Dexpace::Auth::Challenges + + def shapes(value) + Challenges.parse(value).map { |c| [c.scheme, c.params] } + end + + test "AUTH-13: nil, empty and blank input yield an empty list" do + assert_empty(Challenges.parse(nil)) + assert_empty(Challenges.parse("")) + assert_empty(Challenges.parse(" ")) + assert_empty(Challenges.parse(" , ,\t")) + end + + test "AUTH-12: multiple comma-separated challenges at the top level, in wire order" do + challenges = Challenges.parse("#{ChallengeFixtures::BASIC}, #{ChallengeFixtures::DIGEST_MD5}") + + assert_equal(%w[basic digest], challenges.map(&:scheme)) + assert_equal({ "realm" => "example" }, challenges[0].params) + assert_equal("dcd98b7102dd2f0e8b11d0f600bfb0c093", challenges[1].params["nonce"]) + assert_equal("auth,auth-int", challenges[1].params["qop"]) + end + + test "AUTH-12: scheme and parameter names are lower-cased, values kept verbatim" do + assert_equal([["basic", { "realm" => "MiXeD" }]], shapes('BASIC REALM="MiXeD"')) + assert_equal([["digest", { "algorithm" => "SHA-256" }]], shapes("Digest Algorithm=SHA-256")) + end + + test "AUTH-12: a quoted-string may contain commas and equals signs" do + assert_equal("a, b = c", Challenges.parse('Digest realm="a, b = c"').first.params["realm"]) + assert_equal(1, Challenges.parse('Digest realm="a, b = c", nonce="x,y"').size) + end + + test "AUTH-12: backslash escapes are unescaped and the quotes stripped" do + assert_equal('a"b', Challenges.parse('Digest realm="a\\"b"').first.params["realm"]) + assert_equal("a\\b", Challenges.parse('Digest realm="a\\\\b"').first.params["realm"]) + end + + test "AUTH-12: a bare scheme with no params is a challenge with an empty parameter map" do + assert_equal([["ntlm", {}]], shapes("NTLM")) + assert_equal([["negotiate", {}], ["ntlm", {}]], shapes("Negotiate, NTLM")) + end + + test "AUTH-12: a token68 value is recorded whole under the synthetic key, padding included" do + challenge = Challenges.parse(ChallengeFixtures::BARE_TOKEN68).first + + assert_equal("dGhlIHNlY3JldCB0b2tlbg==", challenge.params["token68"]) + assert_equal("dGhlIHNlY3JldCB0b2tlbg==", challenge.token68) + assert_equal([["bearer", { "token68" => "abc" }], ["basic", { "realm" => "r" }]], + shapes("Bearer abc, Basic realm=r"),) + end + + test "AUTH-12: `realm=` is not read as a token68, so a Digest challenge keeps its realm" do + assert_equal([["digest", { "realm" => "r" }]], shapes('Digest realm="r"')) + assert_equal([["digest", { "realm" => "r", "nonce" => "n" }]], + shapes("Digest realm=r, nonce=n"),) + end + + test "AUTH-12: a second challenge after a parameterised first is not swallowed" do + challenges = Challenges.parse('Digest realm="r", nonce="n", Basic realm="r"') + + assert_equal(%w[digest basic], challenges.map(&:scheme)) + assert_equal({ "realm" => "r", "nonce" => "n" }, challenges.first.params) + assert_equal({ "realm" => "r" }, challenges.last.params) + end + + test "AUTH-13: empty list elements are skipped and the parameter continues the challenge" do + assert_equal([["digest", { "realm" => "r", "nonce" => "n" }], ["basic", { "realm" => "ok" }]], + shapes("#{ChallengeFixtures::MALFORMED_STRAY_COMMA}, Basic realm=\"ok\""),) + end + + test "AUTH-13: a malformed value recovers to the next top-level comma, keeping earlier params" do + challenges = Challenges.parse("#{ChallengeFixtures::MALFORMED_VALUE}, Basic realm=\"ok\"") + + assert_equal(%w[digest basic], challenges.map(&:scheme)) + assert_equal({ "nonce" => "n" }, challenges.first.params) + end + + test "AUTH-13: recovery walks a quoted string, so a comma inside one is not the boundary" do + challenges = Challenges.parse('Digest realm=@@ nonce="a,b", Basic realm=x') + + assert_equal(%w[digest basic], challenges.map(&:scheme)) + assert_equal({ "realm" => "x" }, challenges.last.params) + end + + test "AUTH-13: a parameter before any scheme, and a bare token after one, are skipped" do + assert_equal([["basic", { "realm" => "r" }]], shapes("realm=x, Basic realm=r")) + assert_equal([["bearer", {}], ["basic", { "realm" => "r" }]], + shapes("Bearer abc realm=x, Basic realm=r"),) + end + + test "AUTH-13: an unterminated quoted-string terminates at end-of-input" do + challenge = Challenges.parse(ChallengeFixtures::MALFORMED_UNTERMINATED_QUOTE).first + + assert_equal("unterminated", challenge.params["realm"]) + assert_equal({ "realm" => "r", "nonce" => "n" }, + Challenges.parse('Digest realm="r", nonce="n').first.params,) + end + + # The last input is a UTF-8-tagged value with an invalid byte, on which StringScanner#scan + # and String#downcase both raise ArgumentError: the parser scans it as bytes instead. + test "AUTH-13: the parser never raises on adversarial input, invalid UTF-8 included" do + every_ascii = (0x20..0x7E).map(&:chr).join + invalid_utf8 = "Digest realm=\"caf\xE9\"".b.force_encoding(Encoding::UTF_8) + inputs = ["\\" * 5000, ("a=" * 5000), ('"' * 5000), every_ascii, "=", "\"", ",=,", + "Basic realm=\"\\", "\x00\xFF".b, "Digest realm=\"\xC3\xA9\"".b, invalid_utf8,] + + inputs.each do |input| + assert_kind_of(Array, Challenges.parse(input), input.inspect) + end + end + + test "the regexp-timeout house rule is discharged by measurement: 100 000 bytes in under 1 s" do + inputs = ["a" * 100_000, ("a=b," * 25_000), ('"' * 100_000), ("Basic " * 16_000)] + + inputs.each do |input| + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Challenges.parse(input) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + + assert_operator(elapsed, :<, 1.0) + end + end + + test "the list and every challenge are frozen" do + challenges = Challenges.parse("Basic realm=r") + + assert_predicate(challenges, :frozen?) + assert_predicate(challenges.first.params, :frozen?) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/cross_origin_convergence_test.rb b/gems/dexpace-core/test/dexpace/auth/cross_origin_convergence_test.rb new file mode 100644 index 0000000..dd85d03 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/cross_origin_convergence_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/step" +require_relative "../../../lib/dexpace/auth/key_stamper" +require_relative "../../../lib/dexpace/auth/key_credential" +require_relative "../../support/auth_fixtures" + +# Exercises: REDIR-11, AUTH-29 -- the phase-6 charter's convergence point 1: the end-to-end +# cross-origin credential-leak test with the REAL redirect step in front of the real AUTH step. +# Written by phase 6c and guarded, because Dexpace::Redirect::Step does not exist on 6c's base; +# OWNED BY PHASE 6b, which lands last and un-guards it against its real step (the checklist +# row says so). The body is real -- proven against a stub redirect step in a scratch script +# that forked with and without the marker -- and every helper is defined, so un-guarding is one +# line. 6c's own AUTH-29 proof against phase 4c's ForkingProbe is complete without it. +class DexpaceAuthCrossOriginConvergenceTest < DexpaceTestCase + include AuthFixtures + + ORIGIN = "https://api.example.test/v1/pets" + FOREIGN = "https://evil.example.net/collect" + + def seed_request + Dexpace::Request.build(method: "GET", url: ORIGIN, headers: Dexpace::Headers::EMPTY) + end + + # A 302 to a foreign origin, then a 200 from it: whatever the second hop carries is what the + # redirect layer let through. + def two_hop_cross_origin_transport + redirect = Dexpace::Response.builder + redirect.request = seed_request + redirect.protocol = Dexpace::Protocol::HTTP_1_1 + redirect.status = 302 + redirect.headers = Dexpace::Headers.inbound_builder.add("Location", FOREIGN).build + @transport = SequencedTransport.new(redirect.build, ok) + end + + def captured_headers_for_second_hop + second = @transport.requests.fetch(1) + [second.url.to_s, second.headers.names] + end + + test "no Authorization header reaches a foreign origin after a redirect" do + skip "phase 6b's Dexpace::Redirect::Step is not on this base; 6b un-guards this test" \ + unless defined?(Dexpace::Redirect::Step) + + pipeline = Dexpace::Pipeline.builder(transport: two_hop_cross_origin_transport) + .append(Dexpace::Redirect::Step.new, stage: STAGES::REDIRECT) + .append(Dexpace::Auth::Step.build( + stamper: Dexpace::Auth::KeyStamper.new( + Dexpace::Auth::KeyCredential.new(api_key: "secret"), + ), + )) + .build + response = pipeline.call(seed_request) + url, names = captured_headers_for_second_hop + + assert_equal(200, response.status.code) + assert_equal(FOREIGN, url) + refute_includes(names.map { |name| name.to_s.downcase }, "authorization") + # The seed hop was stamped: the suppression is per hop, not a missing stamper. + assert_equal(["secret"], @transport.requests.first.headers["Authorization"]) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/descriptor_test.rb b/gems/dexpace-core/test/dexpace/auth/descriptor_test.rb new file mode 100644 index 0000000..f5b361d --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/descriptor_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/descriptor" + +# Exercises: AUTH-3 -- a non-empty ordered requirement list, refused empty at construction, +# immutable in and out, and allows_anonymous? true iff a requirement's scheme is NO_AUTH. +class DexpaceAuthDescriptorTest < DexpaceTestCase + Descriptor = Dexpace::Auth::Descriptor + Requirement = Dexpace::Auth::Requirement + Scheme = Dexpace::Auth::Scheme + + def requirement(scheme) = Requirement.build(scheme: scheme) + + test "AUTH-3: an ordered list in caller preference order" do + descriptor = Descriptor.build(requirements: [requirement(:digest), requirement(:basic)]) + + assert_equal([Scheme::DIGEST, Scheme::BASIC], descriptor.requirements.map(&:scheme)) + end + + test "AUTH-3: an empty list is refused at construction with the SDK's argument error" do + error = assert_raises(Dexpace::InvalidArgumentError) { Descriptor.build(requirements: []) } + + assert_includes(error.message, "non-empty") + assert_raises(Dexpace::InvalidArgumentError) { Descriptor.build(requirements: nil) } + assert_raises(Dexpace::InvalidArgumentError) { Descriptor.build(requirements: ["basic"]) } + end + + test "AUTH-3: defensive copy in, read-only view out" do + list = [requirement(:basic)] + descriptor = Descriptor.build(requirements: list) + list << requirement(:digest) + + assert_equal(1, descriptor.requirements.size) + assert_predicate(descriptor.requirements, :frozen?) + assert_same(descriptor.requirements, descriptor.requirements) + assert_raises(FrozenError) { descriptor.requirements << requirement(:digest) } + end + + test "AUTH-3: allows_anonymous? is true iff any requirement's scheme is NO_AUTH" do + assert_predicate(Descriptor.build(requirements: [requirement(:no_auth)]), :allows_anonymous?) + assert_predicate(Descriptor.build(requirements: [requirement(:basic), requirement(:no_auth)]), + :allows_anonymous?,) + refute_predicate(Descriptor.build(requirements: [requirement(:basic), requirement(:oauth2)]), + :allows_anonymous?,) + end + + test "the construction pattern: .new private, value equality, #with through .build" do + refute_respond_to(Descriptor, :new) + a = Descriptor.build(requirements: [requirement(:basic)]) + b = Descriptor.build(requirements: [requirement(:basic)]) + + assert_equal(a, b) + assert_equal([Scheme::DIGEST], + a.with(requirements: [requirement(:digest)]).requirements.map(&:scheme),) + assert_raises(Dexpace::InvalidArgumentError) { a.with(requirements: []) } + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb new file mode 100644 index 0000000..8402106 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb @@ -0,0 +1,425 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/digest_handler" +require_relative "../../support/auth_fixtures" +require_relative "../../support/challenge_fixtures" +require_relative "../../support/fixed_cnonce" + +# Exercises: AUTH-15 through AUTH-24 -- RFC 7616 Digest against RFC 2617 §3.5's genuine +# qop=auth vector and three DERIVED expectations (the legacy no-qop form of the same inputs, +# and RFC 7616 §3.9.1's inputs under SHA-256 and SHA-256-sess: the RFC's printed response is 63 +# hex characters and no SHA-256 digest is, so only values matrix_facts_test.rb produced are +# committed), the selection rules, the counter, the encodings, the quoting and the wire forms +# the port decided. Split into nested cases under Metrics/ClassLength. +class DexpaceAuthDigestHandlerTest < DexpaceTestCase + DigestHandler = Dexpace::Auth::DigestHandler + Challenge = Dexpace::Auth::Challenge + Challenges = Dexpace::Auth::Challenges + PasswordCredential = Dexpace::Auth::PasswordCredential + LIB = File.expand_path("../../../lib/dexpace/auth/digest_handler.rb", __dir__) + + # Shared across the nested cases. + module Fixtures + include AuthFixtures + + RFC2617_NONCE = "dcd98b7102dd2f0e8b11d0f600bfb0c093" + RFC7616_NONCE = "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" + RFC7616_CNONCE = "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ" + # Derived on 3.2.11, 3.3.12, 3.4.10 and 4.0.6 (Task 1), identical on every row. + MD5_QOP_AUTH = "6629fae49393a05397450978507c4ef1" + MD5_LEGACY = "670fd8c2df070c60b045671b8b24ff02" + SHA256 = "9fbf3e2223549127935ba79d47a0299af1f57eae1240ead830c0b47ad60346e1" + SHA256_SESS = "a0316f893cdcbd706441a5392ef9e690688b447acf4015a2b9ce520e6b551a5c" + + def credential(username: "u", password: "p") + PasswordCredential.build(username: username, password: password) + end + + def mufasa = credential(username: "Mufasa", password: "Circle Of Life") + + def jason = credential(username: "Jäsøn Doe", password: "Secret, or not?") + + def handler(cred = credential, **) = DigestHandler.new(cred, **) + + def fixed(value) = FixedCnonce.new(value) + + def digest(**params) + defaults = { "realm" => "r", "nonce" => "n" } + Challenge.build(scheme: "digest", + params: defaults.merge(params.transform_keys(&:to_s)).compact,) + end + + def mufasa_challenge(qop:, algorithm: nil) + digest(realm: "testrealm@host.com", nonce: RFC2617_NONCE, algorithm: algorithm, + opaque: "5ccc069c403ebaf9f0171e9517f40e41", qop: qop,) + end + + def jason_challenge(algorithm) + digest(realm: "http-auth@example.org", qop: "auth", algorithm: algorithm, + nonce: RFC7616_NONCE, opaque: "FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", + charset: "UTF-8",) + end + + def request(path = "/dir/index.html", method: "GET") + Dexpace::Request.build(method: method, url: "https://host#{path}", + headers: Dexpace::Headers::EMPTY,) + end + + def answer(handler, challenge, req = request) + handler.authorization_for([challenge], req, proxy: false) + end + + def params_of(header) = Challenges.parse(header).first.params + + def nc_of(handler, nonce) = params_of(answer(handler, digest(nonce: nonce, qop: "auth")))["nc"] + end + + # AUTH-17: the four algorithms against the published vector and the derived expectations. + class VectorsTest < DexpaceTestCase + include Fixtures + + # RFC 2617 §3.5's vector IS a qop=auth value (nc=00000001, cnonce="0a4f113b"), so it is + # asserted against a qop=auth challenge and a fixed cnonce. + test "AUTH-17: RFC 2617 §3.5's MD5 qop=auth vector reproduces exactly" do + header = answer(handler(mufasa, cnonce_source: fixed("0a4f113b")), + mufasa_challenge(qop: "auth,auth-int"),) + fields = params_of(header) + + assert_equal(MD5_QOP_AUTH, fields["response"]) + assert_equal("00000001", fields["nc"]) + assert_equal("0a4f113b", fields["cnonce"]) + assert_equal("auth", fields["qop"]) + assert_equal("/dir/index.html", fields["uri"]) + assert_equal("5ccc069c403ebaf9f0171e9517f40e41", fields["opaque"]) + assert_equal("MD5", fields["algorithm"]) + end + + test "AUTH-17: the legacy RFC 2069 no-qop branch is H(HA1:nonce:HA2), a different value" do + fields = params_of(answer(handler(mufasa), mufasa_challenge(qop: nil))) + + assert_equal(MD5_LEGACY, fields["response"]) + refute(fields.key?("qop")) # AUTH-22: cnonce, nc and qop only when qop is negotiated + refute(fields.key?("nc")) + refute(fields.key?("cnonce")) + end + + test "AUTH-17: RFC 7616 §3.9.1's inputs under SHA-256 give the derived expectation" do + header = answer(handler(jason, cnonce_source: fixed(RFC7616_CNONCE)), + jason_challenge("SHA-256"), request("/doe.json"),) + fields = params_of(header) + + assert_equal(SHA256, fields["response"]) + assert_equal("SHA-256", fields["algorithm"]) + assert_equal(64, fields["response"].size) + end + + test "AUTH-17: SHA-256-sess keys HA1 with the nonce and cnonce; the full spelling, bare" do + header = answer(handler(jason, cnonce_source: fixed(RFC7616_CNONCE)), + jason_challenge("SHA-256-sess"), request("/doe.json"),) + + assert_equal(SHA256_SESS, params_of(header)["response"]) + assert_includes(header, "algorithm=SHA-256-sess,") + refute_includes(header, 'algorithm="') + end + + test "AUTH-17: MD5-sess follows the same session rule" do + header = answer(handler(mufasa, cnonce_source: fixed("0a4f113b")), + mufasa_challenge(qop: "auth", algorithm: "MD5-sess"),) + ha1 = Digest::MD5.hexdigest("Mufasa:testrealm@host.com:Circle Of Life") + session_ha1 = Digest::MD5.hexdigest("#{ha1}:#{RFC2617_NONCE}:0a4f113b") + ha2 = Digest::MD5.hexdigest("GET:/dir/index.html") + expected = Digest::MD5.hexdigest("#{session_ha1}:#{RFC2617_NONCE}:00000001:0a4f113b:auth:#{ha2}") + + assert_equal(expected, params_of(header)["response"]) + assert_includes(header, "algorithm=MD5-sess,") + end + + test "AUTH-17: every hash is lower-case hex of the selected algorithm" do + md5 = params_of(answer(handler, digest(qop: "auth")))["response"] + sha = params_of(answer(handler, digest(qop: "auth", algorithm: "SHA-256")))["response"] + + assert_match(/\A[0-9a-f]{32}\z/, md5) + assert_match(/\A[0-9a-f]{64}\z/, sha) + end + end + + # AUTH-15, AUTH-16: which challenges are declined, and which is selected. + class SelectionTest < DexpaceTestCase + include Fixtures + + test "AUTH-15: an auth-int-only challenge is declined -- token-exact, never a substring" do + declined = Challenges.parse(ChallengeFixtures::DIGEST_UNSUPPORTED_QOP) + + assert_nil(answer(handler, digest(qop: "auth-int"))) + assert_nil(handler.authorization_for(declined, request, proxy: false)) + refute_nil(answer(handler, digest(qop: "auth-int, auth"))) + refute_nil(answer(handler, digest(qop: "auth-int,AUTH"))) + end + + test "AUTH-15: an unsupported algorithm is declined; the four supported are accepted" do + assert_nil(answer(handler, digest(algorithm: "SHA-512-256"))) + assert_nil(answer(handler, digest(algorithm: "SHA-512-256-sess"))) + DigestHandler::ALGORITHMS.each { |name| refute_nil(answer(handler, digest(algorithm: name))) } + assert_equal(%w[MD5 MD5-sess SHA-256 SHA-256-sess], DigestHandler::ALGORITHMS) + end + + test "AUTH-15: no mutual-auth verification -- nothing handles rspauth" do + refute_respond_to(handler, :verify) + refute_includes(File.read(LIB), "rspauth") + end + + test "AUTH-16: satisfiable iff Digest (any case), realm and nonce present, qop auth/absent" do + both = { "realm" => "r", "nonce" => "n" } + + refute_nil(answer(handler, Challenge.build(scheme: "DIGEST", params: both))) + assert_nil(answer(handler, Challenge.build(scheme: "basic", params: both))) + assert_nil(answer(handler, Challenge.build(scheme: "digest", params: { "nonce" => "n" }))) + assert_nil(answer(handler, Challenge.build(scheme: "digest", params: { "realm" => "r" }))) + assert_nil(handler.authorization_for([], request, proxy: false)) + end + + test "AUTH-16: an absent algorithm defaults to MD5; the token is matched case-insensitively" do + assert_equal("MD5", params_of(answer(handler, digest))["algorithm"]) + assert_equal("SHA-256", params_of(answer(handler, digest(algorithm: "sha-256")))["algorithm"]) + end + + test "AUTH-16: selection prefers the algorithm earliest in the preference, whatever order" do + offered = [digest(algorithm: "MD5"), digest(algorithm: "SHA-256")] + prefers_sha = handler(credential, preference: %w[SHA-256 MD5]) + prefers_md5 = handler(credential, preference: %w[MD5 SHA-256]) + sha_only = handler(credential, preference: ["SHA-256"]) + + assert_includes(prefers_sha.authorization_for(offered, request, proxy: false), + "algorithm=SHA-256,",) + assert_includes(prefers_sha.authorization_for(offered.reverse, request, proxy: false), + "algorithm=SHA-256,",) + assert_includes(prefers_md5.authorization_for(offered.reverse, request, proxy: false), + "algorithm=MD5,",) + assert_nil(sha_only.authorization_for([digest(algorithm: "MD5")], request, proxy: false)) + end + + test "the preference must be a non-empty subset of the four; the source must answer #hex" do + assert_raises(Dexpace::InvalidArgumentError) { handler(credential, preference: []) } + assert_raises(Dexpace::InvalidArgumentError) do + handler(credential, preference: %w[SHA-512-256]) + end + assert_raises(Dexpace::InvalidArgumentError) do + handler(credential, cnonce_source: Object.new) + end + end + + test "AUTH-14's rule at use: an empty username or password is refused at construction" do + assert_raises(Dexpace::InvalidArgumentError) { handler(credential(username: "")) } + assert_raises(Dexpace::InvalidArgumentError) { handler(credential(password: "")) } + assert_raises(Dexpace::InvalidArgumentError) { handler("Mufasa:Circle Of Life") } + handler(credential(password: " ")) + end + end + + # AUTH-18, AUTH-19, AUTH-24: the per-nonce counter and its store. + class CounterTest < DexpaceTestCase + include Fixtures + + test "AUTH-18: nc starts at 00000001 per server nonce and increments only on reuse" do + digest_handler = handler + counts = [nc_of(digest_handler, "n1"), nc_of(digest_handler, "n1"), + nc_of(digest_handler, "n2"), nc_of(digest_handler, "n1"),] + + assert_equal(%w[00000001 00000002 00000001 00000003], counts) + end + + test "AUTH-18: exactly 8 lower-case hex digits, wrapping to the low 32 bits" do + digest_handler = handler + store = digest_handler.instance_variable_get(:@nonces) + + assert_kind_of(Dexpace.const_get(:BoundedMap), store) + store.update("wrap") { |_current| 0xFFFFFFFF } + + assert_equal("00000000", nc_of(digest_handler, "wrap")) + assert_equal("00000001", nc_of(digest_handler, "wrap")) + store.update("big") { |_current| 0x1000000FE } + + assert_equal("000000ff", nc_of(digest_handler, "big")) + end + + test "AUTH-19: bounded at the cap, 1024 by default; an evicted nonce restarts at 1" do + assert_equal(1024, DigestHandler::DEFAULT_CAP) + digest_handler = handler(credential, cap: 2) + %w[a b c].each { |nonce| nc_of(digest_handler, nonce) } # "c" evicts "a" + + assert_equal(2, digest_handler.instance_variable_get(:@nonces).size) + assert_equal("00000001", nc_of(digest_handler, "a")) + end + + test "R11: the store is per handler instance, never shared" do + one = handler + two = handler + nc_of(one, "n") + + assert_equal("00000001", nc_of(two, "n")) + assert_equal("00000002", nc_of(one, "n")) + refute_same(one.instance_variable_get(:@nonces), two.instance_variable_get(:@nonces)) + end + + # The deterministic proof that the increment is one critical section is + # bounded_map_test.rb's forced interleaving; this asserts the handler-level property it + # buys -- sixteen threads reusing one nonce produce sixteen hundred distinct counts. + test "AUTH-24: sixteen threads reusing one nonce yield correct, non-duplicated counts" do + digest_handler = handler + barrier = ::Thread::Queue.new + results = Array.new(16) { [] } + threads = Array.new(16) do |index| + Thread.new do + barrier.pop + 100.times { results[index] << nc_of(digest_handler, "shared") } + end + end + 16.times { barrier << true } + threads.each(&:join) + counts = results.flatten + + assert_equal(1600, counts.uniq.size) + assert_equal((1..1600).map { |n| format("%08x", n) }.sort, counts.sort) + end + + test "AUTH-24: the handler is frozen and holds no per-request state" do + assert_predicate(handler, :frozen?) + end + end + + # AUTH-20, AUTH-21: the cnonce source and the hash-input encoding. + class EncodingTest < DexpaceTestCase + include Fixtures + + test "AUTH-20: the cnonce is 16 SecureRandom bytes, hex-encoded, fresh per response" do + source = fixed("a" * 32) + answer(handler(credential, cnonce_source: source), digest(qop: "auth")) + + assert_equal([16], source.requests) + live = handler + cnonces = Array.new(5) { params_of(answer(live, digest(qop: "auth")))["cnonce"] } + + assert_equal(5, cnonces.uniq.size) + cnonces.each { |cnonce| assert_match(/\A[0-9a-f]{32}\z/, cnonce) } + assert_includes(File.read(LIB), "::SecureRandom") + refute_match(/Random\.new|Random\.hex|Kernel#rand|\brand\(/, File.read(LIB)) + end + + test "AUTH-21: charset=UTF-8, in any case, hashes the UTF-8 bytes and never raises" do + cred = credential(username: "a", password: "日") + + %w[UTF-8 utf-8 Utf-8].each do |charset| + refute_nil(answer(handler(cred), digest(qop: "auth", charset: charset))) + end + expected_ha1 = Digest::MD5.hexdigest("a:r:日".b) + header = answer(handler(cred, cnonce_source: fixed("c")), + digest(qop: "auth", charset: "UTF-8"),) + ha2 = Digest::MD5.hexdigest("GET:/dir/index.html") + expected = Digest::MD5.hexdigest("#{expected_ha1}:n:00000001:c:auth:#{ha2}") + + assert_equal(expected, params_of(header)["response"]) + end + + test "AUTH-21: no charset hashes ISO-8859-1 bytes of a representable credential" do + cred = credential(username: "a", password: "café") + header = answer(handler(cred, cnonce_source: fixed("c")), digest(qop: "auth")) + latin1_ha1 = Digest::MD5.hexdigest("a:r:café".encode("ISO-8859-1")) + ha2 = Digest::MD5.hexdigest("GET:/dir/index.html") + expected = Digest::MD5.hexdigest("#{latin1_ha1}:n:00000001:c:auth:#{ha2}") + + assert_equal(expected, params_of(header)["response"]) + refute_equal(Digest::MD5.hexdigest("a:r:café"), latin1_ha1) # the two encodings differ + end + + # R10's matrix, one assertion per algorithm: the raise is a property of the shared + # encoding step, not of one hash routine. + test "AUTH-21 (R10, P6-1): no charset and an unencodable password raise the typed failure" do + cred = credential(username: "a", password: "日") + DigestHandler::ALGORITHMS.each do |algorithm| + error = assert_raises(Dexpace::Auth::UnencodableCredentialError) do + answer(handler(cred), digest(qop: "auth", algorithm: algorithm)) + end + + assert_equal(:password, error.field) + assert_equal("ISO-8859-1", error.encoding) + assert_kind_of(Encoding::UndefinedConversionError, error.cause) + refute_includes(error.message, "日") + end + end + + test "AUTH-21 (R10): an unencodable username names :username" do + error = assert_raises(Dexpace::Auth::UnencodableCredentialError) do + answer(handler(credential(username: "日", password: "p")), digest) + end + + assert_equal(:username, error.field) + end + end + + # AUTH-22 and the two wire forms the port decided. + class WireTest < DexpaceTestCase + include Fixtures + + test "AUTH-22: username, realm, nonce, uri, response, cnonce, opaque quoted; three bare" do + header = answer(handler(credential(username: "u", password: "p"), cnonce_source: fixed("cn")), + digest(qop: "auth", opaque: "op"),) + + %w[username realm nonce uri response cnonce opaque].each do |name| + assert_match(/\b#{name}="[^"]*"/, header, name) + end + %w[qop nc algorithm].each do |name| + assert_match(/\b#{name}=[^"]/, header, name) + refute_match(/\b#{name}="/, header, name) + end + assert_match(/\ADigest /, header) + end + + test "AUTH-22: embedded quotes and backslashes in an echoed value are backslash-escaped" do + header = answer(handler(credential(username: 'u"v\\w', password: "p")), + digest(realm: 'r"ealm', nonce: "n", opaque: 'o\\p'),) + + assert_includes(header, 'username="u\\"v\\\\w"') + assert_includes(header, 'realm="r\\"ealm"') + assert_includes(header, 'opaque="o\\\\p"') + assert_equal('r"ealm', params_of(header)["realm"]) # round-trips through the parser + end + + test "AUTH-22: the digest-uri is the request-target: raw path, / when empty, plus ?query" do + assert_equal("/", params_of(answer(handler, digest, request("")))["uri"]) + assert_equal("/a%20b?q=1&r=%2F", + params_of(answer(handler, digest, request("/a%20b?q=1&r=%2F")))["uri"],) + assert_equal("/p", params_of(answer(handler, digest, request("/p#frag")))["uri"]) + end + + test "AUTH-17: the request method enters HA2, so POST and GET differ" do + get = answer(handler(credential, cnonce_source: fixed("c")), digest(qop: "auth")) + post = answer(handler(credential, cnonce_source: fixed("c")), digest(qop: "auth"), + request(method: "POST"),) + + refute_equal(params_of(get)["response"], params_of(post)["response"]) + end + + # HTTP-18's outbound grammar refuses a byte above 0x7F, so RFC 7616 §3.9.1's quoted + # username cannot be sent as the RFC prints it; §3.4's username* form is the wire form. + test "RFC 7616 §3.4: a non-ASCII username goes on the wire as username*=UTF-8''pct-encoded" do + header = answer(handler(jason, cnonce_source: fixed(RFC7616_CNONCE)), + jason_challenge("SHA-256"), request("/doe.json"),) + + assert_includes(header, "username*=UTF-8''J%C3%A4s%C3%B8n%20Doe") + refute_includes(header, 'username="') + assert_predicate(header, :ascii_only?) + assert_equal(SHA256, params_of(header)["response"]) # the hash still uses the raw username + https_request.with(headers: https_request.headers.new_builder.set("Authorization", + header,).build) + end + + test "a challenge whose realm, nonce or opaque cannot be echoed under HTTP-18 is declined" do + assert_nil(answer(handler, digest(realm: "caf\xC3\xA9".b))) + assert_nil(answer(handler, digest(nonce: "n\x00".b))) + assert_nil(answer(handler, digest(opaque: "日"))) + refute_nil(answer(handler, digest(realm: "plain realm", opaque: "ok"))) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/https_required_error_test.rb b/gems/dexpace-core/test/dexpace/auth/https_required_error_test.rb new file mode 100644 index 0000000..2d003c9 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/https_required_error_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/https_required_error" + +# Exercises: AUTH-28 -- the guard's error: phase 2's shape, naming the concrete step and the +# offending scheme as members and in the message. +class DexpaceAuthHTTPSRequiredErrorTest < DexpaceTestCase + Error = Dexpace::Auth::HTTPSRequiredError + + test "phase 2's shape, carrying the step and the scheme" do + error = Error.new(scheme: "http", step: "Dexpace::Auth::Step") + + assert_kind_of(StandardError, error) + assert_kind_of(Dexpace::Error, error) + assert_equal("http", error.scheme) + assert_equal("Dexpace::Auth::Step", error.step) + end + + test "AUTH-28: the message names the concrete step and the offending scheme" do + message = Error.new(scheme: "ftp", step: "Dexpace::Auth::AsyncStep").message + + assert_includes(message, "Dexpace::Auth::AsyncStep") + assert_includes(message, '"ftp"') + assert_includes(message, "AUTH-28") + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/key_credential_test.rb b/gems/dexpace-core/test/dexpace/auth/key_credential_test.rb new file mode 100644 index 0000000..901382b --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/key_credential_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "pp" +require "stringio" +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/key_credential" + +# Exercises: AUTH-8, AUTH-9, AUTH-26 -- the API-key credential: non-blank key, a valid header +# name, reference identity, and the key absent from every rendering. +class DexpaceAuthKeyCredentialTest < DexpaceTestCase + KeyCredential = Dexpace::Auth::KeyCredential + + test "AUTH-9: the api_key must be non-blank" do + assert_raises(Dexpace::InvalidArgumentError) { KeyCredential.new(api_key: "") } + assert_raises(Dexpace::InvalidArgumentError) { KeyCredential.new(api_key: " ") } + assert_raises(Dexpace::InvalidArgumentError) { KeyCredential.new(api_key: nil) } + end + + test "AUTH-26: the header defaults to Authorization, the prefix to none" do + credential = KeyCredential.new(api_key: "k") + + assert_equal("Authorization", credential.header_name) + assert_nil(credential.prefix) + assert_equal("k", credential.key_value) + end + + test "the header name is validated as a field name, and a prefix must be non-blank" do + credential = KeyCredential.new(api_key: "k", header_name: "X-Api-Key", prefix: "Key") + + assert_equal("X-Api-Key", credential.header_name) + assert_equal("Key", credential.prefix) + assert_raises(Dexpace::InvalidArgumentError) do + KeyCredential.new(api_key: "k", header_name: "bad name") + end + assert_raises(Dexpace::InvalidArgumentError) { KeyCredential.new(api_key: "k", prefix: " ") } + end + + test "AUTH-8: reference identity -- two instances with identical fields are NOT equal" do + a = KeyCredential.new(api_key: "x") + b = KeyCredential.new(api_key: "x") + + refute_equal(a, b) + refute_operator(a, :eql?, b) + refute_equal(a.hash, b.hash) + assert_equal([a], [a] & [a]) + end + + test "AUTH-8: #to_s, #inspect and pp redact the key and show the header name and prefix" do + credential = KeyCredential.new(api_key: "super-secret-key", header_name: "X-Api-Key", + prefix: "Key",) + output = StringIO.new + PP.pp(credential, output) + + [credential.to_s, credential.inspect, output.string, [credential].inspect].each do |text| + refute_includes(text, "super-secret") + assert_includes(text, "X-Api-Key") + assert_includes(text, "Key") + assert_includes(text, Dexpace::Auth::REDACTED) + end + assert_equal("super-secret-key", credential.key_value) + end + + test "frozen at the end of construction, its Strings copied" do + key = +"k" + credential = KeyCredential.new(api_key: key) + key << "!" + + assert_predicate(credential, :frozen?) + assert_equal("k", credential.key_value) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/key_stamper_test.rb b/gems/dexpace-core/test/dexpace/auth/key_stamper_test.rb new file mode 100644 index 0000000..18421f8 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/key_stamper_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/key_stamper" +require_relative "../../../lib/dexpace/auth/key_credential" +require_relative "../../../lib/dexpace/auth/named_key_credential" +require_relative "../../support/auth_fixtures" + +# Exercises: AUTH-26 -- the key written into the configured header, Authorization by default, +# a configured prefix prepended with exactly one space, and a stamper stateless after +# construction; the header SET rather than added, and the value checked against the outbound +# grammar once. +class DexpaceAuthKeyStamperTest < DexpaceTestCase + include AuthFixtures + + KeyStamper = Dexpace::Auth::KeyStamper + KeyCredential = Dexpace::Auth::KeyCredential + NamedKeyCredential = Dexpace::Auth::NamedKeyCredential + + test "AUTH-26: the key goes into the configured header, defaulting to Authorization" do + stamped = KeyStamper.new(KeyCredential.new(api_key: "abc")).call(https_request) + + assert_equal(["abc"], stamped.headers["Authorization"]) + stamped = KeyStamper.new(KeyCredential.new(api_key: "abc", + header_name: "X-Api-Key",)).call(https_request) + + assert_equal(["abc"], stamped.headers["X-Api-Key"]) + assert_nil(stamped.headers["Authorization"]) + end + + test "AUTH-26: a configured prefix is prepended with a single space, for both key types" do + named = NamedKeyCredential.new(name: "n", key: "abc", prefix: "SharedAccessKey") + keyed = KeyCredential.new(api_key: "abc", prefix: "Key") + + assert_equal(["SharedAccessKey abc"], + KeyStamper.new(named).call(https_request).headers["Authorization"],) + assert_equal(["Key abc"], KeyStamper.new(keyed).call(https_request).headers["Authorization"]) + end + + test "AUTH-26: stateless after construction -- the same value every call, frozen, no ivar set" do + stamper = KeyStamper.new(KeyCredential.new(api_key: "abc")) + before = stamper.instance_variables.map { |name| stamper.instance_variable_get(name) } + first = stamper.call(https_request) + second = stamper.call(https_request) + + assert_equal(first.headers["Authorization"], second.headers["Authorization"]) + assert_predicate(stamper, :frozen?) + assert_equal(before, stamper.instance_variables.map do |name| + stamper.instance_variable_get(name) + end,) + end + + test "the header is SET: re-stamping a stamped request replaces rather than appends" do + stamper = KeyStamper.new(KeyCredential.new(api_key: "abc")) + twice = stamper.call(stamper.call(https_request)) + + assert_equal(["abc"], twice.headers["Authorization"]) + end + + test "the request is not mutated: a new request carries the header, the original does not" do + original = https_request + stamped = KeyStamper.new(KeyCredential.new(api_key: "abc")).call(original) + + assert_nil(original.headers["Authorization"]) + refute_same(original, stamped) + end + + test "a credential without the three readers, or a value the wire refuses, fails to construct" do + assert_raises(Dexpace::InvalidArgumentError) { KeyStamper.new(Object.new) } + ["clé", "a\r\nb"].each do |unsendable| + assert_raises(Dexpace::InvalidArgumentError) do + KeyStamper.new(KeyCredential.new(api_key: unsendable)) + end + end + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/matrix_facts_test.rb b/gems/dexpace-core/test/dexpace/auth/matrix_facts_test.rb new file mode 100644 index 0000000..2f5ab7a --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/matrix_facts_test.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "digest" +require "securerandom" +require "pp" +require "stringio" +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/model" + +# Exercises: AUTH-14, AUTH-17, AUTH-18, AUTH-20, AUTH-21 (the Ruby facts they rest on) -- the +# phase-6c plan's verified facts, re-run as a standing test on every CI row rather than once in +# a scratch script (5a's, 5b's and 5c's precedent), with the four Digest expectations DERIVED +# from their inputs here and compared with the values the handler suite commits. No lib/ +# mirror: it asserts the interpreter, not a file. +class DexpaceAuthMatrixFactsTest < DexpaceTestCase + test "AUTH-14: pack(\"m0\") base64-encodes the UTF-8 bytes into a US-ASCII String, no base64" do + assert_equal("YWxpY2U6czNjcjN0", ["alice:s3cr3t"].pack("m0")) + assert_equal(Encoding::US_ASCII, ["alice:s3cr3t"].pack("m0").encoding) + assert_equal("w7w6cMOk", ["ü:pä"].pack("m0")) + end + + test "String#unpack1(\"m\") is lenient: garbage decodes to whatever valid octets survive" do + assert_equal("i\xB7".b, "!!a b c!!".unpack1("m")) + end + + test "AUTH-17: MD5 and SHA-256 hexdigests are lower-case hex of 32 and 64 characters" do + assert_match(/\A[0-9a-f]{32}\z/, Digest::MD5.hexdigest("x")) + assert_match(/\A[0-9a-f]{64}\z/, Digest::SHA256.hexdigest("x")) + end + + test "AUTH-18: format(\"%08x\", n & 0xFFFFFFFF) renders 8 lower-case hex digits and wraps" do + assert_equal("00000001", format("%08x", 1)) + assert_equal("00000001", format("%08x", 0x100000001 & 0xFFFFFFFF)) + assert_equal("ffffffff", format("%08x", 0xFFFFFFFF)) + end + + test "AUTH-21: String#encode(ISO-8859-1) raises on an unmappable character, not a mappable one" do + assert_raises(Encoding::UndefinedConversionError) { "日".encode(Encoding::ISO_8859_1) } + assert_equal([112, 228], "pä".encode(Encoding::ISO_8859_1).bytes) + end + + test "AUTH-20: SecureRandom.hex(16) is 32 lower-case hex characters, 128 bits" do + 100.times { assert_match(/\A[0-9a-f]{32}\z/, SecureRandom.hex(16)) } + end + + test "AUTH-15's trap: a substring test accepts auth-int" do + assert_includes("auth-int", "auth") + end + + test "AUTH-8's trap: pp walks a Data's members and ignores an #inspect override" do + klass = Data.define(:token) do + def inspect = "#" + end + output = StringIO.new + PP.pp(klass.new(token: "SECRET"), output) + + assert_includes(output.string, "SECRET") + end + + test "a Data's members are not ivars: the allocate-and-set trick leaves them nil" do + klass = Data.define(:name) + instance = klass.allocate + instance.instance_variable_set(:@name, "X") + + assert_nil(instance.name) + end + + test "AUTH-2's trap: dup.freeze is shallow, Model.own is deep" do + scopes = [+"read"] + shallow = scopes.dup.freeze + deep = Dexpace::Model.own(scopes) + scopes[0] << ":write" + + assert_equal(["read:write"], shallow) + assert_equal(["read"], deep) + assert_predicate(deep[0], :frozen?) + end + + test "Thread::Mutex is not reentrant: the second synchronize raises ThreadError" do + mutex = ::Thread::Mutex.new + error = assert_raises(ThreadError) { mutex.synchronize { mutex.synchronize { :unreached } } } + + assert_includes(error.message, "recursive locking") + end + + test "Gem::BUNDLED_GEMS::SINCE is undefined on the 3.2 floor and defined above it" do + defined_here = defined?(Gem::BUNDLED_GEMS::SINCE) ? true : false + + assert_equal(RUBY_VERSION >= "3.3", defined_here) + end + + # The four Digest expectations, derived from their inputs. RFC 2617 §3.5's published response + # is a qop=auth value; RFC 7616 §3.9.1's printed SHA-256 response is 63 hex characters and is + # not reproducible from its own inputs, so the two SHA-256 values are the ones this Ruby + # produces from the RFC's inputs, and they are what digest_handler_test.rb commits. + test "AUTH-17: the four Digest expectations derive from their inputs" do + md5 = ->(text) { Digest::MD5.hexdigest(text) } + ha1 = md5.call("Mufasa:testrealm@host.com:Circle Of Life") + ha2 = md5.call("GET:/dir/index.html") + nonce = "dcd98b7102dd2f0e8b11d0f600bfb0c093" + + assert_equal("6629fae49393a05397450978507c4ef1", + md5.call("#{ha1}:#{nonce}:00000001:0a4f113b:auth:#{ha2}"),) + assert_equal("670fd8c2df070c60b045671b8b24ff02", md5.call("#{ha1}:#{nonce}:#{ha2}")) + + sha = ->(text) { Digest::SHA256.hexdigest(text) } + s_nonce = "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" + s_cnonce = "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ" + s_ha1 = sha.call("Jäsøn Doe:http-auth@example.org:Secret, or not?") + s_ha2 = sha.call("GET:/doe.json") + + assert_equal("9fbf3e2223549127935ba79d47a0299af1f57eae1240ead830c0b47ad60346e1", + sha.call("#{s_ha1}:#{s_nonce}:00000001:#{s_cnonce}:auth:#{s_ha2}"),) + session = sha.call("#{s_ha1}:#{s_nonce}:#{s_cnonce}") + + assert_equal("a0316f893cdcbd706441a5392ef9e690688b447acf4015a2b9ce520e6b551a5c", + sha.call("#{session}:#{s_nonce}:00000001:#{s_cnonce}:auth:#{s_ha2}"),) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/named_key_credential_test.rb b/gems/dexpace-core/test/dexpace/auth/named_key_credential_test.rb new file mode 100644 index 0000000..d1bbb6f --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/named_key_credential_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "pp" +require "stringio" +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/named_key_credential" + +# Exercises: AUTH-8, AUTH-9, AUTH-26 -- the named-key credential: non-blank name AND key, +# reference identity, the key redacted and the name (AUTH-8's non-secret "key name") visible. +class DexpaceAuthNamedKeyCredentialTest < DexpaceTestCase + NamedKeyCredential = Dexpace::Auth::NamedKeyCredential + + test "AUTH-9: both the name and the key must be non-blank" do + assert_raises(Dexpace::InvalidArgumentError) { NamedKeyCredential.new(name: "", key: "k") } + assert_raises(Dexpace::InvalidArgumentError) { NamedKeyCredential.new(name: " ", key: "k") } + assert_raises(Dexpace::InvalidArgumentError) { NamedKeyCredential.new(name: "n", key: "") } + assert_raises(Dexpace::InvalidArgumentError) { NamedKeyCredential.new(name: "n", key: nil) } + assert_raises(Dexpace::InvalidArgumentError) { NamedKeyCredential.new(name: nil, key: "k") } + end + + test "AUTH-26: the readers the stamper is written against" do + credential = NamedKeyCredential.new(name: "n", key: "k", prefix: "SharedAccessKey") + + assert_equal("n", credential.name) + assert_equal("k", credential.key_value) + assert_equal("Authorization", credential.header_name) + assert_equal("SharedAccessKey", credential.prefix) + end + + test "AUTH-8: reference identity" do + a = NamedKeyCredential.new(name: "n", key: "k") + + refute_equal(a, NamedKeyCredential.new(name: "n", key: "k")) + assert_equal([a], [a] & [a]) + end + + test "AUTH-8: the key is redacted in every rendering; the name stays visible" do + credential = NamedKeyCredential.new(name: "key-name", key: "super-secret-key") + output = StringIO.new + PP.pp(credential, output) + + [credential.to_s, credential.inspect, output.string].each do |text| + refute_includes(text, "super-secret") + assert_includes(text, "key-name") + end + end + + test "frozen at the end of construction" do + assert_predicate(NamedKeyCredential.new(name: "n", key: "k"), :frozen?) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/password_credential_test.rb b/gems/dexpace-core/test/dexpace/auth/password_credential_test.rb new file mode 100644 index 0000000..44b50e9 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/password_credential_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "pp" +require "stringio" +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/password_credential" + +# Exercises: AUTH-8, AUTH-14 (P6-3) -- the username/password pair: no blank check at +# construction (AUTH-9 does not name this type, and AUTH-14's laxer rule lives at the +# handlers), value equality, both fields redacted in every rendering. +class DexpaceAuthPasswordCredentialTest < DexpaceTestCase + PasswordCredential = Dexpace::Auth::PasswordCredential + + test "P6-3: an empty or whitespace-only field is ACCEPTED; only nil and a non-String refused" do + assert_equal("", PasswordCredential.build(username: "u", password: "").password) + assert_equal(" ", PasswordCredential.build(username: "u", password: " ").password) + assert_equal("", PasswordCredential.build(username: "", password: "p").username) + assert_equal("username is required", + assert_raises(Dexpace::InvalidArgumentError) do + PasswordCredential.build(username: nil, password: "p") + end.message,) + assert_raises(Dexpace::InvalidArgumentError) do + PasswordCredential.build(username: "u", password: 1) + end + end + + test "AUTH-8: both fields redacted in #to_s, #inspect and pp; the real fields intact" do + credential = PasswordCredential.build(username: "alice-user", password: "super-secret") + output = StringIO.new + PP.pp(credential, output) + + [credential.to_s, credential.inspect, output.string, credential.to_s].each do |text| + refute_includes(text, "super-secret") + refute_includes(text, "alice-user") + assert_includes(text, Dexpace::Auth::REDACTED) + end + assert_equal("alice-user", credential.username) + assert_equal("super-secret", credential.password) + end + + test "value equality over both fields, unaffected by the redacted form" do + a = PasswordCredential.build(username: "u", password: "p") + + assert_equal(a, PasswordCredential.build(username: "u", password: "p")) + refute_equal(a, PasswordCredential.build(username: "u", password: "q")) + assert_equal(a.inspect, PasswordCredential.build(username: "u", password: "q").inspect) + end + + test "the construction pattern: .new private, frozen copies, #with through .build" do + refute_respond_to(PasswordCredential, :new) + password = +"p" + credential = PasswordCredential.build(username: "u", password: password) + password << "!" + + assert_equal("p", credential.password) + assert_equal("v", credential.with(username: "v").username) + assert_raises(Dexpace::InvalidArgumentError) { credential.with(password: nil) } + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/pillar_integration_test.rb b/gems/dexpace-core/test/dexpace/auth/pillar_integration_test.rb new file mode 100644 index 0000000..6d55d1d --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/pillar_integration_test.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/step" +require_relative "../../../lib/dexpace/auth/async_step" +require_relative "../../../lib/dexpace/auth/key_stamper" +require_relative "../../../lib/dexpace/auth/key_credential" +require_relative "../../../lib/dexpace/auth/basic_handler" +require_relative "../../../lib/dexpace/auth/digest_handler" +require_relative "../../../lib/dexpace/auth/challenge_handler_chain" +require_relative "../../support/auth_fixtures" +require_relative "../../support/state_probe" +require_relative "../../support/fixed_cnonce" + +# Exercises: AUTH-27, AUTH-28, AUTH-29 (both branches), AUTH-30 through AUTH-33, AUTH-31's +# uniformity -- one shared example set run against BOTH runtimes through real pipelines with +# phase 4c's ForkingProbe standing in for the REDIRECT step and StateProbe reading the slots, +# so the two steps are proven to agree rather than tested twice by hand. The set is first run +# against a deliberately broken step to show it is not vacuously green. No lib/ mirror: a +# cross-cutting suite over step.rb and async_step.rb. +class DexpaceAuthPillarIntegrationTest < DexpaceTestCase + STAGES = Dexpace::Pipeline::Stages + + # One runtime: how to build a pipeline, which transport it takes, and how a result is read. + Runtime = Struct.new(:name, :step_class, :transport_class, :build, :resolve) do + def pipeline(steps, transport) + builder = Dexpace::Pipeline::Builder.new(transport: transport) + steps.each do |step, stage| + stage.nil? ? builder.append(step) : builder.append(step, stage: stage) + end + build.call(builder) + end + end + + SYNC = Runtime.new("sync", Dexpace::Auth::Step, SequencedTransport, :build.to_proc, + :itself.to_proc,) + ASYNC = Runtime.new("async", Dexpace::Auth::AsyncStep, SequencedAsyncTransport, + :build_async.to_proc, :value.to_proc,) + + # A step of the right stage whose every branch is wrong: stamps cross-origin, skips the + # guard, never replays. The shared examples must fail against it. + class BrokenStep + def initialize(stamper) = @stamper = stamper + def stage = Dexpace::Pipeline::Stages::AUTH + def call(request, cursor) = cursor.fork.call(@stamper.call(request)) + + # The .build shape the examples construct through; the hook is what a broken step ignores. + def self.build(stamper:, **) = new(stamper) + end + + # The helpers the example set is written over: a runtime-parametrised dispatch. + module Harness + include AuthFixtures + + def key_stamper + Dexpace::Auth::KeyStamper.new(Dexpace::Auth::KeyCredential.new(api_key: "secret")) + end + + def build_step(hook: Dexpace::Auth::Step::NO_REPLACEMENT) + runtime.step_class.build(stamper: key_stamper, challenge_hook: hook) + end + + def redirect_probe(state) = ForkingProbe.new(times: 1, state_per_drive: [state]) + + def redirected(state, *rest) = [[redirect_probe(state), STAGES::REDIRECT], *rest] + + def transport(*script) = runtime.transport_class.new(*script) + + def dispatch(steps, transport, request = https_request) + runtime.resolve.call(runtime.pipeline(steps, transport).call(request)) + end + + def failure_of(steps, transport, request) + dispatch(steps, transport, request) + nil + rescue StandardError => error + error + end + + def echo_hook = ->(_c, request, _r) { request } + end + + # The shared examples, written once, parametrised by the runtime: the stamping half. + module StampExamples + include Harness + + def examples_cross_origin_suppresses_stamp_and_guard + wire = transport(ok) + reader = StateProbe.new(stage_to_read: STAGES::REDIRECT) + steps = redirected({ cross_origin: true }, [build_step, nil], [reader, STAGES::POST_AUTH]) + response = dispatch(steps, wire, http_request) + + assert_equal(200, response.status.code) + assert_equal([nil], wire.authorization_headers) + assert_equal([{ cross_origin: true }], reader.reads) + end + + def examples_same_origin_restamps_and_reguards + wire = transport(ok) + dispatch(redirected({ cross_origin: false }, [build_step, nil]), wire) + + assert_equal(["secret"], wire.authorization_headers) + error = failure_of(redirected({ cross_origin: false }, [build_step, nil]), transport(ok), + http_request,) + + assert_kind_of(Dexpace::Auth::HTTPSRequiredError, error) + end + + def examples_no_redirect_step_stamps + wire = transport(ok) + dispatch([[build_step, nil]], wire) + + assert_equal(["secret"], wire.authorization_headers) + end + + def examples_guard_before_stamp + wire = transport(ok) + error = failure_of([[build_step, nil]], wire, http_request) + + assert_kind_of(Dexpace::Auth::HTTPSRequiredError, error) + assert_equal(runtime.step_class.name, error.step) + assert_empty(wire.calls) + end + + def examples_stage_order_redirect_wraps_auth + order = [] + wire = transport(unauthorized("Basic realm=r"), ok) + recorder = lambda do |request, cursor| + order << :pre_auth + cursor.call(request) + end + hook = lambda do |_c, request, _r| + order << :hook + request + end + steps = redirected({ cross_origin: false }, [recorder, STAGES::PRE_AUTH], + [build_step(hook: hook), nil],) + dispatch(steps, wire) + + assert_equal(%i[pre_auth hook], order) # PRE_AUTH ran before AUTH replayed + assert_equal(2, wire.calls.size) + end + end + + # The shared examples, the challenge half. + module ChallengeExamples + include Harness + + MUFASA_CHALLENGE = 'Digest realm="testrealm@host.com", qop="auth,auth-int", ' \ + 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093"' + + def examples_replay_once_and_close + first = unauthorized("Basic realm=r") + wire = transport(first, unauthorized("Basic realm=r"), ok) + response = dispatch([[build_step(hook: echo_hook), nil]], wire) + + assert_equal([401, 2], [response.status.code, wire.calls.size]) + assert_equal([1, 0], [closes_of(first), closes_of(response)]) + end + + def examples_unauthorized_without_challenge_passes_through + consulted = false + first = unauthorized(nil) + hook = lambda do |*| + consulted = true + nil + end + response = dispatch([[build_step(hook: hook), nil]], transport(first, ok)) + + assert_same(first, response) + refute(consulted) + end + + def examples_replay_gate_uniform + first = unauthorized("Basic realm=r") + wire = transport(first, ok) + hook = ->(*) { post_request(replayable: false) } + response = dispatch([[build_step(hook: hook), nil]], wire, post_request) + + assert_same(first, response) + assert_equal(0, closes_of(first)) + assert_equal(1, wire.calls.size) + end + + def examples_hook_error_closes_unauthorized + first = unauthorized("Basic realm=r") + hook = ->(*) { raise "boom" } + error = failure_of([[build_step(hook: hook), nil]], transport(first, ok), https_request) + + assert_kind_of(RuntimeError, error) + assert_equal(1, closes_of(first)) + end + + def examples_digest_end_to_end + wire = transport(unauthorized(MUFASA_CHALLENGE), ok) + response = dispatch([[digest_step, nil]], wire, mufasa_request) + + assert_equal(200, response.status.code) + assert_nil(wire.authorization_headers.first) + assert_includes(wire.authorization_headers.last, + 'response="6629fae49393a05397450978507c4ef1"',) + end + + def digest_step + chain = Dexpace::Auth::ChallengeHandlerChain.new([mufasa_digest]) + runtime.step_class.build(stamper: Dexpace::Auth::Step::NO_STAMP, + challenge_hook: chain.as_challenge_hook,) + end + + def examples_basic_preemptive + credential = Dexpace::Auth::PasswordCredential.build(username: "alice", password: "s3cr3t") + step = runtime.step_class.build(stamper: Dexpace::Auth::BasicHandler.new(credential)) + wire = transport(ok) + dispatch([[step, nil]], wire) + + assert_equal(["Basic YWxpY2U6czNjcjN0"], wire.authorization_headers) + end + + def mufasa_digest + credential = Dexpace::Auth::PasswordCredential.build(username: "Mufasa", + password: "Circle Of Life",) + Dexpace::Auth::DigestHandler.new(credential, cnonce_source: FixedCnonce.new("0a4f113b")) + end + + def mufasa_request + Dexpace::Request.build(method: "GET", url: "https://host/dir/index.html", + headers: Dexpace::Headers::EMPTY,) + end + end + + # Both halves, and the one place the example list is taken from. + module Examples + include StampExamples + include ChallengeExamples + end + + EXAMPLE_NAMES = [StampExamples, ChallengeExamples].flat_map do |half| + half.instance_methods(false) + end + .grep(/\Aexamples_/).sort + + # The examples against the sync step. + class SyncTest < DexpaceTestCase + include Examples + + def runtime = SYNC + + EXAMPLE_NAMES.each do |example| + test "sync: #{example.to_s.delete_prefix("examples_").tr("_", " ")}" do + send(example) + end + end + end + + # The examples against the async step. + class AsyncTest < DexpaceTestCase + include Examples + + def runtime = ASYNC + + EXAMPLE_NAMES.each do |example| + test "async: #{example.to_s.delete_prefix("examples_").tr("_", " ")}" do + send(example) + end + end + end + + # The sanity check: the examples are not vacuously green. + class BrokenTest < DexpaceTestCase + include Examples + + def runtime + Runtime.new("broken", BrokenStep, SequencedTransport, :build.to_proc, :itself.to_proc) + end + + test "the shared examples fail against a deliberately broken step" do + failed = EXAMPLE_NAMES.count do |example| + send(example) + false + rescue Minitest::Assertion, StandardError + true + end + + assert_operator(failed, :>=, 7, + "only #{failed} of #{EXAMPLE_NAMES.size} examples caught the broken step",) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/provider_error_test.rb b/gems/dexpace-core/test/dexpace/auth/provider_error_test.rb new file mode 100644 index 0000000..f30f1ed --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/provider_error_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/provider_error" + +# Exercises: AUTH-35, AUTH-11 -- the provider-misbehaviour error's shape. +class DexpaceAuthProviderErrorTest < DexpaceTestCase + test "phase 2's shape, namespaced under Auth, message-only" do + error = Dexpace::Auth::ProviderError.new("the provider returned no token (AUTH-35)") + + assert_kind_of(StandardError, error) + assert_kind_of(Dexpace::Error, error) + assert_equal("the provider returned no token (AUTH-35)", error.message) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/requirement_test.rb b/gems/dexpace-core/test/dexpace/auth/requirement_test.rb new file mode 100644 index 0000000..22f645a --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/requirement_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/requirement" + +# Exercises: AUTH-2 -- one scheme bound to its own scopes and params, immutable against a +# caller's later mutation of the retained collections (the deep half included), value equality +# over the three members, and #with routed through .build. +class DexpaceAuthRequirementTest < DexpaceTestCase + Requirement = Dexpace::Auth::Requirement + Scheme = Dexpace::Auth::Scheme + + test "AUTH-2: binds one scheme to its own scopes and params" do + requirement = Requirement.build(scheme: Scheme::OAUTH2, scopes: %w[read write], + params: { "aud" => "api" },) + + assert_same(Scheme::OAUTH2, requirement.scheme) + assert_equal(%w[read write], requirement.scopes) + assert_equal({ "aud" => "api" }, requirement.params) + end + + test "AUTH-2: the collections default to empty and are preserved for every scheme" do + requirement = Requirement.build(scheme: Scheme::BASIC) + + assert_empty(requirement.scopes) + assert_empty(requirement.params) + assert_equal(["x"], Requirement.build(scheme: Scheme::BASIC, scopes: ["x"]).scopes) + end + + test "AUTH-2: a later mutation of the caller's collection cannot reach the stored value" do + scopes = [+"read"] + params = { "aud" => [+"api"] } + requirement = Requirement.build(scheme: Scheme::OAUTH2, scopes: scopes, params: params) + scopes << "write" + params["aud"] << "other" + + assert_equal(["read"], requirement.scopes) + assert_equal({ "aud" => ["api"] }, requirement.params) + end + + # The half a shallow dup.freeze passes and still gets wrong: the caller mutates a String + # INSIDE the retained collection. The fixture uses +"read" because a frozen literal would + # raise FrozenError at the caller's own `<<` before proving anything. + test "AUTH-2: a caller mutating a String INSIDE a retained collection cannot reach it" do + scopes = [+"read"] + params = { "aud" => [+"api"] } + requirement = Requirement.build(scheme: Scheme::OAUTH2, scopes: scopes, params: params) + scopes[0] << ":write" + params["aud"][0] << ":other" + + assert_equal(["read"], requirement.scopes) + assert_equal({ "aud" => ["api"] }, requirement.params) + assert_predicate(requirement.scopes[0], :frozen?) + end + + test "HTTP-5's pattern: the same frozen reference from every accessor" do + requirement = Requirement.build(scheme: Scheme::OAUTH2, scopes: ["read"]) + + assert_same(requirement.scopes, requirement.scopes) + assert_predicate(requirement.scopes, :frozen?) + assert_predicate(requirement.params, :frozen?) + end + + test "AUTH-2: value equality over scheme, scopes and params" do + a = Requirement.build(scheme: Scheme::BASIC, scopes: ["r"], params: { "k" => "v" }) + b = Requirement.build(scheme: "basic", scopes: ["r"], params: { "k" => "v" }) + + assert_equal(a, b) + assert_equal(a.hash, b.hash) + refute_equal(a, Requirement.build(scheme: Scheme::BASIC, scopes: ["w"], params: { "k" => "v" })) + refute_equal(a, + Requirement.build(scheme: Scheme::DIGEST, scopes: ["r"], params: { "k" => "v" }),) + end + + test "the scheme is resolved through Scheme.of, and an unknown one is refused" do + assert_same(Scheme::DIGEST, Requirement.build(scheme: :digest).scheme) + assert_raises(Dexpace::InvalidArgumentError) { Requirement.build(scheme: "NTLM") } + assert_raises(Dexpace::InvalidArgumentError) { Requirement.build(scheme: nil) } + end + + test "the collections must be an Array and a Hash" do + assert_raises(Dexpace::InvalidArgumentError) { Requirement.build(scheme: :basic, scopes: "r") } + assert_raises(Dexpace::InvalidArgumentError) { Requirement.build(scheme: :basic, params: []) } + end + + test "the construction pattern: .new private, #with re-validates through .build" do + refute_respond_to(Requirement, :new) + requirement = Requirement.build(scheme: Scheme::BASIC) + derived = requirement.with(scheme: Scheme::DIGEST) + + assert_same(Scheme::DIGEST, derived.scheme) + assert_same(requirement, requirement.with) + assert_raises(Dexpace::InvalidArgumentError) { requirement.with(scheme: "NTLM") } + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/resolver_test.rb b/gems/dexpace-core/test/dexpace/auth/resolver_test.rb new file mode 100644 index 0000000..acca3f1 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/resolver_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/resolver" + +# Exercises: AUTH-4, AUTH-5, AUTH-6, AUTH-7 -- strict tier selection with no fall-through, +# first-satisfiable-in-declared-order within the tier, NO_AUTH always satisfiable, the two +# distinct failures, and a stateless module usable from many threads at once. +class DexpaceAuthResolverTest < DexpaceTestCase + Resolver = Dexpace::Auth::Resolver + Descriptor = Dexpace::Auth::Descriptor + Requirement = Dexpace::Auth::Requirement + Scheme = Dexpace::Auth::Scheme + + def descriptor(*schemes) + Descriptor.build(requirements: schemes.map { |scheme| Requirement.build(scheme: scheme) }) + end + + def resolve(per_call: nil, operation: nil, client: nil, available: []) + Resolver.resolve(per_call: per_call, operation: operation, client: client, + available_schemes: available,) + end + + test "AUTH-4: the most specific present tier is selected, per-call over operation over client" do + all_three = resolve(per_call: descriptor(:digest), operation: descriptor(:basic), + client: descriptor(:oauth2), available: Scheme::ALL,) + + assert_same(Scheme::DIGEST, all_three.scheme) + assert_same(Scheme::BASIC, resolve(operation: descriptor(:basic), client: descriptor(:oauth2), + available: Scheme::ALL,).scheme,) + assert_same(Scheme::OAUTH2, resolve(client: descriptor(:oauth2), available: Scheme::ALL).scheme) + end + + test "AUTH-4: a present higher tier that cannot be satisfied fails and never falls through" do + error = assert_raises(Dexpace::AuthResolutionError) do + resolve(per_call: descriptor(:digest), client: descriptor(:basic), available: [:basic]) + end + + assert_equal([Scheme::DIGEST], error.required) + assert_equal([Scheme::BASIC], error.available) + end + + test "AUTH-5: the first requirement in declared order whose scheme is satisfiable wins" do + descriptor = descriptor(:digest, :basic, :oauth2) + + assert_same(Scheme::BASIC, resolve(per_call: descriptor, available: %i[oauth2 basic]).scheme) + assert_same(Scheme::DIGEST, resolve(per_call: descriptor, available: Scheme::ALL).scheme) + end + + test "AUTH-5: NO_AUTH is always satisfiable, with nothing available at all" do + assert_same(Scheme::NO_AUTH, + resolve(client: descriptor(:basic, :no_auth), available: []).scheme,) + end + + test "AUTH-5: satisfiability is membership of the available set; no credential is inspected" do + assert_same(Scheme::BASIC, resolve(client: descriptor(:basic), available: ["basic"]).scheme) + copy = Scheme::BASIC.dup + + assert_same(Scheme::BASIC, resolve(client: descriptor(:basic), available: [copy]).scheme) + end + + test "AUTH-6: every tier absent is the argument error, not the resolution error" do + error = assert_raises(Dexpace::InvalidArgumentError) { resolve } + + assert_includes(error.message, "AUTH-6") + refute_kind_of(Dexpace::AuthResolutionError, error) + end + + test "AUTH-6: no satisfiable scheme is the resolution error carrying both lists in order" do + error = assert_raises(Dexpace::AuthResolutionError) do + resolve(operation: descriptor(:digest, :oauth2), available: %i[basic api_key]) + end + + assert_equal([Scheme::DIGEST, Scheme::OAUTH2], error.required) + assert_equal([Scheme::BASIC, Scheme::API_KEY], error.available) + assert_kind_of(Dexpace::Error, error) + assert_includes(error.message, "DIGEST, OAUTH2") + end + + test "a tier that is not a Descriptor is refused" do + assert_raises(Dexpace::InvalidArgumentError) { resolve(client: "basic") } + end + + test "AUTH-7: a module with no state, deterministic, and safe from twenty threads at once" do + descriptor = descriptor(:digest, :no_auth) + results = Array.new(20) + threads = Array.new(20) do |index| + Thread.new { results[index] = resolve(client: descriptor, available: []).scheme } + end + threads.each(&:join) + + assert_equal([Scheme::NO_AUTH] * 20, results) + assert_empty(Resolver.instance_variables) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/scheme_test.rb b/gems/dexpace-core/test/dexpace/auth/scheme_test.rb new file mode 100644 index 0000000..26458dd --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/scheme_test.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/scheme" + +# Exercises: AUTH-1 -- the closed five-member scheme set, closed structurally in the +# Pipeline::Stage / Proxy::Type shape: both generated constructors private, #with refusing, +# .of the one lookup, and NO_AUTH a distinct sentinel. +class DexpaceAuthSchemeTest < DexpaceTestCase + Scheme = Dexpace::Auth::Scheme + + test "AUTH-1: the set is exactly OAUTH2, API_KEY, BASIC, DIGEST and NO_AUTH, in that order" do + assert_equal(%w[OAUTH2 API_KEY BASIC DIGEST NO_AUTH], Scheme::ALL.map(&:name)) + assert_predicate(Scheme::ALL, :frozen?) + Scheme::ALL.each { |scheme| assert_same(scheme, Scheme.const_get(scheme.name)) } + end + + test "AUTH-1: NO_AUTH is a distinct sentinel and not any wire scheme" do + (Scheme::ALL - [Scheme::NO_AUTH]).each do |scheme| + refute_equal(Scheme::NO_AUTH, scheme) + refute_same(Scheme::NO_AUTH, scheme) + end + end + + test ".of resolves a name in any case, a Symbol, or a Scheme back to the shared constant" do + assert_same(Scheme::BASIC, Scheme.of("BASIC")) + assert_same(Scheme::BASIC, Scheme.of("basic")) + assert_same(Scheme::BASIC, Scheme.of(" Basic ")) + assert_same(Scheme::DIGEST, Scheme.of(:digest)) + assert_same(Scheme::API_KEY, Scheme.of(Scheme::API_KEY)) + assert_same(Scheme::NO_AUTH, Scheme.of(Scheme::NO_AUTH.dup)) + end + + test ".of refuses an unknown, blank or absent name with the SDK's error" do + error = assert_raises(Dexpace::InvalidArgumentError) { Scheme.of("NTLM") } + + assert_includes(error.message, "NTLM") + assert_raises(Dexpace::InvalidArgumentError) { Scheme.of("") } + assert_raises(Dexpace::InvalidArgumentError) { Scheme.of(nil) } + end + + test "the constants carry their names: built through the private .new, never allocate" do + assert_equal("NO_AUTH", Scheme::NO_AUTH.name) + assert_equal("OAUTH2", Scheme::OAUTH2.to_s) + assert_predicate(Scheme::OAUTH2.name, :frozen?) + end + + test "P4-32's shape: .new and .[] are private, #with refuses, so the set cannot grow" do + refute_respond_to(Scheme, :new) + refute_respond_to(Scheme, :[]) + assert_raises(NoMethodError) { Scheme.new(name: "NTLM") } + assert_raises(NoMethodError) { Scheme["NTLM"] } + assert_raises(Dexpace::InvalidArgumentError) { Scheme::BASIC.with(name: "NTLM") } + assert_raises(Dexpace::InvalidArgumentError) { Scheme::BASIC.with } + end + + test "send(:new) past the private constructor still meets the validating initialize" do + assert_raises(Dexpace::InvalidArgumentError) { Scheme.send(:new, name: "ntlm") } + assert_raises(Dexpace::InvalidArgumentError) { Scheme.send(:new, name: nil) } + end + + test "a copy is == its constant and .of canonicalises it back" do + copy = Marshal.load(Marshal.dump(Scheme::DIGEST)) + + assert_equal(Scheme::DIGEST, copy) + refute_same(Scheme::DIGEST, copy) + assert_same(Scheme::DIGEST, Scheme.of(copy)) + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb b/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb new file mode 100644 index 0000000..0cd0ec3 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/step" +require_relative "../../../lib/dexpace/auth/bearer_stamper" +require_relative "../../support/auth_fixtures" +require_relative "../../support/scripted_bearer_provider" +require_relative "../../support/spy_cursor" +require_relative "../../support/fake_clock" + +# Exercises: AUTH-36 (the step half), AUTH-31 (P6-7) -- the sync step's bearer 401 branch: a +# Bearer challenge evicts exactly the rejected token and re-stamps ONE retry with a fresh +# fetch, regardless of method; a token another request refreshed is preserved and reused; no +# Authorization on the rejected request, or no Bearer challenge, surfaces the 401 unchanged; a +# non-replayable body skips the retry and leaves the 401 unclosed; the branch runs before the +# challenge hook. No lib/ mirror: a second suite over step.rb, like 5b's downstream_wirings. +# Split under Metrics/ClassLength. +class DexpaceAuthStepBearerChallengeTest < DexpaceTestCase + Step = Dexpace::Auth::Step + STAGES = Dexpace::Pipeline::Stages + + # The bearer steps and dispatch the two cases share. + module Fixtures + include AuthFixtures + + def provider(*tokens) = ScriptedBearerProvider.new(*tokens) + + def bearer_stamper(prov) + Dexpace::Auth::BearerStamper.new(provider: prov, clock: FakeClock.new) + end + + def bearer_step(prov, hook: Step::NO_REPLACEMENT) + Step.build(stamper: bearer_stamper(prov), challenge_hook: hook) + end + + def dispatch(step, transport, request = https_request, redirect_state: nil) + auth_pipeline(step, transport, redirect_state: redirect_state).call(request) + end + end + + # The retry itself: eviction, one fresh fetch, any method, one fork. + class RetryTest < DexpaceTestCase + include Fixtures + + test "AUTH-36: a 401 with a Bearer challenge evicts the token and retries once, freshly" do + prov = provider("old", "new") + first = unauthorized_bearer + transport = SequencedTransport.new(first, ok) + response = dispatch(bearer_step(prov), transport) + + assert_equal(200, response.status.code) + assert_equal(["Bearer old", "Bearer new"], transport.authorization_headers) + assert_equal(2, prov.fetches) + assert_equal(1, closes_of(first)) + assert_equal(0, closes_of(response)) + end + + test "AUTH-36: the retry fires regardless of HTTP method -- a POST is retried too" do + transport = SequencedTransport.new(unauthorized_bearer, ok) + response = dispatch(bearer_step(provider("old", "new")), transport, post_request) + + assert_equal(200, response.status.code) + assert_equal(["Bearer old", "Bearer new"], transport.authorization_headers) + assert_equal(%w[POST POST], transport.requests.map { |request| request.method.to_s }) + end + + test "AUTH-36: a token another request already refreshed is preserved and reused" do + prov = provider("old", "never-fetched") + stamper = bearer_stamper(prov) + refreshed = Dexpace::Auth::BearerToken.build(token: "refreshed-elsewhere") + # The first drive is stamped from the cache ("old", fetched once); the transport swaps the + # cache before answering 401, standing in for the other request's refresh. + swap = lambda do |_request| + stamper.instance_variable_set(:@token, refreshed) + unauthorized_bearer + end + transport = SequencedTransport.new(swap, ok) + dispatch(Step.build(stamper: stamper), transport) + + assert_equal(["Bearer old", "Bearer refreshed-elsewhere"], transport.authorization_headers) + assert_equal(1, prov.fetches) + end + + test "AUTH-36: the retry drives a fresh fork exactly once and never the handed cursor" do + spy = nil + step = bearer_step(provider("old", "new")) + wrapper = ->(request, cursor) { step.call(request, spy = SpyCursor.new(cursor)) } + transport = SequencedTransport.new(unauthorized_bearer, unauthorized_bearer, ok) + response = Dexpace::Pipeline.builder(transport: transport) + .append(wrapper, stage: STAGES::AUTH) + .build.call(https_request) + + assert_equal(401, response.status.code) # ONE retry only: the second 401 surfaces + assert_equal(2, spy.forks) + assert_equal(0, spy.calls) + assert_equal(2, transport.calls.size) + end + + test "AUTH-35 on the retry: a provider raising during the re-stamp propagates, 401 closed" do + first = unauthorized_bearer + transport = SequencedTransport.new(first, ok) + step = bearer_step(provider("old", RuntimeError.new("boom"))) + + assert_raises(RuntimeError) { dispatch(step, transport) } + assert_equal(1, closes_of(first)) + end + end + + # The four ways the branch surfaces the 401 unchanged, and its place before the hook. + class SurfaceTest < DexpaceTestCase + include Fixtures + + test "AUTH-36: no Authorization on the rejected request (cross-origin) surfaces the 401" do + prov = provider("old") + first = unauthorized_bearer + transport = SequencedTransport.new(first, ok) + response = dispatch(bearer_step(prov), transport, redirect_state: { cross_origin: true }) + + assert_same(first, response) + assert_equal(1, transport.calls.size) + assert_equal([nil], transport.authorization_headers) + assert_equal(0, prov.fetches) + assert_equal(0, closes_of(first)) + end + + test "AUTH-36: a 401 advertising no Bearer challenge surfaces unchanged, no eviction" do + prov = provider("old") + first = unauthorized('Basic realm="r"') + transport = SequencedTransport.new(first, ok) + step = bearer_step(prov) + response = dispatch(step, transport) + + assert_same(first, response) + assert_equal(1, transport.calls.size) + assert_equal(1, prov.fetches) + assert_equal(0, closes_of(first)) + # Still cached: a second dispatch stamps without fetching. + dispatch(step, SequencedTransport.new(ok)) + + assert_equal(1, prov.fetches) + end + + test "AUTH-31, P6-7: a non-replayable body skips the bearer retry; the 401 is left UNCLOSED" do + prov = provider("old", "new") + first = unauthorized_bearer + transport = SequencedTransport.new(first, ok) + response = dispatch(bearer_step(prov), transport, post_request(replayable: false)) + + assert_same(first, response) + assert_equal(0, closes_of(first)) + assert_equal(1, transport.calls.size) + assert_equal(1, prov.fetches) # nothing evicted, nothing re-fetched + end + + test "AUTH-30: the bearer branch is not the challenge hook, which is never consulted for it" do + consulted = false + hook = lambda do |*| + consulted = true + nil + end + transport = SequencedTransport.new(unauthorized_bearer, ok) + response = dispatch(bearer_step(provider("old", "new"), hook: hook), transport) + + assert_equal(200, response.status.code) + refute(consulted) + end + + test "AUTH-30: the hook IS consulted for a bearer stamper when the challenge is not Bearer" do + consulted = false + hook = lambda do |*| + consulted = true + nil + end + transport = SequencedTransport.new(unauthorized('Digest realm="r", nonce="n"'), ok) + dispatch(bearer_step(provider("old"), hook: hook), transport) + + assert(consulted) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/step_test.rb b/gems/dexpace-core/test/dexpace/auth/step_test.rb new file mode 100644 index 0000000..41a0afd --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/step_test.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/step" +require_relative "../../../lib/dexpace/auth/key_stamper" +require_relative "../../../lib/dexpace/auth/key_credential" +require_relative "../../support/auth_fixtures" +require_relative "../../support/spy_cursor" +require_relative "../../support/fake_transport" + +# Exercises: AUTH-27 through AUTH-33 -- the sync AUTH pillar step, driven through a real +# pipeline (only the driver makes a forkable cursor): the stage, the HTTPS guard before any +# stamp, AUTH-29's three cases read from Stages::REDIRECT's slot, forking for every drive and +# never calling the handed cursor, the 401 re-challenge replay, its default, its replayability +# gate, the close-on-hook-error, and the no-challenge pass-through. Split under +# Metrics/ClassLength. +class DexpaceAuthStepTest < DexpaceTestCase + Step = Dexpace::Auth::Step + STAGES = Dexpace::Pipeline::Stages + + # The steps, requests and pipelines the nested cases share. + module Fixtures + include AuthFixtures + + def key_stamper(key = "secret") + Dexpace::Auth::KeyStamper.new(Dexpace::Auth::KeyCredential.new(api_key: key)) + end + + def step(stamper: key_stamper, hook: Step::NO_REPLACEMENT) + Step.build(stamper: stamper, challenge_hook: hook) + end + + def transport_of(response) = FakeTransport.new(response: response) + + # The step behind a recording wrapper, so the cursor it is handed can be inspected. + def spied(auth_step) + spy = nil + wrapper = lambda do |request, cursor| + spy = SpyCursor.new(cursor) + auth_step.call(request, spy) + end + [wrapper, -> { spy }] + end + + def dispatch(auth_step, transport, request = https_request, redirect_state: nil, stage: nil) + builder = Dexpace::Pipeline.builder(transport: transport) + unless redirect_state.nil? + builder.append(ForkingProbe.new(times: 1, state_per_drive: [redirect_state]), + stage: STAGES::REDIRECT,) + end + stage.nil? ? builder.append(auth_step) : builder.append(auth_step, stage: stage) + builder.build.call(request) + end + + def stamped_on(transport) = transport.calls.first.first.headers["Authorization"] + end + + # AUTH-27, the construction pattern and the two default callables. + class ConstructionTest < DexpaceTestCase + include Fixtures + + test "AUTH-27: declares Stages::AUTH, the one pillar stage, and installs without a stage:" do + auth_step = step + + assert_same(STAGES::AUTH, auth_step.stage) + pipeline = Dexpace::Pipeline.builder(transport: transport_of(ok)).append(auth_step).build + + assert_equal([STAGES::AUTH], pipeline.entries.map(&:stage)) + assert_raises(Dexpace::PipelineError) do + Dexpace::Pipeline.builder(transport: transport_of(ok)).append(auth_step).append(step).build + end + end + + test "5b's shape: .build with .new private, frozen, the stamper and hook validated by arity" do + refute_respond_to(Step, :new) + assert_predicate(step, :frozen?) + assert_raises(Dexpace::InvalidArgumentError) { Step.build(stamper: Object.new) } + assert_raises(Dexpace::InvalidArgumentError) { Step.build(stamper: ->(_a, _b) {}) } + assert_raises(Dexpace::InvalidArgumentError) { step(hook: ->(_a) {}) } + assert_raises(Dexpace::InvalidArgumentError) { Step.build(stamper: key_stamper, logger: nil) } + end + + test "AUTH-30, AUTH-1: the two defaults -- no replacement, and the identity stamper" do + request = https_request + + assert_nil(Step::NO_REPLACEMENT.call("Basic realm=r", request, unauthorized)) + assert_same(request, Step::NO_STAMP.call(request)) + end + end + + # AUTH-28: the HTTPS guard, before any stamp. + class GuardTest < DexpaceTestCase + include Fixtures + + test "AUTH-28: a plaintext URL is refused BEFORE any stamp, naming the step and the scheme" do + stamped = false + transport = transport_of(ok) + stamper = lambda do |request| + stamped = true + request + end + error = assert_raises(Dexpace::Auth::HTTPSRequiredError) do + dispatch(step(stamper: stamper), transport, http_request) + end + + assert_equal("http", error.scheme) + assert_equal("Dexpace::Auth::Step", error.step) + refute(stamped) + assert_empty(transport.calls) + end + + test "AUTH-28: the scheme comparison is case-insensitive; the guard also covers NO_STAMP" do + upper = Dexpace::Request.build(method: "GET", url: "HTTPS://api.example.test/", + headers: Dexpace::Headers::EMPTY,) + transport = transport_of(ok) + + assert_equal(200, dispatch(step, transport, upper).status.code) + assert_raises(Dexpace::Auth::HTTPSRequiredError) do + dispatch(step(stamper: Step::NO_STAMP), transport, http_request) + end + end + + test "AUTH-28 with a bare Cursor.build: the guard fires before the stamper on the root too" do + cursor = Dexpace::Pipeline::Cursor.build(drive: Object.new, request: http_request, + options: Dexpace::RequestOptions::EMPTY, + cancellation: Dexpace::Cancellation.none,) + stamper = ->(_request) { flunk "must not stamp before the guard" } + + assert_raises(Dexpace::Auth::HTTPSRequiredError) do + step(stamper: stamper).call(http_request, cursor) + end + end + end + + # AUTH-29: the marker read from the redirect step's slot, and only from there. + class CrossOriginTest < DexpaceTestCase + include Fixtures + + test "AUTH-29: a cross-origin re-issue is neither guarded nor stamped, and still forks" do + transport = transport_of(ok) + wrapper, spy = spied(step(stamper: ->(_request) { flunk "must not stamp cross-origin" })) + response = dispatch(wrapper, transport, http_request, redirect_state: { cross_origin: true }, + stage: STAGES::AUTH,) + + assert_equal(200, response.status.code) + assert_nil(stamped_on(transport)) + assert_equal(1, spy.call.forks) + assert_equal(0, spy.call.calls) + refute_predicate(spy.call.cursor, :spent?) + end + + test "AUTH-29: a same-origin re-issue (cross_origin: false) is re-stamped and re-guarded" do + transport = transport_of(ok) + dispatch(step, transport, redirect_state: { cross_origin: false }) + + assert_equal(["secret"], stamped_on(transport)) + assert_raises(Dexpace::Auth::HTTPSRequiredError) do + dispatch(step, transport, http_request, redirect_state: { cross_origin: false }) + end + end + + test "AUTH-29: no REDIRECT step at all -- the shared frozen empty slot -- is same-origin" do + transport = transport_of(ok) + dispatch(step, transport) + + assert_equal(["secret"], stamped_on(transport)) + end + + test "AUTH-29: the marker is read from the cursor and never from a request header" do + transport = transport_of(ok) + forged = https_request(headers: Dexpace::Headers.builder + .add("X-Dexpace-Cross-Origin", "true").build) + dispatch(step, transport, forged) + + assert_equal(["secret"], stamped_on(transport)) + end + + test "AUTH-29 / 4c's assertion 4: a RETRY step's slot cannot suppress the AUTH stamp" do + transport = transport_of(ok) + retry_probe = ForkingProbe.new(times: 1, state_per_drive: [{ cross_origin: true }]) + Dexpace::Pipeline.builder(transport: transport) + .append(retry_probe, stage: STAGES::RETRY) + .append(step) + .build.call(https_request) + + assert_equal(["secret"], stamped_on(transport)) + end + + # 4c's negative assertion 4 from the AUTH side: a fork from the AUTH step's own cursor + # writes the AUTH slot and cannot reach REDIRECT's, and the cursor has no setter at all. + test "AUTH-29: an AUTH-stage fork cannot write REDIRECT's slot; no cursor method sets state" do + seen = nil + reader = lambda do |request, cursor| + seen = cursor.state(STAGES::REDIRECT) + cursor.call(request) + end + forger = ->(request, cursor) { cursor.fork(state: { cross_origin: false }).call(request) } + redirect_probe = ForkingProbe.new(times: 1, state_per_drive: [{ cross_origin: true }]) + Dexpace::Pipeline.builder(transport: transport_of(ok)) + .append(redirect_probe, stage: STAGES::REDIRECT) + .append(forger, stage: STAGES::AUTH) + .append(reader, stage: STAGES::POST_AUTH) + .build.call(https_request) + + assert_equal({ cross_origin: true }, seen) + setters = Dexpace::Pipeline::Cursor.public_instance_methods(false).grep(/state=|write/) + + assert_empty(setters) + end + end + + # AUTH-30 and P4-39: the drive, the replay and the pass-throughs. + class DriveTest < DexpaceTestCase + include Fixtures + + test "P4-39: forks for every drive including the first; never calls the handed cursor" do + transport = transport_of(ok) + wrapper, spy = spied(step) + dispatch(wrapper, transport, stage: STAGES::AUTH) + + assert_equal(1, spy.call.forks) + assert_equal(0, spy.call.calls) + assert_equal(1, transport.calls.size) + end + + test "AUTH-30: non-401 responses pass through with the stamped request sent once" do + transport = transport_of(closable_response(500)) + response = dispatch(step, transport) + + assert_equal(500, response.status.code) + assert_equal(0, closes_of(response)) + assert_equal(1, transport.calls.size) + end + + test "AUTH-30: a 401 with a challenge consults the hook and replays the replacement once" do + seen = [] + replacement = https_request(headers: Dexpace::Headers.builder + .add("Authorization", "Digest x").build) + hook = lambda do |challenge, request, response| + seen << [challenge, request, response] + replacement + end + transport = SequencedTransport.new(unauthorized('Digest realm="r", nonce="n"'), ok) + wrapper, spy = spied(step(hook: hook)) + response = dispatch(wrapper, transport, stage: STAGES::AUTH) + + assert_equal(200, response.status.code) + assert_equal(['Digest realm="r", nonce="n"'], seen.map(&:first)) + assert_equal(["secret"], seen.first[1].headers["Authorization"]) # the stamped request + assert_equal(401, seen.first[2].status.code) + assert_equal(["secret", "Digest x"], transport.authorization_headers) + assert_equal(2, spy.call.forks) + assert_equal(0, spy.call.calls) + end + + test "AUTH-30: the original 401 is closed before the replay; the replay's response is not" do + first = unauthorized("Basic realm=r") + transport = SequencedTransport.new(first, ok) + response = dispatch(step(hook: ->(_c, request, _r) { request }), transport) + + assert_equal(1, closes_of(first)) + assert_equal(0, closes_of(response)) + end + + test "AUTH-30: no further challenge handling on the replacement -- a second 401 surfaces" do + transport = SequencedTransport.new(unauthorized("Basic realm=r"), + unauthorized("Basic realm=r"), ok,) + calls = 0 + hook = lambda do |_c, request, _r| + calls += 1 + request + end + response = dispatch(step(hook: hook), transport) + + assert_equal(401, response.status.code) + assert_equal(1, calls) + assert_equal(2, transport.calls.size) + end + + test "AUTH-30: the default hook yields no replacement, so the 401 surfaces after one drive" do + transport = SequencedTransport.new(unauthorized("Basic realm=r"), ok) + response = dispatch(step, transport) + + assert_equal(401, response.status.code) + assert_equal(1, transport.calls.size) + assert_equal(0, closes_of(response)) + end + + test "AUTH-30, RFC 7235: a repeated WWW-Authenticate header reaches the hook as one list" do + seen = nil + hook = lambda do |challenge, _q, _r| + seen = challenge + nil + end + two = unauthorized(["Basic realm=r", 'Digest realm="r", nonce="n"']) + transport = SequencedTransport.new(two, ok) + dispatch(step(hook: hook), transport) + + assert_equal('Basic realm=r, Digest realm="r", nonce="n"', seen) + end + end + + # AUTH-31, AUTH-32, AUTH-33: the replay gate and the two pass-throughs. + class ReplayGateTest < DexpaceTestCase + include Fixtures + + test "AUTH-31: a non-replayable replacement body skips the replay; the 401 is left UNCLOSED" do + first = unauthorized("Basic realm=r") + transport = SequencedTransport.new(first, ok) + hook = ->(_c, _q, _r) { post_request(replayable: false) } + response = dispatch(step(hook: hook), transport, post_request) + + assert_same(first, response) + assert_equal(0, closes_of(response)) + assert_equal(1, transport.calls.size) + end + + test "AUTH-31: a replayable body, or no body, is replayed" do + transport = SequencedTransport.new(unauthorized("Basic realm=r"), ok) + hook = ->(_c, _q, _r) { post_request(replayable: true) } + + assert_equal(200, dispatch(step(hook: hook), transport, post_request).status.code) + transport = SequencedTransport.new(unauthorized("Basic realm=r"), ok) + response = dispatch(step(hook: ->(_c, request, _r) { request }), transport) + + assert_equal(200, response.status.code) + end + + test "AUTH-32: a hook that raises leaves the open 401 closed, the error propagating as is" do + first = unauthorized("Basic realm=r") + transport = SequencedTransport.new(first, ok) + error = assert_raises(RuntimeError) do + dispatch(step(hook: ->(*) { raise "hook blew up" }), transport) + end + + assert_equal("hook blew up", error.message) + assert_equal(1, closes_of(first)) + assert_equal(1, transport.calls.size) + end + + test "AUTH-32: a close failure while closing rides the hook error's suppressed trail" do + first = closable_response(401, challenge: "Basic realm=r") + first.body.instance_variable_set(:@close_error, IOError.new("close failed")) + transport = SequencedTransport.new(first, ok) + error = assert_raises(RuntimeError) do + dispatch(step(hook: ->(*) { raise "hook blew up" }), transport) + end + + assert_equal(["close failed"], Dexpace.suppressed(error).map(&:message)) + end + + test "AUTH-32: a hook returning something that is not a request closes the 401 and raises" do + first = unauthorized("Basic realm=r") + transport = SequencedTransport.new(first, ok) + + assert_raises(Dexpace::InvalidArgumentError) do + dispatch(step(hook: ->(*) { "not a request" }), transport) + end + assert_equal(1, closes_of(first)) + end + + test "AUTH-33: a 401 without WWW-Authenticate is returned unchanged; the hook not consulted" do + consulted = false + first = unauthorized(nil) + transport = SequencedTransport.new(first, ok) + hook = lambda do |*| + consulted = true + nil + end + response = dispatch(step(hook: hook), transport) + + assert_same(first, response) + refute(consulted) + assert_equal(0, closes_of(response)) + assert_equal(1, transport.calls.size) + end + end +end diff --git a/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb b/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb new file mode 100644 index 0000000..be706d7 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/unencodable_credential_error" + +# Exercises: AUTH-21 (R10, P6-1) -- the typed failure: phase 2's error shape under the Auth +# namespace, the field and the encoding as members and in the message, never the value. +class DexpaceAuthUnencodableCredentialErrorTest < DexpaceTestCase + Error = Dexpace::Auth::UnencodableCredentialError + + test "phase 2's shape, namespaced under Auth" do + error = Error.new(field: :password, encoding: "ISO-8859-1") + + assert_kind_of(StandardError, error) + assert_kind_of(Dexpace::Error, error) + assert_equal(:password, error.field) + assert_equal("ISO-8859-1", error.encoding) + end + + test "the message names the field and the encoding and says why the encoding applied" do + message = Error.new(field: :username, encoding: "ISO-8859-1").message + + assert_includes(message, "username") + assert_includes(message, "ISO-8859-1") + assert_includes(message, "charset=UTF-8") + assert_includes(message, "AUTH-21") + end +end diff --git a/gems/dexpace-core/test/dexpace/auth_test.rb b/gems/dexpace-core/test/dexpace/auth_test.rb new file mode 100644 index 0000000..5f5fee6 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require_relative "../../lib/dexpace/auth" + +# Exercises: AUTH-8 -- the namespace file: the one redaction marker every credential renders. +class DexpaceAuthTest < DexpaceTestCase + test "the namespace exists with the one shared redaction marker, frozen" do + assert_kind_of(Module, Dexpace::Auth) + assert_equal("[REDACTED]", Dexpace::Auth::REDACTED) + assert_predicate(Dexpace::Auth::REDACTED, :frozen?) + end + + test "the namespace file adds no constant but the marker" do + assert_equal([:REDACTED], Dexpace::Auth.constants(false) & [:REDACTED]) + end +end diff --git a/gems/dexpace-core/test/dexpace/bounded_map_test.rb b/gems/dexpace-core/test/dexpace/bounded_map_test.rb new file mode 100644 index 0000000..211486c --- /dev/null +++ b/gems/dexpace-core/test/dexpace/bounded_map_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../test_helper" +require_relative "../../lib/dexpace/bounded_map" + +# Exercises: AUTH-19, AUTH-24, XCUT-14, CTX-11 -- the private bounded map's phase-6 widening, +# #update, asserted directly for the first time (phase 4a exercised the map only through +# ContextStore; the reachability facts are here too). BoundedMap is a private_constant of +# Dexpace, so a test outside `module Dexpace` cannot name it with the scope operator -- +# `Dexpace::BoundedMap` raises NameError -- and #const_get, which ignores constant privacy, is +# the access route a test has (verified on 3.2.11, 3.4.10 and 4.0.6). +class DexpaceBoundedMapTest < DexpaceTestCase + BOUNDED_MAP = Dexpace.const_get(:BoundedMap) + + def counter(map, key) = map.update(key) { |current| (current || 0) + 1 } + + test "execution-context/b58728da: reachable by const_get, not by a qualified reference" do + error = assert_raises(NameError) { Dexpace::BoundedMap } + + assert_includes(error.message, "private constant") + assert_kind_of(Class, BOUNDED_MAP) + end + + test "#update starts from nil for a new key and stores what the block returns" do + map = BOUNDED_MAP.new(cap: 8) + + assert_equal(1, counter(map, "k")) + assert_equal(1, map["k"]) + assert_equal("x", map.update("k") { |_current| "x" }) + assert_equal("x", map["k"]) + end + + test "#update increments on reuse of the same key" do + map = BOUNDED_MAP.new(cap: 8) + counter(map, "k") + + assert_equal(2, counter(map, "k")) + assert_equal(3, counter(map, "k")) + assert_equal(1, counter(map, "other")) + end + + test "AUTH-19: #update drains back under the cap in the same section, oldest first" do + map = BOUNDED_MAP.new(cap: 2) + counter(map, "a") + counter(map, "b") + + assert_equal(1, counter(map, "c")) # evicts "a" + assert_equal(2, map.size) + assert_nil(map["a"]) + assert_equal(1, counter(map, "a")) # AUTH-19: an evicted nonce restarts at 1 + end + + test "a block that raises leaves the slot as it was and releases the lock" do + map = BOUNDED_MAP.new(cap: 8) + counter(map, "k") + + assert_raises(RuntimeError) { map.update("k") { |_current| raise "boom" } } + assert_equal(1, map["k"]) + assert_equal(2, counter(map, "k")) + end + + # The block runs while the map's own non-reentrant mutex is held: a block that calls back + # into the map meets `ThreadError: deadlock; recursive locking`, deterministically. + test "AUTH-24: the block runs under the map's mutex -- re-entering it raises ThreadError" do + map = BOUNDED_MAP.new(cap: 8) + error = assert_raises(ThreadError) { map.update("k") { |_current| map["k"] } } + + assert_includes(error.message, "recursive locking") + end + + # AUTH-24 made deterministic, not probabilistic: thread A parks INSIDE its block holding the + # old value; thread B then calls #update on the same key. Under one critical section B blocks + # (status "sleep") until A's write lands and then reads 1, so the count is 2. With the block + # run outside the lock B would complete while A is parked, both would write 1, and the count + # would be 1 -- the lost increment. The test waits for B to be blocked-or-finished before it + # releases A, so the interleaving is forced rather than hoped for. + test "AUTH-24: read-modify-write is one critical section, forced interleaving" do + map = BOUNDED_MAP.new(cap: 8) + parked = ::Thread::Queue.new + release = ::Thread::Queue.new + first = Thread.new do + map.update("nonce") do |current| + parked << true + release.pop + (current || 0) + 1 + end + end + parked.pop + second = Thread.new { map.update("nonce") { |current| (current || 0) + 1 } } + blocked_or_done = ["sleep", false].freeze + Thread.pass until blocked_or_done.include?(second.status) + finished_before_release = second.status == false + release << true + [first, second].each(&:join) + + refute(finished_before_release, "the second update completed while the first held the lock") + assert_equal(2, map["nonce"]) + end + + test "AUTH-24: sixteen threads released from one barrier lose no increment" do + map = BOUNDED_MAP.new(cap: 64) + barrier = ::Thread::Queue.new + threads = Array.new(16) do + Thread.new do + barrier.pop + 200.times { counter(map, "shared") } + end + end + 16.times { barrier << true } + threads.each(&:join) + + assert_equal(3200, map["shared"]) + end + + test "phase 4a's surface is untouched: set, put, [], delete_if_identical and size" do + map = BOUNDED_MAP.new(cap: 2) + occupant = +"w" + + assert_equal("v", map.set("a", "v")) + assert(map.put("b", occupant)) + refute(map.put("b", "x")) + assert_same(occupant, map["b"]) + refute(map.delete_if_identical("b", occupant.dup)) + assert(map.delete_if_identical("b", occupant)) + assert_equal(1, map.size) + end +end diff --git a/gems/dexpace-core/test/dexpace/error/auth_resolution_error_test.rb b/gems/dexpace-core/test/dexpace/error/auth_resolution_error_test.rb new file mode 100644 index 0000000..08508f0 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/error/auth_resolution_error_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/error/auth_resolution_error" +require_relative "../../../lib/dexpace/auth/scheme" + +# Exercises: AUTH-6 -- the distinct resolution error: phase 2's error shape, both lists carried +# as frozen members in the order given, and a message naming the schemes. +class DexpaceAuthResolutionErrorTest < DexpaceTestCase + Scheme = Dexpace::Auth::Scheme + + test "phase 2's shape: a StandardError carrying the Dexpace::Error marker, flat under Dexpace" do + error = Dexpace::AuthResolutionError.new(required: [Scheme::DIGEST], available: []) + + assert_kind_of(StandardError, error) + assert_kind_of(Dexpace::Error, error) + refute_kind_of(Dexpace::InvalidArgumentError, error) + end + + test "carries the required schemes in preference order and the available ones, frozen copies" do + required = [Scheme::DIGEST, Scheme::BASIC] + available = [Scheme::API_KEY] + error = Dexpace::AuthResolutionError.new(required: required, available: available) + required << Scheme::OAUTH2 + + assert_equal([Scheme::DIGEST, Scheme::BASIC], error.required) + assert_equal([Scheme::API_KEY], error.available) + assert_predicate(error.required, :frozen?) + assert_predicate(error.available, :frozen?) + end + + test "the message names both lists, and an empty available list as (none)" do + error = Dexpace::AuthResolutionError.new(required: [Scheme::DIGEST, Scheme::BASIC], + available: [],) + + assert_equal("no satisfiable auth scheme: required DIGEST, BASIC in preference order, " \ + "available (none) (AUTH-6)", error.message,) + end +end diff --git a/gems/dexpace-core/test/support/auth_fixtures.rb b/gems/dexpace-core/test/support/auth_fixtures.rb new file mode 100644 index 0000000..4f47c78 --- /dev/null +++ b/gems/dexpace-core/test/support/auth_fixtures.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "dexpace" +require_relative "recovery_fixtures" +require_relative "fake_body" +require_relative "fake_response_body" +require_relative "sequenced_transport" +require_relative "sequenced_async_transport" +require_relative "forking_probe" + +# The requests, responses and pipelines every phase-6c step suite is written against, over +# phase 4b's RecoveryFixtures. A 401 here carries its challenge as a real inbound header, and a +# closable one carries a FakeResponseBody so `body.closes` says whether the step closed it -- +# Dexpace::Response has no #closed?; Response#close is `body&.close`. +module AuthFixtures + include RecoveryFixtures + + STAGES = Dexpace::Pipeline::Stages + + def https_request(method: "GET", body: nil, headers: Dexpace::Headers::EMPTY) + Dexpace::Request.build(method: method, url: "https://api.example.test/v1/pets", + headers: headers, body: body,) + end + + def http_request + Dexpace::Request.build(method: "GET", url: "http://api.example.test/v1/pets", + headers: Dexpace::Headers::EMPTY,) + end + + def post_request(replayable: true) + https_request(method: "POST", body: FakeBody.new("payload", replayable: replayable)) + end + + # A response with a closable body, so the suite can read `closes` off it afterwards. + def closable_response(code, request: https_request, challenge: nil) + builder = Dexpace::Response.builder + builder.request = request + builder.protocol = Dexpace::Protocol::HTTP_1_1 + builder.status = code + builder.headers = challenge_headers(challenge) + builder.body = FakeResponseBody.new(Dexpace::IO::BufferedSource.of_bytes("".b)) + builder.build + end + + def ok = closable_response(200) + + def unauthorized(challenge = nil) = closable_response(401, challenge: challenge) + + def unauthorized_bearer(realm: "api") + unauthorized("Bearer realm=\"#{realm}\", error=\"invalid_token\"") + end + + def challenge_headers(challenge) + return Dexpace::Headers::EMPTY_INBOUND if challenge.nil? + + Array(challenge).reduce(Dexpace::Headers.inbound_builder) do |builder, value| + builder.add("WWW-Authenticate", value) + end.build + end + + # A sync pipeline: an optional REDIRECT-stage probe forking `redirect_state` in front of the + # AUTH step, over a SequencedTransport. `redirect_state: nil` installs no redirect step at all + # -- the "no REDIRECT step" case, whose slot reads as the shared frozen empty Hash. + def auth_pipeline(step, transport, redirect_state: nil) + builder = Dexpace::Pipeline.builder(transport: transport) + unless redirect_state.nil? + builder.append(ForkingProbe.new(times: 1, state_per_drive: [redirect_state]), + stage: STAGES::REDIRECT,) + end + builder.append(step).build + end + + def async_auth_pipeline(step, transport, redirect_state: nil) + builder = Dexpace::Pipeline::Builder.new(transport: transport) + unless redirect_state.nil? + builder.append(ForkingProbe.new(times: 1, state_per_drive: [redirect_state]), + stage: STAGES::REDIRECT,) + end + builder.append(step).build_async + end + + def closes_of(response) = response.body.closes +end diff --git a/gems/dexpace-core/test/support/challenge_fixtures.rb b/gems/dexpace-core/test/support/challenge_fixtures.rb new file mode 100644 index 0000000..23cef25 --- /dev/null +++ b/gems/dexpace-core/test/support/challenge_fixtures.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# Reusable WWW-Authenticate / Proxy-Authenticate header values for the phase-6c suites: RFC +# 2617 §3.5's Basic and MD5 challenges, RFC 7616 §3.9.1's SHA-256-sess challenge, one Digest +# challenge the handler must decline, and one deliberately malformed string per AUTH-13 recovery +# clause. Top level, one module, like every double under test/support/. +module ChallengeFixtures + BASIC = 'Basic realm="example"' + DIGEST_MD5 = 'Digest realm="testrealm@host.com", qop="auth,auth-int", ' \ + 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", ' \ + 'opaque="5ccc069c403ebaf9f0171e9517f40e41"' + DIGEST_SHA256_SESS = 'Digest realm="http-auth@example.org", qop="auth", ' \ + "algorithm=SHA-256-sess, " \ + 'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' \ + 'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS", charset=UTF-8, ' \ + "userhash=false" + DIGEST_UNSUPPORTED_QOP = 'Digest realm="r", qop="auth-int", nonce="n"' + MALFORMED_UNTERMINATED_QUOTE = 'Digest realm="unterminated' + MALFORMED_STRAY_COMMA = "Digest realm=r,,nonce=n" + MALFORMED_VALUE = 'Digest realm=@@, nonce="n"' + BARE_TOKEN68 = "Bearer dGhlIHNlY3JldCB0b2tlbg==" +end diff --git a/gems/dexpace-core/test/support/fixed_cnonce.rb b/gems/dexpace-core/test/support/fixed_cnonce.rb new file mode 100644 index 0000000..a063605 --- /dev/null +++ b/gems/dexpace-core/test/support/fixed_cnonce.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A cnonce source answering SecureRandom's #hex(bytes) with one fixed value, so a Digest +# response can be asserted against a published vector (RFC 2617 §3.5's cnonce="0a4f113b", +# RFC 7616 §3.9.1's). Records the byte count it was asked for, which is AUTH-20's 16. +class FixedCnonce + attr_reader :requests + + def initialize(value) + @value = value + @requests = [] + end + + def hex(bytes) + @requests << bytes + @value + end +end diff --git a/gems/dexpace-core/test/support/scripted_async_bearer_provider.rb b/gems/dexpace-core/test/support/scripted_async_bearer_provider.rb new file mode 100644 index 0000000..084693d --- /dev/null +++ b/gems/dexpace-core/test/support/scripted_async_bearer_provider.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "dexpace" + +# A bearer provider with a genuine #fetch_async: each call pops one script item -- a +# Dexpace::Async::Future is returned as it is (an unsettled one is how a test holds the fetch +# open and settles it deliberately), an Exception is RAISED synchronously (AUTH-11's +# "misbehaving async override"), a callable is called and its result returned, and anything +# else is returned as the fetch_async result (a non-Future, for the normalisation case). The +# last item repeats. #fetch exists because the provider duck type requires it, and raises: a +# test that lands on it has driven the wrong path. +class ScriptedAsyncBearerProvider + attr_reader :fetches + + def initialize(*script) + raise ArgumentError, "a script needs at least one item" if script.empty? + + @script = script + @fetches = 0 + @mutex = ::Thread::Mutex.new + end + + def fetch + raise "ScriptedAsyncBearerProvider#fetch: the async path was expected" + end + + def fetch_async + item = @mutex.synchronize do + @fetches += 1 + @script.size > 1 ? @script.shift : @script.first + end + raise item if item.is_a?(Exception) + + item.respond_to?(:call) && !item.is_a?(Dexpace::Async::Future) ? item.call : item + end +end diff --git a/gems/dexpace-core/test/support/scripted_bearer_provider.rb b/gems/dexpace-core/test/support/scripted_bearer_provider.rb new file mode 100644 index 0000000..09ebeab --- /dev/null +++ b/gems/dexpace-core/test/support/scripted_bearer_provider.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "dexpace" + +# A synchronous bearer provider (#fetch only, AUTH-11's commonest shape) answering a SCRIPT, +# one item per fetch: a BearerToken is returned, an Exception is raised, a callable is called +# and its result returned, and a bare String becomes a never-expiring token of that value. +# The script's LAST item repeats once the script is exhausted, so a provider built with one +# token answers it forever. Counts fetches under a mutex, which is how AUTH-34's "at most one +# provider fetch" is asserted from sixteen threads. +class ScriptedBearerProvider + attr_reader :fetches + + def initialize(*script) + raise ArgumentError, "a script needs at least one item" if script.empty? + + @script = script + @fetches = 0 + @mutex = ::Thread::Mutex.new + @before_fetch = nil + end + + # A callable run inside every #fetch, BEFORE the scripted reply -- how a test parks the + # fetch on a barrier to make a race deterministic. + def before_fetch(&block) + @before_fetch = block + self + end + + def fetch + item = @mutex.synchronize do + @fetches += 1 + @script.size > 1 ? @script.shift : @script.first + end + @before_fetch&.call + raise item if item.is_a?(Exception) + + item = item.call if item.respond_to?(:call) + item.is_a?(String) ? Dexpace::Auth::BearerToken.build(token: item) : item + end +end diff --git a/gems/dexpace-core/test/support/sequenced_async_transport.rb b/gems/dexpace-core/test/support/sequenced_async_transport.rb new file mode 100644 index 0000000..eead7b1 --- /dev/null +++ b/gems/dexpace-core/test/support/sequenced_async_transport.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "dexpace" + +# SequencedTransport's SEAM-16 twin: one script item per call, returned as a future -- a +# Dexpace::Response becomes a settled future, an Exception a failed one, a Dexpace::Async::Future +# is returned as it is (an unsettled one is how a test holds a drive open), and a callable is +# called with the request and its result treated the same way. Records the same triples. +class SequencedAsyncTransport + attr_reader :calls + + def initialize(*script) + @script = script + @calls = [] + @mutex = ::Thread::Mutex.new + end + + def call(request, options, cancellation) + item = @mutex.synchronize do + @calls << [request, options, cancellation] + raise "SequencedAsyncTransport: no scripted reply for drive #{@calls.size}" if @script.empty? + + @script.shift + end + item = item.call(request) if item.respond_to?(:call) && !item.is_a?(Dexpace::Async::Future) + as_future(item) + end + + def as_future(item) + return item if item.is_a?(Dexpace::Async::Future) + + completer = Dexpace::Async::Completer.new + item.is_a?(Exception) ? completer.fail(item) : completer.fulfil(item) + completer.future + end + + def requests = @calls.map(&:first) + + def authorization_headers + requests.map { |request| request.headers["Authorization"]&.first } + end +end diff --git a/gems/dexpace-core/test/support/sequenced_transport.rb b/gems/dexpace-core/test/support/sequenced_transport.rb new file mode 100644 index 0000000..bb135a6 --- /dev/null +++ b/gems/dexpace-core/test/support/sequenced_transport.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A transport that answers a SCRIPT, one item per call, in order: a Dexpace::Response is +# returned, an Exception is raised, a callable is called with the request and its result +# returned. Every 401-then-200 test of the AUTH step needs one, and FakeTransport answers one +# fixed response. Records every [request, options, cancellation] triple, like FakeTransport, +# and raises loudly when the script runs out, so an unexpected extra drive fails the test +# instead of returning nil into a step. +# +# Named SequencedTransport and not ScriptedTransport: phase 6a is building a double of the +# latter name in the same file position at the same time, and the two lanes merge without a +# collision this way; the duplication is the manager's to reconcile after both land. +class SequencedTransport + # @return [Array] one [request, options, cancellation] triple per call + attr_reader :calls + + def initialize(*script) + @script = script + @calls = [] + @mutex = ::Thread::Mutex.new + end + + def call(request, options, cancellation) + item = @mutex.synchronize do + @calls << [request, options, cancellation] + raise "SequencedTransport: no scripted reply for drive #{@calls.size}" if @script.empty? + + @script.shift + end + raise item if item.is_a?(Exception) + + item.respond_to?(:call) ? item.call(request) : item + end + + # The requests driven so far, in order. + def requests = @calls.map(&:first) + + # The Authorization values sent on each drive: nil when the drive carried none. + def authorization_headers + requests.map { |request| request.headers["Authorization"]&.first } + end +end diff --git a/gems/dexpace-core/test/support/spy_cursor.rb b/gems/dexpace-core/test/support/spy_cursor.rb new file mode 100644 index 0000000..1c3ae99 --- /dev/null +++ b/gems/dexpace-core/test/support/spy_cursor.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +# A recording wrapper over a REAL driver-made cursor: counts #call and #fork and delegates +# everything to the cursor it wraps, so a suite can assert "the step forked N times and never +# called its own cursor" (P4-39) on the cursor the step was actually handed. A test installs +# `->(request, cursor) { step.call(request, SpyCursor.new(cursor)) }` at the step's stage -- +# only the driver can make a forkable cursor, and only a step installed in a pipeline is handed +# one (phase 5b's checklist, item 12, is the precedent). +class SpyCursor + attr_reader :calls, :forks, :cursor + + def initialize(cursor) + @cursor = cursor + @calls = 0 + @forks = 0 + end + + def call(request = @cursor.request) + @calls += 1 + @cursor.call(request) + end + + def fork(state: nil) + @forks += 1 + @cursor.fork(state: state) + end + + def state(stage) = @cursor.state(stage) + def request = @cursor.request + def options = @cursor.options + def cancellation = @cursor.cancellation + def spent? = @cursor.spent? + def may_fork? = @cursor.may_fork? +end From fc7f9b450ac5949a2d1beede292ed8d1c9791a71 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 17:00:28 +0300 Subject: [PATCH 09/12] test: tell #stamp_fresh from #stamp, pin the patterns, cover R0-3/R0-4 Review round 0's findings, each with the line that now runs red on its revert. R0-1: AsyncStep's post-eviction routing was indistinguishable under the suite, because the real AsyncBearerStamper fetches through #stamp and #stamp_fresh alike once its cache is empty (mutation M45 survived). A new top-level double, SpyBearerStamper, answers "Bearer cached" from #stamp and "Bearer fresh" from #stamp_fresh and records every call, and two BearerTest cases drive a 401-then-200 through it: a successful eviction retries through #stamp_fresh and a failed one through #stamp. M45 now fails on ["Bearer cached", "Bearer fresh"] versus twice cached. R0-2: the parser suite pins each of the eight scanner patterns as a private, frozen Regexp with a non-nil #timeout, as http_date_test.rb pins CFG-31's grammar; a removed `timeout:` or `.freeze` is a red test. The suite crossed Metrics/ClassLength and is split into GrammarTest and LeniencyTest over one shared helper. R0-3: a BINARY-tagged credential under charset=UTF-8 raises naming UTF-8 with the conversion error as cause; a UTF-8-tagged credential with an invalid sequence is refused on its own bytes, cause nil; a Latin-1-tagged one is transcoded and accepted. The error suite asserts the UTF-8 message says the challenge advertised the charset and never that it did not. R0-4: a refused Digest attempt leaves the nonce's counter unset, and the next response on that nonce sends nc=00000001. --- .../test/dexpace/auth/async_step_test.rb | 31 ++- .../test/dexpace/auth/challenges_test.rb | 227 ++++++++++-------- .../test/dexpace/auth/digest_handler_test.rb | 40 +++ .../auth/unencodable_credential_error_test.rb | 17 +- .../test/support/spy_bearer_stamper.rb | 48 ++++ 5 files changed, 260 insertions(+), 103 deletions(-) create mode 100644 gems/dexpace-core/test/support/spy_bearer_stamper.rb diff --git a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb index 97b1dbd..b9725cf 100644 --- a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb @@ -10,14 +10,17 @@ require_relative "../../support/scripted_bearer_provider" require_relative "../../support/scripted_async_bearer_provider" require_relative "../../support/spy_cursor" +require_relative "../../support/spy_bearer_stamper" require_relative "../../support/fake_clock" # Exercises: AUTH-38 and the async mirror of AUTH-27 through AUTH-37 -- the async AUTH pillar # step through a real async pipeline: every failure a failed future and never a synchronous # raise, the three-zone stamper driven through it, the bearer 401 branch awaiting a genuinely -# fresh fetch after an eviction and reusing a preserved token otherwise, AUTH-31's gate through -# the inherited predicate, AUTH-32's three clauses, and cancellation forwarded both ways. Every -# #value here is on a future the test settles or one already settled. Split under +# fresh fetch after an eviction and reusing a preserved token otherwise -- the routing itself +# through a stamper double whose #stamp and #stamp_fresh differ on the wire (review round 0's +# R0-1), since the real stamper fetches either way once its cache is empty -- AUTH-31's gate +# through the inherited predicate, AUTH-32's three clauses, and cancellation forwarded both +# ways. Every #value here is on a future the test settles or one already settled. Split under # Metrics/ClassLength. class DexpaceAuthAsyncStepTest < DexpaceTestCase AsyncStep = Dexpace::Auth::AsyncStep @@ -362,5 +365,27 @@ class BearerTest < DexpaceTestCase assert_equal(200, dispatch(async_step(stamper: stamper), transport).value.status.code) assert_equal(["Bearer old", "Bearer new"], transport.authorization_headers) end + + # The real stamper fetches through either method once evicted, so only a double whose two + # stamps differ on the wire can tell the routing apart (R0-1). + test "AUTH-37: after a SUCCESSFUL eviction the retry goes through #stamp_fresh, never #stamp" do + stamper = SpyBearerStamper.new(evicts: true) + transport = settled(unauthorized_bearer, ok) + response = dispatch(async_step(stamper: stamper), transport).value + + assert_equal(200, response.status.code) + assert_equal(["Bearer cached", "Bearer fresh"], transport.authorization_headers) + assert_equal([:stamp, [:evict_if_matches, "Bearer cached"], :stamp_fresh], stamper.calls) + end + + test "AUTH-36: after a FAILED eviction (refreshed elsewhere) the retry is stamped by #stamp" do + stamper = SpyBearerStamper.new(evicts: false) + transport = settled(unauthorized_bearer, ok) + response = dispatch(async_step(stamper: stamper), transport).value + + assert_equal(200, response.status.code) + assert_equal(["Bearer cached", "Bearer cached"], transport.authorization_headers) + assert_equal([:stamp, [:evict_if_matches, "Bearer cached"], :stamp], stamper.calls) + end end end diff --git a/gems/dexpace-core/test/dexpace/auth/challenges_test.rb b/gems/dexpace-core/test/dexpace/auth/challenges_test.rb index 458a41e..2638a52 100644 --- a/gems/dexpace-core/test/dexpace/auth/challenges_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/challenges_test.rb @@ -7,136 +7,167 @@ # Exercises: AUTH-12, AUTH-13 -- the RFC 7235 challenge parser: every clause of the two # requirements, the grammar's parameter-versus-challenge ambiguity, the recovery clauses on the -# deliberately malformed fixtures, and the bounded-time measurement that discharges the -# no-regexp house rule by measurement rather than by claim. +# deliberately malformed fixtures, the bounded-time measurement that discharges the no-regexp +# house rule by measurement rather than by claim, and the per-pattern timeout pinned on each of +# the eight scanner patterns. Split into nested cases under Metrics/ClassLength. class DexpaceAuthChallengesTest < DexpaceTestCase Challenges = Dexpace::Auth::Challenges - def shapes(value) - Challenges.parse(value).map { |c| [c.scheme, c.params] } + # The one helper both cases share. + module Fixtures + def shapes(value) + Challenges.parse(value).map { |c| [c.scheme, c.params] } + end end - test "AUTH-13: nil, empty and blank input yield an empty list" do - assert_empty(Challenges.parse(nil)) - assert_empty(Challenges.parse("")) - assert_empty(Challenges.parse(" ")) - assert_empty(Challenges.parse(" , ,\t")) - end + # AUTH-12: the grammar -- every clause of the requirement and the list's own ambiguity. + class GrammarTest < DexpaceTestCase + include Fixtures - test "AUTH-12: multiple comma-separated challenges at the top level, in wire order" do - challenges = Challenges.parse("#{ChallengeFixtures::BASIC}, #{ChallengeFixtures::DIGEST_MD5}") + test "AUTH-12: multiple comma-separated challenges at the top level, in wire order" do + challenges = Challenges.parse("#{ChallengeFixtures::BASIC}, #{ChallengeFixtures::DIGEST_MD5}") - assert_equal(%w[basic digest], challenges.map(&:scheme)) - assert_equal({ "realm" => "example" }, challenges[0].params) - assert_equal("dcd98b7102dd2f0e8b11d0f600bfb0c093", challenges[1].params["nonce"]) - assert_equal("auth,auth-int", challenges[1].params["qop"]) - end - - test "AUTH-12: scheme and parameter names are lower-cased, values kept verbatim" do - assert_equal([["basic", { "realm" => "MiXeD" }]], shapes('BASIC REALM="MiXeD"')) - assert_equal([["digest", { "algorithm" => "SHA-256" }]], shapes("Digest Algorithm=SHA-256")) - end + assert_equal(%w[basic digest], challenges.map(&:scheme)) + assert_equal({ "realm" => "example" }, challenges[0].params) + assert_equal("dcd98b7102dd2f0e8b11d0f600bfb0c093", challenges[1].params["nonce"]) + assert_equal("auth,auth-int", challenges[1].params["qop"]) + end - test "AUTH-12: a quoted-string may contain commas and equals signs" do - assert_equal("a, b = c", Challenges.parse('Digest realm="a, b = c"').first.params["realm"]) - assert_equal(1, Challenges.parse('Digest realm="a, b = c", nonce="x,y"').size) - end + test "AUTH-12: scheme and parameter names are lower-cased, values kept verbatim" do + assert_equal([["basic", { "realm" => "MiXeD" }]], shapes('BASIC REALM="MiXeD"')) + assert_equal([["digest", { "algorithm" => "SHA-256" }]], shapes("Digest Algorithm=SHA-256")) + end - test "AUTH-12: backslash escapes are unescaped and the quotes stripped" do - assert_equal('a"b', Challenges.parse('Digest realm="a\\"b"').first.params["realm"]) - assert_equal("a\\b", Challenges.parse('Digest realm="a\\\\b"').first.params["realm"]) - end + test "AUTH-12: a quoted-string may contain commas and equals signs" do + assert_equal("a, b = c", Challenges.parse('Digest realm="a, b = c"').first.params["realm"]) + assert_equal(1, Challenges.parse('Digest realm="a, b = c", nonce="x,y"').size) + end - test "AUTH-12: a bare scheme with no params is a challenge with an empty parameter map" do - assert_equal([["ntlm", {}]], shapes("NTLM")) - assert_equal([["negotiate", {}], ["ntlm", {}]], shapes("Negotiate, NTLM")) - end + test "AUTH-12: backslash escapes are unescaped and the quotes stripped" do + assert_equal('a"b', Challenges.parse('Digest realm="a\\"b"').first.params["realm"]) + assert_equal("a\\b", Challenges.parse('Digest realm="a\\\\b"').first.params["realm"]) + end - test "AUTH-12: a token68 value is recorded whole under the synthetic key, padding included" do - challenge = Challenges.parse(ChallengeFixtures::BARE_TOKEN68).first + test "AUTH-12: a bare scheme with no params is a challenge with an empty parameter map" do + assert_equal([["ntlm", {}]], shapes("NTLM")) + assert_equal([["negotiate", {}], ["ntlm", {}]], shapes("Negotiate, NTLM")) + end - assert_equal("dGhlIHNlY3JldCB0b2tlbg==", challenge.params["token68"]) - assert_equal("dGhlIHNlY3JldCB0b2tlbg==", challenge.token68) - assert_equal([["bearer", { "token68" => "abc" }], ["basic", { "realm" => "r" }]], - shapes("Bearer abc, Basic realm=r"),) - end + test "AUTH-12: a token68 value is recorded whole under the synthetic key, padding included" do + challenge = Challenges.parse(ChallengeFixtures::BARE_TOKEN68).first - test "AUTH-12: `realm=` is not read as a token68, so a Digest challenge keeps its realm" do - assert_equal([["digest", { "realm" => "r" }]], shapes('Digest realm="r"')) - assert_equal([["digest", { "realm" => "r", "nonce" => "n" }]], - shapes("Digest realm=r, nonce=n"),) - end + assert_equal("dGhlIHNlY3JldCB0b2tlbg==", challenge.params["token68"]) + assert_equal("dGhlIHNlY3JldCB0b2tlbg==", challenge.token68) + assert_equal([["bearer", { "token68" => "abc" }], ["basic", { "realm" => "r" }]], + shapes("Bearer abc, Basic realm=r"),) + end - test "AUTH-12: a second challenge after a parameterised first is not swallowed" do - challenges = Challenges.parse('Digest realm="r", nonce="n", Basic realm="r"') + test "AUTH-12: `realm=` is not read as a token68, so a Digest challenge keeps its realm" do + assert_equal([["digest", { "realm" => "r" }]], shapes('Digest realm="r"')) + assert_equal([["digest", { "realm" => "r", "nonce" => "n" }]], + shapes("Digest realm=r, nonce=n"),) + end - assert_equal(%w[digest basic], challenges.map(&:scheme)) - assert_equal({ "realm" => "r", "nonce" => "n" }, challenges.first.params) - assert_equal({ "realm" => "r" }, challenges.last.params) - end + test "AUTH-12: a second challenge after a parameterised first is not swallowed" do + challenges = Challenges.parse('Digest realm="r", nonce="n", Basic realm="r"') - test "AUTH-13: empty list elements are skipped and the parameter continues the challenge" do - assert_equal([["digest", { "realm" => "r", "nonce" => "n" }], ["basic", { "realm" => "ok" }]], - shapes("#{ChallengeFixtures::MALFORMED_STRAY_COMMA}, Basic realm=\"ok\""),) - end + assert_equal(%w[digest basic], challenges.map(&:scheme)) + assert_equal({ "realm" => "r", "nonce" => "n" }, challenges.first.params) + assert_equal({ "realm" => "r" }, challenges.last.params) + end - test "AUTH-13: a malformed value recovers to the next top-level comma, keeping earlier params" do - challenges = Challenges.parse("#{ChallengeFixtures::MALFORMED_VALUE}, Basic realm=\"ok\"") + test "the list and every challenge are frozen" do + challenges = Challenges.parse("Basic realm=r") - assert_equal(%w[digest basic], challenges.map(&:scheme)) - assert_equal({ "nonce" => "n" }, challenges.first.params) + assert_predicate(challenges, :frozen?) + assert_predicate(challenges.first.params, :frozen?) + end end - test "AUTH-13: recovery walks a quoted string, so a comma inside one is not the boundary" do - challenges = Challenges.parse('Digest realm=@@ nonce="a,b", Basic realm=x') + # AUTH-13: leniency, the bounded-time measurement and the per-pattern timeout pin. + class LeniencyTest < DexpaceTestCase + include Fixtures - assert_equal(%w[digest basic], challenges.map(&:scheme)) - assert_equal({ "realm" => "x" }, challenges.last.params) - end + test "AUTH-13: nil, empty and blank input yield an empty list" do + assert_empty(Challenges.parse(nil)) + assert_empty(Challenges.parse("")) + assert_empty(Challenges.parse(" ")) + assert_empty(Challenges.parse(" , ,\t")) + end - test "AUTH-13: a parameter before any scheme, and a bare token after one, are skipped" do - assert_equal([["basic", { "realm" => "r" }]], shapes("realm=x, Basic realm=r")) - assert_equal([["bearer", {}], ["basic", { "realm" => "r" }]], - shapes("Bearer abc realm=x, Basic realm=r"),) - end + test "AUTH-13: empty list elements are skipped and the parameter continues the challenge" do + assert_equal([["digest", { "realm" => "r", "nonce" => "n" }], ["basic", { "realm" => "ok" }]], + shapes("#{ChallengeFixtures::MALFORMED_STRAY_COMMA}, Basic realm=\"ok\""),) + end - test "AUTH-13: an unterminated quoted-string terminates at end-of-input" do - challenge = Challenges.parse(ChallengeFixtures::MALFORMED_UNTERMINATED_QUOTE).first + test "AUTH-13: a malformed value recovers to the next top-level comma, earlier params kept" do + challenges = Challenges.parse("#{ChallengeFixtures::MALFORMED_VALUE}, Basic realm=\"ok\"") - assert_equal("unterminated", challenge.params["realm"]) - assert_equal({ "realm" => "r", "nonce" => "n" }, - Challenges.parse('Digest realm="r", nonce="n').first.params,) - end + assert_equal(%w[digest basic], challenges.map(&:scheme)) + assert_equal({ "nonce" => "n" }, challenges.first.params) + end - # The last input is a UTF-8-tagged value with an invalid byte, on which StringScanner#scan - # and String#downcase both raise ArgumentError: the parser scans it as bytes instead. - test "AUTH-13: the parser never raises on adversarial input, invalid UTF-8 included" do - every_ascii = (0x20..0x7E).map(&:chr).join - invalid_utf8 = "Digest realm=\"caf\xE9\"".b.force_encoding(Encoding::UTF_8) - inputs = ["\\" * 5000, ("a=" * 5000), ('"' * 5000), every_ascii, "=", "\"", ",=,", - "Basic realm=\"\\", "\x00\xFF".b, "Digest realm=\"\xC3\xA9\"".b, invalid_utf8,] + test "AUTH-13: recovery walks a quoted string, so a comma inside one is not the boundary" do + challenges = Challenges.parse('Digest realm=@@ nonce="a,b", Basic realm=x') - inputs.each do |input| - assert_kind_of(Array, Challenges.parse(input), input.inspect) + assert_equal(%w[digest basic], challenges.map(&:scheme)) + assert_equal({ "realm" => "x" }, challenges.last.params) end - end - test "the regexp-timeout house rule is discharged by measurement: 100 000 bytes in under 1 s" do - inputs = ["a" * 100_000, ("a=b," * 25_000), ('"' * 100_000), ("Basic " * 16_000)] + test "AUTH-13: a parameter before any scheme, and a bare token after one, are skipped" do + assert_equal([["basic", { "realm" => "r" }]], shapes("realm=x, Basic realm=r")) + assert_equal([["bearer", {}], ["basic", { "realm" => "r" }]], + shapes("Bearer abc realm=x, Basic realm=r"),) + end - inputs.each do |input| - started = Process.clock_gettime(Process::CLOCK_MONOTONIC) - Challenges.parse(input) - elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + test "AUTH-13: an unterminated quoted-string terminates at end-of-input" do + challenge = Challenges.parse(ChallengeFixtures::MALFORMED_UNTERMINATED_QUOTE).first - assert_operator(elapsed, :<, 1.0) + assert_equal("unterminated", challenge.params["realm"]) + assert_equal({ "realm" => "r", "nonce" => "n" }, + Challenges.parse('Digest realm="r", nonce="n').first.params,) + end + + # The last input is a UTF-8-tagged value with an invalid byte, on which StringScanner#scan + # and String#downcase both raise ArgumentError: the parser scans it as bytes instead. + test "AUTH-13: the parser never raises on adversarial input, invalid UTF-8 included" do + every_ascii = (0x20..0x7E).map(&:chr).join + invalid_utf8 = "Digest realm=\"caf\xE9\"".b.force_encoding(Encoding::UTF_8) + inputs = ["\\" * 5000, ("a=" * 5000), ('"' * 5000), every_ascii, "=", "\"", ",=,", + "Basic realm=\"\\", "\x00\xFF".b, "Digest realm=\"\xC3\xA9\"".b, invalid_utf8,] + + inputs.each do |input| + assert_kind_of(Array, Challenges.parse(input), input.inspect) + end end - end - test "the list and every challenge are frozen" do - challenges = Challenges.parse("Basic realm=r") + test "the regexp-timeout house rule is discharged by measurement: 100 000 bytes in under 1 s" do + inputs = ["a" * 100_000, ("a=b," * 25_000), ('"' * 100_000), ("Basic " * 16_000)] - assert_predicate(challenges, :frozen?) - assert_predicate(challenges.first.params, :frozen?) + inputs.each do |input| + started = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Challenges.parse(input) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started + + assert_operator(elapsed, :<, 1.0) + end + end + + # The measurement above holds without a timeout because the classes are linear; the house + # rule (design §4, §6.3) is pinned as a property of each pattern too, as http_date_test.rb + # pins CFG-31's grammar, so removing one `timeout:` is a red test and not a silent regression + # (review round 0's R0-2). + test "every scanner pattern is a private, frozen Regexp compiled with a per-pattern timeout" do + names = %i[TOKEN TOKEN68 SEPARATORS BOUNDARY EQUALS SPACES OWS QUOTE] + + names.each do |name| + pattern = Challenges.const_get(name) + + assert_kind_of(Regexp, pattern, name.to_s) + refute_nil(pattern.timeout, name.to_s) + assert_predicate(pattern, :frozen?, name.to_s) + refute_includes(Challenges.constants, name) + end + end end end diff --git a/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb index 8402106..3d2d1b8 100644 --- a/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb @@ -287,6 +287,20 @@ class CounterTest < DexpaceTestCase test "AUTH-24: the handler is frozen and holds no per-request state" do assert_predicate(handler, :frozen?) end + + # The credential is materialised before the count is taken (the design's order), so the + # one step that can raise leaves the nonce's counter where it was (review round 0's R0-4). + test "AUTH-18: a refused attempt consumes no nonce count; the next response is not one high" do + digest_handler = handler(credential(username: "a", password: "日")) + + assert_raises(Dexpace::Auth::UnencodableCredentialError) do + answer(digest_handler, digest(nonce: "once", qop: "auth")) + end + assert_nil(digest_handler.instance_variable_get(:@nonces)["once"]) + header = answer(digest_handler, digest(nonce: "once", qop: "auth", charset: "UTF-8")) + + assert_equal("00000001", params_of(header)["nc"]) + end end # AUTH-20, AUTH-21: the cnonce source and the hash-input encoding. @@ -356,6 +370,32 @@ class EncodingTest < DexpaceTestCase assert_equal(:username, error.field) end + + # The UTF-8 branch can raise too, and when it does the error names UTF-8 and not Latin-1 + # (review round 0's R0-3): a BINARY-tagged credential has no UTF-8 meaning for a high byte, + # and a UTF-8-tagged one with an invalid sequence passes `encode` to the same encoding + # unvalidated, so it is refused on its own bytes rather than hashed as it is. + test "AUTH-21 (R0-3): the UTF-8 branch's failure names UTF-8, for a BINARY or invalid tag" do + binary = credential(username: "a", password: "p\xE4".b) + error = assert_raises(Dexpace::Auth::UnencodableCredentialError) do + answer(handler(binary), digest(charset: "UTF-8")) + end + + assert_equal([:password, "UTF-8"], [error.field, error.encoding]) + assert_kind_of(Encoding::UndefinedConversionError, error.cause) + assert_includes(error.message, "advertised charset=UTF-8") + refute_includes(error.message, "ISO-8859-1") + invalid = credential(username: "a", password: (+"p\xE4").force_encoding(Encoding::UTF_8)) + error = assert_raises(Dexpace::Auth::UnencodableCredentialError) do + answer(handler(invalid), digest(charset: "utf-8")) + end + + assert_equal([:password, "UTF-8"], [error.field, error.encoding]) + assert_nil(error.cause) + latin1 = credential(username: "a", password: "pä".encode(Encoding::ISO_8859_1)) + + refute_nil(answer(handler(latin1), digest(charset: "UTF-8"))) # transcoded, not refused + end end # AUTH-22 and the two wire forms the port decided. diff --git a/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb b/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb index be706d7..d429655 100644 --- a/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb @@ -5,7 +5,8 @@ require_relative "../../../lib/dexpace/auth/unencodable_credential_error" # Exercises: AUTH-21 (R10, P6-1) -- the typed failure: phase 2's error shape under the Auth -# namespace, the field and the encoding as members and in the message, never the value. +# namespace, the field and the encoding as members and in the message, never the value, and +# the reason worded for the branch that raised (6c's P6-84). class DexpaceAuthUnencodableCredentialErrorTest < DexpaceTestCase Error = Dexpace::Auth::UnencodableCredentialError @@ -23,7 +24,19 @@ class DexpaceAuthUnencodableCredentialErrorTest < DexpaceTestCase assert_includes(message, "username") assert_includes(message, "ISO-8859-1") - assert_includes(message, "charset=UTF-8") + assert_includes(message, "did not advertise charset=UTF-8") assert_includes(message, "AUTH-21") end + + # The reason is the target's own (review round 0's R0-3): the UTF-8 branch must not blame + # the challenge for a byte the caller supplied. + test "the UTF-8 branch's message says the challenge advertised it, never that it did not" do + message = Error.new(field: :password, encoding: "UTF-8").message + + assert_includes(message, "password cannot be encoded as UTF-8") + assert_includes(message, "advertised charset=UTF-8") + refute_includes(message, "did not advertise") + refute_includes(message, "ISO-8859-1") + assert_includes(Error.new(field: :realm, encoding: "UTF-16").message, "UTF-16") + end end diff --git a/gems/dexpace-core/test/support/spy_bearer_stamper.rb b/gems/dexpace-core/test/support/spy_bearer_stamper.rb new file mode 100644 index 0000000..47758c9 --- /dev/null +++ b/gems/dexpace-core/test/support/spy_bearer_stamper.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "dexpace" + +# A recording stand-in for AsyncBearerStamper whose three methods can be told apart on the +# wire: #stamp writes "Bearer cached", #stamp_fresh writes "Bearer fresh", and +# #evict_if_matches answers the Boolean it was built with, every call recorded in order. The +# real stamper cannot distinguish the two stamps after a successful eviction -- its cache is +# empty either way, so both await a fetch -- which is why AUTH-37's post-eviction routing +# ("#stamp_fresh after an eviction, #stamp after a preserved token") is asserted through this +# double and not through it (review round 0's R0-1). Both stamps return an already-settled +# future, the shape a #stamp-answering stamper hands AsyncStep. +class SpyBearerStamper + attr_reader :calls + + def initialize(evicts:) + @evicts = evicts + @calls = [] + end + + def stamp(request) + @calls << :stamp + settled(stamped(request, "Bearer cached")) + end + + def stamp_fresh(request) + @calls << :stamp_fresh + settled(stamped(request, "Bearer fresh")) + end + + def evict_if_matches(rejected_header) + @calls << [:evict_if_matches, rejected_header] + @evicts + end + + private + + def stamped(request, value) + request.with(headers: request.headers.new_builder.set("Authorization", value).build) + end + + def settled(request) + completer = Dexpace::Async::Completer.new + completer.fulfil(request) + completer.future + end +end From cc9e2725bde9cd76558ae1d93c78c066877377b8 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 18:27:41 +0300 Subject: [PATCH 10/12] test: pin the close-before-replay order and the causeless failures Review round 1's R1-1, R1-2, R1-3 and R1-5. R1-2: the AUTH-30 order was asserted by count alone, so a close-after- drive mutation survived on both interpreters. The replay's scripted reply is now a callable that reads the 401's close count AS the second drive reaches the transport, in step_test.rb, step_bearer_challenge_ test.rb and both async_step_test.rb branches; the sync and async close-after-drive mutations run red. R1-1: async_step_test.rb gains the future-fulfilled non-request shape, a String and a future of a future, each closing the 401 and failing the future with the InvalidArgumentError naming the class. R1-3: unencodable_credential_error_test.rb takes the source_encoding keyword and proves the error is raised with no cause inside an in-flight rescue; digest_handler_test.rb asserts nil cause, the source encoding, and that message, detailed_message, inspect, full_message and every each_cause message carry neither U+65E5, the character nor the password; basic_handler_test.rb asserts the typed, causeless refusal of a BINARY-tagged and an invalid UTF-8-tagged field naming no byte. R1-5: the async AUTH-35 test asserts the rejected results cache nothing through #evict_if_matches and the cache slot, so round 1's M51 runs red. --- .../dexpace/auth/async_bearer_stamper_test.rb | 5 +++ .../test/dexpace/auth/async_step_test.rb | 34 +++++++++++++++- .../test/dexpace/auth/basic_handler_test.rb | 29 ++++++++++++-- .../test/dexpace/auth/digest_handler_test.rb | 34 ++++++++++++++-- .../auth/step_bearer_challenge_test.rb | 9 ++++- .../test/dexpace/auth/step_test.rb | 14 ++++++- .../auth/unencodable_credential_error_test.rb | 40 ++++++++++++++----- 7 files changed, 142 insertions(+), 23 deletions(-) diff --git a/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb index e25610b..7372169 100644 --- a/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb @@ -182,11 +182,16 @@ class FailureTest < DexpaceTestCase subject = stamper(provider) assert_raises(Dexpace::Auth::ProviderError) { subject.stamp(https_request).value } + # Caches nothing: an already-expired token is not the cached one either (round 1's R1-5). + refute(subject.evict_if_matches("Bearer expired")) + assert_nil(subject.instance_variable_get(:@token)) assert_raises(Dexpace::Auth::ProviderError) { subject.stamp(https_request).value } + assert_nil(subject.instance_variable_get(:@token)) assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) nil_token = stamper(ScriptedBearerProvider.new(-> {})) assert_raises(Dexpace::Auth::ProviderError) { nil_token.stamp(https_request).value } + assert_nil(nil_token.instance_variable_get(:@token)) end # The regression R12 exists to prevent: AUTH-11's default wrapper mirrors a #fetch-only diff --git a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb index b9725cf..e0c4e19 100644 --- a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb @@ -226,11 +226,19 @@ class ChallengeTest < DexpaceTestCase include Fixtures test "AUTH-30: a 401 with a challenge consults the hook and replays the replacement once" do - transport = settled(unauthorized("Basic realm=r"), ok) + first = unauthorized("Basic realm=r") + closed_at_replay = nil + replay = lambda do |_request| + closed_at_replay = closes_of(first) # read AS the replay reaches the transport (R1-2) + ok + end + transport = settled(first, replay) response = dispatch(async_step(hook: ->(_c, request, _r) { request }), transport).value assert_equal(200, response.status.code) assert_equal(2, transport.calls.size) + assert_equal(1, closed_at_replay) # the 401 is closed BEFORE the replay drives + assert_equal(0, closes_of(response)) end test "AUTH-30: the default hook yields no replacement; AUTH-33: no challenge, no consulting" do @@ -286,6 +294,21 @@ class ChallengeTest < DexpaceTestCase assert_equal(1, closes_of(first)) end + # Round 1's R1-1: the settled value was checked outside the closing frame, so this shape + # failed the future with the 401 left open; a future of a future is the same clause. + test "AUTH-32: a hook FUTURE fulfilling with a non-request closes the 401, fails the future" do + inner = Completer.new.tap { |completer| completer.fulfil(https_request) }.future + ["junk", inner].each do |value| + first = unauthorized("Basic realm=r") + hook = ->(*) { Completer.new.tap { |completer| completer.fulfil(value) }.future } + future = dispatch(async_step(hook: hook), settled(first, ok)) + error = assert_raises(Dexpace::InvalidArgumentError) { future.value } + + assert_includes(error.message, "got #{value.class}") + assert_equal(1, closes_of(first)) + end + end + test "AUTH-31 on the async path: a non-replayable replacement surfaces the 401 unclosed" do first = unauthorized("Basic realm=r") transport = settled(first, ok) @@ -304,11 +327,18 @@ class BearerTest < DexpaceTestCase test "AUTH-36, AUTH-37: the bearer 401 branch awaits a fresh fetch, never re-sends the token" do stamper = async_bearer("old", "new") - transport = settled(unauthorized_bearer, ok) + first = unauthorized_bearer + closed_at_retry = nil + retry_reply = lambda do |_request| + closed_at_retry = closes_of(first) + ok + end + transport = settled(first, retry_reply) response = dispatch(async_step(stamper: stamper), transport).value assert_equal(200, response.status.code) assert_equal(["Bearer old", "Bearer new"], transport.authorization_headers) + assert_equal(1, closed_at_retry) # the superseded 401 is closed BEFORE the retry drives end test "AUTH-36: a token another request refreshed is preserved and reused, no fetch" do diff --git a/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb b/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb index 8608f26..a1dab4a 100644 --- a/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb @@ -5,9 +5,10 @@ require_relative "../../../lib/dexpace/auth/basic_handler" require_relative "../../support/auth_fixtures" -# Exercises: AUTH-14 -- Basic: `Basic ` + pack("m0") of the UTF-8 bytes, computed once and -# reused by both roles, the challenge accepted case-insensitively, non-empty (not non-blank) -# credentials, and never Base64. +# Exercises: AUTH-14, AUTH-8 -- Basic: `Basic ` + pack("m0") of the UTF-8 bytes, computed once +# and reused by both roles, the challenge accepted case-insensitively, non-empty (not non-blank) +# credentials, a field UTF-8 cannot carry refused as a typed, causeless failure naming no byte +# of it (6c's P6-85), and never Base64. class DexpaceAuthBasicHandlerTest < DexpaceTestCase include AuthFixtures @@ -81,6 +82,28 @@ def challenge(scheme) = Challenge.build(scheme: scheme) assert_raises(Dexpace::InvalidArgumentError) { BasicHandler.new(credential(username: "a:b")) } end + # The first build let Ruby's own conversion error escape, naming a byte of the password + # (`"\xE4" from ASCII-8BIT to UTF-8`); the failure is now typed, names the field and the two + # encodings, and carries no cause -- #full_message renders one (review round 1's R1-3). + test "AUTH-8, P6-85: a field UTF-8 cannot carry is refused, typed, naming no byte of it" do + binary = assert_raises(Dexpace::InvalidArgumentError) do + BasicHandler.new(credential(password: "p\xE4".b)) + end + + assert_includes(binary.message, "password cannot be encoded as UTF-8 from ASCII-8BIT") + assert_includes(binary.message, "AUTH-14") + assert_nil(binary.cause) + [binary.message, binary.inspect, binary.full_message(highlight: false)].each do |text| + refute_includes(text, "\\xE4", text) + end + invalid = assert_raises(Dexpace::InvalidArgumentError) do + BasicHandler.new(credential(username: (+"\xE4").force_encoding(Encoding::UTF_8))) + end + + assert_includes(invalid.message, "username cannot be encoded as UTF-8 from UTF-8") + assert_nil(invalid.cause) + end + test "never Base64: the source spells pack(\"m0\") and requires no base64" do source = File.read(File.expand_path("../../../lib/dexpace/auth/basic_handler.rb", __dir__)) diff --git a/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb index 3d2d1b8..63a9a43 100644 --- a/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb @@ -358,11 +358,34 @@ class EncodingTest < DexpaceTestCase assert_equal(:password, error.field) assert_equal("ISO-8859-1", error.encoding) - assert_kind_of(Encoding::UndefinedConversionError, error.cause) + assert_equal("UTF-8", error.source_encoding) + assert_nil(error.cause) refute_includes(error.message, "日") end end + # Ruby's conversion error names the offending character (`U+65E5 from UTF-8 to + # ISO-8859-1`), which is a character of the password, and #full_message renders a cause on + # every supported Ruby -- so the typed failure carries none, and the source encoding is a + # member instead (review round 1's R1-3; 6c's P6-85). + test "AUTH-8 (P6-85): no rendering of the failure carries a character of the secret" do + error = assert_raises(Dexpace::Auth::UnencodableCredentialError) do + answer(handler(credential(username: "a", password: "hunter日2")), digest) + end + renderings = [error.message, error.detailed_message, error.inspect, + error.full_message(highlight: false), + *Dexpace.each_cause(error).map(&:message),] + + assert_nil(error.cause) + assert_equal(1, Dexpace.each_cause(error).count) + renderings.each do |text| + refute_includes(text, "U+65E5", text) + refute_includes(text, "日", text) + refute_includes(text, "hunter", text) + end + assert_includes(error.message, "from UTF-8") + end + test "AUTH-21 (R10): an unencodable username names :username" do error = assert_raises(Dexpace::Auth::UnencodableCredentialError) do answer(handler(credential(username: "日", password: "p")), digest) @@ -381,8 +404,10 @@ class EncodingTest < DexpaceTestCase answer(handler(binary), digest(charset: "UTF-8")) end - assert_equal([:password, "UTF-8"], [error.field, error.encoding]) - assert_kind_of(Encoding::UndefinedConversionError, error.cause) + assert_equal([:password, "UTF-8", "ASCII-8BIT"], + [error.field, error.encoding, error.source_encoding],) + assert_nil(error.cause) + refute_includes(error.full_message(highlight: false), "\\xE4") # the byte the cause named assert_includes(error.message, "advertised charset=UTF-8") refute_includes(error.message, "ISO-8859-1") invalid = credential(username: "a", password: (+"p\xE4").force_encoding(Encoding::UTF_8)) @@ -390,7 +415,8 @@ class EncodingTest < DexpaceTestCase answer(handler(invalid), digest(charset: "utf-8")) end - assert_equal([:password, "UTF-8"], [error.field, error.encoding]) + assert_equal([:password, "UTF-8", "UTF-8"], + [error.field, error.encoding, error.source_encoding],) assert_nil(error.cause) latin1 = credential(username: "a", password: "pä".encode(Encoding::ISO_8859_1)) diff --git a/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb b/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb index 0cd0ec3..e199453 100644 --- a/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb @@ -46,13 +46,18 @@ class RetryTest < DexpaceTestCase test "AUTH-36: a 401 with a Bearer challenge evicts the token and retries once, freshly" do prov = provider("old", "new") first = unauthorized_bearer - transport = SequencedTransport.new(first, ok) + closed_at_retry = nil + retry_reply = lambda do |_request| + closed_at_retry = closes_of(first) # read AS the retry reaches the transport (R1-2) + ok + end + transport = SequencedTransport.new(first, retry_reply) response = dispatch(bearer_step(prov), transport) assert_equal(200, response.status.code) assert_equal(["Bearer old", "Bearer new"], transport.authorization_headers) assert_equal(2, prov.fetches) - assert_equal(1, closes_of(first)) + assert_equal(1, closed_at_retry) # the superseded 401 is closed BEFORE the retry drives assert_equal(0, closes_of(response)) end diff --git a/gems/dexpace-core/test/dexpace/auth/step_test.rb b/gems/dexpace-core/test/dexpace/auth/step_test.rb index 41a0afd..83443ae 100644 --- a/gems/dexpace-core/test/dexpace/auth/step_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/step_test.rb @@ -254,11 +254,21 @@ class DriveTest < DexpaceTestCase assert_equal(0, spy.call.calls) end - test "AUTH-30: the original 401 is closed before the replay; the replay's response is not" do + # "Close the original 401 AND drive the replacement", in that order: the close count is + # read as the replay reaches the transport, not after the fact, so a replay that raises + # cannot leave the 401 open (review round 1's R1-2 -- a close-after-drive mutation survived + # the count-only form). + test "AUTH-30: the original 401 is closed BEFORE the replay drives; the replay's is not" do first = unauthorized("Basic realm=r") - transport = SequencedTransport.new(first, ok) + closed_at_replay = nil + replay = lambda do |_request| + closed_at_replay = closes_of(first) + ok + end + transport = SequencedTransport.new(first, replay) response = dispatch(step(hook: ->(_c, request, _r) { request }), transport) + assert_equal(1, closed_at_replay) assert_equal(1, closes_of(first)) assert_equal(0, closes_of(response)) end diff --git a/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb b/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb index d429655..5d58c66 100644 --- a/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb @@ -3,40 +3,60 @@ require_relative "../../test_helper" require_relative "../../../lib/dexpace/auth/unencodable_credential_error" +require_relative "../../../lib/dexpace/each_cause" -# Exercises: AUTH-21 (R10, P6-1) -- the typed failure: phase 2's error shape under the Auth -# namespace, the field and the encoding as members and in the message, never the value, and -# the reason worded for the branch that raised (6c's P6-84). +# Exercises: AUTH-21 (R10, P6-1), AUTH-8 -- the typed failure: phase 2's error shape under the +# Auth namespace, the field, the target encoding and the value's own encoding as members and in +# the message, never the value, the reason worded for the branch that raised (6c's P6-84), and +# no cause at all (6c's P6-85). class DexpaceAuthUnencodableCredentialErrorTest < DexpaceTestCase Error = Dexpace::Auth::UnencodableCredentialError test "phase 2's shape, namespaced under Auth" do - error = Error.new(field: :password, encoding: "ISO-8859-1") + error = Error.new(field: :password, encoding: "ISO-8859-1", source_encoding: "UTF-8") assert_kind_of(StandardError, error) assert_kind_of(Dexpace::Error, error) assert_equal(:password, error.field) assert_equal("ISO-8859-1", error.encoding) + assert_equal("UTF-8", error.source_encoding) end - test "the message names the field and the encoding and says why the encoding applied" do - message = Error.new(field: :username, encoding: "ISO-8859-1").message + test "the message names the field and both encodings and says why the target applied" do + message = Error.new(field: :username, encoding: "ISO-8859-1", source_encoding: "UTF-8").message assert_includes(message, "username") - assert_includes(message, "ISO-8859-1") + assert_includes(message, "cannot be encoded as ISO-8859-1 from UTF-8") assert_includes(message, "did not advertise charset=UTF-8") assert_includes(message, "AUTH-21") end + # The rescued conversion error named a character of the secret and #full_message renders a + # cause (review round 1's R1-3): the type takes no cause and the handler raises it with none. + test "AUTH-8 (P6-85): the error is raised with no cause, so #full_message shows only itself" do + error = assert_raises(Error) do + raise "in flight" + rescue StandardError + raise Error.new(field: :password, encoding: "UTF-8", source_encoding: "ASCII-8BIT"), + cause: nil + end + + assert_nil(error.cause) + assert_equal([error], Dexpace.each_cause(error).to_a) + refute_includes(error.full_message(highlight: false), "in flight") + end + # The reason is the target's own (review round 0's R0-3): the UTF-8 branch must not blame # the challenge for a byte the caller supplied. test "the UTF-8 branch's message says the challenge advertised it, never that it did not" do - message = Error.new(field: :password, encoding: "UTF-8").message + message = Error.new(field: :password, encoding: "UTF-8", source_encoding: "ASCII-8BIT").message - assert_includes(message, "password cannot be encoded as UTF-8") + assert_includes(message, "password cannot be encoded as UTF-8 from ASCII-8BIT") assert_includes(message, "advertised charset=UTF-8") refute_includes(message, "did not advertise") refute_includes(message, "ISO-8859-1") - assert_includes(Error.new(field: :realm, encoding: "UTF-16").message, "UTF-16") + other = Error.new(field: :realm, encoding: "UTF-16", source_encoding: "UTF-8").message + + assert_includes(other, "UTF-16") end end From 8df49e62a43670e7935afd5dab32b31af7ce6047 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 19:45:20 +0300 Subject: [PATCH 11/12] test: pin cancellation isolation, the #update primitive, exact eviction Review round 2's R2-1, R2-2 and R2-3. async_bearer_stamper_test.rb gains a CancellationTest: cancelling one of three coalesced waiters (a #stamp_fresh one among them) leaves the others pending, the provider's fetch unsettled and a new arrival coalescing onto the live slot with one fetch in all, and once the provider settles the survivors stamp the token, the cache holds it and the cancelled one carries its own reason; a provider cancelling its own fetch cancels every waiter AS a cancellation with the provider's reason, caches nothing and frees the slot for a retry; a token the outbound header grammar refuses fails that waiter alone and never raises into the settling thread. async_step_test.rb gains the same isolation through a real async pipeline: one cancelled request, the other two drive with the fresh token. Round 2's tree, the Future#then derivation, a completer wired back to the slot, a cancellation forwarded as a failure, the rescue dropped and a slot left set all run red on 4.0.6 and 3.2.11. digest_handler_test.rb pins the handler's increment to BoundedMap#update deterministically: the store's #[], #set and #put are narrowed in place to raise (the handler is frozen, so the store is not replaced), #update records its key, and two counts on one nonce read 00000001 and 00000002 through two recorded updates. The reviewer's read-then-set counter, which the sixteen-thread race never observed under the GVL, now raises on both interpreters. async_bearer_stamper_test.rb's AUTH-36 case gains the sync suite's exactness pins -- a doubled space, a superstring and the bare token do not evict -- so a substring comparison on the async half runs red as it already did on the sync one. --- .../dexpace/auth/async_bearer_stamper_test.rb | 70 ++++++++++++++++++- .../test/dexpace/auth/async_step_test.rb | 37 +++++++++- .../test/dexpace/auth/digest_handler_test.rb | 26 ++++++- 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb index 7372169..19849a2 100644 --- a/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb @@ -13,7 +13,8 @@ # policy over phase 2's pivot: no #value or #wait anywhere on the stamper's own path, the # expiring zone stamping at once while a refresh it never awaits runs, the expired zone deriving # from one coalesced fetch, a failed background refresh logged and not fatal, the re-entrancy -# trap an already-settled provider future sets, and #stamp_fresh after an eviction. +# trap an already-settled provider future sets, #stamp_fresh after an eviction, and -- the fetch +# being shared -- a cancellation that is not: cancelling one waiter detaches that waiter alone. # # Every wait in this file is on a future the test itself settles, or on one already settled; # a hang here would be a finding, and FakeClock never advances by itself. Split under @@ -217,6 +218,70 @@ class FailureTest < DexpaceTestCase end end + # Review round 2's R2-1: the fetch is shared, a cancellation is not. Through Future#then the + # waiter's cancellation reached the single-flight slot every coalesced caller shares, so one + # request giving up cancelled every other waiter and every arrival until the provider settled. + class CancellationTest < DexpaceTestCase + include Fixtures + + test "AUTH-37, SEAM-18: cancelling one coalesced waiter detaches that waiter alone" do + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future) + subject = stamper(provider) + first = subject.stamp(https_request) + second = subject.stamp(https_request) + fresh = subject.stamp_fresh(https_request) + first.cancel(:caller_gave_up) + fresh.cancel(:caller_gave_up) + + assert_predicate(first, :cancelled?) + assert_predicate(fresh, :cancelled?) + refute_predicate(second, :settled?) # B never asked to be cancelled + refute_predicate(completer.future, :settled?) # the provider's fetch runs on + third = subject.stamp(https_request) # a new arrival still coalesces, onto a live slot + + refute_predicate(third, :settled?) + assert_equal(1, provider.fetches) + completer.fulfil(fresh_token) + + assert_equal(["Bearer fresh"], authorization(second.value)) + assert_equal(["Bearer fresh"], authorization(third.value)) + assert_equal(:caller_gave_up, assert_raises(Dexpace::CancelledError) { first.value }.reason) + assert_nil(subject.instance_variable_get(:@in_flight)) + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) # cached + assert_equal(1, provider.fetches) + end + + test "SEAM-18: a provider cancelling its own fetch cancels every waiter, as a cancellation" do + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future, settled_with(fresh_token)) + subject = stamper(provider) + waiters = Array.new(2) { subject.stamp(https_request) } + completer.future.cancel(:provider_timeout) + + waiters.each do |waiter| + assert_predicate(waiter, :cancelled?) + error = assert_raises(Dexpace::CancelledError) { waiter.value } + + assert_equal(:provider_timeout, error.reason) + end + assert_nil(subject.instance_variable_get(:@token)) # a cancelled fetch caches nothing + assert_nil(subject.instance_variable_get(:@in_flight)) # and the slot is free again + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) + assert_equal(2, provider.fetches) + end + + test "a token the outbound header grammar refuses fails that waiter, never the settler" do + completer = Completer.new + subject = stamper(ScriptedAsyncBearerProvider.new(completer.future)) + waiter = subject.stamp(https_request) + completer.fulfil(BearerToken.build(token: "bad\r\ntoken")) # HTTP-18 refuses it at the stamp + + assert_predicate(waiter, :settled?) + assert_raises(Dexpace::InvalidArgumentError) { waiter.value } + end + end + # AUTH-36's async half, AUTH-37's post-eviction clause, and the construction checks. class EvictionTest < DexpaceTestCase include Fixtures @@ -235,6 +300,9 @@ class EvictionTest < DexpaceTestCase subject = seeded(no_fetch, fresh_token("cur")) refute(subject.evict_if_matches("Bearer stale")) + refute(subject.evict_if_matches("Bearer cur")) # the exact value, as the sync half pins + refute(subject.evict_if_matches("Bearer curator")) # a superstring is not the token sent + refute(subject.evict_if_matches("cur")) assert_equal(["Bearer cur"], authorization(subject.stamp(https_request).value)) assert(subject.evict_if_matches("Bearer cur")) refute(subject.evict_if_matches("Bearer cur")) diff --git a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb index e0c4e19..4775d35 100644 --- a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb @@ -19,8 +19,9 @@ # fresh fetch after an eviction and reusing a preserved token otherwise -- the routing itself # through a stamper double whose #stamp and #stamp_fresh differ on the wire (review round 0's # R0-1), since the real stamper fetches either way once its cache is empty -- AUTH-31's gate -# through the inherited predicate, AUTH-32's three clauses, and cancellation forwarded both -# ways. Every #value here is on a future the test settles or one already settled. Split under +# through the inherited predicate, AUTH-32's three clauses, cancellation forwarded both ways, +# and one request's cancellation reaching no other request coalesced on the same bearer fetch. +# Every #value here is on a future the test settles or one already settled. Split under # Metrics/ClassLength. class DexpaceAuthAsyncStepTest < DexpaceTestCase AsyncStep = Dexpace::Auth::AsyncStep @@ -418,4 +419,36 @@ class BearerTest < DexpaceTestCase assert_equal([:stamp, [:evict_if_matches, "Bearer cached"], :stamp], stamper.calls) end end + + # Review round 2's R2-1 through the pipeline: the step forwards its future's cancellation to + # the stamp future (P6-79), and that must stop at the one request's waiter, never reach the + # single-flight fetch the other requests share. + class CoalescingTest < DexpaceTestCase + include Fixtures + + test "AUTH-37, SEAM-18: cancelling one request's future leaves the coalesced others driving" do + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future) + step = async_step(stamper: async_bearer_over(provider)) + transport = settled(ok, ok) + pipeline = async_auth_pipeline(step, transport) + first = pipeline.call(https_request) + second = pipeline.call(https_request) + first.cancel(:caller_gave_up) + + assert_predicate(first, :cancelled?) + refute_predicate(second, :settled?) + refute_predicate(completer.future, :settled?) + assert_empty(transport.calls) + third = pipeline.call(https_request) # arrives during the fetch, after the cancellation + + assert_equal(1, provider.fetches) + completer.fulfil(Dexpace::Auth::BearerToken.build(token: "fresh")) + + assert_equal(200, second.value.status.code) + assert_equal(200, third.value.status.code) + assert_equal(["Bearer fresh", "Bearer fresh"], transport.authorization_headers) + assert_equal(:caller_gave_up, assert_raises(Dexpace::CancelledError) { first.value }.reason) + end + end end diff --git a/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb index 63a9a43..966ea2b 100644 --- a/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb @@ -264,8 +264,30 @@ class CounterTest < DexpaceTestCase end # The deterministic proof that the increment is one critical section is - # bounded_map_test.rb's forced interleaving; this asserts the handler-level property it - # buys -- sixteen threads reusing one nonce produce sixteen hundred distinct counts. + # bounded_map_test.rb's forced interleaving, and the pin below is what ties the handler to + # it: the store answers #update alone, so a read through #[] followed by #set -- two + # critical sections, the lost-increment shape -- raises rather than surviving the race + # under the GVL (review round 2's R2-2). The handler is frozen, so the store is reached + # and narrowed in place, not replaced. + test "AUTH-24: the increment is one BoundedMap#update, never a read through #[] then #set" do + digest_handler = handler + store = digest_handler.instance_variable_get(:@nonces) + updates = [] + increment = store.method(:update) + store.define_singleton_method(:update) do |key, &block| + updates << key + increment.call(key, &block) + end + %i[[] set put].each do |bypass| + store.define_singleton_method(bypass) { |*| raise "the counter bypassed #update (AUTH-24)" } + end + + assert_equal(%w[00000001 00000002], [nc_of(digest_handler, "n"), nc_of(digest_handler, "n")]) + assert_equal(%w[n n], updates) + end + + # The handler-level property the pin above buys, seen end to end: sixteen threads reusing + # one nonce produce sixteen hundred distinct counts. test "AUTH-24: sixteen threads reusing one nonce yield correct, non-duplicated counts" do digest_handler = handler barrier = ::Thread::Queue.new From a870f7e7d1dedf1fc662393c28c4cceadb10b7cb Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Fri, 18 Sep 2026 21:38:15 +0300 Subject: [PATCH 12/12] test: pin the fourth bearer rejection, uncached on both stampers Review round 3's R3-1. A provider token the outbound header grammar refuses -- a trailing newline, a CR, a non-ASCII byte -- is now AUTH-35's fourth rejection, and the suites pin it where the round found it unobserved: bearer_stamper_test.rb (split into CacheTest, RejectionTest and EvictionTest under Metrics/ClassLength) asserts three such tokens each raise ProviderError with @token nil and no name of the token in the message, then the next call fetches the clean one; async_bearer_stamper_test.rb FailureTest asserts two waiters on one fetch both fail with it, nothing is cached, the slot is free, and the next #stamp returns a future that stamps the clean token -- never a synchronous raise -- and that an unusable BACKGROUND refresh is logged as http.auth.refresh without naming the token and caches nothing, the still-valid token stamped meanwhile. step_bearer_challenge_test.rb and async_step_test.rb carry the same through a real pipeline: one dispatch fails, the transport is never reached, the next two are 200 with one refetch. Round 3's CancellationTest case fed exactly such a token to prove #deliver's rescue; the fourth rejection now pre-empts it before the stamp, so the case becomes a request whose own derivation refuses, which is the raise left for the rescue to keep off the settling thread. Eight mutations run red on 4.0.6 and 3.2.11: the grammar check dropped from either stamper, the sync token cached before validation, the rescue dropped, either message naming the token, the async write made unconditional, and the refusal raised out of the settle block. --- .../dexpace/auth/async_bearer_stamper_test.rb | 82 +++++- .../test/dexpace/auth/async_step_test.rb | 33 ++- .../test/dexpace/auth/bearer_stamper_test.rb | 237 ++++++++++-------- .../auth/step_bearer_challenge_test.rb | 18 +- 4 files changed, 254 insertions(+), 116 deletions(-) diff --git a/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb index 19849a2..9b74abd 100644 --- a/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb @@ -12,9 +12,11 @@ # Exercises: AUTH-37, AUTH-36 (async half), AUTH-11, AUTH-35 -- the three-zone async bearer # policy over phase 2's pivot: no #value or #wait anywhere on the stamper's own path, the # expiring zone stamping at once while a refresh it never awaits runs, the expired zone deriving -# from one coalesced fetch, a failed background refresh logged and not fatal, the re-entrancy -# trap an already-settled provider future sets, #stamp_fresh after an eviction, and -- the fetch -# being shared -- a cancellation that is not: cancelling one waiter detaches that waiter alone. +# from one coalesced fetch, a failed or unusable background refresh logged and not fatal, the +# four provider rejections uncached (the fourth, a token the outbound header grammar refuses, is +# review round 3's R3-1), the re-entrancy trap an already-settled provider future sets, +# #stamp_fresh after an eviction, and -- the fetch being shared -- a cancellation that is not: +# cancelling one waiter detaches that waiter alone. # # Every wait in this file is on a future the test itself settles, or on one already settled; # a hang here would be a finding, and FakeClock never advances by itself. Split under @@ -32,6 +34,16 @@ def synchronize end end + # A request whose own derivation refuses: the one raise left inside the waiter's delivery once + # every cached token has passed the grammar check (a forged or duck-typed request). + class RefusingRequest + def headers = Dexpace::Headers::EMPTY + + def with(**) + raise Dexpace::InvalidArgumentError, "this request refuses to derive" + end + end + # The stampers, tokens and futures the nested cases share. The clock reads 1000 and the # margin is 30, so a token expiring at 1030 or later is fresh, one expiring in (1000, 1030] # is expiring-but-valid, and one expiring at 1000 or earlier is expired. @@ -176,6 +188,54 @@ class FailureTest < DexpaceTestCase assert_equal(2, provider.fetches) end + # R3-1: the fourth rejection. Cached, a token the grammar refuses failed every later #stamp + # until it expired and raised out of the fresh zone rather than settle; nothing could evict it. + test "AUTH-35 async: a token the outbound header grammar refuses fails the waiters, uncached" do + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future, + settled_with(fresh_token("clean")),) + subject = stamper(provider) + waiters = Array.new(2) { subject.stamp(https_request) } + completer.fulfil(BearerToken.build(token: "abc\n")) # a token read off a file, newline kept + + waiters.each do |waiter| + assert_predicate(waiter, :settled?) + error = assert_raises(Dexpace::Auth::ProviderError) { waiter.value } + + refute_match(/abc|\n/, error.message) # the message never names the token + end + assert_nil(subject.instance_variable_get(:@token)) + refute(subject.evict_if_matches("Bearer abc")) # nothing cached, nothing to evict + assert_nil(subject.instance_variable_get(:@in_flight)) # the slot is free again + later = subject.stamp(https_request) # a future, never a synchronous raise + + assert_kind_of(Dexpace::Async::Future, later) + assert_equal(["Bearer clean"], authorization(later.value)) + assert_equal(2, provider.fetches) + end + + test "AUTH-37: an UNUSABLE background refresh (a refused token) is logged and not cached" do + sink = RecordingSink.new + completer = Completer.new + provider = ScriptedAsyncBearerProvider.new(completer.future, settled_with(fresh_token)) + subject = stamper(provider, logger: Dexpace::Instrumentation::Logger.build(sink: sink)) + subject.instance_variable_set(:@token, expiring_token) + + assert_equal(["Bearer still-valid"], authorization(subject.stamp(https_request).value)) + completer.fulfil(BearerToken.build(token: "bad\r\ntoken")) + entry = sink.entries.find { |candidate| candidate.payload["event"] == "http.auth.refresh" } + + refute_nil(entry, sink.entries.inspect) + assert_equal(:warn, entry.severity) + assert_includes(entry.payload["cause"].to_s, "HTTP-18") + refute_match(/bad|[\r\n]/, entry.payload["cause"].to_s) + assert_equal(expiring_token, subject.instance_variable_get(:@token)) # still the valid one + clock.advance(20) # expired now: the next stamp fetches again and succeeds + + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) + assert_equal(2, provider.fetches) + end + test "AUTH-35 on the async path: a nil, expired or non-token result fails the waiters" do provider = ScriptedAsyncBearerProvider.new(settled_with(expired_token), settled_with(Object.new), @@ -271,14 +331,20 @@ class CancellationTest < DexpaceTestCase assert_equal(2, provider.fetches) end - test "a token the outbound header grammar refuses fails that waiter, never the settler" do + # Until round 3 this was a token the grammar refuses; that is now AUTH-35's fourth rejection + # (FailureTest) and never reaches the stamp, so the raise left for #deliver's rescue to keep + # off the settling thread is the request's own. + test "a request whose derivation raises fails that waiter alone, never the settler" do completer = Completer.new subject = stamper(ScriptedAsyncBearerProvider.new(completer.future)) - waiter = subject.stamp(https_request) - completer.fulfil(BearerToken.build(token: "bad\r\ntoken")) # HTTP-18 refuses it at the stamp + refusing = subject.stamp(RefusingRequest.new) + sound = subject.stamp(https_request) + completer.fulfil(fresh_token) # settles on THIS thread: a raise in the delivery lands here - assert_predicate(waiter, :settled?) - assert_raises(Dexpace::InvalidArgumentError) { waiter.value } + assert_predicate(refusing, :settled?) + assert_raises(Dexpace::InvalidArgumentError) { refusing.value } + assert_equal(["Bearer fresh"], authorization(sound.value)) + assert_equal(["Bearer fresh"], authorization(subject.stamp(https_request).value)) # cached end end diff --git a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb index 4775d35..98273e6 100644 --- a/gems/dexpace-core/test/dexpace/auth/async_step_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb @@ -20,9 +20,10 @@ # through a stamper double whose #stamp and #stamp_fresh differ on the wire (review round 0's # R0-1), since the real stamper fetches either way once its cache is empty -- AUTH-31's gate # through the inherited predicate, AUTH-32's three clauses, cancellation forwarded both ways, -# and one request's cancellation reaching no other request coalesced on the same bearer fetch. -# Every #value here is on a future the test settles or one already settled. Split under -# Metrics/ClassLength. +# one request's cancellation reaching no other request coalesced on the same bearer fetch, and +# a provider token the header grammar refuses failing the requests that awaited it and no +# later one (review round 3's R3-1). Every #value here is on a future the test settles or one +# already settled. Split under Metrics/ClassLength. class DexpaceAuthAsyncStepTest < DexpaceTestCase AsyncStep = Dexpace::Auth::AsyncStep Step = Dexpace::Auth::Step @@ -420,12 +421,32 @@ class BearerTest < DexpaceTestCase end end - # Review round 2's R2-1 through the pipeline: the step forwards its future's cancellation to - # the stamp future (P6-79), and that must stop at the one request's waiter, never reach the - # single-flight fetch the other requests share. + # The single-flight fetch through the pipeline. Review round 2's R2-1: the step forwards its + # future's cancellation to the stamp future (P6-79), and that must stop at the one request's + # waiter, never reach the fetch the other requests share. Review round 3's R3-1: a fetch that + # lands a token the header grammar refuses fails the requests coalesced on it and is cached + # for none of the later ones. class CoalescingTest < DexpaceTestCase include Fixtures + test "AUTH-35 through the step: a refused token fails its waiters; the next one refetches" do + provider = ScriptedBearerProvider.new("abc\n", "clean") # once refused, then clean forever + step = async_step(stamper: async_bearer_over(provider)) + transport = settled(ok, ok) + pipeline = async_auth_pipeline(step, transport) + first = pipeline.call(https_request) + + assert_predicate(first, :settled?) + assert_raises(Dexpace::Auth::ProviderError) { first.value } + assert_empty(transport.calls) # the token could never be sent, and was not + second = pipeline.call(https_request) + third = pipeline.call(https_request) + + assert_equal([200, 200], [second.value.status.code, third.value.status.code]) + assert_equal(["Bearer clean", "Bearer clean"], transport.authorization_headers) + assert_equal(2, provider.fetches) # refetched once, then served from the cache + end + test "AUTH-37, SEAM-18: cancelling one request's future leaves the coalesced others driving" do completer = Completer.new provider = ScriptedAsyncBearerProvider.new(completer.future) diff --git a/gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb b/gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb index 05d6872..f14f6b1 100644 --- a/gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb @@ -9,11 +9,10 @@ # Exercises: AUTH-11 (sync half), AUTH-34, AUTH-35, AUTH-36 (the cache half) -- the sync bearer # stamper: the cached token stamped until the refresh margin, a lock-free hot path, at most one -# fetch under sixteen racing threads, the three provider rejections uncached, and the -# compare-and-clear eviction on the stamped header value. +# fetch under sixteen racing threads, the four provider rejections uncached (the fourth, a token +# the outbound header grammar refuses, is review round 3's R3-1), and the compare-and-clear +# eviction on the stamped header value. Split under Metrics/ClassLength. class DexpaceAuthBearerStamperTest < DexpaceTestCase - include AuthFixtures - BearerStamper = Dexpace::Auth::BearerStamper BearerToken = Dexpace::Auth::BearerToken @@ -24,128 +23,166 @@ def synchronize end end - def stamper(provider, clock: FakeClock.new, margin: 30) - BearerStamper.new(provider: provider, clock: clock, refresh_margin: margin) - end - - def authorization(request) = request.headers["Authorization"] + # The stamper and the reader the nested cases share. + module Fixtures + include AuthFixtures - test "AUTH-34: stamps Authorization: Bearer , SET rather than added" do - already = https_request(headers: Dexpace::Headers.builder.add("Authorization", "old").build) - subject = stamper(ScriptedBearerProvider.new("t1")) + def stamper(provider, clock: FakeClock.new, margin: 30) + BearerStamper.new(provider: provider, clock: clock, refresh_margin: margin) + end - assert_equal(["Bearer t1"], authorization(subject.call(https_request))) - assert_equal(["Bearer t1"], authorization(subject.call(already))) + def authorization(request) = request.headers["Authorization"] end - test "AUTH-34: the token is cached until the refresh margin before its expiry, 30 s by default" do - clock = FakeClock.new(now: Time.at(0)) - provider = ScriptedBearerProvider.new(BearerToken.build(token: "t1", expiry: Time.at(100)), - BearerToken.build(token: "t2", expiry: Time.at(300)),) - subject = stamper(provider, clock: clock) - subject.call(https_request) - clock.advance(69) # 69 + 30 = 99, not after 100: still cached - - assert_equal(["Bearer t1"], authorization(subject.call(https_request))) - assert_equal(1, provider.fetches) - clock.advance(2) # 71 + 30 = 101: refreshed - - assert_equal(["Bearer t2"], authorization(subject.call(https_request))) - assert_equal(2, provider.fetches) - assert_equal(30, BearerStamper::DEFAULT_REFRESH_MARGIN) - end + # AUTH-34: the stamp, the cache and its margin, the lock-free hot path, single flight. + class CacheTest < DexpaceTestCase + include Fixtures - test "AUTH-34, XCUT-12: the hot-path read of a valid cached token takes no lock" do - subject = stamper(ScriptedBearerProvider.new("t1")) - subject.call(https_request) - subject.instance_variable_set(:@lock, RefusingMutex.new) + test "AUTH-34: stamps Authorization: Bearer , SET rather than added" do + already = https_request(headers: Dexpace::Headers.builder.add("Authorization", "old").build) + subject = stamper(ScriptedBearerProvider.new("t1")) - assert_equal(["Bearer t1"], authorization(subject.call(https_request))) - end + assert_equal(["Bearer t1"], authorization(subject.call(https_request))) + assert_equal(["Bearer t1"], authorization(subject.call(already))) + end - # Deterministic: the one fetch parks until all sixteen threads have entered #call, so every - # other thread is racing on the missing token while it is in flight. - test "AUTH-34: sixteen threads racing on a missing token cause exactly one fetch" do - arrived = ::Thread::Queue.new - provider = ScriptedBearerProvider.new("t1").before_fetch do - Thread.pass until arrived.size == 16 + test "AUTH-34: the token is cached until the refresh margin before expiry, 30 s by default" do + clock = FakeClock.new(now: Time.at(0)) + provider = ScriptedBearerProvider.new(BearerToken.build(token: "t1", expiry: Time.at(100)), + BearerToken.build(token: "t2", expiry: Time.at(300)),) + subject = stamper(provider, clock: clock) + subject.call(https_request) + clock.advance(69) # 69 + 30 = 99, not after 100: still cached + + assert_equal(["Bearer t1"], authorization(subject.call(https_request))) + assert_equal(1, provider.fetches) + clock.advance(2) # 71 + 30 = 101: refreshed + + assert_equal(["Bearer t2"], authorization(subject.call(https_request))) + assert_equal(2, provider.fetches) + assert_equal(30, BearerStamper::DEFAULT_REFRESH_MARGIN) end - subject = stamper(provider) - threads = Array.new(16) do - Thread.new do - arrived << true - authorization(subject.call(https_request)) - end + + test "AUTH-34, XCUT-12: the hot-path read of a valid cached token takes no lock" do + subject = stamper(ScriptedBearerProvider.new("t1")) + subject.call(https_request) + subject.instance_variable_set(:@lock, RefusingMutex.new) + + assert_equal(["Bearer t1"], authorization(subject.call(https_request))) end - assert_equal([["Bearer t1"]] * 16, threads.map(&:value)) - assert_equal(1, provider.fetches) + # Deterministic: the one fetch parks until all sixteen threads have entered #call, so every + # other thread is racing on the missing token while it is in flight. + test "AUTH-34: sixteen threads racing on a missing token cause exactly one fetch" do + arrived = ::Thread::Queue.new + provider = ScriptedBearerProvider.new("t1").before_fetch do + Thread.pass until arrived.size == 16 + end + subject = stamper(provider) + threads = Array.new(16) do + Thread.new do + arrived << true + authorization(subject.call(https_request)) + end + end + + assert_equal([["Bearer t1"]] * 16, threads.map(&:value)) + assert_equal(1, provider.fetches) + end end - test "AUTH-35: a nil token surfaces as ProviderError and is not cached" do - provider = ScriptedBearerProvider.new(-> {}, "t2") - subject = stamper(provider) + # AUTH-35: the four provider rejections and a raising provider, none of them cached. + class RejectionTest < DexpaceTestCase + include Fixtures - assert_raises(Dexpace::Auth::ProviderError) { subject.call(https_request) } - assert_equal(["Bearer t2"], authorization(subject.call(https_request))) - assert_equal(2, provider.fetches) - end + test "AUTH-35: a nil token surfaces as ProviderError and is not cached" do + provider = ScriptedBearerProvider.new(-> {}, "t2") + subject = stamper(provider) - test "AUTH-35: a token already expired at fetch time, evaluated with NO margin, is an error" do - clock = FakeClock.new(now: Time.at(100)) - provider = ScriptedBearerProvider.new(BearerToken.build(token: "old", expiry: Time.at(99)), - BearerToken.build(token: "edge", expiry: Time.at(100)),) - subject = stamper(provider, clock: clock) + assert_raises(Dexpace::Auth::ProviderError) { subject.call(https_request) } + assert_equal(["Bearer t2"], authorization(subject.call(https_request))) + assert_equal(2, provider.fetches) + end - assert_raises(Dexpace::Auth::ProviderError) { subject.call(https_request) } - # expiry == now is NOT expired with no margin (strictly after), so it is accepted, then - # the margin makes it a refresh candidate on the next call. - assert_equal(["Bearer edge"], authorization(subject.call(https_request))) - end + test "AUTH-35: a token already expired at fetch time, evaluated with NO margin, is an error" do + clock = FakeClock.new(now: Time.at(100)) + provider = ScriptedBearerProvider.new(BearerToken.build(token: "old", expiry: Time.at(99)), + BearerToken.build(token: "edge", expiry: Time.at(100)),) + subject = stamper(provider, clock: clock) - test "AUTH-35: something that is not a BearerToken is an error" do - assert_raises(Dexpace::Auth::ProviderError) do - stamper(ScriptedBearerProvider.new(-> { Object.new })).call(https_request) + assert_raises(Dexpace::Auth::ProviderError) { subject.call(https_request) } + # expiry == now is NOT expired with no margin (strictly after), so it is accepted, then + # the margin makes it a refresh candidate on the next call. + assert_equal(["Bearer edge"], authorization(subject.call(https_request))) end - end - test "AUTH-35, AUTH-11: a raising provider propagates its own error, uncached; next retries" do - provider = ScriptedBearerProvider.new(RuntimeError.new("boom"), "t2") - subject = stamper(provider) + test "AUTH-35: something that is not a BearerToken is an error" do + assert_raises(Dexpace::Auth::ProviderError) do + stamper(ScriptedBearerProvider.new(-> { Object.new })).call(https_request) + end + end - error = assert_raises(RuntimeError) { subject.call(https_request) } + # R3-1: a token read off a file with its newline, a CR, a non-ASCII byte -- none can ever be + # sent, so no 401 could ever evict one (AUTH-36); cached, it would fail every call until it + # expired, which for a token with no expiry is never. + test "AUTH-35: a token the outbound header grammar refuses is an error, uncached; refetched" do + provider = ScriptedBearerProvider.new("abc\n", "bad\r\ntoken", "t\u00f6ken", "clean") + subject = stamper(provider) - assert_equal("boom", error.message) - assert_equal(["Bearer t2"], authorization(subject.call(https_request))) - end + 3.times do + error = assert_raises(Dexpace::Auth::ProviderError) { subject.call(https_request) } - test "AUTH-36: eviction clears only the exact rejected header value, and the next call fetches" do - provider = ScriptedBearerProvider.new("old", "new") - subject = stamper(provider) - subject.call(https_request) + refute_match(/abc|bad|\u00f6|[\r\n]/, error.message) # the message never names the token + assert_nil(subject.instance_variable_get(:@token)) + end + refute(subject.evict_if_matches("Bearer abc")) # nothing cached, nothing to evict + assert_equal(["Bearer clean"], authorization(subject.call(https_request))) + assert_equal(4, provider.fetches) + end - assert(subject.evict_if_matches("Bearer old")) - assert_equal(["Bearer new"], authorization(subject.call(https_request))) - assert_equal(2, provider.fetches) - end + test "AUTH-35, AUTH-11: a raising provider propagates its own error, uncached; next retries" do + provider = ScriptedBearerProvider.new(RuntimeError.new("boom"), "t2") + subject = stamper(provider) - test "AUTH-36: a token another request already refreshed does not match and is preserved" do - provider = ScriptedBearerProvider.new("current") - subject = stamper(provider) - subject.call(https_request) + error = assert_raises(RuntimeError) { subject.call(https_request) } - refute(subject.evict_if_matches("Bearer stale")) - refute(subject.evict_if_matches("Bearer current")) # matched on the exact header value - assert_equal(["Bearer current"], authorization(subject.call(https_request))) - assert_equal(1, provider.fetches) - refute(stamper(provider).evict_if_matches("Bearer current")) # nothing cached yet + assert_equal("boom", error.message) + assert_equal(["Bearer t2"], authorization(subject.call(https_request))) + end end - test "the provider must answer #fetch and the margin must be a non-negative number" do - assert_raises(Dexpace::InvalidArgumentError) { stamper(Object.new) } - provider = ScriptedBearerProvider.new("t") + # AUTH-36's cache half, and the construction checks. + class EvictionTest < DexpaceTestCase + include Fixtures - assert_raises(Dexpace::InvalidArgumentError) { stamper(provider, margin: -1) } - assert_raises(Dexpace::InvalidArgumentError) { stamper(provider, margin: "30") } + test "AUTH-36: eviction clears only the exact rejected header value; the next call fetches" do + provider = ScriptedBearerProvider.new("old", "new") + subject = stamper(provider) + subject.call(https_request) + + assert(subject.evict_if_matches("Bearer old")) + assert_equal(["Bearer new"], authorization(subject.call(https_request))) + assert_equal(2, provider.fetches) + end + + test "AUTH-36: a token another request already refreshed does not match and is preserved" do + provider = ScriptedBearerProvider.new("current") + subject = stamper(provider) + subject.call(https_request) + + refute(subject.evict_if_matches("Bearer stale")) + refute(subject.evict_if_matches("Bearer current")) # matched on the exact header value + assert_equal(["Bearer current"], authorization(subject.call(https_request))) + assert_equal(1, provider.fetches) + refute(stamper(provider).evict_if_matches("Bearer current")) # nothing cached yet + end + + test "the provider must answer #fetch and the margin must be a non-negative number" do + assert_raises(Dexpace::InvalidArgumentError) { stamper(Object.new) } + provider = ScriptedBearerProvider.new("t") + + assert_raises(Dexpace::InvalidArgumentError) { stamper(provider, margin: -1) } + assert_raises(Dexpace::InvalidArgumentError) { stamper(provider, margin: "30") } + end end end diff --git a/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb b/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb index e199453..6e43bfc 100644 --- a/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb +++ b/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb @@ -14,8 +14,9 @@ # fetch, regardless of method; a token another request refreshed is preserved and reused; no # Authorization on the rejected request, or no Bearer challenge, surfaces the 401 unchanged; a # non-replayable body skips the retry and leaves the 401 unclosed; the branch runs before the -# challenge hook. No lib/ mirror: a second suite over step.rb, like 5b's downstream_wirings. -# Split under Metrics/ClassLength. +# challenge hook; a provider token the header grammar refuses fails one dispatch and no later +# one (review round 3's R3-1). No lib/ mirror: a second suite over step.rb, like 5b's +# downstream_wirings. Split under Metrics/ClassLength. class DexpaceAuthStepBearerChallengeTest < DexpaceTestCase Step = Dexpace::Auth::Step STAGES = Dexpace::Pipeline::Stages @@ -110,6 +111,19 @@ class RetryTest < DexpaceTestCase assert_raises(RuntimeError) { dispatch(step, transport) } assert_equal(1, closes_of(first)) end + + test "AUTH-35 through the step: a refused token fails one dispatch; the next fetches again" do + transport = SequencedTransport.new(ok, ok) + prov = provider("abc\n", "clean") # once refused, then clean forever + step = bearer_step(prov) + + assert_raises(Dexpace::Auth::ProviderError) { dispatch(step, transport) } + assert_empty(transport.calls) # the token could never be sent, and was not + assert_equal(200, dispatch(step, transport).status.code) + assert_equal(200, dispatch(step, transport).status.code) + assert_equal(["Bearer clean", "Bearer clean"], transport.authorization_headers) + assert_equal(2, prov.fetches) # refetched once, then served from the cache + end end # The four ways the branch surfaces the 401 unchanged, and its place before the hook.