feat(auth): add CredentialStore and cache GCP credentials - #1174
feat(auth): add CredentialStore and cache GCP credentials#1174wolo-lab wants to merge 9 commits into
Conversation
995f518 to
32dc472
Compare
32dc472 to
d392cd9
Compare
eb90b2c to
b8ec0e5
Compare
df296cf to
725560f
Compare
8376298 to
fe42c2e
Compare
fe42c2e to
3b151ed
Compare
There was a problem hiding this comment.
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:
CredentialKeyis 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) — usera+ keyb/cvs usera/b+ keycand 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
SessionIDfrom 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:79—platform.Now(ctx)runs caller-supplied code whiles.muis held, andsync.Mutexisn't reentrant: aTimeProviderthat touches the store deadlocks it (confirmed). Hoisting the call above theLockfixes it.auth/gcp/client.go:249-251— thetime.Parseerror branch is the only uncovered block in the new code. The behaviour is safe (a malformed, empty, absent, past or proto-zeroexpireTimeall correctly skip the cache — I checked all five), it's just unpinned; one table row would do it.auth/gcp/provider.go:54—ProviderConfig.Storeisn'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)makesCredential()return(nil, nil).auth.Transportfails 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'sInMemoryCredentialService" comment is a little off: the app→user→key bucketing does match, but the Python service has no expiry, its key includes scopes, and forGcpAuthProviderScheme(aCustomAuthScheme) 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 zeroexpiresAtmeaning "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-valueInMemoryCredentialStorepanics onSet(nil map) whileGetis 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 theplatform.WithTimeProviderseam.
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
Storeacross 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
singleflightis 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.
| 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} |
There was a problem hiding this comment.
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.
|
|
||
| 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 { |
There was a problem hiding this comment.
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.
| // 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 |
There was a problem hiding this comment.
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.
| } | ||
| } | ||
|
|
||
| func TestProviderCachesCredential(t *testing.T) { |
There was a problem hiding this comment.
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
}| // 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 |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
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".
| } | ||
|
|
||
| // InMemoryCredentialStore is a concurrency-safe, process-local [CredentialStore] | ||
| // (per app+user+key, across sessions). It mirrors adk-python's |
There was a problem hiding this comment.
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 CustomAuthScheme — CredentialManager 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.
| // InMemoryCredentialStore is a concurrency-safe, process-local [CredentialStore] | ||
| // (per app+user+key, across sessions). It mirrors adk-python's | ||
| // InMemoryCredentialService. | ||
| type InMemoryCredentialStore struct { |
There was a problem hiding this comment.
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.
| return time.Time{} | ||
| } | ||
| t, err := time.Parse(time.RFC3339, s) | ||
| if err != nil { |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| func TestInMemoryCredentialStoreExpiry(t *testing.T) { | ||
| ctx := t.Context() |
There was a problem hiding this comment.
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.
3b151ed to
9d186fc
Compare
9d186fc to
433a58c
Compare
433a58c to
50166d2
Compare
50166d2 to
2e12ee8
Compare
karolpiotrowicz
left a comment
There was a problem hiding this comment.
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 requireserr == 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
expireTimeis written to the store. Only the upper bound is clamped.InMemoryCredentialStoreshrugs it off on the nextGet, but a TTL-based implementation computingexpiresAt.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.
singleflightis 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 byapidiffas the only incompatible change in either package. It has never appeared in a tagged v2 release —auth/gcp/client.gois absent from v2.0.0, v2.1.0 and v2.2.0 — so this only reaches anyone trackingmain. Worth a line in the PR body rather than any code change.expirySkewis unexported, so a third-party store cannot honor the tolerance the provider assumes, and the provider re-checks nothing after a hit.ConsentRequiredError.Keyis astringdocumented 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.%+von aCredentialKeyat 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
AppNameis accepted where an emptyUserIDis 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.
2e12ee8 to
cee554d
Compare
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.
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
auth.CredentialStoreandInMemoryCredentialStore: concurrency-safe, per-entry expiry, a small clock-skew margin exported asauth.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.ProviderConfig.Store).Client.RetrieveCredentialreturns a*Retrievalcarrying the service'sexpireTime, so entries expire when the credential does.*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 aClient, which is what makes one store safe behind several providers.expireTimecannot pin an entry. That hour is also the bound on how long a credential revoked before its expiry keeps being served;Client.CacheKeynames the entry toDeletesooner.Two things worth flagging for review rather than reading out of the diff:
Client.RetrieveCredential's signature changes, whichapidiffreports as the only incompatible change in either package.auth/gcpis absent from v2.0.0, v2.1.0 and v2.2.0, so this reaches only someone trackingmain.singleflightwould 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.