From db36538bdf2d6c543cc82ed02f837d47dad3aaf1 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 22:36:10 +0300 Subject: [PATCH 1/5] docs: design spec for HTTP Digest authentication --- .../specs/2026-06-16-digest-auth-design.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-digest-auth-design.md diff --git a/docs/superpowers/specs/2026-06-16-digest-auth-design.md b/docs/superpowers/specs/2026-06-16-digest-auth-design.md new file mode 100644 index 0000000..390c09a --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-digest-auth-design.md @@ -0,0 +1,131 @@ +# HTTP Digest authentication — design + +**Date:** 2026-06-16 +**Status:** Approved (standing delegation); ready for implementation +**Subsystem:** deferred-feature #5 (WWW-Authenticate Digest, from the auth-breadth roadmap item) + +## Context + +The SDK has Bearer, Basic, and API-key credential policies. Digest Access +Authentication (RFC 7616) completes auth parity with the Java/Python ports. Digest +is challenge-driven: the server answers an unauthenticated request with `401` and +a `WWW-Authenticate: Digest …` challenge (realm, nonce, qop, algorithm, opaque); +the client hashes its credentials with the challenge and retries with an +`Authorization: Digest …` header. Unlike Basic, the password is never sent — only +a keyed hash. + +## Decisions + +1. **Reactive, then preemptive.** The policy sends the request; on a `401` Digest + challenge it computes the response and retries **once**. It caches the challenge + and applies Digest preemptively (with an incrementing nonce count `nc`) on + subsequent requests, re-challenging only when the server issues a new nonce + (stale). At most two sends per `Do`, so the loop is bounded. +2. **Algorithms: MD5 and SHA-256, plus `-sess` variants, `qop=auth` or none.** + These cover essentially all real Digest servers. `SHA-512-256`, `qop=auth-int` + (requires hashing the body), and `userhash` are out of scope (documented). +3. **HTTPS-only, like every other credential policy.** Returns + `auth.ErrInsecureTransport` for a non-`https` URL. Digest was designed for + cleartext HTTP, but this SDK refuses credentials over insecure transports + uniformly; the username and a replayable response hash still travel in the + header, so the guard is kept for consistency and defense in depth. +4. **`BasicCredential` reused** for username/password (no new credential type). +5. **Deterministic test seam.** An unexported `newCnonce func() (string, error)` + field (default `crypto/rand`) lets package-internal tests inject a fixed cnonce + and assert against the RFC 7616 §3.9.1 published response vectors. +6. **Umbrella `WithDigestAuth(username, password)`** at `StageAuth`. Precedence + when several auth options are set: `WithCredential` → `WithBasicAuth` → + `WithAPIKey` → `WithDigestAuth`. + +## Architecture + +### `auth/digest.go` + +```go +type DigestAuthPolicy struct { + cred BasicCredential + newCnonce func() (string, error) // test seam; default randomCnonce + + mu sync.Mutex + challenge *digestChallenge // last seen; nil until first 401 + nc uint64 // nonce count for the current challenge +} + +func NewDigestAuthPolicy(cred BasicCredential) *DigestAuthPolicy +func (p *DigestAuthPolicy) Do(req *pipeline.Request) (*http.Response, error) +``` + +**`Do` flow:** +1. HTTPS guard → `ErrInsecureTransport`. +2. Preemptive: if a challenge is cached and the request has no `Authorization`, + attach `Digest …` with the next `nc`. +3. `resp, err := req.Next()`. Return early on error or non-401. +4. Parse the `WWW-Authenticate` values; if no Digest challenge, return the 401. +5. `RewindBody()`; if it fails (non-replayable body), return the 401 (can't retry). +6. Adopt the challenge (reset `nc` if the nonce changed), compute `Authorization`, + drain+close the 401 body, set the header, and `return req.Next()` (the one retry). + +**Challenge + crypto helpers (all unexported):** +- `digestChallenge{realm, nonce, opaque, algorithm, qopAuth bool, sess bool, hashFactory func() hash.Hash}`. +- `parseChallenge([]string) *digestChallenge` — picks the `Digest` scheme from the + `WWW-Authenticate` header value(s); requires `realm`+`nonce`; selects the hash via + `algorithm` (default MD5); records whether `qop` offers `auth`. One scheme per + header value (multi-scheme single-line not supported — documented). +- `parseAuthParams(string) map[string]string` — quote-aware `key=value` scanner + (handles commas inside quoted values and `qop="auth,auth-int"`). +- `authorization(ch, nc, method, uri)` — RFC 7616 computation: + `HA1 = H(user:realm:pass)` (sess: `H(HA1:nonce:cnonce)`), + `HA2 = H(method:uri)`, + `response = H(HA1:nonce:nc:cnonce:auth:HA2)` for `qop=auth` else `H(HA1:nonce:HA2)`. + `uri` is `raw.URL.RequestURI()`. Emits quoted `username/realm/nonce/uri/cnonce/ + response/opaque` and token `algorithm/qop=auth/nc`. +- `randomCnonce()` — 16 bytes from `crypto/rand`, hex-encoded. +- `drainClose(resp)` — discard ≤4 KiB then close, so the keep-alive connection is + reusable for the retry. + +### Umbrella wiring + +`options.go`: `config.digestAuth *auth.BasicCredential`; `WithDigestAuth`. +`client.go`: a `case cfg.digestAuth != nil` in the auth switch installing +`auth.NewDigestAuthPolicy(*cfg.digestAuth)` at `StageAuth`. + +## Edge cases + +- Non-replayable body + 401 → the 401 is returned (no retry); documented. +- Server omits `algorithm` → MD5, and the response omits the `algorithm` param. +- Stale nonce (a preemptive attempt gets a fresh 401) → adopt the new challenge, + reset `nc`, retry once. +- Concurrent first challenges may both use `nc=00000001` with distinct cnonces; + acceptable (cnonce disambiguates; strict servers re-challenge). Documented. +- `crypto/rand` failure → surfaced as an error from `Do`. + +## Package layout + +| Path | Change | +|---|---| +| `auth/digest.go` (new) | `DigestAuthPolicy` + challenge/crypto helpers | +| `auth/digest_test.go` (new, `auth`) | RFC vectors via injected cnonce; parser tests | +| `auth/digest_roundtrip_test.go` (new, `auth_test`) | httptest 401→retry→200 round trip | +| `options.go`, `client.go` (modify) | `WithDigestAuth` + wiring | +| `client_test.go` (modify) | end-to-end `WithDigestAuth` over a stub/httptest | +| `doc.go`, `README.md`, `CLAUDE.md` | document | + +## Testing + +- **RFC 7616 §3.9.1 vectors** (deterministic via injected cnonce): username + `Mufasa`, password `Circle of Life`, realm `http-auth@example.org`, the RFC nonce + and cnonce, `uri=/dir/index.html`, `GET`, `qop=auth` — assert SHA-256 response + `753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1` and MD5 + response `8ca523f5e9506fed4657c9700eebdbec`. +- **Parser**: realm/nonce/opaque/qop/algorithm extracted; quoted commas and + `qop="auth,auth-int"` handled; a non-Digest scheme ignored. +- **Round trip**: an httptest server issues a 401 challenge, then validates the + retried `Authorization` by recomputing the response from the known password; + asserts a single challenge then `200`, and `nc` increments on a second request. +- **Insecure transport**: an `http://` URL returns `ErrInsecureTransport`. +- Table-driven, parallel; stdlib-only; `gofmt`/`go vet`/`go test -race` clean. + +## Out of scope (deferred) + +- `qop=auth-int`, `SHA-512-256`, `userhash=true`, and multi-scheme single-header + parsing. Add if a real server requires them. From d5c485ddd8a7eb9df9148b02f9ee520ac08c0376 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 22:38:48 +0300 Subject: [PATCH 2/5] feat(auth): add HTTP Digest authentication policy (RFC 7616) --- auth/digest.go | 303 ++++++++++++++++++++++++++++++++++++++++++++ auth/digest_test.go | 85 +++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 auth/digest.go create mode 100644 auth/digest_test.go diff --git a/auth/digest.go b/auth/digest.go new file mode 100644 index 0000000..13c7e71 --- /dev/null +++ b/auth/digest.go @@ -0,0 +1,303 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package auth + +import ( + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "io" + "net/http" + "strings" + "sync" + + "github.com/dexpace/go-sdk/header" + "github.com/dexpace/go-sdk/pipeline" +) + +// DigestAuthPolicy authenticates requests with HTTP Digest Access Authentication +// (RFC 7616). On a 401 response carrying a "WWW-Authenticate: Digest" challenge it +// computes the digest response and retries the request once, then reuses the +// challenge preemptively (with an incrementing nonce count) on later requests +// until the server issues a new nonce. It supports the MD5 and SHA-256 algorithms +// and their "-sess" variants, with qop=auth or no qop. +// +// Like the other credential policies it requires HTTPS and returns +// [ErrInsecureTransport] otherwise; the username and a replayable response hash +// still travel in the header, so the guard is kept for consistency. qop=auth-int, +// SHA-512-256, userhash, and multi-scheme single-header challenges are not +// supported. It implements pipeline.Policy and is safe for concurrent use. +type DigestAuthPolicy struct { + cred BasicCredential + newCnonce func() (string, error) + + mu sync.Mutex + challenge *digestChallenge + nc uint64 +} + +// NewDigestAuthPolicy returns a Digest auth policy for the given credentials. +func NewDigestAuthPolicy(cred BasicCredential) *DigestAuthPolicy { + return &DigestAuthPolicy{cred: cred, newCnonce: randomCnonce} +} + +// Do implements pipeline.Policy. +func (p *DigestAuthPolicy) Do(req *pipeline.Request) (*http.Response, error) { + raw := req.Raw() + if raw.URL == nil || raw.URL.Scheme != "https" { + return nil, ErrInsecureTransport + } + + if ch, nc := p.preempt(); ch != nil && raw.Header.Get(header.Authorization) == "" { + hdr, err := p.authorization(ch, nc, raw.Method, raw.URL.RequestURI()) + if err != nil { + return nil, err + } + raw.Header.Set(header.Authorization, hdr) + } + + resp, err := req.Next() + if err != nil || resp.StatusCode != http.StatusUnauthorized { + return resp, err + } + + ch := parseChallenge(resp.Header.Values(header.WWWAuthenticate)) + if ch == nil { + return resp, nil + } + if rerr := req.RewindBody(); rerr != nil { + return resp, nil // non-replayable body: cannot retry, surface the 401 + } + nc := p.adopt(ch) + hdr, herr := p.authorization(ch, nc, raw.Method, raw.URL.RequestURI()) + if herr != nil { + drainClose(resp) + return nil, herr + } + drainClose(resp) + raw.Header.Set(header.Authorization, hdr) + return req.Next() +} + +// preempt returns the cached challenge and the next nonce count, or (nil, 0) if no +// challenge has been seen yet. +func (p *DigestAuthPolicy) preempt() (*digestChallenge, uint64) { + p.mu.Lock() + defer p.mu.Unlock() + if p.challenge == nil { + return nil, 0 + } + p.nc++ + return p.challenge, p.nc +} + +// adopt records ch as the current challenge (resetting the nonce count when the +// nonce changes) and returns the next nonce count. +func (p *DigestAuthPolicy) adopt(ch *digestChallenge) uint64 { + p.mu.Lock() + defer p.mu.Unlock() + if p.challenge == nil || p.challenge.nonce != ch.nonce { + p.challenge = ch + p.nc = 0 + } + p.nc++ + return p.nc +} + +// digestChallenge is a parsed "WWW-Authenticate: Digest" challenge. +type digestChallenge struct { + realm string + nonce string + opaque string + algorithm string // echoed verbatim, e.g. "MD5", "SHA-256", "SHA-256-sess" + qopAuth bool + sess bool + hashFactory func() hash.Hash +} + +// parseChallenge returns the Digest challenge from the WWW-Authenticate header +// value(s), or nil if none is present or usable. Only one scheme per header value +// is recognised. +func parseChallenge(values []string) *digestChallenge { + for _, v := range values { + rest, ok := cutScheme(v) + if !ok { + continue + } + params := parseAuthParams(rest) + realm, hasRealm := params["realm"] + nonce, hasNonce := params["nonce"] + if !hasRealm || !hasNonce { + continue + } + factory, sess, supported := hashFor(params["algorithm"]) + if !supported { + continue + } + ch := &digestChallenge{ + realm: realm, + nonce: nonce, + opaque: params["opaque"], + algorithm: params["algorithm"], + sess: sess, + hashFactory: factory, + } + for _, opt := range strings.Split(params["qop"], ",") { + if strings.TrimSpace(opt) == "auth" { + ch.qopAuth = true + } + } + return ch + } + return nil +} + +// hashFor selects the hash factory for a Digest algorithm token. An empty token +// means MD5. The bool reports whether the algorithm is supported. +func hashFor(algorithm string) (func() hash.Hash, bool, bool) { + switch strings.ToUpper(algorithm) { + case "", "MD5": + return md5.New, false, true + case "MD5-SESS": + return md5.New, true, true + case "SHA-256": + return sha256.New, false, true + case "SHA-256-SESS": + return sha256.New, true, true + default: + return nil, false, false + } +} + +// cutScheme strips a leading "Digest" auth-scheme token (case-insensitive) and +// returns the remaining challenge parameters. +func cutScheme(v string) (string, bool) { + v = strings.TrimSpace(v) + const scheme = "digest" + if len(v) <= len(scheme) || !strings.EqualFold(v[:len(scheme)], scheme) { + return "", false + } + rest := v[len(scheme):] + if rest[0] != ' ' && rest[0] != '\t' { + return "", false + } + return strings.TrimSpace(rest), true +} + +// parseAuthParams scans comma-separated key=value auth parameters, honouring +// double-quoted values (so commas inside a quoted value, as in qop="auth,auth-int" +// or domain="/a,/b", are preserved). Keys are lower-cased. +func parseAuthParams(s string) map[string]string { + m := make(map[string]string) + i, n := 0, len(s) + for i < n { + for i < n && (s[i] == ' ' || s[i] == '\t' || s[i] == ',') { + i++ + } + start := i + for i < n && s[i] != '=' && s[i] != ',' { + i++ + } + key := strings.ToLower(strings.TrimSpace(s[start:i])) + if i >= n || s[i] == ',' { + if key != "" { + m[key] = "" + } + continue + } + i++ // consume '=' + if i < n && s[i] == '"' { + i++ + var b strings.Builder + for i < n && s[i] != '"' { + if s[i] == '\\' && i+1 < n { + i++ + } + b.WriteByte(s[i]) + i++ + } + if i < n { + i++ // consume closing quote + } + m[key] = b.String() + } else { + vstart := i + for i < n && s[i] != ',' { + i++ + } + m[key] = strings.TrimSpace(s[vstart:i]) + } + } + return m +} + +// authorization builds the Authorization header value for a request. +func (p *DigestAuthPolicy) authorization(ch *digestChallenge, nc uint64, method, uri string) (string, error) { + cnonce, err := p.newCnonce() + if err != nil { + return "", err + } + ha1 := hashHex(ch.hashFactory, p.cred.Username+":"+ch.realm+":"+p.cred.Password) + if ch.sess { + ha1 = hashHex(ch.hashFactory, ha1+":"+ch.nonce+":"+cnonce) + } + ha2 := hashHex(ch.hashFactory, method+":"+uri) + + ncHex := fmt.Sprintf("%08x", nc) + var response string + if ch.qopAuth { + response = hashHex(ch.hashFactory, strings.Join([]string{ha1, ch.nonce, ncHex, cnonce, "auth", ha2}, ":")) + } else { + response = hashHex(ch.hashFactory, ha1+":"+ch.nonce+":"+ha2) + } + + parts := []string{ + quoted("username", p.cred.Username), + quoted("realm", ch.realm), + quoted("nonce", ch.nonce), + quoted("uri", uri), + } + if ch.algorithm != "" { + parts = append(parts, "algorithm="+ch.algorithm) + } + if ch.qopAuth { + parts = append(parts, "qop=auth", "nc="+ncHex, quoted("cnonce", cnonce)) + } + parts = append(parts, quoted("response", response)) + if ch.opaque != "" { + parts = append(parts, quoted("opaque", ch.opaque)) + } + return "Digest " + strings.Join(parts, ", "), nil +} + +func quoted(key, value string) string { + return key + `="` + value + `"` +} + +func hashHex(factory func() hash.Hash, s string) string { + h := factory() + _, _ = io.WriteString(h, s) + return hex.EncodeToString(h.Sum(nil)) +} + +func randomCnonce() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("auth: generate cnonce: %w", err) + } + return hex.EncodeToString(b[:]), nil +} + +// drainClose discards a bounded amount of the response body and closes it, so the +// underlying keep-alive connection can be reused for the retry. +func drainClose(resp *http.Response) { + if resp == nil || resp.Body == nil { + return + } + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + _ = resp.Body.Close() +} diff --git a/auth/digest_test.go b/auth/digest_test.go new file mode 100644 index 0000000..a8ec9cf --- /dev/null +++ b/auth/digest_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package auth + +import "testing" + +// RFC 7616 §3.9.1 published vectors. +const ( + rfcUser = "Mufasa" + rfcPass = "Circle of Life" + rfcRealm = "http-auth@example.org" + rfcNonce = "7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v" + rfcCnonce = "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ" + rfcURI = "/dir/index.html" +) + +func fixedDigest(t *testing.T, algorithm string) *DigestAuthPolicy { + t.Helper() + p := NewDigestAuthPolicy(BasicCredential{Username: rfcUser, Password: rfcPass}) + p.newCnonce = func() (string, error) { return rfcCnonce, nil } + return p +} + +func TestAuthorizationRFC7616SHA256(t *testing.T) { + t.Parallel() + p := fixedDigest(t, "SHA-256") + ch := parseChallenge([]string{`Digest realm="` + rfcRealm + `", qop="auth", algorithm=SHA-256, nonce="` + rfcNonce + `", opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"`}) + if ch == nil { + t.Fatal("parseChallenge returned nil for a SHA-256 challenge") + } + hdr, err := p.authorization(ch, 1, "GET", rfcURI) + if err != nil { + t.Fatalf("authorization: %v", err) + } + const want = "753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1" + if !containsParam(hdr, "response", want) { + t.Fatalf("SHA-256 response mismatch.\nheader: %s\nwant response=%q", hdr, want) + } +} + +func TestAuthorizationRFC7616MD5(t *testing.T) { + t.Parallel() + p := fixedDigest(t, "MD5") + ch := parseChallenge([]string{`Digest realm="` + rfcRealm + `", qop="auth", algorithm=MD5, nonce="` + rfcNonce + `", opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"`}) + if ch == nil { + t.Fatal("parseChallenge returned nil for an MD5 challenge") + } + hdr, err := p.authorization(ch, 1, "GET", rfcURI) + if err != nil { + t.Fatalf("authorization: %v", err) + } + const want = "8ca523f5e9506fed4657c9700eebdbec" + if !containsParam(hdr, "response", want) { + t.Fatalf("MD5 response mismatch.\nheader: %s\nwant response=%q", hdr, want) + } +} + +func TestParseChallengeFields(t *testing.T) { + t.Parallel() + ch := parseChallenge([]string{`Digest realm="r", domain="/a,/b", qop="auth,auth-int", nonce="n", opaque="o", algorithm=SHA-256`}) + if ch == nil { + t.Fatal("nil challenge") + } + if ch.realm != "r" || ch.nonce != "n" || ch.opaque != "o" || !ch.qopAuth || ch.sess { + t.Fatalf("bad parse: %+v", ch) + } +} + +func TestParseChallengeIgnoresNonDigest(t *testing.T) { + t.Parallel() + if ch := parseChallenge([]string{`Basic realm="r"`}); ch != nil { + t.Fatalf("expected nil for a non-Digest scheme, got %+v", ch) + } + if ch := parseChallenge([]string{`Digest realm="r"`}); ch != nil { + t.Fatal("expected nil when nonce is missing") + } +} + +// containsParam reports whether the Digest header contains key="value" (quoted) or +// key=value (token). +func containsParam(hdr, key, value string) bool { + params := parseAuthParams(hdr[len("Digest "):]) + return params[key] == value +} From 4d1d374995a7a590258fe9d18944c35f30124984 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 22:39:41 +0300 Subject: [PATCH 3/5] test(auth): Digest 401-challenge round-trip and nc increment --- auth/digest_roundtrip_test.go | 171 ++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 auth/digest_roundtrip_test.go diff --git a/auth/digest_roundtrip_test.go b/auth/digest_roundtrip_test.go new file mode 100644 index 0000000..09d98f9 --- /dev/null +++ b/auth/digest_roundtrip_test.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 dexpace and Omar Aljarrah. +// Licensed under the MIT License. See LICENSE in the repository root for details. + +package auth_test + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/dexpace/go-sdk/auth" + "github.com/dexpace/go-sdk/pipeline" +) + +// sha256Hex hashes s and returns the lowercase hex digest, matching the server +// side of an RFC 7616 SHA-256 exchange. +func sha256Hex(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// digestParam extracts a single auth-param from a "Digest ..." header value. +// Quoted and token forms are both handled. +func digestParam(hdr, key string) string { + rest := strings.TrimPrefix(hdr, "Digest ") + for _, part := range splitAuthParams(rest) { + k, v, ok := strings.Cut(part, "=") + if !ok { + continue + } + if strings.EqualFold(strings.TrimSpace(k), key) { + return strings.Trim(strings.TrimSpace(v), `"`) + } + } + return "" +} + +// splitAuthParams splits a comma-separated parameter list, keeping commas inside +// double-quoted values intact. +func splitAuthParams(s string) []string { + var parts []string + var b strings.Builder + inQuote := false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '"': + inQuote = !inQuote + b.WriteByte(c) + case c == ',' && !inQuote: + parts = append(parts, b.String()) + b.Reset() + default: + b.WriteByte(c) + } + } + if b.Len() > 0 { + parts = append(parts, b.String()) + } + return parts +} + +// expectedResponse recomputes the RFC 7616 SHA-256 qop=auth response for the +// server-known credentials. +func expectedResponse(username, password, realm, nonce, uri, nc, cnonce string) string { + ha1 := sha256Hex(username + ":" + realm + ":" + password) + ha2 := sha256Hex("GET:" + uri) + return sha256Hex(strings.Join([]string{ha1, nonce, nc, cnonce, "auth", ha2}, ":")) +} + +func TestDigestRoundTrip(t *testing.T) { + t.Parallel() + + const ( + user = "u" + pass = "pw" + realm = "test" + nonce = "abc123" + opaque = "xyz" + ) + + var ( + challenges atomic.Int64 // count of 401 challenges issued + successes atomic.Int64 // count of 200 responses + firstHadCtl atomic.Bool // whether the very first request carried Authorization + seenRequest atomic.Int64 // total requests seen + ) + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := seenRequest.Add(1) + authz := r.Header.Get("Authorization") + if n == 1 { + firstHadCtl.Store(authz != "") + } + if !strings.HasPrefix(authz, "Digest ") { + challenges.Add(1) + w.Header().Set("WWW-Authenticate", + fmt.Sprintf(`Digest realm=%q, qop="auth", nonce=%q, algorithm=SHA-256, opaque=%q`, realm, nonce, opaque)) + w.WriteHeader(http.StatusUnauthorized) + return + } + uri := digestParam(authz, "uri") + nc := digestParam(authz, "nc") + cnonce := digestParam(authz, "cnonce") + got := digestParam(authz, "response") + want := expectedResponse(user, pass, realm, nonce, uri, nc, cnonce) + if got != want { + challenges.Add(1) + w.Header().Set("WWW-Authenticate", + fmt.Sprintf(`Digest realm=%q, qop="auth", nonce=%q, algorithm=SHA-256, opaque=%q`, realm, nonce, opaque)) + w.WriteHeader(http.StatusUnauthorized) + return + } + successes.Add(1) + w.Header().Set("X-Echo-NC", nc) + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + t.Cleanup(srv.Close) + + // *http.Client satisfies pipeline.Transporter (Do(*http.Request) (*http.Response, error)). + policy := auth.NewDigestAuthPolicy(auth.BasicCredential{Username: user, Password: pass}) + pl := pipeline.New(srv.Client(), policy) + + // First request: server challenges once, policy retries with the digest, 200. + req1, _ := http.NewRequest(http.MethodGet, srv.URL+"/dir/index.html", nil) + resp1, err := pl.Do(req1) + if err != nil { + t.Fatalf("first Do: %v", err) + } + t.Cleanup(func() { _ = resp1.Body.Close() }) + body1, _ := io.ReadAll(resp1.Body) + if resp1.StatusCode != http.StatusOK || string(body1) != "ok" { + t.Fatalf("first response = %d %q, want 200 \"ok\"", resp1.StatusCode, body1) + } + if got := challenges.Load(); got != 1 { + t.Fatalf("challenges after first request = %d, want exactly 1", got) + } + if firstHadCtl.Load() { + t.Fatal("first request unexpectedly carried Authorization (no challenge cached yet)") + } + if got := resp1.Header.Get("X-Echo-NC"); got != "00000001" { + t.Fatalf("first success nc = %q, want 00000001", got) + } + + // Second request through the SAME policy: the challenge is reused preemptively, + // so the very first hit carries Authorization and increments nc to 00000002. + beforeChallenges := challenges.Load() + req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/dir/index.html", nil) + resp2, err := pl.Do(req2) + if err != nil { + t.Fatalf("second Do: %v", err) + } + t.Cleanup(func() { _ = resp2.Body.Close() }) + body2, _ := io.ReadAll(resp2.Body) + if resp2.StatusCode != http.StatusOK || string(body2) != "ok" { + t.Fatalf("second response = %d %q, want 200 \"ok\"", resp2.StatusCode, body2) + } + if got := challenges.Load(); got != beforeChallenges { + t.Fatalf("second request triggered %d new challenge(s), want 0 (preemptive auth)", got-beforeChallenges) + } + if got := resp2.Header.Get("X-Echo-NC"); got != "00000002" { + t.Fatalf("second success nc = %q, want 00000002 (incrementing nonce count)", got) + } +} From 3b0e3b8b7ee91c9851abbbc9e6e9082f68ffc58d Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 22:41:56 +0300 Subject: [PATCH 4/5] feat: wire WithDigestAuth into the client --- CLAUDE.md | 8 +++---- README.md | 2 ++ client.go | 3 +++ client_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++ doc.go | 3 ++- options.go | 11 +++++++++ 6 files changed, 86 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cec9272..564aa5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ go-sdk/ ├── transport/ # default net/http Transporter ├── retry/ # retry policy (backoff + jitter + Retry-After) ├── idempotency/ # idempotency-key policy (default-on for POST) -├── auth/ # TokenCredential, BearerTokenPolicy, StaticToken +├── auth/ # TokenCredential, BearerTokenPolicy, StaticToken, Digest (RFC 7616) ├── logging/ # slog request/response policy ├── httperr/ # ResponseError + FromResponse ├── mediatype/ # immutable MediaType + constants @@ -137,9 +137,9 @@ terminating in a `Transporter`: streaming body (`io.Reader` with no `GetBody`) is **not** replayable — rewind returns an error and retries fail. Buffer such bodies before sending. - **The credential policies are HTTPS-only.** `BearerTokenPolicy`, - `BasicAuthPolicy`, and `APIKeyPolicy` all return `auth.ErrInsecureTransport` - for a non-`https` URL rather than leaking a credential. Tests must use - `https://` URLs (a stub transporter never dials). + `BasicAuthPolicy`, `APIKeyPolicy`, and `DigestAuthPolicy` all return + `auth.ErrInsecureTransport` for a non-`https` URL rather than leaking a + credential. Tests must use `https://` URLs (a stub transporter never dials). - **Policy order changes semantics.** Retry is outside auth, so a 401-triggered token refresh requires the auth policy to be inside retry (it is, by default). Moving logging outside retry collapses per-attempt logs into one. diff --git a/README.md b/README.md index 84ca65c..a308495 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ stripped and query values are redacted unless allowlisted with by default) across clients so a cached token is reused. - `WithBasicAuth(username, password)` — authenticates requests with HTTP Basic auth (HTTPS-only). - `WithAPIKey(header, key)` — sets an API-key header on every request (HTTPS-only). +- `WithDigestAuth(username, password)` — authenticates requests with HTTP Digest Access + Authentication, RFC 7616 (MD5/SHA-256, qop=auth; HTTPS-only). - `WithConfig(cfg)` — sources defaults from `DEXPACE_*` environment variables — `DEXPACE_USER_AGENT`, `DEXPACE_MAX_RETRIES` (0 or negative disables retries), `DEXPACE_RETRY_BASE_DELAY`, `DEXPACE_HTTP_TIMEOUT` (default transport only) — for diff --git a/client.go b/client.go index 0fae87b..3852d46 100644 --- a/client.go +++ b/client.go @@ -131,6 +131,9 @@ func New(opts ...Option) *Client { case cfg.apiKey.set: placements = append(placements, pipeline.At(pipeline.StageAuth, auth.NewAPIKeyPolicy(cfg.apiKey.header, cfg.apiKey.key))) + case cfg.digestAuth != nil: + placements = append(placements, + pipeline.At(pipeline.StageAuth, auth.NewDigestAuthPolicy(*cfg.digestAuth))) } if cfg.date { placements = append(placements, pipeline.At(pipeline.StageDate, datePolicy())) diff --git a/client_test.go b/client_test.go index 5f3451c..62b9a5b 100644 --- a/client_test.go +++ b/client_test.go @@ -6,6 +6,8 @@ package dexpace_test import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "io" @@ -672,6 +674,68 @@ func TestWithAPIKey(t *testing.T) { } } +func TestWithDigestAuth(t *testing.T) { + t.Parallel() + + const ( + user = "u" + pass = "pw" + realm = "test" + nonce = "abc123" + ) + + // sha256 helper for the server-side recomputation of the RFC 7616 response. + sum := func(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) + } + param := func(hdr, key string) string { + for _, raw := range strings.Split(strings.TrimPrefix(hdr, "Digest "), ",") { + k, v, ok := strings.Cut(strings.TrimSpace(raw), "=") + if ok && strings.EqualFold(k, key) { + return strings.Trim(v, `"`) + } + } + return "" + } + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authz := r.Header.Get("Authorization") + if !strings.HasPrefix(authz, "Digest ") { + w.Header().Set("WWW-Authenticate", + fmt.Sprintf(`Digest realm=%q, qop="auth", nonce=%q, algorithm=SHA-256`, realm, nonce)) + w.WriteHeader(http.StatusUnauthorized) + return + } + ha1 := sum(user + ":" + realm + ":" + pass) + ha2 := sum(r.Method + ":" + param(authz, "uri")) + want := sum(strings.Join([]string{ha1, nonce, param(authz, "nc"), param(authz, "cnonce"), "auth", ha2}, ":")) + if param(authz, "response") != want { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + t.Cleanup(srv.Close) + + c := dexpace.New( + dexpace.WithTransport(srv.Client()), + dexpace.WithDigestAuth(user, pass), + dexpace.WithoutIdempotency(), + ) + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/resource", nil) + resp, err := c.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + t.Cleanup(func() { _ = resp.Body.Close() }) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 after Digest challenge", resp.StatusCode) + } +} + func TestAuthPrecedenceBearerBeatsBasic(t *testing.T) { t.Parallel() diff --git a/doc.go b/doc.go index 2e0c4e5..1cfbdbd 100644 --- a/doc.go +++ b/doc.go @@ -20,7 +20,8 @@ // which query-param values survive redaction in logs and traces. // // Beyond bearer tokens (WithCredential), WithBasicAuth and WithAPIKey authenticate -// requests with HTTP Basic auth or an API-key header; both require HTTPS. +// requests with HTTP Basic auth or an API-key header, and WithDigestAuth performs +// HTTP Digest Access Authentication (RFC 7616); all require HTTPS. // // WithTokenCache shares a bearer-token cache across clients (auth.TokenCache, with // an in-memory default). diff --git a/options.go b/options.go index 72e30bd..f4b3ba2 100644 --- a/options.go +++ b/options.go @@ -32,6 +32,7 @@ type config struct { tokenCache auth.TokenCache basicAuth *auth.BasicCredential apiKey apiKeyConfig + digestAuth *auth.BasicCredential logger *slog.Logger logging bool date bool @@ -97,6 +98,16 @@ func WithAPIKey(header, key string) Option { } } +// WithDigestAuth authenticates requests with HTTP Digest Access Authentication +// (RFC 7616). Like all credential policies it requires HTTPS. Precedence when +// multiple auth options are set: WithCredential, WithBasicAuth, WithAPIKey, then +// WithDigestAuth. +func WithDigestAuth(username, password string) Option { + return func(c *config) { + c.digestAuth = &auth.BasicCredential{Username: username, Password: password} + } +} + // WithLogging enables structured request/response logging via log/slog. A nil // logger uses slog.Default(). func WithLogging(logger *slog.Logger) Option { From c53d8e127a469b13c123f84ef3fb1d36790c17bf Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 16 Jun 2026 22:45:52 +0300 Subject: [PATCH 5/5] test(auth): drop unused parameter in Digest test helper --- auth/digest_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/auth/digest_test.go b/auth/digest_test.go index a8ec9cf..277dc93 100644 --- a/auth/digest_test.go +++ b/auth/digest_test.go @@ -15,7 +15,7 @@ const ( rfcURI = "/dir/index.html" ) -func fixedDigest(t *testing.T, algorithm string) *DigestAuthPolicy { +func fixedDigest(t *testing.T) *DigestAuthPolicy { t.Helper() p := NewDigestAuthPolicy(BasicCredential{Username: rfcUser, Password: rfcPass}) p.newCnonce = func() (string, error) { return rfcCnonce, nil } @@ -24,7 +24,7 @@ func fixedDigest(t *testing.T, algorithm string) *DigestAuthPolicy { func TestAuthorizationRFC7616SHA256(t *testing.T) { t.Parallel() - p := fixedDigest(t, "SHA-256") + p := fixedDigest(t) ch := parseChallenge([]string{`Digest realm="` + rfcRealm + `", qop="auth", algorithm=SHA-256, nonce="` + rfcNonce + `", opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"`}) if ch == nil { t.Fatal("parseChallenge returned nil for a SHA-256 challenge") @@ -41,7 +41,7 @@ func TestAuthorizationRFC7616SHA256(t *testing.T) { func TestAuthorizationRFC7616MD5(t *testing.T) { t.Parallel() - p := fixedDigest(t, "MD5") + p := fixedDigest(t) ch := parseChallenge([]string{`Digest realm="` + rfcRealm + `", qop="auth", algorithm=MD5, nonce="` + rfcNonce + `", opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"`}) if ch == nil { t.Fatal("parseChallenge returned nil for an MD5 challenge")