Skip to content

Phase 6c: authentication — the authentication layer - #75

Merged
Wahbeh-Mohammad merged 7 commits into
mainfrom
24-phase-6c-authentication
Sep 19, 2026
Merged

Wahbeh-Mohammad merged 7 commits into
mainfrom
24-phase-6c-authentication

Conversation

@Wahbeh-Mohammad

Copy link
Copy Markdown
Contributor

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 main at f1fe848 (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, .new and .[] private, #with refusing, Scheme::ALL), Requirement (scopes owned — a caller's scopes[0] << ":write" cannot reach the stored value), Descriptor, and the pure Resolver over the three tiers with Dexpace::AuthResolutionError (flat, error/).
  • Four credential types with variant-specific equality and redaction — #to_s, #inspect and #pretty_print (pp walks a Data's members and never calls #inspect; P6-72, a new corpus note); PasswordCredential redacts the username too (P6-73); key credentials compare by identity.
  • Challenges.parse — an RFC 7235 StringScanner state 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, never Base64; 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 own BoundedMap (bare name from the full-nesting body; DEFAULT_CAP 1024; nc wrapping at 32 bits, restarting per nonce, counted after the raising step); cnonce from ::SecureRandom; a non-ASCII username sent as RFC 7616 §3.4 username* because HTTP-18's outbound grammar refuses the raw form (P6-76); AUTH-21's ISO-8859-1 branch a typed UnencodableCredentialError naming the field and the encoding, raised cause: nil so 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 raising ProviderError and caching nothing, exact-match eviction), AsyncBearerStamper (AUTH-37's three zones from one clock reading, a background refresh through Future#on_settle never awaited, each waiter settled from the single-flight slot through its own Completer so one caller's cancel detaches that waiter alone — P6-86; a failed refresh logged as Events::AUTH_REFRESH, P6-77).
  • Auth::Step at Stages::AUTH — .build(stamper:, challenge_hook: NO_REPLACEMENT, logger: Logger::NULL), .new private, frozen, no redactor: (P6-71): the cross-origin marker read from cursor.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 (HTTPSRequiredError before 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 private Step#replayable? (P6-80), AUTH-36's evict-if-matches-then-re-stamp. AsyncStep < Step — the same keywords, one Completer frame, a hook that may answer a Future (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.
  • The round-4 repair (P6-87): a fetched token whose Bearer <token> wire form the outbound header grammar refuses is AUTH-35's fourth rejection on both stampers — nothing cached, the waiter fails with a ProviderError that 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/ mirrors lib/ one file per file (auth/validation.rb's with hooks.rbs's comment; _BearerProvider/_AsyncBearerProvider declared; no Digest/SecureRandom type 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 (digest joins), 5b's keys_test.rb event 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::Error is a module, Headers#[] returns a list, Request::Builder#header appends (stampers set), a root Cursor.build cannot fork (every drive test through a real pipeline), no redactor: keyword (P5-95), Future#then exists, the three Auth:: errors under lib/dexpace/auth/, bounded_map.rb given 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

  • Independent review, five rounds by five fresh reviewers with a fix round between each: round 0 0 blocking / 1 should-fix / 4 nits; round 1 0 / 3 / 2; round 2 0 / 3 / 2; round 3 0 / 1 / 1 (the four-review cap — the open should-fix was the poisoned-cache defect, so one targeted fix round followed by the maintainer's decision); round 4 0 / 1 / 0. No round found a red gate or a layering fault.
  • All seventeen gates individually at this tip on 4.0.6; the matrix set on 3.2.11; honest RuboCop (--ignore-parent-exclusion) 426 files clean.
  • Mutations: 58, 58, 46, 43 and 37 applied by the five reviewers on both interpreters; every one caught at the final tip except R4-1's three (below).
  • By experiment, both interpreters: Basic and the four Digest algorithms against the derived vectors; the parser on the fixtures and a 100 KB value; the nonce store's cap, per-nonce restart and sixteen-thread determinism; the step against 4c's ForkingProbe at 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 a FakeClock, a cancelled waiter detaching alone; every credential's renderings and every error's #cause/full_message free of the secret; the round-3 poisoned-cache reproduction now refetching at the stamper and through an AsyncStep pipeline.

Known follow-ups from the final review (not blocking a gate)

  • R4-1 (should-fix, tests) — bearer_stamper_test.rb:95: the sync BearerStamper's AUTH-35 rejections 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.
  • R3-2 (nit) — two 73-character body lines in the round-2 docs commit (a12c758).
  • Findings routed: three spellings of the body-replayability predicate after phase 6 (6a's Resend.eligible?, 6b's Resend.replayable_body?, this private Step#replayable?) → phase 10's inbound list; SequencedTransport beside 6a's ScriptedTransport → 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.

@Wahbeh-Mohammad Wahbeh-Mohammad added type:feature New capability or enhancement area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* area:auth Authentication and credentials: AUTH-* labels Sep 18, 2026
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
Wahbeh-Mohammad force-pushed the 24-phase-6c-authentication branch from 430c527 to 5a74e17 Compare September 19, 2026 09:56
@Wahbeh-Mohammad
Wahbeh-Mohammad merged commit 37e47d1 into main Sep 19, 2026
5 checks passed
@Wahbeh-Mohammad
Wahbeh-Mohammad deleted the 24-phase-6c-authentication branch September 19, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:auth Authentication and credentials: AUTH-* area:core Core HTTP, IO, body, context, encoding: HTTP-* IO-* BODY-* CTX-* UTF-* type:feature New capability or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant