chore(auth): dispatch middleware and per-prefix challenge construction
Summary
Step 3 of plan docs/plans/2026-05-12-authentication-stub.md — dispatch middleware and per-prefix WWW-Authenticate challenge construction for the S08 authentication stub. Sibling of Steps 4 and 5; depends on Step 2 (!266 (merged)).
Implements:
- Segment-aware dispatch matcher with seven-row table per spec § Path-prefix dispatch.
- Per-row
WWW-Authenticateconstruction per spec § Per-prefix WWW-Authenticate headers. - OCI error envelope writer for OCI-row 401 bodies per spec § Error Cases.
- 404 unknown-prefix fallback via
internal/transport.WriteError(S01 envelope,code: "not_found"). - Plan-territory additions:
GET /v2→ 301 redirect (GET-only gate), per-requestvalidateChallengeValuewith safe-literal realm fallback, validator cancellation/deadline skip-response.
MR size justification
Diffstat: ~2300 LOC across 9 files (~700 impl + ~1600 test). Plan estimate was ~405 LOC (impl-only); test code lifts the total. Above sweet-spot is intentional:
- Plan § Defensive patterns — security-boundary additions on top of the spec:
validateChallengeValuestartup + per-request hardening (printable-ASCII byte check, RFC 9110 quoted-string safety).- Length cap
maxChallengeValue = 1024onvalidateChallengeValue— Go's defaultServer.MaxHeaderBytesis 1 MiB, so an attacker filling the Host header would otherwise burn ~1 MiB of byte-by-byte scanning per OCI-row 401. Cap rejects before the loop runs. - Empty-password rejection in the Basic branch of
extractCredential— a credential likeAuthorization: Basic dTo=(decodedu:) is rejected at the parser instead of reaching the validator with an empty secret. - Fuzz targets
FuzzBuildChallenge(theWWW-Authenticateformatter),FuzzValidateChallengeValue(the realm/service validator), andFuzzExtractCredential(the Authorization-header parser). AllocsPerRun <= 8alloc-budget regression guard on the auth happy path (measured 2.0 allocs/op; 6 allocs headroom).
- Plan § Plan-territory additions on top of the spec — three behaviors the spec leaves open:
GET /v2(no trailing slash) →301 Moved Permanently+Location: /v2/(Docker interop, GET-only gate; non-GET falls through to 404).- Per-request
Host-header sanitization with safe-literal realm fallback. - 404 unknown-prefix fallback via the S01 envelope writer.
- Test ratio (~2.3x impl) — typical for security-boundary code. Every dispatch row carries positive + negative outcomes including the
requireSlugAfterFormatfall-through on each guarded row, every plan-territory addition has dedicated coverage, plus fuzz seed corpora and the alloc regression guard.
/review-branch findings addressed
- O3 —
/v2 → 301gated onr.Method == http.MethodGet. Non-GET on bare/v2falls through to 404. Test:TestMiddleware_V2RedirectGETOnly(POST, PUT, DELETE, PATCH, HEAD). - O11 —
ocierror.goTODO marksformat/ocias the long-term home for OCI error shaping. - W1 — this description.
- O12 —
type::featurelabel applied.
Round-2 review fixes
Six blocking comments on the initial review (commit 184b63a):
- Unexported the dispatch
rowtype and its match/action fields. Corrected the table comment's "compile error" claim about field mutation (Go struct arrays are addressable; the fixed-size array only preventsappendand length-mismatched re-assignment, not field-level mutation). - Removed "Step N" and "plan's …" references across the seven implementation/test files. Spec references kept.
- Added the length cap on
validateChallengeValue(above). - Strengthened OCI 401 envelope assertions:
assertOCIEnvelopeUnauthorizednow pins message ("authentication required") and detail (NotNil + Empty, so a nil map serialized as JSONnullis caught). NewTestMiddleware_OCIEnvelopeRawShapedoes a raw-shapeassert.JSONEqto catch schema drift the typed decode would absorb. - Renamed
FuzzBearerToken→FuzzBuildChallengeto match the function it actually fuzzes. AddedFuzzExtractCredential(new filemiddleware_internal_test.gofor unexported access) — invariants: never panic; never return(secret, true)with an empty value. - Added three
requireSlugAfterFormatfall-through cases for the npm, maven, and container rows. Only/api/v1was covered before.
Also caught during the post-review quality check:
- Empty-password bug —
FuzzExtractCredentialfound within 0.74s thatextractCredentialreturned(NewSecret(""), true)for Basic credentials whose decoded value ends in:(e.g.u:). Fixed with alen(pass) == 0guard; failing fuzz input plus named unit testTestExtractCredential_BasicRejectsEmptyPassword(three sub-cases) pin the new behavior. - Plan doc synced to the renamed identifiers (
row,FuzzBuildChallenge) and the new fuzz target.
Spec coverage
Full coverage map is in the commit body (git log -n1 --format=%B). Covers AC-1 dispatch table, six WWW-Authenticate rows + 404 fallback, error cases E-1/E-1a/E-1b/E-1c/E-2, per-request flow properties, plan-territory additions, and security considerations S-3/S-4/S-5.
AC-2/3/4/5 + S-1/S-2 are owned by Steps 1/4/5 (out of Step 3 scope).
Test plan
-
go test ./internal/auth/...— 167 tests pass. -
go test ./...— no sibling-package regressions (417 tests, 15 packages). -
go vet,gofmt -l,goimports -l,golangci-lint run ./internal/auth/...— clean. - Happy-path alloc measurement: 2.0 allocs/op against
<=8budget. - Fuzz seed corpora pass on every
go test. - Fuzz long runs (verify locally before merge):
go test -run='^$' -fuzz='FuzzBuildChallenge' -fuzztime=30s ./internal/authand analogues forFuzzValidateChallengeValueandFuzzExtractCredential.