Skip to content

feat(auth): add CredentialStore and cache GCP credentials - #1174

Open
wolo-lab wants to merge 9 commits into
wolo/auth-gcp-providerfrom
wolo/auth-store-cache
Open

feat(auth): add CredentialStore and cache GCP credentials#1174
wolo-lab wants to merge 9 commits into
wolo/auth-gcp-providerfrom
wolo/auth-store-cache

Conversation

@wolo-lab

@wolo-lab wolo-lab commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Problem

The GCP provider (#1173) does a credential-service round trip — plus up to a ~10s pending poll — on every outbound request. There is no caching. adk-python has an InMemoryCredentialService, but it is not on this provider's path at all, so caching GCP credentials is new work rather than a port.

A credential cache is also exactly where cross-user leaks live, so most of the design here is about what the key must cover.

Summary

  • Adds auth.CredentialStore and InMemoryCredentialStore: concurrency-safe, per-entry expiry, a small clock-skew margin exported as auth.ExpirySkew, and a paced sweep so a principal that resolves once and never returns does not keep its credential for the life of the process.
  • The GCP provider caches through the store (a private in-memory one by default, or ProviderConfig.Store). Client.RetrieveCredential returns a *Retrieval carrying the service's expireTime, so entries expire when the credential does.
  • The cache key covers everything that decides which credential comes back: the app, the end user, the resource, the scopes, the continue URI, and the *Client — which fixes both the service asked and the identity the ask is authenticated as. Components are length-prefixed before hashing, so no delimiter inside a scope or URI can collide two schemes. Entries are shared exactly among providers that share a Client, which is what makes one store safe behind several providers.
  • Nothing is cached without a usable lifetime. An absent, unparseable, already-past, or too-short expiry is declined, and the far end is clamped to an hour so a wrong or injected expireTime cannot pin an entry. That hour is also the bound on how long a credential revoked before its expiry keeps being served; Client.CacheKey names the entry to Delete sooner.
  • Caching never fails auth: a store read error, a write error, or a malformed hit all fall through to a fresh retrieval.

Two things worth flagging for review rather than reading out of the diff:

  • Client.RetrieveCredential's signature changes, which apidiff reports as the only incompatible change in either package. auth/gcp is absent from v2.0.0, v2.1.0 and v2.2.0, so this reaches only someone tracking main.
  • Concurrent cold misses on one key are not coalesced — N misses make N round trips. This is never worse than the per-request round trip it replaces, and singleflight would need a context detached from any one caller to avoid one cancellation failing everybody who joined. Left as a follow-up rather than folded into this change.

@wolo-lab
wolo-lab force-pushed the wolo/auth-store-cache branch from 995f518 to 32dc472 Compare July 18, 2026 20:44
@wolo-lab
wolo-lab force-pushed the wolo/auth-store-cache branch from 32dc472 to d392cd9 Compare July 18, 2026 23:33
@wolo-lab
wolo-lab force-pushed the wolo/auth-store-cache branch from eb90b2c to b8ec0e5 Compare July 19, 2026 14:48
@wolo-lab
wolo-lab marked this pull request as ready for review July 19, 2026 14:58
@wolo-lab
wolo-lab requested a review from hanorik July 20, 2026 07:55
@wolo-lab
wolo-lab force-pushed the wolo/auth-store-cache branch 2 times, most recently from df296cf to 725560f Compare July 22, 2026 19:51
@wolo-lab
wolo-lab force-pushed the wolo/auth-store-cache branch 2 times, most recently from 8376298 to fe42c2e Compare July 22, 2026 21:43
@wolo-lab
wolo-lab force-pushed the wolo/auth-store-cache branch from fe42c2e to 3b151ed Compare July 22, 2026 22:27

@karolpiotrowicz karolpiotrowicz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the expiry to the payload and the cache in the same change is the right order of operations, and it directly answers the "no caching" concern from the provider PR. I reviewed only the diff against wolo/auth-gcp-provider; the rest of auth/gcp belongs to the earlier PRs in the stack.

The headline is a clean result. Because a credential cache is exactly where cross-user leaks live, I attacked the cache key rather than reading it, and it holds up:

  • CredentialKey is a struct used directly as a map key, so there is no string concatenation and no delimiter/canonicalization surface. I tried 9 adversarial pairs (/, |, :, NUL delimiter shifting, empty components, case, Turkish dotless ı, NFC/NFD) — user a + key b/c vs user a/b + key c and friends — 0 collisions, with a negative control confirming the harness detects a real hit.
  • Two users on one provider get their own tokens on both the cold and cached read; the same user id under two different app names does not share an entry.
  • Excluding SessionID from the key is the right call, and I want to call it out explicitly since it's the kind of decision that looks like an omission later.

So I found no cross-principal disclosure. The findings below are about the dimensions the key doesn't cover, and about what protects it.


Main points

1. The cache key omits the scope dimension. Scopes and ContinueURI go on the wire and determine what the minted token authorizes, but they aren't in the key. Since ProviderConfig.Store is exported specifically so one store can back several providers, two providers for the same resource that differ only in scopes will collide. I measured it: a drive provider and a drive.readonly provider sharing one store, same user — the read-only provider was served the broad drive token and its own request never reached the service (1 service call total). The default per-provider store is unaffected (2 calls, correct tokens). Worth noting adk-python derives its credential key from a digest of the whole scheme, scopes included.

2. Nothing protects the cache key. Replacing provider.go:98 with key := auth.CredentialKey{} — one global entry shared by every user in the process — leaves the entire test suite green (70 packages, 0 failures), and that mutant is a genuine leak (bob receives alice's token). This isn't an unreasonable bar: the control mutant, removing the !r.ExpiresAt.IsZero() guard, is caught by TestProviderSkipsCacheWithoutExpiry. The gap is specific to the key. A two-user (and two-app) provider test asserting each principal gets its own token would close it, and it's the one thing I'd most like to see before this lands.

3. CredentialStore freezes as {Get, Set} with no way to invalidate. There's no Delete/Invalidate, so nothing can purge an entry ahead of its expiry — not a revocation, not a logout, not a downstream 401 — and the accepted lifetime is entirely service-chosen: a response claiming expireTime: 9999-12-31 is honoured verbatim. Because this is a bare exported interface with no unexported method, adding a method after release breaks every third-party implementer. Adding Delete (and clamping the accepted expiry) is cheap now and awkward later. The same argument applies to widening CredentialKey for point 1.

4. Eviction. The only removal is Get deleting the key it was asked for, so a principal that never returns retains its entry forever. Measured: 200k already-expired entries survive 10k Set/Get on a different live key, ~27 MiB retained. To be fair to the design, growth is O(distinct principals) at a few hundred bytes each, not O(requests) — re-caching the same keys millions of times adds nothing. Whether that needs an LRU/cap now depends on your expected tenant count, but a Delete on the interface is worth having regardless.

Smaller items

  • auth/store.go:79platform.Now(ctx) runs caller-supplied code while s.mu is held, and sync.Mutex isn't reentrant: a TimeProvider that touches the store deadlocks it (confirmed). Hoisting the call above the Lock fixes it.
  • auth/gcp/client.go:249-251 — the time.Parse error branch is the only uncovered block in the new code. The behaviour is safe (a malformed, empty, absent, past or proto-zero expireTime all correctly skip the cache — I checked all five), it's just unpinned; one table row would do it.
  • auth/gcp/provider.go:54ProviderConfig.Store isn't set by any test, so the plumbing (and the documented "a store write failure must not fail auth" property) is unasserted. The same test that fixes point 2 can take a store and cover this at no extra cost.
  • auth/store.go:100 — a store returning (nil, true, nil) makes Credential() return (nil, nil). auth.Transport fails closed on that, so it's minor, but treating a nil credential as a miss would be more robust.
  • auth/store.go:52-54 — the "mirrors adk-python's InMemoryCredentialService" comment is a little off: the app→user→key bucketing does match, but the Python service has no expiry, its key includes scopes, and for GcpAuthProviderScheme (a CustomAuthScheme) it isn't in the path at all. Maybe "serves the same role as" plus a note that this adds per-entry expiry.
  • auth/store.go:48 — a zero expiresAt meaning "never expires" makes the Go zero value the fail-open case. The provider guards it correctly; a second implementer might not.
  • auth/store.go:55 — a zero-value InMemoryCredentialStore panics on Set (nil map) while Get is a clean miss. A lazy init, or a doc line requiring the constructor, would remove the asymmetry.
  • auth/store_test.go:49 — the skew-window case uses the real wall clock while the very next test demonstrates the platform.WithTimeProvider seam.

Questions rather than findings

  • Do the credential services actually mint scope-differentiated tokens for a single authProviders/* resource? If they don't, point 1 is only a documentation note — I couldn't verify this from outside.
  • Is sharing one Store across providers a configuration you intend to support? Nothing in-repo does it today, and if it isn't intended, documenting that on the field would be an alternative to widening the key.
  • Concurrent cold misses for one identity aren't coalesced (32 goroutines → 32 round trips). This is not a regression — I compared against pre-cache behaviour and the change is never worse, and far better in steady state (32 sequential resolves → 1 call vs 32) — but singleflight is already imported in the file if the cold-start burst matters against a per-user quota.

One cross-reference: the redirect issue noted on the earlier client PR becomes more durable with caching in place. An injected credential is now stored with an attacker-chosen expiry and kept being served after the source is unreachable, where previously each resolve refetched. Nothing to change here — it's an argument for fixing it upstream, and for clamping the expiry this PR accepts.

Build, go vet, gofmt, golangci-lint, staticcheck, go mod tidy -diff and go test -race are all clean, and govulncheck's two findings are identical at the base commit (zero delta). TestProviderCachesCredential correctly fails at the base commit, so the new coverage is real.

Comment thread auth/gcp/provider.go Outdated
return nil, errors.New("gcp: no acting user in ADK context; provider must run within an agent invocation")
}

key := auth.CredentialKey{AppName: id.AppName, UserID: id.UserID, Key: p.scheme.Name}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key covers app, user and resource, but not Scopes or ContinueURI — and both are sent on the wire (retrieveRequest), so they determine what the minted token authorizes.

Because ProviderConfig.Store is exported so one store can back several providers, two providers for the same resource that differ only in scopes collide. I measured this with a shared store: a drive provider and a drive.readonly provider, same user — the read-only provider was served the broad drive token, and its own request never reached the service (1 service call total). With the default per-provider store there's no collision (2 calls, correct tokens each).

Direction matters: broad-then-narrow hands out more authority than was asked for; narrow-then-broad produces a confusing 403. For reference, adk-python derives its credential key from a digest of the whole scheme, scopes included.

Suggestion — widen the key while the package is still unreleased:

// in NewProvider, after the Scopes clone:
slices.Sort(scheme.Scopes)

// in Credential:
key := auth.CredentialKey{
	AppName: id.AppName,
	UserID:  id.UserID,
	Key:     p.scheme.Name + "|" + strings.Join(p.scheme.Scopes, ",") + "|" + p.scheme.ContinueURI,
}

If sharing a store across differently-scoped providers isn't a configuration you want to support, documenting that on ProviderConfig.Store would be a reasonable alternative.

Comment thread auth/gcp/provider.go Outdated

key := auth.CredentialKey{AppName: id.AppName, UserID: id.UserID, Key: p.scheme.Name}
// A store read error is non-fatal: fall through and fetch a fresh credential.
if cred, ok, err := p.store.Get(ctx, key); err == nil && ok {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor robustness point: Get returns (Credential, bool, error), so (nil, true, nil) is representable, and this returns it straight through — Credential() then hands the caller (nil, nil). I confirmed that with a store implementation returning a nil credential on a hit.

auth.Transport fails closed on a nil credential, so nothing in-repo breaks, but CredentialProvider is exported and third-party callers may not check. Treating it as a miss keeps the failure local:

if cred, ok, err := p.store.Get(ctx, key); err == nil && ok && cred != nil {
	return cred, nil
}

Rejecting a nil credential in Set would close the other half.

Comment thread auth/gcp/provider.go
// resource). When nil, an in-memory store is used. Caching matters here
// because each miss is a network round-trip (and up to a ~10s pending poll)
// to the credential service.
Store auth.CredentialStore

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test sets this field, so the store plumbing is unasserted — including the best-effort property described at line 120 ("a store write failure must not fail auth"), which is currently stated only in a comment. A fake store that records calls and one that always errors would pin both, and the two-user test suggested on line 98 could take the store as a parameter and cover this at no extra cost.

Comment thread auth/gcp/provider_test.go Outdated
}
}

func TestProviderCachesCredential(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts the call count but never that two principals stay separate, and that turns out to be the only thing standing between the cache and a cross-user leak.

Concretely: replacing the key construction at provider.go:98 with key := auth.CredentialKey{} — one global entry shared by every user in the process — leaves the whole test suite green (70 packages, 0 failures), and that mutant genuinely leaks: with it, a second user receives the first user's token. The bar is reasonable, though — the control mutant (removing the !r.ExpiresAt.IsZero() guard) is caught, by TestProviderSkipsCacheWithoutExpiry on line 132. The gap is specific to the key.

A test along these lines would close it:

func TestProviderCacheIsolatesPrincipals(t *testing.T) {
	// server echoes the requesting userId back in the token
	// resolve for user-1 and user-2 (and for the same user under two app names)
	// assert each receives its own token, on the cold read and on the cached read
}

Comment thread auth/store.go
// Get returns the cached, unexpired credential for key, if present.
Get(ctx context.Context, key CredentialKey) (Credential, bool, error)
// Set stores cred for key. A zero expiresAt means the entry does not expire.
Set(ctx context.Context, key CredentialKey, cred Credential, expiresAt time.Time) error

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CredentialStore is a bare exported interface with no unexported method, so its method set freezes the moment this ships: any later addition breaks every third-party implementer. Two things are worth settling now rather than after release.

No invalidation. With only {Get, Set} there's no way to purge an entry ahead of its expiry — not on consent revocation, not on logout, not on a downstream 401. A Delete(ctx context.Context, key CredentialKey) error costs little today.

The accepted lifetime is entirely service-chosen. A response claiming expireTime: 9999-12-31T23:59:59Z is honoured verbatim — I checked, and three resolves then produce a single service call. Clamping to a sane maximum in the provider before calling Set would bound the exposure of any bad or injected expiry.

Comment thread auth/store.go Outdated
type CredentialStore interface {
// Get returns the cached, unexpired credential for key, if present.
Get(ctx context.Context, key CredentialKey) (Credential, bool, error)
// Set stores cred for key. A zero expiresAt means the entry does not expire.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making a zero expiresAt mean "never expires" puts the fail-open case on the Go zero value, in brand-new public API. The GCP provider handles it correctly (it only calls Set when the service reported an expiry), but that guard lives in the consumer rather than in the store, so a second implementer or a future caller gets a permanent entry by omission.

Either invert it (zero = already expired) or, if the current semantics are deliberate, make it explicit here — something like "a zero expiresAt means the entry never expires; callers that cannot establish a lifetime should not call Set".

Comment thread auth/store.go Outdated
}

// InMemoryCredentialStore is a concurrency-safe, process-local [CredentialStore]
// (per app+user+key, across sessions). It mirrors adk-python's

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comparison is a bit off on the details. The app→user→key bucketing genuinely does match InMemoryCredentialService, but: that service has no expiry at all (this one adds per-entry expiry, which is an improvement); its credential key is a digest over the whole auth scheme, scopes included; and for GcpAuthProviderScheme — a CustomAuthSchemeCredentialManager returns the provider's credential directly and never consults the credential service.

Something like "serves the same role as adk-python's InMemoryCredentialService (app+user+key bucketing), and adds per-entry expiry" would be accurate.

Comment thread auth/store.go
// InMemoryCredentialStore is a concurrency-safe, process-local [CredentialStore]
// (per app+user+key, across sessions). It mirrors adk-python's
// InMemoryCredentialService.
type InMemoryCredentialStore struct {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A zero-value InMemoryCredentialStore is asymmetric: Get is a clean miss, but Set panics with assignment to entry in nil map. Since the type is exported, var s auth.InMemoryCredentialStore is a natural thing to write.

Either initialise lazily in Set under the lock (if s.entries == nil { s.entries = make(...) }) or state on the type that NewInMemoryCredentialStore is required.

Comment thread auth/gcp/client.go
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error branch is the only uncovered block in the new code. The behaviour is right — I checked malformed, empty, absent, past and 0001-01-01T00:00:00Z inputs, and all five correctly skip the cache (three resolves → three service calls), so it fails closed. It's just not pinned, on a path that parses input from a network service.

One row in the TestRetrieveCredential table with "expireTime":"not-a-time", asserting the credential still comes back with a zero ExpiresAt, would cover it.

Worth noting in passing that 0001-01-01T00:00:00Z parses to exactly time.Time{}, so "the service sent proto-zero" and "the service sent nothing" are indistinguishable downstream. Both mean "don't cache" today, so it's harmless — just a little surprising if the zero ever grows a second meaning.

Comment thread auth/store_test.go
}

func TestInMemoryCredentialStoreExpiry(t *testing.T) {
ctx := t.Context()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case asserts the clock-skew window against the real wall clock, while TestInMemoryCredentialStoreGetHonorsClock just below demonstrates the deterministic platform.WithTimeProvider seam. The 10s skew gives plenty of headroom so it's unlikely to flake in practice, but using the seam here too would make the intent ("an expiry inside the skew window is treated as expired") explicit rather than timing-dependent.

@karolpiotrowicz karolpiotrowicz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 2e12ee85, against the merge base 7e641247. The store's expiry logic is careful — the skew is applied in the conservative direction, the service's expiry is distrusted and capped, and the clock is resolved outside the lock with a comment explaining why. Three things need fixing before this lands — two cache-key defects and one revocation gap — and two more are worth settling in the same pass: a pair of key dimensions no test can fail on, and the store's growth behavior.

First, though: this branch does not merge into its own base. There are content conflicts in auth/gcp/provider.go and auth/gcp/provider_test.go, and the branch is 8 commits behind wolo/auth-gcp-provider, whose head moved to 6dbe3401 while this review was running. Those are the two files most of what follows is about, so it is worth rebasing before acting on any of it — some line references will move.

A shared store serves one caller identity's token to another

The cache key is {AppName, UserID, slot} at provider.go#L132, and the slot covers only Name, Scopes and ContinueURI (provider.go#L101-L105). The *Client is not in it, and the *Client is what fixes the minting identity — Config.HTTPClient is documented at client.go#L96-L100 as "used verbatim and ADC is not applied, so it must carry its own credentials". Two providers built with different HTTPClient identities and one shared Store therefore share a cache entry, and sharing a store across providers is this PR's own tested idiom.

The comment at provider.go#L129-L131 says the slot "covers everything that shapes the minted token". The caller identity shapes it and is not covered.

Reproduction — one endpoint, two caller identities, one shared store
// in auth/gcp, package gcp_test. Run: go test -run TestClientIdentityNotInKey ./auth/gcp/
type identityRT struct {
	base http.RoundTripper
	who  string
}

func (t identityRT) RoundTrip(r *http.Request) (*http.Response, error) {
	r = r.Clone(r.Context())
	r.Header.Set("X-Caller-Identity", t.who)
	return t.base.RoundTrip(r)
}

func TestClientIdentityNotInKey(t *testing.T) {
	var seen []string
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		who := r.Header.Get("X-Caller-Identity")
		seen = append(seen, who)
		// the service mints a token scoped to the CALLING service account
		_, _ = io.WriteString(w, `{"success":{"token":"tok-minted-for-`+who+
			`","header":"Authorization: Bearer","expireTime":"2999-01-01T00:00:00Z"}}`)
	}))
	defer srv.Close()

	store := auth.NewInMemoryCredentialStore()
	scheme := gcp.Scheme{Name: "projects/p/locations/l/authProviders/ap", Scopes: []string{"drive"}}
	mk := func(who string) auth.CredentialProvider {
		hc := &http.Client{Transport: identityRT{base: srv.Client().Transport, who: who}}
		c, _ := gcp.NewClient(t.Context(), &gcp.Config{HTTPClient: hc, AgentIdentityEndpoint: srv.URL})
		p, _ := gcp.NewProvider(scheme, &gcp.ProviderConfig{Client: c, Store: store})
		return p
	}

	_, _ = mk("sa-alpha").Credential(adkContext(t, "user-1"))
	cred, _ := mk("sa-beta").Credential(adkContext(t, "user-1"))
	bc := cred.(auth.BearerCredential)
	if bc.Token != "tok-minted-for-sa-beta" {
		t.Errorf("sa-beta provider served %q; service saw callers %v", bc.Token, seen)
	}
}

Observed: the sa-beta provider is served tok-minted-for-sa-alpha, and the service records only [sa-alpha] as a caller.

The slot encoding is not injective

provider.go#L104 joins with | and ,, neither of which is escaped or excluded from the components. resourceNameRE does bar | from Name, but that check runs inside RetrieveCredential after the store read, and it is never applied to Scopes or ContinueURI — which is where both collisions live.

Scopes:["a,b"]  and  Scopes:["a","b"]                          -> both slot to  "R|a,b|"
{Scopes:["x"], ContinueURI:"y|z"}  and  {Scopes:["x|y"], ContinueURI:"z"}  -> both slot to  "R|x|y|z"

Driven through NewProvider(...).Credential(ctx) with a shared store, each pair produces one service call instead of two, and the second provider is handed a token minted for the first one's scopes. Since the encoding is the thing at fault rather than any one component, length-prefixing or hashing a struct-encoded key would close the whole class rather than the two instances above — I have not run that change, so treat it as a direction and not a verified patch.

A revoked credential keeps working for up to an hour

store.go#L53-L56 documents Delete as the hook for "consent revocation, logout, or a downstream 401". Nothing outside tests calls it, and auth.Transport.RoundTrip returns a downstream 401 to the caller without invalidating anything.

Before this change every Credential() was a live round trip, so a revocation took effect on the very next request. After it, warming the cache and then making the service reject returns the cached token with a nil error and never re-consults the service, for min(service expiry, now+1h). That may well be the trade you intend for a credential cache — but right now the interface doc promises an invalidation path that no code path reaches, so either the wiring or the promise should change.

Two key dimensions have no test that can fail

AppName and ContinueURI are both in the key by construction and neither is covered. Deleting AppName from the key at provider.go#L132 leaves go test ./auth/... fully green, and so does dropping ContinueURI from the slot. The cause for the first is provider_test.go#L208, which hardcodes AppName: "app" at the only place a test context is built.

This is worth separating from ordinary coverage talk: schemeSlot is at 100% statement coverage and the ContinueURI deletion still survives. The UserID, Scopes and skew dimensions all do have tests that die when you break them, so the gap is specific rather than general.

A second, separate test problem sits next to it. TestProviderClampsServiceExpiry at provider_test.go#L388-L391 hand-writes the slot string and asserts only that no entry exists, with no positive control that one existed beforehand. Any change to the slot format turns it into a silent no-op that also stops guarding maxCachedLifetime.

Deleting the "don't cache without an expiry" guard also leaves the suite green

Removing if !r.ExpiresAt.IsZero() at provider.go#L155-L160 and calling Set unconditionally breaks no test, because TestProviderSkipsCacheWithoutExpiry passes no Store and so measures the default store's own rejection rather than the provider's guard. Since ProviderConfig.Store is exported, the guard is exactly the thing that protects a third-party store from being asked to cache a credential of unknown lifetime.

The same shape applies to the store's locking: removing every mutex from auth/store.go leaves go test -race ./auth/... green, because no test drives the store from two goroutines at once. The locking is correct as written — the green race run just is not evidence of it.

Growth

store.go#L119-L126 sweeps only on every 256th Set, Get evicts only the key it looked up, and there is no size cap. 255 principals that each resolve once and never return leave 255 expired entries resident indefinitely, which is the case store.go#L59-L60 already names. That is bearer-token material staying in the heap well past its usefulness.

Smaller things

  • Get's three-value return is unspecified. store.go#L47-L48 says nothing about (nil, true, nil) or about a credential returned alongside a non-fatal error. The provider requires err == nil && ok && cred != nil, so a store reporting a degraded backend has its credential silently dropped and the cache becomes permanently ineffective. The comment at provider.go#L133-L134 says "the interface allows it", but the interface does not say so, and this is new exported surface so it is cheapest to pin now.
  • A past-but-well-formed expireTime is written to the store. Only the upper bound is clamped. InMemoryCredentialStore shrugs it off on the next Get, but a TTL-based implementation computing expiresAt.Sub(now) gets a negative TTL.
  • Concurrent cold callers are not coalesced. provider.go#L135-L160 is an unsynchronized check-then-act, so N concurrent misses on one key can each make their own round trip and each poll for up to the 10s timeout. singleflight is already imported and used for the much cheaper client init at provider.go#L187. This is still a strict improvement over the previous per-request round trip, so it is a follow-up rather than a blocker.
  • RetrieveCredential's signature change is source-breaking, confirmed by apidiff as the only incompatible change in either package. It has never appeared in a tagged v2 release — auth/gcp/client.go is absent from v2.0.0, v2.1.0 and v2.2.0 — so this only reaches anyone tracking main. Worth a line in the PR body rather than any code change.
  • expirySkew is unexported, so a third-party store cannot honor the tolerance the provider assumes, and the provider re-checks nothing after a hit.
  • ConsentRequiredError.Key is a string documented as "the credential-store key", while the store's key is now a struct. That field predates this PR and is never populated, but the two now describe the same concept incompatibly.
  • %+v on a CredentialKey at store.go#L110 puts the app name and end-user ID into an error string. No token material, so this is log hygiene rather than credential exposure.
  • An empty AppName is accepted where an empty UserID is rejected, so two apps that both omit it share cache entries.

One claim I want to flag as not a problem, in case it comes up: parseExpireTime using time.RFC3339 is fine. Go's parser accepts fractional seconds after a seconds field regardless of the layout — .000Z, .123456Z and .123456789Z all parse cleanly on go1.26.6, so protobuf JSON timestamps are handled.

Add auth.CredentialStore + InMemoryCredentialStore (concurrency-safe,
per-entry expiry with a small clock-skew margin) and cache resolved GCP
credentials in the provider, keyed by (app, user, resource). The client
surfaces the service's expireTime via a Retrieval result so entries expire
correctly; TokenSource-backed providers are self-caching and don't use it.

Resolves the acting user via agent.IdentityFromContext (rebased onto the
current auth/gcp provider).
Store:
- Add Delete, so a credential can be invalidated ahead of its expiry —
  on revocation, logout, or a downstream 401. The interface is exported
  and has no unexported method, so its method set freezes on release.
- Set requires a credential and an expiry. A zero expiry meant "never
  expires", putting the fail-open case on the Go zero value in new
  public API; a caller that cannot establish a lifetime must not cache.
- Read the clock before taking the lock: platform.Now is caller-supplied,
  and a TimeProvider that reaches back into the store deadlocked it.
- Sweep expired entries periodically. Get evicts only the key it is asked
  for, so a principal that resolves once and never returns kept its
  credential for the life of the process.
- The zero value works: Set used to panic on the nil map.

GCP provider:
- Key the cache on scopes and the continue URI as well as the resource.
  They shape what the token authorizes, so with a shared store a
  drive.readonly provider was served the broad drive token.
- Clamp the cached lifetime, so a bad or injected expireTime cannot pin a
  credential in the cache.
- Treat a cache hit carrying no credential as a miss.

Tests: per-principal and per-scope isolation (collapsing the key left the
whole suite green), the store plumbing including a failing write, the
clamp, the sweep, the reentrant clock, and an unparseable expireTime.
@wolo-lab
wolo-lab force-pushed the wolo/auth-store-cache branch from 2e12ee8 to cee554d Compare August 25, 2026 22:48
The cache key covered the app, the end user, the resource, the scopes and
the continue URI, but not the *Client. The Client fixes both the service
asked and the identity the ask is authenticated as — Config.HTTPClient is
used verbatim and ADC is not applied — so two providers built on
different caller identities and one shared Store shared an entry, and the
second was served the first one's token. Sharing a Store across providers
is this PR's own tested idiom.

The key is now derived from the Client as well, which means resolving the
client before reading the cache. A caller-supplied HTTPClient is opaque,
so each gets a slot of its own; clients left to Application Default
Credentials all authenticate as one process principal and keep sharing.

The slot encoding was also not injective: it joined on "|" and ",",
neither escaped nor barred from a scope or a URI, so Scopes:["a,b"] and
Scopes:["a","b"] collided, as did {["x"],"y|z"} and {["x|y"],"z"}.
Components are length-prefixed before hashing now, which closes the class
rather than the two instances. adk-python keys the same way, on a SHA-256
digest of a canonical encoding of the scheme plus the credential used to
obtain it.

Store:
- Pace the sweep by wall clock, and let a Get drive it. Sweeping every
  256th Set never fires again once a process stops writing, so expired
  entries for principals that never return stayed resident; counting Gets
  toward the same counter would charge every call an amortised
  O(entries)/N instead. State the resulting growth bound on the type.
- Specify Get's three-value return: a miss is (nil, false, nil), a hit
  never carries a nil credential, and a credential is never returned
  alongside an error, since callers discard it when the error is non-nil.
- Say what a store must do about expiry tolerance, rather than leaving
  the margin an unexported detail a third-party store cannot match.
- Keep the key out of Set's error text: an error is logged far more
  freely than the store's contents.
- Delete is the caller's invalidation hook and ADK does not call it. Say
  so, and say on ProviderConfig.Store that a revoked credential is served
  until the entry expires — the service's expiry or an hour.

An expireTime already in the past is no longer cached: only the far end
was clamped, so a store deriving a TTL from it got a negative one. That
one test also subsumes an absent and an unparseable expiry, both of which
arrive as the zero time.

ConsentRequiredError.Key described itself as "the credential-store key"
while the store's key is now a struct. Doc-only — the field ships in
v2.1.0.

Tests: every dimension of the cache key, each verified by deleting it and
watching a named case fail (collapsing the key used to leave all 70
packages green); the two encoding collisions; that the cache still hits
across providers, and that scope order is not a dimension; what lifetime
the provider will cache, asked of the store rather than inferred from a
call count, with a positive control; the store under eight concurrent
goroutines, which is what makes the -race run evidence of the locking;
and the sweep firing and being paced.
…t have

The cache key covered the Client by way of an inference — a caller-supplied
HTTPClient is opaque so each got its own slot, while clients left to
Application Default Credentials were assumed to authenticate as one
process principal and shared a slot derived from the endpoints. Review
found two ways that assumption fails today, both reachable without doing
anything unusual:

- NewClient resolves ADC on every call, not once per process, so two ADC
  clients straddling a credentials-file rewrite (rotation, gcloud auth
  application-default login, a sidecar materializing the file after
  startup) are different principals with the same slot.
- oauth2.NewClient takes its base transport from oauth2.HTTPClient on the
  context, which runs beneath the ADC transport and can present another
  identity — and Config.PollTimeout's own doc tells callers to put a
  client there.

A third failure applied to both branches: the slot was a counter starting
at 1 in every process, so a store that outlives the process, or is shared
by two, serves an entry written by a Client that no longer exists.

So the slot no longer claims to know an identity. It is one value per
Client, drawn from a per-process nonce and a counter, and this package
does not pretend two Clients agree unless they are the same Client.
Entries are shared exactly among providers sharing a Client, which is
stated on ProviderConfig.Store and Config.HTTPClient — building a Client
per request or per provider now defeats the cache rather than risking a
wrong token.

Client.CacheKey makes the documented invalidation hook real. Both
CredentialStore.Delete and ProviderConfig.Store told callers to delete an
entry to invalidate a credential early, and no exported API could name
the key.

Also from review:
- A credential with less lifetime left than the store's margin was cached
  and then refused on the very next read. The provider now declines it,
  and the margin is exported as auth.ExpirySkew so a third-party store can
  apply the same one and a producer can test against it.
- The sweep treated a clock that went backwards as "not due", so one call
  arriving with a far-future reading parked lastSweep there and switched
  eviction off until real time caught up. The clock is caller-supplied and
  need not be monotonic.
- Set strips the monotonic reading from expiresAt. Comparing a clamped
  entry (derived from platform.Now) against an unclamped one (parsed from
  the wire) put two entries in different clock domains, which diverge
  across a suspend.
- CredentialKey.Key's doc recommended "typically the target resource or
  scheme name", which is exactly the collision this PR's own slot exists
  to avoid: two providers for one resource differing only in scope would
  share an entry. It now says what the slot must distinguish and why to
  derive it from a digest.
- Set's doc states the immutability the store relies on, and that a store
  persisting entries must cope with a credential it cannot encode.
- Delete's doc states that it does not cancel a retrieval already in
  flight.
- The adk-python parity note claimed Go keys "the same way". It does not:
  Python digests the credential, Go cannot see one and names the Client
  instead. Also two truncated digests there, not one full one.
- expireTime's name and its "may expire slightly earlier" and "might be
  permanent" semantics now cite the published API surface, which is what
  the skew, the cap and the don't-cache-without-an-expiry rule rest on.
- auth's package doc mentions the store.

Tests. Every one of these was found by deleting the code and watching the
suite stay green:
- echoRequest.token(), the helper the cache-dimension test compares
  tokens with, joined on delimiters and so collided on exactly the two
  pairs the test was written to catch — those cases rested on the call
  count alone. It is length-prefixed now and carries the request path.
- The concurrency test returned no expireTime, so 32 goroutines never
  reached store.Set at all, and passed nil Scopes, so sorting the scopes
  in place instead of on a clone was a no-op. Both fixed, and -race now
  catches the in-place sort.
- The store's concurrency test stored only live entries, so Get's
  eviction and the sweep's deletes never ran under contention and an
  RWMutex read lock survived it.
- A store read error was untested, so making it fatal survived.
- recordingStore.lastKey was recorded and never asserted, so swapping
  AppName and UserID in the key survived.
- Get's per-key eviction, the sweep after a clock jump, and the stripped
  monotonic reading each have a test that fails without them.
Two doc-only points from review.

parseExpireTime described what it does, not why: an absent expiry and an
unparseable one collapse to the same zero time on purpose, because the
service omits the field when the token may be permanent or when it cannot
say, and a value we cannot read tells us no more than silence. Both mean
"do not cache".

CredentialStore's frozen method set is a deliberate trade, not an
oversight — an unexported marker method would close the seam the interface
exists to open. Say so, so that a maintainer tempted to add a method later
knows what it costs, and why Delete is there before anything needs it.
platform.Now is scoped to one call tree by design — that isolation is what
platform's own doc says makes concurrent runs with independent providers
safe — and the sweep was reading it to decide the fate of every entry in
the store. One request arriving with a clock pinned a year ahead emptied
a shared store, and two call trees whose clocks differed by more than the
interval made every call pay an O(entries) scan under the lock. The same
read-before-the-lock that keeps a reentrant clock from deadlocking also
let three concurrent callers each sweep, where the comment promised one.

The sweep now reads time.Now itself. That is one decision, about entries
the caller does not own, so it should not be the caller's to make — and
because two time.Now readings are monotonic, the whole class of stepped,
frozen and rewound clocks disappears with it. The per-key expiry test in
Get still uses the caller's clock, which is what tests drive.

Delete sweeps too, which the type's doc already claimed: a caller whose
only traffic after a burst is invalidation ran none.

expired() now counts exactly ExpirySkew of remaining life as spent, which
makes it the precise complement of the provider's caching floor. They
differed by a nanosecond at the boundary, so the provider declined to
cache something the store would have served.

Client.CacheKey needs a Client, and the lazy default does not give the
caller one — the provider builds it internally and does not expose it. So
the advice to invalidate with it now says to set ProviderConfig.Client if
you intend to invalidate at all, instead of stating it unconditionally.

The adk-python parity claim on the slot was still wrong in both
directions: Python's key is a joined string of two truncated digests, and
more to the point its credential service is not on this provider's path
at all — GcpAuthProviderScheme is a CustomAuthScheme, so CredentialManager
returns the provider's credential without ever loading or saving one.
Caching GCP credentials is a Go addition. Say that. ErrNoActingUser's doc
claimed adk-python degrades a userless turn into an auth request; both
Python providers raise, and the degradation is the consent path.

Also: the store's growth bound now names the case that makes it grow with
request rate (a Client per request, since the Client is a key dimension);
Get's contract states the margin bound a conforming store must respect and
that the credential it returns must be safe for concurrent Apply; Set's
says the key and the credential are both secret; Delete's says a retrieval
that began before the revocation restores exactly the credential you were
removing; the connector's expireTime is documented as an assumed name,
since that service publishes no discovery document to anonymous callers;
and Transport's doc no longer says caching belongs to the token source.

Tests, each pinned by deleting the code and watching a named case fail:
- The two encoding-collision cases could not fail on the encoding at all.
  Both pairs also differ in the scope count, so joining on a comma passed
  them. joinFields is now tested directly for injectivity over 820 field
  lists built from an alphabet of delimiters.
- The IAM Connector's expiry path had no fixture carrying an expireTime,
  so dropping it left the suite green and a connector-backed provider
  would silently never cache.
- NewClient is called concurrently in production, by two providers on the
  lazy path, and nothing tested that two of them get different slots.
- The nonce test measured the constant against itself, so narrowing the
  nonce to one byte passed it; the skew test did the same, so widening
  ExpirySkew six-fold passed. Both now assert against a fixed expectation:
  128 bits, and a credential with a minute of life left is still cached.
- The store concurrency test's sweep never ran on a non-empty map, so the
  delete loop had no concurrent coverage. It is armed now.
- recordingStore implements an interface documented safe for concurrent
  use and was not.
The last round moved the sweep off the caller's clock and left the rest of
the path on it, so the store held timestamps written on platform.Now and
deleted them on time.Now. A call tree whose clock trails real time had its
own live entries swept out from under it and never got a cache hit again.

The right resolution is not to pick a side per call site. A credential's
expiry is not the kind of quantity platform.Now is for: that seam is
scoped to one call tree so concurrent runs can hold independent clocks,
and a token dies when its issuer says it does, on everybody's clock at
once. So nothing on this path reads a simulated clock any more — not the
store's per-key test, not its sweep, and not the provider's floor or
clamp. One domain, no residual, and the reentrancy hazard that made the
store read its clock before taking the lock is gone with it.

Tests drive expiry by choosing how much life to store rather than by
moving a clock, which is what the sweep tests already did. The
clock-may-touch-the-store test guarded a hazard that no longer exists.

Also from review:

- The joinFields injectivity test could not fail on the delimiter. Every
  field in its alphabet was under ten bytes, so the length prefix was a
  single digit and self-punctuating, and dropping the ":" survived. It now
  carries the witness: ["23", 19 a's] and ["319" + 19 a's] encode alike
  without it.
- TestExpirySkewBoundary measured the constant against itself, so any
  value in [10s, 60s) shipped green. Two cases are spelled in seconds now,
  and the exact boundary moved to an internal test of expired() — a wall
  clock cannot hold still long enough to test equality through Get.
- The concurrency test swept on every call, which removed every expired
  entry before a Get could reach one and left the eviction branch with no
  concurrent coverage. Paced instead of disabled.
- Config.HTTPClient now states the precondition this package cannot
  enforce: one Client must authenticate as one identity. A transport that
  picks credentials out of the request context makes a Client several
  principals sharing one cache entry, and nothing here can see it.
- The sweep's rationale claimed the rejected design would make every call
  sweep. Under that design lastSweep only moves forward, so the real
  pathology is a frozen future clock after which nothing sweeps again.
- Get's contract said producers decline with "less than" the margin left;
  the reference producer and expired() both treat exactly the margin as
  spent.
…equest

Adding ExpireTime to credentialPayload made the whole retrieval fail on a
value of the wrong JSON type. The field feeds nothing but the cache, and
the IAM Connector's shape for it is an assumption — that service publishes
no discovery document to anonymous callers — so a connector answering
{"expireTime":{"seconds":...}} would have taken auth down with it, where
before this PR the unknown field was ignored. It decodes leniently now:
anything that is not a JSON string leaves the expiry empty, which is what
the field's own doc already promised.

Three smaller things from the same review:

- Two boundary cases had a one-second real-time budget between storing an
  expiry and reading it back, which is a flake waiting for a loaded
  runner. They carry a minute of slack now, and ExpirySkew's value is
  asserted against a literal instead — a test written only in terms of the
  constant moves with it and pins nothing.
- The provider's caching floor could be widened to any multiple of the
  margin and stay green. A credential with twice the margin left must
  still be cached: the floor exists to keep out an entry the store would
  refuse on sight, not to refuse short-lived credentials.
- Three comments said things that were not true. ExpirySkew's own doc gave
  the producer boundary as exclusive where the code and its two sibling
  doc sites are inclusive; the concurrency test claimed a zero sweepEvery
  would sweep on every call, when zero is the sentinel for the one-minute
  default.
…he key

Three doc points and one test guard from review.

The service-account provider's comment called itself "stricter than
adk-python (scopes optional there)". adk-python raises on exactly the same
input — an explicit key with no scopes — and defaults them only on the
default-credential branch, which this code does too. Left as written, a
maintainer restoring "parity" would delete a guard that is already at it.

The cache slot's comment named _stable_model_digest as adk-python's
analogue; that helper digests one model, and it is
AuthConfig.get_credential_key that joins two of them into a store key,
which is the thing being compared.

CredentialKey's doc told the producer to digest rather than join its
inputs, and said nothing to a store implementer about the three fields it
is handed. A store that flattens them into a row key on a separator lets
{app "acme", user "bob|X"} and {app "acme|bob", user "X"} name one entry,
and neither field is authenticated by ADK. InMemoryCredentialStore uses a
struct map key and is immune; a persisted store is exactly what the
interface invites, and the interface freezes.

The client test discarded the error from parsing its own expected expiry,
so a mistyped case would silently expect the zero time — which is what a
dropped expiry produces, inverting the guard it exists to be.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants