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-Authenticate construction 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-request validateChallengeValue with 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:
    • validateChallengeValue startup + per-request hardening (printable-ASCII byte check, RFC 9110 quoted-string safety).
    • Length cap maxChallengeValue = 1024 on validateChallengeValue — Go's default Server.MaxHeaderBytes is 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 like Authorization: Basic dTo= (decoded u:) is rejected at the parser instead of reaching the validator with an empty secret.
    • Fuzz targets FuzzBuildChallenge (the WWW-Authenticate formatter), FuzzValidateChallengeValue (the realm/service validator), and FuzzExtractCredential (the Authorization-header parser).
    • AllocsPerRun <= 8 alloc-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 requireSlugAfterFormat fall-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 → 301 gated on r.Method == http.MethodGet. Non-GET on bare /v2 falls through to 404. Test: TestMiddleware_V2RedirectGETOnly (POST, PUT, DELETE, PATCH, HEAD).
  • O11ocierror.go TODO marks format/oci as the long-term home for OCI error shaping.
  • W1 — this description.
  • O12type::feature label applied.

Round-2 review fixes

Six blocking comments on the initial review (commit 184b63a):

  • Unexported the dispatch row type 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 prevents append and 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: assertOCIEnvelopeUnauthorized now pins message ("authentication required") and detail (NotNil + Empty, so a nil map serialized as JSON null is caught). New TestMiddleware_OCIEnvelopeRawShape does a raw-shape assert.JSONEq to catch schema drift the typed decode would absorb.
  • Renamed FuzzBearerTokenFuzzBuildChallenge to match the function it actually fuzzes. Added FuzzExtractCredential (new file middleware_internal_test.go for unexported access) — invariants: never panic; never return (secret, true) with an empty value.
  • Added three requireSlugAfterFormat fall-through cases for the npm, maven, and container rows. Only /api/v1 was covered before.

Also caught during the post-review quality check:

  • Empty-password bugFuzzExtractCredential found within 0.74s that extractCredential returned (NewSecret(""), true) for Basic credentials whose decoded value ends in : (e.g. u:). Fixed with a len(pass) == 0 guard; failing fuzz input plus named unit test TestExtractCredential_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 <=8 budget.
  • Fuzz seed corpora pass on every go test.
  • Fuzz long runs (verify locally before merge): go test -run='^$' -fuzz='FuzzBuildChallenge' -fuzztime=30s ./internal/auth and analogues for FuzzValidateChallengeValue and FuzzExtractCredential.
Edited by Moaz Khalifa

Merge request reports

Loading
Loading