Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions gems/dexpace-core/lib/dexpace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions gems/dexpace-core/lib/dexpace/auth.rb
Original file line number Diff line number Diff line change
@@ -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
253 changes: 253 additions & 0 deletions gems/dexpace-core/lib/dexpace/auth/async_bearer_stamper.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading