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..b00ac1d --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb @@ -0,0 +1,253 @@ +# 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 "../http/header_syntax" +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" +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 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 -- 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 + # 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 + # 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. + # + # #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 # 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 + # @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: 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) + 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 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 + own.fail(error) + 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, #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 + error = settlement.error || invalid(token) + @lock.synchronize do + @in_flight = nil + @token = token if error.nil? + end + 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 + + # 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 + 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 no outbound header value may carry " \ + "(HTTP-18)") + 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..ecd8f77 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/async_step.rb @@ -0,0 +1,220 @@ +# 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) { |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 (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 + + # 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); + # 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..2af28ad --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/basic_handler.rb @@ -0,0 +1,108 @@ +# 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). + # + # 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), a + # colon in the username, or a field that is not text UTF-8 can carry + def initialize(credential) + credential!(credential) + pair = "#{utf8!(credential.username, :username)}:#{utf8!(credential.password, :password)}" + @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 + + 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/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..40d8a0c --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/bearer_stamper.rb @@ -0,0 +1,127 @@ +# 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 "../http/header_syntax" +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, 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 + + # @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, 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) + 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 + 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 + 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..a92f2fa --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/challenges.rb @@ -0,0 +1,215 @@ +# 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 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 + # 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).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).freeze + # One or more list separators with their whitespace: what sits between two elements. + 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).freeze + # A parameter's `=` with the bad whitespace RFC 7235 tolerates on either side. + 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).freeze + # Optional whitespace at the start of an element, which recovery leaves behind. + OWS = Regexp.new("[ \\t]*", timeout: 1.0).freeze + # The opening quote of a quoted-string. + 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 + # 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..37669ff --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/digest_handler.rb @@ -0,0 +1,315 @@ +# 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. + # 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, ha1_parts)) + end + + # AUTH-17: HA1, HA2 over the method and the request-target, then the response. + def response_for(computed, method, ha1_parts) + hasher = HASHES.fetch(computed.algorithm.delete_suffix("-sess")) + ha1 = ha1_for(computed, hasher, ha1_parts) + 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) 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, computed.challenge.params.fetch("nonce"), computed.cnonce)) + end + + # 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" + 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 + # 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). 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 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. + 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..51c6697 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/provider_error.rb @@ -0,0 +1,20 @@ +# 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, 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 + 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..de7f371 --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/step.rb @@ -0,0 +1,211 @@ +# 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. + def consult(challenge, stamped, response) + 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 + 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..0b8f6dc --- /dev/null +++ b/gems/dexpace-core/lib/dexpace/auth/unencodable_credential_error.rb @@ -0,0 +1,67 @@ +# 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 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, 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. + 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, 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 + private_constant :REASONS + + # @return [Symbol] :username, :realm or :password + attr_reader :field + # @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] + # @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} from #{source_encoding}: #{reason} " \ + "(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..1131a75 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/async_bearer_stamper.rbs @@ -0,0 +1,37 @@ +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 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 + 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..4dfd529 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/async_step.rbs @@ -0,0 +1,42 @@ +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 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 +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..2bdf721 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/basic_handler.rbs @@ -0,0 +1,21 @@ +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? + + 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/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..7f9224c --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/digest_handler.rbs @@ -0,0 +1,74 @@ +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, Array[String] ha1_parts) -> String + def response_digest: (_Hasher hasher, Computed computed, String ha1, String ha2) -> 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, 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 + 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..24bd214 --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/step.rbs @@ -0,0 +1,50 @@ +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 closing_on_error: [T] (Dexpace::Response response) { () -> T } -> T + 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..e255bdb --- /dev/null +++ b/gems/dexpace-core/sig/dexpace/auth/unencodable_credential_error.rbs @@ -0,0 +1,17 @@ +module Dexpace + module Auth + # 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 + + REASONS: Hash[String, String] + + attr_reader field: Symbol + attr_reader encoding: String + attr_reader source_encoding: String + + def initialize: (field: Symbol, encoding: String, source_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/auth/async_bearer_stamper_test.rb b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb new file mode 100644 index 0000000..9b74abd --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/async_bearer_stamper_test.rb @@ -0,0 +1,389 @@ +# 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 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 +# 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 + + # 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. + 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 + + # 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), + settled_with(fresh_token),) + 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 + # 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 + + # 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 + + # 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)) + 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(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 + + # 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")) + 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")) + 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..98273e6 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/async_step_test.rb @@ -0,0 +1,475 @@ +# 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/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 -- 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, cancellation forwarded both ways, +# 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 + 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 + 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 + 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 + + # 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) + 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") + 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 + 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 + + # 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 + + # 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) + 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/basic_handler_test.rb b/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb new file mode 100644 index 0000000..a1dab4a --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/basic_handler_test.rb @@ -0,0 +1,113 @@ +# 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, 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 + + 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 + + # 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__)) + + 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..f14f6b1 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/bearer_stamper_test.rb @@ -0,0 +1,188 @@ +# 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 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 + 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 + + # The stamper and the reader the nested cases share. + module Fixtures + include AuthFixtures + + def stamper(provider, clock: FakeClock.new, margin: 30) + BearerStamper.new(provider: provider, clock: clock, refresh_margin: margin) + end + + def authorization(request) = request.headers["Authorization"] + 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: 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 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 + end + + # AUTH-35: the four provider rejections and a raising provider, none of them cached. + class RejectionTest < DexpaceTestCase + include Fixtures + + 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 + + # 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) + + 3.times do + error = assert_raises(Dexpace::Auth::ProviderError) { 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 + + 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 + end + + # AUTH-36's cache half, and the construction checks. + class EvictionTest < DexpaceTestCase + include Fixtures + + 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/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..2638a52 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/challenges_test.rb @@ -0,0 +1,173 @@ +# 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, 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 + + # The one helper both cases share. + module Fixtures + def shapes(value) + Challenges.parse(value).map { |c| [c.scheme, c.params] } + end + 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}") + + 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 "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 + + # AUTH-13: leniency, the bounded-time measurement and the per-pattern timeout pin. + class LeniencyTest < DexpaceTestCase + include Fixtures + + 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: 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, earlier params kept" 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 + + # 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/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..966ea2b --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/digest_handler_test.rb @@ -0,0 +1,513 @@ +# 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, 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 + 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 + + # 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. + 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_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) + end + + 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", "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)) + error = assert_raises(Dexpace::Auth::UnencodableCredentialError) do + answer(handler(invalid), digest(charset: "utf-8")) + end + + 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)) + + refute_nil(answer(handler(latin1), digest(charset: "UTF-8"))) # transcoded, not refused + 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..6e43bfc --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/step_bearer_challenge_test.rb @@ -0,0 +1,200 @@ +# 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; 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 + + # 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 + 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, closed_at_retry) # the superseded 401 is closed BEFORE the retry drives + 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 + + 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. + 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..83443ae --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/step_test.rb @@ -0,0 +1,389 @@ +# 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 + + # "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") + 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 + + 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..5d58c66 --- /dev/null +++ b/gems/dexpace-core/test/dexpace/auth/unencodable_credential_error_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "../../test_helper" +require_relative "../../../lib/dexpace/auth/unencodable_credential_error" +require_relative "../../../lib/dexpace/each_cause" + +# 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", 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 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, "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", source_encoding: "ASCII-8BIT").message + + 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") + other = Error.new(field: :realm, encoding: "UTF-16", source_encoding: "UTF-8").message + + assert_includes(other, "UTF-16") + 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/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..0b9aaf7 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 @@ -172,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)) @@ -217,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). 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_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 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 diff --git a/test/fixtures/surface/dexpace-core.txt b/test/fixtures/surface/dexpace-core.txt index a5d8b0f..03b277c 100644 --- a/test/fixtures/surface/dexpace-core.txt +++ b/test/fixtures/surface/dexpace-core.txt @@ -50,6 +50,109 @@ 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::Auth::UnencodableCredentialError#source_encoding +Dexpace::AuthResolutionError +Dexpace::AuthResolutionError#available +Dexpace::AuthResolutionError#required Dexpace::Body Dexpace::Body#== Dexpace::Body#close @@ -369,6 +472,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