Skip to content

Commit cbff536

Browse files
committed
Add converter round-trip coverage and sanitize cleanup log
Addresses #5196 review comments: - MEDIUM dcr_store.go (3203565436) F7: added TestResolutionCredentialsRoundTrip pinning the field-by-field contract between resolutionToCredentials and credentialsToResolution (preserved, dropped, key-recovered, nil-shortcircuit). Added MUST-update-both-converters comments on the DCRResolution struct in dcr.go and the DCRCredentials struct in storage/types.go so a future contributor adding a field to either type sees the converter obligation at the struct definition rather than only at the converters. Documented the ProviderName asymmetry: the field is storage-only ("debug/audit only" per its own docstring) and is intentionally left unpopulated by the runner; the test asserts that invariant so any future threading is paired with the assertion update. - MEDIUM embeddedauthserver.go (3203565446) F8: route both closeErr and retErr in the deferred-cleanup slog.Warn through sanitizeErrorForLog so a wrapped DCR failure whose error chain inlines an upstream /register response body cannot leak userinfo, query, or fragment components into operator logs. Renamed the "original_error" key to "cause" to match the package-wide vocabulary. Added TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog which captures the Warn record by swapping slog.Default() and asserts both that literal secret markers are scrubbed and that host components survive for operator correlation.
1 parent 39dfc62 commit cbff536

5 files changed

Lines changed: 256 additions & 2 deletions

File tree

pkg/authserver/runner/dcr.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ var authMethodPreference = []string{
7373
//
7474
// The struct is the unit of storage in dcrResolutionCache and the unit of
7575
// application via consumeResolution.
76+
//
77+
// MUST update both converters (resolutionToCredentials and
78+
// credentialsToResolution in dcr_store.go) when adding, renaming, or
79+
// removing a field here. The two converters are the seam between this
80+
// runner-side type and the persisted *storage.DCRCredentials shape; a
81+
// field added here without a paired converter update will silently fail
82+
// to round-trip across an authserver restart, the exact "parallel types
83+
// drift" failure mode .claude/rules/go-style.md warns about. The
84+
// round-trip behaviour is pinned by TestResolutionCredentialsRoundTrip
85+
// in dcr_store_test.go.
7686
type DCRResolution struct {
7787
// ClientID is the RFC 7591 "client_id" returned by the authorization
7888
// server.

pkg/authserver/runner/dcr_store_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,3 +298,104 @@ func TestStorageBackedStore_ConcurrentAccess(t *testing.T) {
298298
assert.Zero(t, atomic.LoadInt32(&errCount),
299299
"no Get/Put should have errored under concurrent access")
300300
}
301+
302+
// TestResolutionCredentialsRoundTrip pins the field-by-field contract
303+
// between resolutionToCredentials and credentialsToResolution: which
304+
// fields survive a round-trip, which are intentionally dropped, and
305+
// which are recovered from the persisted DCRKey. The test exists
306+
// because the two converters are the seam where a field added to either
307+
// DCRResolution or storage.DCRCredentials must be paired with an update
308+
// here; without coverage, a future field addition would silently fail
309+
// to persist across an authserver restart.
310+
//
311+
// The "preserved" group asserts equality on round-tripped values. The
312+
// "dropped" group asserts that the post-round-trip value is the type's
313+
// zero value (ClientIDIssuedAt is informational per RFC 7591 §3.2.1 and
314+
// is not persisted). RedirectURI is in its own group because it is
315+
// dropped from DCRCredentials and recovered via Key.RedirectURI on
316+
// read.
317+
//
318+
// ProviderName is the one DCRCredentials field with no DCRResolution
319+
// counterpart. It is documented in storage.DCRCredentials as "debug /
320+
// audit only — never used as a primary key" and no current consumer
321+
// reads it. The decision to leave it unpopulated by the runner is
322+
// recorded here so a future contributor adding ProviderName threading
323+
// has a single grep target.
324+
func TestResolutionCredentialsRoundTrip(t *testing.T) {
325+
t.Parallel()
326+
327+
now := time.Now().UTC().Round(time.Second)
328+
expiry := now.Add(30 * 24 * time.Hour)
329+
330+
key := DCRKey{
331+
Issuer: "https://idp.example.com",
332+
RedirectURI: "https://thv.example.com/oauth/callback",
333+
ScopesHash: storage.ScopesHash([]string{"openid", "profile"}),
334+
}
335+
336+
original := &DCRResolution{
337+
ClientID: "round-trip-client-id",
338+
ClientSecret: "round-trip-secret",
339+
AuthorizationEndpoint: "https://idp.example.com/authorize",
340+
TokenEndpoint: "https://idp.example.com/token",
341+
RegistrationAccessToken: "rfc7592-token",
342+
RegistrationClientURI: "https://idp.example.com/register/round-trip-client-id",
343+
TokenEndpointAuthMethod: "client_secret_basic",
344+
// RedirectURI is recovered from Key on read; pre-populate it on
345+
// the input to confirm it survives via the key, not via a
346+
// dedicated field on DCRCredentials.
347+
RedirectURI: key.RedirectURI,
348+
ClientIDIssuedAt: now, // intentionally dropped on persist
349+
ClientSecretExpiresAt: expiry,
350+
CreatedAt: now,
351+
}
352+
353+
creds := resolutionToCredentials(key, original)
354+
require.NotNil(t, creds)
355+
356+
// Persisted-side assertions: which fields the converter writes to
357+
// DCRCredentials, which it leaves zero (the asymmetry F7 documents).
358+
assert.Equal(t, key, creds.Key,
359+
"Key must round-trip via the explicit parameter")
360+
assert.Empty(t, creds.ProviderName,
361+
"ProviderName has no DCRResolution counterpart and is intentionally not populated; "+
362+
"a future contributor threading it through must update this assertion and the converters together")
363+
364+
roundTripped := credentialsToResolution(creds)
365+
require.NotNil(t, roundTripped)
366+
367+
t.Run("preserved fields survive round-trip", func(t *testing.T) {
368+
t.Parallel()
369+
assert.Equal(t, original.ClientID, roundTripped.ClientID)
370+
assert.Equal(t, original.ClientSecret, roundTripped.ClientSecret)
371+
assert.Equal(t, original.AuthorizationEndpoint, roundTripped.AuthorizationEndpoint)
372+
assert.Equal(t, original.TokenEndpoint, roundTripped.TokenEndpoint)
373+
assert.Equal(t, original.RegistrationAccessToken, roundTripped.RegistrationAccessToken)
374+
assert.Equal(t, original.RegistrationClientURI, roundTripped.RegistrationClientURI)
375+
assert.Equal(t, original.TokenEndpointAuthMethod, roundTripped.TokenEndpointAuthMethod)
376+
assert.Equal(t, original.ClientSecretExpiresAt, roundTripped.ClientSecretExpiresAt)
377+
assert.Equal(t, original.CreatedAt, roundTripped.CreatedAt)
378+
})
379+
380+
t.Run("RedirectURI is recovered from Key on read", func(t *testing.T) {
381+
t.Parallel()
382+
assert.Equal(t, key.RedirectURI, roundTripped.RedirectURI,
383+
"RedirectURI on the round-tripped resolution must match Key.RedirectURI; "+
384+
"the converter does not store it twice")
385+
})
386+
387+
t.Run("dropped fields zero on read", func(t *testing.T) {
388+
t.Parallel()
389+
// ClientIDIssuedAt is informational (RFC 7591 §3.2.1) and not
390+
// persisted; round-tripping must reset it to the zero value
391+
// rather than silently re-deriving it from CreatedAt.
392+
assert.True(t, roundTripped.ClientIDIssuedAt.IsZero(),
393+
"ClientIDIssuedAt must be zero after round-trip; the field is intentionally dropped from DCRCredentials")
394+
})
395+
396+
t.Run("nil inputs short-circuit", func(t *testing.T) {
397+
t.Parallel()
398+
assert.Nil(t, resolutionToCredentials(key, nil))
399+
assert.Nil(t, credentialsToResolution(nil))
400+
})
401+
}

pkg/authserver/runner/embeddedauthserver.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,24 @@ func newEmbeddedAuthServerWithStorage(
9393
stor storage.Storage,
9494
) (retEAS *EmbeddedAuthServer, retErr error) {
9595
// From here on, any error must close stor before returning.
96+
//
97+
// Both errors are passed through sanitizeErrorForLog before being
98+
// recorded: closeErr for symmetry with retErr, retErr because the
99+
// most common cause of reaching this gate is a wrapped DCR failure
100+
// whose error chain may inline several KiB of the upstream's raw
101+
// /register response body — that body is attacker-influenced and may
102+
// contain URL components that carry credentials (userinfo, query,
103+
// fragment). The existing logDCRStepError boundary log routes
104+
// through the same sanitiser; keep the two log paths consistent so
105+
// the cleanup log cannot regress to a less-defended state. The
106+
// "cause" key matches the package-wide vocabulary for the
107+
// triggering error.
96108
defer func() {
97109
if retErr != nil {
98110
if closeErr := stor.Close(); closeErr != nil {
99111
slog.Warn("failed to close storage on NewEmbeddedAuthServer error path",
100-
"error", closeErr,
101-
"original_error", retErr,
112+
"error", sanitizeErrorForLog(closeErr),
113+
"cause", sanitizeErrorForLog(retErr),
102114
)
103115
}
104116
}

pkg/authserver/runner/embeddedauthserver_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package runner
55

66
import (
7+
"bytes"
78
"context"
89
"crypto/ecdsa"
910
"crypto/elliptic"
@@ -13,6 +14,7 @@ import (
1314
"encoding/pem"
1415
"fmt"
1516
"io"
17+
"log/slog"
1618
"net/http"
1719
"net/http/httptest"
1820
"os"
@@ -1827,3 +1829,120 @@ func TestNewEmbeddedAuthServer_ClosesStorageOnError(t *testing.T) {
18271829
"failed NewEmbeddedAuthServer must Close the storage exactly once via the deferred-cleanup gate; "+
18281830
"a count of 0 indicates the deferred Close did not run, leaking the backend on the error path")
18291831
}
1832+
1833+
// urlErrorOnCloseStorage wraps an authserver storage and returns a fixed
1834+
// URL-bearing error from Close. It exists so
1835+
// TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog can verify that the
1836+
// deferred-cleanup gate routes both closeErr and retErr through
1837+
// sanitizeErrorForLog, scrubbing any query / userinfo / fragment that might
1838+
// carry credentials in a future regression.
1839+
type urlErrorOnCloseStorage struct {
1840+
storage.Storage
1841+
closeErr error
1842+
}
1843+
1844+
func (s *urlErrorOnCloseStorage) Close() error {
1845+
// Intentionally drop the inner Close result: this test is about the log
1846+
// path, not about double-closing the inner storage. Returning the
1847+
// fixed error makes the slog capture deterministic.
1848+
_ = s.Storage.Close()
1849+
return s.closeErr
1850+
}
1851+
1852+
// TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog pins the post-#5196
1853+
// invariant that the deferred-cleanup slog.Warn at the top of
1854+
// newEmbeddedAuthServerWithStorage routes both closeErr and retErr through
1855+
// sanitizeErrorForLog, so a future regression that drops the call (or that
1856+
// changes the error chain to inline an upstream response body containing a
1857+
// userinfo/query/fragment) cannot silently leak secrets to operator logs.
1858+
//
1859+
// The test injects a closeErr containing a URL with a secret-bearing query
1860+
// (?token=leak-marker) and a discovery URL whose host appears verbatim in
1861+
// the wrapped DCR error chain (so the captured slog record's `cause` field
1862+
// also exercises the sanitiser). It then asserts:
1863+
// - the captured log record does NOT contain the literal secret marker;
1864+
// - the captured log record DOES contain the host components, so
1865+
// operators retain enough context to correlate the failure.
1866+
//
1867+
// NOT t.Parallel(): the test swaps slog.Default() to capture output and
1868+
// restores it via t.Cleanup. Running in parallel would race with any other
1869+
// test in this package that emits a log record. Confirmed against the
1870+
// paralleltest rule on a sample run — every other test failed with a
1871+
// data-race report on slog's internal default-logger handle.
1872+
//
1873+
//nolint:paralleltest // see comment above; mutates the package-global slog.Default()
1874+
func TestNewEmbeddedAuthServer_DeferredCleanupSanitizesLog(t *testing.T) {
1875+
const (
1876+
closeErrSecretMarker = "close-leak-marker-7f9c"
1877+
retErrSecretMarker = "ret-leak-marker-3b2a"
1878+
)
1879+
1880+
closeErr := fmt.Errorf(
1881+
"redis: connection broken: https://primary:hidden@redis.example.com/0?token=%s",
1882+
closeErrSecretMarker,
1883+
)
1884+
tracker := &urlErrorOnCloseStorage{
1885+
Storage: storage.NewMemoryStorage(),
1886+
closeErr: closeErr,
1887+
}
1888+
1889+
// Discovery endpoint returns 500 so the DCR resolver fails and the
1890+
// constructor reaches the deferred-cleanup gate. The query string
1891+
// embedded in the discovery URL is what the resolver wraps into its
1892+
// error chain via fmt.Errorf("...%w", err) — so retErr's text is the
1893+
// vehicle for the second secret marker.
1894+
httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1895+
w.WriteHeader(http.StatusInternalServerError)
1896+
}))
1897+
t.Cleanup(httpServer.Close)
1898+
1899+
discoveryURL := httpServer.URL + "/.well-known/oauth-authorization-server?token=" + retErrSecretMarker
1900+
1901+
cfg := &authserver.RunConfig{
1902+
SchemaVersion: authserver.CurrentSchemaVersion,
1903+
Issuer: httpServer.URL,
1904+
Upstreams: []authserver.UpstreamRunConfig{
1905+
{
1906+
Name: "dcr-upstream",
1907+
Type: authserver.UpstreamProviderTypeOAuth2,
1908+
OAuth2Config: &authserver.OAuth2UpstreamRunConfig{
1909+
ClientID: "",
1910+
AuthorizationEndpoint: httpServer.URL + "/authorize",
1911+
TokenEndpoint: httpServer.URL + "/token",
1912+
Scopes: []string{"openid", "profile"},
1913+
DCRConfig: &authserver.DCRUpstreamConfig{
1914+
DiscoveryURL: discoveryURL,
1915+
},
1916+
},
1917+
},
1918+
},
1919+
AllowedAudiences: []string{"https://mcp.example.com"},
1920+
}
1921+
1922+
// Capture slog output by swapping the default logger for the duration
1923+
// of this test. Restore on cleanup so parallel tests are unaffected.
1924+
var buf bytes.Buffer
1925+
prev := slog.Default()
1926+
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
1927+
t.Cleanup(func() { slog.SetDefault(prev) })
1928+
1929+
embed, err := newEmbeddedAuthServerWithStorage(context.Background(), cfg, tracker)
1930+
require.Error(t, err)
1931+
assert.Nil(t, embed)
1932+
1933+
logged := buf.String()
1934+
1935+
// Defense in depth: both secret markers must be stripped before the
1936+
// Warn record reaches operator logs.
1937+
assert.NotContains(t, logged, closeErrSecretMarker,
1938+
"closeErr secret marker must be sanitised before reaching the Warn record")
1939+
assert.NotContains(t, logged, retErrSecretMarker,
1940+
"retErr secret marker must be sanitised before reaching the Warn record")
1941+
assert.NotContains(t, logged, "primary:hidden",
1942+
"closeErr userinfo must be sanitised before reaching the Warn record")
1943+
1944+
// The host components survive sanitisation so operators retain enough
1945+
// context to correlate the failure with upstream logs.
1946+
assert.Contains(t, logged, "redis.example.com",
1947+
"closeErr host must remain in the Warn record after sanitisation")
1948+
}

pkg/authserver/storage/types.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,18 @@ func validateDCRCredentialsForStore(creds *DCRCredentials) error {
229229
// retains entries for the process lifetime and is intentionally excluded from
230230
// the periodic cleanup loop. The Redis backend (future sub-issue) applies
231231
// TTL via SetEX when ClientSecretExpiresAt is non-zero.
232+
//
233+
// # Converter contract
234+
//
235+
// MUST update both converters in
236+
// pkg/authserver/runner/dcr_store.go (resolutionToCredentials and
237+
// credentialsToResolution) when adding, renaming, or removing a field
238+
// here. The two converters are the only translation seam between this
239+
// persisted type and the runner-side *DCRResolution; a field added here
240+
// without a paired converter update will silently fail to round-trip
241+
// across an authserver restart. The round-trip behaviour is pinned by
242+
// TestResolutionCredentialsRoundTrip in
243+
// pkg/authserver/runner/dcr_store_test.go.
232244
type DCRCredentials struct {
233245
// Key is the canonical cache key: (Issuer, RedirectURI, ScopesHash).
234246
Key DCRKey

0 commit comments

Comments
 (0)