Repository navigation
Phase 6c: authentication — the authentication layer - #75
Merged
Merged
Conversation
Phase 6c, cut from main at f1fe848 with nothing of phase 6a present, so the steps take their own logger: keyword and AUTH-31 calls phase 3b's Body#replayable? directly. Twenty-five new lib files: auth.rb, the flat AuthResolutionError under error/, and twenty-three under auth/ -- the closed five-member Scheme set with ALL and .of, Requirement, Descriptor and the pure three-tier Resolver (AUTH-1..7); BearerToken, KeyCredential, NamedKeyCredential and PasswordCredential, each redacting in #to_s, #inspect and, for the two Data types, #pretty_print, because pp walks a Data's members and never calls an #inspect override (AUTH-8..10); the never-raising RFC 7235 list parser Challenges.parse over Challenge, the one fold point (AUTH-12, AUTH-13); BasicHandler over pack("m0") and never Base64 (AUTH-14); the challenge-only DigestHandler with MD5, MD5-sess, SHA-256 and SHA-256-sess, qop=auth or legacy, a per-nonce counter in its own BoundedMap and a typed failure for a credential ISO-8859-1 cannot carry (AUTH-15..22, AUTH-24); ChallengeHandlerChain with its proxy-aware header name and the hook adapter (AUTH-23, AUTH-25); the stateless KeyStamper (AUTH-26); BearerStamper with a lock-free hot path and XCUT-12's sanctioned lock-across-fetch (AUTH-34..36); AsyncBearerStamper with AUTH-37's three zones and one single-flight slot; BearerProvider.fetch_async as AUTH-11's never-raising default; and the AUTH pillar Step and AsyncStep at Stages::AUTH, built through .build(stamper:, challenge_hook:, logger:), forking for every drive, reading the cross-origin marker off the cursor and never a header, guarding HTTPS before any fetch or write, replaying a 401 at most once behind the replayability gate and closing the superseded response (AUTH-27..33, AUTH-38). Three earlier files widen in place: BoundedMap gains #update(key), the read-yield-write the nonce counter needs; Instrumentation::Events gains AUTH_REFRESH, the ninth event; lib/dexpace.rb gains the twenty-five-line Phase 6c block. Every file has a sig/ mirror; the strict Steep target is green with no relaxation and no Digest, SecureRandom or Random in any signature. Three existing tests change as pins the code invalidated: the smoke suite's layer table and its preloaded stdlib features, the seam surface's require pin (digest joins the four), and 5b's keys_test.rb (eight events become nine). The surface manifest is regenerated once, 956 to 1059 lines, all 103 rows read against the object model.
Review round 0's R0-3 and R0-4, both in DigestHandler. UnencodableCredentialError always named ISO-8859-1, and its message blamed the challenge for not advertising charset=UTF-8, even when the UTF-8 branch was the one that raised -- a BINARY-tagged credential against a charset=UTF-8 challenge fails "\xE4 from ASCII-8BIT to UTF-8" and was reported as a Latin-1 failure. #materialize now takes the branch's target Encoding and the error names it; the message's reason is keyed by the target (a private REASONS table) so each branch says why its encoding applied. The same branch also refuses a UTF-8-tagged credential carrying an invalid sequence: `encode` to the same encoding passes bytes through unvalidated, so such a value would have been hashed as it was, which is the silently wrong response R10 rejects. #compute took the nonce count before hashing, so a refused attempt consumed an nc and the next response on that nonce went out one higher than the server had seen. The credential is now materialised first -- the one step that can raise -- and the count taken after it, which is the order the design's own authorization_for fence has. The RBS mirrors follow: REASONS declared, the three private signatures that carry the materialised parts and the target updated.
Review round 0's R0-2. Regexp.new, unlike a Regexp literal, returns an unfrozen object, so the parser's eight timeout-compiled patterns were the one set of core patterns not frozen at load: phase 5a's HTTPDate::GRAMMAR freezes and its suite pins the property. The eight now freeze the same way, so the tests branch can pin "a private, frozen Regexp with a per-pattern timeout" on each of them and a removed `timeout:` runs red instead of surviving. Private constants; no surface change.
Review round 1's R1-1 and R1-3.
AsyncStep validated a challenge hook's future-settled value outside the
frame that closes the 401: a hook future fulfilling with a non-request
failed the step's future with the 401 body left open, contradicting the
class comment and AUTH-32. Step#consult's rescue becomes one
closing_on_error(response) frame both runtimes use; AsyncStep overrides
consult to pass a future through and checks the settled value inside the
same frame, so a String, or a future of a future, closes the 401 before
the frame fails the future. replacement! is strict everywhere.
UnencodableCredentialError carried the rescued conversion error as its
cause, whose message names the offending character (U+65E5) or byte
("\xE4") of the secret, and full_message renders a cause on every
supported Ruby (AUTH-8). Both raises in DigestHandler#materialize now
spell cause: nil, and the error carries the value's own encoding as
#source_encoding and in its message instead, so the diagnostic loses
only the character. BasicHandler raised the bare Encoding error for a
BINARY-tagged or invalid UTF-8-tagged field; each field is now
transcoded under its own name and refused as an InvalidArgumentError
naming the field and the two encodings, cause nil.
RBS mirrors follow; the surface manifest gains the one new reader.
Review round 2's R2-1 and R2-4, both in AsyncBearerStamper. The expired-or-missing zone derived the caller's future from the single-flight slot through Future#then, and #then wires the derived future's cancellation back to its source. The source here is the ONE slot every coalesced caller shares, and every arrival until the provider settles, so cancelling one request's future -- which AsyncStep#observe forwards from the step's future -- cancelled every other waiter, and every new request coalesced onto an already-cancelled future until the provider settled (AUTH-37, SEAM-18). R12 prescribes a second #on_settle and a second Completer; the stamper now builds each waiter's future that way, settled FROM the slot's settlement and never wired back to it, so cancelling a waiter detaches that waiter alone and the fetch, the cache and the other waiters are untouched. One private #settle classifies both completers by Future#then's three rules: a cancellation stays a cancellation (a provider that cancels its own fetch cancels the slot and every waiter with its reason), a failure is the same object, a success settles with the block's value; a stamp the outbound header grammar refuses fails that waiter only. The class comment's line beginning `@lock` read to YARD as an unknown tag; reworded. RBS mirror follows; no public surface changes.
Review round 3's R3-1, in both bearer stampers. AUTH-35's validation checked a fetched token for nil, class and expiry and nothing else, so a token whose `Bearer <token>` wire form the outbound header grammar refuses (HTTP-18: a trailing newline read off a file, a CR) was written into the cache. It can never be sent, so no 401 can ever arrive to evict it (AUTH-36), and it stays until it expires -- never, for a token with no expiry: the sync stamper raised HTTP-18's InvalidArgumentError on every later call with the provider never asked again, and the async stamper's fresh zone raised it synchronously out of a method that returns a Future, failing every later request through an AsyncStep with the transport never reached. AUTH-35 wants a misbehaving provider result uncached so a later request retries. The grammar check is now the fourth rejection in BearerStamper#validate and AsyncBearerStamper#invalid, a ProviderError whose message never names the token: nothing is cached, the sync call raises from inside the lock with @token untouched, the async waiters fail with it, the slot clears, and the next call fetches again. Checked where the token arrives, as KeyStamper checks its key where IT arrives (construction), not in BearerToken.build, whose contract is AUTH-9's non-blank rule. A cached token therefore always stamps, so #stamp's fresh and expiring zones cannot raise; #deliver's rescue stays for a request whose own derivation refuses. Comments on both stampers and ProviderError follow; no public surface changes, the RBS mirrors are unchanged.
The reconciled Layers class carried both phase 6a's retry pin and phase 6c's authentication pin beside the phase-3b through phase-5 cases and reached 104 lines, over Metrics/ClassLength's 100, which the honest RuboCop run sees and the nested-worktree rake gate does not. The two phase-6 cases move to a sibling PhaseSixLayers class, the shape phase 4a's reconciliation used for the same collision.
Wahbeh-Mohammad
force-pushed
the
24-phase-6c-authentication
branch
from
September 19, 2026 09:56
430c527 to
5a74e17
Compare
This was referenced Sep 19, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #24. First PR of phase 6c's three-PR stack — the code. Its tests are the next PR up and the phase record the one after. Cut from
mainatf1fe848(phase 5 complete); phase 6a (#22, PRs #72–#74) was built in parallel off the same base — whichever merges second gets the rebase-and-reprove pass (this one, by the maintainer's order: 6a first).What lands
The authentication layer in
dexpace-core— 59 files, +3,266 / −15 —AUTH-1–AUTH-38, all 38 implemented, no ⏳ row: the only sub-phase of phase 6 with none.Dexpace::Auth::Scheme(closed at five,.newand.[]private,#withrefusing,Scheme::ALL),Requirement(scopes owned — a caller'sscopes[0] << ":write"cannot reach the stored value),Descriptor, and the pureResolverover the three tiers withDexpace::AuthResolutionError(flat,error/).#to_s,#inspectand#pretty_print(ppwalks aData's members and never calls#inspect; P6-72, a new corpus note);PasswordCredentialredacts the username too (P6-73); key credentials compare by identity.Challenges.parse— an RFC 7235StringScannerstate machine, no regexp over the grammar, eight timeout-compiled frozen patterns; the top-level comma and token68 padding handled; recovery from an unterminated quote and a stray comma; an invalidly encoded value scanned as BINARY (P6-74).BasicHandler—["u:p"].pack("m0")over the UTF-8-transcoded pair, neverBase64; a colon in the username refused (P6-75).DigestHandler— MD5 / MD5-sess / SHA-256 / SHA-256-sess with the expectations derived from RFC 2617 §3.5's and RFC 7616 §3.9.1's inputs (the RFC's printed SHA-256 response is 63 hex characters and cannot be one); the per-nonce counter in the handler's ownBoundedMap(bare name from the full-nesting body;DEFAULT_CAP1024;ncwrapping at 32 bits, restarting per nonce, counted after the raising step);cnoncefrom::SecureRandom; a non-ASCII username sent as RFC 7616 §3.4username*because HTTP-18's outbound grammar refuses the raw form (P6-76);AUTH-21's ISO-8859-1 branch a typedUnencodableCredentialErrornaming the field and the encoding, raisedcause: nilso no rendering names a byte of the secret (P6-84/85).ChallengeHandlerChain,KeyStamper(the header value grammar-checked at construction),BearerProvider(.fetch_async,.conforms?, P6-81),BearerStamper(lock-free hot path by publication, the fetch under the credential's mutex —XCUT-12's one sanctioned lock across a fetch — single-flight,AUTH-35's rejections raisingProviderErrorand caching nothing, exact-match eviction),AsyncBearerStamper(AUTH-37's three zones from one clock reading, a background refresh throughFuture#on_settlenever awaited, each waiter settled from the single-flight slot through its ownCompleterso one caller's cancel detaches that waiter alone — P6-86; a failed refresh logged asEvents::AUTH_REFRESH, P6-77).Auth::StepatStages::AUTH—.build(stamper:, challenge_hook: NO_REPLACEMENT, logger: Logger::NULL),.newprivate, frozen, noredactor:(P6-71): the cross-origin marker read fromcursor.state(Stages::REDIRECT)first (AUTH-29— the reader half of the contract 6b writes; the shared frozen empty hash is the same-origin case), the HTTPS guard second (HTTPSRequiredErrorbefore any fetch), a fork for every drive, the 401 re-challenge replayed exactly once through a fresh fork with the superseded 401 closed before the replay,AUTH-31's gate a privateStep#replayable?(P6-80),AUTH-36's evict-if-matches-then-re-stamp.AsyncStep < Step— the same keywords, oneCompleterframe, a hook that may answer aFuture(P6-78), every inner future observed with cancellation forwarded both ways (P6-79), a provider error a failed future never a raise (AUTH-38).BoundedMap#update(key) { |old| new }— the one earlier-phase widening 4a anticipated, read-yield-write under the map's own mutex;Events::AUTH_REFRESH— the ninth event in 5b's vocabulary.Bearer <token>wire form the outbound header grammar refuses isAUTH-35's fourth rejection on both stampers — nothing cached, the waiter fails with aProviderErrorthat never names the token, the next call refetches — so a token read from a file with its newline can no longer poison every later request until expiry.sig/mirrorslib/one file per file (auth/validation.rb's withhooks.rbs's comment;_BearerProvider/_AsyncBearerProviderdeclared; noDigest/SecureRandomtype in any signature —NFR-11); the manifest regenerated once from 956 to 1060 rows. Three earlier-phase test pins the code invalidated ride this branch:dexpace_test.rb's layer table,seam_surface_test.rb's plain-require list (digestjoins), 5b'skeys_test.rbevent count.Decisions taken in the open, against the plan's text
Ledger rows P6-71–P6-87 and the checklist's "Deviations from the plan" (35 items). The plan was written against phase 4/5 designs and one interpreter; the load-bearing corrections:
Dexpace::Erroris a module,Headers#[]returns a list,Request::Builder#headerappends (stampersset), a rootCursor.buildcannot fork (every drive test through a real pipeline), noredactor:keyword (P5-95),Future#thenexists, the threeAuth::errors underlib/dexpace/auth/,bounded_map.rbgiven a true test mirror.Layering
Each tip of the stack is green under every gate on its own tree. This branch is green on the SimpleCov floor too — 92.43% on 4.0.6 (92.36% on 3.2.11; 2,051 runs, 0 failures). The tests PR takes the same tree to 99.98%.
Verification
--ignore-parent-exclusion) 426 files clean.ForkingProbeat REDIRECT with the marker set, unset and absent, the HTTPS guard before any fetch, the 401 replay through one fresh fork with the close ordered before it; the bearer hot path lock-free under a raising mutex, sixteen threads coalescing on one fetch, the three zones on aFakeClock, a cancelled waiter detaching alone; every credential's renderings and every error's#cause/full_messagefree of the secret; the round-3 poisoned-cache reproduction now refetching at the stamper and through anAsyncSteppipeline.Known follow-ups from the final review (not blocking a gate)
bearer_stamper_test.rb:95: the syncBearerStamper'sAUTH-35rejections are pinned from a cold cache only; a rejection on the refresh path (a warm cache inside the margin) is exercised by no suite, so a validate-skipped-on-refresh mutation survives. A warm-cache rejection case on the tests PR closes it; the code is correct as built.a12c758).Resend.eligible?, 6b'sResend.replayable_body?, this privateStep#replayable?) → phase 10's inbound list;SequencedTransportbeside 6a'sScriptedTransport→ reconciled after both lanes land; the end-to-end cross-origin test (test/dexpace/auth/cross_origin_convergence_test.rb, guarded) → 6b un-guards it.